Skip to content

Commit 82ea014

Browse files
HumanBean17claude
andauthored
fix(graph): refresh preserved dependent nodes on increment (PR-P4) (#343)
PR-P2 replaced the per-row `_MERGE_SYMBOL` upsert in `_write_nodes_impl` with a skip-if-exists filter, which dropped the property refresh on preserved dependent type nodes. A dependent's `role`/`capabilities` depend on project-wide inputs (meta-annotation chain, brownfield overrides) and can shift without its own source changing, so the incremental graph diverged from a full rebuild — a preserved dependent whose role lever changed stayed stale until the next full rebuild. The `_delete_file_scope` contract (issue #305) relied on that re-MERGE to refresh preserved dependents in place. Found by fanning out reviewer subagents over the landed init/increment-perf plan and adjudicating a B-vs-D conflict empirically (the "benign" verdict came from a reviewer that couldn't run ladybug; the "bug" verdict reproduced). Changes: - Mark `TypeIndexEntry`/`MemberEntry` `loaded_from_db` when `_load_existing_{types,members}` creates them, so DB-loaded stubs (placeholder decls) are distinguishable from freshly-parsed scoped/dependent decls. - After the bulk COPY of new nodes, re-SET every mutable Symbol field on preserved, non-stub, type-kind nodes (`_SET_SYMBOL_BY_ID`), restoring the upsert the design relied on. Stubs are skipped (their stored values are authoritative); non-type kinds carry no mutable role/capabilities; the full path is unaffected (empty DB → no existing nodes). - `_populate_declares_rows` skips loaded members: their DECLARES edges already persist and re-emitting duplicated them (REL tables carry no PK). This second incremental≠full divergence surfaced once the equivalence test was fixed. - Delete dead `_existing_symbol_ids` (zero callers); correct the Route-MERGE loop comment (it iterates all routes, not phantoms). Tests: - `test_incremental_bulk_write_equivalent_to_full_rebuild` now asserts real incremental≡full equivalence (node count, per-type edge counts, type roles) instead of the prior tautology (`set(keys)==set(keys)` + `count>0`). - New `test_incremental_refreshes_dependent_role_on_meta_chain_change` guards the exact regression. - Rename `test_bulk_write_edges_match_per_row_baseline` → `test_bank_chat_bulk_build_matches_committed_baseline`: the per-row path is gone, so the fixture (bulk-generated) is a drift anchor, not a per-row proof. Known follow-ups (out of scope): converting the remaining per-row Route MERGE to bulk; OVERRIDES may have an analogous duplication for cross-file pairs; annotation-definition edits don't pull annotation-users into incremental scope (a separate, pre-existing scope gap, not the upsert regression fixed here). Co-authored-by: Claude <noreply@anthropic.com>
1 parent 100c666 commit 82ea014

5 files changed

Lines changed: 237 additions & 85 deletions

File tree

build_ast_graph.py

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ class TypeIndexEntry:
188188
package: str
189189
outer_fqn: str | None
190190
node_id: str
191+
# True when this entry was loaded from the existing graph by
192+
# `_load_existing_types` (an unchanged-file stub used only for cross-file
193+
# resolution). Its `decl` is a placeholder (no annotations/methods), so its
194+
# recomputed role/capabilities must never be written back over the real
195+
# stored values. See `_write_nodes_impl`.
196+
loaded_from_db: bool = False
191197

192198

193199
@dataclass
@@ -200,6 +206,11 @@ class MemberEntry:
200206
module: str
201207
microservice: str
202208
node_id: str
209+
# True when loaded from the existing graph by `_load_existing_members`
210+
# (an unchanged-file stub used only for cross-file call resolution). Its
211+
# DECLARES edge already persists in the graph, so it must not be re-emitted
212+
# by `_populate_declares_rows` (REL tables have no PK → would duplicate).
213+
loaded_from_db: bool = False
203214

204215

205216
@dataclass
@@ -556,7 +567,7 @@ def _load_existing_types(conn: ladybug.Connection, tables: GraphTables, exclude_
556567
if exclude_files is not None and not exclude_files:
557568
return
558569

559-
where = "WHERE s.kind IN ['class', 'interface', 'enum', 'annotation', 'record']"
570+
where = f"WHERE s.kind IN {list(_TYPE_KINDS)}"
560571
params: dict = {}
561572
if exclude_files:
562573
where += "\n AND NOT (s.filename IN $exclude_files)"
@@ -586,6 +597,7 @@ def _load_existing_types(conn: ladybug.Connection, tables: GraphTables, exclude_
586597
package=package,
587598
outer_fqn=None,
588599
node_id=node_id,
600+
loaded_from_db=True,
589601
)
590602
tables.types[fqn] = entry
591603
tables.by_simple_name.setdefault(name, []).append(entry)
@@ -634,6 +646,7 @@ def _load_existing_members(conn: ladybug.Connection, tables: GraphTables, exclud
634646
module="",
635647
microservice="",
636648
node_id=node_id,
649+
loaded_from_db=True,
637650
))
638651

639652

@@ -3044,19 +3057,6 @@ def _existing_node_ids(conn: ladybug.Connection) -> set[str]:
30443057
return ids
30453058

30463059

3047-
def _existing_symbol_ids(conn: ladybug.Connection) -> set[str]:
3048-
"""Return every Symbol node id currently in the graph.
3049-
3050-
Deprecated: use _existing_node_ids for filtering all node types.
3051-
Kept for compatibility with existing _write_edges implementation.
3052-
"""
3053-
result = conn.execute("MATCH (n:Symbol) RETURN n.id")
3054-
ids: set[str] = set()
3055-
while result.has_next():
3056-
ids.add(result.get_next()[0])
3057-
return ids
3058-
3059-
30603060
# Column-order constants for bulk COPY FROM.
30613061
# For REL tables, the first two entries are FROM/TO node primary keys (kuzu requirement).
30623062
# Order matches the corresponding _SCHEMA_* declarations above.
@@ -3066,6 +3066,29 @@ def _existing_symbol_ids(conn: ladybug.Connection) -> set[str]:
30663066
"modifiers", "annotations", "capabilities", "role", "signature", "parent_id", "resolved"
30673067
]
30683068

3069+
# Type declaration kinds. Tuple (not set) so the rendered SQL `IN` clause is
3070+
# deterministic. Used to (a) load type stubs for cross-file resolution and
3071+
# (b) scope the incremental property-refresh SET to type nodes.
3072+
_TYPE_KINDS: tuple[str, ...] = ("class", "interface", "enum", "annotation", "record")
3073+
3074+
# Update every mutable Symbol field on an existing node by primary key. Used on
3075+
# the incremental path to refresh preserved dependent type nodes whose
3076+
# `role`/`capabilities` (and other project-wide-derived fields) can shift
3077+
# without their own source changing — restoring the upsert the legacy per-row
3078+
# `MERGE (n:Symbol {id:$id}) SET …` provided. Field list mirrors `_NODE_COLUMNS`
3079+
# minus `id`.
3080+
_SET_SYMBOL_BY_ID = (
3081+
"MATCH (n:Symbol {id: $id}) "
3082+
"SET n.kind = $kind, n.name = $name, n.fqn = $fqn, "
3083+
"n.package = $package, n.module = $module, n.microservice = $microservice, "
3084+
"n.filename = $filename, "
3085+
"n.start_line = $start_line, n.end_line = $end_line, "
3086+
"n.start_byte = $start_byte, n.end_byte = $end_byte, "
3087+
"n.modifiers = $modifiers, n.annotations = $annotations, "
3088+
"n.capabilities = $capabilities, n.role = $role, "
3089+
"n.signature = $signature, n.parent_id = $parent_id, n.resolved = $resolved"
3090+
)
3091+
30693092
_REL_EXTENDS_COLUMNS = ["FROM", "TO", "source_file", "dst_name", "dst_fqn", "resolved"]
30703093
_REL_IMPLEMENTS_COLUMNS = ["FROM", "TO", "source_file", "dst_name", "dst_fqn", "resolved"]
30713094
_REL_INJECTS_COLUMNS = ["FROM", "TO", "source_file", "dst_name", "dst_fqn", "resolved", "mechanism", "annotation", "field_or_param"]
@@ -3106,6 +3129,10 @@ def _write_nodes_impl(
31063129

31073130
# Stage all Symbol rows
31083131
rows: list[dict] = []
3132+
# Node ids loaded from the existing graph as resolution-only stubs
3133+
# (`_load_existing_types`); their staged rows carry placeholder values and
3134+
# must never be written back over the real nodes.
3135+
stub_ids: set[str] = set()
31093136

31103137
# packages
31113138
for pkg, pid in tables.packages.items():
@@ -3119,6 +3146,8 @@ def _write_nodes_impl(
31193146
))
31203147
# types
31213148
for entry in tables.types.values():
3149+
if entry.loaded_from_db:
3150+
stub_ids.add(entry.node_id)
31223151
d = entry.decl
31233152
role, capabilities = resolve_role_and_capabilities(
31243153
d,
@@ -3158,14 +3187,34 @@ def _write_nodes_impl(
31583187
for pid, row in tables.phantoms.items():
31593188
rows.append(row)
31603189

3161-
# For incremental path, filter out nodes that already exist to avoid duplicate primary key errors
3162-
# The full rebuild path starts with an empty database, so all rows are new
3190+
# Bulk-load new Symbol rows. The full-rebuild path starts from an empty
3191+
# database (`_drop_all`), so every row is new. The incremental path reaches
3192+
# here with a populated database: changed-file nodes were deleted by
3193+
# `_delete_file_scope` (absent here → new), while dependent-file nodes are
3194+
# deliberately preserved (see `_delete_file_scope` / issue #305).
31633195
existing_ids = _existing_node_ids(conn)
31643196
new_rows = [row for row in rows if row["id"] not in existing_ids]
3165-
3166-
# Bulk-load only new Symbol rows
31673197
_bulk_copy(conn, "Symbol", _NODE_COLUMNS, new_rows)
31683198

3199+
# Refresh mutable properties on preserved dependent TYPE nodes (incremental
3200+
# path only; `update_rows` is empty on the full path). `role`/`capabilities`
3201+
# — and any other field derived from project-wide inputs (meta-annotation
3202+
# chain, brownfield overrides) — can shift without the type's own source
3203+
# changing, so a preserved dependent must be re-SET to stay byte-equivalent
3204+
# with a full rebuild. The legacy per-row `_MERGE_SYMBOL` upserted every
3205+
# staged node and did this implicitly; bulk `COPY FROM` only appends, so the
3206+
# SET is explicit here. Stubs (`stub_ids`) are skipped: their decl is a
3207+
# placeholder and their stored values are authoritative. Non-type kinds
3208+
# carry no mutable role/capabilities, so they are skipped too.
3209+
update_rows = [
3210+
row for row in rows
3211+
if row["id"] in existing_ids
3212+
and row["id"] not in stub_ids
3213+
and row["kind"] in _TYPE_KINDS
3214+
]
3215+
for row in update_rows:
3216+
conn.execute(_SET_SYMBOL_BY_ID, row)
3217+
31693218

31703219
def _write_nodes(
31713220
conn: ladybug.Connection,
@@ -3180,8 +3229,15 @@ def _write_nodes(
31803229

31813230

31823231
def _populate_declares_rows(tables: GraphTables) -> None:
3232+
# Skip members loaded from the existing graph for cross-file resolution: a
3233+
# DECLARES edge for an unchanged-file member already persists (its
3234+
# source_file is out of scope, so `_delete_file_scope` left it), and
3235+
# re-emitting it would append a duplicate (REL tables carry no primary key).
3236+
# Full-rebuild never loads members, so this is a no-op there.
31833237
tables.declares_rows = [
3184-
DeclaresRow(src_id=m.parent_id, dst_id=m.node_id) for m in tables.members
3238+
DeclaresRow(src_id=m.parent_id, dst_id=m.node_id)
3239+
for m in tables.members
3240+
if not m.loaded_from_db
31853241
]
31863242

31873243

@@ -3884,8 +3940,12 @@ def _write_clients_producers_and_calls(conn: ladybug.Connection, tables: GraphTa
38843940
Route nodes (created by pass5 for cross-service calls) that wouldn't
38853941
otherwise exist in LadybugDB.
38863942
"""
3887-
# Write phantom routes that don't already exist (pass5 creates these for cross-service calls)
3888-
# Intentionally retained MERGE for dedup against routes written during scoped step
3943+
# Upsert every route via MERGE. `tables.routes_rows` is the full route set
3944+
# (pass4 routes + pass5 phantom routes), not just phantoms; MERGE is
3945+
# idempotent against routes already written during the scoped step, so it
3946+
# neither duplicates nor drops them. This is the one remaining per-row graph
3947+
# write — converting it to bulk COPY requires filtering against existing
3948+
# route ids to reproduce the dedup, and is left as a future optimization.
38893949
for row in tables.routes_rows:
38903950
conn.execute(
38913951
"MERGE (r:Route {id: $id}) "

plans/AGENT-PROMPTS-INIT-INCREMENT-PERF.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ lines vs the pre-PR baseline.
137137
- [ ] Step-1 spike result recorded in `_bulk_copy` docstring.
138138
- [ ] `_write_edges` stages per-type rows (CALLS dedup + callee_declaring_role at staging); UnresolvedCallSite bulk-loaded before UNRESOLVED_AT.
139139
- [ ] `_CREATE_EXT/IMPL/INJ/DECL/OVERRIDES/CALL` + local `_CREATE_UNRESOLVED/_UNRESOLVED_AT` deleted.
140-
- [ ] `test_bulk_write_edges_match_per_row_baseline`, `test_bulk_write_is_deterministic_double_build`, `test_bulk_write_preserves_calls_dedup_and_callee_declaring_role`, `test_bulk_write_empty_rel_table_is_noop` pass.
140+
- [ ] `test_bank_chat_bulk_build_matches_committed_baseline` (renamed from `test_bulk_write_edges_match_per_row_baseline` in PR-P4), `test_bulk_write_is_deterministic_double_build`, `test_bulk_write_preserves_calls_dedup_and_callee_declaring_role`, `test_bulk_write_empty_rel_table_is_noop` pass.
141141
- [ ] Full `test_ast_graph_build.py` + `test_incremental_graph.py` pass unchanged.
142142
- [ ] Sentinel greps: zero where required, non-zero where required.
143143
- [ ] `.venv/bin/ruff check .` clean; benchmark in PR description.

plans/active/PLAN-INIT-INCREMENT-PERF.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,12 @@ phase delta. Not packaged; documents the measured speedup in the PR description.
161161

162162
## Tests for PR-P1
163163

164-
1. `test_bulk_write_edges_match_per_row_baseline` — build `tests/bank-chat-system`
165-
via the bulk path, assert node count, per-type edge counts, `GraphMeta`
166-
counters, and sampled edge rows equal `graph_baseline_bank_chat.json`.
164+
1. `test_bank_chat_bulk_build_matches_committed_baseline` (renamed from
165+
`test_bulk_write_edges_match_per_row_baseline` in PR-P4 once the per-row
166+
reference was gone) — build `tests/bank-chat-system` via the bulk path,
167+
assert node count, per-type edge counts, `GraphMeta` counters, and sampled
168+
edge rows equal `graph_baseline_bank_chat.json` (a drift anchor, not a
169+
per-row equivalence proof).
167170
2. `test_bulk_write_is_deterministic_double_build` — build bank-chat twice to two
168171
DBs via the bulk path, assert identical counts + query battery. Models on
169172
`tests/test_brownfield_routes.py::test_29_determinism_pass4_route_ids` and
@@ -183,7 +186,7 @@ phase delta. Not packaged; documents the measured speedup in the PR description.
183186
- [ ] `_bulk_copy` helper added; step-1 spike result in its docstring.
184187
- [ ] `_write_edges` stages per-type rows (CALLS dedup + `callee_declaring_role` at staging) and bulk-loads UnresolvedCallSite before UNRESOLVED_AT.
185188
- [ ] `_CREATE_EXT/IMPL/INJ/DECL/OVERRIDES/CALL` deleted; local `_CREATE_UNRESOLVED/_UNRESOLVED_AT` gone with the rewrite.
186-
- [ ] `test_bulk_write_edges_match_per_row_baseline`,
189+
- [ ] `test_bank_chat_bulk_build_matches_committed_baseline`,
187190
`test_bulk_write_is_deterministic_double_build`,
188191
`test_bulk_write_preserves_calls_dedup_and_callee_declaring_role`,
189192
`test_bulk_write_empty_rel_table_is_noop` pass.

tests/test_ast_graph_build.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -554,11 +554,20 @@ def _load_baseline() -> dict:
554554
return json.load(f)
555555

556556

557-
def test_bulk_write_edges_match_per_row_baseline(ladybug_db_path: Path) -> None:
558-
"""Bulk COPY FROM produces identical graph to the per-row baseline.
559-
560-
Asserts node count, per-type edge counts, GraphMeta counters, and sampled edge
561-
properties match the baseline generated from the last per-row _write_edges build.
557+
def test_bank_chat_bulk_build_matches_committed_baseline(ladybug_db_path: Path) -> None:
558+
"""Bank-chat full build matches the committed baseline (a drift anchor).
559+
560+
Asserts node count, per-type edge counts, GraphMeta counters, and sampled
561+
CALLS properties match ``graph_baseline_bank_chat.json``.
562+
563+
This is a **regression anchor**, not a per-row equivalence proof: the legacy
564+
per-row ``_write_edges`` was removed in PR-P1, so there is no per-row
565+
reference to compare against, and this fixture was itself generated from a
566+
bulk build (commit 8261acf). It guards against unintended drift in the
567+
bank-chat full-build graph. The actual write-mechanism equivalence gates are
568+
``test_bulk_write_is_deterministic_double_build`` (two bulk builds identical)
569+
and ``test_incremental_bulk_write_equivalent_to_full_rebuild`` (incremental
570+
matches a full rebuild of the same state).
562571
"""
563572
baseline = _load_baseline()
564573
conn = _connect(ladybug_db_path)

0 commit comments

Comments
 (0)