Skip to content

Fix attribution and performance bugs in hunk-level significance matching - #140

Merged
EsmeYi merged 10 commits into
mainfrom
fix/hunk-attribution-bleed
Aug 16, 2026
Merged

Fix attribution and performance bugs in hunk-level significance matching#140
EsmeYi merged 10 commits into
mainfrom
fix/hunk-attribution-bleed

Conversation

@EsmeYi

@EsmeYi EsmeYi commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ten related fixes to extractSnapshot()/consumeAndShift()'s significance-filtering and line-matching, all about attribution ending up on the wrong line/tasklet (or the process falling over, or a whole hunk losing attribution) rather than just being imprecise.

1–9 (see commit history for full detail on each)

  1. Unrelated lines swept into attribution by blanket hunk crediting
  2. Significance check using array-adjacent chain entry instead of the real parent
  3. Duplicate lines within a hunk colliding onto the same match
  4. O(n*m) memory blowup in the alignment itself
  5. O(k²) bucket consumption via Array.shift()
  6. BLEU fallback dropping whole hunks past a total-size cap
  7. Wrong-position matches in the BLEU fallback near ambiguous content
  8. Ties defeating the fuzzy-match early exit on massively-repeated content
  9. Tie-break drifting attribution when nothing actually shifted

10. Drift bias walking uniform content into an unrelated appended tail

Caught in review as a mirror-image regression of fix #9: biasing ties toward signedLocalDrift is a single number for the WHOLE gap — it has no way to know WHERE within the gap an insertion/deletion actually happened. Fix #7's own repro put the extra lines BEFORE the AI's block, so biasing toward "+drift" happened to be correct there. Mirroring that construction with the extra lines AFTER the block instead (50 lines identical to the AI's own content, appended right after it) showed the AI's 1,000 lines needed no shift at all, but the same bias still walked lines near the end of the block into the appended, non-AI region — confirmed empirically: 50 lines pulled into the tail.

Traced the root cause further than the tie-break alone: the anchor-interpolated estimate itself already assumes a gap's size change is spread proportionally across it, which is also wrong when concentrated at one end.

Fixed by deciding, per old line, whether reaching for the gap's drift is warranted at all — based on how many OTHER old lines share that exact text. A rare line (occurs only a handful of times) is what a real "moved block vs. one stray duplicate elsewhere" situation looks like, and both the estimate and the tie-break account for the full drift there. Text that repeats many times over (uniform/templated content) gets an estimate assuming NO shift, with ties preferring whichever candidate is closest to that instead — there's no way to tell, from content alone, which of many identical occurrences is this line's own.

Verified against all three relevant scenarios (new append-after repro, original insert-before repro, no-drift uniform-rename case) — all correct. Also extracted bleuSimilarity() out of computeHunkSignificance() in utils.ts so the hunk-level and line-level comparisons share one implementation.

Test plan

EsmeYi added 2 commits August 14, 2026 15:21
… crediting

consumeAndShift() decided whether to keep a tracked line's AI attribution
at the whole-hunk level: if a hunk's overall old-vs-new text was similar
enough ("insignificant"), it credited *every* new line in that hunk's
range to the tasklet that owned the one tracked line inside it — not just
the specific line that actually matched.

That's fine for the common case (a hunk that's just the tracked line
itself, tweaked). But git diff has no move detection, so a restructuring
can bundle a wide, mostly-unrelated span of lines into one hunk that still
scores as "similar overall" (most of the text is still present, just
reshuffled or interleaved with something else nearby). When that happens,
lines the tracked tasklet never touched — anything else that happened to
land in the same hunk — were getting mislabeled as AI-generated.

Fixed by matching each tracked line to the specific new line its own
content actually corresponds to (exact match first, then best BLEU-similar
candidate), instead of crediting the whole hunk indiscriminately. If
nothing in the hunk resembles it anymore, the line's attribution is
dropped rather than guessed at.

Verified with a real git-based repro: an AI-written line and an adjacent,
never-AI-attributed comment edited in the same commit (no blank line
between them, so git bundles both into one hunk). Before the fix, both
lines were credited to the AI tasklet; after, only the one it actually
wrote is.
…al parent

Code review finding on this PR: extractSnapshot() decided whether a
snapshot was diffing against a prior AI edit by checking
isAiChange(chain[index - 1]) — the array-adjacent entry. That's only a
valid stand-in for "this snapshot's actual parent" on a simple linear
chain. getTracyChain() does a BFS over (possibly multiple) parents to
support squash-merged chains (see its doc comment), so on a chain with a
merge point, array-adjacent entries can be siblings from different
branches rather than parent/child.

Concretely: a branch A snapshot whose real parent is the base (non-AI)
commit could end up array-adjacent to an unrelated branch B AI snapshot
after the BFS-then-reverse ordering. The significance check would then
wrongly treat branch A's edit as AI-to-AI and skip filtering, letting a
trivial edit (that should have been filtered as a User->AI change) get
attributed.

The diff base itself (diffFromTree) was already resolved correctly via
snapshot.parentHash — only the "is this AI-authored" check was using the
wrong reference. Fixed by resolving the real parent from
snapshot.parentHash and looking it up in the chain by hash, instead of
assuming array adjacency. Multi-parent snapshots (the merge commit itself)
keep the old array-adjacent fallback, since diffFromTree already silently
falls back to it too (getCommitTree rejects the space-joined multi-parent
string), so the two stay consistent.

Verified with a real git-based repro: a squash-merged two-branch chain
where branch A's tiny edit and branch B's substantial edit land array-
adjacent to each other. Before the fix, branch A's trivial edit was wrongly
attributed; after, only branch B's real edit is.
@EsmeYi EsmeYi changed the title Fix unrelated lines getting swept into AI attribution by blanket hunk crediting Fix hunk-boundary and chain-adjacency attribution bugs Aug 14, 2026
EsmeYi added 4 commits August 14, 2026 15:44
Code review finding on this PR: findMatchingNewLineIndex matched each
tracked old line independently via findIndex, which always resolves to
the FIRST matching new line. If an insignificant hunk contains two
duplicate AI-attributed old lines (common: `}`, `return;`, identical log
statements), both independent lookups landed on the same new position.
The Set-based survivor collection then silently dropped the second one —
and if the two duplicates belonged to different tasklets (consumeAndShift
is called separately per tasklet's own Change), the later call's line
could overwrite the earlier tasklet's legitimate claim entirely.

Replaced the independent per-line lookup with alignHunkLines(), which
aligns a hunk's old and new lines one-to-one and in order for the whole
hunk at once: exact (whitespace-insensitive) matches are aligned via LCS,
which respects both order and duplicate multiplicity, so two `}` lines in
the old text land on two different `}` lines in the new text rather than
both on the first one. Anything left over falls back to the best
remaining (not-yet-used) BLEU-similar candidate. The alignment is a pure
function of the hunk's own content, memoized per hunk within a single
consumeAndShift call — and since it doesn't depend on which specific old
line is being queried, two separate calls against the same hunk (e.g. for
two different tasklets) independently arrive at the same consistent
mapping.

Verified with two real git-based repros: two duplicate AI-written lines
from the SAME tasklet bundled into one hunk (before: the second one
vanished; after: both survive at distinct positions), and two duplicate
lines from DIFFERENT tasklets bundled together (before: one tasklet's
line was silently absorbed into the other's; after: each keeps its own).
Code review finding on this PR: alignHunkLines() unconditionally built a
full (n+1)x(m+1) LCS table to align a hunk's old and new lines. A
reformat, reorder, or bulk rename across a large AI-generated file can
easily put thousands of lines into a single hunk (whitespace is stripped
before the significance check, so a pure reindent stays "insignificant"
and lands right in this path) — at that scale the DP table means tens to
hundreds of millions of number slots for one hunk alone. Confirmed
empirically: at 8,000 lines the old table pushed peak memory to ~620MB
for a single hunk, growing quadratically from there. The prior
implementation (independent per-line findIndex lookups) was linear, so
this was a real regression, not a pre-existing cost.

Replaced the LCS table with an O(n+m) hash-based pass for exact
(whitespace-insensitive) matches: group new-line indices by content, then
consume each group in order as old lines are walked. This still gives
each duplicate line (`}`, `return;`, identical log statements) its own
distinct occurrence — the property the LCS table existed for — without
building a quadratic table. It's not strictly optimal the way LCS is (a
pathological case with duplicates AND genuine reordering could pick a
less-intuitive pairing), but it never produces a collision or an
incorrect cross-tasklet overwrite, which is what actually mattered.

The BLEU fuzzy-match fallback stays inherently O(unmatched x unmatched),
so it's now bounded by FUZZY_MATCH_SEARCH_CAP (200,000 candidate pairs):
past that, leftover lines are left unmatched rather than guessed at,
consistent with this function's existing "drop rather than guess"
philosophy. In practice this pass has little to do anyway once exact
matching (which alone handles ordinary reformatting/reindentation) runs
first.

Verified all six previously-added repro scenarios (from this PR and the
one before it) still produce identical results. Added a performance
regression test: a 5,000-line single-hunk reindent now resolves in
~0.5s with ~0MB heap growth; the same scenario against the old LCS
implementation took ~195MB of heap growth, confirming the test catches
the regression this fix addresses.
Code review finding on this PR: the exact-match pass in alignHunkLines()
(added to fix the O(n*m) LCS table) grouped new-line indices by content
into buckets, then consumed each bucket with Array.prototype.shift().
shift() shifts every remaining element down by one index, so it's O(k)
per call — and a hunk with many identical lines (`}`, blank lines,
templated log statements) means the SAME bucket gets shifted repeatedly,
for a total cost of O(k^2) on that bucket alone. The previous performance
test used lines with unique content (each line embeds its own index), so
every bucket had exactly one entry and never exercised this path.

Confirmed empirically with an isolated microbenchmark of shift() alone:
30k elements ~50ms, 60k ~219ms, 120k ~901ms — a clean quadratic curve.
Reproduced in the full pipeline with a 300,000-line single-hunk,
all-identical-content reindent: ~11.7s pre-fix vs ~6.1s after.

Fixed by tracking a read cursor per content bucket instead of mutating
the array — each consumption becomes an O(1) map lookup plus array
index, with no shifting. Every previously-passing scenario (six repro
tests across this PR and the one before it) still produces identical
results.

Added a dedicated large-hunk-with-repeated-content performance test,
separate from the existing large-unique-content one, since that one
doesn't build large buckets and can't catch this specific cost.
Code review finding on this PR: the BLEU fuzzy-match fallback gated the
ENTIRE pass on the total unmatched-old x unmatched-new product against a
fixed cap (FUZZY_MATCH_SEARCH_CAP = 200,000). Past that cap, the whole
fallback was skipped rather than searching less — so a hunk with enough
purely-unmatched lines (e.g. a uniform field rename applied throughout a
large AI-generated block, ~450 lines on each side with none exact-
matching post-rename) lost attribution for the ENTIRE hunk, not just the
lines a bounded search couldn't resolve. This is exactly the case the
fallback exists to handle, so the cap turned a precision trade-off into a
functional regression: confirmed empirically with a 500-line uniform
rename (500*500 = 250,000, over the cap) — only the 2 untouched lines
(def/return) kept attribution; the 500 renamed lines were dropped
entirely.

Replaced the total-size gate with a fixed-size search window per old
line: a restructuring or in-place rename rarely moves a line far from its
proportional position in the hunk, so each unmatched old line searches
outward from its expected position (estimated by linear scaling across
the hunk) instead of scanning every remaining candidate. This bounds cost
per line regardless of hunk size — no all-or-nothing cliff — while still
giving every old line a real, local search.

The window alone reintroduced a different cost: always scanning the full
window even after finding an obviously-correct match at offset 0 (the
common case for an in-place rename) made a 5,000-line rename take ~67s.
Added an early exit: once a match already above the significance
threshold has gone unbeaten for 20 consecutive offsets, stop searching.
Brought the same case down to ~3.5s while keeping identical results.

Verified: the 500-line rename now correctly attributes all 502 lines
(previously only 2). Re-verified all six repro scenarios from this PR and
#139 still produce identical results, and the two existing performance
tests (large unique-content and large repeated-content hunks) are
unaffected. Added a committed regression test for this exact scenario.
@EsmeYi EsmeYi changed the title Fix hunk-boundary and chain-adjacency attribution bugs Fix attribution and performance bugs in hunk-level significance matching Aug 16, 2026
EsmeYi added 4 commits August 16, 2026 15:57
Code review finding on this PR: the fixed-size proportional estimate
assumes a hunk's line-count change is spread evenly across it. When it's
actually concentrated at one end — e.g. a block of lines inserted right
before a large renamed section — an old line near that end gets an
estimate off by roughly the insertion size. Combined with the early exit,
if something else in the hunk happens to be near-duplicate content
(templated/generated code often is), the search could settle on that
wrong occurrence before ever reaching the real match, which sits further
out but is still within the window. Not a missed match — a wrong one.

Two changes:

1. Estimate the expected position by interpolating between the nearest
   exact-match anchors on either side of the gap (already found by the
   earlier exact-match pass) instead of a single global proportional
   scale. An anchor's position is exactly correct by construction, so
   this corrects for insertions/deletions concentrated anywhere in the
   hunk, not just ones spread evenly. Falls back to the hunk's own
   boundaries when there are no anchors nearby (a hunk with no exact
   matches at all, which is exactly the case this whole fallback exists
   for).

2. Scale the early-exit patience by the local line-count drift for that
   gap (nextAnchorNew - prevAnchorNew vs nextAnchorOld - prevAnchorOld),
   so the search can't settle before at least reaching the position the
   gap's own count change implies — and prefer the later candidate on an
   exact score tie, so that when a nearby wrong candidate and the position
   accounting for the drift score identically, the one that reflects the
   actual shift wins instead of whichever was found first.

Verified with a repro that isolates the mechanism: 50 lines inserted
right before a 500-line renamed block, reusing indices 0..49 so the
inserted lines become byte-identical to the first 50 lines of the real
(shifted) block — the sharpest version of "templated lines similar
enough to collide". Before this fix, all 50 of those lines were pulled
onto the inserted duplicate instead of the real block; after, none are.
Re-verified all seven prior repro scenarios and all three performance
tests in this PR still produce identical results.
…ontent

Code review finding on this PR: the previous commit's tie-break (prefer
the farther-explored candidate on an exact score tie, so a real match
found after accounting for drift wins over an earlier, wrong one) treated
every tie as "progress" and reset the patience counter. A hunk where
every old line is identical to every other — a real pattern: the same
templated statement repeated verbatim thousands of times — and gets
uniformly renamed means every old line's fuzzy search sees thousands of
candidates that all score identically. Patience never accumulates, so
every single line scans the full search window regardless of the
patience setting. Confirmed empirically: ~26s for a 5,000-line uniform
rename where every line is identical, versus ~0.4-3.5s for the earlier
tests that used unique-per-line content and never actually exercised a
long run of ties.

Fixed by separating "does this become the new pick" from "does this count
as progress": a strict score improvement does both (updates the pick,
resets patience). An exact tie still updates the pick — preserving the
drift-direction tie-break from the previous commit — but does NOT reset
patience, so a long run of tied candidates no longer defeats the exit.

Brought the 5,000-line uniform-rename case down to ~2s (from ~26s) while
keeping correctness: re-verified the previous commit's drift/tie-break
repro (50 lines inserted before a 500-line rename, duplicate indices)
still has zero misattribution, and all other repro and performance
scenarios in this PR are unaffected. Added a dedicated regression test
for this exact case.
Code review finding on this PR: always preferring the farther-explored
candidate on an exact score tie was only correct when the gap actually
had a size change to account for. When it didn't (no insertion/deletion —
oldCount === newCount for the gap, an in-place rename with no lines
added or removed), the estimate is already exactly right, and "prefer
farther" has no justification — it just walks every tied pick outward
toward the edge of the search window for no reason. Concretely: 1,000
identical lines "record = load(user_id=value)" uniformly renamed to
userId — old line i's correct position is new line i, but tie-breaking
toward "farthest explored" pushed every pick to roughly i + patience,
losing the earliest lines entirely (repro showed the lowest attributed
line at 4 instead of 1, and 991/1002 lines instead of 1002).

Fixed by making the tie-break target the position implied by the gap's
own SIGNED line-count change (positive for a net insertion, negative for
a deletion, zero when nothing shifted) instead of unconditionally
preferring "farther". On a tie, the candidate whose offset from the
estimate is closest to that signed drift wins — closest to the estimate
itself when nothing shifted (fixing this bug), and still closest to the
drift-corrected position when something did (preserving the previous
commit's fix for the insertion case). Ties still don't count as progress
for the patience counter, so the earlier fix for long tie-runs stalling
the search is unaffected.

Verified: the no-drift uniform-rename case now attributes the full,
unshifted range [1, 1002] with no loss. Re-verified the drift/tie-break
insertion repro from two commits ago still has zero misattribution, and
all other repro and performance scenarios in this PR are unaffected.
Strengthened the existing large-uniform-rename test to check the
attributed line range (not just the count), since a count-only check is
exactly what let this regression through undetected.
Code review finding on this PR (confirmed as a real, mirror-image
regression of the previous commit's own fix): biasing ties toward
signedLocalDrift is a single number for the WHOLE gap — it has no way
to know WHERE within the gap an insertion or deletion actually happened.
The previous commit's own repro put the extra lines BEFORE the AI's
block, so biasing toward "+drift" happened to be correct. Mirrored the
same construction with the extra lines AFTER the block instead (50
lines, identical to the AI's own content, appended right after it): the
AI's 1,000 lines need no shift at all, but the same "+drift" bias walked
lines near the end of the block into the appended, non-AI region, since
that region is exactly as textually similar to the AI's own lines as the
AI's own lines are to each other. Confirmed empirically: 50 lines pulled
into the appended tail with the code as of the previous commit.

Root cause traced further than the tie-break alone: the anchor-
interpolated ESTIMATE itself already assumes a gap's size change is
spread proportionally across it, which is also wrong when concentrated
at one end — for old lines near that end, no tie-break fix alone could
undo an estimate that already overshoots.

Fixed by deciding, per old line, whether reaching for the gap's drift is
warranted at all — based on how many OTHER old lines share that exact
text. A rare old line (occurs only a handful of times) is what a real
"moved/renamed block vs. one stray duplicate elsewhere" situation looks
like (the previous commit's repro: exactly one decoy per rare index), and
both the estimate and the tie-break target account for the full drift for
those. Text that instead repeats many times over (uniform/templated
content) gets an estimate assuming NO shift (1:1 against the nearest
anchor) and ties prefer whichever candidate is closest to THAT — there's
no way to tell, from content alone, which of many identical occurrences
is this line's own, and assuming a shift risks walking into a same-
looking but unrelated region.

Verified against all three relevant scenarios: the new append-after
repro (0 lines pulled into the tail, previously 50), the original
insert-before repro from two commits ago (0 misattribution, unchanged),
and the no-drift uniform-rename case (still the full, unshifted [1,
1002] range). Re-verified all seven correctness repro scenarios and all
five performance tests in this PR still produce identical results.
@EsmeYi
EsmeYi merged commit 114641d into main Aug 16, 2026
1 check passed
@EsmeYi
EsmeYi deleted the fix/hunk-attribution-bleed branch August 16, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow navigation between sessions The extension shall indicate when multiple tasklets have resulted in changes to the same lines

1 participant