explore-displacement-guard.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /**
  2. * Regression fixture for CG-31 — a clustered render may not spend a reservation
  3. * still owed to a file the loop has not reached.
  4. *
  5. * The allocator hands every admitted file a reservation (CG-12), and the render
  6. * loop then walks the files in rank order. Carry-forward slack lets a file spend
  7. * what the files ABOVE it left on the table, which is right; what was missing is
  8. * the other half — nothing was held back for the files BELOW it. The whole-file
  9. * BUY arm has always refused that trade (`owedBelow`, `tools.ts`); the cluster
  10. * path had no equivalent, so `fileBudget`/`SPINE_CEILING` read what was left
  11. * before the hard ceiling rather than what was still promised, and the first
  12. * oversize file could take the response.
  13. *
  14. * `__tests__/fixtures/displacement-ts/` reproduces it. Four pipeline stages
  15. * compete for one envelope; the first, `ingest.ts`, is a single ~20K function —
  16. * one cluster member far bigger than any reservation it can earn — so it takes
  17. * the bounded overshoot CG-30 left it. The fixture is padded to >500 indexed
  18. * files on purpose: the displacement only exists on the 24K tier, where the
  19. * reservations plus the response preamble genuinely saturate the hard ceiling.
  20. *
  21. * Measured against the pre-fix build (CG-30 landed, CG-31 not):
  22. *
  23. * ingest.ts 9,301 chars emitted on a 6,289 spendable — then dropped whole
  24. * by the final ceiling, so it cost the response and delivered 0
  25. * types.ts skipped `budget-whole-file`
  26. * sink.ts skipped `budget-whole-file`
  27. * delivered 3 of 6 admitted files, 14,908-char envelope
  28. *
  29. * With the guard: 6 of 6, 22,066-char envelope, and `ingest.ts` bounded to the
  30. * 4,913 that were actually still free.
  31. */
  32. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  33. import * as fs from 'fs';
  34. import * as path from 'path';
  35. import * as os from 'os';
  36. import CodeGraph from '../src/index';
  37. import { ToolHandler } from '../src/mcp/tools';
  38. import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
  39. import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
  40. const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'displacement-ts');
  41. /**
  42. * Padding modules, written into the temp copy rather than checked in. The
  43. * output tier is chosen by INDEXED FILE COUNT, and the displacement this test
  44. * pins only exists at >=500 files (24K envelope against a 24.4K render ceiling
  45. * that also has to hold the response preamble). Below that the ceiling has
  46. * enough slack to absorb an overshoot and the bug is invisible.
  47. */
  48. const FILLER_FILES = 520;
  49. /** A symbol bag spanning all four stages — they compete for one envelope. */
  50. const QUERY = 'ingestRecords normalizeRecords enrichRecords publishRecords';
  51. /** One symbol, one file — the concentration case the guard must not flatten. */
  52. const PRECISE_QUERY = 'ingestRecords';
  53. /** The giant: one ~20K function, the file that used to take the response. */
  54. const GIANT = 'src/pipeline/ingest.ts';
  55. /** Ranked below the giant and dropped by it pre-fix. */
  56. const STARVED = ['src/pipeline/types.ts', 'src/pipeline/sink.ts'];
  57. interface Probe {
  58. response: string;
  59. report: ExploreDiagnosticReport;
  60. bytes: Map<string, number>;
  61. }
  62. describe('CG-31 — the cluster path holds back what is still owed below it', () => {
  63. let testDir: string;
  64. let cg: CodeGraph;
  65. let spread: Probe;
  66. let precise: Probe;
  67. const fileOf = (probe: Probe, p: string): ExploreDiagnosticFile => {
  68. const rec = probe.report.files.find((f) => f.path === p);
  69. if (!rec) throw new Error(`${p} absent from the diagnostic report`);
  70. return rec;
  71. };
  72. /** Admitted = the allocator reserved bytes for it. */
  73. const admitted = (probe: Probe): ExploreDiagnosticFile[] =>
  74. probe.report.files.filter((f) => (f.allowance ?? 0) > 0);
  75. beforeAll(async () => {
  76. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg31-'));
  77. fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
  78. fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
  79. const filler = path.join(testDir, 'src', 'generated');
  80. fs.mkdirSync(filler, { recursive: true });
  81. for (let i = 0; i < FILLER_FILES; i++) {
  82. // Deterministic, unrelated to the query — these pad the file count, they
  83. // must never rank.
  84. fs.writeFileSync(
  85. path.join(filler, `unit${i}.ts`),
  86. `export const seed${i} = ${i};\n`
  87. + `export function widget${i}(n: number): number {\n return n * ${i + 1} + seed${i};\n}\n`,
  88. );
  89. }
  90. cg = CodeGraph.initSync(testDir);
  91. await cg.indexAll();
  92. // The per-file bounds are only observable through the diagnostic sidecar.
  93. const sidecar = path.join(testDir, 'explore-diag.jsonl');
  94. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  95. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  96. const run = async (handler: ToolHandler, query: string): Promise<Probe> => {
  97. const result = await handler.execute('codegraph_explore', { query });
  98. const response = result.content?.[0]?.text ?? '';
  99. const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
  100. return {
  101. response,
  102. report: JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport,
  103. bytes: attributeSourceBytes(response),
  104. };
  105. };
  106. try {
  107. const handler = new ToolHandler(cg);
  108. spread = await run(handler, QUERY);
  109. precise = await run(handler, PRECISE_QUERY);
  110. } finally {
  111. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  112. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  113. }
  114. }, 180_000);
  115. afterAll(() => {
  116. if (cg) cg.destroy();
  117. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  118. });
  119. // ── Fixture shape — if these rot, the gate below means nothing ─────────────
  120. describe('fixture shape', () => {
  121. it('sits on the 24K tier, where the reservations saturate the ceiling', () => {
  122. expect(cg.getStats().fileCount).toBeGreaterThanOrEqual(500);
  123. expect(spread.report.budget.maxOutputChars).toBe(24000);
  124. });
  125. it('admits every stage file, so there is something to displace', () => {
  126. const paths = admitted(spread).map((f) => f.path);
  127. expect(paths).toContain(GIANT);
  128. for (const p of STARVED) expect(paths).toContain(p);
  129. expect(paths.length).toBeGreaterThanOrEqual(5);
  130. });
  131. it('renders the giant through the CLUSTER path, over its reservation', () => {
  132. const rec = fileOf(spread, GIANT);
  133. expect(rec.render).toBe('clusters');
  134. // One member bigger than anything it can earn beside its siblings — the
  135. // shape that makes the bounded overshoot fire at all.
  136. const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8');
  137. expect(source.length).toBeGreaterThan((rec.spendable ?? 0) * 2);
  138. // And the guard actually bit — a vacuous pass here would hide a
  139. // regression. Measured against the bounded overshoot a cluster's top
  140. // member may otherwise take (1.5x, CG-30), which is what it refused.
  141. expect(rec.funded).not.toBeNull();
  142. expect(rec.funded!).toBeLessThan(Math.round(rec.spendable! * 1.5));
  143. });
  144. });
  145. // ── The gate ──────────────────────────────────────────────────────────────
  146. describe('displacement refusal', () => {
  147. it('CG-31 GATE: no clustered file emits past what was still free to spend', () => {
  148. for (const probe of [spread, precise]) {
  149. const over = probe.report.files
  150. .filter((f) => f.render === 'clusters' && f.funded !== null)
  151. // +1 for the render loop's own rounding on the windowed cut.
  152. .filter((f) => f.emittedChars > f.funded! + 1)
  153. .map((f) => `${f.path}: ${f.emittedChars} of ${f.funded}`);
  154. expect(over).toEqual([]);
  155. }
  156. });
  157. it('CG-31 GATE: every admitted file below the top one is delivered', () => {
  158. // Pre-fix: 3 of 6 — `ingest.ts` overshot, was itself cut by the final
  159. // ceiling, and took `types.ts` + `sink.ts` down with it.
  160. for (const rec of admitted(spread)) {
  161. expect(rec.skipped, `${rec.path} skipped`).toBeNull();
  162. expect(spread.bytes.get(rec.path) ?? 0, `${rec.path} bytes`).toBeGreaterThan(0);
  163. }
  164. for (const p of STARVED) expect(spread.bytes.get(p) ?? 0).toBeGreaterThan(0);
  165. });
  166. it('the guard is symmetric — it is about ORDER, not rank', () => {
  167. // Nothing here protects rank #1 specifically: the LAST admitted file, the
  168. // only one with no reservation owed below it, is delivered too.
  169. const files = admitted(spread);
  170. const last = files[files.length - 1]!;
  171. expect(last.skipped).toBeNull();
  172. expect(spread.bytes.get(last.path) ?? 0).toBeGreaterThan(0);
  173. // And the last file is never itself cut by the guard — nothing is owed
  174. // below it, so `funded` may not sit under its own reservation.
  175. expect(last.funded!).toBeGreaterThanOrEqual(Math.min(last.allowance!, last.emittedChars));
  176. });
  177. it('a kept promise is not a displacement — no file is cut below its reservation', () => {
  178. for (const probe of [spread, precise]) {
  179. for (const rec of admitted(probe)) {
  180. if (rec.funded === null) continue;
  181. expect(rec.funded, rec.path).toBeGreaterThanOrEqual(
  182. Math.min(rec.allowance!, rec.emittedChars));
  183. }
  184. }
  185. });
  186. it('nothing is lost to the hard ceiling — the epilogue is cut before a section', () => {
  187. // A section thrown away by the final truncation is the same starvation
  188. // arriving after the guard has done its work: the bytes were held back
  189. // for that file and then nobody received them.
  190. for (const probe of [spread, precise]) {
  191. expect(probe.report.files.filter((f) => f.render === 'dropped')).toEqual([]);
  192. }
  193. });
  194. it('keeps the response inside the hard ceiling', () => {
  195. for (const probe of [spread, precise]) {
  196. expect(probe.report.envelope.chars).toBeLessThanOrEqual(probe.report.budget.hardCeiling);
  197. }
  198. });
  199. });
  200. // ── The thing the guard must NOT become ───────────────────────────────────
  201. describe('concentration survives', () => {
  202. it('a precise symbol query still puts the most source in the named file', () => {
  203. const mine = precise.bytes.get(GIANT) ?? 0;
  204. const others = [...precise.bytes.entries()].filter(([p]) => p !== GIANT);
  205. expect(mine).toBeGreaterThan(0);
  206. for (const [p, n] of others) {
  207. expect(mine, `${GIANT} vs ${p}`).toBeGreaterThan(n);
  208. }
  209. // Not a forced even split: the named file takes a clear plurality.
  210. const total = [...precise.bytes.values()].reduce((s, n) => s + n, 0);
  211. expect(mine / total).toBeGreaterThan(1 / precise.bytes.size);
  212. });
  213. it('the named file still outspends what it would get from an even split', () => {
  214. const rec = fileOf(precise, GIANT);
  215. const even = precise.report.budget.maxOutputChars / admitted(precise).length;
  216. expect(rec.emittedChars).toBeGreaterThan(even);
  217. });
  218. });
  219. });