Skip to content

Commit 19c496d

Browse files
safishamsiclaude
andcommitted
fix(build): make _semantic_id_remap idempotent to stop id accretion (#1917)
An id whose canonical stem contains its legacy stem as a prefix (parent dir name == file stem, e.g. .claude/CLAUDE.md) re-matched the legacy branch every build and grew another stem segment, defeating the same_topology/no_change short-circuits and churning graph.json + clustering on every zero-delta update. Skip an id that already carries its canonical stem (mirrors graph_has_legacy_ids); genuine legacy migration still applies. Also records the #1889/#1918 query-scoring perf work in the changelog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c0875cc commit 19c496d

3 files changed

Lines changed: 47 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu
44

55
## 0.9.17 (unreleased)
66

7+
- Fix: `_semantic_id_remap` is now idempotent, so incremental rebuilds stop churning (#1917). When a file's canonical stem contained its own legacy stem as a prefix (parent dir name equals the file stem, e.g. `.claude/CLAUDE.md`, `docs/docs.md`), an already-migrated semantic node id re-matched the legacy branch and gained another stem segment on every build (`claude_x` -> `claude_claude_x` -> ...). Because `_origin` is persisted, every `graphify update` re-fed nodes through the remap, so the ids grew unboundedly and the `same_topology`/`same_graph`/`no_change` short-circuits never fired — rewriting `graph.json` and re-running clustering on every zero-delta update. The remap now skips an id that already carries its canonical stem (mirroring the `graph_has_legacy_ids` check), while a genuine one-time legacy migration still applies. (An already-corrupted graph needs one `graphify extract --force` to reset the grown ids.)
8+
- Perf: `graphify query` now scores the graph once per query instead of T+1 times for a T-term query (#1889 / #1918, thanks @Sirhan1). The per-term-guarantee (#1445) previously re-scored the whole graph once per token; `_score_query` now computes the combined ranking and each token's singleton winner in a single traversal, feeding `_pick_seeds` via `best_seed_by_term`. Behavior is preserved (verified byte-identical against the old per-term scoring across a differential fuzz); ~1.3-1.4x faster and independent of query length.
9+
10+
711
- Fix: `--mode deep` is now effective over a warm cache instead of a silent no-op (#1894). The semantic cache is namespaced by mode (`semantic` vs `semantic-deep`) so a shallow-cached file no longer satisfies a deep run; `graphify extract` gains `--force` (and honors `GRAPHIFY_FORCE`) to bypass the incremental gate and the cache read; and a deep incremental run widens its dispatch to the full live doc set so the deep namespace actually gets populated. Cache prune/clear now sweep both namespaces so the deep cache can't accumulate orphans. (The skill-side flow that passes the mode through is a follow-up; the CLI is complete and backward compatible — the new `mode` argument defaults to the existing behavior.)
812
- Fix: the semantic cache no longer persists dangling edges/hyperedges (#1916). When a node group was skipped on write (out-of-scope per the batch guard, or a ghost `source_file`), edges/hyperedges in the kept groups that referenced those never-written nodes were saved anyway and re-surfaced on every cache replay. Those references are now pruned at write time (gated on the scoping allowlist, so unscoped callers are unchanged), and `build_from_json` validates hyperedge members against the node set so a dangling hyperedge can't reach `graph.json` even from a live extraction.
913
- Fix: `graphify update`/watch no longer produces a bloated graph by double-representing documents (#1915). `_rebuild_code` AST-quick-scanned Markdown/doc files and then preserved their existing semantic (LLM) nodes on top, so each doc appeared twice (a real corpus came out ~4x). A doc that already has semantic nodes in the graph is no longer AST-quick-scanned (its semantic nodes are the sole representation), while a doc with no semantic layer still gets the structural quick-scan; incremental rebuilds now preserve a doc's semantic nodes instead of evicting them, and previously-bloated graphs self-heal on the next full rebuild.

graphify/build.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,16 @@ def _semantic_id_remap(nodes: list, root: str | None) -> dict:
287287
if not new_stem:
288288
continue
289289
norm_nid = _normalize_id(nid)
290+
# Idempotency guard (#1917): an id already carrying its canonical stem is
291+
# done — do not re-run the legacy branch on it. When the canonical stem
292+
# contains a shorter legacy stem as a prefix (parent dir name == file
293+
# stem, e.g. `.claude/CLAUDE.md` -> `claude_claude` over legacy `claude`),
294+
# an already-migrated id like `claude_claude_x` still matches the legacy
295+
# `claude_` prefix below and would gain another stem segment on every
296+
# build, defeating the same_topology/no_change short-circuits. Mirrors the
297+
# canonical check in graph_has_legacy_ids.
298+
if norm_nid == new_stem or norm_nid.startswith(new_stem + "_"):
299+
continue
290300
new_id: str | None = None
291301
for old_stem in _old_file_stems(rel):
292302
if old_stem == new_stem:

tests/test_semantic_id_remap_root.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,36 @@ def test_normal_semantic_remap_still_works():
4848
remap = _semantic_id_remap(
4949
[{"id": "foo", "source_file": "src/foo.py", "_origin": "semantic"}], "/proj")
5050
assert isinstance(remap, dict)
51+
52+
53+
# --- #1917: _semantic_id_remap must be idempotent (no id accretion) ---
54+
55+
def test_semantic_id_remap_is_idempotent_when_stem_contains_legacy_stem():
56+
"""A file whose parent dir name equals its stem (.claude/CLAUDE.md ->
57+
canonical `claude_claude`, legacy `claude`) must not re-prefix an
58+
already-canonical id on every build (#1917). Without the guard, ids grow
59+
`claude_x` -> `claude_claude_x` -> `claude_claude_claude_x` ..., defeating
60+
the same_topology/no_change short-circuits."""
61+
nodes = [{"id": "claude_graphify_trigger",
62+
"source_file": ".claude/CLAUDE.md", "_origin": "semantic"}]
63+
first = _semantic_id_remap(nodes, ".")
64+
assert first == {"claude_graphify_trigger": "claude_claude_graphify_trigger"}
65+
# Feed the migrated ids back through: a second pass must be a fixed point.
66+
migrated = [{**n, "id": first.get(n["id"], n["id"])} for n in nodes]
67+
assert _semantic_id_remap(migrated, ".") == {}, "id re-prefixed on second build (#1917)"
68+
69+
70+
def test_semantic_id_remap_bare_file_node_is_idempotent():
71+
"""The bare file node id follows the same fixed-point rule."""
72+
nodes = [{"id": "claude", "source_file": ".claude/CLAUDE.md", "_origin": "semantic"}]
73+
first = _semantic_id_remap(nodes, ".")
74+
assert first == {"claude": "claude_claude"}
75+
migrated = [{"id": "claude_claude", "source_file": ".claude/CLAUDE.md", "_origin": "semantic"}]
76+
assert _semantic_id_remap(migrated, ".") == {}
77+
78+
79+
def test_semantic_id_remap_still_migrates_genuine_legacy_id():
80+
"""The idempotency guard must not block a real one-time legacy migration:
81+
a pre-scheme id under a normal path still remaps once to the canonical stem."""
82+
nodes = [{"id": "readme_booking", "source_file": "api/README.md", "_origin": "semantic"}]
83+
assert _semantic_id_remap(nodes, ".") == {"readme_booking": "api_readme_booking"}

0 commit comments

Comments
 (0)