explore-proportional-allocation.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. /**
  2. * Score-proportional byte allocation for codegraph_explore (CG-12 / #1500).
  3. *
  4. * `allocateExploreBudget` decides, before anything renders, how many chars of
  5. * source each ranked file may spend. Its contract is what stops the explore
  6. * envelope from following FILE SIZE — which is the bug #1500 reported: a small
  7. * weakly-relevant file shipped whole while the file that actually answered the
  8. * question was clipped at a flat per-file cap.
  9. *
  10. * These pin the allocator's invariants directly. End-to-end behaviour on the two
  11. * regression fixtures lives in `explore-allocation-1500.test.ts`.
  12. */
  13. import { describe, it, expect } from 'vitest';
  14. import { allocateExploreBudget, getExploreOutputBudget, EXPLORE_ALLOCATION } from '../src/mcp/tools';
  15. import type { ExploreAllocationCandidate, ExploreAllocation, ExploreOutputBudget } from '../src/mcp/tools';
  16. /** A candidate with sane defaults — tests override only what they're about. */
  17. const cand = (
  18. path: string,
  19. score: number,
  20. extra: Partial<ExploreAllocationCandidate> = {},
  21. ): ExploreAllocationCandidate => ({ path, score, worth: 1, spine: false, ...extra });
  22. const TIER_FILE_COUNTS = [10, 100, 300, 1000, 4000, 10000, 20000, 60000];
  23. /**
  24. * The inline tool-result limit. Above it the host writes the response to a file
  25. * the agent Reads back, re-introducing the read this tool exists to prevent — so
  26. * it bounds every tier, not just the big ones (`hardCeiling`, tools.ts).
  27. */
  28. const INLINE_CAP = 25000;
  29. const reservedTotal = (a: ExploreAllocation) =>
  30. [...a.allowances.values()].reduce((sum, n) => sum + n, 0);
  31. /**
  32. * What the render loop can actually emit for these reservations: each file's
  33. * slice, plus the whole-file grace it may overshoot by, plus the markdown
  34. * overhead charged per section. The allocator's job is to keep this inside the
  35. * envelope it was handed.
  36. */
  37. const worstCaseEmission = (a: ExploreAllocation) => {
  38. let total = 0;
  39. for (const chars of a.allowances.values()) {
  40. total += chars + EXPLORE_ALLOCATION.FILE_OVERHEAD;
  41. }
  42. return total;
  43. };
  44. describe('allocateExploreBudget — proportional split', () => {
  45. const budget = getExploreOutputBudget(1000); // 24,000 / 6,500 / 8 files
  46. it('gives the higher-scoring file the bigger share', () => {
  47. const { allowances } = allocateExploreBudget(
  48. [cand('a.ts', 40), cand('b.ts', 10)],
  49. budget,
  50. 8,
  51. );
  52. expect(allowances.get('a.ts')!).toBeGreaterThan(allowances.get('b.ts')!);
  53. });
  54. it('scales the split with the score RATIO, not just the ordering', () => {
  55. // The heart of the fix. Under the old flat `maxCharsPerFile` both files got
  56. // the same cap and the split fell out of whichever happened to be small
  57. // enough to ship whole; here a 4x score buys materially more than a 1.1x one.
  58. const wide = allocateExploreBudget([cand('a.ts', 40), cand('b.ts', 10)], budget, 8).allowances;
  59. const narrow = allocateExploreBudget([cand('a.ts', 22), cand('b.ts', 20)], budget, 8).allowances;
  60. expect(wide.get('a.ts')! / wide.get('b.ts')!)
  61. .toBeGreaterThan(narrow.get('a.ts')! / narrow.get('b.ts')!);
  62. });
  63. it('never reserves more than the envelope', () => {
  64. const { allowances, pool } = allocateExploreBudget(
  65. [cand('a.ts', 90), cand('b.ts', 40), cand('c.ts', 30), cand('d.ts', 12)],
  66. budget,
  67. 8,
  68. );
  69. const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
  70. expect(reserved).toBeLessThanOrEqual(pool);
  71. expect(pool).toBeLessThanOrEqual(budget.maxOutputChars);
  72. });
  73. it('caps any single file at the MAX_SHARE safety valve', () => {
  74. // The per-file cap is retired as the primary guard, but a lone dominant file
  75. // must still not be handed the entire response.
  76. const { allowances } = allocateExploreBudget([cand('god.ts', 500)], budget, 8);
  77. expect(allowances.get('god.ts')!).toBeLessThanOrEqual(Math.round(budget.maxOutputChars * 0.7));
  78. });
  79. it('lets the top file exceed the old flat per-file cap when it earns it', () => {
  80. // The regression this task exists to fix: `maxCharsPerFile` clipped the file
  81. // that scored 4x its peers at exactly the same 6,500 as the noise.
  82. const { allowances } = allocateExploreBudget(
  83. [cand('answer.ts', 60), cand('noise.ts', 12)],
  84. budget,
  85. 8,
  86. );
  87. expect(allowances.get('answer.ts')!).toBeGreaterThan(budget.maxCharsPerFile);
  88. });
  89. });
  90. describe('allocateExploreBudget — the relative cliff', () => {
  91. const budget = getExploreOutputBudget(1000);
  92. it('gives zero source to a file far below the top score', () => {
  93. const { allowances, cliffed } = allocateExploreBudget(
  94. [cand('answer.ts', 90), cand('incidental.ts', 3)],
  95. budget,
  96. 8,
  97. );
  98. expect(cliffed).toContain('incidental.ts');
  99. expect(allowances.has('incidental.ts')).toBe(false);
  100. });
  101. it('is RELATIVE — the same score survives against weaker company', () => {
  102. const strong = allocateExploreBudget([cand('a.ts', 90), cand('b.ts', 8)], budget, 8);
  103. const even = allocateExploreBudget([cand('a.ts', 12), cand('b.ts', 8)], budget, 8);
  104. expect(strong.cliffed).toContain('b.ts');
  105. expect(even.cliffed).not.toContain('b.ts');
  106. });
  107. it('never rises above the score-floor ceiling, however dominant the top file', () => {
  108. // A 500-scoring god-file would otherwise put the cliff at 75 and silence
  109. // every peer the score floor had just deliberately admitted.
  110. const { cliffed } = allocateExploreBudget(
  111. [cand('god.ts', 500), cand('peer.ts', 13), cand('peer2.ts', 11)],
  112. budget,
  113. 8,
  114. );
  115. expect(cliffed).toEqual([]);
  116. });
  117. it('doubles the penalty on bytes that are worth less (generated / low-value)', () => {
  118. // `worth` is `rankPenalty` applied a second time: generated CRUD can rank on
  119. // name collisions while its bytes stay boilerplate. Same score, different fate.
  120. const { cliffed } = allocateExploreBudget(
  121. [cand('answer.ts', 60), cand('gen.ts', 12, { worth: 0.3 }), cand('hand.ts', 12)],
  122. budget,
  123. 8,
  124. );
  125. expect(cliffed).toContain('gen.ts');
  126. expect(cliffed).not.toContain('hand.ts');
  127. });
  128. it('exempts flow-spine files from the cliff', () => {
  129. // Clipping the spine causes the Read fallback — it IS the answer to a flow
  130. // question — so a spine file is never zeroed on relative score alone.
  131. const { allowances, cliffed } = allocateExploreBudget(
  132. [cand('a.ts', 400), cand('spine.ts', 2, { spine: true })],
  133. budget,
  134. 8,
  135. );
  136. expect(cliffed).not.toContain('spine.ts');
  137. expect(allowances.get('spine.ts')!).toBeGreaterThan(0);
  138. });
  139. it('never cliffs every candidate — an empty response costs a round-trip', () => {
  140. const { allowances, cliffed } = allocateExploreBudget([cand('only.ts', 0.5)], budget, 8);
  141. expect(cliffed).toEqual([]);
  142. expect(allowances.get('only.ts')!).toBeGreaterThan(0);
  143. });
  144. it('hands a cliffed file\'s maxFiles slot to the next file down', () => {
  145. // The mechanism that got `BuildPayslip` into the #1500 response: cliffing is
  146. // not just "spend fewer bytes here", it frees the SLOT too.
  147. const { allowances } = allocateExploreBudget(
  148. [cand('a.ts', 90), cand('noise.ts', 2), cand('b.ts', 40)],
  149. budget,
  150. 2,
  151. );
  152. expect([...allowances.keys()].sort()).toEqual(['a.ts', 'b.ts']);
  153. });
  154. });
  155. describe('allocateExploreBudget — the floor keeps diffuse questions useful', () => {
  156. const budget = getExploreOutputBudget(1000);
  157. it('gives every admitted file a slice big enough for a method', () => {
  158. // A survey question must still return a spread. The earlier design cliffed a
  159. // starved file instead of flooring it, and that CASCADED: removing the
  160. // smallest raised everyone else so little that the next-smallest starved too,
  161. // eating six legitimately-ranked peers one at a time.
  162. const files = [cand('a.ts', 100), cand('b.ts', 90), ...Array.from({ length: 6 }, (_, i) => cand(`p${i}.ts`, 20))];
  163. const { allowances } = allocateExploreBudget(files, budget, 8);
  164. expect(allowances.size).toBe(8);
  165. for (const [, chars] of allowances) expect(chars).toBeGreaterThanOrEqual(700);
  166. });
  167. it('serves fewer files well rather than many badly when the envelope cannot afford them', () => {
  168. const tiny = getExploreOutputBudget(10); // 13,000-char envelope
  169. const files = Array.from({ length: 40 }, (_, i) => cand(`f${i}.ts`, 50 - i * 0.1));
  170. const { allowances, cliffed } = allocateExploreBudget(files, tiny, 40);
  171. expect(allowances.size).toBeLessThan(40);
  172. expect(cliffed.length).toBeGreaterThan(0);
  173. for (const [, chars] of allowances) expect(chars).toBeGreaterThanOrEqual(700);
  174. const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
  175. expect(reserved).toBeLessThanOrEqual(tiny.maxOutputChars);
  176. });
  177. it('returns an empty allocation for an empty candidate list', () => {
  178. const { allowances, cliffed } = allocateExploreBudget([], budget, 8);
  179. expect(allowances.size).toBe(0);
  180. expect(cliffed).toEqual([]);
  181. });
  182. it('does not crash or over-allocate when every score is zero', () => {
  183. const { allowances } = allocateExploreBudget([cand('a.ts', 0), cand('b.ts', 0)], budget, 8);
  184. const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
  185. expect(reserved).toBeLessThanOrEqual(budget.maxOutputChars);
  186. });
  187. });
  188. describe('allocateExploreBudget — tier invariant', () => {
  189. it('never gives a larger tier a smaller allowance than a smaller tier', () => {
  190. // The standing invariant from `getExploreOutputBudget`: a bigger project must
  191. // never be served LESS per file. It held for the flat cap by inspection; with
  192. // a proportional split it has to hold for the same candidate set across every
  193. // tier, which is what this walks.
  194. const files = [cand('a.ts', 60), cand('b.ts', 30), cand('c.ts', 15)];
  195. let previous: Map<string, number> | null = null;
  196. for (const fileCount of TIER_FILE_COUNTS) {
  197. const { allowances } = allocateExploreBudget(files, getExploreOutputBudget(fileCount), 8);
  198. if (previous) {
  199. for (const [path, chars] of allowances) {
  200. expect(chars, `${path} shrank at ${fileCount} files`).toBeGreaterThanOrEqual(previous.get(path)!);
  201. }
  202. }
  203. previous = allowances;
  204. }
  205. });
  206. it('cliffs the same files at every tier — the cliff is relative, not sized', () => {
  207. const files = [cand('a.ts', 90), cand('noise.ts', 2)];
  208. const cliffs = TIER_FILE_COUNTS.map((n) =>
  209. allocateExploreBudget(files, getExploreOutputBudget(n), 8).cliffed.join(','));
  210. expect(new Set(cliffs).size).toBe(1);
  211. });
  212. });
  213. // ── CG-14 ───────────────────────────────────────────────────────────────────
  214. // Everything above pins the behaviours CG-12 was written to produce. What
  215. // follows pins the ones it must never produce: an over-spent envelope, a
  216. // starved diffuse query, a NaN slice — the failures that would ship silently
  217. // because they only surface as an agent falling back to Read.
  218. describe('allocateExploreBudget — calibration', () => {
  219. it('pins the constants the two #1500 fixtures were calibrated against', () => {
  220. // Deliberately literal. Every other test here asserts an INVARIANT and reads
  221. // the constants, so it holds at any value; this one exists so that changing a
  222. // value is a visible decision rather than a silent re-tune of the fixtures.
  223. // If you change one, re-run `node scripts/agent-eval/probe-allocation.mjs`.
  224. expect(EXPLORE_ALLOCATION).toMatchObject({
  225. CLIFF_FRACTION: 0.15,
  226. CLIFF_MAX: 10,
  227. MIN_CHARS: 700,
  228. MAX_SHARE: 0.7,
  229. FILE_OVERHEAD: 200,
  230. SPINE_WEIGHT_BOOST: 2,
  231. WHOLE_FILE_GRACE_FRACTION: 0.15,
  232. WHOLE_FILE_GRACE_MAX: 800,
  233. });
  234. });
  235. it('cliffs strictly BELOW the threshold, so a file exactly at it is still served', () => {
  236. // The boundary matters because `cliffAt` sits at CLIFF_MAX for any dominant
  237. // top file, which is also where the score floor's own ceiling sits — a file
  238. // that clears one must clear the other or the two gates disagree.
  239. const budget = getExploreOutputBudget(1000);
  240. const at = allocateExploreBudget([cand('top.ts', 1000), cand('probe.ts', 10)], budget, 8);
  241. const under = allocateExploreBudget([cand('top.ts', 1000), cand('probe.ts', 9.9)], budget, 8);
  242. expect(at.cliffAt).toBe(EXPLORE_ALLOCATION.CLIFF_MAX);
  243. expect(at.cliffed).not.toContain('probe.ts');
  244. expect(under.cliffed).toContain('probe.ts');
  245. });
  246. it('tracks the top file until CLIFF_MAX caps it', () => {
  247. const budget = getExploreOutputBudget(1000);
  248. const cliffFor = (top: number) =>
  249. allocateExploreBudget([cand('top.ts', top), cand('b.ts', 1)], budget, 8).cliffAt;
  250. expect(cliffFor(20)).toBeCloseTo(20 * EXPLORE_ALLOCATION.CLIFF_FRACTION, 5);
  251. expect(cliffFor(50)).toBeCloseTo(50 * EXPLORE_ALLOCATION.CLIFF_FRACTION, 5);
  252. expect(cliffFor(500)).toBe(EXPLORE_ALLOCATION.CLIFF_MAX);
  253. });
  254. });
  255. describe('allocateExploreBudget — envelope safety', () => {
  256. /** Deterministic LCG: a seeded sweep reproduces exactly, unlike Math.random. */
  257. const shapes = (): ExploreAllocationCandidate[][] => {
  258. let seed = 0x1500;
  259. const next = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
  260. const out: ExploreAllocationCandidate[][] = [];
  261. for (let n = 1; n <= 30; n++) {
  262. out.push(Array.from({ length: n }, (_, i) =>
  263. cand(`f${i}.ts`, Math.round(next() * 120 * 100) / 100, {
  264. worth: next() < 0.25 ? 0.3 : 1,
  265. spine: next() < 0.1,
  266. })));
  267. }
  268. return out;
  269. };
  270. it('never reserves more than the envelope, at any tier or shape', () => {
  271. // The one invariant that must hold unconditionally: the render loop spends
  272. // reservations, so an over-allocation is an over-long response, and an
  273. // over-long response is externalized to a file the agent has to Read back.
  274. for (const fileCount of TIER_FILE_COUNTS) {
  275. const budget = getExploreOutputBudget(fileCount);
  276. for (const files of shapes()) {
  277. for (const maxFiles of [1, 4, 8, 30]) {
  278. const alloc = allocateExploreBudget(files, budget, maxFiles);
  279. const label = `${files.length} files, maxFiles=${maxFiles}, tier ${fileCount}`;
  280. expect(reservedTotal(alloc), label).toBeLessThanOrEqual(alloc.pool);
  281. expect(worstCaseEmission(alloc), label).toBeLessThanOrEqual(budget.maxOutputChars);
  282. for (const chars of alloc.allowances.values()) {
  283. expect(Number.isFinite(chars) && chars > 0, label).toBe(true);
  284. }
  285. }
  286. }
  287. }
  288. });
  289. it('never renders more files than maxFiles', () => {
  290. for (const maxFiles of [1, 2, 4, 8]) {
  291. const files = Array.from({ length: 25 }, (_, i) => cand(`f${i}.ts`, 100 - i));
  292. const alloc = allocateExploreBudget(files, getExploreOutputBudget(1000), maxFiles);
  293. expect(alloc.allowances.size).toBeLessThanOrEqual(maxFiles);
  294. }
  295. });
  296. it('accounts for every candidate — a file is served, cliffed, or neither by choice', () => {
  297. // Nothing may vanish silently: a cliffed file is still NAMED in the response,
  298. // which is what makes withholding its bytes cheap. A file that is neither
  299. // served nor cliffed would be dropped without a pointer.
  300. const files = Array.from({ length: 25 }, (_, i) => cand(`f${i}.ts`, 100 - i * 4));
  301. const alloc = allocateExploreBudget(files, getExploreOutputBudget(1000), 8);
  302. const accounted = new Set([...alloc.allowances.keys(), ...alloc.cliffed]);
  303. expect(accounted.size).toBe(files.length);
  304. });
  305. it('leaves the ~25K inline cap reachable only through the hard ceiling', () => {
  306. // Reservations always fit `maxOutputChars`, but the whole-file grace lets the
  307. // render loop overshoot a slice — so the envelope alone does NOT bound the
  308. // response, and `hardCeiling` is load-bearing rather than defensive. Pin both
  309. // halves: every tier's envelope is inside the inline cap, and the worst-case
  310. // graced emission is what the ceiling has to catch.
  311. for (const fileCount of TIER_FILE_COUNTS) {
  312. const budget = getExploreOutputBudget(fileCount);
  313. const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), INLINE_CAP);
  314. expect(budget.maxOutputChars).toBeLessThan(INLINE_CAP);
  315. expect(hardCeiling).toBeLessThanOrEqual(INLINE_CAP);
  316. const files = Array.from({ length: 8 }, (_, i) => cand(`f${i}.ts`, 90 - i * 9));
  317. const alloc = allocateExploreBudget(files, budget, 8);
  318. const graced = [...alloc.allowances.values()].reduce((sum, chars) => sum + chars
  319. + Math.min(EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_MAX,
  320. Math.round(chars * EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_FRACTION))
  321. + EXPLORE_ALLOCATION.FILE_OVERHEAD, 0);
  322. expect(graced).toBeGreaterThan(budget.maxOutputChars);
  323. expect(reservedTotal(alloc)).toBeLessThanOrEqual(budget.maxOutputChars);
  324. }
  325. });
  326. });
  327. describe('allocateExploreBudget — spine first', () => {
  328. const budget = getExploreOutputBudget(1000);
  329. it('reserves more for a spine file than for an identically-scoring peer', () => {
  330. // "Spine first, unclipped" is enforced by WEIGHT, not by ordering: the spine
  331. // boost multiplies into the proportional split, so the flow gets its bytes
  332. // before any peripheral file competes for them.
  333. const { allowances } = allocateExploreBudget(
  334. [cand('peer.ts', 20), cand('spine.ts', 20, { spine: true })],
  335. budget,
  336. 8,
  337. );
  338. expect(allowances.get('spine.ts')!).toBeGreaterThan(allowances.get('peer.ts')!);
  339. expect(allowances.get('spine.ts')! / allowances.get('peer.ts')!).toBeGreaterThan(1.3);
  340. });
  341. it('keeps a spine file even when the envelope cannot afford everyone', () => {
  342. // The affordability trim keeps the highest weights and drops the rest in one
  343. // pass — but a dropped spine file breaks the flow, which is precisely the
  344. // failure that sends the agent back to Read. It is force-kept past the trim.
  345. const tiny = getExploreOutputBudget(10);
  346. const files = [
  347. ...Array.from({ length: 20 }, (_, i) => cand(`f${i}.ts`, 100 - i)),
  348. cand('spine.ts', 4, { spine: true }),
  349. ];
  350. const { allowances, cliffed } = allocateExploreBudget(files, tiny, 40);
  351. expect(allowances.has('spine.ts')).toBe(true);
  352. expect(cliffed).not.toContain('spine.ts');
  353. // Force-keeping it costs everyone a sliver — bounded, and the envelope still
  354. // holds. A real starvation regression would blow well past this.
  355. for (const [path, chars] of allowances) {
  356. expect(chars, path).toBeGreaterThanOrEqual(Math.round(EXPLORE_ALLOCATION.MIN_CHARS * 0.9));
  357. }
  358. expect(reservedTotal({ allowances, cliffed, cliffAt: 0, pool: 0 })).toBeLessThanOrEqual(tiny.maxOutputChars);
  359. });
  360. it('does NOT exempt a spine file from maxFiles — the slot cap is separate', () => {
  361. // Documented boundary, not an oversight: the cliff is a relevance gate the
  362. // spine overrides, `maxFiles` is a response-shape cap it does not. In
  363. // practice the 2x boost lifts a spine file into the slots long before this
  364. // bites; the test exists so a future change to either gate is deliberate.
  365. const { allowances, cliffed } = allocateExploreBudget(
  366. [cand('a.ts', 90), cand('b.ts', 80), cand('spine.ts', 3, { spine: true })],
  367. budget,
  368. 2,
  369. );
  370. expect(allowances.has('spine.ts')).toBe(false);
  371. expect(cliffed).toContain('spine.ts');
  372. });
  373. it('serves a spine-only candidate set', () => {
  374. const { allowances, cliffed } = allocateExploreBudget(
  375. [cand('a.ts', 5, { spine: true }), cand('b.ts', 5, { spine: true })],
  376. budget,
  377. 8,
  378. );
  379. expect(cliffed).toEqual([]);
  380. expect(allowances.size).toBe(2);
  381. });
  382. });
  383. describe('allocateExploreBudget — degenerate inputs', () => {
  384. const budget = getExploreOutputBudget(1000);
  385. it('splits evenly when every file scores identically, without starving any', () => {
  386. // The proportional split divides by the TOTAL weight, so an all-equal set is
  387. // the divide-by-a-degenerate-denominator case. Nobody is cliffed (nothing is
  388. // relatively weak) and everybody gets the same slice.
  389. for (const n of [2, 4, 8]) {
  390. const files = Array.from({ length: n }, (_, i) => cand(`f${i}.ts`, 17));
  391. const { allowances, cliffed } = allocateExploreBudget(files, budget, 8);
  392. expect(cliffed, `${n} files`).toEqual([]);
  393. expect(allowances.size, `${n} files`).toBe(n);
  394. const values = [...allowances.values()];
  395. expect(Math.max(...values) - Math.min(...values), `${n} files`).toBeLessThanOrEqual(1);
  396. for (const chars of values) expect(chars).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.MIN_CHARS);
  397. expect(worstCaseEmission({ allowances, cliffed, cliffAt: 0, pool: 0 }))
  398. .toBeLessThanOrEqual(budget.maxOutputChars);
  399. }
  400. });
  401. it('gives a lone file a real answer, not the whole envelope', () => {
  402. const { allowances, cliffed } = allocateExploreBudget([cand('only.ts', 42)], budget, 8);
  403. expect(cliffed).toEqual([]);
  404. expect(allowances.size).toBe(1);
  405. const chars = allowances.get('only.ts')!;
  406. expect(chars).toBeGreaterThan(budget.maxCharsPerFile);
  407. expect(chars).toBe(Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE));
  408. });
  409. it('holds a runaway top scorer to its share ceiling and still names the rest', () => {
  410. // One file 100x above everything else must not eat the response: the cliff
  411. // zeroes its peers' BYTES, but MAX_SHARE keeps the remainder for the pointer
  412. // list and the flow/relationship meta-text that lets the agent follow up.
  413. const { allowances, cliffed } = allocateExploreBudget(
  414. [cand('god.ts', 5000), cand('p1.ts', 9), cand('p2.ts', 8)],
  415. budget,
  416. 8,
  417. );
  418. expect(allowances.get('god.ts')!).toBe(Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE));
  419. expect(reservedTotal({ allowances, cliffed, cliffAt: 0, pool: 0 }))
  420. .toBeLessThan(budget.maxOutputChars);
  421. expect(cliffed).toEqual(['p1.ts', 'p2.ts']);
  422. });
  423. it('returns nothing to render when nothing scored', () => {
  424. for (const files of [
  425. [] as ExploreAllocationCandidate[],
  426. [cand('a.ts', 0), cand('b.ts', 0)],
  427. [cand('a.ts', 10, { worth: 0 }), cand('b.ts', 5, { worth: 0 })],
  428. [cand('a.ts', -5), cand('b.ts', -1)],
  429. ]) {
  430. const { allowances, pool } = allocateExploreBudget(files, budget, 8);
  431. expect(allowances.size).toBe(0);
  432. expect(pool).toBeLessThanOrEqual(budget.maxOutputChars);
  433. }
  434. });
  435. it('fails safe on a non-finite score instead of handing the render loop a NaN slice', () => {
  436. // Scores are finite sums in the pipeline, so this only has to not corrupt the
  437. // split — an Infinity weight would otherwise make every share Infinity/Infinity.
  438. for (const bad of [Infinity, NaN, -Infinity]) {
  439. const { allowances } = allocateExploreBudget([cand('bad.ts', bad), cand('ok.ts', 20)], budget, 8);
  440. for (const [path, chars] of allowances) {
  441. expect(Number.isFinite(chars), `${String(bad)} → ${path}`).toBe(true);
  442. }
  443. expect(allowances.get('ok.ts')).toBeGreaterThan(0);
  444. }
  445. });
  446. it('renders nothing when maxFiles is zero, and still names every candidate', () => {
  447. const { allowances, cliffed } = allocateExploreBudget(
  448. [cand('a.ts', 10), cand('b.ts', 5)],
  449. budget,
  450. 0,
  451. );
  452. expect(allowances.size).toBe(0);
  453. expect(cliffed).toEqual(['a.ts', 'b.ts']);
  454. });
  455. it('survives an envelope too small for even one floored slice', () => {
  456. const cramped: ExploreOutputBudget = { ...budget, maxOutputChars: 300 };
  457. const { allowances, cliffed } = allocateExploreBudget(
  458. [cand('a.ts', 40), cand('b.ts', 30)],
  459. cramped,
  460. 8,
  461. );
  462. expect(allowances.size).toBeLessThanOrEqual(1);
  463. for (const chars of allowances.values()) {
  464. expect(chars).toBeGreaterThan(0);
  465. expect(chars).toBeLessThanOrEqual(cramped.maxOutputChars);
  466. }
  467. expect([...allowances.keys(), ...cliffed].sort()).toEqual(['a.ts', 'b.ts']);
  468. });
  469. });
  470. describe('allocateExploreBudget — the diffuse-query control', () => {
  471. const budget = getExploreOutputBudget(1000);
  472. it('keeps a survey-style spread readable — no file collapses to a fragment', () => {
  473. // The over-correction guard. Concentration is the point, but a genuinely
  474. // diffuse question (many comparably-relevant files) must still come back as a
  475. // usable spread: under-serving costs a whole round-trip, and the agent's
  476. // fallback is Grep, not a second explore.
  477. const files = Array.from({ length: 8 }, (_, i) => cand(`f${i}.ts`, 30 - i));
  478. const { allowances, cliffed } = allocateExploreBudget(files, budget, 8);
  479. expect(cliffed).toEqual([]);
  480. expect(allowances.size).toBe(8);
  481. const values = [...allowances.values()];
  482. for (const chars of values) expect(chars).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.MIN_CHARS);
  483. // Nobody is starved to make room for the leader: on a flat score curve the
  484. // spread between best and worst slice stays within a small multiple.
  485. expect(Math.max(...values) / Math.min(...values)).toBeLessThan(3);
  486. });
  487. it('concentrates a precise query far harder than a diffuse one', () => {
  488. // Same envelope, same file count — only the score CURVE differs. This is the
  489. // whole thesis of the epic in one assertion.
  490. const topShareOf = (files: ExploreAllocationCandidate[]) => {
  491. const { allowances } = allocateExploreBudget(files, budget, 8);
  492. const values = [...allowances.values()];
  493. return Math.max(...values) / values.reduce((s, n) => s + n, 0);
  494. };
  495. const diffuse = topShareOf(Array.from({ length: 8 }, (_, i) => cand(`f${i}.ts`, 30 - i)));
  496. const precise = topShareOf([cand('answer.ts', 120), ...Array.from({ length: 7 }, (_, i) => cand(`f${i}.ts`, 14 - i))]);
  497. expect(diffuse).toBeLessThan(0.25);
  498. expect(precise).toBeGreaterThan(0.45);
  499. });
  500. });