mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-16 17:09:17 +00:00
feat(watch): route hub fallback labels to a pending sidecar (#3334)
Hub-fallback community names were written straight into the tracked .graphify_labels.json. They now go to a .graphify_labels.pending.json sidecar (written atomically alongside the curated labels), so the tracked file holds only curated (manual/LLM) names; promotion drops a cid from pending and unlinks an emptied sidecar. Squashed from #3551 with authorship preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
07588ee1e7
commit
97db005309
2 files changed
+182
-7
No files matched your search
+48
-2
@@ -2394,6 +2394,19 @@ def _rebuild_code(
|
||||
surprises = surprising_connections(G, communities)
|
||||
labels_file = out / ".graphify_labels.json"
|
||||
sig_file = out / (".graphify_labels.json" + ".sig")
|
||||
# #3334: a community without a curated name gets a deterministic hub
|
||||
# fallback below so reports and graph.json always show something
|
||||
# readable -- but that fallback used to be written straight into the
|
||||
# same tracked file as genuinely-curated (manual or LLM) names, so a
|
||||
# from-empty rebuild (a fresh clone, a new worktree, any CI run) kept
|
||||
# appending fallback entries to a file users are told is the reviewed,
|
||||
# committable semantic layer. Fallback names now live in a SEPARATE
|
||||
# sidecar (`pending_labels_file`) that never needs reviewing, so the
|
||||
# tracked file only ever contains names someone actually set.
|
||||
# `pending_cids` tracks which entries in `labels` came from there (or
|
||||
# from this run's hub-fill) rather than the tracked file, so the write
|
||||
# step below can route each cid back to the file it belongs in.
|
||||
pending_labels_file = out / ".graphify_labels.pending.json"
|
||||
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
|
||||
@@ -2405,6 +2418,19 @@ def _rebuild_code(
|
||||
except Exception:
|
||||
raw = {}
|
||||
labels = {}
|
||||
pending_cids: set[int] = set()
|
||||
try:
|
||||
pending_raw = (
|
||||
json.loads(pending_labels_file.read_text(encoding="utf-8"))
|
||||
if pending_labels_file.exists() else {}
|
||||
)
|
||||
for k, v in pending_raw.items():
|
||||
cid = int(k)
|
||||
if cid in communities and cid not in labels and v != f"Community {cid}":
|
||||
labels[cid] = v
|
||||
pending_cids.add(cid)
|
||||
except Exception:
|
||||
pass
|
||||
# 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
|
||||
@@ -2431,16 +2457,21 @@ def _rebuild_code(
|
||||
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.
|
||||
# an equal count is the best available "unchanged" signal. Compare
|
||||
# against the tracked file alone (`raw`), not the pending-merged
|
||||
# `labels` -- a from-empty rebuild's fresh pending entries must not
|
||||
# count toward "how many labels were saved".
|
||||
stale = set(labels) if len(raw) != len(communities) else set()
|
||||
for cid in stale:
|
||||
del labels[cid]
|
||||
pending_cids.discard(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))
|
||||
pending_cids.update(missing)
|
||||
if stale:
|
||||
print(
|
||||
f"[graphify watch] community set changed since labeling "
|
||||
@@ -2455,7 +2486,14 @@ def _rebuild_code(
|
||||
{"input": 0, "output": 0}, report_root, suggested_questions=questions,
|
||||
built_at_commit=commit, learning=_llfr(out / "graph.json"))
|
||||
report_path = out / "GRAPH_REPORT.md"
|
||||
labels_json = json.dumps({str(k): v for k, v in sorted(labels.items())}, ensure_ascii=False, indent=2) + "\n"
|
||||
# Split back into the tracked (curated) file and the pending
|
||||
# (hub-fallback) sidecar (#3334) -- report/graph.json generation above
|
||||
# already used the full, merged `labels` so a fallback-named community
|
||||
# still reads correctly there; only the on-disk split changes.
|
||||
curated_labels = {cid: v for cid, v in labels.items() if cid not in pending_cids}
|
||||
pending_labels = {cid: v for cid, v in labels.items() if cid in pending_cids}
|
||||
labels_json = json.dumps({str(k): v for k, v in sorted(curated_labels.items())}, ensure_ascii=False, indent=2) + "\n"
|
||||
pending_labels_json = json.dumps({str(k): v for k, v in sorted(pending_labels.items())}, ensure_ascii=False, indent=2) + "\n"
|
||||
graph_tmp = out / ".graph.tmp.json"
|
||||
json_written = to_json(G, communities, str(graph_tmp), force=True, built_at_commit=commit, community_labels=labels)
|
||||
if not json_written:
|
||||
@@ -2538,6 +2576,14 @@ def _rebuild_code(
|
||||
# that no longer exists. Writing labels first keeps the sidecar
|
||||
# trailing, which the guard sees as a mismatch and re-labels.
|
||||
write_text_atomic(labels_file, labels_json)
|
||||
if pending_labels:
|
||||
write_text_atomic(pending_labels_file, pending_labels_json)
|
||||
else:
|
||||
# Every community now has a curated name (or there simply are
|
||||
# none pending this run) -- a leftover empty/stale sidecar would
|
||||
# otherwise sit there forever once its last entry is promoted to
|
||||
# a real label.
|
||||
pending_labels_file.unlink(missing_ok=True)
|
||||
write_text_atomic(
|
||||
sig_file, json.dumps({str(k): v for k, v in cur_sigs.items()}))
|
||||
|
||||
|
||||
+134
-5
@@ -385,16 +385,23 @@ def test_rebuild_code_drops_labels_whose_community_changed(tmp_path):
|
||||
|
||||
out = corpus / "graphify-out"
|
||||
labels_file = out / ".graphify_labels.json"
|
||||
pending_file = out / ".graphify_labels.pending.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"
|
||||
# A from-empty rebuild's hub-fallback names land in the pending sidecar,
|
||||
# not the tracked file (#3334) -- read the cid list from there.
|
||||
pending = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
assert pending, "expected the first rebuild to write pending hub-fallback labels"
|
||||
|
||||
# Stand in for an LLM naming pass: give every community a distinctive,
|
||||
# CURATED name (a real naming pass promotes a pending entry into the
|
||||
# tracked file), leaving the signatures untouched so they still describe
|
||||
# THIS clustering.
|
||||
labels_file.write_text(
|
||||
json.dumps({cid: f"Named-{cid}" for cid in labels}), encoding="utf-8"
|
||||
json.dumps({cid: f"Named-{cid}" for cid in pending}), encoding="utf-8"
|
||||
)
|
||||
pending_file.unlink()
|
||||
|
||||
# Grow the corpus so clustering changes, then rebuild incrementally.
|
||||
for name in ("b.py", "c.py", "d.py"):
|
||||
@@ -445,6 +452,128 @@ def test_rebuild_code_drops_labels_whose_community_changed(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
def test_rebuild_code_keeps_hub_fallback_labels_out_of_the_tracked_file(tmp_path):
|
||||
"""#3334: a from-empty rebuild (a fresh clone, a new worktree, any CI job
|
||||
with no prior graphify-out/) names every community by its deterministic
|
||||
hub, but that name is not something anyone reviewed or asked to commit.
|
||||
Writing it into the same tracked file as genuinely curated names meant a
|
||||
routine rebuild kept appending placeholder entries to a file the project
|
||||
is told to commit as its reviewed semantic layer. The tracked file must
|
||||
stay empty on a first, from-empty rebuild; the fallback names must still
|
||||
be findable (in the pending sidecar) and must still reach the report."""
|
||||
import json
|
||||
from graphify.watch import _rebuild_code
|
||||
|
||||
corpus = tmp_path / "corpus"
|
||||
corpus.mkdir()
|
||||
(corpus / "a.py").write_text("def alpha():\n return 1\n", encoding="utf-8")
|
||||
(corpus / "b.py").write_text("def beta():\n return 2\n", encoding="utf-8")
|
||||
|
||||
assert _rebuild_code(corpus, acquire_lock=False) is True
|
||||
|
||||
out = corpus / "graphify-out"
|
||||
labels_file = out / ".graphify_labels.json"
|
||||
pending_file = out / ".graphify_labels.pending.json"
|
||||
|
||||
tracked = json.loads(labels_file.read_text(encoding="utf-8"))
|
||||
assert tracked == {}, (
|
||||
f"a from-empty rebuild must not write hub-fallback names into the "
|
||||
f"tracked labels file; got {tracked}"
|
||||
)
|
||||
assert pending_file.exists(), "hub-fallback names must land in the pending sidecar"
|
||||
pending = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
assert pending, "expected at least one hub-fallback label in the sidecar"
|
||||
|
||||
# The fallback names must still reach graph.json, unaffected by the split:
|
||||
# every node's community_name comes from the merged (curated + pending)
|
||||
# labels dict, not from the tracked file alone.
|
||||
graph = json.loads((out / "graph.json").read_text(encoding="utf-8"))
|
||||
names_seen = {
|
||||
str(n.get("community")): n.get("community_name")
|
||||
for n in graph["nodes"] if n.get("community") is not None
|
||||
}
|
||||
for cid, name in pending.items():
|
||||
assert names_seen.get(cid) == name, (
|
||||
f"community {cid}'s hub-fallback name did not reach graph.json "
|
||||
f"(expected {name!r}, saw {names_seen.get(cid)!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_rebuild_code_reuses_pending_labels_without_regenerating(tmp_path):
|
||||
"""A hub-fallback name, once written to the pending sidecar, must be
|
||||
REUSED on the next rebuild (not silently regenerated every run) as long
|
||||
as the community's membership hasn't changed -- matching the existing
|
||||
reuse guarantee already given to curated labels."""
|
||||
import json
|
||||
from graphify.watch import _rebuild_code
|
||||
|
||||
corpus = tmp_path / "corpus"
|
||||
corpus.mkdir()
|
||||
(corpus / "a.py").write_text("def alpha():\n return 1\n", encoding="utf-8")
|
||||
|
||||
assert _rebuild_code(corpus, acquire_lock=False) is True
|
||||
out = corpus / "graphify-out"
|
||||
pending_file = out / ".graphify_labels.pending.json"
|
||||
first = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
assert first
|
||||
|
||||
# Hand-edit the pending sidecar to a distinguishable value, then force a
|
||||
# real rebuild (unrelated file added, so the topology fast path is
|
||||
# skipped and the label merge logic actually runs) without touching the
|
||||
# community's own membership.
|
||||
pending_file.write_text(
|
||||
json.dumps({cid: "Reused Fallback Name" for cid in first}), encoding="utf-8"
|
||||
)
|
||||
(corpus / "unrelated.py").write_text("x = 1\n", encoding="utf-8")
|
||||
assert _rebuild_code(corpus, acquire_lock=False) is True
|
||||
|
||||
second = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
assert "Reused Fallback Name" in second.values(), (
|
||||
f"pending label was regenerated instead of reused; got {second}"
|
||||
)
|
||||
|
||||
|
||||
def test_rebuild_code_promotes_curated_label_and_drops_it_from_pending(tmp_path):
|
||||
"""A community that starts as an unreviewed hub fallback and then gets a
|
||||
real (curated) name -- simulating what a `graphify label` pass does --
|
||||
must end up in the tracked file, not still duplicated in the pending
|
||||
sidecar."""
|
||||
import json
|
||||
from graphify.watch import _rebuild_code
|
||||
|
||||
corpus = tmp_path / "corpus"
|
||||
corpus.mkdir()
|
||||
(corpus / "a.py").write_text("def alpha():\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"
|
||||
pending_file = out / ".graphify_labels.pending.json"
|
||||
pending = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
assert pending
|
||||
|
||||
# Promote every pending entry into the tracked file, as a real naming
|
||||
# pass would, and remove it from pending.
|
||||
labels_file.write_text(
|
||||
json.dumps({cid: f"Curated {cid}" for cid in pending}), encoding="utf-8"
|
||||
)
|
||||
pending_file.unlink()
|
||||
|
||||
(corpus / "unrelated.py").write_text("x = 1\n", encoding="utf-8")
|
||||
assert _rebuild_code(corpus, acquire_lock=False) is True
|
||||
|
||||
tracked_after = json.loads(labels_file.read_text(encoding="utf-8"))
|
||||
for cid in pending:
|
||||
assert tracked_after.get(cid, "").startswith("Curated "), (
|
||||
f"promoted label for community {cid} did not survive in the tracked file"
|
||||
)
|
||||
if pending_file.exists():
|
||||
pending_after = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
assert set(pending_after) & set(pending) == set(), (
|
||||
"a promoted community must not still appear in the pending sidecar"
|
||||
)
|
||||
|
||||
|
||||
def test_rebuild_code_keeps_a_visualization_when_over_the_viz_cap(tmp_path, monkeypatch):
|
||||
"""Crossing the viz node limit must not leave the project with no graph.html.
|
||||
_rebuild_code used to unlink the existing file and write nothing, so a repo
|
||||
|
||||
Reference in new issue
Block a user