1
0

probe-allocation.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. #!/usr/bin/env node
  2. /**
  3. * Deterministic per-file budget-share probe for `codegraph_explore` (CG-6).
  4. *
  5. * `probe-explore.mjs` prints what explore returned. This prints how the response
  6. * was DIVIDED — which files won the byte envelope and in what proportion — and
  7. * checks that division against a declared expectation. It is the regression gate
  8. * for GitHub issue #1500 / epic CG-1: an architecture question that doesn't name
  9. * the exact use-case must concentrate the budget on the code that answers it, not
  10. * on a generated CRUD layer (or an eval script) that merely name-collides.
  11. *
  12. * The numbers come from the CG-4 diagnostic (`CODEGRAPH_EXPLORE_DEBUG`), read back
  13. * from a JSONL sidecar, so the probe measures the shipping allocator rather than
  14. * re-deriving shares from the markdown.
  15. *
  16. * Fixtures are declared in `allocation-fixtures.json`. A `kind: "fixture"` entry is
  17. * hermetic — the fixture tree is copied to a fresh temp dir and indexed per run, so
  18. * two runs on one build give identical numbers. A `kind: "self"` entry reads this
  19. * repo's live index and therefore moves as the repo changes; its assertions are
  20. * relative for that reason.
  21. *
  22. * Usage (needs a current `npm run build`):
  23. * node scripts/agent-eval/probe-allocation.mjs # every fixture
  24. * node scripts/agent-eval/probe-allocation.mjs payroll-go # one fixture
  25. * node scripts/agent-eval/probe-allocation.mjs --json # machine-readable
  26. * node scripts/agent-eval/probe-allocation.mjs --keep # keep the temp index
  27. *
  28. * Exit code: 0 if every assertion holds, 1 if any fails, 2 on a setup error.
  29. * BOTH FIXTURES ARE EXPECTED TO FAIL until CG-10/CG-12 land — that failure is the
  30. * documented bug. Use --expect-fail to invert the exit code while it is the state
  31. * of the world (0 = still broken, 1 = fixed, go flip the gate).
  32. */
  33. import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
  34. import { tmpdir } from 'node:os';
  35. import { dirname, join, resolve } from 'node:path';
  36. import { fileURLToPath, pathToFileURL } from 'node:url';
  37. const HERE = dirname(fileURLToPath(import.meta.url));
  38. const REPO_ROOT = resolve(HERE, '../..');
  39. const SPEC_PATH = join(HERE, 'allocation-fixtures.json');
  40. const argv = process.argv.slice(2);
  41. const flags = new Set(argv.filter((a) => a.startsWith('--')));
  42. const wanted = argv.filter((a) => !a.startsWith('--'));
  43. const asJson = flags.has('--json');
  44. const keepTemp = flags.has('--keep');
  45. const expectFail = flags.has('--expect-fail');
  46. const say = (line = '') => { if (!asJson) console.log(line); };
  47. const pct = (f) => `${(f * 100).toFixed(1)}%`;
  48. const num = (n) => Math.round(n).toLocaleString('en-US');
  49. /** Load the built dist — the probe measures the shipping allocator, not src. */
  50. async function loadDist() {
  51. const distIndex = join(REPO_ROOT, 'dist/index.js');
  52. if (!existsSync(distIndex)) {
  53. console.error('dist/ not built — run `npm run build` first.');
  54. process.exit(2);
  55. }
  56. const idx = await import(pathToFileURL(distIndex).href);
  57. const tools = await import(pathToFileURL(join(REPO_ROOT, 'dist/mcp/tools.js')).href);
  58. // esModuleInterop: dynamic import of CJS yields { default: module.exports, ...named }
  59. const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
  60. const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
  61. if (typeof CodeGraph?.openSync !== 'function' || typeof ToolHandler !== 'function') {
  62. console.error('could not resolve CodeGraph/ToolHandler from dist/');
  63. process.exit(2);
  64. }
  65. return { CodeGraph, ToolHandler };
  66. }
  67. /** `internal/gen/**` → /^internal\/gen\/.*$/ . Supports `**`, `*` and literals. */
  68. function globToRegExp(glob) {
  69. // Park `**` on a sentinel no path can contain, so the `*` pass cannot eat it.
  70. // Written as an escape, not a literal byte — a raw NUL makes git treat this
  71. // whole script as binary, which costs every future diff of it.
  72. const DOUBLE_STAR = '\u0000';
  73. const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
  74. const body = escaped
  75. .replace(/\*\*/g, DOUBLE_STAR)
  76. .replace(/\*/g, '[^/]*')
  77. .replaceAll(DOUBLE_STAR, '.*');
  78. return new RegExp(`^${body}$`);
  79. }
  80. const groupOf = (path, groups) => {
  81. for (const [name, globs] of Object.entries(groups)) {
  82. if (globs.some((g) => globToRegExp(g).test(path))) return name;
  83. }
  84. return 'other';
  85. };
  86. /**
  87. * Run one explore call with the diagnostic pointed at a sidecar, and return the
  88. * report plus the response text.
  89. */
  90. async function runExplore({ CodeGraph, ToolHandler }, repoPath, query, sidecar) {
  91. const cg = CodeGraph.openSync(repoPath);
  92. const prior = process.env.CODEGRAPH_EXPLORE_DEBUG;
  93. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  94. try {
  95. const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
  96. const text = res.content?.[0]?.text ?? '';
  97. const lines = readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
  98. if (lines.length === 0) throw new Error('diagnostic produced no report');
  99. return { report: JSON.parse(lines[lines.length - 1]), text };
  100. } finally {
  101. if (prior === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  102. else process.env.CODEGRAPH_EXPLORE_DEBUG = prior;
  103. try { cg.close?.(); } catch {}
  104. }
  105. }
  106. /** Copy a fixture tree to a fresh temp dir and index it — hermetic per run. */
  107. async function materializeFixture({ CodeGraph }, fixturePath) {
  108. const src = resolve(REPO_ROOT, fixturePath);
  109. if (!existsSync(src)) throw new Error(`fixture tree not found: ${src}`);
  110. const dir = mkdtempSync(join(tmpdir(), 'cg-alloc-'));
  111. cpSync(src, dir, { recursive: true });
  112. // A stray index inside the checked-in tree would be copied in and reused.
  113. rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
  114. const cg = CodeGraph.initSync(dir);
  115. await cg.indexAll();
  116. cg.close?.();
  117. return dir;
  118. }
  119. /** Evaluate one fixture's assertions against its report. Returns check rows. */
  120. function evaluate(fixture, report, text) {
  121. const { groups, assert: want } = fixture;
  122. const delivered = new Map();
  123. const allocated = new Map();
  124. for (const f of report.files) {
  125. const g = groupOf(f.path, groups);
  126. delivered.set(g, (delivered.get(g) ?? 0) + f.share);
  127. allocated.set(g, (allocated.get(g) ?? 0) + f.allocatedShare);
  128. }
  129. const share = (g) => delivered.get(g) ?? 0;
  130. const top = report.files
  131. .filter((f) => f.finalChars > 0)
  132. .sort((a, b) => b.finalChars - a.finalChars)[0];
  133. const checks = [];
  134. const add = (name, pass, detail) => checks.push({ name, pass, detail });
  135. if (want.answerShareAtLeast !== undefined) {
  136. add(
  137. `answer group takes >= ${pct(want.answerShareAtLeast)} of the envelope`,
  138. share('answer') >= want.answerShareAtLeast,
  139. `answer ${pct(share('answer'))} delivered (${pct(allocated.get('answer') ?? 0)} allocated)`,
  140. );
  141. }
  142. // Same question against the SOURCE the response delivered rather than the
  143. // whole envelope (CG-26). The envelope-denominated gate above moves whenever
  144. // the response's prose does — the epilogue surviving instead of being
  145. // discarded costs it a point, and every additional admitted file that gets
  146. // paid dilutes it further — so it cannot tell "the answer was starved" from
  147. // "everything else was also delivered". Allocation is about source bytes;
  148. // measure it in source bytes.
  149. if (want.answerShareOfSourceAtLeast !== undefined) {
  150. const sourceBy = new Map();
  151. let totalSource = 0;
  152. for (const f of report.files) {
  153. const g = groupOf(f.path, groups);
  154. sourceBy.set(g, (sourceBy.get(g) ?? 0) + f.finalChars);
  155. totalSource += f.finalChars;
  156. }
  157. const answerSource = totalSource > 0 ? (sourceBy.get('answer') ?? 0) / totalSource : 0;
  158. add(
  159. `answer group takes >= ${pct(want.answerShareOfSourceAtLeast)} of DELIVERED SOURCE`,
  160. answerSource >= want.answerShareOfSourceAtLeast,
  161. `answer ${num(sourceBy.get('answer') ?? 0)} of ${num(totalSource)} source chars (${pct(answerSource)})`,
  162. );
  163. }
  164. if (want.incidentalShareAtMost !== undefined) {
  165. add(
  166. `incidental group takes <= ${pct(want.incidentalShareAtMost)} of the envelope`,
  167. share('incidental') <= want.incidentalShareAtMost,
  168. `incidental ${pct(share('incidental'))} delivered (${pct(allocated.get('incidental') ?? 0)} allocated)`,
  169. );
  170. }
  171. if (want.topFileGroup) {
  172. const actual = top ? groupOf(top.path, groups) : '(nothing delivered)';
  173. add(
  174. `largest delivered file is in "${want.topFileGroup}"`,
  175. actual === want.topFileGroup,
  176. top ? `${top.path} (${pct(top.share)}, group "${actual}")` : 'no file delivered any source',
  177. );
  178. }
  179. for (const path of want.mustDeliverBytes ?? []) {
  180. const rec = report.files.find((f) => f.path === path);
  181. add(
  182. `${path} delivers source`,
  183. !!rec && rec.finalChars > 0,
  184. rec
  185. ? `${num(rec.finalChars)} delivered of ${num(rec.emittedChars)} allocated` +
  186. (rec.finalChars === 0 && rec.emittedChars > 0 ? ' — hard ceiling dropped the whole section' : '') +
  187. (rec.emittedChars === 0 ? ` — never rendered (${rec.skipped ?? 'not reached'}, rank #${rec.rank})` : '')
  188. : 'not among the ranked candidates',
  189. );
  190. }
  191. // Reservation-vs-delivered, per file (CG-36). The share gates above ask which
  192. // files won the envelope; this asks whether a file that WON its share then
  193. // actually spent it. A file can rank #1, be reserved the largest slice, and
  194. // still deliver a quarter of it because the cluster carrying the answer was
  195. // dropped whole instead of shrunk — and the share gates read that as a pass,
  196. // since the unspent bytes carry forward and the envelope stays full.
  197. for (const [path, floor] of Object.entries(want.spendShareAtLeast ?? {})) {
  198. const rec = report.files.find((f) => f.path === path);
  199. const spent = rec && rec.allowance ? rec.finalChars / rec.allowance : 0;
  200. add(
  201. `${path} spends >= ${pct(floor)} of its reservation`,
  202. !!rec && rec.allowance > 0 && spent >= floor,
  203. rec
  204. ? `${num(rec.finalChars)} delivered of a ${num(rec.allowance ?? 0)} reservation (${pct(spent)})`
  205. : 'not among the ranked candidates',
  206. );
  207. }
  208. for (const needle of want.mustContain ?? []) {
  209. add(`response contains "${needle}"`, text.includes(needle), text.includes(needle) ? 'present' : 'absent');
  210. }
  211. return { checks, delivered, allocated, top };
  212. }
  213. function printReport(fixture, report, evaluated) {
  214. const { checks, delivered, allocated } = evaluated;
  215. const env = report.envelope;
  216. say('');
  217. say(`── ${fixture.id} — ${fixture.title}`);
  218. say(` query "${report.query}"`);
  219. say(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
  220. say(
  221. ` envelope ${num(env.chars)} delivered · ${num(env.allocatedChars)} allocated` +
  222. ` of ${num(report.budget.maxOutputChars)} budget (hard ceiling ${num(report.budget.hardCeiling)})` +
  223. `${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`,
  224. );
  225. say('');
  226. say(' group alloc% deliv%');
  227. for (const g of ['answer', 'incidental', 'other']) {
  228. if (!delivered.has(g) && !allocated.has(g)) continue;
  229. say(` ${g.padEnd(12)} ${pct(allocated.get(g) ?? 0).padStart(6)} ${pct(delivered.get(g) ?? 0).padStart(6)}`);
  230. }
  231. say('');
  232. say(' # alloc% deliv% bytes score graph hits gen render file');
  233. for (const f of report.files.filter((f) => f.emittedChars > 0 || f.finalChars > 0)) {
  234. say(
  235. ' ' + String(f.rank).padStart(2) + ' ' +
  236. pct(f.allocatedShare).padStart(6) + ' ' +
  237. pct(f.share).padStart(6) + ' ' +
  238. num(f.emittedChars).padStart(7) + ' ' +
  239. String(f.score).padStart(5) + ' ' +
  240. f.graphScore.toFixed(5).padStart(7) + ' ' +
  241. String(f.termHits).padStart(4) + ' ' +
  242. (f.generated ? ' ✓ ' : ' ') + ' ' +
  243. ((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' +
  244. f.path,
  245. );
  246. }
  247. say('');
  248. for (const c of checks) say(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
  249. }
  250. async function main() {
  251. const spec = JSON.parse(readFileSync(SPEC_PATH, 'utf-8'));
  252. const fixtures = spec.fixtures.filter((f) => wanted.length === 0 || wanted.includes(f.id));
  253. if (fixtures.length === 0) {
  254. console.error(`no fixture matched ${JSON.stringify(wanted)}; known: ${spec.fixtures.map((f) => f.id).join(', ')}`);
  255. process.exit(2);
  256. }
  257. const dist = await loadDist();
  258. const sidecarDir = mkdtempSync(join(tmpdir(), 'cg-alloc-diag-'));
  259. const results = [];
  260. const temps = [];
  261. for (const fixture of fixtures) {
  262. let repoPath;
  263. if (fixture.kind === 'fixture') {
  264. repoPath = await materializeFixture(dist, fixture.path);
  265. temps.push(repoPath);
  266. } else {
  267. repoPath = resolve(REPO_ROOT, fixture.path);
  268. if (!existsSync(join(repoPath, '.codegraph'))) {
  269. console.error(`${fixture.id}: ${repoPath} has no .codegraph index — run \`codegraph init\` there first.`);
  270. process.exit(2);
  271. }
  272. }
  273. const sidecar = join(sidecarDir, `${fixture.id}.jsonl`);
  274. mkdirSync(dirname(sidecar), { recursive: true });
  275. const { report, text } = await runExplore(dist, repoPath, fixture.query, sidecar);
  276. const evaluated = evaluate(fixture, report, text);
  277. printReport(fixture, report, evaluated);
  278. results.push({
  279. id: fixture.id,
  280. kind: fixture.kind,
  281. query: fixture.query,
  282. passed: evaluated.checks.every((c) => c.pass),
  283. checks: evaluated.checks,
  284. shares: {
  285. delivered: Object.fromEntries(evaluated.delivered),
  286. allocated: Object.fromEntries(evaluated.allocated),
  287. },
  288. envelope: report.envelope,
  289. files: report.files.filter((f) => f.emittedChars > 0 || f.finalChars > 0),
  290. });
  291. }
  292. if (!keepTemp) {
  293. for (const dir of temps) rmSync(dir, { recursive: true, force: true });
  294. rmSync(sidecarDir, { recursive: true, force: true });
  295. } else {
  296. say('');
  297. say(` kept: ${[...temps, sidecarDir].join(' ')}`);
  298. }
  299. const allPassed = results.every((r) => r.passed);
  300. if (asJson) {
  301. console.log(JSON.stringify({ passed: allPassed, fixtures: results }, null, 2));
  302. } else {
  303. say('');
  304. for (const r of results) say(`${r.passed ? 'PASS' : 'FAIL'} ${r.id}`);
  305. if (!allPassed) {
  306. say('');
  307. say('Failures here are the DOCUMENTED #1500 bug — the budget goes to files that merely');
  308. say('name-collide with the query. They become the pass gate once CG-10/CG-12 land.');
  309. }
  310. }
  311. process.exit(expectFail ? (allPassed ? 1 : 0) : (allPassed ? 0 : 1));
  312. }
  313. main().catch((err) => {
  314. console.error(err?.stack ?? String(err));
  315. process.exit(2);
  316. });