From 27096817ed3624a17c901f20937983beb2d16f5a Mon Sep 17 00:00:00 2001 From: ErichKinuya <75656129+ErichKinuya@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:18:46 +0100 Subject: [PATCH] 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. --- graphify/cluster.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/graphify/cluster.py b/graphify/cluster.py index db476b5b7..34952a44a 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -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), ), )