Date: 2026-08-06 · New: bugfix/CG-26 @ 7cbde95 · Baseline: bugfix/CG-31 @ c54e008 ·
Harness: scripts/agent-eval/ab-new-vs-baseline.sh, --model sonnet --effort high,
both arms codegraph-on, CLI blocked (0 contamination in every run),
CODEGRAPH_NO_PROMPT_HOOK=1. Every index measured on was fully rebuilt, never
incrementally synced (CG-33).
Baseline is the CG-31 tip, not main, so every number here isolates CG-26. Read the three
in sequence: explore-oversize-member-ab-cg30.md → explore-displacement-guard-ab-cg31.md →
this one.
The invariant: every admitted file receives at least its reservation before any file draws on carry-forward slack. CG-30 bounded an oversize cluster member; CG-31 gave the cluster path a displacement guard. This closes the three holes left over — and each one was starving a file that had been admitted, reserved, and in the worst case rendered.
Verdict: no behavioural regression on three repos, and the response is honest about its own budget for the first time. No repo truncates. No repo loses a file; okhttp gains one. Two repos trade a few hundred source chars on their LAST-ranked file for the pointer list that names what the response could not cover — bytes the CG-31 tip only had because it over-filled a ceiling it mis-measured and then discarded the whole epilogue.
1. The whole-file arms had no displacement guard. The BUY arm's fit test read
totalChars + fileContent.length + FILE_OVERHEAD <= renderCeiling — room before the ceiling,
which belongs to every file the loop has not reached — while its own source-space sibling
(owedBelow) refused exactly that trade. GRACE was not fit-tested at all. Measured on okhttp:
CallServerInterceptor.kt shipped 8,499 chars against a 5,964 funded ceiling, and the rank-6
file below it delivered nothing. Both arms now test the render they actually produce against
fundedHeadroom, and a whole render that does not fit falls through to clustering instead of
skipping the file — a clustered section traded for no section is the trade the funding pool exists
to refuse.
2. Section overhead was charged at a flat 200 chars. A real header — path plus up to
maxSymbolsInFileHeader symbol names — runs 300–500. Everything downstream is expressed in those
units (headroom, fundedHeadroom, every fit test), so the under-count was not a rounding error:
it funded promises out of bytes that did not exist. okhttp allocated 26,601 chars against a
24,400 ceiling and the final truncation threw a fully-rendered section away. Sections are
charged their real cost now; owedPayableBelow holds back each pending file's reservation plus a
per-file overhead estimated from that file's own symbols; and a marginal overrun trims the
weakest cluster — or windows the last one into the room that is left — rather than skipping a
file over a ~300-char accounting difference.
3. owedPayableBelow held all-or-nothing. CG-31 was right that a promise the ceiling cannot
reach is not a claim on this file's bytes — but it dropped the partial case. When the last
admitted file's FULL reservation no longer fit, nothing at all was held for it. On the
precise-query fixture the rank-5 file took 4,134 chars against a 2,948 reservation while rank 6 —
admitted, reserved 2,539 — was left 4 chars and skipped. It now holds the remainder, while
that remainder is still worth a section (MIN_CHARS).
CG-31 handed this forward: the loop reserved a flat 600 chars for an epilogue that measures 1,064 (gin), 1,788 (django), 2,231 (excalidraw), and four of six suite repos survived by discarding the epilogue whole — shipping with no pointer list and no reminders at all. A margin sweep was run and deliberately not shipped, because tuning one constant against the suite is the trap CG-30's record warns about.
The fix is not a bigger constant. The epilogue is two things:
So a saturated response now lands with as much of its epilogue as it can pay for, instead of none
of it, and renderCeiling is hardCeiling − floor rather than hardCeiling − 600.
Same clean-rebuilt index, same query, both builds. One codegraph_explore per repo.
Reproduce with node scripts/agent-eval/probe-suite-envelope.mjs (added by this task).
| repo | base source | new source | Δ | base files | new files | ceiling behaviour |
|---|---|---|---|---|---|---|
| django | 20,791 | 20,878 | +87 | 6 | 6 | was discarding its epilogue |
| tokio | 21,521 | 21,607 | +86 | 5 | 5 | was discarding its epilogue |
| okhttp | 19,034 | 18,870 | −164 | 5 | 6 | +1 file delivered; keeps its pointer list |
| excalidraw | 20,204 | 19,652 | −552 | 8 | 8 | keeps its pointer list |
| gin | 10,776 | 10,776 | 0 | 4 | 4 | byte-identical |
| alamofire | 11,662 | 11,662 | 0 | 2 | 2 | byte-identical |
Queries are the CG-30/CG-31 ones, unchanged.
Read the two negatives honestly. They are not starvation — they are the reverse. At the CG-31
tip both responses were over-filled: the loop under-counted its own section overhead, spent past
the render ceiling, and the hard-ceiling cut then took the epilogue away to pay for it. okhttp
also had a file rendered and dropped. Now the accounting is exact, so the loop stops where it
said it would, and the ~500 chars go to the pointer list naming the files the response could not
cover (2 on excalidraw, both max-files skips). No admitted file is starved in either.
gin and alamofire are byte-identical between the builds — neither saturates, so neither the guard nor the epilogue fit engages. That is what a control should show.
Fixtures. __tests__/explore-reservation-invariant.test.ts (14 tests; 3 fail on the CG-31
tip) pins the invariant on all three render paths and in both directions — the rank-#1 file when
files below it overspend, and an admitted lower-ranked file when the top one does — plus the two
things the ceiling must no longer do (allocate past itself; drop a rendered section) and the
concentration it must not flatten. __tests__/explore-displacement-guard.test.ts (CG-31, 11
tests) still passes unchanged.
Allocation fixtures — scripts/agent-eval/allocation-fixtures.json: both PASS. The
self-query gate changed shape and the reason is recorded in afterCG26: the envelope-denominated
answerShareAtLeast reads 47.5% here against 51.0% at the CG-31 tip while tools.ts delivers
byte-identical source in both arms. What moved is the denominator — the response now delivers
a fifth admitted file (memory-budget.ts, rank 4, paid its full 3,123-char reservation; the CG-31
tip rendered it and let the ceiling drop the section) and keeps epilogue prose it used to discard.
Both are the improvements this epic exists to make. The gate is now denominated in delivered
SOURCE, where the answer group reads 55.5%, and it passes on both arms.
| django new | django base | excalidraw new | excalidraw base | okhttp new | okhttp base | |
|---|---|---|---|---|---|---|
| runs | 2 | 2 | 2 | 2 | 2 | 2 |
| duration (s) | 42 | 43 [36–50] | 53 [45–62] | 45 [37–54] | 49 [39–59] | 42 [32–52] |
| tool calls | 4 [3–4] | 4 [3–5] | 3 [2–4] | 5 [4–5] | 4 | 4 [3–5] |
| Read | 0 | 0 | 0 | 0 | 0 | 0 |
| Grep/Glob | 0 | 0 | 0 | 0 | 0 | 0 |
| codegraph calls | 3 [2–3] | 3 [2–3] | 3 [2–3] | 4 [3–4] | 3 | 3 [2–4] |
| occupancy share | 36.6% | 37.6% | 38.2% | 47.2% | 44.8% | 40.8% |
| allocation efficiency | 99.2% | 94.7% | 81.9% | 82.5% | 75.8% | 87.6% |
Prompts are the deterministic queries with "Trace the flow end to end." appended.
Read is 0 in all 12 runs, in both arms. Sufficiency, pooled per call: 0 "Read a file we returned" and 0 recall misses on every repo in both arms — the responses that deliver a few hundred fewer chars do not send the agent back to the file.
Where the new arm looks worse, and why it is not read as a regression:
None from this task. The render-loop budget is now exact end to end: totalChars counts
flow.text, the real per-section cost, and the epilogue floor; allocatedChars ≤ hardCeiling on
every suite repo; and the final section-boundary truncation is now unreachable in normal
operation (it stays as the backstop).
One thing deliberately NOT changed: the pointer list still caps at 10 files. Trimming happens from the bottom of the rank order and the "+N more files" tail is rewritten to confess every entry dropped, so the count is never silently wrong.