fix(label): --no-label placeholders no longer permanently suppress real labels (#2073)

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.
This commit is contained in:
safishamsi
2026-07-21 12:59:10 +01:00
parent b194301c05
commit 87c870495f
3 changed files with 107 additions and 9 deletions
+25 -8
View File
@@ -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
+6 -1
View File
@@ -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 = {}
+76
View File
@@ -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"