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
+68 -6
View File
@@ -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`);