fix(cluster): canonicalise edge endpoints before sorting in _partition

For an undirected graph, (a,b) and (b,a) are the same edge but networkx may yield the
endpoints in either order depending on insertion order / version / hashseed, so the edge
sort was keyed on a non-canonical orientation and the ordering fed to Leiden could vary
across builds/machines, drifting community assignments. Sort each endpoint pair to (min,max)
before the sort so the edge order is canonical; the edge set and weights are unchanged.
This commit is contained in:
ErichKinuya
2026-08-28 01:18:46 +01:00
committed by safishamsi
parent c60ddf7b3e
commit 27096817ed
+12 -2
View File
@@ -108,11 +108,21 @@ def _partition(G: nx.Graph, resolution: float = 1.0) -> dict[str, int]:
"""
stable = nx.Graph()
stable.add_nodes_from(sorted(G.nodes(), key=str))
# Canonicalise the endpoint pair before sorting. On an undirected graph the
# (u, v) orientation each edge is yielded with comes from adjacency
# iteration, which follows CPython's per-process string-hash order - so the
# SAME edge appears as (A, B) in one run and (B, A) in the next. Sorting on
# the raw pair therefore does not canonicalise anything: the edge lands in a
# different position, `stable` is built in a different insertion order, and
# Louvain - order-sensitive even with a fixed seed - can return a different
# grouping. Measured on a 914-node graph: identical input, identical
# first-pass partition, but the cohesion-split pass produced 70 communities
# under PYTHONHASHSEED=1 and 69 under =2. Sorting the pair itself removes
# the dependency; for nx.Graph the orientation carries no meaning anyway.
edge_rows = sorted(
G.edges(data=True),
key=lambda row: (
str(row[0]),
str(row[1]),
*sorted((str(row[0]), str(row[1]))),
json.dumps(row[2], sort_keys=True, ensure_ascii=False, default=str),
),
)