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
+15
-4
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user