fix(cluster): detect stale community labels on cluster-only re-cluster

Reported from a real run: after re-scoping a repo (5807->3710 nodes), the
228-community re-cluster kept run-1's 300 saved labels, so cids now covering a
different community wore the wrong (LLM) names — silently. cluster-only reused
.graphify_labels.json wholesale, and the overlap-based cid remap grabs a prior
cid on any overlap, inheriting a stale name.

Fix: write a per-community membership signature (sha256 of sorted member ids)
beside the labels. On reuse, keep a saved label only when the community's
signature is unchanged; a changed community is renamed by its deterministic
hub (correct-by-construction) with a warning to run `graphify label` for fresh
LLM names. For label files predating the signature, fall back to a community-
count check (a differing count means a different clustering -> don't trust cid
labels). Unchanged graphs reuse labels silently — no false warnings.

Verified: stale legacy labels (42) on a 12-community graph -> warned + hub-
renamed all + sig written; rerun on the unchanged graph -> silent reuse, labels
stable. Unit tests for the signature (deterministic, order-independent, changes
on membership change). Full suite 2771.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-01 11:52:50 +01:00
co-authored by Claude Opus 4.8
parent e34e27c24c
commit 8127ff9a9c
4 changed files with 97 additions and 4 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: `graphify cluster-only` no longer reuses stale community labels after the graph changed. When a repo was re-scoped/re-clustered, the saved `.graphify_labels.json` was applied wholesale to the new community set — so a community id that now covered a different community wore the old (LLM) name, silently. cluster-only now writes a per-community membership signature beside the labels and, on reuse, keeps a saved label only for communities whose membership is unchanged; any community that changed (or, for pre-signature label files, when the community count no longer matches) is renamed by its deterministic hub, with a warning to run `graphify label` for fresh LLM names.
- Fix: cross-file `indirect_call` edges were dropped by `graphify extract` on the CLI (a 0.9.4 regression). The callable-target guard for cross-file indirect dispatch was keyed on node ids collected before the id-relativization/disambiguation passes; when the scan root relativizes ids (the CLI's default, `cache_root == project root`), those ids went stale and every cross-file indirect edge was silently dropped — only same-file ones survived. Callable-ness is now read from a node marker that rides through the remaps, so `submit(imported_fn)`, imported dispatch tables, assignment/getattr aliases across files resolve on the CLI as they already did via the `extract()` API.
## 0.9.4 (2026-07-01)
+58 -4
View File
@@ -3550,10 +3550,59 @@ def main() -> None:
except Exception:
existing_labels = {}
if labels_path.exists() and not force_relabel:
try:
labels = existing_labels
except Exception:
labels = {cid: f"Community {cid}" for cid in communities}
# Reuse saved labels, but don't blindly trust them: the graph may have
# been re-scoped/re-clustered since labeling, in which case a cid now
# covers a DIFFERENT community and its old (LLM) name is wrong (#label-stale).
# Validate each community against the membership signature saved beside the
# labels; any community that changed (or has no saved label) is renamed by
# its current hub — deterministic and correct-by-construction — and the user
# is told to `graphify label` for fresh LLM names. Unchanged communities keep
# their saved label. When no signature sidecar exists (labels predate this),
# fall back to hub-filling only the communities missing a label.
from graphify.cluster import community_member_sigs, label_communities_by_hub
sig_path = labels_path.parent / (labels_path.name + ".sig")
saved_sigs: dict[int, str] = {}
if sig_path.exists():
try:
saved_sigs = {
int(k): v for k, v in
json.loads(sig_path.read_text(encoding="utf-8")).items()
if isinstance(v, str)
}
except Exception:
saved_sigs = {}
cur_sigs = community_member_sigs(communities)
count_mismatch = len(existing_labels) != len(communities)
labels = {}
hub_labels: dict[int, str] | None = None
changed = 0
for cid in communities:
have_label = cid in existing_labels
if saved_sigs:
# Precise: the membership signature tells us if this exact
# community changed since it was labeled.
fresh = have_label and saved_sigs.get(cid) == cur_sigs.get(cid)
else:
# No signature sidecar (labels predate it). A differing community
# COUNT means the labels describe a different clustering, so a cid's
# old label can't be trusted; equal count is the best "same" signal.
fresh = have_label and not count_mismatch
if fresh:
labels[cid] = existing_labels[cid]
else:
if hub_labels is None:
hub_labels = label_communities_by_hub(G, communities)
labels[cid] = hub_labels[cid]
if have_label:
changed += 1
if changed:
print(
f"[graphify] community set changed since labeling "
f"({len(existing_labels)} saved labels, {len(communities)} communities now; "
f"renamed {changed} community(ies) by their hub). "
f"Run `graphify label` to refresh names with the LLM.",
file=sys.stderr,
)
elif no_label and not force_relabel:
labels = {cid: f"Community {cid}" for cid in communities}
else:
@@ -3608,6 +3657,11 @@ def main() -> None:
_backup(out)
to_json(G, communities, str(out / "graph.json"), community_labels=labels)
labels_path.write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding="utf-8")
# Membership signatures beside the labels so a later cluster-only can detect
# which communities changed and avoid reusing a stale label (see reuse above).
from graphify.cluster import community_member_sigs as _cms
(labels_path.parent / (labels_path.name + ".sig")).write_text(
json.dumps({str(k): v for k, v in _cms(communities).items()}), encoding="utf-8")
# Mirror watch.py pattern: gate to_html so core outputs (graph.json +
# GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise
+21
View File
@@ -110,6 +110,27 @@ def label_communities_by_hub(
return labels
def community_member_sigs(communities: dict[int, list[str]]) -> dict[int, str]:
"""Per-community membership fingerprints: ``{cid: sha256(sorted member ids)}``.
Persisted next to ``.graphify_labels.json`` so a later ``cluster-only`` can tell
which communities actually changed since labeling. A cid whose members no longer
hash the same is a different community — reusing its old (LLM) label there is the
"stale label after re-scoping" bug this guards against. Deterministic; independent
of cid index, node order, and machine.
"""
import hashlib
sigs: dict[int, str] = {}
for cid, members in communities.items():
h = hashlib.sha256()
for nid in sorted(str(n) for n in members):
h.update(nid.encode("utf-8", "replace"))
h.update(b"\x00")
sigs[cid] = h.hexdigest()[:16]
return sigs
def cluster(
G: nx.Graph,
resolution: float = 1.0,
+17
View File
@@ -64,3 +64,20 @@ def test_multiple_communities_each_get_their_own_hub():
)
labels = label_communities_by_hub(g, {0: ["h1", "a1", "a2"], 1: ["h2", "b1", "b2"]})
assert labels[0] == "auth" and labels[1] == "billing"
# ── community membership signatures (stale-label detection, cluster-only) ──────
def test_community_member_sigs_are_deterministic_and_order_independent():
from graphify.cluster import community_member_sigs
a = community_member_sigs({0: ["x", "y", "z"], 1: ["a"]})
b = community_member_sigs({0: ["z", "x", "y"], 1: ["a"]}) # member order shuffled
assert a == b
assert a[0] != a[1]
def test_community_member_sigs_change_when_membership_changes():
from graphify.cluster import community_member_sigs
before = community_member_sigs({0: ["x", "y", "z"]})
after = community_member_sigs({0: ["x", "y"]}) # a node left the community
assert before[0] != after[0], "signature must change when a community's members change"