Explorar el Código

feat(mcp): score-proportional byte allocation for explore, with a relative cliff (CG-12, #1500)

The explore envelope used to follow FILE SIZE, not relevance. Every admitted
file was capped at the same flat `maxCharsPerFile`, while the whole-file rule
handed anything under `maxCharsPerFile * 3` its entire contents — a 3x swing
decided by how big a file happened to be:

  - self-query: `memory-budget.ts` (score 18) shipped whole and took 51.2% of
    the response; `src/mcp/tools.ts` (score 41, 4x the graph mass, 3x the term
    hits — it holds the allocator itself) was clipped at 3,800 and got 32.9%.
  - #1500 Go fixture: two generated CRUD files shipped whole at ~4.5K each AND
    consumed two of the tier's four file slots, so `BuildPayslip` — the
    hand-written "calculate" half of the question — ranked #6 and never
    rendered at all.

`allocateExploreBudget` now reserves each ranked file a share of the envelope
before anything renders, so the render loop spends a reservation instead of
racing for whatever the files above it left:

  - weight = score x worth x (spine ? 2 : 1), where `worth` is `rankPenalty`
    applied a SECOND time — ranking answers "is this file about the query",
    allocation answers "will these bytes teach the agent anything", and
    generated CRUD can legitimately rank while its bytes stay boilerplate;
  - a relative cliff at 15% of the top weight (capped at SCORE_FLOOR_MAX, so a
    god-file can't silence peers the score floor just admitted) gives a file
    ZERO source — path, symbols and line numbers only — and crucially frees its
    `maxFiles` slot for a file that earns its bytes;
  - every admitted file gets MIN_CHARS, then the remainder splits by weight:
    the floor keeps a diffuse survey question returning a spread, the remainder
    concentrates a precise one;
  - the flat per-file cap is retired as the primary guard, leaving a 70%-of-
    envelope safety valve.

Two changes were needed to make the reservation bite: an oversize cluster now
shrinks by whole MEMBER symbol ranges (a single-cluster god-file previously
took ~40% more than allotted, and the file below it was dropped for lack of
room), and the arrival-order budget stops are gone — they cut files by the
order they were reached rather than by merit.

Measured: payroll-go answer group 25.6% -> 78.7%, generated 57.4% -> 0%, and
`func (s *Service) BuildPayslip` now delivered; self-query `tools.ts` 18.5% ->
60.6%, past the epic's >50% bar. Controls hold: cobra/gin diffuse survey
queries keep their file spread (3->3, 3->4), express's middleware query is
byte-identical, and gin's flow query moves its top file from the thin `ginS`
singleton wrapper to `routergroup.go`.

One documented exception to "no previously-unclipped file becomes clipped":
`memory-budget.ts` was unclipped-whole at 5,672 and now clusters within its
3.1K reservation. That is the epic's own diagnosis of the bug — it scored 18
against 58 and was taking the larger slice purely for being small.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry hace 1 mes
padre
commit
5f7f5f59df

+ 2 - 0
CHANGELOG.md

@@ -17,6 +17,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- `codegraph_explore` now gives the most relevant file the most room, instead of splitting its answer roughly evenly and letting whichever file happened to be small enough take the largest share. Every file's slice is now sized in proportion to how well it matches your question, decided before any code is written out — so the file that actually answers you is no longer trimmed at a fixed per-file limit while a small, weakly-related file is included in full. Files far below the best match are listed by name, symbol and line number instead of taking space with their source, and you can pull any of them up in full with one more `codegraph_explore`. On a Go service with a generated CRUD layer beside a hand-written payroll workflow, the hand-written code went from a quarter of the response to nearly four-fifths of it, and the calculation the question was actually about — previously missing entirely — is now included. (#1500)
+
 - `codegraph_explore` no longer spends its answer on files that merely share a word with your question. A file whose only connection to the query was an unused local variable or constant of the same name — an eval script with a `const explore` in it, say — used to count as strongly as the file that actually implements the thing, and being small enough to include whole, it could take most of the response while the real answer got trimmed. Matches are now weighted by what was matched (a function, class or route counts for far more than a local variable, and a variable nothing else references counts for almost nothing), and a file has to score within reach of the best match rather than clear a fixed low bar. Generated files are down-weighted throughout the ranking instead of only breaking ties, so a generated CRUD layer no longer outranks the hand-written workflow beside it. Test and spec files in a top-level `test/` or `spec/` directory are now recognized and kept out, which they weren't before. (#1500)
 
 - A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431)

+ 20 - 10
__tests__/explore-allocation-1500.test.ts

@@ -218,11 +218,13 @@ describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', (
      * `cycle.go` now delivers 38.9% and the generated layer 23.5%. Four of the
      * five gates below are green and are now live regressions.
      *
-     * STILL OPEN, for CG-12 (proportional byte allocation): the render loop
-     * still allocates by FILE SIZE within the ranked set, and `maxFiles` is 4 at
-     * this tier — so `payslip_builder.go` ranks #6 and never renders, and
-     * `func (s *Service) BuildPayslip` is absent. Its `it.fails` passes ONLY
-     * while that is still true. ⚠ When it goes red, remove `.fails`, keep it.
+     * AFTER CG-12 (score-proportional allocation): every file's share of the
+     * envelope is reserved before anything renders, and a file under 15% of the
+     * top weight gets no source at all — so the two generated files cliff to
+     * pointers, hand their `maxFiles` slots to the hand-written store and
+     * builder, and the answer group takes ~79% with the generated layer at 0%.
+     * `func (s *Service) BuildPayslip` — the "calculate" half of the question —
+     * finally reaches the agent. All gates below are live regressions now.
      */
     it('CG-10 GATE: concentrates the envelope on the hand-written workflow', () => {
       expect(answerShare()).toBeGreaterThanOrEqual(0.55);
@@ -249,14 +251,22 @@ describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', (
       expect(response).toContain('s.store.Upsert(ctx, slip)');
     });
 
-    it.fails('CG-12 GATE: delivers the calculation the question asks about', () => {
-      // `payslip_builder.go` ranks #6; the tier's maxFiles is 4 and the render
-      // loop spends by file size, so it never gets bytes. Score-proportional
-      // allocation (CG-12) is what closes this.
+    it('CG-12 GATE: delivers the calculation the question asks about', () => {
+      // `payslip_builder.go` ranks #6 and the tier's maxFiles is 4 — it reaches
+      // the response only because the two generated files cliff to pointers
+      // WITHOUT consuming a slot. That slot hand-off is the CG-12 mechanism.
       expect(bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0).toBeGreaterThan(0);
       expect(response).toContain('func (s *Service) BuildPayslip');
     });
 
+    it('CG-12 GATE: withholds the generated CRUD bytes but still names it', () => {
+      // A cliffed file costs ~100 chars instead of ~4,500, and stays one
+      // follow-up explore away — withholding is only cheap if it stays nameable.
+      expect(bytes.get('internal/gen/fkit/payroll/payslip.go') ?? 0).toBe(0);
+      expect(response).toContain('**Not shown above — explore these names for their source**');
+      expect(response).toMatch(/internal\/gen\/fkit\/payroll\/payslip\.go: \w+:\d+/);
+    });
+
     it('records the shape of the allocation so a regression is legible', () => {
       // Not a gate — a snapshot of the split, so a future change that shifts the
       // numbers shows up in the diff rather than silently flipping a gate.
@@ -269,7 +279,7 @@ describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', (
       }).toEqual({
         generatedWinsEnvelope: false,
         workflowFileDelivers: true,
-        builderFileDelivers: false,
+        builderFileDelivers: true,
       });
     });
   });

+ 3 - 1
__tests__/explore-diagnostics.test.ts

@@ -202,7 +202,9 @@ describe('codegraph_explore allocation diagnostic', () => {
       /files [\d,]+ grouped .*past low-value filter .*past score floor \(>=[\d.]+\).*in output \(maxFiles \d+\)/,
     );
     // Per-file columns.
-    expect(out).toMatch(/#\s+alloc%\s+deliv%\s+bytes\s+score\s+graph\s+hits\s+pen\s+flags\s+render\s+file/);
+    expect(out).toMatch(/#\s+alloc%\s+deliv%\s+bytes\s+reserved\s+score\s+graph\s+hits\s+pen\s+flags\s+render\s+file/);
+    // The proportional split (CG-12): what was reserved, and where the cliff fell.
+    expect(out).toMatch(/allocation [\d,]+ reserved of [\d,]+ pool · cliff at weight [\d.]+/);
     expect(out).toContain('src/session.ts');
     expect(out).toMatch(/\d+\.\d%/);
     // Kind mix — what each file's score was bought with.

+ 214 - 0
__tests__/explore-proportional-allocation.test.ts

@@ -0,0 +1,214 @@
+/**
+ * Score-proportional byte allocation for codegraph_explore (CG-12 / #1500).
+ *
+ * `allocateExploreBudget` decides, before anything renders, how many chars of
+ * source each ranked file may spend. Its contract is what stops the explore
+ * envelope from following FILE SIZE — which is the bug #1500 reported: a small
+ * weakly-relevant file shipped whole while the file that actually answered the
+ * question was clipped at a flat per-file cap.
+ *
+ * These pin the allocator's invariants directly. End-to-end behaviour on the two
+ * regression fixtures lives in `explore-allocation-1500.test.ts`.
+ */
+import { describe, it, expect } from 'vitest';
+import { allocateExploreBudget, getExploreOutputBudget } from '../src/mcp/tools';
+import type { ExploreAllocationCandidate } from '../src/mcp/tools';
+
+/** A candidate with sane defaults — tests override only what they're about. */
+const cand = (
+  path: string,
+  score: number,
+  extra: Partial<ExploreAllocationCandidate> = {},
+): ExploreAllocationCandidate => ({ path, score, worth: 1, spine: false, ...extra });
+
+const TIER_FILE_COUNTS = [10, 100, 300, 1000, 4000, 10000, 20000, 60000];
+
+describe('allocateExploreBudget — proportional split', () => {
+  const budget = getExploreOutputBudget(1000); // 24,000 / 6,500 / 8 files
+
+  it('gives the higher-scoring file the bigger share', () => {
+    const { allowances } = allocateExploreBudget(
+      [cand('a.ts', 40), cand('b.ts', 10)],
+      budget,
+      8,
+    );
+    expect(allowances.get('a.ts')!).toBeGreaterThan(allowances.get('b.ts')!);
+  });
+
+  it('scales the split with the score RATIO, not just the ordering', () => {
+    // The heart of the fix. Under the old flat `maxCharsPerFile` both files got
+    // the same cap and the split fell out of whichever happened to be small
+    // enough to ship whole; here a 4x score buys materially more than a 1.1x one.
+    const wide = allocateExploreBudget([cand('a.ts', 40), cand('b.ts', 10)], budget, 8).allowances;
+    const narrow = allocateExploreBudget([cand('a.ts', 22), cand('b.ts', 20)], budget, 8).allowances;
+    expect(wide.get('a.ts')! / wide.get('b.ts')!)
+      .toBeGreaterThan(narrow.get('a.ts')! / narrow.get('b.ts')!);
+  });
+
+  it('never reserves more than the envelope', () => {
+    const { allowances, pool } = allocateExploreBudget(
+      [cand('a.ts', 90), cand('b.ts', 40), cand('c.ts', 30), cand('d.ts', 12)],
+      budget,
+      8,
+    );
+    const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
+    expect(reserved).toBeLessThanOrEqual(pool);
+    expect(pool).toBeLessThanOrEqual(budget.maxOutputChars);
+  });
+
+  it('caps any single file at the MAX_SHARE safety valve', () => {
+    // The per-file cap is retired as the primary guard, but a lone dominant file
+    // must still not be handed the entire response.
+    const { allowances } = allocateExploreBudget([cand('god.ts', 500)], budget, 8);
+    expect(allowances.get('god.ts')!).toBeLessThanOrEqual(Math.round(budget.maxOutputChars * 0.7));
+  });
+
+  it('lets the top file exceed the old flat per-file cap when it earns it', () => {
+    // The regression this task exists to fix: `maxCharsPerFile` clipped the file
+    // that scored 4x its peers at exactly the same 6,500 as the noise.
+    const { allowances } = allocateExploreBudget(
+      [cand('answer.ts', 60), cand('noise.ts', 12)],
+      budget,
+      8,
+    );
+    expect(allowances.get('answer.ts')!).toBeGreaterThan(budget.maxCharsPerFile);
+  });
+});
+
+describe('allocateExploreBudget — the relative cliff', () => {
+  const budget = getExploreOutputBudget(1000);
+
+  it('gives zero source to a file far below the top score', () => {
+    const { allowances, cliffed } = allocateExploreBudget(
+      [cand('answer.ts', 90), cand('incidental.ts', 3)],
+      budget,
+      8,
+    );
+    expect(cliffed).toContain('incidental.ts');
+    expect(allowances.has('incidental.ts')).toBe(false);
+  });
+
+  it('is RELATIVE — the same score survives against weaker company', () => {
+    const strong = allocateExploreBudget([cand('a.ts', 90), cand('b.ts', 8)], budget, 8);
+    const even = allocateExploreBudget([cand('a.ts', 12), cand('b.ts', 8)], budget, 8);
+    expect(strong.cliffed).toContain('b.ts');
+    expect(even.cliffed).not.toContain('b.ts');
+  });
+
+  it('never rises above the score-floor ceiling, however dominant the top file', () => {
+    // A 500-scoring god-file would otherwise put the cliff at 75 and silence
+    // every peer the score floor had just deliberately admitted.
+    const { cliffed } = allocateExploreBudget(
+      [cand('god.ts', 500), cand('peer.ts', 13), cand('peer2.ts', 11)],
+      budget,
+      8,
+    );
+    expect(cliffed).toEqual([]);
+  });
+
+  it('doubles the penalty on bytes that are worth less (generated / low-value)', () => {
+    // `worth` is `rankPenalty` applied a second time: generated CRUD can rank on
+    // name collisions while its bytes stay boilerplate. Same score, different fate.
+    const { cliffed } = allocateExploreBudget(
+      [cand('answer.ts', 60), cand('gen.ts', 12, { worth: 0.3 }), cand('hand.ts', 12)],
+      budget,
+      8,
+    );
+    expect(cliffed).toContain('gen.ts');
+    expect(cliffed).not.toContain('hand.ts');
+  });
+
+  it('exempts flow-spine files from the cliff', () => {
+    // Clipping the spine causes the Read fallback — it IS the answer to a flow
+    // question — so a spine file is never zeroed on relative score alone.
+    const { allowances, cliffed } = allocateExploreBudget(
+      [cand('a.ts', 400), cand('spine.ts', 2, { spine: true })],
+      budget,
+      8,
+    );
+    expect(cliffed).not.toContain('spine.ts');
+    expect(allowances.get('spine.ts')!).toBeGreaterThan(0);
+  });
+
+  it('never cliffs every candidate — an empty response costs a round-trip', () => {
+    const { allowances, cliffed } = allocateExploreBudget([cand('only.ts', 0.5)], budget, 8);
+    expect(cliffed).toEqual([]);
+    expect(allowances.get('only.ts')!).toBeGreaterThan(0);
+  });
+
+  it('hands a cliffed file\'s maxFiles slot to the next file down', () => {
+    // The mechanism that got `BuildPayslip` into the #1500 response: cliffing is
+    // not just "spend fewer bytes here", it frees the SLOT too.
+    const { allowances } = allocateExploreBudget(
+      [cand('a.ts', 90), cand('noise.ts', 2), cand('b.ts', 40)],
+      budget,
+      2,
+    );
+    expect([...allowances.keys()].sort()).toEqual(['a.ts', 'b.ts']);
+  });
+});
+
+describe('allocateExploreBudget — the floor keeps diffuse questions useful', () => {
+  const budget = getExploreOutputBudget(1000);
+
+  it('gives every admitted file a slice big enough for a method', () => {
+    // A survey question must still return a spread. The earlier design cliffed a
+    // starved file instead of flooring it, and that CASCADED: removing the
+    // smallest raised everyone else so little that the next-smallest starved too,
+    // eating six legitimately-ranked peers one at a time.
+    const files = [cand('a.ts', 100), cand('b.ts', 90), ...Array.from({ length: 6 }, (_, i) => cand(`p${i}.ts`, 20))];
+    const { allowances } = allocateExploreBudget(files, budget, 8);
+    expect(allowances.size).toBe(8);
+    for (const [, chars] of allowances) expect(chars).toBeGreaterThanOrEqual(700);
+  });
+
+  it('serves fewer files well rather than many badly when the envelope cannot afford them', () => {
+    const tiny = getExploreOutputBudget(10); // 13,000-char envelope
+    const files = Array.from({ length: 40 }, (_, i) => cand(`f${i}.ts`, 50 - i * 0.1));
+    const { allowances, cliffed } = allocateExploreBudget(files, tiny, 40);
+    expect(allowances.size).toBeLessThan(40);
+    expect(cliffed.length).toBeGreaterThan(0);
+    for (const [, chars] of allowances) expect(chars).toBeGreaterThanOrEqual(700);
+    const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
+    expect(reserved).toBeLessThanOrEqual(tiny.maxOutputChars);
+  });
+
+  it('returns an empty allocation for an empty candidate list', () => {
+    const { allowances, cliffed } = allocateExploreBudget([], budget, 8);
+    expect(allowances.size).toBe(0);
+    expect(cliffed).toEqual([]);
+  });
+
+  it('does not crash or over-allocate when every score is zero', () => {
+    const { allowances } = allocateExploreBudget([cand('a.ts', 0), cand('b.ts', 0)], budget, 8);
+    const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
+    expect(reserved).toBeLessThanOrEqual(budget.maxOutputChars);
+  });
+});
+
+describe('allocateExploreBudget — tier invariant', () => {
+  it('never gives a larger tier a smaller allowance than a smaller tier', () => {
+    // The standing invariant from `getExploreOutputBudget`: a bigger project must
+    // never be served LESS per file. It held for the flat cap by inspection; with
+    // a proportional split it has to hold for the same candidate set across every
+    // tier, which is what this walks.
+    const files = [cand('a.ts', 60), cand('b.ts', 30), cand('c.ts', 15)];
+    let previous: Map<string, number> | null = null;
+    for (const fileCount of TIER_FILE_COUNTS) {
+      const { allowances } = allocateExploreBudget(files, getExploreOutputBudget(fileCount), 8);
+      if (previous) {
+        for (const [path, chars] of allowances) {
+          expect(chars, `${path} shrank at ${fileCount} files`).toBeGreaterThanOrEqual(previous.get(path)!);
+        }
+      }
+      previous = allowances;
+    }
+  });
+
+  it('cliffs the same files at every tier — the cliff is relative, not sized', () => {
+    const files = [cand('a.ts', 90), cand('noise.ts', 2)];
+    const cliffs = TIER_FILE_COUNTS.map((n) =>
+      allocateExploreBudget(files, getExploreOutputBudget(n), 8).cliffed.join(','));
+    expect(new Set(cliffs).size).toBe(1);
+  });
+});

+ 85 - 3
docs/design/explore-budget-allocation.md

@@ -190,12 +190,94 @@ was tried and rejected: node-count ties handed the slot to `examples/route-middl
 instead, at 48% of the envelope. Thin-and-precise beats padded-with-noise — a wrong file
 does not save the agent the follow-up call it would pad against.
 
+## CG-12 — score-proportional allocation
+
+CG-10 fixed *what* gets into the response. This fixes *how the bytes are split among what
+got in* — which, until now, was not really decided at all. Every admitted file was capped at
+the same flat `maxCharsPerFile`, and the whole-file rule handed anything under
+`maxCharsPerFile × 3` its entire contents. So the envelope followed **file size**:
+
+- self-query: `memory-budget.ts` (score 18) shipped whole at 5,672 and took **51.2%**;
+  `tools.ts` (score 41, 4× the graph mass, 3× the term hits — it literally holds the
+  allocator) was clipped at 3,800 and got **32.9%**.
+- payroll-go: two generated CRUD files shipped whole at ~4.5 KB each *and* consumed two of
+  the tier's four file slots, so `BuildPayslip` — the hand-written "calculate" half of the
+  question — ranked #6 and never rendered at all.
+
+### The model
+
+`allocateExploreBudget` (`src/mcp/tools.ts`) runs once, after ranking, before anything
+renders. It reserves each file a share of the envelope; the render loop then spends a
+reservation instead of racing for whatever the files above it left.
+
+1. **Weight** = `score × worth × (spine ? 2 : 1)`. `worth` is `rankPenalty` applied a
+   *second* time: ranking answers "is this file about the query", allocation answers "will
+   these bytes teach the agent anything". Generated CRUD can legitimately rank — it
+   name-collides on every domain word, and it is big and densely self-referential, so it
+   scores on the structural keys the comparator leads with — while its bytes stay mechanical
+   boilerplate. That second penalty is what finally sinks it.
+2. **Relative cliff** at 15% of the top weight, itself capped at `SCORE_FLOOR_MAX`. A file
+   under it gets **zero source** — path, symbols and line numbers only. It costs ~100 chars
+   instead of ~4,500, and it does **not consume a `maxFiles` slot**, so the slot passes to a
+   file that earns its bytes. That slot hand-off is what got `BuildPayslip` into the
+   response. The cap matters as much as the fraction: one 500-scoring god-file would
+   otherwise put the cliff at 75 and silence every peer the score floor had just admitted.
+3. **Floor then split.** Every admitted file gets `MIN_CHARS` (700 — enough for one complete
+   method); the remainder splits by weight. The floor is what keeps a diffuse survey
+   question returning a spread; the remainder is what concentrates a precise one.
+4. **Safety valve**, not a per-file cap: no file exceeds 70% of the envelope. The flat
+   per-file cap is retired as the primary guard — the proportional split already bounds a
+   file by its weight share.
+
+The reservation then governs **every** render path — whole-file, clusters, focused/skeleton
+— where before the whole-file branch was 3× more generous than the cluster branch, which is
+the 3× swing that decided the split by file size.
+
+Two supporting changes were needed to make the reservation actually bite:
+
+- **Oversize clusters shrink by member.** A cluster is a merge of whole symbol ranges, and
+  on a densely-packed file every symbol merges into one blob spanning the file (cycle.go's
+  209-line `Service`). The old rule took the top-ranked cluster whole however big it was, so
+  a single-cluster file simply ignored its budget — it took ~40% more than allotted and the
+  file below it was then dropped for lack of room. Shrinking drops whole **members** by
+  importance, so a body is still never cut.
+- **The arrival-order stops are gone.** `budget-90pct` and the `!fileNecessary && totalChars
+  > maxOutputChars` checks dropped files by the order they were reached: whichever files
+  ranked first spent the envelope, and everything after them was cut on a cap it had no say
+  in. Only an absolute hard-ceiling stop remains.
+
+### Measured effect (CG-12)
+
+| repo · query | before (post-CG-10) | after |
+|---|---|---|
+| this repo · self-query fixture | `tools.ts` 32.9%, `memory-budget.ts` 51.2% | **`tools.ts` 60.6%**, memory-budget 17.2% |
+| payroll-go fixture | answer 61.5%, generated 23.5%, `BuildPayslip` absent | answer **78.7%**, generated **0%**, `BuildPayslip` **delivered** |
+| express · route a request | 2 files, top 43.1% | 1 file, top **82.3%** (`response.js` cliffed — 0 term hits) |
+| express · app registers middleware | 1 file, 71.6% | **byte-identical** |
+| cobra · parse flags and execute | 2 files, `command.go` 40.1% | 2 files, `command.go` **77.2%** |
+| cobra · DIFFUSE "main components" | 3 files, top 48.1% | 3 files, top 50.0% — spread preserved |
+| gin · request reaches a handler | 3 files, top `ginS/gins.go` 48.8% | 3 files, top **`routergroup.go`** 53.8% |
+| gin · DIFFUSE "what it provides" | 3 files, top `recovery.go` 43.0% | **4 files**, top `context.go` 33.3% |
+
+The two diffuse rows are the over-correction control: file counts hold (3→3, 3→4), so a
+survey question still gets a spread. The two gin rows also moved the top file to a more apt
+one — `routergroup.go` over the thin `ginS` singleton wrapper, `context.go` over
+`recovery.go` — because concentration is decided by weight rather than by which file
+happened to be small.
+
+**Exception to "no previously-unclipped file becomes clipped".** `memory-budget.ts` was
+unclipped-whole at 5,672 and now clusters within its 3.1 KB reservation. That is the epic's
+own diagnosis of the bug rather than a regression: it scored 18 against `tools.ts`'s 58 and
+was taking the larger slice purely for being small enough to ship whole. The guarantee holds
+where it was meant to — no file loses bytes to a *tighter cap*; the only files that lose are
+ones the proportional split says were over-served.
+
 ## The regression fixtures (CG-6)
 
 Two fixtures pin the failure mode so it can never silently return. They were written to
-**fail** — that is what they were for. CG-10 closed the ranking half of both; the byte-split
-assertions still fail and are the pass gate for CG-12. The numbers quoted below are the
-**pre-CG-10 baseline**; see "Measured effect" above for where they stand now.
+**fail** — that is what they were for. CG-10 closed the ranking half of both and CG-12 the
+byte-split half; **both now pass** and are live regressions. The numbers quoted below are
+the **pre-CG-10 baseline**; see the two "Measured effect" tables above for where they stand.
 
 They are declared in `scripts/agent-eval/allocation-fixtures.json` and run by
 `scripts/agent-eval/probe-allocation.mjs`, which drives the CG-4 diagnostic through a JSONL

+ 29 - 6
scripts/agent-eval/allocation-fixtures.json

@@ -4,12 +4,12 @@
     "explore budget allocation. Run them with `node scripts/agent-eval/probe-allocation.mjs`",
     "against a built dist/.",
     "",
-    "STATUS: CG-10 (relevance scoring) closed the RANKING half of both fixtures  nothing",
-    "incidental reaches the envelope any more. What still fails is the BYTE SPLIT among the",
-    "files that correctly ranked in, because the render loop spends by file size: a small",
-    "weakly-relevant file ships whole while the strongly-relevant one is clipped at",
-    "maxCharsPerFile. That is CG-12's gate. Each fixture's `baseline` records the pre-CG-10",
-    "numbers; `afterCG10` records where it stands now.",
+    "STATUS: BOTH FIXTURES PASS. CG-10 (relevance scoring) closed the RANKING half —",
+    "nothing incidental reaches the envelope any more — and CG-12 (score-proportional",
+    "allocation with a relative cliff) closed the BYTE SPLIT: each file's share is reserved",
+    "before anything renders, and a file under 15% of the top weight gets no source at all,",
+    "freeing both its bytes and its maxFiles slot. Each fixture records the pre-CG-10",
+    "`baseline`, the interim `afterCG10`, and the current `afterCG12`.",
     "",
     "`groups` partitions the files explore rendered into `answer` (what the query is",
     "actually about) and `incidental` (what wins the envelope today on name collisions).",
@@ -81,6 +81,19 @@
           "internal/gen/fkit/payroll/payslip.go": 0.0
         },
         "verdict": "PASSES answerShareAtLeast (61.5%), incidentalShareAtMost (23.5%), topFileGroup, and the cycle.go + runPayrollCycleAll + real-Upsert needles. The generated files now rank #3/#4 instead of #1/#2 — kind weighting plus a 0.3x generated penalty on BOTH the score and the graph mass, which is the key the comparator sorts on. STILL FAILING for CG-12: payslip_builder.go ranks #6 against the tier's maxFiles of 4, so `func (s *Service) BuildPayslip` never renders."
+      },
+      "afterCG12": {
+        "measuredOn": "2026-08-04",
+        "note": "19,337 delivered, nothing truncated. Both generated files cliffed to pointers (weight 3.9 and 3.5 against a cliff of 5.4), which frees the two maxFiles slots the hand-written store and builder then take.",
+        "delivered": {
+          "internal/usecase/payroll/cycle.go": 0.306,
+          "internal/domain/payroll/payslip.go": 0.212,
+          "internal/usecase/payroll/payslip_builder.go": 0.151,
+          "internal/store/payslipstore/store.go": 0.118,
+          "internal/gen/fkit/payroll/payroll_cycle.go": 0.0,
+          "internal/gen/fkit/payroll/payslip.go": 0.0
+        },
+        "verdict": "ALL GATES PASS. Answer group 78.7% (from 25.6% at baseline), generated layer 0.0% (from 57.4%). All four hand-written files deliver source, including payslip_builder.go — `func (s *Service) BuildPayslip`, the 'calculate' half of the question, finally reaches the agent. The generated files are still NAMED with their symbols and line numbers under 'Not shown above', so withholding their bytes costs ~100 chars each instead of ~4,500 and stays one follow-up explore away."
       }
     },
     {
@@ -130,6 +143,16 @@
           "src/mcp/tools.ts": 0.329
         },
         "verdict": "PASSES incidentalShareAtMost: every scripts/agent-eval/*.mjs file is gone (0.0%), which is CG-10's acceptance bar — their sole match was an unused file-scope `explore`/`BUDGET` constant, worth 0.08x weight, and the relative floor (8.2) then cut them. tools.ts ranks #1. STILL FAILING for CG-12: memory-budget.ts scores 18 to tools.ts's 41 yet takes 51.2% of the envelope to tools.ts's 32.9%, purely because it is small enough to ship whole while tools.ts is clipped at maxCharsPerFile. Allocation still follows file size, not relevance."
+      },
+      "afterCG12": {
+        "measuredOn": "2026-08-04",
+        "note": "18,134 delivered, nothing truncated. tools.ts scores 58 here (it grew by the allocator this task added), memory-budget.ts 18.",
+        "delivered": {
+          "src/mcp/tools.ts": 0.606,
+          "src/resolution/memory-budget.ts": 0.172,
+          "src/resolution/lru-cache.ts": 0.111
+        },
+        "verdict": "ALL GATES PASS. tools.ts takes 60.6% of the envelope, up from 18.5% at baseline and 32.9% after CG-10 — past the epic's >50% acceptance bar. The reversal is the whole point: memory-budget.ts no longer wins by being small enough to ship whole (it now clusters within its 3.1K reservation), and tools.ts is no longer clipped at maxCharsPerFile (11K reservation, ~3x the old flat cap). Exception to 'no previously-unclipped file becomes clipped': memory-budget.ts was unclipped-whole at 5,672 and is now clipped to its proportional share. That is the epic's own diagnosis of the bug, not a regression — it scored 18 against tools.ts's 58 and was taking the larger slice."
       }
     }
   ]

+ 68 - 6
src/mcp/explore-diagnostics.ts

@@ -45,9 +45,9 @@ export type ExploreRenderMode =
 /** Why a ranked candidate never reached the output. */
 export type ExploreSkipReason =
   | 'max-files'          // maxFiles reached before this file
-  | 'budget-90pct'       // incidental file past the 90%-of-budget soft stop
-  | 'budget-whole-file'  // incidental whole-file render wouldn't fit
-  | 'budget-clusters'    // incidental cluster render wouldn't fit
+  | 'cliff'              // below the relevance cliff — pointer, not bytes (CG-12)
+  | 'budget-whole-file'  // whole-file render wouldn't fit under the hard ceiling
+  | 'budget-clusters'    // cluster render wouldn't fit under the hard ceiling
   | 'unreadable'         // outside root, missing, or read error
   | 'no-ranges';         // no renderable line ranges in this file
 
@@ -82,6 +82,14 @@ export interface ExploreCandidateMeta {
 
 interface FileRecord extends ExploreCandidateMeta {
   path: string;
+  /**
+   * Chars this file was RESERVED by the proportional allocator (CG-12), before
+   * it rendered anything. `0` = cliffed; `null` = never reached the allocator.
+   * The gap between this and `emittedChars` is the whole story of a budget bug:
+   * reserved-but-unspent means the file had nothing to say, spent-over-reserved
+   * means an oversize first cluster or the whole-file grace overshot.
+   */
+  allowance: number | null;
   render?: ExploreRenderMode;
   /** Source chars the render loop handed to `lines` (pre-final-truncation). */
   emittedChars: number;
@@ -117,6 +125,7 @@ interface BudgetShape {
 /** One file's line in the report. Also the JSONL sidecar's per-file shape. */
 export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
   path: string;
+  allowance: number | null;
   render: ExploreRenderMode | null;
   skipped: ExploreSkipReason | null;
   clipped: boolean;
@@ -163,6 +172,17 @@ export interface ExploreDiagnosticReport {
     filesRenderedByLoop: number;
     filesInFinalOutput: number;
   };
+  /** The proportional split (CG-12): what each file was promised, and why. */
+  allocation: {
+    /** Chars divided among admitted files (envelope minus per-file overhead). */
+    pool: number;
+    /** Weight threshold the cliff fired at; 0 when nothing was cliffed. */
+    cliffAt: number;
+    /** Files given zero source — pointers in the not-shown list instead. */
+    cliffed: string[];
+    /** Sum of reservations. Must not exceed `pool`. */
+    reserved: number;
+  };
   files: ExploreDiagnosticFile[];
 }
 
@@ -201,6 +221,9 @@ export class ExploreDiagnostics {
   private graphGateThreshold = 0;
   private graphGateApplied = false;
   private note = '';
+  private allocPool = 0;
+  private allocCliffAt = 0;
+  private allocCliffed: string[] = [];
 
   private constructor(
     private readonly sink: Sink,
@@ -260,11 +283,34 @@ export class ExploreDiagnostics {
   /** Record one ranked candidate's scoring inputs, in final sort order. */
   noteCandidate(path: string, meta: ExploreCandidateMeta): void {
     this.files.set(path, {
-      path, ...meta,
+      path, ...meta, allowance: null,
       emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
     });
   }
 
+  /**
+   * Record the proportional split (CG-12), taken right after ranking and before
+   * a single byte renders. Called once per explore.
+   */
+  setAllocation(
+    allowances: ReadonlyMap<string, number>,
+    cliffed: readonly string[],
+    cliffAt: number,
+    pool: number,
+  ): void {
+    this.allocPool = pool;
+    this.allocCliffAt = cliffAt;
+    this.allocCliffed = [...cliffed];
+    for (const [path, chars] of allowances) {
+      const rec = this.files.get(path);
+      if (rec) rec.allowance = chars;
+    }
+    for (const path of cliffed) {
+      const rec = this.files.get(path);
+      if (rec) rec.allowance = 0;
+    }
+  }
+
   /** A candidate rendered source into the response. */
   recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void {
     const rec = this.files.get(path);
@@ -366,6 +412,12 @@ export class ExploreDiagnostics {
         filesRenderedByLoop: filesIncluded,
         filesInFinalOutput: rendered.length,
       },
+      allocation: {
+        pool: this.allocPool,
+        cliffAt: round6(this.allocCliffAt),
+        cliffed: [...this.allocCliffed],
+        reserved: records.reduce((s, r) => s + (r.allowance ?? 0), 0),
+      },
       files: records
         .slice()
         .sort((a, b) => b.emittedChars - a.emittedChars || b.finalChars - a.finalChars || a.rank - b.rank)
@@ -384,6 +436,7 @@ export class ExploreDiagnostics {
           generated: r.generated,
           penalty: round6(r.penalty),
           kinds: r.kinds,
+          allowance: r.allowance,
           render: r.render ?? null,
           skipped: r.skipped ?? null,
           clipped: r.clipped,
@@ -492,6 +545,14 @@ export function renderTable(report: ExploreDiagnosticReport): string {
     `  relevance gate ${sel.graphGateApplied ? 'applied' : 'not applied'}` +
     ` at graph >= ${sel.graphGateThreshold.toFixed(5)} (6% of max ${sel.maxGraph.toFixed(5)})`,
   );
+  const alloc = report.allocation;
+  out.push(
+    `  allocation ${num(alloc.reserved)} reserved of ${num(alloc.pool)} pool` +
+    ` · cliff at weight ${alloc.cliffAt.toFixed(2)}` +
+    (alloc.cliffed.length > 0
+      ? ` · ${alloc.cliffed.length} cliffed to pointers: ${alloc.cliffed.join(', ')}`
+      : ' · nothing cliffed'),
+  );
   out.push('');
 
   // Allocated (not delivered) is the allocator's own decision — the number the
@@ -499,7 +560,7 @@ export function renderTable(report: ExploreDiagnosticReport): string {
   // when the ceiling truncated; showing both makes that divergence obvious.
   const shown = files.filter((f) => f.emittedChars > 0 || f.finalChars > 0);
   if (shown.length > 0) {
-    out.push('   #  alloc%  deliv%    bytes  score    graph  hits  pen   flags                render     file');
+    out.push('   #  alloc%  deliv%    bytes  reserved  score    graph  hits  pen   flags                render     file');
     for (const f of shown) {
       out.push(
         '  ' +
@@ -507,6 +568,7 @@ export function renderTable(report: ExploreDiagnosticReport): string {
         pct(f.allocatedShare).padStart(6) + '  ' +
         pct(f.share).padStart(6) + '  ' +
         num(f.emittedChars).padStart(7) + '  ' +
+        (f.allowance === null ? '-' : num(f.allowance)).padStart(8) + '  ' +
         f.score.toFixed(1).padStart(5) + '  ' +
         f.graphScore.toFixed(5).padStart(7) + '  ' +
         String(f.termHits).padStart(4) + '  ' +
@@ -531,7 +593,7 @@ export function renderTable(report: ExploreDiagnosticReport): string {
       out.push(
         `    #${String(f.rank).padStart(2)} ${f.path} — ${f.skipped ?? f.render ?? 'not reached'}` +
         ` (score ${f.score.toFixed(1)}, graph ${f.graphScore.toFixed(5)}, hits ${f.termHits},` +
-        ` pen ${f.penalty.toFixed(2)}, ${f.kinds || '-'})`,
+        ` pen ${f.penalty.toFixed(2)}, ${flagString(f) || 'no flags'}, ${f.kinds || '-'})`,
       );
     }
     if (skipped.length > 15) out.push(`    … and ${skipped.length - 15} more`);

+ 393 - 71
src/mcp/tools.ts

@@ -425,6 +425,215 @@ const SCORE_FLOOR_MAX = 10;
  */
 const SCORE_FLOOR_KEEP_MIN = 3;
 
+// ── Score-proportional byte allocation (CG-12 / #1500) ─────────────────────
+//
+// The score floor above decides WHICH files reach the response. This decides how
+// the byte envelope is SPLIT among them — and until this existed, it wasn't
+// really decided at all: every admitted file was capped at the same
+// `maxCharsPerFile`, and the whole-file rule handed anything under
+// `maxCharsPerFile * 3` its entire contents. So allocation followed FILE SIZE,
+// not relevance. On this repo's own "how does explore allocate its output budget
+// across files", `src/mcp/tools.ts` (score 41, 4x the graph mass, 3x the distinct
+// term hits — it literally holds the allocator) was clipped at 3,800 while a
+// score-18 file shipped whole at 5,672 and took 51% of the envelope, purely for
+// being small. On the #1500 Go fixture, two generated CRUD files shipped whole at
+// ~4.5K each and consumed the tier's 4 file slots, so `BuildPayslip` — the
+// hand-written half of "create and calculate payslips" — never appeared at all.
+//
+// The replacement: reserve each file a share of the envelope proportional to what
+// it is worth, up front, before anything renders. Three consequences:
+//
+//   1. A reservation is a GUARANTEE, not a race. The old loop spent the envelope
+//      first-come-first-served in rank order, so the top two files could exhaust
+//      it and every later file hit a `budget-90pct` skip regardless of merit.
+//   2. A file below the cliff gets ZERO source — its path, symbols and line
+//      numbers only. It costs ~100 chars instead of ~4,500, and (crucially) it
+//      does not consume a `maxFiles` slot, so the slot goes to a file that earns
+//      its bytes. This is the concentration lever.
+//   3. The per-file cap stops being the primary guard. It survives only as
+//      `ALLOC_MAX_SHARE`, a safety valve against a single god-file — which the
+//      proportional split already bounds, since a file's share can't exceed its
+//      weight share.
+const EXPLORE_ALLOCATION = {
+  /**
+   * A file whose weight is under this fraction of the top file's gets no source.
+   *
+   * Calibrated between the two shapes the fixtures pin: the #1500 generated CRUD
+   * lands at 10–11% of the top weight (penalised twice — once into the score by
+   * `rankPenalty`, once again here) and must cliff; a genuinely peripheral but
+   * hand-written flow file — `payslip_builder.go`, the direct callee of the
+   * workflow entry — lands at 25% and must NOT. Everything in between is a
+   * judgement call the agent can undo for ~0 cost, because a cliffed file is
+   * still NAMED in the response and one follow-up explore fetches it.
+   */
+  CLIFF_FRACTION: 0.15,
+  /**
+   * Ceiling on the cliff, in the same units as `SCORE_FLOOR_MAX` — and for the
+   * same reason. A file whose weight clears a full-strength direct match is never
+   * incidental, so no amount of concentration elsewhere may zero it: one
+   * overwhelming top file (a 99-scoring god-file among score-10 peers) otherwise
+   * puts the cliff at 14.9 and silences every peer the score floor had just
+   * deliberately admitted. The cliff is a RELATIVE prune of weak evidence, not a
+   * second admission gate — the score floor already owns admission.
+   */
+  CLIFF_MAX: SCORE_FLOOR_MAX,
+  /**
+   * Floor on a useful reservation — every admitted file gets this much before
+   * the proportional split divides the rest. Under it a slice can't hold one
+   * complete method, and a fragment is strictly worse than a pointer: it forces
+   * the Read this tool exists to prevent.
+   *
+   * It is a FLOOR, not a second cliff. Cliffing the starved file instead
+   * cascades: removing the smallest raises everyone else's share by so little
+   * that the next-smallest starves too, and a query with two dominant files ate
+   * six legitimately-ranked peers one at a time. Concentration is the relative
+   * cliff's job; this only keeps a served file's slice usable.
+   */
+  MIN_CHARS: 700,
+  /**
+   * Safety valve, as a fraction of the envelope. Not the primary guard any more —
+   * the proportional split is — so this only has to stop a pathological
+   * single-file response.
+   */
+  MAX_SHARE: 0.7,
+  /**
+   * Markdown overhead charged per rendered file (header + fences + blank lines),
+   * matching the render loop's own `+ 200` accounting. Held out of the pool
+   * before the split so the reservations plus their overhead fit the envelope —
+   * without this the last file's reservation is always the one that doesn't fit.
+   */
+  FILE_OVERHEAD: 200,
+  /**
+   * Flow-spine files are weighted up and are exempt from the cliff. Clipping the
+   * spine causes the Read fallback (it IS the answer to a flow question);
+   * clipping a peripheral file does not. This makes the existing advisory spine
+   * handling — `hasSpine`, `SPINE_CEILING` — strict at the allocation layer.
+   */
+  SPINE_WEIGHT_BOOST: 2,
+  /**
+   * Slack allowed on the whole-file rule: a file a little over its reservation
+   * still ships WHOLE rather than as clusters, because slicing off that last
+   * sliver saves ~1% of the envelope and costs a Read — the trade the whole-file
+   * rule exists to refuse. Proportional (with an absolute ceiling) because a
+   * "sliver" is relative: a flat 800 is 15% of a 5K reservation but 31% of a 2.5K
+   * one, and at the small end that overshoot is exactly what the file below then
+   * loses.
+   */
+  WHOLE_FILE_GRACE_FRACTION: 0.15,
+  WHOLE_FILE_GRACE_MAX: 800,
+} as const;
+
+/** One candidate file's allocation inputs, in final rank order. */
+export interface ExploreAllocationCandidate {
+  path: string;
+  /** Post-`rankPenalty` relevance score from the ranking pass. */
+  score: number;
+  /**
+   * How much this file's BYTES are worth, independent of how well it matched.
+   * Ranking answers "is this file about the query"; allocation answers "will
+   * these bytes teach the agent anything". Generated CRUD can legitimately rank
+   * (it name-collides on every domain word) while its bytes stay mechanical
+   * boilerplate the agent gains nothing from reading — so `rankPenalty` is
+   * applied a SECOND time here. That is what finally sinks the #1500 generated
+   * layer below the cliff: it survived CG-10's single penalty because the sort's
+   * leading keys (entry-point, graph mass) are structural, and a big densely
+   * self-referential generated file scores well on both.
+   */
+  worth: number;
+  /** Carries a symbol on the rendered flow spine. */
+  spine: boolean;
+}
+
+export interface ExploreAllocation {
+  /** path → chars of source it may render. Only holds admitted files. */
+  allowances: Map<string, number>;
+  /** Files the cliff zeroed, in rank order — pointers, not bytes. */
+  cliffed: string[];
+  /** The weight threshold the cliff fired at (0 when nothing was cliffed). */
+  cliffAt: number;
+  /** Chars actually split among the admitted files. */
+  pool: number;
+}
+
+/**
+ * Split `budget.maxOutputChars` across ranked candidates in proportion to
+ * relevance, with a hard relative cliff.
+ *
+ * `candidates` must arrive in FINAL RANK ORDER — `maxFiles` is applied to the
+ * survivors of the cliff, in that order, so cliffing genuinely hands a slot to
+ * the next file down rather than leaving it unused.
+ *
+ * Tier invariant (`getExploreOutputBudget`): a larger tier must never allow less
+ * per file than a smaller one. It holds here by construction — every bound is a
+ * fraction of `maxOutputChars` or of `maxCharsPerFile`, both monotonic across
+ * tiers — except `MIN_CHARS`, which is an absolute floor and so identical at
+ * every tier.
+ */
+export function allocateExploreBudget(
+  candidates: readonly ExploreAllocationCandidate[],
+  budget: ExploreOutputBudget,
+  maxFiles: number,
+): ExploreAllocation {
+  const A = EXPLORE_ALLOCATION;
+  const empty: ExploreAllocation = { allowances: new Map(), cliffed: [], cliffAt: 0, pool: 0 };
+  if (candidates.length === 0) return empty;
+
+  const weightOf = (c: ExploreAllocationCandidate) =>
+    Math.max(0, c.score) * Math.max(0, Math.min(1, c.worth)) * (c.spine ? A.SPINE_WEIGHT_BOOST : 1);
+
+  const weights = new Map(candidates.map((c) => [c.path, weightOf(c)]));
+  const topWeight = Math.max(...weights.values());
+  if (!(topWeight > 0)) return empty;
+
+  // Cliff over the WHOLE candidate list, before `maxFiles` — otherwise the file
+  // cap fills with cliff-bound files and the slot they free is never handed on.
+  const cliffAt = Math.min(topWeight * A.CLIFF_FRACTION, A.CLIFF_MAX);
+  const cliffed: string[] = [];
+  let admitted: ExploreAllocationCandidate[] = [];
+  for (const c of candidates) {
+    if (!c.spine && (weights.get(c.path) ?? 0) < cliffAt) cliffed.push(c.path);
+    else admitted.push(c);
+  }
+  // Never cliff every candidate: an empty response costs a whole round-trip.
+  if (admitted.length === 0) {
+    admitted = [candidates[0]!];
+    cliffed.splice(cliffed.indexOf(candidates[0]!.path), 1);
+  }
+  for (const c of admitted.slice(maxFiles)) cliffed.push(c.path);
+  admitted = admitted.slice(0, maxFiles);
+
+  // Serve fewer files well rather than many badly: the envelope has to afford
+  // MIN_CHARS for everything admitted. When it can't, cliff the lowest-weight
+  // files (never a spine file, never the last one) in one deterministic trim —
+  // not one at a time, which is how the old starvation rule snowballed.
+  const affordable = Math.max(1, Math.floor(budget.maxOutputChars / (A.MIN_CHARS + A.FILE_OVERHEAD)));
+  if (admitted.length > affordable) {
+    const byWeight = [...admitted].sort((a, b) => (weights.get(b.path) ?? 0) - (weights.get(a.path) ?? 0));
+    const keep = new Set(byWeight.slice(0, affordable).map((c) => c.path));
+    for (const c of admitted) if (c.spine) keep.add(c.path);
+    for (const c of admitted) if (!keep.has(c.path)) cliffed.push(c.path);
+    admitted = admitted.filter((c) => keep.has(c.path));
+  }
+
+  const allowances = new Map<string, number>();
+  const pool = Math.max(0, budget.maxOutputChars - A.FILE_OVERHEAD * admitted.length);
+  const total = admitted.reduce((s, c) => s + (weights.get(c.path) ?? 0), 0);
+  if (total <= 0 || admitted.length === 0) return { allowances, cliffed, cliffAt, pool };
+  // Everyone gets MIN_CHARS; the REMAINDER is what splits by weight. The floor
+  // is what keeps a diffuse survey question returning a useful spread, and the
+  // remainder is what concentrates a precise one — the top file's slice grows
+  // with its weight share, uncapped by any flat per-file limit.
+  const ceiling = Math.round(budget.maxOutputChars * A.MAX_SHARE);
+  const floors = Math.min(pool, A.MIN_CHARS * admitted.length);
+  const remainder = Math.max(0, pool - floors);
+  for (const c of admitted) {
+    const share = Math.floor(floors / admitted.length)
+      + Math.round((remainder * (weights.get(c.path) ?? 0)) / total);
+    allowances.set(c.path, Math.min(share, ceiling));
+  }
+  return { allowances, cliffed, cliffAt, pool };
+}
+
 /**
  * Whether `codegraph_explore` should prefix source lines with their line
  * numbers (cat -n style: `<num>\t<code>`).
@@ -3497,6 +3706,26 @@ export class ToolHandler {
       });
     }
 
+    // Score-proportional byte allocation (CG-12). Every file's share of the
+    // envelope is reserved HERE, before a single byte renders, so the render loop
+    // spends a reservation instead of racing for whatever the files above it left.
+    const allocation = allocateExploreBudget(
+      sortedFiles.map(([fp, group]) => ({
+        path: fp,
+        score: group.score,
+        worth: rankPenalty(fp),
+        spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
+      })),
+      budget,
+      maxFiles,
+    );
+    diag?.setAllocation(allocation.allowances, allocation.cliffed, allocation.cliffAt, allocation.pool);
+    // Cliffed files ship as pointers — path, symbols, line numbers — so the agent
+    // can name one in a follow-up explore. Rendered below with the other
+    // not-shown files, and force-enabled even on tiers that suppress that list:
+    // a file we deliberately withheld source for must still be nameable.
+    const cliffedFiles = new Set(allocation.cliffed);
+
     // Polymorphic-sibling detector for adaptive sizing. A class that implements/
     // extends a supertype shared by >= MIN_SIBLINGS classes is one of many
     // INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
@@ -3557,6 +3786,13 @@ export class ToolHandler {
     lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
     lines.push('');
 
+    // Absolute stop for the render loop. Reservations already fit the envelope, so
+    // this only catches their bounded overshoot (the whole-file grace, an oversize
+    // first cluster) — and catches it HERE, where a file can be skipped cleanly and
+    // a later one still render, instead of at the final truncation, which lops off
+    // whichever section happened to land last. Kept in sync with `hardCeiling`
+    // below; the margin covers the drift epilogue and the trailing notes.
+    const renderCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000) - 600;
     let totalChars = lines.join('\n').length;
     let filesIncluded = 0;
     // Paths we actually render source for below. Drives the curated header count
@@ -3577,19 +3813,22 @@ export class ToolHandler {
         if (diag) for (const [fp] of sortedFiles) diag.recordSkip(fp, 'max-files');
         break;
       }
-      // A file DEFINES a named/spine symbol (the answer) vs merely references the
-      // flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
-      // for necessary ones, which render even past the cap (bounded by maxFiles).
-      // Without this `continue` (was an unconditional `break`), the loop stopped
-      // after the build + validators-exec files and never reached the ranked-in
-      // validate-logic file (Alamofire's Validation.swift).
-      const fileNecessary = group.nodes.some(n =>
-        entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
-      if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9) {
-        diag?.recordSkip(filePath, 'budget-90pct');
+      // Below the relevance cliff: no source, no `maxFiles` slot. It is still
+      // named — with its matched symbols and their line numbers — in the
+      // not-shown list, so one follow-up explore fetches it in full.
+      if (cliffedFiles.has(filePath)) {
+        diag?.recordSkip(filePath, 'cliff');
+        continue;
+      }
+      // This file's reserved share of the envelope. Every render path below is
+      // bounded by it instead of by the flat per-file cap, which is what stops
+      // allocation from following file size: a small weakly-relevant file no
+      // longer ships whole while the strongly-relevant one is clipped.
+      const allowance = allocation.allowances.get(filePath);
+      if (allowance === undefined) {
+        diag?.recordSkip(filePath, 'max-files');
         continue;
       }
-
       const absPath = validatePathWithinRoot(projectRoot, filePath);
       if (!absPath || !existsSync(absPath)) {
         diag?.recordSkip(filePath, 'unreadable');
@@ -3648,7 +3887,7 @@ export class ToolHandler {
         .filter(n => CALLABLE_BODY.has(n.kind) && (flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)))
         .reduce((s, n) => s + fileLines.slice(n.startLine - 1, n.endLine).join('\n').length, 0);
       const onSpineGodFile = hasSpineNode
-        && namedBodyChars > budget.maxCharsPerFile
+        && namedBodyChars > allowance
         && group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
       if (!fileStale && adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
           && (onSpineGodFile || (!hasSpineNode && isPolymorphicSibling(group.nodes) && !spared))) {
@@ -3666,14 +3905,14 @@ export class ToolHandler {
           : flow.pathNodeIds.has(n.id) ? 0
           : flow.uniqueNamedNodeIds.has(n.id) ? 1
           : (fileDefinesSuper && flow.namedNodeIds.has(n.id)) ? 2 : 99;
-        // One ~250-line WINDOW per file. syms are taken by priority (spine first,
-        // then uniquely-named, then family-base), and the cap applies to ALL of
-        // them — including the spine — so a big-spine god-file (tokio's worker.rs:
-        // run→run_task→next_task→steal_work) can't eat the whole response and
-        // starve the co-flow file (harness.rs's poll). The native agent windows
-        // such a file too (~190 lines at a time), so this mimics, not truncates.
-        // Always emit ≥1 (never an empty section).
-        const bodyCap = budget.maxCharsPerFile * 1.5;
+        // One WINDOW per file, sized by this file's RESERVATION. syms are taken by
+        // priority (spine first, then uniquely-named, then family-base), and the cap
+        // applies to ALL of them — including the spine — so a big-spine god-file
+        // (tokio's worker.rs: run→run_task→next_task→steal_work) can't eat the whole
+        // response and starve the co-flow file (harness.rs's poll). The native agent
+        // windows such a file too (~190 lines at a time), so this mimics, not
+        // truncates. Always emit ≥1 (never an empty section).
+        const bodyCap = allowance;
         const bodyIds = new Set<string>();
         let bodyChars = 0;
         for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
@@ -3746,16 +3985,19 @@ export class ToolHandler {
       // the ceiling and falls through to sectioning/clustering below — full method
       // bodies + signatures — so we never dump (or overflow on) a whole god-file.
       const isCentralFile = centralFiles.has(filePath);
-      // Central files get a slightly larger whole-file window than peripheral ones,
-      // but a TIGHT one (~1.5× the per-file cap): the native read of a central file
-      // is a ~150–250 line orientation window, NOT the whole file. A flat "whole
-      // central file" both overflowed the inline cap AND starved the co-flow files
-      // (worker.rs ate the budget, dropping harness.rs's poll). A larger central
-      // file falls through to per-method windowing/clustering below.
+      // A file ships whole when it fits its RESERVATION (plus a small grace — see
+      // WHOLE_FILE_GRACE). This is the site of the #1500 allocation bug: the
+      // peripheral bound used to be a flat `maxCharsPerFile * 3`, so ANY file under
+      // ~11K shipped its entire contents regardless of relevance, while a
+      // high-scoring file too big for that window was clipped to `maxCharsPerFile`
+      // — a 3x swing decided by file size alone. Tying both bounds to the
+      // reservation removes the swing without touching the rule's purpose (a small
+      // file sliced is a lossy subset the agent just Reads in full anyway).
       const WHOLE_FILE_MAX_LINES = isCentralFile ? 280 : 220;
-      const WHOLE_FILE_MAX_CHARS = isCentralFile
-        ? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
-        : budget.maxCharsPerFile * 3;
+      const WHOLE_FILE_MAX_CHARS = allowance + Math.min(
+        EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_MAX,
+        Math.round(allowance * EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_FRACTION),
+      );
       if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
         const body = fileContent.replace(/\n+$/, '');
         let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
@@ -3772,10 +4014,9 @@ export class ToolHandler {
         const staleSuffix = fileStale ? ' · ⚠ changed since last index sync — source below is current; the symbol list may be outdated' : '';
         const wholeHeader = fileSectionHeader(filePath, (omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')) + staleSuffix);
 
-        if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
-          // Don't slice a whole file mid-method: an incidental file that doesn't
-          // fit is skipped; a necessary one (below) renders in full. Half a file
-          // forces the Read this is meant to prevent.
+        if (totalChars + wholeSection.length + 200 > renderCeiling) {
+          // Don't slice a whole file mid-method — a file that doesn't fit is
+          // skipped whole. Half a file forces the Read this is meant to prevent.
           anyFileTrimmed = true;
           diag?.recordSkip(filePath, 'budget-whole-file');
           continue;
@@ -3883,8 +4124,16 @@ export class ToolHandler {
       }
 
       const gapThreshold = budget.gapThreshold;
-      const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineCallLine?: number }> = [];
-      let current = {
+      type ExploreRange = typeof ranges[number];
+      type ExploreCluster = {
+        start: number; end: number; symbols: string[]; score: number;
+        maxImportance: number; hasSpine: boolean; spineCallLine?: number;
+        /** The whole symbol ranges this cluster merged — the unit an oversize
+         *  cluster is shrunk by, so shrinking never cuts through a body. */
+        members: ExploreRange[];
+      };
+      const clusters: ExploreCluster[] = [];
+      let current: ExploreCluster = {
         start: ranges[0]!.start,
         end: ranges[0]!.end,
         symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
@@ -3892,6 +4141,7 @@ export class ToolHandler {
         maxImportance: ranges[0]!.importance,
         hasSpine: ranges[0]!.spine,
         spineCallLine: ranges[0]!.spineCallLine,
+        members: [ranges[0]!],
       };
 
       for (let i = 1; i < ranges.length; i++) {
@@ -3903,6 +4153,7 @@ export class ToolHandler {
           current.maxImportance = Math.max(current.maxImportance, r.importance);
           current.hasSpine = current.hasSpine || r.spine;
           current.spineCallLine = current.spineCallLine ?? r.spineCallLine;
+          current.members.push(r);
         } else {
           clusters.push(current);
           current = {
@@ -3913,6 +4164,7 @@ export class ToolHandler {
             maxImportance: r.importance,
             hasSpine: r.spine,
             spineCallLine: r.spineCallLine,
+            members: [r],
           };
         }
       }
@@ -3962,6 +4214,50 @@ export class ToolHandler {
         return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
       };
 
+      /**
+       * Shrink an oversize cluster to the highest-importance symbols inside it
+       * that fit `cap`, rendered in source order with gap markers (CG-12).
+       *
+       * A cluster is a MERGE of whole symbol ranges, and on a densely-packed file
+       * every symbol merges into one blob spanning the file — cycle.go's 209-line
+       * `Service` is one cluster covering `RunCycle`, `runPayrollCycleAll` and
+       * seven incidental accessors. The old rule took the top-ranked cluster whole
+       * however big it was, so a single-cluster file simply ignored its budget:
+       * it took ~40% more than it was allotted, and the file below it was then
+       * dropped for lack of room (that is how `BuildPayslip` — the "calculate"
+       * half of the #1500 query — went missing entirely). Shrinking by MEMBER
+       * keeps every rule that matters: only whole symbol ranges are emitted, so a
+       * body is never cut, and the members are chosen by the same importance the
+       * cluster ranking uses. Returns null when nothing needed shrinking.
+       */
+      const shrinkCluster = (c: ExploreCluster, cap: number): string | null => {
+        if (c.members.length < 2) return null;
+        const byImportance = [...c.members].sort((a, b) =>
+          b.importance - a.importance || (a.end - a.start) - (b.end - b.start) || a.start - b.start);
+        const sizeOf = (r: ExploreRange) => fileLines.slice(r.start - 1, r.end).join('\n').length;
+        const keep: ExploreRange[] = [];
+        let kept = 0;
+        for (const r of byImportance) {
+          const sz = sizeOf(r) + GAP_MARKER.length;
+          // Always keep the most important range, even if it alone is oversize —
+          // an empty section sends the agent to Read, which costs far more.
+          if (keep.length > 0 && kept + sz > cap) continue;
+          keep.push(r);
+          kept += sz;
+        }
+        if (keep.length === c.members.length) return null;
+        // Re-merge the kept ranges in source order so adjacent survivors read as
+        // one block rather than a stutter of one-symbol fragments.
+        keep.sort((a, b) => a.start - b.start);
+        const merged: Array<{ start: number; end: number }> = [];
+        for (const r of keep) {
+          const last = merged[merged.length - 1];
+          if (last && r.start <= last.end + gapThreshold) last.end = Math.max(last.end, r.end);
+          else merged.push({ start: r.start, end: r.end });
+        }
+        return merged.map((m) => buildSection(m)).join(GAP_MARKER);
+      };
+
       // Rank clusters for inclusion under the per-file cap. Entry-point
       // clusters come first: a cluster containing a query entry point
       // (importance 10) must outrank a dense block of mere declarations,
@@ -3988,28 +4284,41 @@ export class ToolHandler {
           return a.span - b.span;
         });
 
-      // Per-file budget is the SMALLER of the per-file cap and what's left of the
-      // total output cap — so selection (which ranks by importance) keeps the
+      // Per-file budget is this file's RESERVATION, bounded by what's left before
+      // the hard ceiling — so selection (which ranks by importance) keeps the
       // high-importance clusters and drops peripheral ones, instead of the
       // downstream source-order trim slicing off whatever comes last in the file.
       // That source-order slice is what cut Django's `_fetch_all` (L2237, importance
       // 9 — agent-named) when query.py was the last of four big files to be emitted.
-      const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
-      // Spine ceiling: a flow-path cluster may exceed the per-file cap (the call
-      // path is the answer), but bounded — at most ~2.5× the per-file cap and never
-      // past what's left of the total output cap — so a pathological long in-file
+      // It used to be `min(maxCharsPerFile, remaining)`: a flat cap that clipped the
+      // top-scoring file at the same 3,800 as the weakest one, while the whole-file
+      // branch above handed a small file 3x that. The reservation is the whole point
+      // of CG-12 — bytes follow relevance, not file size.
+      const headroom = Math.max(0, renderCeiling - totalChars - 200);
+      const fileBudget = Math.min(allowance, headroom);
+      // Spine ceiling: a flow-path cluster may exceed the reservation (the call path
+      // IS the answer and clipping it forces the Read), but bounded — 1.5x the
+      // reservation and never past the ceiling — so a pathological long in-file
       // spine can't run away or starve co-flow files entirely.
-      const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, Math.max(0, budget.maxOutputChars - totalChars - 200));
+      const SPINE_CEILING = Math.min(Math.round(allowance * 1.5), headroom);
       const chosenIndices = new Set<number>();
+      // Shrunk renders for oversize clusters, by cluster index (CG-12). Computed
+      // during selection and reused at emission so the two never disagree.
+      const shrunkSections = new Map<number, string>();
       let projectedChars = 0;
       for (const rc of rankedClusters) {
         const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
-        // Always take the top-ranked cluster, even if oversize, so we don't
-        // return an empty file section (agent would then re-Read the file,
-        // negating the savings).
+        // The top-ranked cluster is always taken — an empty file section sends the
+        // agent to Read, negating the savings. But "always taken" is not "taken at
+        // any size": when it overruns the reservation it is SHRUNK to the
+        // highest-importance whole symbol ranges inside it, so a single-cluster
+        // god-file spends its allotment instead of the whole response's.
         if (chosenIndices.size === 0) {
+          const cap = rc.c.hasSpine ? SPINE_CEILING : fileBudget;
+          const shrunk = sectionLen > cap ? shrinkCluster(rc.c, cap) : null;
+          if (shrunk !== null) shrunkSections.set(rc.idx, shrunk);
           chosenIndices.add(rc.idx);
-          projectedChars += sectionLen;
+          projectedChars += shrunk !== null ? shrunk.length : sectionLen;
           continue;
         }
         // A spine cluster (the rendered call path) is the flow answer — include it
@@ -4028,18 +4337,19 @@ export class ToolHandler {
       for (let i = 0; i < clusters.length; i++) {
         if (!chosenIndices.has(i)) continue;
         const cluster = clusters[i]!;
-        const section = buildSection(cluster);
+        const section = shrunkSections.get(i) ?? buildSection(cluster);
         if (fileSection.length > 0) fileSection += GAP_MARKER;
         fileSection += section;
         allSymbols.push(...cluster.symbols);
       }
 
-      // A chosen cluster is a COMPLETE method-range — we never cut through a body.
-      // An oversize single cluster (a long monolithic function) renders in FULL:
-      // half a method is useless (the agent just Reads the rest for the other half),
-      // which is the very fallback explore exists to prevent. A pathological file is
-      // bounded by the per-file cluster SELECTION above + the total hard ceiling.
-      if (chosenIndices.size < clusters.length) {
+      // A chosen cluster is a COMPLETE method-range — we never cut through a body,
+      // and a shrunk cluster drops WHOLE members for the same reason. An oversize
+      // single MEMBER (one long monolithic function) still renders in full: half a
+      // method is useless (the agent just Reads the rest for the other half), which
+      // is the very fallback explore exists to prevent. A pathological file is
+      // bounded by the cluster SELECTION above + the total hard ceiling.
+      if (chosenIndices.size < clusters.length || shrunkSections.size > 0) {
         anyFileTrimmed = true;
       }
 
@@ -4062,20 +4372,16 @@ export class ToolHandler {
         : headerSymbols.join(', ');
       const fileHeader = fileSectionHeader(filePath, headerSuffix);
 
-      // The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
-      // the agent named (or that's on the flow spine) renders even when the
-      // nominal total is used up — it's the answer, and the set is bounded by
-      // maxFiles AND by true-spine/named-seeding having already trimmed each file
-      // to its necessary content. A file that merely REFERENCES the flow
-      // (Combine.swift name-drops request/task) is incidental → still capped, so
-      // freed budget never leaks into noise. This is the last god-file layer:
-      // build (Session, true-spined) + validators-exec (Request) + validate
-      // (DataRequest/Validation) all render, instead of the cap dropping whichever
-      // phase the file order happened to put last.
-      if (!fileNecessary && totalChars + fileSection.length + 200 > budget.maxOutputChars) {
-        // Incidental file that doesn't fit: SKIP it whole — never slice mid-method.
-        // Keep scanning for necessary files (which bypass this cap and render in
-        // full, bounded by the hard ceiling).
+      // Last stop before the hard ceiling. The reservation already bounded cluster
+      // selection above, so reaching this means the bounded overshoot (an oversize
+      // first cluster, taken whole rather than sliced mid-method) ran the response
+      // out of room. Skip the file whole and keep scanning — never slice mid-method.
+      // This used to compare against `maxOutputChars` and exempt "necessary" files,
+      // which is how arrival order decided the answer: whichever files ranked first
+      // spent the envelope, and everything after them was dropped on a cap they had
+      // no say in. Reservations replace that exemption — a file that earned bytes
+      // was already given them.
+      if (totalChars + fileSection.length + 200 > renderCeiling) {
         anyFileTrimmed = true;
         diag?.recordSkip(filePath, 'budget-clusters');
         continue;
@@ -4118,9 +4424,16 @@ export class ToolHandler {
 
     // Add remaining files as references (from both relevant and peripheral files).
     // Small projects (per budget) skip this — the relevant story already fits
-    // in the source section, and a trailing pointer list is pure overhead.
-    if (budget.includeAdditionalFiles) {
-      const remainingRelevant = sortedFiles.slice(filesIncluded);
+    // in the source section, and a trailing pointer list is pure overhead. But a
+    // CLIFFED file is source we deliberately withheld, so the list is forced on
+    // whenever there is one: withholding a file's bytes is only cheap if the agent
+    // can still name it in a follow-up call (CG-12).
+    if (budget.includeAdditionalFiles || cliffedFiles.size > 0) {
+      // Everything ranked that didn't render, in rank order — cliffed files first,
+      // since they outrank whatever the file cap cut. (Indexing by `filesIncluded`
+      // would be wrong now that cliffed files are skipped without consuming a slot.)
+      const rendered = new Set(renderedFilePaths);
+      const remainingRelevant = sortedFiles.filter(([fp]) => !rendered.has(fp));
       // Ranked files are already covered by `remainingRelevant`; the rest of the
       // gather (below the floor) becomes the pointer list. The Set guards the
       // one overlap case — a file the SCORE_FLOOR_KEEP_MIN fallback pulled in
@@ -4133,8 +4446,17 @@ export class ToolHandler {
       if (remainingFiles.length > 0) {
         lines.push('**Not shown above — explore these names for their source**');
         lines.push('');
+        // A pointer only has to make the file NAMEABLE in a follow-up call, so cap
+        // the symbols per line: an un-capped list ran to ~1.9K on the #1500 fixture
+        // (12 generated CRUD symbols on one line), meta-text bought at the price of
+        // the source bytes this section exists to point away from.
+        const POINTER_SYMBOLS = 6;
         for (const [filePath, group] of remainingFiles.slice(0, 10)) {
-          const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
+          const named = group.nodes.filter(n => n.kind !== 'import' && n.kind !== 'export');
+          const shown = (named.length > 0 ? named : group.nodes).slice(0, POINTER_SYMBOLS);
+          const more = (named.length > 0 ? named : group.nodes).length - shown.length;
+          const symbols = shown.map(n => `${n.name}:${n.startLine}`).join(', ')
+            + (more > 0 ? `, +${more} more` : '');
           lines.push(`- ${filePath}: ${symbols}`);
         }
         if (remainingFiles.length > 10) {