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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3f8efaebc2
commit
9abaa77c62
+13
-1
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user