diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c1d5c..e5b2827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/__main__.py b/graphify/__main__.py index 01b69b7..736d7be 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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 diff --git a/graphify/cluster.py b/graphify/cluster.py index d35c90d..6822107 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -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, diff --git a/tests/test_community_hub_labels.py b/tests/test_community_hub_labels.py index 1e50abc..281a853 100644 --- a/tests/test_community_hub_labels.py +++ b/tests/test_community_hub_labels.py @@ -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"