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.
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.
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 1379–1400 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.
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.
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.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.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.
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 300–500, 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.
hasSpine → maxImportance → density → score → span, untouched.Session.swift. The shape density-first exists for; it gains 599 chars.probe-factory-closure.mjs: 7 of 11 inner closure
definitions delivered, identical to the epic tip.explore-reservation-invariant.test.ts green;
every repo stays at or under its hard ceiling.payroll-go, self-query, and the two added here.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.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.