From 6d42c48f4de6773970982b332c1acbc92016ff6a Mon Sep 17 00:00:00 2001 From: Bob Spryn Date: Sun, 26 Jul 2026 14:29:13 -0400 Subject: [PATCH] fix(watch): stop incremental rebuilds from reusing stale community labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community labels are saved keyed by community id, but re-clustering reassigns those ids: after a rebuild that adds nodes, cid 30 can cover a completely different community and its saved name is then simply wrong. cluster-only already guards this — it validates each reused label against the `.graphify_labels.json.sig` membership fingerprints and re-hubs any community that changed (the case community_member_sigs() was written for). _rebuild_code() skipped that check entirely: it reused every label whose cid was present and hub-filled only the *missing* ones, so stale names survived — and then wrote them back to labels.json, laundering them as current. It also never refreshed the .sig sidecar, so the signatures kept describing an older clustering and drifted out of step with the labels they sit beside, leaving the cluster-only guard nothing accurate to check. Adding ~3.5k nodes to a real graph re-clustered 463 -> 515 communities and mislabeled 162 of them this way: a `domain.audit` namespace reading "ACH / bank payments", a `domain.auth` namespace reading "document-sensitivity.up.sql". Node and edge data stayed correct, so nothing failed loudly — only the names lied. Apply the same signature check in the incremental path, write the sidecar in step with the labels, and print the same "run `graphify label`" notice cluster-only emits so a drifted community set is visible rather than silent. --- graphify/watch.py | 45 ++++++++++++++++++++++++++ tests/test_watch.py | 79 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/graphify/watch.py b/graphify/watch.py index 1ef1ebd4..08b6fdd4 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1240,6 +1240,7 @@ def _rebuild_code( gods = god_nodes(G) surprises = surprising_connections(G, communities) labels_file = out / ".graphify_labels.json" + sig_file = out / (".graphify_labels.json" + ".sig") try: raw = json.loads(labels_file.read_text(encoding="utf-8")) if labels_file.exists() else {} # Skip persisted "Community N" placeholders so the hub-fill below @@ -1251,12 +1252,50 @@ def _rebuild_code( except Exception: raw = {} labels = {} + # A saved label belongs to a cid, but re-clustering reassigns cids: after a + # rebuild that adds nodes, cid 30 can cover a completely different community + # and its old name is then simply wrong. Validate every reused label against + # the membership signature saved beside the labels — the same guard the + # cluster-only path applies — and drop any whose community changed so the + # hub-fill below renames it, deterministically and correct-by-construction. + # Without this, an incremental `graphify update` launders stale names into + # labels.json as though they were current (#label-stale). + from graphify.cluster import community_member_sigs + cur_sigs = community_member_sigs(communities) + saved_sigs: dict[int, str] = {} + if sig_file.exists(): + try: + saved_sigs = { + int(k): v for k, v in + json.loads(sig_file.read_text(encoding="utf-8")).items() + if isinstance(v, str) + } + except Exception: + saved_sigs = {} + if saved_sigs: + # Precise: the signature tells us exactly which communities changed. + stale = {cid for cid in labels if saved_sigs.get(cid) != cur_sigs.get(cid)} + else: + # No sidecar (labels predate it). A differing community COUNT means the + # labels describe a different clustering, so no cid's label is trustworthy; + # an equal count is the best available "unchanged" signal. + stale = set(labels) if len(raw) != len(communities) else set() + for cid in stale: + del labels[cid] missing = {cid: members for cid, members in communities.items() if cid not in labels} if missing: # Deterministic hub name (highest-degree member) beats a bare "Community N" # placeholder for any community without a saved label. from graphify.cluster import label_communities_by_hub labels.update(label_communities_by_hub(G, missing)) + if stale: + print( + f"[graphify watch] community set changed since labeling " + f"({len(raw)} saved labels, {len(communities)} communities now; " + f"renamed {len(stale)} community(ies) by their hub). " + f"Run `graphify label` to refresh names with the LLM.", + file=sys.stderr, + ) questions = suggest_questions(G, communities, labels) from graphify.report import load_learning_for_report as _llfr report = generate(G, communities, cohesion, labels, gods, surprises, detection, @@ -1301,6 +1340,12 @@ def _rebuild_code( graph_tmp.replace(existing_graph) report_path.write_text(report, encoding="utf-8") labels_file.write_text(labels_json, encoding="utf-8") + # Keep the membership signatures in step with the labels we just wrote. + # Skipping this was the other half of the stale-label bug: labels.json + # advanced every rebuild while the sidecar kept describing an older + # clustering, so the guard above had nothing accurate to check against. + sig_file.write_text( + json.dumps({str(k): v for k, v in cur_sigs.items()}), encoding="utf-8") (out / ".graphify_root").write_text(str(watch_path), encoding="utf-8") diff --git a/tests/test_watch.py b/tests/test_watch.py index b60c0f60..a9b3ebe8 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -188,6 +188,85 @@ def test_rebuild_code_writes_community_name(tmp_path): ) +def test_rebuild_code_drops_labels_whose_community_changed(tmp_path): + """An incremental rebuild must not reuse a saved label for a community whose + membership changed. Labels are keyed by cid, but re-clustering reassigns cids, + so after new files land cid N can cover a different community and its old name + is then simply wrong. cluster-only guards this with the `.sig` membership + fingerprints; _rebuild_code ignored them and hub-filled only *missing* labels, + so stale names survived and were written back to labels.json as if current.""" + import json + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "a.py").write_text( + "def alpha():\n return beta()\n\ndef beta():\n return 1\n", encoding="utf-8" + ) + assert _rebuild_code(corpus, acquire_lock=False) is True + + out = corpus / "graphify-out" + labels_file = out / ".graphify_labels.json" + sig_file = out / ".graphify_labels.json.sig" + assert sig_file.exists(), "rebuild must persist membership signatures beside labels" + + # Stand in for an LLM naming pass: give every community a distinctive name, + # leaving the signatures untouched so they still describe THIS clustering. + labels = json.loads(labels_file.read_text(encoding="utf-8")) + assert labels, "expected the first rebuild to write community labels" + labels_file.write_text( + json.dumps({cid: f"Named-{cid}" for cid in labels}), encoding="utf-8" + ) + + # Grow the corpus so clustering changes, then rebuild incrementally. + for name in ("b.py", "c.py", "d.py"): + (corpus / name).write_text( + f"def {name[0]}_one():\n return {name[0]}_two()\n\n" + f"def {name[0]}_two():\n return 2\n", + encoding="utf-8", + ) + assert _rebuild_code(corpus, acquire_lock=False) is True + + graph = json.loads((out / "graph.json").read_text(encoding="utf-8")) + after = json.loads(labels_file.read_text(encoding="utf-8")) + sigs = json.loads(sig_file.read_text(encoding="utf-8")) + + communities = {} + for node in graph["nodes"]: + cid = node.get("community") + if cid is not None: + communities.setdefault(str(cid), []).append(node["id"]) + + from graphify.cluster import community_member_sigs + expected = { + str(cid): sig + for cid, sig in community_member_sigs( + {int(c): m for c, m in communities.items()} + ).items() + } + assert sigs == expected, ( + "signatures must be rewritten in step with the labels; a drifting sidecar " + "leaves the staleness guard nothing accurate to check against" + ) + + # Any surviving "Named-N" must sit on a community that genuinely did not change. + for cid, name in after.items(): + if name.startswith("Named-"): + assert cid in communities, f"label kept for vanished community {cid}" + assert sigs[cid] == expected[cid], ( + f"community {cid} kept the stale label {name!r} after its " + f"membership changed" + ) + + for node in graph["nodes"]: + cid = node.get("community") + if cid is not None and node.get("community_name", "").startswith("Named-"): + assert sigs[str(cid)] == expected[str(cid)], ( + f"node {node['id']} carries stale community_name " + f"{node['community_name']!r}" + ) + + def test_update_rebuilds_with_nested_star_gitignore(tmp_path): """#1880: `graphify update` must not emit 0 nodes (and then refuse to overwrite) just because the source tree has a nested `.gitignore` with a