From 87c870495fad4743484349de78d3de52ef9dd49b Mon Sep 17 00:00:00 2001 From: safishamsi Date: Tue, 21 Jul 2026 12:59:10 +0100 Subject: [PATCH] fix(label): --no-label placeholders no longer permanently suppress real labels (#2073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `cluster-only --no-label` run wrote "Community N" placeholders into .graphify_labels.json plus a matching .sig, and the reuse path treated them as fresh, so real labels were never regenerated on later runs. Two fixes: (a) don't persist the labels sidecar (or its .sig) on a placeholder-only run, so a later run generates real labels; (b) treat a stored "Community {cid}" as absent in the reuse path so an already-polluted sidecar self-heals via the hub labeler while genuine labels are still reused with no LLM call. The watch/update rebuild had the same placeholder-perpetuation twin — fixed alongside. --- graphify/cli.py | 33 +++++++++++++----- graphify/watch.py | 7 +++- tests/test_labeling.py | 76 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index e6b3ee5..8edc6af 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1568,6 +1568,10 @@ def dispatch_command(cmd: str) -> None: # reports real cost instead of a hardcoded zero (#1694). Stays {0, 0} on # the reuse / no-label paths, which make no LLM calls. label_token_usage = {"input": 0, "output": 0} + # #2073: a --no-label run produces only "Community N" placeholders. + # Persisting them (plus a matching .sig) made the reuse branch treat them + # as fresh forever, permanently blocking real labeling on later runs. + placeholder_only = False if labels_path.exists() and not force_relabel: # 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 @@ -1596,7 +1600,14 @@ def dispatch_command(cmd: str) -> None: hub_labels: dict[int, str] | None = None changed = 0 for cid in communities: - have_label = cid in existing_labels + # A persisted "Community {cid}" is a placeholder, not an earned + # label — treat it as absent so the hub labeler replaces it and an + # already-polluted sidecar (e.g. from a prior --no-label run) heals + # instead of suppressing real labels forever (#2073). + have_label = ( + cid in existing_labels + and existing_labels[cid] != f"Community {cid}" + ) if saved_sigs: # Precise: the membership signature tells us if this exact # community changed since it was labeled. @@ -1624,6 +1635,7 @@ def dispatch_command(cmd: str) -> None: ) elif no_label and not force_relabel: labels = {cid: f"Community {cid}" for cid in communities} + placeholder_only = True else: # No labels file yet (or `graphify label` forced a refresh). When run # standalone there is no orchestrating agent to do skill.md Step 5, so @@ -1687,13 +1699,18 @@ def dispatch_command(cmd: str) -> None: encoding="utf-8", ) to_json(G, communities, str(out / "graph.json"), community_labels=labels) - from graphify.paths import write_json_atomic as _wja - _wja(labels_path, {str(k): v for k, v in labels.items()}, ensure_ascii=False) - # 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") + # Don't persist placeholder-only labels (or their .sig): leaving the + # sidecar absent lets a later run generate real labels instead of reading + # back "Community N" as authoritative (#2073). + if not placeholder_only: + from graphify.paths import write_json_atomic as _wja + _wja(labels_path, {str(k): v for k, v in labels.items()}, ensure_ascii=False) + # 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/watch.py b/graphify/watch.py index a2b65dd..1ef1ebd 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1242,7 +1242,12 @@ def _rebuild_code( labels_file = out / ".graphify_labels.json" try: raw = json.loads(labels_file.read_text(encoding="utf-8")) if labels_file.exists() else {} - labels = {int(k): v for k, v in raw.items() if int(k) in communities} + # Skip persisted "Community N" placeholders so the hub-fill below + # replaces them instead of perpetuating them on every rebuild (#2073). + labels = { + int(k): v for k, v in raw.items() + if int(k) in communities and v != f"Community {int(k)}" + } except Exception: raw = {} labels = {} diff --git a/tests/test_labeling.py b/tests/test_labeling.py index 7acbd30..bb7bc3c 100644 --- a/tests/test_labeling.py +++ b/tests/test_labeling.py @@ -474,3 +474,79 @@ def test_label_communities_counts_tokens_for_failed_batch(monkeypatch): G2, {0: ["a"]}, backend="gemini", usage_out=usage, ) assert usage == {"input": 50, "output": 5} + + +def _two_community_graph(out): + """Two disconnected components -> two stable communities, each hub-labelled + by its own node.""" + graph = { + "directed": False, "multigraph": False, + "nodes": [ + {"id": "orders", "label": "OrderService", "community": 0}, + {"id": "order_db", "label": "OrderDB", "community": 0}, + {"id": "payments", "label": "PaymentService", "community": 1}, + {"id": "pay_db", "label": "PayDB", "community": 1}, + ], + "links": [ + {"source": "orders", "target": "order_db", "relation": "calls"}, + {"source": "payments", "target": "pay_db", "relation": "calls"}, + ], + } + (out / "graph.json").write_text(json.dumps(graph), encoding="utf-8") + + +def test_cluster_only_no_label_does_not_persist_placeholders(tmp_path, monkeypatch): + """#2073: --no-label must not write .graphify_labels.json with 'Community N' + placeholders (which the reuse path would then treat as fresh forever). A + later normal run must produce real (non-placeholder) labels.""" + import graphify.__main__ as cli + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + labels_path = out / ".graphify_labels.json" + + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr("graphify.export.to_html", lambda *a, **k: None) + # No-backend fallback shape: returns placeholders, which must not clobber hubs. + monkeypatch.setattr("graphify.llm.generate_community_labels", + lambda G, comms, **k: ({cid: f"Community {cid}" for cid in comms}, "none")) + + monkeypatch.setattr(sys, "argv", ["graphify", "cluster-only", str(tmp_path), "--no-label", "--no-viz"]) + cli.main() + assert not labels_path.exists(), "--no-label persisted a placeholder labels file (#2073)" + assert not (out / ".graphify_labels.json.sig").exists() + + # A later normal run generates real labels (no sticky placeholders blocking it). + monkeypatch.setattr(sys, "argv", ["graphify", "cluster-only", str(tmp_path), "--no-viz"]) + cli.main() + assert labels_path.exists() + saved = json.loads(labels_path.read_text(encoding="utf-8")) + assert saved, "no labels written on the normal run" + assert not any(v == f"Community {k}" for k, v in saved.items()), ( + f"real labels expected, got placeholders: {saved} (#2073)" + ) + + +def test_cluster_only_heals_persisted_placeholder_but_reuses_genuine(tmp_path, monkeypatch): + """#2073: an already-polluted sidecar (a placeholder for one community, a + genuine label for another) self-heals — the placeholder is replaced by the + hub name while the genuine label is reused, with no LLM call.""" + import graphify.__main__ as cli + out = tmp_path / "graphify-out" + out.mkdir() + _two_community_graph(out) + labels_path = out / ".graphify_labels.json" + # Polluted state: community 0 is a stuck placeholder, community 1 is genuine. + labels_path.write_text(json.dumps({"0": "Community 0", "1": "Payment Flow"}), encoding="utf-8") + + monkeypatch.setattr(cli, "_check_skill_version", lambda _: None) + monkeypatch.setattr("graphify.export.to_html", lambda *a, **k: None) + def _fail_generate(*a, **k): + raise AssertionError("generate_community_labels must not be called on the reuse path") + monkeypatch.setattr("graphify.llm.generate_community_labels", _fail_generate) + + monkeypatch.setattr(sys, "argv", ["graphify", "cluster-only", str(tmp_path), "--no-viz"]) + cli.main() + saved = json.loads(labels_path.read_text(encoding="utf-8")) + assert saved["0"] != "Community 0", "stuck placeholder was not healed (#2073)" + assert saved["1"] == "Payment Flow", "genuine label was not reused"