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:
+25
-8
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user