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:
co-authored by
Claude Opus 5
parent
a3898cdc70
commit
5f7f5f59df
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user