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>
This commit is contained in:
Colby McHenry
2026-08-04 00:37:17 -05:00
co-authored by Claude Opus 5
parent a3898cdc70
commit 5f7f5f59df
8 changed files with 814 additions and 97 deletions
+20 -10
View File
@@ -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,
});
});
});