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