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.
Localised:
_partitionis deterministic,cluster()post-processing is notSame graph object, graphifyy 0.9.42, 4 unpinned runs + 2 with
PYTHONHASHSEED=0:_partition's sorted node/edge construction and hardcodedseed=42workcorrectly - 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 orderingguarantees.
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-clustercomparison on our frozen OREextraction to check it holds there too (graphifyy 0.9.42, Windows, Python
3.12.4, same
.graphify_extract.jsonas every measurement in this issue), 4unpinned runs + 2 pinned:
Same shape on a completely different, real-world input:
_partitionnevermoves,
cluster()does,PYTHONHASHSEED=0fixes it. This localises the buginside
cluster()'s splitting/re-splitting passes rather than in_partition,build_from_json, or Leiden/Louvain itself.Suggested direction: apply the same normalisation
_partitionalreadydoes - iterate communities and their members in a sorted, deterministic
order when deciding what to split and how, in
_split_communityand thecohesion-based second pass in
cluster().Correction to the hypothesis in the previous update: the
json.dumpslist-ordering theory is refuted -
G.edges()yield order and edgeorientation were byte-stable across hash seeds, so the sort key passed into
_partitionwas never the variable. Posting as a correction rather thanediting 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:
PYTHONHASHSEED=0, 3 runs)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=0still fully fixes it on 0.9.42 (3/3 identicalruns) — 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
stablegraph with nodes and edges sortedby
str()/json.dumps(attrs, sort_keys=True)before calling Louvain witha hardcoded
seed=42- so given an identicalnetworkx.Graphobject, thatfunction should already be order-independent. If that's right, the residual
0.9.42 variance is more likely upstream of
_partition: an edge attributeholding a list (e.g. a relation-type list collapsed from a set somewhere in
build_from_json) would still sort inconsistently underjson.dumps(..., sort_keys=True), since that only orders dict keys, not listcontents - which would perturb
_partition's edge sort order even though theedge 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 bygraphify.cluster.cluster()produces a different graph (edge count) and a different Louvain partition (community count and membership) on every process run, unlessPYTHONHASHSEEDis 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
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.jsonbelow is a.graphify_extract.jsonproduced once bygraphify.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.Run three times without
PYTHONHASHSEEDset:Run three times with
PYTHONHASHSEED=0: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
setordictkeyed by node/edge identity strings) somewhere in edge construction or deduplication insidebuild_from_json, before the graph is handed tocluster().We separately confirmed
cluster()is deterministic on its own: clustering one already-built, byte-identicalnetworkx.Graphobject in-process three times gave identical results each time. The drift enters earlier, whilebuild_from_jsonis still constructing the graph.Impact
Any project that clusters a
build_from_jsongraph 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 underPYTHONHASHSEED=0for 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 unorderedset/dictkeyed 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 toPYTHONHASHSEED, without needing every caller to pin it.Happy to share the exact repro script and the frozen
.graphify_extract.jsoninput if useful.