explore-reservation-invariant.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. /**
  2. * Regression fixture for CG-26 — the end-to-end reservation invariant.
  3. *
  4. * Every admitted file receives at least its reservation before any file draws
  5. * on carry-forward slack.
  6. *
  7. * CG-30 bounded how far an oversize cluster member may overshoot and CG-31 gave
  8. * the cluster path a displacement guard. This pins the invariant they jointly
  9. * satisfy across EVERY render path — cluster, whole-file grace, whole-file BUY —
  10. * and in BOTH directions: the top-ranked file when the files below it overspend,
  11. * and an admitted lower-ranked file when the top one does.
  12. *
  13. * Two things CG-26 fixed are pinned here because nothing else can see them:
  14. *
  15. * - The whole-file arms were fit-tested against raw room before the ceiling,
  16. * never against what was still owed below. A grace-sized file could take a
  17. * pending file's reservation on its way to the ceiling; okhttp's
  18. * `CallServerInterceptor.kt` shipped 8,499 chars on a 5,964 funded ceiling
  19. * and the rank-6 file below it delivered nothing.
  20. * - Every section was charged a flat 200 chars of overhead while a real header
  21. * runs 300–500. The loop believed it had room it did not have (okhttp
  22. * rendered 26,601 chars against a 24,400 ceiling), so the final truncation
  23. * threw a fully-rendered section away — the same starvation, arriving after
  24. * the guard had done its work.
  25. *
  26. * Shares the `displacement-ts` fixture: four pipeline stages competing for one
  27. * envelope, the first a single ~20K function, padded past 500 indexed files so
  28. * the response sits on the 24K tier where reservations genuinely saturate the
  29. * ceiling.
  30. */
  31. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  32. import * as fs from 'fs';
  33. import * as path from 'path';
  34. import * as os from 'os';
  35. import CodeGraph from '../src/index';
  36. import { ToolHandler } from '../src/mcp/tools';
  37. import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
  38. import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
  39. const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'displacement-ts');
  40. const FILLER_FILES = 520;
  41. /** The giant: one ~20K function. Ranks #1 under the spread query. */
  42. const GIANT = 'src/pipeline/ingest.ts';
  43. /**
  44. * Three shapes, so the invariant is tested from both sides:
  45. * spread — every stage named; the giant ranks #1 and overspends downwards.
  46. * tail — the stages BELOW the giant named; something small ranks #1 while
  47. * the giant competes from underneath. This is the direction CG-31's
  48. * fixture could not reach.
  49. * precise — one symbol. The concentration case the guard must not flatten.
  50. */
  51. const QUERIES = {
  52. spread: 'ingestRecords normalizeRecords enrichRecords publishRecords',
  53. tail: 'publishRecords sinkRecord PipelineRecord ingestRecords',
  54. precise: 'ingestRecords',
  55. } as const;
  56. type Shape = keyof typeof QUERIES;
  57. interface Probe {
  58. response: string;
  59. report: ExploreDiagnosticReport;
  60. bytes: Map<string, number>;
  61. }
  62. describe('CG-26 — no admitted file is starved, on any render path', () => {
  63. let testDir: string;
  64. let cg: CodeGraph;
  65. const probes = {} as Record<Shape, Probe>;
  66. /** Admitted = the allocator reserved bytes for it. */
  67. const admitted = (probe: Probe): ExploreDiagnosticFile[] =>
  68. probe.report.files.filter((f) => (f.allowance ?? 0) > 0);
  69. const all = (): Probe[] => Object.values(probes);
  70. beforeAll(async () => {
  71. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg26-'));
  72. fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
  73. fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
  74. const filler = path.join(testDir, 'src', 'generated');
  75. fs.mkdirSync(filler, { recursive: true });
  76. for (let i = 0; i < FILLER_FILES; i++) {
  77. fs.writeFileSync(
  78. path.join(filler, `unit${i}.ts`),
  79. `export const seed${i} = ${i};\n`
  80. + `export function widget${i}(n: number): number {\n return n * ${i + 1} + seed${i};\n}\n`,
  81. );
  82. }
  83. cg = CodeGraph.initSync(testDir);
  84. await cg.indexAll();
  85. const sidecar = path.join(testDir, 'explore-diag.jsonl');
  86. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  87. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  88. try {
  89. const handler = new ToolHandler(cg);
  90. for (const [shape, query] of Object.entries(QUERIES) as [Shape, string][]) {
  91. const result = await handler.execute('codegraph_explore', { query });
  92. const response = result.content?.[0]?.text ?? '';
  93. const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
  94. probes[shape] = {
  95. response,
  96. report: JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport,
  97. bytes: attributeSourceBytes(response),
  98. };
  99. }
  100. } finally {
  101. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  102. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  103. }
  104. }, 180_000);
  105. afterAll(() => {
  106. if (cg) cg.destroy();
  107. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  108. });
  109. // ── Fixture shape — if these rot, the gates below mean nothing ─────────────
  110. describe('fixture shape', () => {
  111. it('sits on the 24K tier, where the reservations saturate the ceiling', () => {
  112. expect(cg.getStats().fileCount).toBeGreaterThanOrEqual(500);
  113. for (const probe of all()) expect(probe.report.budget.maxOutputChars).toBe(24000);
  114. });
  115. it('exercises both directions — the giant ranks #1 in one shape and lower in another', () => {
  116. // Which shape puts it where is the ranker's business and may move; that
  117. // it lands on BOTH sides across the three is what makes the gates below
  118. // test the invariant rather than one arrangement of it.
  119. const ranks = all().map((p) => p.report.files.find((f) => f.path === GIANT)?.rank ?? -1);
  120. expect(ranks).toContain(1);
  121. expect(ranks.some((r) => r > 1)).toBe(true);
  122. });
  123. it('exercises both render paths — something ships whole, something clusters', () => {
  124. const modes = new Set(all().flatMap((p) => p.report.files.map((f) => f.render)));
  125. expect(modes).toContain('clusters');
  126. expect(modes).toContain('whole');
  127. });
  128. });
  129. // ── The invariant ─────────────────────────────────────────────────────────
  130. describe('the reservation invariant', () => {
  131. it('CG-26 GATE: no file on ANY render path emits past what was still free', () => {
  132. // CG-31 pinned this for `clusters` only. The whole-file arms were fit-
  133. // tested against `renderCeiling - totalChars`, which is everyone's room,
  134. // not this file's — so a whole render could spend a reservation the loop
  135. // had already promised further down.
  136. for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
  137. const over = probe.report.files
  138. .filter((f) => f.render !== null && f.render !== 'dropped' && f.funded !== null)
  139. // +1 for the render loop's own rounding on a windowed cut.
  140. .filter((f) => f.emittedChars > f.funded! + 1)
  141. .map((f) => `${shape}/${f.path}: ${f.emittedChars} emitted of ${f.funded} funded (${f.render})`);
  142. expect(over).toEqual([]);
  143. }
  144. });
  145. it('CG-26 GATE: every admitted file is delivered, whatever its rank', () => {
  146. for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
  147. for (const rec of admitted(probe)) {
  148. expect(rec.skipped, `${shape}/${rec.path} skipped`).toBeNull();
  149. expect(probe.bytes.get(rec.path) ?? 0, `${shape}/${rec.path} bytes`).toBeGreaterThan(0);
  150. }
  151. }
  152. });
  153. it('CG-26 GATE: the rank-#1 file gets its reservation even when a file below overspends', () => {
  154. // The direction CG-31's fixture could not reach: under `tail` the giant
  155. // ranks below a small file and draws far past its own reservation from
  156. // carry-forward slack. Rank #1 must still receive what it was promised
  157. // (or its whole file, if that is less).
  158. for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
  159. const top = admitted(probe).sort((a, b) => a.rank - b.rank)[0];
  160. if (!top) continue;
  161. const onDisk = fs.statSync(path.join(testDir, top.path)).size;
  162. expect(probe.bytes.get(top.path) ?? 0, `${shape}/${top.path}`)
  163. .toBeGreaterThanOrEqual(Math.min(top.allowance!, onDisk) * 0.9);
  164. }
  165. });
  166. it('and the gate above is not vacuous — a lower-ranked file does overspend', () => {
  167. const overspenders = (probe: Probe) => admitted(probe)
  168. .filter((f) => f.rank > 1 && f.emittedChars > f.allowance!);
  169. expect(overspenders(probes.tail).length).toBeGreaterThan(0);
  170. });
  171. });
  172. // ── What the ceiling must no longer do ────────────────────────────────────
  173. describe('the hard ceiling never throws a rendered section away', () => {
  174. it('the render loop spends what it counts — nothing is allocated past the ceiling', () => {
  175. // Sections used to be charged a flat 200 chars against a header that runs
  176. // 300–500, so the loop over-filled and the final truncation dropped whole
  177. // sections. `allocatedChars` is the pre-truncation length: it staying
  178. // under the ceiling IS the accounting being exact.
  179. for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
  180. expect(probe.report.envelope.allocatedChars, shape)
  181. .toBeLessThanOrEqual(probe.report.budget.hardCeiling);
  182. expect(probe.report.envelope.truncated, shape).toBe(false);
  183. }
  184. });
  185. it('no file is rendered and then dropped', () => {
  186. for (const probe of all()) {
  187. expect(probe.report.files.filter((f) => f.render === 'dropped')).toEqual([]);
  188. }
  189. });
  190. it('keeps the response inside the hard ceiling', () => {
  191. for (const probe of all()) {
  192. expect(probe.report.envelope.chars).toBeLessThanOrEqual(probe.report.budget.hardCeiling);
  193. }
  194. });
  195. });
  196. // ── The epilogue is budgeted, not discarded ───────────────────────────────
  197. describe('the epilogue the loop budgeted for is the epilogue it emits', () => {
  198. it('a response that withheld files still says so, and says to explore not Read', () => {
  199. // The flat 600-char margin was neither the epilogue's size nor a bound on
  200. // it, so a saturated response shipped with no pointer list and no
  201. // reminders at all. Whatever else is traded away, the agent must be told
  202. // an uncovered area exists and that another explore reaches it.
  203. for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
  204. const withheld = probe.report.files.some(
  205. (f) => f.render === null || (probe.bytes.get(f.path) ?? 0) === 0);
  206. if (!withheld) continue;
  207. expect(
  208. /Not shown above|omitted for size|codegraph_explore/.test(probe.response),
  209. `${shape} withheld files without saying where to look`,
  210. ).toBe(true);
  211. }
  212. });
  213. it('never steers the agent to Read', () => {
  214. for (const probe of all()) {
  215. expect(/use (the )?Read|fall back to Read(?!ing those files)/i.test(probe.response)).toBe(false);
  216. }
  217. });
  218. });
  219. // ── The thing the invariant must NOT become ───────────────────────────────
  220. describe('concentration survives', () => {
  221. it('a precise symbol query still puts the most source in the named file', () => {
  222. const mine = probes.precise.bytes.get(GIANT) ?? 0;
  223. expect(mine).toBeGreaterThan(0);
  224. for (const [p, n] of probes.precise.bytes) {
  225. if (p === GIANT) continue;
  226. expect(mine, `${GIANT} vs ${p}`).toBeGreaterThan(n);
  227. }
  228. });
  229. it('is not an even split — the named file outspends its equal share', () => {
  230. const rec = probes.precise.report.files.find((f) => f.path === GIANT)!;
  231. const even = probes.precise.report.budget.maxOutputChars / admitted(probes.precise).length;
  232. expect(rec.emittedChars).toBeGreaterThan(even);
  233. });
  234. });
  235. });