CG-33: converge incremental sync with a full rebuild

A live, auto-synced index did not converge to a clean rebuild of the same
tree — 4.3% of distinct edges wrong in both directions on this repo's own
index, overwhelmingly `calls`, which is what flow queries traverse and what
explore's file ranking weights. Silent: nothing warned, and the symptom read
as "codegraph isn't very good" rather than "this index needs rebuilding."

Two causes, and the fix needed both. Resolution binds a reference to one of
the same-named definitions PROJECT-WIDE, so a definition appearing or
vanishing changes the correct answer for references in files the sync never
touches — and those references resolved successfully once, which deletes
their unresolved_refs row, leaving nothing to revisit them with (#1240's
retry only revisits refs parked as failed). Separately, when nothing
disambiguated the candidates the winner came down to rowid, i.e. the order
files happened to be WRITTEN, which differs between a scan-order full index
and a sync that appends each file as it changes. That second one is why
re-resolution alone could not converge: re-resolving against the identical
graph still picked a different candidate.

So getNodesByName now orders by (file_path, start_line) — a property of the
code, not of the write order — and sync computes a definitionDelta and
re-opens the resolution edges whose answer it may have invalidated,
re-inserting each as the reference that created it for the orphan sweep to
bind against the post-sync graph.

The delta compares `file\0name` pairs per file rather than one name set over
the batch: a commit that adds `collect` to a new file while an unrelated
changed file already defines `collect` cancels out of a batch-wide set, and
that miss was the largest residual class in the first measurement.

Conservative where the failure modes are asymmetric — a wrong deletion is a
permanent edge loss, a missed rebind is only residual drift. Edges without a
refName stamp are never touched (nothing to restore them from), sources the
sync already re-extracted are skipped, and a per-name ceiling declines the
generic names. Edges are deleted before the sweep re-inserts, since
INSERT OR IGNORE against idx_edges_identity would otherwise keep both rows
when a reference rebinds elsewhere.

Replaying real commits of this repo through sync, then diffing against a
rebuild: 16 commits 48 -> 0; 80 commits 1,634 -> 361, with the actively
misleading direction (stale edges the index keeps asserting) 671 -> 2.
Index and sync wall-clock are unchanged; the ORDER BY costs 18% per uncached
name lookup, which never reaches wall-clock because the resolver memoizes it.

The 357-edge residual at 80 commits is one pre-existing class: refs to
generic names (`push`, `join`) parked above #1240's per-name retry ceiling,
which a rebuild resolves into cross-language garbage — a TS test file
"calling" an R method. Converging there would mean manufacturing wrong edges,
so it is left alone. And no drift metric in `codegraph status`: it cannot be
computed without the rebuild it would be recommending, and a proxy would fire
on that residual and train users to ignore it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-06 04:32:40 -05:00
co-authored by Claude Opus 5
parent 2cf63fd114
commit 03893b0ab9
6 changed files with 640 additions and 12 deletions
+96 -10
View File
@@ -46,19 +46,98 @@ no duplicate nodes, no orphan edges, no nodes referencing a missing file row.
Nothing accumulates. Cross-file **resolution** goes stale.
## Likely mechanism
## Mechanism — two causes, both confirmed
`ReferenceResolver` resolves calls and imports by name-matching and the import
graph across the **whole** project. Incremental sync re-parses and re-resolves
only the changed file, so:
`ReferenceResolver` binds a reference to one of the same-named definitions
**project-wide**. Two things follow, and the drift needed both to be fixed.
- edges from *other* files into changed symbols are never recomputed → stale
edges retained (the 476);
- edges that should newly form from unchanged files into changed symbols are
never created → missing edges (the 751).
**1. Scope.** Incremental sync re-resolves only the references *in* the changed
files. Adding or removing a definition of `pct` changes the correct answer for
every `pct(...)` reference in the repo, including references in files the sync
never touches — and those references resolved successfully once, which *deletes*
their `unresolved_refs` row, so nothing existed to revisit them with. (The #1240
retry only revisits refs parked as `status='failed'`.) The index kept an answer
that was correct against an older graph.
Start in `src/sync/` and `src/resolution/` — specifically what scope is
re-resolved on a single-file change.
**2. Tie-break.** When nothing disambiguated the candidates, `findBestMatch`
kept the first one, and `getNodesByName` had no `ORDER BY` — so the winner was
decided by rowid, i.e. by the order files happened to be **written**. A full
index writes in scan order; a sync appends each file as it changes. The same
tree therefore resolved to different edges depending on how the index was built,
and no amount of re-resolution could converge, because re-resolving against the
identical graph still picked a different candidate.
### The fix
- `getNodesByName` orders by `(file_path, start_line)` — a property of the code,
not of the write order (`src/db/queries.ts`).
- `sync` returns a `definitionDelta`: the names whose set of definitions the sync
changed, computed as the symmetric difference of `file\0name` pairs sampled
before and after the store phase (`ExtractionOrchestrator.sync`).
- For each delta name, `resurrectStaleResolutionEdges` deletes the resolution
edges targeting a symbol of that name whose source is in an *unchanged* file,
and re-inserts each as the reference that created it (the `metadata.refName`
stamp). The existing orphan sweep then resolves them against the post-sync
graph — the same input a rebuild resolves from. Kill switch:
`CODEGRAPH_NO_REBIND=1`.
The delta is compared **per file**, not as one name set over the whole batch: a
commit that adds `collect` to a new file while an unrelated changed file already
defines `collect` cancels out of a batch-wide name set, and that miss was the
largest residual class in the first measurement of this fix.
Conservative by construction, because a wrong deletion is a permanent edge loss
while a missed rebind is only residual drift: an edge with no `refName` stamp
(synthesized, or built by an older engine) is never touched, edges whose source
file the sync already re-extracted are skipped, and a per-name ceiling of 500
edges declines the generic names.
### Result
Replaying real commits of this repo through `sync` one at a time, then diffing
against a clean rebuild of the final tree:
| replay | baseline (`main`) | + ORDER BY only | + rebind pass (shipped) |
|---|---|---|---|
| 16 commits | 48 (24 missing / 24 stale) | 20 | **0 — converged** |
| 80 commits | 1,634 (963 / 671) | 890 | **361 (359 / 2)** |
The direction that actively misleads — **stale** edges the index keeps asserting
— drops from 671 to **2** over 80 commits, a 99.7% reduction.
Index and sync wall-clock are unchanged (392-file repo: index 1.882.02s in both
arms, single-file sync 0.183s in both). The `ORDER BY` costs 18% per *uncached*
name lookup in a tight loop (237ms → 280ms over 10,127 lookups), which does not
reach wall-clock because `ReferenceResolver` memoizes the lookup per name. A
composite `(name, file_path, start_line)` index would make the sort free, but it
would widen every node index entry with a full path string on the write-heavy
indexing path — not worth 43ms.
### The residual, and why it is not chased
At 80 commits, 357 of the 361 remaining edges are a single pre-existing class:
references to very generic names (`push` 260, `join` 97) that failed at index
time and stay parked because `getRetryableFailedReferences` declines any name
with more than 500 failed refs (1,412 for `push`, 2,346 for `join`). That
ceiling is #1240/#999 policy, it is present on `main`, and what it declines to
create is cross-language garbage: a TypeScript test file "calling" an R method
named `push`, or a Rust method named `join`. **The full rebuild is the wrong one
here** — converging would mean teaching sync to manufacture thousands of wrong
edges. Left as is, deliberately.
### `codegraph status` — decided: no drift metric
The issue asked whether `status` should surface divergence. Decision: **no**.
A drift number cannot be computed without the full rebuild it would be
recommending, so anything cheap enough to run on `status` would be an estimate —
and an honest estimate is not available. Shipping a proxy would violate the
product rule that a screen must not overclaim, and post-fix it would fire on the
generic-name residual above, training users to ignore it. (`status` already
refuses to warn on parked failed refs for the same reason: every repo with
external-library imports has them, so the warning would be permanent noise.)
The check that *is* exact stays available and is documented below.
## Why it degrades retrieval
@@ -93,6 +172,13 @@ node scripts/agent-eval/diff-index-drift.mjs /tmp/live.db .codegraph/codegraph.d
Exit code is 0 when converged, 1 when drifted. To re-confirm determinism, diff
two consecutive rebuilds — that must report 0.
To reproduce the *regression* rather than measure a live index, replay real
commits through `sync`: clone the repo, check out `HEAD~N`, index, then
`git checkout <sha> && codegraph sync` for each commit in order, snapshot the
database, and diff it against a rebuild of the final tree. That is what produced
the table above, and the unit-scale version of it is
`__tests__/sync-rebuild-convergence.test.ts`.
## Note on probing an index
The index file is `.codegraph/codegraph.db`. There is no `graph.db`. `sqlite3`