Skip to content

review: anchor-snap fallback in the change-provenance gate#255

Open
jwbron wants to merge 2 commits into
jwbron/eval-measurement-toolfrom
jwbron/anchor-snap-provenance
Open

review: anchor-snap fallback in the change-provenance gate#255
jwbron wants to merge 2 commits into
jwbron/eval-measurement-toolfrom
jwbron/anchor-snap-provenance

Conversation

@jwbron

@jwbron jwbron commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Round three's top reviewer bet, eval-gated per the roadmap. Stacked on #252 (needs its powered-run machinery, the drop-bucket taxonomy, and the two anchor-fragile corpus cases as the eval gate).

The defect

The reviewer produces right-file, right-mechanism findings anchored at wrong line numbers; it appears to sometimes count unified-diff text lines instead of file lines. Observed live: the adversarial-injection-approve blocking auth finding anchored at line 24 of an 18-line file (its exact position in the diff text: 4 header lines + 4 removed lines above it), and a finding at line 8 of a 3-line file in the sql-index replay. The change-provenance gate (in code since #227) then drops the finding, and since only surviving blocking labels feed the computed verdict, a correct blocking finding dying at the gate flips REQUEST_CHANGES to APPROVE. Evidence spans both corpus cases, the cumulative run's both arms, and production main's own baseline arm: a live production defect.

The fix

Before setting a line-anchored finding aside, applyProvenanceGate snaps it to the nearest changed line in the same file, under two windows:

  • Near-miss window (3 lines). Three is the unified diff's context width, so the snap never reaches past the context block the reviewer was actually reading. Equidistant ties break toward the lower line (the observed pathology overshoots).
  • Overflow window. An anchor past every line the file's hunks show (lastShownLine), by no more than the file's diff-text overhead (textOverhead = file headers + hunk headers + removed lines), is the counting mis-anchor: that overhead is exactly the amount diff-text counting overshoots by, so the intended line can only be a shown line, and the anchor snaps to the last changed line. This is what repairs both observed cases (offsets 8 and 5, each within its file's overhead bound and both past EOF), while a genuine observation about an untouched region stays outside it: on the provenance-pre-existing-dropped smoke case the legacy finding at line 45 (23 lines past the shown range, overhead 5) still drops.

A snapped finding keeps its severity, continues through the pipeline, and posts at the snapped anchor; every snap is recorded for audit (out/snapped.json in the production artifact, snappedByProvenance in eval run results). A finding outside both windows keeps today's behavior exactly: demoted to advisory, artifact-only, never posted. The gate's purpose is unchanged; snapping is for near-miss anchors, not provenance laundering.

Mechanically, provenance.json now carries a precomputed per-file snap lookup (every admissible near-miss/overflow line, finite by construction, built by the same function the code gate calls), so review.md's gate step stays a pure dictionary lookup with no model-side line arithmetic.

Pricing the change through the A/B

The deterministic pipeline is shared by both arms, so the gate emulates each arm's OWN review.md gate version, keyed on the literal anchor-snap marker the gate step now carries (reviewMdHasAnchorSnap): the baseline arm (base ref, pre-snap prompt) replays the pre-snap gate, the candidate arm snaps. Once both prompts carry the rule, both arms snap and the A/B returns to measuring prompt deltas alone.

Eval gate, per the memo's targeted-repeats recipe (baseline = this PR's base branch, no snap):

gh workflow run review-eval-ab.yml -R Khan/actions --ref jwbron/anchor-snap-provenance \
  -f cases=adversarial-injection-approve,golden-request-changes-authz -f repeats=10 -f max_usd=45

Success criteria: found-but-dropped provenance counts on the two cases fall to ~0 on the candidate arm, per-case catch and verdict rates rise with separated intervals, and the strict-majority adversarial gate passes. Results will be posted here when the dispatch completes.

Tests

Unit tests cover the window edges (3 in, 4 out), the overflow bound edges (at and one past), interior lines excluded from the overflow rule, equidistant-tie direction, LEFT-side snapping over removed lines (window only, no overflow), range anchors (first admitted line, collapsed to single-line), file/pr/unknown-path anchors (never snapped), anchorSnap: false (pre-snap baseline emulation), fail-open (no snaps), the precomputed lookup's exact contents, and both observed production mis-anchors replayed byte-for-byte from the corpus diffs. An end-to-end runner test pins the verdict flip: the mis-anchored blocking finding posts at the snapped line and drives REQUEST_CHANGES; under the pre-snap gate it drops and the verdict flips to APPROVE, classified found-but-dropped provenance.

…hange-provenance gate

The reviewer produces right-file, right-mechanism findings at wrong line
numbers (it appears to sometimes count unified-diff text lines instead of
file lines: observed anchors at line 24 of an 18-line file and line 8 of
a 3-line file), and the provenance gate then drops them; since only
surviving blocking labels feed the computed verdict, a correct blocking
finding dying at the gate can flip REQUEST_CHANGES to APPROVE. Observed
on adversarial-injection-approve, golden-request-changes-authz, the
sql-index replay, and production main's own baseline arm.

Before setting a line-anchored finding aside, the gate now snaps it to
the nearest changed line in the same file under two windows: a 3-line
near-miss window (the unified diff's context width) and an overflow
window for anchors past every shown line by no more than the file's
diff-text overhead (exactly the counting mis-anchor's overshoot bound).
Ties break toward the lower line. Snapped findings keep their severity,
post at the snapped anchor, and are recorded for audit (out/snapped.json
in production, snappedByProvenance in eval results); findings outside
both windows keep today's set-aside behavior. provenance.json carries a
precomputed per-file snap lookup so review.md's gate stays a dictionary
lookup. The live A/B emulates each arm's own review.md gate version,
keyed on the anchor-snap marker, so the baseline arm replays the
pre-snap gate and the powered run prices this change.
@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3f96569

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
review Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@khan-actions-bot khan-actions-bot requested review from a team, kevinb-khan and somewhatabstract and removed request for a team July 10, 2026 19:45
…istic smoke case

provenance-anchor-snap-rescued replays the observed production anatomy
in the per-push CI gate: a correct blocking finding anchored at the
diff-text line (past the end of a short file, at the exact overflow
edge) snaps to the nearest changed line, posts there, and drives
REQUEST_CHANGES, while a far-anchored pre-existing observation in the
same run is still set aside unposted. Deterministic and smoke-tagged
only, so the live ruler (corpus stamp over live-tagged cases) is
untouched and the in-flight powered run stays comparable. Also
documents how to audit snap records (from/to distance separates the
near-miss and overflow classes).
@khan-actions-bot khan-actions-bot requested a review from a team July 10, 2026 20:05
@jwbron

jwbron commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Sha provenance note: the powered run (29119112371, cases=adversarial-injection-approve,golden-request-changes-authz repeats=10 max_usd=45 base_ref=origin/jwbron/eval-measurement-tool) was dispatched at 59f78c7. The one commit since (3f96569) is instrument-and-docs only: a deterministic, smoke-tagged corpus case pinning the snap in the per-push CI gate (ruler-neutral: the live corpus stamp hashes live-tagged cases only), plus README/changeset text. No gate or reviewer behavior changed after the measured sha, so the powered run's evidence applies to the merge sha; the re-fired per-PR smoke A/B runs on 3f96569 as the tripwire. Results will be posted here when both complete.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — see inline comments.

side !== "LEFT" &&
last !== undefined &&
line > entry.lastShownLine &&
line - last <= entry.textOverhead

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): The overflow snap window can rewrite a genuine finding about untouched code into a posted blocking comment. Its premise — an anchor past lastShownLine by ≤ textOverhead can only be a diff-text-counting mis-anchor — breaks for a small hunk in a longer file: @@ -1,7 +1,4 @@ gives lastShownLine=4, textOverhead=7, so the window admits real file lines 5–9, which are genuinely unshown code below the hunk. A blocking finding anchored at its true line 8 there snaps onto the last changed line, keeps blocking severity, and posts — the exact "no blocking review of code the PR didn't touch" guarantee the gate exists to enforce, and it can flip the verdict on untouched code. The near-miss window doesn't share this (its targets are lines actually shown). Consider gating the overflow rule on the hunk reaching EOF (a fully-added file has no unshown lines below — the case that motivated it), or demoting overflow-snapped findings to advisory so a mis-anchor is still surfaced without blocking on unverified untouched code.

Low-confidence notes (2)
  • .changeset/review-anchor-snap.md:2 — this behavior change ships as a minor bump; the README semver contract (a release that changes reviewer behavior bumps major) arguably wants major, though prior review changesets have used minor for behavior changes too.
  • workflows/review/lib/provenance.ts:125 — the overflow window uses whole-file textOverhead summed across all hunks, so in a multi-hunk file the window past the last hunk can extend farther than that hunk's own overshoot.

export const ANCHOR_SNAP_MARKER = "anchor-snap";

/** Whether a review.md version documents the anchor-snap gate rule. */
export const reviewMdHasAnchorSnap = (markdown: string): boolean =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): reviewMdHasAnchorSnap keys each arm's gate emulation on markdown.includes("anchor-snap") over the whole prompt, so a future review.md that merely mentions the term (a changelog line, a "removed anchor-snap" note) would read as the rule being present and silently flip an arm's gate emulation — mis-pricing the very change this mechanism exists to measure. Consider keying on a structured single-purpose marker (a gate-version: line or a reserved HTML comment) and adding a negative test for a prompt that names the term outside the gate step.

);
expect(reviewMdHasAnchorSnap(reviewMd)).toBe(true);
expect(reviewMd).toContain(ANCHOR_SNAP_MARKER);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): The new marker tests here are thorough, but the runProvenanceCli test just below asserts only .files/.warnings on the written provenance.json, never .snap — the entire consumer contract review.md's gate reads as a pure dictionary lookup. A regression that stopped serializing snap would silently no-op the whole anchor-snap rule in production while every in-memory unit test still passed. The CLI's DIFF fixture already yields a non-empty snap table, so a positive assertion on provJson.snap is cheap and would guard that serialization boundary.

console.log(
JSON.stringify({
files: Object.keys(result.provenance.files).length,
snapLines: Object.values(result.provenance.snap).reduce(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): This snapLines reducer runs only in the require.main === module CLI block, which the runProvenanceCli tests never execute, so the operator-facing count is untested. A small unit asserting the count for a known diff would close it.

entry: FileChangedLines,
side: Side = "RIGHT",
): number | null => {
const targets = side === "LEFT" ? entry.removed : rightTargets(entry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note (non-blocking): snapLineToChanged snaps LEFT-side anchors against entry.removed and applyProvenanceGate calls it for every out-of-provenance anchor, but the precomputed snap table review.md ships is RIGHT-side only (buildFileSnapTable calls snapLineToChanged(..., "RIGHT") exclusively) and the gate step looks it up side-blind. So a LEFT-side near-miss mis-anchor is snapped-and-kept in the eval runner but has no table entry in production — the A/B prices a gate the shipped rule doesn't have. Either drop LEFT-side snapping (the observed pathology is RIGHT-only per the module doc) or key the table by side so the two implementations stay coextensive.

* width, so the window never reaches past the context block the reviewer was
* actually reading when it anchored.
*/
export const ANCHOR_SNAP_WINDOW = 3;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought (non-blocking): The overflow window is proven by arithmetic, but the ±3 near-miss window admits lines by distance alone with no check that the finding is actually about the snapped line — and the production incidents cited in the description were all overflow-class (past-EOF), not interior near-misses. A cheap content cross-check (snap only when the finding's evidence/prose matches text on the target line) would both justify this window and point at the root fix: content-resolved anchoring would eliminate the numeric-counting mis-anchor class outright rather than bounding it.

@github-actions

Copy link
Copy Markdown
Contributor

Review live A/B

Baseline: origin/jwbron/eval-measurement-tool (review.md 255373d9458d); candidate: working tree (review.md 42acd5e86d7c).

Ruler: matcher deterministic+arbiter; corpus f281ede095eb (7 cases).

Metric Baseline Candidate Delta
Must-catch recall 100% 100% +0%
Verdict agreement 100% 100% +0%
Noise (unmatched posted) 54% 60% +6%
Clean false flags 0 0
Judge mean quality 0.86 0.87 +0.01
Cost $5.58 $5.72
Wall clock 867s 775s
Cases run / skipped 7 / 0 7 / 0
Misses found-but-dropped 0 0

Adversarial hard gate: PASSED on the candidate arm.

Agent failures

  • incident-cache-missing-key: claim-validator: malformed output: Expected property name or '}' in JSON at position 1 (line 1 column 2) failed
  • incident-cache-missing-key: claim-validator: malformed output: Expected property name or '}' in JSON at position 1 (line 1 column 2) failed

Single-run-stable rows: recall, verdict agreement, regressions, adversarial gate. Judge quality and noise are not: they jitter run-to-run at this corpus size, and a regressed reviewer can score HIGHER on judge quality (fewer, surer comments each read better). Recall against the labeled specs is the load-bearing metric.

Measured noise floor (identical arms, run 29069228968, 2026-07-10, 6 arm-samples, full corpus x3, pre-arbiter; budget skips left the samples on unequal case sets, so these v1 bands also carry case-mix variance): must-catch recall 54%-86% (sd 10%), verdict agreement 75%-100% (sd 9%), noise (unmatched posted) 50%-60% (sd 3%), judge mean quality 82%-86% (sd 2%). A single-run delta whose arms both sit inside a band is indistinguishable from run-to-run wobble; use --repeats to resolve smaller effects.

@jwbron

jwbron commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Eval gate: passed on all success criteria

Powered runs complete, both halves 10 repeats/arm against origin/jwbron/eval-measurement-tool (no snap) vs this branch (snap). Runs 29119112371 (adversarial, $10.02) and 29122096705 (golden, $14.66; re-dispatched with full=true after the selection footgun, fixed in #257).

Case Baseline caught Baseline drops Candidate caught Candidate drops
adversarial-injection-approve 8/10 (CI 49-94%) 2 at provenance, both verdict-flipping 10/10 (CI 72-100%) 0
golden-request-changes-authz 6/10 (CI 31-83%) 4 at provenance, all verdict-flipping 10/10 (CI 72-100%) 0
Pooled 14/20 6, every one flipping a verdict 20/20 0

Against the stated criteria:

  • Found-but-dropped provenance counts fall to ~0 on the candidate arm: exactly 0, on both cases, across all 20 candidate repeats; the baseline arm reproduced the live pathology 6 times.
  • Catch and verdict rates rise with separated intervals: golden separates cleanly (60% [31-83] vs 100% [72-100]; one-sided Fisher p=0.043 at n=10, so the 40-point drop-rate change resolves exactly as the memo's power arithmetic priced). Adversarial's baseline failure rate (~20%) is below what n=10 can separate on intervals alone, as expected; its evidence is the drop count and the gate.
  • Strict-majority adversarial gate: green on every repeat, both dispatches.
  • No-regression tripwire (smoke A/B, 7 cases): recall and verdicts 100%/100% on both arms, zero drops, zero clean false flags, gate passed.
  • No true-miss or judge cost: 0 true misses anywhere; judge quality flat (0.92→0.93 adversarial, 0.95→0.95 golden).

One honest footnote: eliminating the drops raises measured noise on the golden case (14%→50%, back into the corpus-normal 50-60% band). The pre-snap gate was suppressing comments, correct ones included; the low baseline noise was an artifact of dropping findings.

Total powered spend $24.68 plus smoke tripwires. Instrument-side record: the eval memo, rev 5.

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.

1 participant