probe-file-spend.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #!/usr/bin/env node
  2. /**
  3. * Per-file reservation-vs-delivered sweep for `codegraph_explore` (CG-36).
  4. *
  5. * `probe-suite-envelope.mjs` answers "how much source did the response deliver";
  6. * this answers the question one level down — "did the bytes go to the files that
  7. * earned them". The CG-36 defect was invisible to the envelope probe because the
  8. * envelope stayed full: a rank-#3 file spent 24% of its reservation, the slack
  9. * carried forward exactly as designed, and a far weaker file spent 3.5x its own.
  10. * The response looked healthy; the ANSWER-bearing file had been starved.
  11. *
  12. * So the flag here is a PAIR, not a per-file threshold: a file that leaves a
  13. * large share of its reservation unspent WHILE a materially lower-scoring file
  14. * spends well over its own. Either alone is legitimate — a small file simply has
  15. * less to say, and carry-forward is the mechanism that hands its slack down.
  16. *
  17. * Numbers come from the CG-4 diagnostic (`CODEGRAPH_EXPLORE_DEBUG`), so this
  18. * measures the shipping allocator rather than re-deriving shares from markdown.
  19. *
  20. * Usage (needs a current `npm run build`, and full-REBUILT indexes — CG-33):
  21. * node scripts/agent-eval/probe-file-spend.mjs
  22. * node scripts/agent-eval/probe-file-spend.mjs --json > /tmp/new.json
  23. * node scripts/agent-eval/probe-file-spend.mjs --baseline /tmp/base.json
  24. * node scripts/agent-eval/probe-file-spend.mjs django --all # every file, not just flags
  25. * CORPUS=/tmp/codegraph-corpus node scripts/agent-eval/probe-file-spend.mjs
  26. *
  27. * Exit code is 1 when any repo carries a starvation flag, so this can gate.
  28. */
  29. import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
  30. import { tmpdir } from 'node:os';
  31. import { join, resolve } from 'node:path';
  32. import { pathToFileURL } from 'node:url';
  33. const CORPUS = process.env.CORPUS ?? '/tmp/codegraph-corpus';
  34. /** Same six repos and queries the CG-30/CG-31/CG-26 envelope tables use. */
  35. const SUITE = [
  36. { id: 'django', q: 'How does a QuerySet turn into SQL and fetch rows from the database?' },
  37. { id: 'excalidraw', q: 'How does updating an element re-render the canvas on screen?' },
  38. { id: 'okhttp', q: 'How does a call go through the interceptor chain to the network?' },
  39. { id: 'tokio', q: 'How does a spawned task get scheduled and run by a worker?' },
  40. { id: 'gin', q: 'How does a registered route handler get invoked for an incoming HTTP request?' },
  41. { id: 'alamofire', q: 'How does a request get built and sent through the session?' },
  42. ];
  43. /**
  44. * Starvation thresholds. A flag needs BOTH sides — the starved file and the
  45. * overspending one it lost the bytes to.
  46. *
  47. * `MIN_RESERVED` keeps the noise out: under it, "80% unspent" is a few hundred
  48. * chars and means nothing. `SCORE_RATIO` is what makes the pair meaningful —
  49. * a higher-scoring file underspending while a *comparable* one overspends is
  50. * ordinary; the defect is a materially weaker file taking the bytes.
  51. */
  52. const STARVED_SHARE = 0.5; // spent < half its reservation
  53. const OVERSPEND_RATIO = 1.5; // spent > 1.5x its own reservation
  54. const SCORE_RATIO = 2; // ...while scoring less than half the starved file
  55. const MIN_RESERVED = 2000; // ignore files whose reservation is too small to matter
  56. const argv = process.argv.slice(2);
  57. const asJson = argv.includes('--json');
  58. const showAll = argv.includes('--all');
  59. const baselineAt = argv.includes('--baseline') ? argv[argv.indexOf('--baseline') + 1] : null;
  60. const only = argv.filter((a) => !a.startsWith('--') && a !== baselineAt);
  61. const say = (s = '') => { if (!asJson) console.log(s); };
  62. const num = (n) => Math.round(n).toLocaleString('en-US');
  63. const pct = (f) => `${(f * 100).toFixed(1)}%`;
  64. const load = (rel) => import(pathToFileURL(resolve(rel)).href);
  65. const idx = await load('dist/index.js');
  66. const toolsMod = await load('dist/mcp/tools.js');
  67. const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
  68. const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
  69. if (typeof CodeGraph?.openSync !== 'function' || typeof ToolHandler !== 'function') {
  70. console.error('could not resolve CodeGraph/ToolHandler from dist/ — run `npm run build`');
  71. process.exit(2);
  72. }
  73. /**
  74. * Pair up the starved with the overspenders they lost bytes to. Only files the
  75. * render loop actually reached (a reservation and a render mode) take part —
  76. * a cliffed or max-files file never had bytes to spend.
  77. */
  78. function findStarvation(files) {
  79. const spenders = files.filter(
  80. (f) => f.allowance !== null && f.allowance > 0 && f.render && f.render !== 'backref',
  81. );
  82. const flags = [];
  83. for (const s of spenders) {
  84. if (s.allowance < MIN_RESERVED) continue;
  85. if (s.finalChars >= s.allowance * STARVED_SHARE) continue;
  86. for (const o of spenders) {
  87. if (o.path === s.path) continue;
  88. if (o.finalChars <= o.allowance * OVERSPEND_RATIO) continue;
  89. if (o.score * SCORE_RATIO > s.score) continue;
  90. flags.push({
  91. starved: s.path,
  92. starvedScore: s.score,
  93. starvedReserved: s.allowance,
  94. starvedSpent: s.finalChars,
  95. overspent: o.path,
  96. overspentScore: o.score,
  97. overspentReserved: o.allowance,
  98. overspentSpent: o.finalChars,
  99. });
  100. }
  101. }
  102. return flags;
  103. }
  104. const tmp = mkdtempSync(join(tmpdir(), 'cg-spend-'));
  105. const results = [];
  106. try {
  107. for (const { id, q } of SUITE) {
  108. if (only.length > 0 && !only.includes(id)) continue;
  109. const repo = join(CORPUS, id);
  110. if (!existsSync(join(repo, '.codegraph', 'codegraph.db'))) {
  111. say(`${id}: no index at ${repo} — skipped`);
  112. continue;
  113. }
  114. const sidecar = join(tmp, `${id}.jsonl`);
  115. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  116. const cg = CodeGraph.openSync(repo);
  117. const h = new ToolHandler(cg);
  118. await h.execute('codegraph_explore', { query: q });
  119. try { cg.close?.(); } catch { /* best effort */ }
  120. const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop());
  121. const files = report.files.map((f) => ({
  122. path: f.path,
  123. rank: f.rank,
  124. score: f.score,
  125. allowance: f.allowance,
  126. spendable: f.spendable,
  127. finalChars: f.finalChars,
  128. render: f.render,
  129. skipped: f.skipped,
  130. spent: f.allowance ? f.finalChars / f.allowance : null,
  131. }));
  132. results.push({
  133. repo: id,
  134. sourceChars: report.envelope.sourceChars,
  135. files,
  136. flags: findStarvation(files),
  137. });
  138. }
  139. } finally {
  140. rmSync(tmp, { recursive: true, force: true });
  141. }
  142. if (asJson) {
  143. console.log(JSON.stringify(results, null, 2));
  144. } else {
  145. const base = baselineAt ? JSON.parse(readFileSync(baselineAt, 'utf8')) : null;
  146. const byRepo = new Map((base ?? []).map((r) => [r.repo, r]));
  147. for (const r of results) {
  148. const b = byRepo.get(r.repo);
  149. say(`\n${r.repo} — ${num(r.sourceChars)} source chars`
  150. + (b ? ` (baseline ${num(b.sourceChars)})` : ''));
  151. say(' # score reserved spent spent% render file');
  152. say('-'.repeat(96));
  153. const flagged = new Set(r.flags.flatMap((f) => [f.starved, f.overspent]));
  154. for (const f of r.files) {
  155. if (f.allowance === null || f.allowance === 0) continue;
  156. if (!showAll && !flagged.has(f.path) && f.spent > STARVED_SHARE && f.spent < OVERSPEND_RATIO) continue;
  157. const mark = flagged.has(f.path) ? '*' : ' ';
  158. say(
  159. `${String(f.rank).padStart(2)}${mark} ${String(Math.round(f.score)).padStart(6)} `
  160. + `${num(f.allowance).padStart(9)} ${num(f.finalChars).padStart(7)} `
  161. + `${pct(f.spent).padStart(7)} ${(f.render ?? f.skipped ?? '—').padEnd(13)} ${f.path}`,
  162. );
  163. }
  164. for (const f of r.flags) {
  165. say(` FLAG: ${f.starved} (score ${Math.round(f.starvedScore)}) spent `
  166. + `${num(f.starvedSpent)}/${num(f.starvedReserved)} while ${f.overspent} `
  167. + `(score ${Math.round(f.overspentScore)}) spent ${num(f.overspentSpent)}/${num(f.overspentReserved)}`);
  168. }
  169. }
  170. const total = results.reduce((n, r) => n + r.flags.length, 0);
  171. say('');
  172. say(total === 0
  173. ? 'No file leaves a large share of its reservation unspent while a weaker file overspends.'
  174. : `STARVATION: ${total} flag(s) across `
  175. + `${results.filter((r) => r.flags.length > 0).map((r) => r.repo).join(', ')}.`);
  176. if (base) {
  177. const worse = results.filter((r) => {
  178. const b = byRepo.get(r.repo);
  179. return b && (r.flags.length > b.flags.length || r.sourceChars < b.sourceChars);
  180. });
  181. say(worse.length === 0
  182. ? 'No repo flags more or delivers less than the baseline.'
  183. : `REGRESSION vs baseline: ${worse.map((r) => r.repo).join(', ')}.`);
  184. }
  185. if (total > 0) process.exitCode = 1;
  186. }