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:
co-authored by
Claude Opus 4.8
parent
e34e27c24c
commit
8127ff9a9c
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user