explore-allocation-e2e.test.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  1. /**
  2. * Score-proportional explore allocation, end to end (CG-14 / epic CG-1 / #1500).
  3. *
  4. * `explore-proportional-allocation.test.ts` pins `allocateExploreBudget` in
  5. * isolation; `explore-allocation-1500.test.ts` pins the reporter's Go shape.
  6. * What is left — and what this file owns — is everything the allocator only
  7. * *promises*: the render loop has to spend those reservations, the hard ceiling
  8. * has to catch the overshoot, and a degenerate or diffuse result set has to come
  9. * back usable rather than empty. Each of those is invisible to a unit test,
  10. * because the failure mode is not an exception — it is a response the agent
  11. * quietly abandons in favour of Read.
  12. *
  13. * Two halves:
  14. *
  15. * 1. **The self-query fixture's shape.** CG-6 declared a second regression
  16. * fixture beside payroll-go: this repo, asked "how does explore allocate its
  17. * output budget across files", spending 63% of its envelope on
  18. * `scripts/agent-eval/*.mjs` files that merely mention `explore` and
  19. * `BUDGET`, while `src/mcp/tools.ts` — the file that actually answers — sat
  20. * clipped at the flat `maxCharsPerFile`. That fixture reads THIS repo's live
  21. * index, so it belongs to the out-of-band probe
  22. * (`node scripts/agent-eval/probe-allocation.mjs self-query`) where its
  23. * numbers can move with the repo. Reproduced here as a synthetic project so
  24. * `npm test` owns the MECHANISM deterministically: a large relevant file, a
  25. * small genuinely-relevant helper, and an incidental name-collision script.
  26. *
  27. * 2. **Degenerate and diffuse result sets.** One file, no files, all files
  28. * scoring alike, a survey question. The proportional split divides by a total
  29. * weight and concentrates on a leader — both of which have a degenerate case
  30. * that ends in a division by zero or a starved response.
  31. *
  32. * Nothing here is platform-gated: fixtures are written through `path.join`, and
  33. * every path ASSERTED against is an indexed relative path, which extraction
  34. * normalizes to forward slashes on every platform (`normalizePath`, utils.ts).
  35. * A literal like `src/mcp/allocator.ts` is therefore correct on Windows too —
  36. * gate a new assertion with `it.runIf` only if it reaches for a real filesystem
  37. * path or a platform-specific separator.
  38. */
  39. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  40. import * as fs from 'fs';
  41. import * as path from 'path';
  42. import * as os from 'os';
  43. import CodeGraph from '../src/index';
  44. import { ToolHandler, getExploreOutputBudget, EXPLORE_ALLOCATION } from '../src/mcp/tools';
  45. import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
  46. import type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics';
  47. /** The host's inline tool-result limit — above it the response is externalized. */
  48. const INLINE_CAP = 25000;
  49. const DEBUG_ENV = 'CODEGRAPH_EXPLORE_DEBUG';
  50. interface Project {
  51. dir: string;
  52. cg: CodeGraph;
  53. handler: ToolHandler;
  54. }
  55. /** Build + index a throwaway project from a `{ relPath: source }` map. */
  56. async function buildProject(prefix: string, files: Record<string, string>): Promise<Project> {
  57. const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
  58. for (const [rel, body] of Object.entries(files)) {
  59. const abs = path.join(dir, rel);
  60. fs.mkdirSync(path.dirname(abs), { recursive: true });
  61. fs.writeFileSync(abs, body.trimStart());
  62. }
  63. const cg = CodeGraph.initSync(dir);
  64. await cg.indexAll();
  65. return { dir, cg, handler: new ToolHandler(cg) };
  66. }
  67. function destroyProject(project?: Project): void {
  68. if (!project) return;
  69. project.cg.destroy();
  70. if (fs.existsSync(project.dir)) fs.rmSync(project.dir, { recursive: true, force: true });
  71. }
  72. /**
  73. * One explore call, reduced to what the allocation assertions need — plus the
  74. * CG-4 per-file diagnostic, which is where the SCORE and the RESERVATION live.
  75. * The instrument is observational (byte-identical output either way), so reading
  76. * it here measures the same response the agent would have received.
  77. */
  78. async function explore(project: Project, query: string) {
  79. // Outside the project root on purpose: a sidecar written INTO the indexed tree
  80. // is a new file the watcher can pick up mid-suite.
  81. const sidecar = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-alloc-diag-')), 'report.jsonl');
  82. const previous = process.env[DEBUG_ENV];
  83. process.env[DEBUG_ENV] = sidecar;
  84. let result;
  85. try {
  86. result = await project.handler.execute('codegraph_explore', { query });
  87. } finally {
  88. if (previous === undefined) delete process.env[DEBUG_ENV];
  89. else process.env[DEBUG_ENV] = previous;
  90. }
  91. const text = result.content?.[0]?.text ?? '';
  92. const bytes = attributeSourceBytes(text);
  93. const lines = fs.existsSync(sidecar)
  94. ? fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean)
  95. : [];
  96. const report = JSON.parse(lines[lines.length - 1]!) as ExploreDiagnosticReport;
  97. fs.rmSync(path.dirname(sidecar), { recursive: true, force: true });
  98. const fileOf = (file: string) => report.files.find((f) => f.path === file);
  99. return {
  100. text,
  101. bytes,
  102. report,
  103. isError: result.isError === true,
  104. /** Relevance score the ranking pass gave this file. */
  105. score: (file: string) => fileOf(file)?.score ?? 0,
  106. /** Chars of source the allocator RESERVED for it, before anything rendered. */
  107. allowance: (file: string) => fileOf(file)?.allowance ?? 0,
  108. /** Which render path the loop took: `whole`, `clusters`, `focused`, `skeleton`. */
  109. render: (file: string) => fileOf(file)?.render ?? null,
  110. /**
  111. * Rank the ranking pass gave it (1 = the file the response leads with, and
  112. * the first the render loop reaches). Read off the record rather than from
  113. * the position in `report.files`, which the report re-sorts by delivered
  114. * bytes for legibility.
  115. */
  116. rank: (file: string) => fileOf(file)?.rank ?? -1,
  117. /** Total source bytes delivered across every rendered file. */
  118. sourceTotal: () => [...bytes.values()].reduce((sum, n) => sum + n, 0),
  119. /** Fraction of the WHOLE response this file's source occupies. */
  120. share: (file: string) => (bytes.get(file) ?? 0) / (text.length || 1),
  121. shareUnder: (prefix: string) => {
  122. let total = 0;
  123. for (const [file, n] of bytes) if (file.startsWith(prefix)) total += n;
  124. return total / (text.length || 1);
  125. },
  126. };
  127. }
  128. // ── 1. The self-query fixture's shape ───────────────────────────────────────
  129. describe('#1500 fixture 2 — allocation followed FILE SIZE, not relevance', () => {
  130. /**
  131. * The three roles from the real fixture, at synthetic scale:
  132. *
  133. * - `src/mcp/allocator.ts` — stands in for `src/mcp/tools.ts`. Carries the
  134. * query's terms on real functions with real call edges, and is deliberately
  135. * too big to ship whole, so under the old rule it was clipped at the flat
  136. * `maxCharsPerFile` no matter how far it outscored its peers.
  137. * - `src/util/budget-math.ts` — stands in for `src/resolution/memory-budget.ts`.
  138. * Genuinely relevant (the allocator calls it) but scoring about half as
  139. * well — and small enough to ship WHOLE, which under the old rule was worth
  140. * more than being right.
  141. * - `scripts/eval-harness.mjs` — stands in for `scripts/agent-eval/*.mjs`. Its
  142. * only claim on the query is a file-scope `explore` and `BUDGET` that nothing
  143. * reads: the incidental collision CG-10 demoted.
  144. *
  145. * Measured on this fixture, reverting the render loop to the pre-CG-12 rules
  146. * (`fileBudget = maxCharsPerFile`, whole-file bound `maxCharsPerFile * 3`)
  147. * reproduces the report exactly — and every gate below goes red:
  148. *
  149. * | file | score | pre-CG-12 | CG-12 |
  150. * |-------------------------|-------|----------------|----------------|
  151. * | `src/mcp/allocator.ts` | 77.5 | 4,843 (39.7%) | 9,335 (80.1%) |
  152. * | `src/util/budget-math.ts` | 36.0 | 6,079 (49.8%) | 1,037 ( 8.9%) |
  153. *
  154. * The half-as-relevant file taking the larger share, purely on size, IS #1500.
  155. */
  156. const QUERY = 'how does explore allocate its output budget across files';
  157. const ALLOCATOR = 'src/mcp/allocator.ts';
  158. const HELPER = 'src/util/budget-math.ts';
  159. const INCIDENTAL = 'scripts/eval-harness.mjs';
  160. const allocatorPass = (index: number, name: string) => `
  161. /** ${name}: one pass of the explore output split. */
  162. export function ${name}(
  163. candidates: AllocationCandidate[],
  164. budget: ExploreOutputBudget,
  165. ): Map<string, number> {
  166. const allowances = new Map<string, number>();
  167. const pool = clampOutputBudget(budget.maxOutputChars - ${index} * 200);
  168. const total = candidates.reduce((sum, candidate) => sum + candidate.score, 0);
  169. if (total <= 0) {
  170. return allowances;
  171. }
  172. const floors = Math.min(pool, 700 * candidates.length);
  173. const remainder = budgetRemainderAfterFloors(pool, floors);
  174. for (const candidate of candidates) {
  175. const floor = Math.floor(floors / candidates.length);
  176. const proportional = splitOutputEvenly(remainder, total, candidate.score);
  177. const boosted = candidate.spine ? proportional * 2 : proportional;
  178. const share = Math.min(floor + boosted, budget.maxCharsPerFile * 3);
  179. if (share <= 0) {
  180. continue;
  181. }
  182. allowances.set(candidate.path, share);
  183. }
  184. return allowances;
  185. }
  186. `;
  187. /**
  188. * Neutral bulk for the helper file: real symbols that match NOTHING in the
  189. * query, so the file grows in BYTES without gaining relevance. That asymmetry
  190. * is the fixture — the real `memory-budget.ts` won 51% of the envelope against
  191. * a file scoring twice its score purely by being small enough to ship whole.
  192. */
  193. const helperFiller = (n: number) => `
  194. export function normalizeLedgerRow${n}(row: string[], fallback: string): string[] {
  195. const trimmed = row.map((cell) => cell.trim()).filter((cell) => cell.length > 0);
  196. return trimmed.length > 0 ? trimmed : [fallback];
  197. }
  198. `;
  199. const ALLOCATOR_SOURCE = `
  200. /** Explore budget allocation: splits the output envelope across relevant files. */
  201. export interface ExploreOutputBudget {
  202. maxOutputChars: number;
  203. maxCharsPerFile: number;
  204. defaultMaxFiles: number;
  205. }
  206. export interface AllocationCandidate {
  207. path: string;
  208. score: number;
  209. spine: boolean;
  210. }
  211. ${[
  212. 'allocateExploreBudget',
  213. 'reserveOutputPerFile',
  214. 'distributeOutputBudget',
  215. 'planExploreOutput',
  216. 'spendExploreBudget',
  217. 'balanceOutputAcrossFiles',
  218. 'concentrateExploreOutput',
  219. 'settleExploreAllocation',
  220. 'apportionExploreBudget',
  221. 'rationOutputAcrossFiles',
  222. 'tallyExploreOutputBudget',
  223. 'weighExploreAllocation',
  224. ].map((name, i) => allocatorPass(i + 1, name)).join('')}
  225. import {
  226. clampOutputBudget,
  227. splitOutputEvenly,
  228. budgetRemainderAfterFloors,
  229. } from '../util/budget-math';
  230. `;
  231. let project: Project;
  232. let run: Awaited<ReturnType<typeof explore>>;
  233. beforeAll(async () => {
  234. project = await buildProject('codegraph-alloc-selfquery-', {
  235. [ALLOCATOR]: ALLOCATOR_SOURCE,
  236. [HELPER]: `
  237. /** Budget arithmetic the explore output allocator leans on. */
  238. export function clampOutputBudget(value: number): number {
  239. if (value < 0) return 0;
  240. return Math.floor(value);
  241. }
  242. export function splitOutputEvenly(pool: number, total: number, score: number): number {
  243. if (total <= 0) return 0;
  244. return Math.floor((pool * score) / total);
  245. }
  246. export function budgetRemainderAfterFloors(pool: number, floors: number): number {
  247. const remainder = pool - floors;
  248. return remainder > 0 ? remainder : 0;
  249. }
  250. export function splitBudgetAcrossFiles(pool: number, fileCount: number): number {
  251. return fileCount > 0 ? Math.floor(pool / fileCount) : pool;
  252. }
  253. export function describeOutputBudget(pool: number, perFile: number): string {
  254. return \`explore budget pool of \${pool} chars, \${perFile} per file\`;
  255. }
  256. ${Array.from({ length: 22 }, (_, i) => helperFiller(i + 1)).join('')}`,
  257. [INCIDENTAL]: `
  258. // Eval harness. Mentions explore and BUDGET incidentally; nothing here allocates.
  259. const explore = 'explore';
  260. const BUDGET = 24000;
  261. export function runHarness(repo) {
  262. const rows = [];
  263. for (const line of repo.split('\\n')) {
  264. rows.push(line.trim());
  265. }
  266. return rows;
  267. }
  268. export function summarizeRun(rows) {
  269. return { count: rows.length, first: rows[0] };
  270. }
  271. `,
  272. 'src/mcp/server.ts': `
  273. import { allocateExploreBudget } from './allocator';
  274. export function serve(candidates: any[]) {
  275. return allocateExploreBudget(candidates, { maxOutputChars: 13000, maxCharsPerFile: 3800, defaultMaxFiles: 4 });
  276. }
  277. `,
  278. 'src/util/logger.ts': `
  279. export function log(message: string): void {
  280. console.log(message);
  281. }
  282. `,
  283. });
  284. run = await explore(project, QUERY);
  285. }, 120_000);
  286. afterAll(() => destroyProject(project));
  287. describe('fixture shape', () => {
  288. it('indexes all three roles, so a zero share means demoted and not missing', () => {
  289. // Without this the incidental assertion below could pass vacuously — a file
  290. // that was never indexed also delivers 0 bytes.
  291. for (const rel of [ALLOCATOR, HELPER, INCIDENTAL]) {
  292. expect(project.cg.getFile(rel), `${rel} indexed`).toBeTruthy();
  293. }
  294. });
  295. it('sizes the two files so the size-driven render split actually bites', () => {
  296. // The mechanism the epic is about. The answer file must be too big to ship
  297. // whole (so the old flat cap clipped it), and the helper small enough that
  298. // shipping it whole was always affordable under the old `maxCharsPerFile * 3`
  299. // bound. Without that asymmetry the fixture stops reproducing anything.
  300. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  301. const answer = fs.readFileSync(path.join(project.dir, ALLOCATOR), 'utf-8');
  302. const helper = fs.readFileSync(path.join(project.dir, HELPER), 'utf-8');
  303. expect(answer.split('\n').length).toBeGreaterThan(280);
  304. expect(answer.length).toBeGreaterThan(budget.maxCharsPerFile * 3);
  305. expect(helper.split('\n').length).toBeLessThan(220);
  306. expect(helper.length).toBeLessThan(budget.maxCharsPerFile * 3);
  307. });
  308. it('scores the answer file well above the helper it calls', () => {
  309. // The other half of the asymmetry: the reversal below only means something
  310. // if the file that used to WIN the envelope was the less relevant one.
  311. expect(run.score(ALLOCATOR)).toBeGreaterThan(run.score(HELPER) * 1.5);
  312. });
  313. });
  314. describe('budget allocation', () => {
  315. it('gives the file that answers the question the majority of the envelope', () => {
  316. // The epic's acceptance bar for this fixture: >50%, from 18.5% at baseline.
  317. // Pre-CG-12 this file took 39.7% — behind the helper it calls.
  318. expect(run.share(ALLOCATOR)).toBeGreaterThan(0.5);
  319. });
  320. it('lets the answer file spend multiples of the flat cap it used to be clipped at', () => {
  321. // The mechanism as a byte count rather than a share: this file is too big
  322. // to ship whole, so under the old rule its source was truncated at
  323. // `maxCharsPerFile` however far it outscored its peers. Its reservation is
  324. // now several times that cap. A build that re-imposes a flat per-file cap
  325. // fails HERE first — it delivered 4,843 against a 3,800 cap.
  326. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  327. expect(run.bytes.get(ALLOCATOR) ?? 0).toBeGreaterThan(budget.maxCharsPerFile * 2);
  328. });
  329. it('stops the smaller file winning on size — it no longer ships whole', () => {
  330. // The reversal, from the other side. The helper scores about half the
  331. // answer file and is small enough that the old whole-file bound shipped it
  332. // ENTIRE (6,079 chars, 49.8% of the envelope — more than the file that
  333. // answered the question). It now clusters inside its proportional share.
  334. const helperSource = fs.readFileSync(path.join(project.dir, HELPER), 'utf-8');
  335. const delivered = run.bytes.get(HELPER) ?? 0;
  336. expect(delivered).toBeGreaterThan(0);
  337. expect(delivered).toBeLessThan(helperSource.length);
  338. });
  339. it('orders per-file shares by relevance, not by file size', () => {
  340. // Both files deliver — this is not concentration by elimination — but the
  341. // one that answers the question gets several times the bytes of the helper
  342. // it calls. Pre-CG-12 this ratio was 0.8, i.e. inverted.
  343. const answer = run.share(ALLOCATOR);
  344. const helper = run.share(HELPER);
  345. expect(helper).toBeGreaterThan(0);
  346. expect(answer).toBeGreaterThan(helper * 3);
  347. });
  348. it('spends nothing on the incidental name collision', () => {
  349. expect(run.bytes.get(INCIDENTAL) ?? 0).toBe(0);
  350. expect(run.shareUnder('scripts/')).toBe(0);
  351. });
  352. it('reserves in proportion to score, before anything renders', () => {
  353. // The reservations are the contract the render loop then spends. Asserting
  354. // them directly — not just the bytes that came out — separates "allocation
  355. // is proportional" from "the render loop happened to emit these sizes".
  356. const answerReserved = run.allowance(ALLOCATOR);
  357. const helperReserved = run.allowance(HELPER);
  358. expect(answerReserved).toBeGreaterThan(helperReserved);
  359. expect(answerReserved / helperReserved).toBeGreaterThan(run.score(ALLOCATOR) / run.score(HELPER) * 0.5);
  360. // Nothing is over-promised: the sum of reservations fits the pool, and the
  361. // pool fits the envelope. This is the invariant the whole epic rests on.
  362. expect(run.report.allocation.reserved).toBeLessThanOrEqual(run.report.allocation.pool);
  363. expect(run.report.allocation.pool).toBeLessThanOrEqual(run.report.budget.maxOutputChars);
  364. });
  365. it('keeps the response inside the hard ceiling and under the inline cap', () => {
  366. // Two different bounds, and it matters which is which. `maxOutputChars`
  367. // bounds the RESERVATIONS (asserted above); the RESPONSE is bounded by
  368. // `hardCeiling` — 1.5x the envelope, capped at 25K — because the render
  369. // loop is allowed a bounded overshoot for the whole-file grace and an
  370. // oversize first cluster. The 25K is the one that must never move: past it
  371. // the host writes the result to a file the agent Reads back.
  372. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  373. const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), INLINE_CAP);
  374. expect(run.text.length).toBeLessThanOrEqual(hardCeiling);
  375. expect(run.text.length).toBeLessThan(INLINE_CAP);
  376. });
  377. it('records the shape of the split so a regression is legible', () => {
  378. // Not a gate — a snapshot, so a change that shifts the split shows up in the
  379. // diff rather than silently flipping a threshold.
  380. expect({
  381. answerWinsEnvelope: run.share(ALLOCATOR) > run.share(HELPER),
  382. helperStillDelivers: (run.bytes.get(HELPER) ?? 0) > 0,
  383. incidentalDelivers: (run.bytes.get(INCIDENTAL) ?? 0) > 0,
  384. }).toEqual({
  385. answerWinsEnvelope: true,
  386. helperStillDelivers: true,
  387. incidentalDelivers: false,
  388. });
  389. });
  390. });
  391. });
  392. // ── 1b. CG-21: a reservation below the file's size must not lose its bytes ──
  393. /**
  394. * The shape CG-15's agent A/B found in the wild, and the one thing the suite
  395. * above could not catch: a file whose reservation lands BELOW its own size.
  396. *
  397. * Express, `lib/utils.js` (5,293 B), the top-ranked file for
  398. * "res.send Content-Type ETag generateETag setETag":
  399. *
  400. * | | baseline | CG-12 |
  401. * |---|---|---|
  402. * | delivered | 6,380 (46.1%) whole | **583 (7.7%) cluster stub** |
  403. * | source envelope (13,000 budget) | 13,849 | **9,241** |
  404. *
  405. * It was reserved 3,870 and spent 583. The whole-file grace bound
  406. * (`allowance + min(800, allowance * 0.15)` = 4,450) sits just under the file,
  407. * so the whole-file render is declined; the fallback cluster render has three
  408. * matched symbols to work with and emits a stub. The other 3,287 chars were
  409. * neither delivered nor redistributed — **the pool shrank by a third against an
  410. * unchanged budget**, and the agent Read the file back four times.
  411. *
  412. * Everything about that is invisible to the fixtures above, and to the payroll
  413. * one: both SATURATE (`[over budget] [TRUNCATED]`, 23,599 of a 23,600 pool),
  414. * so there is no unspent reservation to lose. This fixture is built to sit in
  415. * the gap instead — a mid-sized top-ranked file with a THIN matched-symbol set,
  416. * sized just above its reservation — which is the combination that has to hold
  417. * for the defect to reproduce, and is why it shipped.
  418. *
  419. * The `fixture shape` block below is load-bearing, not scaffolding: every gate
  420. * here passes vacuously if the target ever drifts small enough for the grace
  421. * bound to cover it, so the window `0.6 × size <= reservation < size` is
  422. * asserted directly.
  423. */
  424. describe('CG-21 — a reservation under the file size still buys the file', () => {
  425. // Names two symbols that live in ONE mid-sized file (the named-seed tier is
  426. // what puts it at rank 0) while the rest of the terms pull in its peers, so
  427. // the proportional split hands the target well under its own size.
  428. const QUERY = 'generateEtag compileEtag send response body';
  429. const TARGET = 'src/http/etag.ts';
  430. const RESPONSE = 'src/http/response.ts';
  431. const APPLICATION = 'src/http/application.ts';
  432. /**
  433. * Bulk for the target: real, extractable symbols that match NOTHING in the
  434. * query. They make the file BIG without making it more relevant — which is
  435. * precisely how a file ends up reserved less than it is worth in bytes. Kept
  436. * dense (4 lines each) so the file stays well inside `WHOLE_FILE_MAX_LINES`
  437. * and the byte bound is the only thing that can decline the whole render.
  438. */
  439. const inertFiller = (n: number) => `
  440. export function normalizeLedgerRow${n}(row: string[], fallback: string, separator: string): string[] {
  441. const trimmed = row.map((cell) => cell.trim()).filter((cell) => cell.length > 0 && cell !== separator);
  442. return trimmed.length > 0 ? trimmed : [fallback, separator, String(trimmed.length), 'ledger-row-${n}'];
  443. }
  444. `;
  445. /**
  446. * The matched-symbol set, deliberately THIN and small. This is the second
  447. * half of the shape: with only these two tiny functions to cluster around,
  448. * the fallback render emits a few hundred chars and abandons the rest of the
  449. * reservation. A file with a fat matched set would spend its allowance the
  450. * ordinary way and never expose the bug.
  451. */
  452. const TARGET_SOURCE = `
  453. /** ETag helpers. */
  454. export function generateEtag(body: string): string {
  455. return '"' + body.length.toString(16) + '"';
  456. }
  457. export function compileEtag(setting: string): (body: string) => string {
  458. return setting === 'strong' ? generateEtag : (body: string) => 'W/' + generateEtag(body);
  459. }
  460. ${Array.from({ length: 27 }, (_, i) => inertFiller(i + 1)).join('')}`;
  461. const responseMethod = (name: string) => `
  462. public ${name}(body: string): string {
  463. const etag = compileEtag(this.etagSetting)(body);
  464. this.headers.set('etag', etag);
  465. return body;
  466. }
  467. `;
  468. const RESPONSE_SOURCE = `
  469. import { compileEtag } from './etag';
  470. /** The response object: sends a body and negotiates its representation. */
  471. export class ServerResponse {
  472. private headers = new Map<string, string>();
  473. private etagSetting = 'strong';
  474. ${[
  475. 'send',
  476. 'sendBody',
  477. 'sendResponse',
  478. 'writeBody',
  479. 'endResponse',
  480. 'json',
  481. 'setResponseBody',
  482. 'flushResponseBody',
  483. ].map(responseMethod).join('')}
  484. }
  485. `;
  486. let project: Project;
  487. let run: Awaited<ReturnType<typeof explore>>;
  488. let targetSize = 0;
  489. beforeAll(async () => {
  490. project = await buildProject('codegraph-alloc-cg21-', {
  491. [TARGET]: TARGET_SOURCE,
  492. [RESPONSE]: RESPONSE_SOURCE,
  493. [APPLICATION]: `
  494. import { ServerResponse } from './response';
  495. /** The application: routes a request and hands the response its body. */
  496. export class Application {
  497. private routes = new Map<string, (res: ServerResponse) => string>();
  498. public handleRequest(path: string, res: ServerResponse, body: string): string {
  499. const route = this.routes.get(path);
  500. return route ? route(res) : res.send(body);
  501. }
  502. public registerResponseRoute(path: string, handler: (res: ServerResponse) => string): void {
  503. this.routes.set(path, handler);
  504. }
  505. }
  506. `,
  507. 'src/http/request.ts': `
  508. /** The request object: carries the inbound body. */
  509. export class ServerRequest {
  510. public constructor(public readonly body: string) {}
  511. public freshResponseBody(): string {
  512. return this.body.trim();
  513. }
  514. }
  515. `,
  516. 'src/util/logger.ts': `
  517. export function log(message: string): void {
  518. console.log(message);
  519. }
  520. `,
  521. });
  522. targetSize = fs.readFileSync(path.join(project.dir, TARGET), 'utf-8').length;
  523. run = await explore(project, QUERY);
  524. }, 120_000);
  525. afterAll(() => destroyProject(project));
  526. describe('fixture shape', () => {
  527. it('ranks the target first, on a matched set of only two symbols', () => {
  528. // Rank 0 is what makes the loss expensive: this is the file the response
  529. // leads with, and the one the agent Reads back when it arrives as a stub.
  530. expect(run.rank(TARGET)).toBe(1);
  531. });
  532. it('sizes the target ABOVE its reservation but inside the buy window', () => {
  533. // The whole assertion set below is vacuous outside this window, so it is
  534. // pinned here rather than assumed:
  535. // reservation >= size → the grace bound already covers it, and the
  536. // buy rule is never consulted (express's other
  537. // three queries look like this).
  538. // reservation < 0.6×size → the shortfall is real, clustering is the
  539. // right answer, and the carry-forward — not the
  540. // buy rule — is what conserves the bytes.
  541. const reserved = run.allowance(TARGET);
  542. expect(reserved).toBeGreaterThan(0);
  543. expect(reserved).toBeLessThan(targetSize);
  544. expect(reserved / targetSize).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.WHOLE_FILE_BUY_FRACTION);
  545. // ...and specifically OUTSIDE the grace bound, which is the pre-CG-21
  546. // rule. If grace alone could carry it, this fixture proves nothing.
  547. const graceBound = reserved + Math.min(
  548. EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_MAX,
  549. Math.round(reserved * EXPLORE_ALLOCATION.WHOLE_FILE_GRACE_FRACTION),
  550. );
  551. expect(targetSize).toBeGreaterThan(graceBound);
  552. });
  553. it('keeps the target inside the whole-file LINE bound, so only bytes can gate it', () => {
  554. // `WHOLE_FILE_MAX_LINES` (220 for a non-central file) is a separate gate
  555. // that also declines a whole render. If the fixture ever crossed it the
  556. // suite would go red for the wrong reason — and, worse, a genuine
  557. // regression in the BYTE bound would be masked by it.
  558. const lines = fs.readFileSync(path.join(project.dir, TARGET), 'utf-8').split('\n').length;
  559. expect(lines).toBeLessThanOrEqual(220);
  560. });
  561. });
  562. describe('the reservation is spent', () => {
  563. it('delivers the target WHOLE rather than as a cluster stub', () => {
  564. // The headline. Pre-CG-21 this file rendered `clusters` and emitted a few
  565. // hundred chars against a multi-thousand-char reservation.
  566. expect(run.render(TARGET)).toBe('whole');
  567. });
  568. it('spends more than the reservation, not a fraction of it', () => {
  569. // Stated as bytes so it bites independently of the render-mode label: a
  570. // build that renamed the whole path but still emitted a stub fails here.
  571. // Express: 583 delivered against 3,870 reserved.
  572. const delivered = run.bytes.get(TARGET) ?? 0;
  573. expect(delivered).toBeGreaterThanOrEqual(targetSize);
  574. expect(delivered).toBeGreaterThan(run.allowance(TARGET));
  575. });
  576. it('leaves no rendered file both under its reservation and short of content', () => {
  577. // The defect stated as an invariant, which is what makes it general rather
  578. // than a re-assertion of the case above: a rendered file either SPENDS what
  579. // it was promised, or it ran out of file. Express's `lib/utils.js` did
  580. // neither — 583 delivered, 3,870 promised, 5,293 bytes of file sitting
  581. // there — and the difference was dropped rather than redistributed, which
  582. // is why the source envelope fell 13,849 → 9,241 on an unchanged budget.
  583. //
  584. // `response.ts` is the case the naive "spend the whole pool" version of
  585. // this test gets wrong: it delivers 1,635 of a 5,292 reservation and that
  586. // is CORRECT — the file is only 1,635 bytes. A pool cannot be spent past
  587. // the content that exists to fill it.
  588. for (const f of run.report.files) {
  589. if (!f.render || (f.emittedChars ?? 0) === 0) continue;
  590. const size = fs.readFileSync(path.join(project.dir, f.path), 'utf-8').length;
  591. expect(f.emittedChars, `${f.path} spent its reservation or ran out of file`)
  592. .toBeGreaterThanOrEqual(Math.min(f.allowance ?? 0, size));
  593. }
  594. });
  595. it('holds the hard ceiling while doing it', () => {
  596. // The buy rule spends MORE than the reservation, so the bound that stops
  597. // it running away has to be re-proved here and not inherited: the
  598. // overshoot pool is finite, and the 25K inline cap is absolute — past it
  599. // the host writes the result to a file the agent Reads back.
  600. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  601. const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), INLINE_CAP);
  602. expect(run.text.length).toBeLessThanOrEqual(hardCeiling);
  603. expect(run.text.length).toBeLessThan(INLINE_CAP);
  604. });
  605. it('still serves the peers — concentration, not a single-file response', () => {
  606. // The over-correction control for this fixture. Buying the target whole
  607. // must not eat the files below it: that is the trade the shared overshoot
  608. // pool refuses (it dropped `payslip_builder.go` when funding was per-file).
  609. const peers = [RESPONSE, APPLICATION].filter((f) => (run.bytes.get(f) ?? 0) > 0);
  610. expect(peers.length).toBeGreaterThan(0);
  611. });
  612. });
  613. });
  614. /**
  615. * The other half of CG-21, and the half the whole-file buy rule cannot reach.
  616. *
  617. * Buying the file whole only helps when the reservation has already covered
  618. * most of it. Below that the shortfall is real — the file is several times its
  619. * reservation, and clustering IS the right render — but the bytes it cannot
  620. * spend still must not evaporate. Express, query "compileETag req.fresh":
  621. * `lib/utils.js` was reserved 3,809 and spent 791; the 3,018 chars it left had
  622. * to reach `lib/response.js` below it, which delivered 4,650 on a 1,895
  623. * reservation.
  624. *
  625. * So this fixture is deliberately the INVERSE of the one above: the leading
  626. * file is far too big for the buy rule to fire, and the assertion is on the
  627. * file BELOW it. Without this, `allowance = reserved` — the whole carry-forward
  628. * deleted — passes every other test in this file.
  629. */
  630. describe('CG-21 — an unspendable reservation flows to the next file down', () => {
  631. // Names three tiny callables that all live in the SPRAWL file — the named-seed
  632. // tier is what puts a file with almost no matched content at rank 1 — plus one
  633. // term the absorber's methods carry, so it ranks second rather than cliffing.
  634. const QUERY = 'renderStaticScene renderInteractiveScene renderNewElementScene paintSceneLayer';
  635. // Rank 1: a huge file the query names two symbols in. Its reservation cannot
  636. // approach its size, so it clusters — and clusters thinly, because those two
  637. // symbols are all it matched.
  638. const SPRAWL = 'src/scene/sprawl.ts';
  639. // Rank 2: dense with matched symbols and bigger than any share it can be
  640. // reserved, so it will absorb whatever the file above it leaves.
  641. const ABSORBER = 'src/render/absorber.ts';
  642. const inertBulk = (n: number) => `
  643. export function reconcileLedgerEntry${n}(rows: string[], fallback: string, separator: string): string[] {
  644. const trimmed = rows.map((cell) => cell.trim()).filter((cell) => cell.length > 0 && cell !== separator);
  645. return trimmed.length > 0 ? trimmed : [fallback, separator, String(trimmed.length), 'entry-${n}'];
  646. }
  647. `;
  648. // Long ENOUGH, in lines, that the absorber cannot ship whole (220 lines is the
  649. // other whole-file gate). That matters: a file that renders whole ignores the
  650. // per-file budget entirely, and this fixture is about a budget being spent.
  651. const matchedPaint = (n: number) => `
  652. public paintSceneLayer${n}(canvas: string, scene: string, element: string): string {
  653. const appState = this.appState.get('layer${n}') ?? scene;
  654. const painted = canvas + '|' + appState + '|' + element;
  655. const stamped = painted + '|layer-${n}';
  656. const merged = stamped + '|' + scene + '|' + element;
  657. const settled = merged.split('|').filter((part) => part.length > 0).join('|');
  658. this.appState.set('layer${n}', settled);
  659. if (settled.length === 0) {
  660. return this.paint(scene, scene);
  661. }
  662. return this.paint(settled, scene);
  663. }
  664. `;
  665. let project: Project;
  666. let run: Awaited<ReturnType<typeof explore>>;
  667. beforeAll(async () => {
  668. project = await buildProject('codegraph-alloc-cg21-carry-', {
  669. [SPRAWL]: `
  670. import { Absorber } from '../render/absorber';
  671. /** Scene sprawl: three one-line answers buried in a very large file. */
  672. export function renderStaticScene(scene: string): string {
  673. return new Absorber().paint(scene, scene);
  674. }
  675. export function renderInteractiveScene(scene: string): string {
  676. return new Absorber().paint(scene, scene + ':interactive');
  677. }
  678. export function renderNewElementScene(scene: string): string {
  679. return new Absorber().paint(scene, scene + ':new-element');
  680. }
  681. ${Array.from({ length: 90 }, (_, i) => inertBulk(i + 1)).join('')}`,
  682. [ABSORBER]: `
  683. /** The renderer: many matched paint passes, all of them wanted. */
  684. export class Absorber {
  685. private appState = new Map<string, string>();
  686. public paint(element: string, scene: string): string {
  687. return element + '|' + scene;
  688. }
  689. ${Array.from({ length: 20 }, (_, i) => matchedPaint(i + 1)).join('')}
  690. }
  691. ${/* Inert tail: pushes the absorber FAR past its reservation so the whole-file
  692. buy rule cannot fire on it either. Without this the absorber ships whole
  693. and the fixture measures the buy rule a second time instead of the
  694. carry-forward — which is exactly how it read on the first attempt. */
  695. Array.from({ length: 40 }, (_, i) => inertBulk(100 + i)).join('')}`,
  696. 'src/util/logger.ts': `
  697. export function log(message: string): void {
  698. console.log(message);
  699. }
  700. `,
  701. });
  702. run = await explore(project, QUERY);
  703. }, 120_000);
  704. afterAll(() => destroyProject(project));
  705. it('leaves the leading file unable to spend its reservation', () => {
  706. // The precondition. If the sprawl file ever spends its share, there is no
  707. // slack, and the assertion below passes for no reason at all.
  708. const spent = run.bytes.get(SPRAWL) ?? 0;
  709. expect(spent).toBeGreaterThan(0);
  710. expect(spent).toBeLessThan(run.allowance(SPRAWL));
  711. // ...and it is out of reach of the buy rule, so this is genuinely the
  712. // carry-forward's case and not a second test of the fixture above.
  713. const size = fs.readFileSync(path.join(project.dir, SPRAWL), 'utf-8').length;
  714. expect(run.allowance(SPRAWL) / size).toBeLessThan(EXPLORE_ALLOCATION.WHOLE_FILE_BUY_FRACTION);
  715. });
  716. it('hands the shortfall to the file below, which spends past its own reservation', () => {
  717. // The lever. Measured both ways on this fixture: with the carry-forward the
  718. // absorber delivers 9,297 against a 7,455 reservation; with
  719. // `allowance = reserved` it delivers 7,479 — its reservation and nothing
  720. // more, while the sprawl file's 4,408 unspent chars are dropped.
  721. //
  722. // The 1.1 margin is not padding. A cluster section can land a few chars over
  723. // the budget it was selected against (whole symbol ranges, never sliced
  724. // mid-method), so "delivered > reserved" alone is true by ~24 chars even on
  725. // the mutated build — a test that passes on the defect.
  726. const delivered = run.bytes.get(ABSORBER) ?? 0;
  727. expect(delivered).toBeGreaterThan(Math.round(run.allowance(ABSORBER) * 1.1));
  728. });
  729. it('keeps the shortfall in the envelope instead of dropping it', () => {
  730. // The same lever read off the response as a whole, which is the form the
  731. // user actually feels: express's source envelope fell 13,849 → 9,241 on an
  732. // unchanged 13,000 budget because nothing picked up what `lib/utils.js`
  733. // could not spend. Here: 10,033 delivered with the carry-forward, 8,215
  734. // without.
  735. //
  736. // Stated against what a no-carry build could produce — the leader's actual
  737. // spend plus the absorber's own reservation — so it stays a statement about
  738. // the mechanism rather than a hard-coded byte count.
  739. const noCarryCeiling = (run.bytes.get(SPRAWL) ?? 0) + Math.round(run.allowance(ABSORBER) * 1.05);
  740. expect(run.sourceTotal()).toBeGreaterThan(noCarryCeiling);
  741. });
  742. it('bounds the borrowing — slack concentrates, it does not consume', () => {
  743. // Carried slack is clamped to `MAX_SHARE` of the envelope, so an
  744. // under-spending leader cannot hand the file below it the whole response.
  745. // The bound is stated WITH the spine allowance (`SPINE_CEILING`, 1.5x)
  746. // folded in: a flow-path cluster is deliberately allowed past the per-file
  747. // share, and that predates CG-21 — writing the tighter bound here would
  748. // make this test fail on a build with no defect in it.
  749. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  750. const clamp = Math.max(
  751. run.allowance(ABSORBER),
  752. Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE),
  753. );
  754. expect(run.bytes.get(ABSORBER) ?? 0).toBeLessThanOrEqual(Math.round(clamp * 1.5));
  755. // The anti-starvation half, and the one that would actually bite: the file
  756. // that lent the slack still gets rendered.
  757. expect(run.bytes.get(SPRAWL) ?? 0).toBeGreaterThan(0);
  758. expect(run.text.length).toBeLessThan(INLINE_CAP);
  759. });
  760. });
  761. // ── 2. Degenerate and diffuse result sets ───────────────────────────────────
  762. describe('allocation on degenerate result sets', () => {
  763. let project: Project;
  764. beforeAll(async () => {
  765. // Four modules that are deliberate COPIES of each other, plus one unrelated
  766. // file. Copies are the pathological input for a proportional split: every
  767. // candidate carries the same weight, so the split divides by a denominator
  768. // that is entirely made of ties.
  769. const twin = (n: number) => `
  770. export class InventoryLedger${n} {
  771. private rows: number[] = [];
  772. public recordInventoryMovement(quantity: number): void {
  773. this.rows.push(quantity);
  774. }
  775. public settleInventoryLedger(): number {
  776. return this.rows.reduce((sum, row) => sum + row, 0);
  777. }
  778. }
  779. `;
  780. project = await buildProject('codegraph-alloc-degenerate-', {
  781. 'src/ledger/one.ts': twin(1),
  782. 'src/ledger/two.ts': twin(2),
  783. 'src/ledger/three.ts': twin(3),
  784. 'src/ledger/four.ts': twin(4),
  785. 'src/unrelated/colors.ts': `
  786. export const PALETTE = ['oxblood', 'paper', 'ink'];
  787. export function pickPaletteEntry(index: number): string {
  788. return PALETTE[index % PALETTE.length]!;
  789. }
  790. `,
  791. });
  792. }, 120_000);
  793. afterAll(() => destroyProject(project));
  794. it('does not starve anyone when every file scores identically', async () => {
  795. // The all-ties case, end to end: no division by zero, nobody cliffed for
  796. // being relatively weak (nothing IS relatively weak), and no single copy
  797. // sweeping the envelope on an arbitrary tiebreak.
  798. const run = await explore(project, 'how does the inventory ledger record and settle movements');
  799. expect(run.isError).toBe(false);
  800. const ledger = [...run.bytes].filter(([file]) => file.startsWith('src/ledger/'));
  801. expect(ledger.length).toBeGreaterThanOrEqual(2);
  802. const shares = ledger.map(([, n]) => n);
  803. expect(Math.max(...shares) / Math.min(...shares)).toBeLessThan(3);
  804. for (const [file, n] of ledger) {
  805. expect(n, `${file} starved`).toBeGreaterThan(0);
  806. }
  807. // The reservations behind those bytes divided cleanly too.
  808. expect(run.report.allocation.reserved).toBeLessThanOrEqual(run.report.allocation.pool);
  809. });
  810. it('answers a single-file question without over-spending the envelope on it', async () => {
  811. const run = await explore(project, 'pickPaletteEntry');
  812. expect(run.isError).toBe(false);
  813. expect(run.bytes.get('src/unrelated/colors.ts') ?? 0).toBeGreaterThan(0);
  814. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  815. // One dominant file still cannot exceed the share ceiling, and the response
  816. // as a whole still fits the envelope's hard ceiling.
  817. expect(run.bytes.get('src/unrelated/colors.ts')!)
  818. .toBeLessThanOrEqual(Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE));
  819. expect(run.text.length).toBeLessThan(INLINE_CAP);
  820. });
  821. it('returns guidance rather than an error when nothing matches', async () => {
  822. // An `isError` response teaches the agent to abandon codegraph for the rest
  823. // of the session, so a zero-result allocation must stay success-shaped.
  824. const run = await explore(project, 'quantumFluxCapacitorHandshake');
  825. expect(run.isError).toBe(false);
  826. expect(run.text.length).toBeGreaterThan(0);
  827. expect(run.bytes.size).toBe(0);
  828. });
  829. });
  830. describe('the diffuse-query control', () => {
  831. let project: Project;
  832. beforeAll(async () => {
  833. // Six genuinely distinct subsystems, each a legitimate partial answer to a
  834. // survey question. Concentration is the epic's goal, but over-correcting here
  835. // costs a round-trip: the agent's fallback for an under-served survey is
  836. // Grep, not a second explore.
  837. const subsystem = (name: string, verb: string) => `
  838. export interface ${name}Options {
  839. retries: number;
  840. }
  841. export class ${name}Service {
  842. constructor(private readonly options: ${name}Options) {}
  843. public ${verb}Request(payload: string): string {
  844. return this.describe${name}() + ':' + payload;
  845. }
  846. public describe${name}(): string {
  847. return '${name} with ' + this.options.retries + ' retries';
  848. }
  849. }
  850. `;
  851. project = await buildProject('codegraph-alloc-diffuse-', {
  852. 'src/services/auth.ts': subsystem('Auth', 'authorize'),
  853. 'src/services/billing.ts': subsystem('Billing', 'charge'),
  854. 'src/services/search.ts': subsystem('Search', 'query'),
  855. 'src/services/notify.ts': subsystem('Notify', 'publish'),
  856. 'src/services/report.ts': subsystem('Report', 'render'),
  857. 'src/services/audit.ts': subsystem('Audit', 'record'),
  858. });
  859. }, 120_000);
  860. afterAll(() => destroyProject(project));
  861. it('still returns a spread for a survey-style question', async () => {
  862. // The over-correction guard for CG-10's floor and CG-12's cliff together: a
  863. // question with no single right answer must come back as several usable
  864. // sections, not one file plus a pointer list.
  865. const run = await explore(project, 'what services does this project expose and what does each one do');
  866. expect(run.isError).toBe(false);
  867. const services = [...run.bytes].filter(([file]) => file.startsWith('src/services/'));
  868. expect(services.length).toBeGreaterThanOrEqual(3);
  869. const total = services.reduce((sum, [, n]) => sum + n, 0);
  870. expect(total).toBeGreaterThan(0);
  871. for (const [file, n] of services) {
  872. // Nobody is reduced to a fragment, and nobody swallows the response.
  873. expect(n, `${file} fragment`).toBeGreaterThan(200);
  874. expect(n / total, `${file} hogged the envelope`).toBeLessThan(0.8);
  875. }
  876. });
  877. it('names whatever it could not show, so the spread stays completable', async () => {
  878. const run = await explore(project, 'what services does this project expose and what does each one do');
  879. const shown = [...run.bytes.keys()].filter((f) => f.startsWith('src/services/'));
  880. const missing = ['auth', 'billing', 'search', 'notify', 'report', 'audit']
  881. .map((n) => `src/services/${n}.ts`)
  882. .filter((f) => !shown.includes(f));
  883. for (const file of missing) {
  884. expect(run.text, `${file} dropped without a pointer`).toContain(file);
  885. }
  886. });
  887. });