Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions tests/test_cli_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down