From 9abaa77c62bb874e48299604dbf5f5151443d847 Mon Sep 17 00:00:00 2001 From: Leon GyeongMan Baek Date: Wed, 27 May 2026 04:12:02 +0900 Subject: [PATCH] fix(cli): apply remap_communities_to_previous in cluster-only path (#1028) cluster-only re-runs Leiden clustering and then re-applies the existing .graphify_labels.json by raw cid index, which causes labels to attach to clusters whose members are unrelated to the label's original meaning whenever the graph has changed between labeling and re-clustering. Mirror the safety net already present in watch.py:_rebuild_code added in #822 for the watch/update paths. Adds a regression test that fails without the fix (label cids become orphaned from graph.json community attributes after re-clustering). Refs: #1027 Co-authored-by: Claude Opus 4.7 (1M context) --- graphify/__main__.py | 14 ++++++++++- tests/test_cli_export.py | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 7e0e101..6b144a2 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -2238,7 +2238,7 @@ def main() -> None: sys.exit(1) from networkx.readwrite import json_graph as _jg from graphify.build import build_from_json - from graphify.cluster import cluster, score_all + from graphify.cluster import cluster, score_all, remap_communities_to_previous from graphify.analyze import god_nodes, surprising_connections, suggest_questions from graphify.report import generate from graphify.export import to_json, to_html @@ -2250,6 +2250,18 @@ def main() -> None: print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges") print("Re-clustering...") communities = cluster(G, resolution=co_resolution, exclude_hubs_percentile=co_exclude_hubs) + # Mirror the watch/update path (#822): map new cids to prior ones by + # node-overlap so the existing .graphify_labels.json keeps attaching + # to the same conceptual community after re-clustering. Without this, + # labels follow raw cid index and become misaligned whenever the + # graph has changed between labeling and cluster-only (#1027). + previous_node_community = { + n["id"]: n["community"] + for n in _raw.get("nodes", []) + if n.get("community") is not None and n.get("id") is not None + } + if previous_node_community: + communities = remap_communities_to_previous(communities, previous_node_community) cohesion = score_all(G, communities) gods = god_nodes(G) surprises = surprising_connections(G, communities) diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index e23c987..942dbcf 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -288,6 +288,58 @@ def test_cluster_only_creates_output_dir_when_missing(tmp_path): assert (tmp_path / "graphify-out" / "GRAPH_REPORT.md").exists() +# Regression test for #1027 - cluster-only must remap labels via node overlap + +def test_cluster_only_remaps_labels_to_previous_cids(tmp_path): + """cluster-only must invoke remap_communities_to_previous so the existing + .graphify_labels.json keeps tracking the same conceptual communities after + re-clustering. Without the remap call, Leiden's size-descending cid order + re-applies labels by raw index and they silently misalign with cluster + contents (#1027). Mirror of the watch/update fix from #822. + """ + out = _make_graph(tmp_path) + graph_json = out / "graph.json" + labels_json = out / ".graphify_labels.json" + + # Tag every node with an out-of-band community id and write a labels file + # keyed on those ids. After cluster-only, at least one of those sentinel + # ids must survive in the labels file (= remap succeeded by node overlap). + # If the cluster-only branch skips remap, Leiden returns small ints + # (0, 1, ...) and the sentinel keys disappear entirely. + g = json.loads(graph_json.read_text(encoding="utf-8")) + nodes = g.get("nodes", []) + assert len(nodes) >= 4, "fixture must have enough nodes to form 2+ communities" + sentinel_a, sentinel_b = 4242, 9999 + half = len(nodes) // 2 + for i, n in enumerate(nodes): + n["community"] = sentinel_a if i < half else sentinel_b + graph_json.write_text(json.dumps(g), encoding="utf-8") + labels_json.write_text( + json.dumps({str(sentinel_a): "First Group", str(sentinel_b): "Second Group"}), + encoding="utf-8", + ) + + r = _run(["cluster-only", ".", "--no-viz"], tmp_path) + assert r.returncode == 0, r.stderr + + # Real signal: labels.json keys must align with the community ids actually + # written to graph.json's per-node community attribute. Without remap, + # Leiden returns small cids (0, 1, ...) but labels.json still carries the + # old sentinel keys, so the intersection is empty and labels are orphaned. + final_graph = json.loads(graph_json.read_text(encoding="utf-8")) + final_labels = json.loads(labels_json.read_text(encoding="utf-8")) + actual_cids = {n.get("community") for n in final_graph.get("nodes", [])} + label_cids = {int(k) for k in final_labels.keys()} + overlap = actual_cids & label_cids + assert overlap, ( + f"After cluster-only with prior labels keyed on cids {label_cids}, at " + f"least one of those cids must still appear in graph.json's community " + f"attribute ({actual_cids}). Without remap_communities_to_previous " + f"(#1027) Leiden renumbers communities to 0,1,... and the prior labels " + f"become orphaned. Final labels: {final_labels}" + ) + + # ── communities-fallback when .graphify_analysis.json is absent ────────────── # The watch / post-commit rebuild path only writes graph.json + GRAPH_REPORT.md; # it does NOT regenerate .graphify_analysis.json. The full `graphify extract`