Where the review context lost, on the 13 PRs that lost

The quality arm of the PR benchmark found 13 pull requests out of 60 where a model reviewing the naive context (diff + every file it touches) named the defect being fixed and the same model reviewing the trace-mcp context did not. This page is the diagnosis of those 13, run before any tuning.

The expected answer was a distribution: some unresolved import edges, some symbols dropped under the bundle’s 8,000-token budget, some file never pulled in. The actual answer has no distribution in it.

Finding: the context contained no source code at all — in all 13

Dumping both arms’ prompts (bench-pr-context.ts --only <prs> --dump-prompts) and reading them shows the same thing 13 times out of 13. The trace-mcp arm’s “context bundle” was a list of signatures. Not truncated bodies, not partial bodies — zero lines of source, in every section, on every PR. In full, this was the entire context axios#11073 was reviewed from, after the diff:

=== Primary Symbol ===
[namespace] __module__:server — sandbox/server.js
(module body) sandbox/server.js

[function] requestHandler — sandbox/server.js
function requestHandler(req, res)
## Impact — call sites this change can break

The PR changes const pathname to let pathname. Deciding whether that is a fix or a regression requires seeing the later reassignment inside the function body — which the naive arm had and this arm did not. The pattern repeats: every one of the 13 judge notes describes speculation (“focuses on hypothetical scenarios”, “treats the fix as a behavior change”, “falsely claims XSS”), which is what a reader does when it has names and no code.

Root cause: a bare require() under ESM, swallowed by a catch

FileReadCache.readSymbolSource in src/tools/navigation/context-bundle.ts read file bytes through require('node:fs'). The package is "type": "module", so under real ESM that is a ReferenceError — caught by the catch {} next to it, cached as “file unreadable”, and reported as a bundle that assembled fine. assembleContext then degraded every item to no_source, its documented behaviour when a body is unavailable.

It worked in the two places we look:

It failed in exactly one place: a consumer that imports src/ as real ESM. The published package ships dist only (bindist/cli.js), so no install path reaches that code; the two consumers that do are scripts/bench-pr-context.ts under tsx — which is how the benchmark measured a context with the source removed — and pnpm serve, the contributor’s run-from-source path.

This is the second instance of the class; TRA-542 was the same bare require in dropDecisionRows, also behind a non-fatal catch, also reporting success.

What it cost

Both halves of the benchmark measured the degraded arm, so both numbers were wrong, in opposite directions.

The token number was too good. Re-running the same 60 PRs, same corpus, same pinned SHAs, with bodies restored:

  before after
median input tokens, trace arm 1,326 3,951
median saving 90.6% 70.5%
PRs where the index did not pay off 5 23
— of those, costing more than reading the files 0 13

The baseline arm is untouched (13,595 median, identical), which is the control this correction rests on.

Per PR, on the 13:

PR baseline trace before trace after saving before → after
axios/axios#11073 960 227 1,184 76% → −23%
axios/axios#11118 3,551 1,502 5,198 58% → −46%
expressjs/express#5555 46,454 524 1,550 99% → 97%
expressjs/express#7265 3,514 237 3,527 93% → −0%
honojs/hono#5236 44,246 3,813 8,594 91% → 81%
honojs/hono#5250 5,846 561 4,446 90% → 24%
honojs/hono#5283 19,517 1,583 4,751 92% → 76%
honojs/hono#5291 19,866 1,753 4,213 91% → 79%
pallets/flask#5808 8,617 1,331 2,040 85% → 76%
psf/requests#6806 6,017 995 3,207 83% → 47%
sindresorhus/got#2362 18,777 526 1,363 97% → 93%
sindresorhus/got#2454 55,721 2,502 6,075 96% → 89%
sindresorhus/got#2471 31,642 2,222 5,446 93% → 83%

The quality number was too bad. The re-run, same 60 PRs, same preregistered bars, same judge protocol. Each run is its own pair — the naive arm was re-measured alongside the trace arm, so the struck run’s numbers are only comparable to the struck run’s naive column, not to this one:

60 PRs struck run: naive struck run: trace re-run: naive re-run: trace
understood the change 65.0% 50.0% 65.0% 66.7%
false positives per PR 0.65 1.20 0.58 0.80
PRs only the naive arm understood 13 3
review latency, median 90.0 s 74.5 s 93.0 s 92.9 s

Read across the pairs: comprehension went from −15 pp to +1.7 pp, false positives from +0.55 to +0.22, and latency from 17% faster to level. The naive arm scored 65.0% in both runs — measured twice, independently, at the same value.

One row of the re-run needed a second attempt: honojs/hono#5283 failed with a transport error (claude exited 1) and was filled by re-invoking the same script, which re-runs only the directories with no cached judgement. Nothing inspects a row’s outcome before deciding to keep it — the retry closed a gap in the artifact, it did not re-roll a result.

Both bars are met (≤10 pp comprehension loss, ≤+0.5 false positives): the trace arm lands 1.7 points above the naive one, which the bar never asked for and which 60 pull requests cannot make significant — parity is the honest reading. The struck run’s latency advantage was the speed of a context with the code removed, and it is gone.

Why no test caught it, and what does now

Nothing asserted that a body ever reached the caller. The behavioural suite for this tool checked the result shape{ primary, dependencies, callers, totalTokens, truncated } — and the markdown branch was covered only by existence, not content. A signature-only bundle satisfies every one of those assertions.

Two gates were added:

Two further latent instances of the same bug were fixed while the rule was being written: python-modules.ts (readdirSync for src-layout detection, which silently returned “not a src layout” under ESM) and install-app.ts.

The other lesson: changed_symbol_readable measured nothing

The token benchmark reported changed-symbol readability of 100% in both arms — including on all 13 of these PRs, while the trace arm contained no code. The metric recorded a span whenever the bundle listed a symbol, never checking that the bundle carried its body. It was the one indicator that should have caught this and it was structurally incapable of it.

Fixed, not renamed (TRA-1100). get_context_bundle now reports a detail field ('full' | 'no_source' | 'signature_only') per symbol — the same classification assembleContext already computed internally but never surfaced. changed_symbol_readable and dependent_readable in scripts/bench-pr-context.ts now count a span as readable only when detail === 'full'; dependent_pointed is unchanged, since “named with a location” was always the honest claim for that one. The same field backs a cheap CI gate (tests/ci/context-bundle-body-coverage.test.ts) that fails on partial body loss, not just total loss — see the preregistration’s release gate section.

And once it measured something, it failed. Under the corrected definition the trace arm reads 71% against the naive arm’s 100%: 98 of 338 changed symbols across the corpus arrive without their bodies. That misses a preregistered quality floor, which now publishes as MISSED.

The cause is not what this page first guessed. 93 of the 98 are the bundle budget truncating — raising it to 64,000 brings all but five bodies through, at 8,000: 240 bodies and 72.7% saved; 16,000: 263 at 32.3%; 32,000: 299 at −0.1%; 64,000: 333 at −0.3%. The saving crosses zero while 39 bodies are still missing, so there is no budget on this corpus that buys full coverage and a token win. The missed floor is therefore a packing problem — which symbols get the budget — not a budget-size problem.

Whole-file nodes — the suspect named here on 2026-09-07, meaning a __module__ / <module> node or a document node whose body is the file itself — are 65 of the bodyless, and they thin out with budget like everything else, so they were never a separate structural cause.

5 bodies arrive missing at any budget, down from 29 measured before the containment fix below: most of what the higher budgets were buying back was budget the bundle had been spending on duplicate bytes. Five is small enough to read one by one. Per-symbol counts: benchmarks/pr-context/symbol-detail.json; reproduce with --symbol-detail and --bundle-budget. —

Second pass, same 13 PRs: the context was paying for the same bytes twice

Added 2026-09-07 (TRA-1141), after the correction above.

With bodies restored, 13 of the 60 PRs cost more than reading the files outright. Breaking those 13 prompts down by section — token counts per section of the dumped prompts, gpt-tokenizer, same pinned SHAs — puts the excess in one place. In 10 of 13 the “Primary Symbol” section alone was larger than the naive arm’s entire file dump.

The reason is containment. __module__:foo spans its whole file and note:Readme spans its whole document, and a changed-symbol review bundle asks for both the container and the functions or headings inside it — so the members’ bytes shipped twice. axios#11118 sent InterceptorManager.js as a module body and then again as three functions, a class and two methods. Two related cases cost as much: an entire markdown document inlined because a wikilink mentions the symbol (32% of got#2379’s prompt), and an entire test file inlined as a “caller” (94% of axios#11039’s excess).

get_context_bundle now emits a contained symbol once, inside the container that already carries it, and keeps whole-file and prose symbols in the dependency and caller lists as pointers rather than bodies. Same 60 PRs, same SHAs, same corpus:

  before after
median input tokens, trace arm 3,951 3,291
median saving 70.5% 72.7%
PRs costing more than reading the files 13 13
worst single PR −129.3% −62.4%

The loss count moved as well, 23 → 56, for a reason that has nothing to do with this table: the metric behind it changed in the same run. The two sections below are that story.

The 13 costliest PRs stay costlier, and that is structural rather than fixable: when the changed symbol is the module container, the bundle’s primary section is the file, so it can approach the cost of reading the file but never beat it, and the diff, callers and impact list sit on top. What changed is the size of the overrun — across those 13 prompts, 49,770 → 40,991 tokens.

What it cost on the quality side. Shaping a response without checking comprehension is the failure this whole page exists to prevent, so the two bundle versions were run head-to-head on the 13 PRs the change touched most: same judge protocol, same model, blind and order-randomised, arm A the old bundle and arm B the new one.

13 PRs old bundle new bundle
understood the change 69.2% 69.2%
false positives per PR 0.23 0.38
findings per PR 2.85 2.31

Comprehension is identical (9 of 13 each; 8 understood by both, one by each arm alone). The false-positive difference is two claims across 13 PRs — a number this sample cannot resolve, reported because it moved the wrong way, not because it means anything.

What the honest metric reads, now that it has been run

TRA-1100 landed detail and pointed both readability columns at it, in parallel with this work and without re-running the benchmark — so the published artifact still carried the figures the old metric produced. This is that re-run. Holding everything else constant, dependent_readable in the trace arm:

trace arm, dependent_readable value
published, listing counted as readable 58%
same bundle, honest metric 28%
new bundle, honest metric 22%

Thirty of those thirty-six points were the metric; six are real — bodies the new rules moved into the pointer list. dependent_pointed stays 100%: every dependent is still named with a location the agent can fetch. That trade is the change’s actual cost, and it belongs next to the 72.7%, not underneath it.

And the finding that costs the most: half the changed bodies never shipped

The same re-run moves the column that had read 100% since this benchmark was written. changed_symbol_readable counted a changed symbol as readable whenever the bundle listed it; scored against detail === 'full', on the same 60 pull requests:

60 PRs, trace arm published old bundle, honest new bundle, honest
changed_symbol_readable (median) 100% 67% 71%
PRs where the index did not pay off 23 56 56 — 42 truncated, 13 costlier, 1 marginal

Nothing about what the product serves changed between the first two columns. This is the same defect as the one at the top of this page, one level deeper: the budget was truncating changed-symbol bodies all along, and the metric recorded a span whenever the bundle listed the symbol. A third of the PRs never had the changed code in front of them.

The third column is this change, and it is the reason the median saving reads 72.7% rather than 75.2%. Deduplicating a member into its container is only free while the container’s body survives assembly; when the budget reduces the container to a signature, the member was the one thing that could still have fitted. The bundle now notices that and re-assembles with those members restored — which costs 2.4 points of saving and buys back four points of changed-symbol coverage against the old bundle, on top of the eleven the deduplication had cost.

The mechanism is visible in the extremes. axios#11119 edits a line of README.md; the changed symbol is the whole 24,000-token document, it cannot fit the primary category’s share of an 8,000-token budget, and what shipped was its first line. psf/requests#7371 fixes a typo in a comment inside a 30,000- token test module; what shipped was module tests/test_requests.py. Both are counted in the 72.7% median saving, and in both the saving is partly the cost of not sending the code — 349 tokens against 24,348, and 282 against 25,197.

This does not retract the token figure, which counts what the arms actually sent and is unchanged by the metric fix, and it does not contradict the comprehension parity measured in TRA-568 — that judgement was made on these same prompts. It does say the bundle needs a better answer than a signature when the changed symbol is larger than its budget: the sub-symbols the diff actually touched, or the hunks’ surroundings, rather than the container’s first line. That is TRA-1144, filed from this run, and it is now measurable because the metric finally moves when it happens.

Saying so when the budget refuses a body (TRA-1144, 2026-09-07)

Re-assembling with the restored members answers the case where there is something smaller to send. Some changed symbols have nothing smaller. axios#11119’s is a whole 24,000-token README.md; psf/requests#7371’s is a 30,000-token test module. No share of an 8,000-token budget delivers either, and sending them whole would buy the coverage column at a price that defeats the tool — so that ceiling stands.

What does not have to stand is how it was reported. The degraded entry shipped as (module body) tests/test_requests.py: one line, reading like the whole answer, indistinguishable from a symbol that genuinely has no body. It now carries the fact:

[namespace] __module__:test_requests — tests/test_requests.py
(module body) tests/test_requests.py
[body omitted — 29,847 tokens, over this section's budget]

Cost, same 60 PRs, same pinned SHAs, measured against master at the merge of the change above: median saving 72.8% → 72.7%, median trace tokens 3,286 → 3,291, +1,445 across the set — about 23 tokens on each of the 46 PRs that omit anything, nothing on the other 14. Changed-symbol coverage is unchanged at 71%, by design: the marker declares the loss, it does not repair it. The declaration sits inside the token accounting, so a longer degraded entry can consume budget a shorter one would not have; that is a property of the greedy assembler, not a defect, and it is what the +1,445 is.

The marker stays silent for the entries the bundle caps before the assembler runs — dependencies past MAX_FULL_SOURCE_DEPS, markdown files, whole-file containers. Those reach the same no_source tier with their source never read, so there is no omitted body to report, and a behavioural test pins the two routes apart.

This closes the third of the three answers TRA-1144 asked for. The first — send the sub-symbols the diff touched rather than their container — is the re-assembly rule above. The second, a line window around each hunk, was not built: get_context_bundle never sees the diff, and the sub-symbols the caller already asked for are the same information arriving through a channel that exists.

The comprehension result, which did not go the expected way

An implementation of the first answer, built in parallel with the one that shipped and discarded in its favour, was run head-to-head against the pre-fix bundle on the 13 PRs it moved most — the TRA-568 judge protocol, blind and order-randomised:

13 PRs before restoring members after
understood the change 46.2% 46.2%
false positives per PR 0.92 1.00
findings per PR 2.92 3.00

Not equal only on aggregate — identical row by row: all 13 landed on the same verdict in both arms (6 understood by both, 7 by neither, 0 by either alone). The check on the implementation that did ship reported 69.2% in both arms on its own 13 PRs. Different samples, same shape of answer: no movement.

So the coverage column’s climb from 50% to 71% has no comprehension result behind it yet. Both samples are underpowered and both were chosen for being hard, which does not settle whether the effect is absent or merely unmeasurable here — but until something does, the recovered bodies are worth reporting as delivery, not as quality.