Merge branch 'main' into feature/CG-35

This commit is contained in:
Colby McHenry
2026-08-06 21:17:56 -05:00
79 changed files with 8946 additions and 169 deletions
@@ -0,0 +1,164 @@
# Deterministic measurement — cluster starvation inside one file (task CG-36)
**Date:** 2026-08-06 · **Baseline:** `feature/CG-24` @ `76ab1fe` (the CG-24 epic tip) ·
**Harness:** `scripts/agent-eval/probe-file-spend.mjs` and `probe-suite-envelope.mjs` over the
deterministic 6-repo corpus at `/tmp/codegraph-corpus`, clean full-rebuilt indexes (CG-33), plus
two hermetic fixtures through `probe-allocation.mjs`. No agent A/B: the claim is which bytes go
to which file, and the agent runs are far too noisy to see a 2K shift.
**Verdict: the defect is real, the diagnosis in the issue was half wrong, and the fix holds.**
All 8 starvation flags across the suite clear; net **+1,012 source chars**. One repo (okhttp)
trades its weakest file for +7,196 chars in the two that answer the question — stated in full
below rather than smoothed over.
---
## The defect
A file's ranked clusters were all-or-nothing past the first one. The top-ranked cluster was
always taken — shrunk to the highest-importance whole symbol ranges that fit when it overran —
and every cluster below it was rendered **whole** and then either fit the remainder or was
dropped entirely. On a file whose top-ranked cluster is trivial, that discards the answer.
What made it invisible: the response stays FULL. The unspent reservation carries forward exactly
as CG-31 designed, so a lower-scoring file takes the bytes and every envelope-share measure still
reads healthy. Measured on the epic tip:
| repo | file | score | reserved | spent | share |
|---|---|---|---|---|---|
| django | `db/models/sql/query.py` | 83 | 7,947 | 1,923 | **24%** |
| django | `contrib/admin/filters.py` | 18 | 2,271 | 8,057 | **355%** |
| okhttp | `.../RealInterceptorChain.kt` | 86 | 6,058 | 1,474 | **24%** |
| okhttp | `.../CallServerInterceptor.kt` | 20 | 1,974 | 5,832 | **295%** |
A score-83 file spends a quarter of its reservation while a score-18 file takes 3.5× its own.
## What the issue got wrong
The issue named two candidate fix points and suspected the first: cluster ranking breaks ties on
**density** (`score / span`) after `hasSpine``maxImportance`, which structurally favours a
small trivial cluster over a large answer-bearing one. Dumping the cluster set says otherwise —
in **both** real cases the loser lost on `maxImportance`, not on density:
```
django/db/models/sql/query.py budget 10,135 spent 1,923
KEPT 13791400 span 22 score 14 maxImp 6 (check_related_objects — a glue symbol)
DROPPED 306 929 span 624 score 290 maxImp 3 (133 members: the whole Query class)
okhttp .../RealInterceptorChain.kt budget 6,058 spent 1,474
KEPT 16 44 span 29 score 44 maxImp 6 (package decl + import block)
DROPPED 113 373 span 261 score 171 maxImp 3 (73 members: the chain itself)
```
`maxImportance` first is deliberate and protective — it is what stops Alamofire's `Session.swift`
from losing its budget to the top-of-file property list — so **ranking was not touched**. The
lever is the second fix point: stop dropping the loser whole.
## The change
Two sites, one rule — *hold the remainder while it is still worth a section*, which is CG-26's
between-FILES lesson applied between CLUSTERS.
1. **Selection.** A later cluster is now shrunk into what is left of the file's budget, by the
same whole-member rule the first cluster already used. Below `MIN_CHARS` (700) the remainder
cannot hold a readable block, so it stays a drop rather than a stutter of fragments the next
call's dedup has to shred around. The never-empty windowing floors may overrun that room; the
first cluster is allowed that overshoot, a later one is not.
2. **The ceiling trim.** When the exact section cost overruns `renderCeiling`, the weakest chosen
cluster is re-rendered into the room that remains before being dropped. This one is worth
naming on its own: on excalidraw's `typeChecks.ts` the section-cost estimate missed by
**13 chars** and a 1,512-char cluster — the file's highest-*scoring* one, last in rank order
only because rank breaks ties on density — was thrown away to pay for it. Recovered 1,501 of
excalidraw's 1,449-char loss.
## Suite result
`node scripts/agent-eval/probe-file-spend.mjs`, 6 repos, clean rebuilds. Only files whose spend
moved are listed; score is the candidate's ranking score, reserved its allocation.
| repo | file | score | reserved | before | after |
|---|---|---|---|---|---|
| django | `db/models/sql/query.py` | 83 | 7,947 | 1,923 | **10,082** |
| django | `contrib/admin/filters.py` | 18 | 2,271 | 8,057 | 2,198 |
| django | `utils/autoreload.py` | 12 | 1,747 | 3,145 | 1,709 |
| django | `db/models/fields/related_descriptors.py` | 11 | 1,660 | 2,516 | 1,493 |
| excalidraw | `element/src/typeChecks.ts` | 23 | 2,740 | 3,102 | 2,573 |
| excalidraw | `excalidraw/types.ts` | 14 | 1,942 | 819 | 1,372 |
| okhttp | `.../RealInterceptorChain.kt` | 86 | 6,058 | 1,474 | **6,038** |
| okhttp | `.../Interceptor.kt` | 64 | 4,697 | 2,027 | **4,659** |
| okhttp | `.../RealCall.kt` | 52 | 3,972 | 3,628 | 3,922 |
| okhttp | `.../Call.kt` | 54 | 4,097 | 4,097 | 2,073 |
| okhttp | `.../CallServerInterceptor.kt` | 20 | 1,974 | 5,832 | 1,959 |
| okhttp | `androidMain/.../AndroidDns.kt` | 21 | 1,999 | 1,812 | **0** |
| tokio | `task/local.rs` | 40 | 4,361 | 4,599 | 4,798 |
| tokio | `runtime/task/harness.rs` | 14 | 1,981 | 2,565 | 2,341 |
| gin | `routergroup.go` | 87 | 5,782 | 3,273 | **5,632** |
| gin | `tree.go` | 17 | 1,693 | 892 | 1,969 |
| gin | `ginS/gins.go` | 26 | 2,213 | 4,431 | 2,171 |
| alamofire | `Source/Core/Session.swift` | 34 | 2,792 | 2,797 | 3,396 |
| alamofire | `Source/Core/Request.swift` | 148 | 9,100 | 8,865 | 8,453 |
Bytes move up the score order in every repo. Envelope totals:
| repo | source before | after | Δ | files | ceiling |
|---|---|---|---|---|---|
| django | 20,878 | 20,719 | 159 | 6 → 6 | 24,963 ≤ 25,000 |
| excalidraw | 19,652 | 19,704 | +52 | 8 → 8 | 24,813 ≤ 25,000 |
| okhttp | 18,870 | 18,651 | 219 | 6 → **5** | 24,985 ≤ 25,000 |
| tokio | 21,607 | 21,582 | 25 | 5 → 5 | 24,777 ≤ 25,000 |
| gin | 10,776 | 11,952 | **+1,176** | 4 → 4 | 14,655 ≤ 19,500 |
| alamofire | 11,662 | 11,849 | +187 | 2 → 2 | 12,862 ≤ 19,500 |
| **total** | **103,445** | **104,457** | **+1,012** | | |
Starvation flags: **8 → 0**.
## The one cost, stated plainly
okhttp drops its rank-6 file, `androidMain/.../AndroidDns.kt` (score 21, a platform DNS helper on
a question about the interceptor chain), and 219 source chars, in exchange for +4,564 to
`RealInterceptorChain.kt` and +2,632 to `Interceptor.kt` — the two files that answer the question.
This is not a new defect and it is not the fix over-reaching. okhttp's reservations are
**structurally over-subscribed**: the allocator splits `maxOutputChars` charging a flat
`FILE_OVERHEAD` of 200 per file while a real header runs 300500, so the sum of promises
(~22,800 source + ~2,100 of real headers) exceeds what the ~24,760-char render ceiling can hold.
`owedPayableBelow` already refuses to hold bytes back for a file it can see will be dropped, and
AndroidDns.kt is the file past that line. On the epic tip it survived only because the files above
it under-spent — by luck, not by design. Closing the over-subscription means charging the
allocator per-file header estimates rather than the flat 200; that is a wider change than this
issue, and CG-26 deliberately kept `FILE_OVERHEAD` as the allocator's own constant.
## What did NOT change
- **Cluster ranking.** `hasSpine``maxImportance` → density → score → span, untouched.
- **Alamofire `Session.swift`.** The shape density-first exists for; it *gains* 599 chars.
- **The factory-closure outcome (CG-27).** `probe-factory-closure.mjs`: 7 of 11 inner closure
definitions delivered, identical to the epic tip.
- **The reservation invariant (CG-31/CG-26).** `explore-reservation-invariant.test.ts` green;
every repo stays at or under its hard ceiling.
- **All four allocation fixtures pass** — `payroll-go`, `self-query`, and the two added here.
## What ships so this stays measurable
- `scripts/agent-eval/probe-file-spend.mjs` — the standing per-file reservation-vs-delivered
sweep. It flags a **pair**, never a single file: a large share unspent *while* a materially
lower-scoring file overspends. Either alone is legitimate (a small file has less to say;
carry-forward is the mechanism that hands its slack down), which is why the envelope probe
could never see this. Exit code 1 on any flag, so it gates.
- `__tests__/fixtures/starved-cluster-ts/` — django's and okhttp's shape reduced to a fixture.
Fails on the epic tip (28.8% of reservation, neither `proceed` nor `writeAndRead` delivered),
passes with the fix.
- `__tests__/fixtures/dense-header-ts/` — the `Session.swift` shape, byte-identical on both
builds. The counterweight: it fails if a future change lets density outrank importance again.
- `spendShareAtLeast` in `probe-allocation.mjs`, and `__tests__/explore-cluster-starvation.test.ts`
pinning both fixtures in `npm test`.
## Method note
None of this is visible in the rendered markdown. To see it you must dump the cluster set —
patch `dist/mcp/tools.js` just before `let assembled = assembleSection(chosenIndices);` and log
`fileBudget` / `projectedChars` / each ranked cluster's span, score, `maxImportance`, chosen flag
and members. Reading only the response makes member-selection effects look like budget effects.
Equally, a source-chars diff between builds is not automatically a regression: excalidraw's
1,449 on the first cut was the elastic epilogue expanding into room a 13-char accounting error
had released, not source lost to allocation.
@@ -0,0 +1,149 @@
# Deterministic measurement — declaration-only files in the explore envelope (task CG-28)
**Date:** 2026-08-06 · **Baseline:** `feature/CG-24` @ `463f6e7` ·
**Harness:** `scripts/agent-eval/probe-decl-only.mjs` against a hermetic fixture
(`__tests__/fixtures/ambient-decls-ts/`, copied to a temp dir and indexed per run, so two runs on
one build give identical numbers), plus `probe-suite-envelope.mjs` and a corpus-wide flag-rate
survey for the regression side. No agent A/B: the claim under test is which FILES get selected and
in what order, and the agent runs are far too noisy to see that.
**Verdict, both halves:**
- **The motivating file is already handled — CG-25 credited.** The Wrangler `worker-configuration.d.ts`
that opened this issue is demoted by the generated penalty alone, worth 1546 points of envelope
share on the four flow queries measured. No new mechanism needed for it.
- **The narrower gap is real and was fixed.** A declaration file with NO banner carried `pen 1.00`,
took **rank #1 and 51% of delivered source** on a prose flow query, and displaced the flow's own
entry file out of the response entirely. It is now damped — but only when nothing in the index
depends on it, which is the condition that makes the rule safe.
---
## The fixture
`__tests__/fixtures/ambient-decls-ts/` — an upload path (route → stream → metadata → queue) with
four declaration-shaped files competing against it for one envelope. All four declare nothing but
`interface`/`type_alias` and have no bodies; they differ only in the two properties under test.
| file | banner | depended on | lines |
|---|---|---|---|
| `types/worker-configuration.d.ts` | Wrangler | no | 271 |
| `types/platform-shims.d.ts` | none | no | 212 |
| `src/storage/types.ts` | none | **yes** (2 imports, 3 references) | 18 |
| implementation (`routes/`, `storage/`, `lib/`) | — | — | 3771 each |
The declaration files carry the same generic identifiers the prose queries use — `Body`, `Message`,
`ImageMetadata`, `ReadableStream`, `Upload*` — which is the whole mechanism of the original report.
## Result 1 — what CG-25 is worth (the obsolescence leg)
Same fixture, same queries, one variable: `--variant strip-banner` deletes the two banner COMMENT
lines from `worker-configuration.d.ts` and changes nothing else, so the two declaration files become
indistinguishable to the ranker. Delivered share of the envelope for that file:
| query | with banner | banner stripped |
|---|---|---|
| flow-upload | not a candidate | 15.1% (2,271 chars) |
| flow-pipe | not a candidate | 38.5% (4,398 chars) |
| flow-generic | cliffed to a pointer, 0 chars | 35.1% (3,076 chars) |
| flow-queue | 9.4% via clusters (1,264 chars) | **46.1%, rank #1, whole file** (7,390 chars) |
The generated penalty alone is the difference between "rank #1 and nearly half the answer" and
"named in the not-shown list". **The file this issue was filed about needs nothing further.**
## Result 2 — the gap that survived
`platform-shims.d.ts` — hand-written, no banner — on the `feature/CG-24` tip:
| query | rank | score | pen | delivered |
|---|---|---|---|---|
| flow-upload | **#1** | 53.0 | 1.00 | 6,044 chars (**50.7%**) |
| flow-queue | **#1** | 21.0 | 1.00 | 6,044 chars (44.9%) |
On `flow-upload` the response carried three files and `src/routes/upload.ts` — the handler the
question is *about* — was not one of them. That is the CG-24 epic symptom, reproduced with no
generated banner anywhere in it.
Note also: `.pyi` is **not an indexed extension**, so Python stubs never enter the graph and cannot
take an envelope. That third of the issue's premise does not occur today.
## The mechanism, and why it is drawn this tight
`AMBIENT_DECLARATION_RANK_PENALTY` (0.5) multiplies score and graph mass in `rankPenalty`, for files
`QueryBuilder.getAmbientDeclarationPathsAmong` flags. Four conditions, all required — the first
three were the obvious rule, the fourth is the one that makes it safe:
1. declares ≥1 symbol;
2. **every** declared symbol is type-level (`interface`, `type_alias`, `enum`, `enum_member`,
`namespace`);
3. originates no `calls`/`instantiates` edge;
4. **nothing outside the file points at it.**
Conditions 2 and 4 were both forced by measurement, not taste:
**Why not just "no callables" (condition 2).** Surveyed across the corpus, a rule of "declares no
callable and calls nothing" flags **1.1%18.0%** of files, and what it catches is real source:
okhttp's `SocketPolicy.kt` (19 declarations, a Kotlin sealed hierarchy), `BrotliInterceptor.kt`,
`tokio/src/runtime/mod.rs`, Alamofire's umbrella `Alamofire.swift`, and all 500+ of django's
`conf/locale/*/formats.py` constant tables. Requiring every symbol to be type-level drops that to
**0%4%**.
**Why "nothing depends on it" (condition 4).** Without it the rule also flags
`__tests__/fixtures/displacement-ts/src/pipeline/types.ts` — pure interfaces, no bodies, structurally
identical to an ambient shim — and demoting it **broke the CG-31 displacement gate**, which is a
different invariant entirely. That file carries 13 inbound imports and 21 references: the pipeline
stages that answer a query about the pipeline are typed *by* it, so it is part of that answer's
structure. The ambient shims carry **zero** inbound edges — reachable by name, attached to nothing.
That is the real distinction, and the graph already holds it.
**The counter-case guard.** A query that NAMES a declared type is a question about the declaration,
so its file is exempt and ranks at full weight. Only shape-precise tokens count (the same
NL-stopword reasoning as named-seed selection) — "…the file **body**…" must not exempt a `Body`
interface it never meant to name. This needed its own set: `namedSeedIds` is callable-only by
construction, so a type can never become a named seed.
**No double-charging.** Generated and ambient-declaration are combined with `Math.min`, not
multiplied. A generated `.d.ts` has one property that two signals happen to see; charging it twice
(0.3 × 0.5 = 0.15) is how a file gets cliffed out of answers where it is genuinely relevant. The
low-value multiplier is orthogonal and still compounds.
## Result 3 — after the fix
| query | before | after |
|---|---|---|
| flow-upload | rank **#1**, 50.7% | rank **#2**, 38.6% — and `src/routes/upload.ts` now delivered (2,417 chars) |
| flow-queue | rank **#1**, 44.9% | rank **#3**, 41.3% |
| flow-pipe / flow-generic | not a candidate | unchanged |
| type-shim (`UploadStorage StoredUploadObject ImageMetadataShim`) | rank #1, `pen 1.00` | **unchanged** — exempt |
| type-prose (*what does the UploadStorage interface declare…*) | rank #1, `pen 1.00` | **unchanged** — exempt |
The byte share falls less than the rank does, and that is the correct outcome rather than a weak
fix: on this fixture every implementation file already delivers its entire contents, so the
declaration file is filling envelope nobody else needs. What it was actually taking was a **file
slot** — which is why the entry file came back. The issue explicitly forbids suppression, and a
damped file is still a candidate, still named in the response, and one follow-up explore away.
## Regression evidence
- **`probe-suite-envelope.mjs`, 6 repos, new build vs a clean `feature/CG-24` baseline build:
byte-identical.** django 20,878 · excalidraw 19,652 · okhttp 18,870 · tokio 21,607 · gin 10,776 ·
alamofire 11,662 source chars, same file counts, on both builds.
- **VS Code** — the repo the issue names for `.d.ts` surface — across five flow and type queries:
**zero** ambient-declaration files reach the ranked candidate set, so the output cannot differ.
- **Corpus-wide flag rate:** django 0.00% · okhttp 0.00% · gin 0.00% · alamofire 0.00% ·
tokio 0.12% · vscode 0.53% · excalidraw 0.74%. What it catches is `global.d.ts`, `vite-env.d.ts`,
`css.d.ts`, unreferenced vendored headers and test fixtures — exactly the intended shape.
- `probe-allocation.mjs`: `payroll-go` PASS, `self-query` PASS.
- Full suite: **178 files, 2,978 passed**, 6 skipped.
The change is inert everywhere the shape does not occur, which is most places. That is the point:
the defect is real but rare, and the mechanism costs nothing where it does not apply.
## Reproducing
```bash
npm run build
node scripts/agent-eval/probe-decl-only.mjs # as committed
node scripts/agent-eval/probe-decl-only.mjs --variant strip-banner # what CG-25 is worth
npx vitest run __tests__/explore-declaration-only.test.ts # the standing gate
```
@@ -0,0 +1,140 @@
# Agent A/B — cluster-path displacement guard (task CG-31)
**Date:** 2026-08-06 · **New:** `bugfix/CG-31` · **Baseline:** `bugfix/CG-30` @ `0d014a6` ·
**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-30 tip, not `main`, so every number here isolates CG-31. CG-30's own A/B
against `main` is `explore-oversize-member-ab-cg30.md`; read them in sequence for the combined
picture the two issues asked for.
CG-31 stops a clustered render from spending a reservation still owed to a file the render loop
has not reached. The whole-file BUY arm has always refused that trade (`owedBelow`); the cluster
path read what was left before the hard ceiling instead of what was still promised.
**Verdict: no regression, and this one is a straight win on both halves.** Four of six suite
repos deliver MORE source and one more file each; the other two are byte-identical. The agent
runs are faster in all three repos measured, with Read at or below baseline.
---
## The two corrections the measurement forced
Worth recording, because the first version of the guard was **wrong in the direction the guard
itself is about**, and only a suite measurement showed it.
**1. Holding back the full owed sum was too much.** The allocator splits the envelope; the render
loop spends against a ceiling that also has to hold the response's own prose, so on a saturated
response the promises are over-subscribed and the tail is going to be dropped whatever happens
above it. Bytes held for a file that is then dropped are bytes nobody receives. Measured: django
2,319 source, tokio 1,298, both handed to a section the ceiling threw away.
`owedPayableBelow` now holds back only the prefix of what is owed below that the response can
still pay, in rank order.
**2. The final truncation was eating the guard's work.** It cut at the last file-section header,
which drops that whole section *and* the trailing notes. Dropping the notes alone is almost
always enough. The epilogue is a pointer list and two reminders; a section is source the agent
otherwise has to Read. Cutting the epilogue first is what turned the remaining deficits into
gains — and it is the same starvation CG-31 is about, arriving one layer below the guard.
A third, smaller fix: `flow.text` is prepended to `lines` to make the final output but was never
counted in `totalChars`, so the render loop spent against a ceiling it was ~2K under on
symbol-bag queries.
## Deterministic measurement — the primary evidence
Same clean-rebuilt index, same query, both builds. One `codegraph_explore` per repo.
| repo | base source | new source | Δ | base files | new files |
|---|---|---|---|---|---|
| django | 20,033 | **20,791** | +758 | 5 (truncated) | **6** |
| excalidraw | 18,776 | **20,204** | +1,428 | 7 (truncated) | **8** |
| okhttp | 15,628 | **19,034** | +3,406 | 4 (truncated) | **5** |
| tokio | 20,340 | **21,521** | +1,181 | 4 (truncated) | **5** |
| gin | 10,776 | 10,776 | 0 | 4 | 4 |
| alamofire | 11,662 | 11,662 | 0 | 2 | 2 |
Queries: django "How does a QuerySet turn into SQL and fetch rows from the database?";
excalidraw "How does updating an element re-render the canvas on screen?"; gin "How does a
registered route handler get invoked for an incoming HTTP request?"; alamofire "How does a
request get built and sent through the session?"; okhttp "How does a call go through the
interceptor chain to the network?"; tokio "How does a spawned task get scheduled and run by a
worker?".
No repo delivers less. **Four of six stopped truncating**, which is where the extra file comes
from: each of those responses had been throwing a fully-rendered section away.
**gin and alamofire are byte-identical between the builds** — nothing in them is oversize enough
for the guard to engage and neither response was truncated. That is what a control should show,
and it means every gin number in the agent table below is run-to-run variance.
**Fixture**`__tests__/fixtures/displacement-ts`, four pipeline stages competing for one
envelope, the first a single ~20K function. Padded past 500 indexed files on purpose: the
displacement only exists on the 24K tier, where the reservations plus the preamble genuinely
saturate the render ceiling.
| | baseline | new |
|---|---|---|
| `ingest.ts` | 9,301 chars on a 6,289 spendable, then **dropped whole** by the ceiling — 0 delivered | 4,851, bounded |
| `types.ts` / `sink.ts` | skipped `budget-whole-file` | delivered |
| admitted files delivered | **3 of 6** | **6 of 6** |
| envelope | 14,908 | 22,066 |
Pinned by `__tests__/explore-displacement-guard.test.ts` (11 tests; 3 fail on the baseline).
**Allocation fixtures**`scripts/agent-eval/allocation-fixtures.json` flips back to
**BOTH PASS**. Its `afterCG30` verdict blamed an over-RESERVED incidental file; the reservation
is identical in both arms — the file was over-SPENDING, which is exactly this defect. Recorded
honestly in `afterCG31`.
## Agent runs
| | django new | django base | okhttp new | okhttp base | gin new | gin base |
|---|---|---|---|---|---|---|
| runs | 3 | 3 | 2 | 2 | 2 | 2 |
| duration (s) | **36** [3351] | 46 [3549] | **42** [4044] | 52 [5053] | **33** [3135] | 39 |
| tool calls | 3 [34] | 3 [34] | **4** [34] | 5 [45] | **4** [34] | 5 [45] |
| Read | 0 [01] | 0 | **0** | 1 [02] | 1 [01] | 1 [02] |
| Grep/Glob | 0 | 0 | 0 | 0 | 0 | 0 |
| codegraph calls | 2 | 2 [23] | 3 [23] | 3 [23] | 2 | 3 [23] |
| occupancy share | **32.1%** [31.1%36.1%] | 33.8% [28.7%42.1%] | **40.0%** [36.6%43.3%] | 40.8% [40.3%41.4%] | **29.7%** [28.3%31.1%] | 33.5% [29.8%37.2%] |
| allocation efficiency | 96.8% | 98.9% | 88.2% | 97.2% | **91.2%** | 85.0% |
Prompts are the deterministic queries above with "Trace the flow end to end." appended.
**Sufficiency, pooled per call.** okhttp: **0** "Read a file we returned" in 5 against the
baseline's 1 in 5 — the arm that returns 3,406 more chars needs fewer follow-up Reads, which is
the mechanism working. django: 1 in 6 against 0 in 7. gin: 1 in 4 against 1 in 5, on a repo where
the builds emit identical bytes. Neither arm produced a single recall miss (a Read of a file we
did NOT return, or a Grep) on any repo.
**Where the new arm looks worse, and why it is not read as a regression:**
- *okhttp allocation efficiency, 88.2% vs 97.2%.* The new arm's envelope is 85,197 chars against
the baseline's 66,014 — it returns substantially more source, and the metric is the share of
returned bytes the answer *cited*. A larger, more complete response with a smaller cited share
and Read driven to 0 is the trade this tool exists to make. The metric's own documentation says
it is relative and must not be read as waste.
- *django, 1 allocation miss in 6 answered calls against 0 in 7.* One run, n=3, and django is the
repo whose duration range overlaps most (3351 vs 3549).
## Residual carried forward — for CG-26
Four repos stopped truncating; **okhttp, django, excalidraw and tokio now land at 24,75824,998
chars against a 25,000 hard ceiling.** That is deliberate (the ceiling exists so the host never
externalizes the result) but it means the render loop's 600-char margin for the epilogue is still
wrong — the epilogue measures 1,064 (gin), 1,788 (django), 2,231 (excalidraw). The response now
survives that by dropping the epilogue rather than a section, which is strictly better, but the
honest fix is for the loop to budget for the epilogue in the first place.
A margin sweep was run and deliberately **not** shipped: at 1,200 django stops truncating on its
own but tokio loses 286 chars; at 2,400 django loses 1,895. Tuning one constant against the suite
is the trap CG-30's own record warns about. Sizing the margin from the epilogue the response is
actually going to emit is the real fix and belongs with the end-to-end reservation invariant.
Second residual: the whole-file BUY arm's fit test (`totalChars + fileContent.length +
FILE_OVERHEAD <= renderCeiling`) has no `owedBelow` term of its own — its displacement guard is
source-space only. It was left alone here to keep this change attributable; the epilogue-first cut
removes the failure mode it would have caused.
@@ -0,0 +1,133 @@
# Deterministic measurement — the factory-closure envelope (task CG-27)
**Date:** 2026-08-06 · **Baseline:** `feature/CG-24` @ `dc4fd75` ·
**Harness:** `scripts/agent-eval/probe-factory-closure.mjs` against a hermetic fixture
(`__tests__/fixtures/factory-closure-ts/`, copied to a temp dir and indexed per run, so two runs
on one build give identical numbers). No agent A/B: the claim under test is which SYMBOLS get
selected inside one file, and the agent runs are far too noisy to see that.
**Verdict: the premise does not survive measurement. CG-27 is closed as obsolete, CG-30 credited.**
The literal change the issue proposes is a large REGRESSION, and a more careful mechanism reaching
the same intent is noise (69 vs 68 inner definitions delivered across nine query shapes).
---
## The claim
`ENVELOPE_KINDS` in `src/mcp/tools.ts` drops a node covering >50% of its file from the cluster
ranges, so the granular symbols inside form their own clusters instead of merging into one blob.
It lists container kinds — `class`, `struct`, `interface`, `enum`, … — and **not `function` or
`method`**. A factory closure (`createFoo()` returning an object of closures) therefore survives
as a file-spanning range. That shape is common, not a one-repo quirk: Svelte 5 `.svelte.ts` rune
stores, React custom-hook modules, IIFE/module-pattern JS, and Zustand's
`create((set, get) => ({ … }))`.
CG-30 already bounds the BYTES such a member may spend, so what remained was a ranking claim:
a file-spanning range merges every inner symbol into one cluster, so selection cannot rank and
pick the relevant closures independently. The issue required that claim be measured before any fix.
## The fixture
`__tests__/fixtures/factory-closure-ts/` — a dashboard app with three stores written as factory
closures, two stateless services and a UI consumer competing for one envelope.
| file | lines | shape |
|---|---|---|
| `src/stores/dashboard-store.ts` | 385 | `createDashboardStore` spans 15376 (**94%**), 11 closures inside; a tail type alias + helper at file scope |
| `src/stores/alerts-store.ts` | 141 | `createAlertsStore` spans 19138 (**85%**), 9 closures inside |
| `src/stores/session-store.ts` | 148 | a factory and NOTHING else at file scope — no companion type, no tail helper |
| `src/services/metric-service.ts`, `src/services/filter-parser.ts` | 105, 62 | ordinary top-level functions — the control |
Both factory files are past `WHOLE_FILE_MAX_LINES` where it matters, so they render through the
cluster path and the envelope actually bites.
## Result 1 — the envelope is almost never selected in the first place
`shrinkCluster` orders a cluster's members by **(importance desc, size ASC)** and refuses any
member that overruns the cap once something is kept. A file-spanning member is therefore only ever
selected when it is the FIRST candidate — which requires it to be the *sole* member of the top
importance tier. In eight of the nine query shapes measured, some smaller member shared that tier
(a one-line type alias, a tail helper, another closure), so the factory sorted last and was never
kept. The envelope was inert.
## Result 2 — the proposed change is a large regression
Making the >50% drop kind-independent, measured on the primary query
(*"how does the dashboard store refresh its metrics and apply a filter"*):
| | baseline | drop the range |
|---|---|---|
| `dashboard-store.ts` (rank #1) delivered | 7,539 chars | **397** |
| inner closure definitions delivered | 7 of 11 | **0 of 11** |
| its own reservation left unspent | 0 | ~5,200 of 5,601 |
The mechanism, from the cluster dump: dropping the range **splits** the file into two clusters —
`378-384` (a one-line type alias plus a four-line helper, score 15, span 7) and `4-362` (every
closure, score 116, span 359). Cluster ranking breaks the `maxImportance` tie on **density**, so
the trivial cluster wins, is taken first, and is the only one that may be shrunk. The
answer-bearing cluster then does not fit the remainder and is **dropped whole** — later clusters
are never shrunk, by design.
The enclosing range is what was holding the file together as one cluster, inside which
`shrinkCluster` was already doing exactly the per-symbol ranking the issue asked for.
## Result 3 — the careful version of the same intent is noise
Deferring the envelope MEMBER inside `shrinkCluster` (leaving clustering granularity untouched, so
Result 2's split never happens) reaches the issue's intent by a better mechanism. Nine query
shapes, same fixture, same indexes — inner closure definitions delivered:
| query | target | baseline | deferred |
|---|---|---|---|
| how does the dashboard store refresh its metrics and apply a filter | dashboard | 7/11 | **8/11** |
| createDashboardStore | dashboard | 8/11 | 8/11 |
| how is the dashboard store created and wired up | dashboard | **9/11** | 8/11 |
| createDashboardStore exportCsv summarize | dashboard | 9/11 | 9/11 |
| where is the dashboard store constructed | dashboard | 7/11 | 7/11 |
| how are widgets loaded and the layout reconciled | dashboard | 4/11 | 4/11 |
| createSessionStore (adverse: the factory IS the sole top-tier member) | alerts | 6/9 | **7/9** |
| how are alerts refreshed and acknowledged | alerts | 9/9 | 9/9 |
| createAlertsStore | alerts | 9/9 | 9/9 |
| **total** | | **68** | **69** |
One better, one worse, seven unchanged — on a fixture built specifically to make this pattern
maximally visible. That is not a measurable selection improvement, so nothing shipped.
## Where the envelope DOES get selected, and why CG-30 already covers it
The adverse row above is the one configuration the ordering cannot neutralise: `createAlertsStore`
was the sole importance-10 member, so it was kept first at 3,939 chars against a 2,468 cap and
every closure was skipped. CG-30 then **windowed it on whole lines** rather than emitting it whole
or dropping the file — the response carried lines 16108, a contiguous, readable head of the
factory carrying 6 of its 9 closure definitions. Bounded, sufficient, never empty. That is the
symptom this issue was filed against, already absorbed.
---
## Byproduct — a real defect this measurement exposed (filed separately)
Result 2's mechanism is not confined to the hypothetical change. Instrumenting the **epic tip**
across the deterministic 6-repo suite for files that drop a cluster while leaving most of their
reservation unspent:
| file | budget | spent | unspent | kept cluster | dropped cluster |
|---|---|---|---|---|---|
| `django/db/models/sql/query.py` | 10,135 | 1,923 | **8,212 (81%)** | 13791400, score 14 | 306929, **score 290** |
| `okhttp .../RealInterceptorChain.kt` | 6,058 | 1,474 | **4,584 (76%)** | 1644, score 44 | 113373, score 171 |
| `okhttp .../Interceptor.kt` | 4,697 | 2,027 | 2,670 (57%) | 85138, score 21 | 154257, score 10 |
| `gin/routergroup.go` | 5,782 | 3,273 | 2,509 (43%) | 3391, score 116 | 103188, score 128 |
A file whose top cluster by density is trivial keeps that one, drops the cluster carrying 20x the
score, and leaves most of its own reservation unspent — because only the first-chosen cluster may
be shrunk. `query.py` is the file CLAUDE.md already names as the `_fetch_all` case.
## Reproducing
```bash
npm run build
node scripts/agent-eval/probe-factory-closure.mjs # primary query
node scripts/agent-eval/probe-factory-closure.mjs \
--target src/stores/alerts-store.ts --factory createAlertsStore \
--query "createSessionStore" # the adverse configuration
npx vitest run __tests__/explore-factory-closure.test.ts # the standing gate
```
+119
View File
@@ -0,0 +1,119 @@
# Epic resolution — explore response noise (CG-24)
Worked 2026-08-05 → 2026-08-06. Started from one bad `codegraph_explore` response
in a real session and ended with four shipped fixes, one open defect, and five
issues closed because measurement contradicted them.
**The headline: the reported symptom was not an explore bug.** It was a degraded
index. The explore defects the investigation found are real and were fixed, but
none of them caused the report.
## The report
A prose flow query returned an unusable response: the symbol the agent had named
never rendered, and a 12k-line generated Cloudflare ambient-types file took 60.7%
of the output envelope.
```
# deliv% bytes reserved score pen flags file
1 1.0% 251 10,970 87.0 1.00 named entry central <the named file>
2 60.7% 15,043 6,484 49.0 1.00 entry central worker-configuration.d.ts
4 — — — 19.9 1.00 dropped: budget <a third file>
```
## Root cause
**Index drift ([CG-33](index-drift-cg33.md)).** The live incrementally-synced index
diverged from a clean rebuild by 4.3% of distinct edges, bidirectionally,
overwhelmingly `calls`. RWR graph mass is relative and normalized, so call edges
missing elsewhere inflate an unaffected file's share — the `.d.ts` carried mass
0.24750 drifted vs 0.13119 rebuilt (~1.9×), score 49.0 vs 27.0.
Two causes, both fixed: incremental sync re-resolved only references *in* changed
files, and `getNodesByName` had no `ORDER BY`, so ties broke by rowid — i.e. by
the order files happened to be **written**. The second is why scope alone could
never converge. Stale edges dropped 671 → 2 across an 80-commit replay.
On a freshly rebuilt index the reported query answers correctly **with no explore
change at all**.
## Shipped
| | what |
|---|---|
| **CG-30** | Bounded how far an oversize cluster member may overshoot; windows on whole lines past 1.5× instead of emitting whole — or, when larger than the response ceiling, dropping the file silently. |
| **CG-31** | Gave the cluster path the `owedBelow` displacement guard the whole-file BUY arm always had, holding back only the prefix of what is owed below that the response can actually pay. |
| **CG-26** | Closed the remaining holes: whole-file arms had no displacement guard at all, section overhead was charged at a flat 200 against a real 300500, and `owedPayableBelow` held all-or-nothing. |
| **CG-25** | Recognize `Generated by <tool> by running <command>` banners. Precision held by requiring two `by` clauses, so ordinary prose does not match. |
| **CG-28** | Damp declaration-only files that nothing in the index depends on. Does not stack with the generated penalty (`Math.min`), and naming a declaration symbol exempts its file. |
| **CG-33 / CG-35** | Incremental sync converges with a rebuild, plus a regression suite that fails when the fix is disabled. |
| **CG-36** | A later cluster is shrunk into the remainder rather than dropped whole — at selection, and again in the ceiling trim. All 8 starvation flags across the suite clear; +1,012 source chars net. `explore-cluster-starvation-cg36.md`. |
Deterministic across the 6-repo suite: no repo truncates, none loses a file,
okhttp gains one, every repo lands at or under the 25,000 hard ceiling.
## Open
**CG-38** — agent-named symbols in the tail of a large file never render. On the
motivating repo, `queueMessage` (line 1087) and `flushQueuedMessages` (1102) in a
1,414-line file are absent from the response on both prose and symbol-bag
queries, even when that file wins rank #1 with 67% of the envelope. The response
returns the `QueuedMessage` *interface* at line 70 — a fuzzy near-match on the
query token — instead of the function.
**Pre-existing, not caused by this epic.** A controlled bisect (index held fixed,
engine varied across every merge point) shows the pre-epic engine rendering 12
lines here and CG-36 rendering 463; the symbols render at neither. The epic
strictly improves the case. An earlier claim that the epic regressed it was
wrong — it compared runs across two different indexes.
Sharpest lead: an earlier index of the same repo with the `.d.ts` **not** flagged
generated rendered 581 lines including both symbols on the pre-epic engine, where
the current flagged index renders 12. A penalty on one file should not shrink an
unrelated top-ranked file's render; `rankPenalty` scales `fileGraphScore`, which
moves the relevance gate and reshuffles the admitted set.
This epic's probes measure envelope share, starvation, source totals and file
counts. **None measures "did the agent-named symbol render"** — which is why this
survived the whole epic. CG-38 requires the fixture that closes that gap.
CG-36's own measurement is worth carrying forward, because the issue named the wrong
fix point: both real cases (`query.py`, `RealInterceptorChain.kt`) lost on
`maxImportance`, **not** on the density tiebreak the issue suspected. Ranking was
left alone; the never-shrink rule was the lever. Full numbers and the one cost
(okhttp trades its rank-6 file for +7,196 chars in the two that answer the
question) in `explore-cluster-starvation-cg36.md`.
## Closed because measurement contradicted them
Five, which is the story of this epic as much as the fixes are.
| | why |
|---|---|
| **CG-32** | Named file "didn't render first." Drift artifact; on a clean index it renders first and takes 89%. |
| **CG-34** | "Allocator over-reserves for low-scoring files." Filed on a runner's diagnosis without checking the numbers. The file was never over-reserved (4,314 in both arms) — it was over-*spending*, which is CG-31. |
| **CG-27** | Adding `function`/`method` to `ENVELOPE_KINDS` measured as a **large regression** — rank #1 fell from 7,539 delivered chars to 397, 7 of 11 inner closures to 0. The enclosing range was holding the file together as one cluster, inside which `shrinkCluster` already did the per-symbol ranking the issue wanted. A careful version was noise (69 vs 68 across nine queries). |
| **CG-29** | Prose-vs-symbol query gap. Inverted on measurement: prose matches symbol on django and delivers 63% more source on okhttp. The founding observation was drift. |
| **CG-37** | Duplicate of CG-36, filed without seeing it. |
## What this epic is actually a lesson in
**A confident diagnosis is worth less than a cheap measurement.** Every issue
above was filed by someone — human or agent — who had read the code and had a
plausible mechanism. Five were wrong. The ones that survived did so because a
deterministic probe disagreed with them and the probe won.
Two specific traps this cost real time on, both now guarded in tooling:
- **`.codegraph/graph.db` does not exist** — the index is `codegraph.db`, and
`sqlite3` against a mistyped path *creates* an empty database rather than
failing. An empty schema reads exactly like a stale pre-migration index. This
produced a wrong root cause. `diff-index-drift.mjs` refuses a missing path.
- **`ab-new-vs-baseline.sh` checks the engine out at the baseline ref mid-run.**
A commit made while it runs captures baseline sources and silently reverts the
fix under test. This happened during CG-30. Check the `changed:` line before
believing any A/B result.
And one measurement discipline worth keeping: **compare sets, not totals.** The
drift that started all of this shows up as +0.7% on raw edge counts, because it
is bidirectional and nets out. On distinct edge triples it is 4.3%.
@@ -0,0 +1,108 @@
# Agent A/B — bounded oversize cluster member (task CG-30)
**Date:** 2026-08-06 · **New:** `bugfix/CG-30` · **Baseline:** `main` @ `d6d1728` ·
**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`.
CG-30 bounds how far a cluster's top member may overshoot what its file may spend: past 1.5x it
is windowed on whole lines instead of emitted whole. The risk the A/B exists to price is the one
CLAUDE.md names — a section that is no longer sufficient sends the agent to Read, and one or two
of those teach it to stop calling codegraph at all.
**Verdict: no regression, and the deterministic win is unambiguous.** The behavioural bar holds
(Read/Grep ~0, no abandonment, allocation efficiency 100% on the repo where the bound engages),
the cost is a ~10% median duration increase on django inside overlapping ranges, and the one
allocation-miss call the new arm produced is matched by two recall-miss calls in the baseline.
> **Harness note, recorded because it cost a re-run:** `ab-new-vs-baseline.sh` checks the engine
> out at the BASELINE ref while its baseline arm runs and restores it on exit. **Do not commit
> while it is running** — a commit made mid-run captures baseline sources. The first django/gin
> batches were void for exactly this reason (their `changed:` line listed only
> `explore-diagnostics.ts`, i.e. both arms ran identical retrieval code) and were re-run. Check
> that line before believing any A/B in this harness.
---
## Deterministic measurement — where the bound actually engages
Same index, same query, both builds. This is the primary evidence; the agent runs below only
price the risk.
**django**`codegraph explore "How does a QuerySet turn into SQL and fetch rows from the
database?"`
| | baseline | new |
|---|---|---|
| `django/db/models/query.py` | 7,784 chars on a 3,669 budget — **2.12x** | 5,464 — **1.49x**, windowed |
| `django/contrib/admin/filters.py` | 3,633 (inherited 2,271 spendable) | **8,057** (inherited 9,160) |
| source delivered | 17,929 chars, 5 files | **20,033** chars, 5 files |
The reported CG-30 signature, reproduced on a public repo and then closed: the rank-#1 file took
2.12x its budget, and the files below it inherited the shortfall. Bounding it hands those bytes
straight down the rank order — the response carries the same five files and 2,104 more chars of
actual source.
**gin (control)** — the two builds produce **byte-identical** explore output (13,457 chars) for
the route-dispatch query. Nothing in gin is oversize enough for the bound to engage (max
observed 0.94x of spendable), which is exactly what a control should show — and it means every
gin number in the agent table below is run-to-run variance, not the change.
**Fixture**`__tests__/fixtures/oversize-member-ts`, three report builders competing for one
envelope, each a single long function:
| File | baseline | new |
|---|---|---|
| `monthly.ts` (24.5K, one ~490-line function) | 12,391 chars on a 3,334 budget — **3.7x** | 4,941 — **1.48x**, windowed |
| `quarterly.ts` (11.4K, one ~200-line function) | **dropped**`budget-clusters`, no headroom left | 4,004 delivered |
| response | 19,223 chars, 3 files | 15,852 chars, 4 files |
The two rows are the same defect from both sides: a member bigger than the file's share eats the
envelope, and a member bigger than the whole response ceiling makes the file vanish. Pinned by
`__tests__/explore-oversize-member.test.ts` (9 tests; 4 fail on `main`).
## Agent runs
| | django new | django base | gin new | gin base | excalidraw new | excalidraw base |
|---|---|---|---|---|---|---|
| runs | 5 | 5 | 3 | 3 | 2 | 2 |
| duration (s) | 39 [3671] | 35 [3560] | 39 [3751] | 34 [2846] | 52 [4360] | 41 [4042] |
| tool calls | 3 [310] | 4 [323] | 4 [34] | 3 | 4 [35] | 4 [34] |
| codegraph calls | 2 [23] | 2 [03] | 2 [23] | 2 | 3 [24] | 3 [23] |
| Read | 0 [05] | 0 [013] | 0 [01] | 0 | 0 | 0 |
| Grep/Glob | 0 | 0 | 0 | 0 | 0 | 0 |
| occupancy share | 33.1% [30.7%49.5%] | 34.4% [29.3%47.3%] | 28.9% [26.4%37.3%] | 30.1% [28.6%31.9%] | 43.0% | 39.3% |
| allocation efficiency | 100.0% | 98.6% | 88.5% | 98.3% | 85.8% | 90.5% |
django is pooled over two batches (n=2 + n=3). Questions: django "How does a QuerySet turn into
SQL and fetch rows from the database? Trace the flow end to end."; gin "How does a registered
route handler get invoked for an incoming HTTP request?…"; excalidraw "How does updating an
element re-render the canvas on screen?…".
**Sufficiency, pooled per call — the bar that matters.** django: new 1 "Read a file we returned"
in 10 answered calls (the allocation-miss signal a window would trip first) against the
baseline's 1 "Read a file we did not return" + 1 Grep in 10 — a shift in miss type, not an
increase. gin: 1 allocation miss in 7 against 0 in 6, on a repo where the two builds emit
identical bytes, so it is variance by construction. excalidraw: 0 misses in either arm.
**Where the new arm looks worse, and why it is not read as a regression:**
- *django duration, ~10% slower median.* Ranges overlap (3671 vs 3560) at n=5, and one
baseline run lost its codegraph attach entirely (0 codegraph calls, 13 Reads, 23 tool calls),
which distorts that arm's spread in both directions.
- *gin allocation efficiency 88.5% vs 98.3%.* The builds are byte-identical on gin. This is the
metric's documented relativity — attribution is by citation and the agent's follow-up queries
differ per run — not an effect of the change.
- *excalidraw occupancy/duration.* Call-count noise: one of the two new-arm runs made a 4th
explore call where the baseline made 23, and duration, envelope and occupancy all follow it.
Per-call envelope is flat (20,015 vs 19,446 chars/call), Read/Grep stay 0, tool calls match.
CLAUDE.md's own worked example records 310 codegraph calls on this prompt.
## Caveat carried forward
The `self-query` probe fixture in `scripts/agent-eval/allocation-fixtures.json` flips to FAIL
under this change. Allocation is unchanged between arms and `tools.ts` delivers the identical
8,282 chars in both — what changed is that an over-reserved incidental file now *delivers*
instead of being cut by the hard-ceiling truncation, which is what its previous PASS depended
on. Recorded as that fixture's `afterCG30` block. The over-reservation itself is epic CG-24's
subject; it should not be answered by loosening this bound.
@@ -0,0 +1,156 @@
# Agent A/B — the end-to-end reservation invariant (task CG-26)
**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.
---
## The three holes
**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 300500. 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`).
## The epilogue, budgeted instead of discarded
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**:
- a **floor** the render loop reserves, sized from the real strings: the one line that says an
uncovered area exists and that another explore — not a Read — reaches it, plus a pointer for
every file whose bytes were deliberately WITHHELD (a cliffed file's bytes were traded away on
the promise that the agent can still name it — CG-12; if the ceiling eats that name the trade
was a silent drop);
- an **elastic tail** — the rest of the pointer list and the reminders — fitted, in priority
order and entry by entry, to the room that is actually left once the loop is done.
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`.
## Deterministic measurement — the primary evidence
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.
## Agent runs
| | django new | django base | excalidraw new | excalidraw base | okhttp new | okhttp base |
|---|---|---|---|---|---|---|
| runs | 2 | 2 | 2 | 2 | 2 | 2 |
| duration (s) | **42** | 43 [3650] | 53 [4562] | 45 [3754] | 49 [3959] | 42 [3252] |
| tool calls | 4 [34] | 4 [35] | **3** [24] | 5 [45] | 4 | 4 [35] |
| Read | **0** | 0 | **0** | 0 | **0** | 0 |
| Grep/Glob | 0 | 0 | 0 | 0 | 0 | 0 |
| codegraph calls | 3 [23] | 3 [23] | **3** [23] | 4 [34] | 3 | 3 [24] |
| 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:**
- *okhttp allocation efficiency, 75.8% vs 87.6%, and occupancy 44.8% vs 40.8%.* The new arm's
envelope is 99,411 chars against 89,297 — it returns one more file and more source overall, and
the metric is the share of returned bytes the answer *cited*. Same trade the CG-31 record noted
on this repo; the metric's own documentation says it is relative and must not be read as waste.
- *Duration on excalidraw and okhttp.* n=2 with fully overlapping ranges (4562 vs 3754;
3959 vs 3252), on a machine also running the other arm's build. excalidraw's new arm does the
same work in **3 tool calls against 5** and holds **9 points less context**.
## Residuals
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.