Skip to content

cluster() community-splitting pass is nondeterministic across PYTHONHASHSEED (_partition itself is not; edge-count loss appears fixed since 0.9.6) #2817

Description

@AlexisDavid1989

Localised: _partition is deterministic, cluster() post-processing is not

Same graph object, graphifyy 0.9.42, 4 unpinned runs + 2 with PYTHONHASHSEED=0:

_partition(G)   61 communities, identical partition hash in all 6 runs
cluster(G)      345 / 342 / 346 / 350 unpinned
                344 / 344 with PYTHONHASHSEED=0

_partition's sorted node/edge construction and hardcoded seed=42 work
correctly - it never varies, pinned or not. The variance is introduced
downstream, in cluster()'s community-splitting pass
(_MAX_COMMUNITY_FRACTION / _MIN_SPLIT_SIZE / _COHESION_SPLIT_THRESHOLD /
_COHESION_SPLIT_MIN_SIZE), which expands 61 communities to ~345 - about 85%
of the final structure - without inheriting _partition's ordering
guarantees.

Caveat on the numbers above: measured on a synthetic 4,000-node /
9,000-edge fixture, on Linux / Python 3.11 - not the ORE corpus this issue
was originally opened against.

Re-ran the same _partition-vs-cluster comparison on our frozen ORE
extraction to check it holds there too (graphifyy 0.9.42, Windows, Python
3.12.4, same .graphify_extract.json as every measurement in this issue), 4
unpinned runs + 2 pinned:

_partition(G)   83 communities, identical hash all 6 runs (eaaaf43442be)
cluster(G)      230 / 230 / 231 / 229 unpinned
                231 / 231 with PYTHONHASHSEED=0

Same shape on a completely different, real-world input: _partition never
moves, cluster() does, PYTHONHASHSEED=0 fixes it. This localises the bug
inside cluster()'s splitting/re-splitting passes rather than in
_partition, build_from_json, or Leiden/Louvain itself.

Suggested direction: apply the same normalisation _partition already
does - iterate communities and their members in a sorted, deterministic
order when deciding what to split and how, in _split_community and the
cohesion-based second pass in cluster().

Correction to the hypothesis in the previous update: the json.dumps
list-ordering theory is refuted - G.edges() yield order and edge
orientation were byte-stable across hash seeds, so the sort key passed into
_partition was never the variable. Posting as a correction rather than
editing the earlier text away.


Update: edge loss looks fixed since 0.9.6; clustering nondeterminism remains on 0.9.42

Re-ran the same frozen-input repro below against graphifyy 0.9.42 in a
clean venv (0.9.6 left untouched). On our real extraction input:

edges (3 unpinned runs) communities (3 unpinned runs) pinned (PYTHONHASHSEED=0, 3 runs)
0.9.6 7513 / 7518 / 7519 (varies) 212 / 208 / 210 (varies) 7522 edges, 209 communities — identical all 3
0.9.42 7095 / 7095 / 7095 (stable) 230 / 230 / 231 (varies) 7095 edges, 231 communities — identical all 3

Edge count is no longer a source of nondeterminism in 0.9.42 on this input —
only the Louvain partition still is, and by a much narrower margin than 0.9.6
(1 community of drift here vs. up to 4). Retitled this issue to lead with the
clustering nondeterminism, since that's the part still open; the edge-loss
numbers below are kept for the record and to show the delta between
versions. PYTHONHASHSEED=0 still fully fixes it on 0.9.42 (3/3 identical
runs) — required, not just historically.

One correction to the original report below: it claimed cluster() is
"deterministic on its own" without a test that actually isolates hash-seed
effects (the in-process check described doesn't - the seed is fixed for the
lifetime of a process either way, so running it multiple times in one process
proves nothing about hash-seed sensitivity specifically; sorry for the
imprecision). Looking at the source instead: cluster.py's _partition()
already rebuilds the input into a stable graph with nodes and edges sorted
by str() / json.dumps(attrs, sort_keys=True) before calling Louvain with
a hardcoded seed=42 - so given an identical networkx.Graph object, that
function should already be order-independent. If that's right, the residual
0.9.42 variance is more likely upstream of _partition: an edge attribute
holding a list (e.g. a relation-type list collapsed from a set somewhere in
build_from_json) would still sort inconsistently under
json.dumps(..., sort_keys=True), since that only orders dict keys, not list
contents - which would perturb _partition's edge sort order even though the
edge set is now stable. Haven't traced this to a specific line, but it
would explain why edges converged in 0.9.42 while communities didn't.


Original report (graphifyy 0.9.6)

Summary

Given byte-identical extraction input, graphify.build.build_from_json() followed by graphify.cluster.cluster() produces a different graph (edge count) and a different Louvain partition (community count and membership) on every process run, unless PYTHONHASHSEED is pinned. This makes any pipeline built on graphify's public API non-reproducible by default: two people building from the same commit get different community structure, and any downstream artifact keyed by community id (labels, anchors, reports) silently drifts.

Environment

  • graphifyy 0.9.6 (PyPI)
  • Python 3.12.4, Windows
  • networkx 3.6.1
  • Reproduces with default bufsize/no other flags; not specific to our extraction pipeline (see minimal repro below, which calls only stock graphify functions on a frozen input file).

Minimal reproduction

extract.json below is a .graphify_extract.json produced once by graphify.extract.extract() and then frozen to disk, so every run reads the exact same bytes as input — no re-extraction, no filesystem variance, no code from our project.

import json
from graphify.build import build_from_json
from graphify.cluster import cluster

ex = json.load(open("extract.json", encoding="utf-8"))
G = build_from_json(ex, root="/path/to/repo", directed=False)
comm = cluster(G)
print(G.number_of_nodes(), G.number_of_edges(), len(comm))

Run three times without PYTHONHASHSEED set:

4047 nodes  7513 edges  212 communities
4047 nodes  7518 edges  208 communities
4047 nodes  7519 edges  210 communities

Run three times with PYTHONHASHSEED=0:

4047 nodes  7522 edges  209 communities   (identical all three times)

Node count is stable across all six runs (4047) — only edges and the resulting partition vary. The edge-count delta (7513–7519, up to 6 edges short relative to the pinned run) and the community-count delta (208–212) are both consistent with iteration over an unordered/hash-ordered collection (a set or dict keyed by node/edge identity strings) somewhere in edge construction or deduplication inside build_from_json, before the graph is handed to cluster().

We separately confirmed cluster() is deterministic on its own: clustering one already-built, byte-identical networkx.Graph object in-process three times gave identical results each time. The drift enters earlier, while build_from_json is still constructing the graph.

Impact

Any project that clusters a build_from_json graph and treats community ids or edge sets as stable — labelling, diffing, caching, reporting — gets silent drift instead of a reproducibility guarantee. We worked around it downstream by relaunching under PYTHONHASHSEED=0 for any command that builds or clusters, but that's a workaround at the call site, not a fix — anyone calling these functions without knowing to pin the seed will hit this with no warning.

At full pipeline scale (90k nodes, our real corpus) the same pattern held: unpinned runs varied between 190,503 and roughly 190,550 edges with correspondingly different community counts; three pinned full builds were byte-identical.

Suggested fix direction

Somewhere in the edge construction/dedup path of build_from_json, an unordered set/dict keyed by node-id strings appears to be iterated to decide edge order or dedup precedence. Sorting that iteration (or using an insertion-ordered structure keyed consistently) before building the edge list should make the output invariant to PYTHONHASHSEED, without needing every caller to pin it.

Happy to share the exact repro script and the frozen .graphify_extract.json input if useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions