From 1d9206d2d0e96f3f2284b92264bcec9d4791869d Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 4 Aug 2026 00:56:50 -0500 Subject: [PATCH] test(explore): lock down proportional byte allocation (CG-14, #1500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage for the CG-12 allocator, built around "would this go red if the lever were removed" rather than line coverage — every way this regresses is silent, ending in an agent falling back to Read. Unit (`explore-proportional-allocation.test.ts`, 18 -> 38): calibration pins, envelope safety across every tier and 30 candidate shapes, the cliff boundary, spine weighting/trim survival, the diffuse control, and the degenerate inputs — identical scores, a lone file, a runaway top scorer, zero results, maxFiles 0, a non-finite score. End-to-end (`explore-allocation-e2e.test.ts`, new): CG-6's second regression fixture as a deterministic synthetic mirror — a large relevant file, a small helper that used to win by shipping whole, and an incidental `explore`/`BUDGET` collision — asserting per-file budget share, not file presence. Plus degenerate result sets and a survey-style diffuse control through the real render loop. The live self-query arm stays in probe-allocation.mjs, where drift is a number to re-baseline rather than a red suite. Reverting the render loop to the pre-CG-12 rules reproduces #1500 on the mirror exactly and takes 5 e2e + 2 payroll gates red: file score pre-CG-12 CG-12 src/mcp/allocator.ts 77.5 4,843 (39.7%) 9,335 (80.1%) src/util/budget-math.ts 36.0 6,079 (49.8%) 1,037 ( 8.9%) Two defects the invariants surfaced, both fixed in tools.ts: - rounded shares could sum past `pool`, so "reservations fit the envelope" was approximate rather than exact; both terms now floor - a non-finite score made every share Infinity/Infinity, handing the render loop a NaN allowance; `weightOf` now fails safe to 0 Also adds a hard-ceiling gate to the payroll fixture — at 19.3K against a 19.5K ceiling it is the only fixture that stresses the ~25K inline cap — and exports EXPLORE_ALLOCATION so invariant tests read the constants while one test pins the literals. Co-Authored-By: Claude Opus 5 --- __tests__/explore-allocation-1500.test.ts | 17 +- __tests__/explore-allocation-e2e.test.ts | 558 ++++++++++++++++++ .../explore-proportional-allocation.test.ts | 342 ++++++++++- docs/design/explore-budget-allocation.md | 79 +++ src/mcp/tools.ts | 19 +- 5 files changed, 1008 insertions(+), 7 deletions(-) create mode 100644 __tests__/explore-allocation-e2e.test.ts diff --git a/__tests__/explore-allocation-1500.test.ts b/__tests__/explore-allocation-1500.test.ts index ffe513a..5d76d7c 100644 --- a/__tests__/explore-allocation-1500.test.ts +++ b/__tests__/explore-allocation-1500.test.ts @@ -36,7 +36,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import CodeGraph from '../src/index'; -import { ToolHandler } from '../src/mcp/tools'; +import { ToolHandler, getExploreOutputBudget } from '../src/mcp/tools'; import { attributeSourceBytes } from '../src/mcp/explore-diagnostics'; import { isGeneratedFile, hasGeneratedHeader } from '../src/extraction/generated-detection'; @@ -267,6 +267,21 @@ describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', ( expect(response).toMatch(/internal\/gen\/fkit\/payroll\/payslip\.go: \w+:\d+/); }); + it('CG-14 GATE: holds the response inside the hard ceiling under real pressure', () => { + // This fixture is the stress case for the ceiling, not just for the split: + // 19 files put it in the very-tiny tier (13,000-char envelope) while the + // answer genuinely needs more, so the render loop spends its full allowed + // overshoot — ~19.3K against a 19.5K ceiling. That leaves ~1% of headroom, + // which is exactly why this is worth pinning: the bound that matters is the + // host's ~25K inline cap, and above it the response is written to a file + // the agent Reads back, undoing the point of the tool. + const budget = getExploreOutputBudget(cg.getFiles().length); + const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000); + expect(response.length).toBeGreaterThan(budget.maxOutputChars); + expect(response.length).toBeLessThanOrEqual(hardCeiling); + expect(response.length).toBeLessThan(25000); + }); + 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. diff --git a/__tests__/explore-allocation-e2e.test.ts b/__tests__/explore-allocation-e2e.test.ts new file mode 100644 index 0000000..70e4d21 --- /dev/null +++ b/__tests__/explore-allocation-e2e.test.ts @@ -0,0 +1,558 @@ +/** + * Score-proportional explore allocation, end to end (CG-14 / epic CG-1 / #1500). + * + * `explore-proportional-allocation.test.ts` pins `allocateExploreBudget` in + * isolation; `explore-allocation-1500.test.ts` pins the reporter's Go shape. + * What is left — and what this file owns — is everything the allocator only + * *promises*: the render loop has to spend those reservations, the hard ceiling + * has to catch the overshoot, and a degenerate or diffuse result set has to come + * back usable rather than empty. Each of those is invisible to a unit test, + * because the failure mode is not an exception — it is a response the agent + * quietly abandons in favour of Read. + * + * Two halves: + * + * 1. **The self-query fixture's shape.** CG-6 declared a second regression + * fixture beside payroll-go: this repo, asked "how does explore allocate its + * output budget across files", spending 63% of its envelope on + * `scripts/agent-eval/*.mjs` files that merely mention `explore` and + * `BUDGET`, while `src/mcp/tools.ts` — the file that actually answers — sat + * clipped at the flat `maxCharsPerFile`. That fixture reads THIS repo's live + * index, so it belongs to the out-of-band probe + * (`node scripts/agent-eval/probe-allocation.mjs self-query`) where its + * numbers can move with the repo. Reproduced here as a synthetic project so + * `npm test` owns the MECHANISM deterministically: a large relevant file, a + * small genuinely-relevant helper, and an incidental name-collision script. + * + * 2. **Degenerate and diffuse result sets.** One file, no files, all files + * scoring alike, a survey question. The proportional split divides by a total + * weight and concentrates on a leader — both of which have a degenerate case + * that ends in a division by zero or a starved response. + * + * Nothing here is platform-gated: fixtures are written through `path.join`, and + * every path ASSERTED against is an indexed relative path, which extraction + * normalizes to forward slashes on every platform (`normalizePath`, utils.ts). + * A literal like `src/mcp/allocator.ts` is therefore correct on Windows too — + * gate a new assertion with `it.runIf` only if it reaches for a real filesystem + * path or a platform-specific separator. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler, getExploreOutputBudget, EXPLORE_ALLOCATION } from '../src/mcp/tools'; +import { attributeSourceBytes } from '../src/mcp/explore-diagnostics'; +import type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics'; + +/** The host's inline tool-result limit — above it the response is externalized. */ +const INLINE_CAP = 25000; + +const DEBUG_ENV = 'CODEGRAPH_EXPLORE_DEBUG'; + +interface Project { + dir: string; + cg: CodeGraph; + handler: ToolHandler; +} + +/** Build + index a throwaway project from a `{ relPath: source }` map. */ +async function buildProject(prefix: string, files: Record): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + for (const [rel, body] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body.trimStart()); + } + const cg = CodeGraph.initSync(dir); + await cg.indexAll(); + return { dir, cg, handler: new ToolHandler(cg) }; +} + +function destroyProject(project?: Project): void { + if (!project) return; + project.cg.destroy(); + if (fs.existsSync(project.dir)) fs.rmSync(project.dir, { recursive: true, force: true }); +} + +/** + * One explore call, reduced to what the allocation assertions need — plus the + * CG-4 per-file diagnostic, which is where the SCORE and the RESERVATION live. + * The instrument is observational (byte-identical output either way), so reading + * it here measures the same response the agent would have received. + */ +async function explore(project: Project, query: string) { + // Outside the project root on purpose: a sidecar written INTO the indexed tree + // is a new file the watcher can pick up mid-suite. + const sidecar = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-alloc-diag-')), 'report.jsonl'); + const previous = process.env[DEBUG_ENV]; + process.env[DEBUG_ENV] = sidecar; + let result; + try { + result = await project.handler.execute('codegraph_explore', { query }); + } finally { + if (previous === undefined) delete process.env[DEBUG_ENV]; + else process.env[DEBUG_ENV] = previous; + } + const text = result.content?.[0]?.text ?? ''; + const bytes = attributeSourceBytes(text); + const lines = fs.existsSync(sidecar) + ? fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean) + : []; + const report = JSON.parse(lines[lines.length - 1]!) as ExploreDiagnosticReport; + fs.rmSync(path.dirname(sidecar), { recursive: true, force: true }); + const fileOf = (file: string) => report.files.find((f) => f.path === file); + return { + text, + bytes, + report, + isError: result.isError === true, + /** Relevance score the ranking pass gave this file. */ + score: (file: string) => fileOf(file)?.score ?? 0, + /** Chars of source the allocator RESERVED for it, before anything rendered. */ + allowance: (file: string) => fileOf(file)?.allowance ?? 0, + /** Fraction of the WHOLE response this file's source occupies. */ + share: (file: string) => (bytes.get(file) ?? 0) / (text.length || 1), + shareUnder: (prefix: string) => { + let total = 0; + for (const [file, n] of bytes) if (file.startsWith(prefix)) total += n; + return total / (text.length || 1); + }, + }; +} + +// ── 1. The self-query fixture's shape ─────────────────────────────────────── + +describe('#1500 fixture 2 — allocation followed FILE SIZE, not relevance', () => { + /** + * The three roles from the real fixture, at synthetic scale: + * + * - `src/mcp/allocator.ts` — stands in for `src/mcp/tools.ts`. Carries the + * query's terms on real functions with real call edges, and is deliberately + * too big to ship whole, so under the old rule it was clipped at the flat + * `maxCharsPerFile` no matter how far it outscored its peers. + * - `src/util/budget-math.ts` — stands in for `src/resolution/memory-budget.ts`. + * Genuinely relevant (the allocator calls it) but scoring about half as + * well — and small enough to ship WHOLE, which under the old rule was worth + * more than being right. + * - `scripts/eval-harness.mjs` — stands in for `scripts/agent-eval/*.mjs`. Its + * only claim on the query is a file-scope `explore` and `BUDGET` that nothing + * reads: the incidental collision CG-10 demoted. + * + * Measured on this fixture, reverting the render loop to the pre-CG-12 rules + * (`fileBudget = maxCharsPerFile`, whole-file bound `maxCharsPerFile * 3`) + * reproduces the report exactly — and every gate below goes red: + * + * | file | score | pre-CG-12 | CG-12 | + * |-------------------------|-------|----------------|----------------| + * | `src/mcp/allocator.ts` | 77.5 | 4,843 (39.7%) | 9,335 (80.1%) | + * | `src/util/budget-math.ts` | 36.0 | 6,079 (49.8%) | 1,037 ( 8.9%) | + * + * The half-as-relevant file taking the larger share, purely on size, IS #1500. + */ + const QUERY = 'how does explore allocate its output budget across files'; + const ALLOCATOR = 'src/mcp/allocator.ts'; + const HELPER = 'src/util/budget-math.ts'; + const INCIDENTAL = 'scripts/eval-harness.mjs'; + + const allocatorPass = (index: number, name: string) => ` +/** ${name}: one pass of the explore output split. */ +export function ${name}( + candidates: AllocationCandidate[], + budget: ExploreOutputBudget, +): Map { + const allowances = new Map(); + const pool = clampOutputBudget(budget.maxOutputChars - ${index} * 200); + const total = candidates.reduce((sum, candidate) => sum + candidate.score, 0); + if (total <= 0) { + return allowances; + } + const floors = Math.min(pool, 700 * candidates.length); + const remainder = budgetRemainderAfterFloors(pool, floors); + for (const candidate of candidates) { + const floor = Math.floor(floors / candidates.length); + const proportional = splitOutputEvenly(remainder, total, candidate.score); + const boosted = candidate.spine ? proportional * 2 : proportional; + const share = Math.min(floor + boosted, budget.maxCharsPerFile * 3); + if (share <= 0) { + continue; + } + allowances.set(candidate.path, share); + } + return allowances; +} +`; + + /** + * Neutral bulk for the helper file: real symbols that match NOTHING in the + * query, so the file grows in BYTES without gaining relevance. That asymmetry + * is the fixture — the real `memory-budget.ts` won 51% of the envelope against + * a file scoring twice its score purely by being small enough to ship whole. + */ + const helperFiller = (n: number) => ` +export function normalizeLedgerRow${n}(row: string[], fallback: string): string[] { + const trimmed = row.map((cell) => cell.trim()).filter((cell) => cell.length > 0); + return trimmed.length > 0 ? trimmed : [fallback]; +} +`; + + const ALLOCATOR_SOURCE = ` +/** Explore budget allocation: splits the output envelope across relevant files. */ +export interface ExploreOutputBudget { + maxOutputChars: number; + maxCharsPerFile: number; + defaultMaxFiles: number; +} + +export interface AllocationCandidate { + path: string; + score: number; + spine: boolean; +} +${[ + 'allocateExploreBudget', + 'reserveOutputPerFile', + 'distributeOutputBudget', + 'planExploreOutput', + 'spendExploreBudget', + 'balanceOutputAcrossFiles', + 'concentrateExploreOutput', + 'settleExploreAllocation', + 'apportionExploreBudget', + 'rationOutputAcrossFiles', + 'tallyExploreOutputBudget', + 'weighExploreAllocation', +].map((name, i) => allocatorPass(i + 1, name)).join('')} +import { + clampOutputBudget, + splitOutputEvenly, + budgetRemainderAfterFloors, +} from '../util/budget-math'; +`; + + let project: Project; + let run: Awaited>; + + beforeAll(async () => { + project = await buildProject('codegraph-alloc-selfquery-', { + [ALLOCATOR]: ALLOCATOR_SOURCE, + [HELPER]: ` +/** Budget arithmetic the explore output allocator leans on. */ +export function clampOutputBudget(value: number): number { + if (value < 0) return 0; + return Math.floor(value); +} + +export function splitOutputEvenly(pool: number, total: number, score: number): number { + if (total <= 0) return 0; + return Math.floor((pool * score) / total); +} + +export function budgetRemainderAfterFloors(pool: number, floors: number): number { + const remainder = pool - floors; + return remainder > 0 ? remainder : 0; +} + +export function splitBudgetAcrossFiles(pool: number, fileCount: number): number { + return fileCount > 0 ? Math.floor(pool / fileCount) : pool; +} + +export function describeOutputBudget(pool: number, perFile: number): string { + return \`explore budget pool of \${pool} chars, \${perFile} per file\`; +} +${Array.from({ length: 22 }, (_, i) => helperFiller(i + 1)).join('')}`, + [INCIDENTAL]: ` +// Eval harness. Mentions explore and BUDGET incidentally; nothing here allocates. +const explore = 'explore'; +const BUDGET = 24000; + +export function runHarness(repo) { + const rows = []; + for (const line of repo.split('\\n')) { + rows.push(line.trim()); + } + return rows; +} + +export function summarizeRun(rows) { + return { count: rows.length, first: rows[0] }; +} +`, + 'src/mcp/server.ts': ` +import { allocateExploreBudget } from './allocator'; + +export function serve(candidates: any[]) { + return allocateExploreBudget(candidates, { maxOutputChars: 13000, maxCharsPerFile: 3800, defaultMaxFiles: 4 }); +} +`, + 'src/util/logger.ts': ` +export function log(message: string): void { + console.log(message); +} +`, + }); + run = await explore(project, QUERY); + }, 120_000); + + afterAll(() => destroyProject(project)); + + describe('fixture shape', () => { + it('indexes all three roles, so a zero share means demoted and not missing', () => { + // Without this the incidental assertion below could pass vacuously — a file + // that was never indexed also delivers 0 bytes. + for (const rel of [ALLOCATOR, HELPER, INCIDENTAL]) { + expect(project.cg.getFile(rel), `${rel} indexed`).toBeTruthy(); + } + }); + + it('sizes the two files so the size-driven render split actually bites', () => { + // The mechanism the epic is about. The answer file must be too big to ship + // whole (so the old flat cap clipped it), and the helper small enough that + // shipping it whole was always affordable under the old `maxCharsPerFile * 3` + // bound. Without that asymmetry the fixture stops reproducing anything. + const budget = getExploreOutputBudget(project.cg.getFiles().length); + const answer = fs.readFileSync(path.join(project.dir, ALLOCATOR), 'utf-8'); + const helper = fs.readFileSync(path.join(project.dir, HELPER), 'utf-8'); + expect(answer.split('\n').length).toBeGreaterThan(280); + expect(answer.length).toBeGreaterThan(budget.maxCharsPerFile * 3); + expect(helper.split('\n').length).toBeLessThan(220); + expect(helper.length).toBeLessThan(budget.maxCharsPerFile * 3); + }); + + it('scores the answer file well above the helper it calls', () => { + // The other half of the asymmetry: the reversal below only means something + // if the file that used to WIN the envelope was the less relevant one. + expect(run.score(ALLOCATOR)).toBeGreaterThan(run.score(HELPER) * 1.5); + }); + }); + + describe('budget allocation', () => { + it('gives the file that answers the question the majority of the envelope', () => { + // The epic's acceptance bar for this fixture: >50%, from 18.5% at baseline. + // Pre-CG-12 this file took 39.7% — behind the helper it calls. + expect(run.share(ALLOCATOR)).toBeGreaterThan(0.5); + }); + + it('lets the answer file spend multiples of the flat cap it used to be clipped at', () => { + // The mechanism as a byte count rather than a share: this file is too big + // to ship whole, so under the old rule its source was truncated at + // `maxCharsPerFile` however far it outscored its peers. Its reservation is + // now several times that cap. A build that re-imposes a flat per-file cap + // fails HERE first — it delivered 4,843 against a 3,800 cap. + const budget = getExploreOutputBudget(project.cg.getFiles().length); + expect(run.bytes.get(ALLOCATOR) ?? 0).toBeGreaterThan(budget.maxCharsPerFile * 2); + }); + + it('stops the smaller file winning on size — it no longer ships whole', () => { + // The reversal, from the other side. The helper scores about half the + // answer file and is small enough that the old whole-file bound shipped it + // ENTIRE (6,079 chars, 49.8% of the envelope — more than the file that + // answered the question). It now clusters inside its proportional share. + const helperSource = fs.readFileSync(path.join(project.dir, HELPER), 'utf-8'); + const delivered = run.bytes.get(HELPER) ?? 0; + expect(delivered).toBeGreaterThan(0); + expect(delivered).toBeLessThan(helperSource.length); + }); + + it('orders per-file shares by relevance, not by file size', () => { + // Both files deliver — this is not concentration by elimination — but the + // one that answers the question gets several times the bytes of the helper + // it calls. Pre-CG-12 this ratio was 0.8, i.e. inverted. + const answer = run.share(ALLOCATOR); + const helper = run.share(HELPER); + expect(helper).toBeGreaterThan(0); + expect(answer).toBeGreaterThan(helper * 3); + }); + + it('spends nothing on the incidental name collision', () => { + expect(run.bytes.get(INCIDENTAL) ?? 0).toBe(0); + expect(run.shareUnder('scripts/')).toBe(0); + }); + + it('reserves in proportion to score, before anything renders', () => { + // The reservations are the contract the render loop then spends. Asserting + // them directly — not just the bytes that came out — separates "allocation + // is proportional" from "the render loop happened to emit these sizes". + const answerReserved = run.allowance(ALLOCATOR); + const helperReserved = run.allowance(HELPER); + expect(answerReserved).toBeGreaterThan(helperReserved); + expect(answerReserved / helperReserved).toBeGreaterThan(run.score(ALLOCATOR) / run.score(HELPER) * 0.5); + // Nothing is over-promised: the sum of reservations fits the pool, and the + // pool fits the envelope. This is the invariant the whole epic rests on. + expect(run.report.allocation.reserved).toBeLessThanOrEqual(run.report.allocation.pool); + expect(run.report.allocation.pool).toBeLessThanOrEqual(run.report.budget.maxOutputChars); + }); + + it('keeps the response inside the hard ceiling and under the inline cap', () => { + // Two different bounds, and it matters which is which. `maxOutputChars` + // bounds the RESERVATIONS (asserted above); the RESPONSE is bounded by + // `hardCeiling` — 1.5x the envelope, capped at 25K — because the render + // loop is allowed a bounded overshoot for the whole-file grace and an + // oversize first cluster. The 25K is the one that must never move: past it + // the host writes the result to a file the agent Reads back. + const budget = getExploreOutputBudget(project.cg.getFiles().length); + const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), INLINE_CAP); + expect(run.text.length).toBeLessThanOrEqual(hardCeiling); + expect(run.text.length).toBeLessThan(INLINE_CAP); + }); + + it('records the shape of the split so a regression is legible', () => { + // Not a gate — a snapshot, so a change that shifts the split shows up in the + // diff rather than silently flipping a threshold. + expect({ + answerWinsEnvelope: run.share(ALLOCATOR) > run.share(HELPER), + helperStillDelivers: (run.bytes.get(HELPER) ?? 0) > 0, + incidentalDelivers: (run.bytes.get(INCIDENTAL) ?? 0) > 0, + }).toEqual({ + answerWinsEnvelope: true, + helperStillDelivers: true, + incidentalDelivers: false, + }); + }); + }); +}); + +// ── 2. Degenerate and diffuse result sets ─────────────────────────────────── + +describe('allocation on degenerate result sets', () => { + let project: Project; + + beforeAll(async () => { + // Four modules that are deliberate COPIES of each other, plus one unrelated + // file. Copies are the pathological input for a proportional split: every + // candidate carries the same weight, so the split divides by a denominator + // that is entirely made of ties. + const twin = (n: number) => ` +export class InventoryLedger${n} { + private rows: number[] = []; + + public recordInventoryMovement(quantity: number): void { + this.rows.push(quantity); + } + + public settleInventoryLedger(): number { + return this.rows.reduce((sum, row) => sum + row, 0); + } +} +`; + project = await buildProject('codegraph-alloc-degenerate-', { + 'src/ledger/one.ts': twin(1), + 'src/ledger/two.ts': twin(2), + 'src/ledger/three.ts': twin(3), + 'src/ledger/four.ts': twin(4), + 'src/unrelated/colors.ts': ` +export const PALETTE = ['oxblood', 'paper', 'ink']; + +export function pickPaletteEntry(index: number): string { + return PALETTE[index % PALETTE.length]!; +} +`, + }); + }, 120_000); + + afterAll(() => destroyProject(project)); + + it('does not starve anyone when every file scores identically', async () => { + // The all-ties case, end to end: no division by zero, nobody cliffed for + // being relatively weak (nothing IS relatively weak), and no single copy + // sweeping the envelope on an arbitrary tiebreak. + const run = await explore(project, 'how does the inventory ledger record and settle movements'); + expect(run.isError).toBe(false); + const ledger = [...run.bytes].filter(([file]) => file.startsWith('src/ledger/')); + expect(ledger.length).toBeGreaterThanOrEqual(2); + const shares = ledger.map(([, n]) => n); + expect(Math.max(...shares) / Math.min(...shares)).toBeLessThan(3); + for (const [file, n] of ledger) { + expect(n, `${file} starved`).toBeGreaterThan(0); + } + // The reservations behind those bytes divided cleanly too. + expect(run.report.allocation.reserved).toBeLessThanOrEqual(run.report.allocation.pool); + }); + + it('answers a single-file question without over-spending the envelope on it', async () => { + const run = await explore(project, 'pickPaletteEntry'); + expect(run.isError).toBe(false); + expect(run.bytes.get('src/unrelated/colors.ts') ?? 0).toBeGreaterThan(0); + const budget = getExploreOutputBudget(project.cg.getFiles().length); + // One dominant file still cannot exceed the share ceiling, and the response + // as a whole still fits the envelope's hard ceiling. + expect(run.bytes.get('src/unrelated/colors.ts')!) + .toBeLessThanOrEqual(Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE)); + expect(run.text.length).toBeLessThan(INLINE_CAP); + }); + + it('returns guidance rather than an error when nothing matches', async () => { + // An `isError` response teaches the agent to abandon codegraph for the rest + // of the session, so a zero-result allocation must stay success-shaped. + const run = await explore(project, 'quantumFluxCapacitorHandshake'); + expect(run.isError).toBe(false); + expect(run.text.length).toBeGreaterThan(0); + expect(run.bytes.size).toBe(0); + }); +}); + +describe('the diffuse-query control', () => { + let project: Project; + + beforeAll(async () => { + // Six genuinely distinct subsystems, each a legitimate partial answer to a + // survey question. Concentration is the epic's goal, but over-correcting here + // costs a round-trip: the agent's fallback for an under-served survey is + // Grep, not a second explore. + const subsystem = (name: string, verb: string) => ` +export interface ${name}Options { + retries: number; +} + +export class ${name}Service { + constructor(private readonly options: ${name}Options) {} + + public ${verb}Request(payload: string): string { + return this.describe${name}() + ':' + payload; + } + + public describe${name}(): string { + return '${name} with ' + this.options.retries + ' retries'; + } +} +`; + project = await buildProject('codegraph-alloc-diffuse-', { + 'src/services/auth.ts': subsystem('Auth', 'authorize'), + 'src/services/billing.ts': subsystem('Billing', 'charge'), + 'src/services/search.ts': subsystem('Search', 'query'), + 'src/services/notify.ts': subsystem('Notify', 'publish'), + 'src/services/report.ts': subsystem('Report', 'render'), + 'src/services/audit.ts': subsystem('Audit', 'record'), + }); + }, 120_000); + + afterAll(() => destroyProject(project)); + + it('still returns a spread for a survey-style question', async () => { + // The over-correction guard for CG-10's floor and CG-12's cliff together: a + // question with no single right answer must come back as several usable + // sections, not one file plus a pointer list. + const run = await explore(project, 'what services does this project expose and what does each one do'); + expect(run.isError).toBe(false); + const services = [...run.bytes].filter(([file]) => file.startsWith('src/services/')); + expect(services.length).toBeGreaterThanOrEqual(3); + const total = services.reduce((sum, [, n]) => sum + n, 0); + expect(total).toBeGreaterThan(0); + for (const [file, n] of services) { + // Nobody is reduced to a fragment, and nobody swallows the response. + expect(n, `${file} fragment`).toBeGreaterThan(200); + expect(n / total, `${file} hogged the envelope`).toBeLessThan(0.8); + } + }); + + it('names whatever it could not show, so the spread stays completable', async () => { + const run = await explore(project, 'what services does this project expose and what does each one do'); + const shown = [...run.bytes.keys()].filter((f) => f.startsWith('src/services/')); + const missing = ['auth', 'billing', 'search', 'notify', 'report', 'audit'] + .map((n) => `src/services/${n}.ts`) + .filter((f) => !shown.includes(f)); + for (const file of missing) { + expect(run.text, `${file} dropped without a pointer`).toContain(file); + } + }); +}); diff --git a/__tests__/explore-proportional-allocation.test.ts b/__tests__/explore-proportional-allocation.test.ts index 13c8863..2c11ac0 100644 --- a/__tests__/explore-proportional-allocation.test.ts +++ b/__tests__/explore-proportional-allocation.test.ts @@ -11,8 +11,8 @@ * 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'; +import { allocateExploreBudget, getExploreOutputBudget, EXPLORE_ALLOCATION } from '../src/mcp/tools'; +import type { ExploreAllocationCandidate, ExploreAllocation, ExploreOutputBudget } from '../src/mcp/tools'; /** A candidate with sane defaults — tests override only what they're about. */ const cand = ( @@ -23,6 +23,30 @@ const cand = ( const TIER_FILE_COUNTS = [10, 100, 300, 1000, 4000, 10000, 20000, 60000]; +/** + * The inline tool-result limit. Above it the host writes the response to a file + * the agent Reads back, re-introducing the read this tool exists to prevent — so + * it bounds every tier, not just the big ones (`hardCeiling`, tools.ts). + */ +const INLINE_CAP = 25000; + +const reservedTotal = (a: ExploreAllocation) => + [...a.allowances.values()].reduce((sum, n) => sum + n, 0); + +/** + * What the render loop can actually emit for these reservations: each file's + * slice, plus the whole-file grace it may overshoot by, plus the markdown + * overhead charged per section. The allocator's job is to keep this inside the + * envelope it was handed. + */ +const worstCaseEmission = (a: ExploreAllocation) => { + let total = 0; + for (const chars of a.allowances.values()) { + total += chars + EXPLORE_ALLOCATION.FILE_OVERHEAD; + } + return total; +}; + describe('allocateExploreBudget — proportional split', () => { const budget = getExploreOutputBudget(1000); // 24,000 / 6,500 / 8 files @@ -212,3 +236,317 @@ describe('allocateExploreBudget — tier invariant', () => { expect(new Set(cliffs).size).toBe(1); }); }); + +// ── CG-14 ─────────────────────────────────────────────────────────────────── +// Everything above pins the behaviours CG-12 was written to produce. What +// follows pins the ones it must never produce: an over-spent envelope, a +// starved diffuse query, a NaN slice — the failures that would ship silently +// because they only surface as an agent falling back to Read. + +describe('allocateExploreBudget — calibration', () => { + it('pins the constants the two #1500 fixtures were calibrated against', () => { + // Deliberately literal. Every other test here asserts an INVARIANT and reads + // the constants, so it holds at any value; this one exists so that changing a + // value is a visible decision rather than a silent re-tune of the fixtures. + // If you change one, re-run `node scripts/agent-eval/probe-allocation.mjs`. + expect(EXPLORE_ALLOCATION).toMatchObject({ + CLIFF_FRACTION: 0.15, + CLIFF_MAX: 10, + MIN_CHARS: 700, + MAX_SHARE: 0.7, + FILE_OVERHEAD: 200, + SPINE_WEIGHT_BOOST: 2, + WHOLE_FILE_GRACE_FRACTION: 0.15, + WHOLE_FILE_GRACE_MAX: 800, + }); + }); + + it('cliffs strictly BELOW the threshold, so a file exactly at it is still served', () => { + // The boundary matters because `cliffAt` sits at CLIFF_MAX for any dominant + // top file, which is also where the score floor's own ceiling sits — a file + // that clears one must clear the other or the two gates disagree. + const budget = getExploreOutputBudget(1000); + const at = allocateExploreBudget([cand('top.ts', 1000), cand('probe.ts', 10)], budget, 8); + const under = allocateExploreBudget([cand('top.ts', 1000), cand('probe.ts', 9.9)], budget, 8); + expect(at.cliffAt).toBe(EXPLORE_ALLOCATION.CLIFF_MAX); + expect(at.cliffed).not.toContain('probe.ts'); + expect(under.cliffed).toContain('probe.ts'); + }); + + it('tracks the top file until CLIFF_MAX caps it', () => { + const budget = getExploreOutputBudget(1000); + const cliffFor = (top: number) => + allocateExploreBudget([cand('top.ts', top), cand('b.ts', 1)], budget, 8).cliffAt; + expect(cliffFor(20)).toBeCloseTo(20 * EXPLORE_ALLOCATION.CLIFF_FRACTION, 5); + expect(cliffFor(50)).toBeCloseTo(50 * EXPLORE_ALLOCATION.CLIFF_FRACTION, 5); + expect(cliffFor(500)).toBe(EXPLORE_ALLOCATION.CLIFF_MAX); + }); +}); + +describe('allocateExploreBudget — envelope safety', () => { + /** Deterministic LCG: a seeded sweep reproduces exactly, unlike Math.random. */ + const shapes = (): ExploreAllocationCandidate[][] => { + let seed = 0x1500; + const next = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + const out: ExploreAllocationCandidate[][] = []; + for (let n = 1; n <= 30; n++) { + out.push(Array.from({ length: n }, (_, i) => + cand(`f${i}.ts`, Math.round(next() * 120 * 100) / 100, { + worth: next() < 0.25 ? 0.3 : 1, + spine: next() < 0.1, + }))); + } + return out; + }; + + it('never reserves more than the envelope, at any tier or shape', () => { + // The one invariant that must hold unconditionally: the render loop spends + // reservations, so an over-allocation is an over-long response, and an + // over-long response is externalized to a file the agent has to Read back. + for (const fileCount of TIER_FILE_COUNTS) { + const budget = getExploreOutputBudget(fileCount); + for (const files of shapes()) { + for (const maxFiles of [1, 4, 8, 30]) { + const alloc = allocateExploreBudget(files, budget, maxFiles); + const label = `${files.length} files, maxFiles=${maxFiles}, tier ${fileCount}`; + expect(reservedTotal(alloc), label).toBeLessThanOrEqual(alloc.pool); + expect(worstCaseEmission(alloc), label).toBeLessThanOrEqual(budget.maxOutputChars); + for (const chars of alloc.allowances.values()) { + expect(Number.isFinite(chars) && chars > 0, label).toBe(true); + } + } + } + } + }); + + it('never renders more files than maxFiles', () => { + for (const maxFiles of [1, 2, 4, 8]) { + const files = Array.from({ length: 25 }, (_, i) => cand(`f${i}.ts`, 100 - i)); + const alloc = allocateExploreBudget(files, getExploreOutputBudget(1000), maxFiles); + expect(alloc.allowances.size).toBeLessThanOrEqual(maxFiles); + } + }); + + it('accounts for every candidate — a file is served, cliffed, or neither by choice', () => { + // Nothing may vanish silently: a cliffed file is still NAMED in the response, + // which is what makes withholding its bytes cheap. A file that is neither + // served nor cliffed would be dropped without a pointer. + const files = Array.from({ length: 25 }, (_, i) => cand(`f${i}.ts`, 100 - i * 4)); + const alloc = allocateExploreBudget(files, getExploreOutputBudget(1000), 8); + const accounted = new Set([...alloc.allowances.keys(), ...alloc.cliffed]); + expect(accounted.size).toBe(files.length); + }); + + it('leaves the ~25K inline cap reachable only through the hard ceiling', () => { + // Reservations always fit `maxOutputChars`, but the whole-file grace lets the + // render loop overshoot a slice — so the envelope alone does NOT bound the + // response, and `hardCeiling` is load-bearing rather than defensive. Pin both + // halves: every tier's envelope is inside the inline cap, and the worst-case + // graced emission is what the ceiling has to catch. + for (const fileCount of TIER_FILE_COUNTS) { + const budget = getExploreOutputBudget(fileCount); + const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), INLINE_CAP); + expect(budget.maxOutputChars).toBeLessThan(INLINE_CAP); + expect(hardCeiling).toBeLessThanOrEqual(INLINE_CAP); + + const files = Array.from({ length: 8 }, (_, i) => cand(`f${i}.ts`, 90 - i * 9)); + const alloc = allocateExploreBudget(files, budget, 8); + const graced = [...alloc.allowances.values()].reduce((sum, chars) => sum + chars + + Math.min(EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_MAX, + Math.round(chars * EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_FRACTION)) + + EXPLORE_ALLOCATION.FILE_OVERHEAD, 0); + expect(graced).toBeGreaterThan(budget.maxOutputChars); + expect(reservedTotal(alloc)).toBeLessThanOrEqual(budget.maxOutputChars); + } + }); +}); + +describe('allocateExploreBudget — spine first', () => { + const budget = getExploreOutputBudget(1000); + + it('reserves more for a spine file than for an identically-scoring peer', () => { + // "Spine first, unclipped" is enforced by WEIGHT, not by ordering: the spine + // boost multiplies into the proportional split, so the flow gets its bytes + // before any peripheral file competes for them. + const { allowances } = allocateExploreBudget( + [cand('peer.ts', 20), cand('spine.ts', 20, { spine: true })], + budget, + 8, + ); + expect(allowances.get('spine.ts')!).toBeGreaterThan(allowances.get('peer.ts')!); + expect(allowances.get('spine.ts')! / allowances.get('peer.ts')!).toBeGreaterThan(1.3); + }); + + it('keeps a spine file even when the envelope cannot afford everyone', () => { + // The affordability trim keeps the highest weights and drops the rest in one + // pass — but a dropped spine file breaks the flow, which is precisely the + // failure that sends the agent back to Read. It is force-kept past the trim. + const tiny = getExploreOutputBudget(10); + const files = [ + ...Array.from({ length: 20 }, (_, i) => cand(`f${i}.ts`, 100 - i)), + cand('spine.ts', 4, { spine: true }), + ]; + const { allowances, cliffed } = allocateExploreBudget(files, tiny, 40); + expect(allowances.has('spine.ts')).toBe(true); + expect(cliffed).not.toContain('spine.ts'); + // Force-keeping it costs everyone a sliver — bounded, and the envelope still + // holds. A real starvation regression would blow well past this. + for (const [path, chars] of allowances) { + expect(chars, path).toBeGreaterThanOrEqual(Math.round(EXPLORE_ALLOCATION.MIN_CHARS * 0.9)); + } + expect(reservedTotal({ allowances, cliffed, cliffAt: 0, pool: 0 })).toBeLessThanOrEqual(tiny.maxOutputChars); + }); + + it('does NOT exempt a spine file from maxFiles — the slot cap is separate', () => { + // Documented boundary, not an oversight: the cliff is a relevance gate the + // spine overrides, `maxFiles` is a response-shape cap it does not. In + // practice the 2x boost lifts a spine file into the slots long before this + // bites; the test exists so a future change to either gate is deliberate. + const { allowances, cliffed } = allocateExploreBudget( + [cand('a.ts', 90), cand('b.ts', 80), cand('spine.ts', 3, { spine: true })], + budget, + 2, + ); + expect(allowances.has('spine.ts')).toBe(false); + expect(cliffed).toContain('spine.ts'); + }); + + it('serves a spine-only candidate set', () => { + const { allowances, cliffed } = allocateExploreBudget( + [cand('a.ts', 5, { spine: true }), cand('b.ts', 5, { spine: true })], + budget, + 8, + ); + expect(cliffed).toEqual([]); + expect(allowances.size).toBe(2); + }); +}); + +describe('allocateExploreBudget — degenerate inputs', () => { + const budget = getExploreOutputBudget(1000); + + it('splits evenly when every file scores identically, without starving any', () => { + // The proportional split divides by the TOTAL weight, so an all-equal set is + // the divide-by-a-degenerate-denominator case. Nobody is cliffed (nothing is + // relatively weak) and everybody gets the same slice. + for (const n of [2, 4, 8]) { + const files = Array.from({ length: n }, (_, i) => cand(`f${i}.ts`, 17)); + const { allowances, cliffed } = allocateExploreBudget(files, budget, 8); + expect(cliffed, `${n} files`).toEqual([]); + expect(allowances.size, `${n} files`).toBe(n); + const values = [...allowances.values()]; + expect(Math.max(...values) - Math.min(...values), `${n} files`).toBeLessThanOrEqual(1); + for (const chars of values) expect(chars).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.MIN_CHARS); + expect(worstCaseEmission({ allowances, cliffed, cliffAt: 0, pool: 0 })) + .toBeLessThanOrEqual(budget.maxOutputChars); + } + }); + + it('gives a lone file a real answer, not the whole envelope', () => { + const { allowances, cliffed } = allocateExploreBudget([cand('only.ts', 42)], budget, 8); + expect(cliffed).toEqual([]); + expect(allowances.size).toBe(1); + const chars = allowances.get('only.ts')!; + expect(chars).toBeGreaterThan(budget.maxCharsPerFile); + expect(chars).toBe(Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE)); + }); + + it('holds a runaway top scorer to its share ceiling and still names the rest', () => { + // One file 100x above everything else must not eat the response: the cliff + // zeroes its peers' BYTES, but MAX_SHARE keeps the remainder for the pointer + // list and the flow/relationship meta-text that lets the agent follow up. + const { allowances, cliffed } = allocateExploreBudget( + [cand('god.ts', 5000), cand('p1.ts', 9), cand('p2.ts', 8)], + budget, + 8, + ); + expect(allowances.get('god.ts')!).toBe(Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE)); + expect(reservedTotal({ allowances, cliffed, cliffAt: 0, pool: 0 })) + .toBeLessThan(budget.maxOutputChars); + expect(cliffed).toEqual(['p1.ts', 'p2.ts']); + }); + + it('returns nothing to render when nothing scored', () => { + for (const files of [ + [] as ExploreAllocationCandidate[], + [cand('a.ts', 0), cand('b.ts', 0)], + [cand('a.ts', 10, { worth: 0 }), cand('b.ts', 5, { worth: 0 })], + [cand('a.ts', -5), cand('b.ts', -1)], + ]) { + const { allowances, pool } = allocateExploreBudget(files, budget, 8); + expect(allowances.size).toBe(0); + expect(pool).toBeLessThanOrEqual(budget.maxOutputChars); + } + }); + + it('fails safe on a non-finite score instead of handing the render loop a NaN slice', () => { + // Scores are finite sums in the pipeline, so this only has to not corrupt the + // split — an Infinity weight would otherwise make every share Infinity/Infinity. + for (const bad of [Infinity, NaN, -Infinity]) { + const { allowances } = allocateExploreBudget([cand('bad.ts', bad), cand('ok.ts', 20)], budget, 8); + for (const [path, chars] of allowances) { + expect(Number.isFinite(chars), `${String(bad)} → ${path}`).toBe(true); + } + expect(allowances.get('ok.ts')).toBeGreaterThan(0); + } + }); + + it('renders nothing when maxFiles is zero, and still names every candidate', () => { + const { allowances, cliffed } = allocateExploreBudget( + [cand('a.ts', 10), cand('b.ts', 5)], + budget, + 0, + ); + expect(allowances.size).toBe(0); + expect(cliffed).toEqual(['a.ts', 'b.ts']); + }); + + it('survives an envelope too small for even one floored slice', () => { + const cramped: ExploreOutputBudget = { ...budget, maxOutputChars: 300 }; + const { allowances, cliffed } = allocateExploreBudget( + [cand('a.ts', 40), cand('b.ts', 30)], + cramped, + 8, + ); + expect(allowances.size).toBeLessThanOrEqual(1); + for (const chars of allowances.values()) { + expect(chars).toBeGreaterThan(0); + expect(chars).toBeLessThanOrEqual(cramped.maxOutputChars); + } + expect([...allowances.keys(), ...cliffed].sort()).toEqual(['a.ts', 'b.ts']); + }); +}); + +describe('allocateExploreBudget — the diffuse-query control', () => { + const budget = getExploreOutputBudget(1000); + + it('keeps a survey-style spread readable — no file collapses to a fragment', () => { + // The over-correction guard. Concentration is the point, but a genuinely + // diffuse question (many comparably-relevant files) must still come back as a + // usable spread: under-serving costs a whole round-trip, and the agent's + // fallback is Grep, not a second explore. + const files = Array.from({ length: 8 }, (_, i) => cand(`f${i}.ts`, 30 - i)); + const { allowances, cliffed } = allocateExploreBudget(files, budget, 8); + expect(cliffed).toEqual([]); + expect(allowances.size).toBe(8); + const values = [...allowances.values()]; + for (const chars of values) expect(chars).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.MIN_CHARS); + // Nobody is starved to make room for the leader: on a flat score curve the + // spread between best and worst slice stays within a small multiple. + expect(Math.max(...values) / Math.min(...values)).toBeLessThan(3); + }); + + it('concentrates a precise query far harder than a diffuse one', () => { + // Same envelope, same file count — only the score CURVE differs. This is the + // whole thesis of the epic in one assertion. + const topShareOf = (files: ExploreAllocationCandidate[]) => { + const { allowances } = allocateExploreBudget(files, budget, 8); + const values = [...allowances.values()]; + return Math.max(...values) / values.reduce((s, n) => s + n, 0); + }; + const diffuse = topShareOf(Array.from({ length: 8 }, (_, i) => cand(`f${i}.ts`, 30 - i))); + const precise = topShareOf([cand('answer.ts', 120), ...Array.from({ length: 7 }, (_, i) => cand(`f${i}.ts`, 14 - i))]); + expect(diffuse).toBeLessThan(0.25); + expect(precise).toBeGreaterThan(0.45); + }); +}); diff --git a/docs/design/explore-budget-allocation.md b/docs/design/explore-budget-allocation.md index 51a5ad5..8658d36 100644 --- a/docs/design/explore-budget-allocation.md +++ b/docs/design/explore-budget-allocation.md @@ -367,3 +367,82 @@ tree reported tools.ts at 13–19% depending on the sync state). Measure against tree: restore `src/mcp/tools.ts` from `main`, remove `src/mcp/explore-diagnostics.ts`, `codegraph sync`, then run the built `dist/` binary (which still carries the instrument). Restore afterwards. + +--- + +## CG-14 — locking the allocation down + +The allocation change is one function plus three render-loop bounds, and every way it can +regress is silent: nothing throws, the response just gets less useful and the agent falls +back to Read. So the coverage is built around the question "would this test go red if the +lever were removed?" rather than around line coverage. + +### Where the coverage lives + +| File | Owns | +|---|---| +| `__tests__/explore-proportional-allocation.test.ts` | `allocateExploreBudget` in isolation — the split, the cliff, the tier invariant, envelope safety, spine weighting, degenerate inputs | +| `__tests__/explore-allocation-e2e.test.ts` | The same behaviours through the real render loop on real indexed projects — the self-query fixture's shape, degenerate result sets, the diffuse-query control | +| `__tests__/explore-allocation-1500.test.ts` | The reporter's Go shape (CG-6 fixture 1), plus the hard-ceiling stress case | +| `scripts/agent-eval/probe-allocation.mjs` | Both CG-6 fixtures against the built `dist/`, including the **live** self-query arm that reads this repo's own index | + +The split between the last two is deliberate. The self-query fixture reads a moving target +(this repo), so its exact numbers drift with the tree and it belongs out of band, where a +drift is a number to re-baseline rather than a red suite. `npm test` owns a synthetic +**mirror** of it instead: same three roles, same size asymmetry, fixed. + +### The two bounds are not the same bound + +Worth stating once, because a test that conflates them looks right and passes for the wrong +reason: + +- **`maxOutputChars` bounds the RESERVATIONS.** `sum(allowances) <= pool <= maxOutputChars`, + exactly, at every tier and every candidate shape. +- **`hardCeiling` — `min(maxOutputChars * 1.5, 25000)` — bounds the RESPONSE.** The render + loop is allowed a bounded overshoot (the whole-file grace, an oversize first cluster), so + a response legitimately exceeds the envelope. `payroll-go` does exactly that: 19.3K + delivered against a 13,000 envelope and a 19,500 ceiling. + +Only the 25K is absolute. Above it the host writes the result to a file the agent Reads +back, which is the failure the tool exists to prevent. + +### Mutation-tested, not just green + +Each lever was removed from `src/mcp/tools.ts` in turn and the suite re-run. Every one is +covered by at least one failing test — a lever with no red test is a lever that can be +deleted by accident: + +| Mutation | Tests that go red | +|---|---| +| Render loop reverted to pre-CG-12 (`fileBudget = maxCharsPerFile`, whole-file bound `maxCharsPerFile * 3`) | 5 e2e + 2 payroll | +| Proportional split replaced with an equal one | 4 unit | +| Cliff disabled (`cliffAt = 0`) | 7 unit + 3 payroll | +| Spine boost and cliff exemption removed | 3 unit | +| `MIN_CHARS` floor removed | 2 unit | +| `MAX_SHARE` ceiling removed | 4 unit | + +The first row is the one that matters most: it reproduces #1500 on the synthetic fixture +verbatim, with the half-as-relevant file taking the larger share purely on size. + +| file | score | pre-CG-12 | CG-12 | +|---|---|---|---| +| `src/mcp/allocator.ts` | 77.5 | 4,843 (39.7%) | 9,335 (80.1%) | +| `src/util/budget-math.ts` | 36.0 | 6,079 (49.8%) | 1,037 (8.9%) | + +### Two defects the coverage surfaced + +Both were found by writing the invariant rather than by reading the code: + +1. **Rounded shares could exceed the pool.** `Math.round` on each file's proportional slice + let the reservations sum past `pool` by up to half a char per file — small, but it made + "reservations fit the envelope" false rather than exact. Both terms now floor. +2. **A non-finite score produced a NaN allowance.** An `Infinity` weight makes every share + `Infinity / Infinity`. Scores are finite sums in the pipeline so it was unreachable, but + the failure mode is a NaN handed to the render loop. `weightOf` now fails safe to 0. + +### Calibration vs. invariant + +`EXPLORE_ALLOCATION` is exported so the tests can read it. Invariant tests (envelope safety, +tier monotonicity, the floor) reference the constants and hold at any value; one test pins +the literals, so re-tuning a constant is a visible decision that says *re-run the probe* +rather than a silent re-calibration of the fixtures. diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 782ccc3..85a0e99 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -454,7 +454,7 @@ const SCORE_FLOOR_KEEP_MIN = 3; // `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 = { +export const EXPLORE_ALLOCATION = { /** * A file whose weight is under this fraction of the top file's gets no source. * @@ -578,8 +578,14 @@ export function allocateExploreBudget( 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); + // A non-finite weight is treated as no evidence rather than propagated: an + // Infinity score would otherwise make every share `Infinity/Infinity` = NaN and + // hand the render loop a NaN allowance. Scores are finite sums in the real + // pipeline, so this only has to fail safe. + const weightOf = (c: ExploreAllocationCandidate) => { + const w = Math.max(0, c.score) * Math.max(0, Math.min(1, c.worth)) * (c.spine ? A.SPINE_WEIGHT_BOOST : 1); + return Number.isFinite(w) ? w : 0; + }; const weights = new Map(candidates.map((c) => [c.path, weightOf(c)])); const topWeight = Math.max(...weights.values()); @@ -626,9 +632,14 @@ export function allocateExploreBudget( 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); + // Both parts FLOOR: a sum of rounded shares can exceed the remainder that fed + // it (by up to half a char per file), and the reservations must fit the pool + // exactly — the render loop spends them, so an over-allocation is an over-long + // response the hard ceiling then has to truncate. Flooring costs at most one + // char per file. for (const c of admitted) { const share = Math.floor(floors / admitted.length) - + Math.round((remainder * (weights.get(c.path) ?? 0)) / total); + + Math.floor((remainder * (weights.get(c.path) ?? 0)) / total); allowances.set(c.path, Math.min(share, ceiling)); } return { allowances, cliffed, cliffAt, pool };