test(explore): lock down proportional byte allocation (CG-14, #1500)

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 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-04 00:56:50 -05:00
co-authored by Claude Opus 5
parent 5f7f5f59df
commit 1d9206d2d0
5 changed files with 1008 additions and 7 deletions
+16 -1
View File
@@ -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.
+558
View File
@@ -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<string, string>): Promise<Project> {
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<string, number> {
const allowances = new Map<string, number>();
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<ReturnType<typeof explore>>;
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);
}
});
});
@@ -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);
});
});
+79
View File
@@ -367,3 +367,82 @@ tree reported tools.ts at 1319% 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.
+15 -4
View File
@@ -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 };