compare-arms.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. #!/usr/bin/env node
  2. // One side-by-side table for the three feedback metrics, across the arms of a
  3. // single A/B output directory. This is the "did it move?" view — the per-run
  4. // blocks parse-run.mjs prints are the "why did it move?" view, and both are
  5. // printed by the harnesses (ab-new-vs-baseline.sh, run-all.sh).
  6. //
  7. // residual context occupancy how much window the arm's retrieval still holds
  8. // explore sufficiency whether a response was ENOUGH (agent's next act)
  9. // allocation efficiency what share of returned bytes the answer used
  10. //
  11. // Usage: compare-arms.mjs <out-dir> <label> [<label> ...]
  12. // e.g. compare-arms.mjs /tmp/ab-new-vs-baseline new baseline
  13. // compare-arms.mjs /tmp/agent-eval headless-with headless-without
  14. //
  15. // Run discovery handles both shapes the harnesses write, per label:
  16. // run-<label>-<i>.jsonl N independent runs (ab-new-vs-baseline, RUNS=N)
  17. // run-<label>.jsonl + .tN ONE session, N turns (run-all.sh multi-turn)
  18. // A `.tN` file is always a resumed SEGMENT of the run it hangs off, never a run
  19. // of its own — mixing those up would report a three-turn session as three runs
  20. // and average away the residual the later turns exist to charge.
  21. import { existsSync, readdirSync } from 'fs';
  22. import { join } from 'path';
  23. import { pathToFileURL } from 'url';
  24. import { parseSession, SUFFICIENCY } from './parse-run.mjs';
  25. /** Segment files of one run, in turn order: run-X.jsonl, run-X.t2.jsonl, … */
  26. function segmentsOf(dir, stem) {
  27. const first = join(dir, `${stem}.jsonl`);
  28. if (!existsSync(first)) return null;
  29. const rest = readdirSync(dir)
  30. .map((f) => [f, new RegExp(`^${stem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.t(\\d+)\\.jsonl$`).exec(f)])
  31. .filter(([, m]) => m)
  32. .sort((a, b) => Number(a[1][1]) - Number(b[1][1]))
  33. .map(([f]) => join(dir, f));
  34. return [first, ...rest];
  35. }
  36. /** Every run of one arm, newest-numbering-first-run order. */
  37. export function discoverRuns(dir, label) {
  38. const session = segmentsOf(dir, `run-${label}`);
  39. if (session) return [{ name: label, files: session }];
  40. const indexed = readdirSync(dir)
  41. .map((f) => new RegExp(`^run-${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-(\\d+)\\.jsonl$`).exec(f))
  42. .filter(Boolean)
  43. .map((m) => Number(m[1]))
  44. .sort((a, b) => a - b);
  45. return indexed.map((i) => ({ name: `${label}-${i}`, files: segmentsOf(dir, `run-${label}-${i}`) }));
  46. }
  47. const median = (xs) => {
  48. if (!xs.length) return null;
  49. const a = [...xs].sort((x, y) => x - y);
  50. const m = a.length >> 1;
  51. return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
  52. };
  53. /** The numbers one arm's runs contribute to the table. */
  54. function measure(run) {
  55. const s = parseSession(run.files);
  56. const o = s.occupancy;
  57. const a = s.allocation;
  58. return {
  59. name: run.name,
  60. ok: s.ok,
  61. raced: s.raced,
  62. turns: s.turns,
  63. dur: s.dur,
  64. tools: s.tools,
  65. reads: s.reads,
  66. grep: s.grep,
  67. bash: s.counts.Bash || 0,
  68. cg: s.cg,
  69. cliCalls: s.cliCalls,
  70. cliContaminated: s.cliContaminated,
  71. ctx: o.ctxFinal,
  72. occCg: o.residual.codegraph,
  73. occFile: o.residualFileAccess,
  74. // The arm's OWN retrieval residual: codegraph in a with-arm, Read/Grep/Bash
  75. // in a without-arm. Comparing these two is the apples-to-apples pair.
  76. occSelf: o.residual.codegraph + o.residualFileAccess,
  77. occShare: o.ctxFinal > 0 ? ((o.residual.codegraph + o.residualFileAccess) / o.ctxFinal) * 100 : 0,
  78. suffAnswered: s.sufficiency.answered,
  79. suffCounts: s.sufficiency.counts,
  80. suffErrors: s.sufficiency.errors,
  81. // Allocation is byte-weighted, so a run with no explore contributes nothing
  82. // rather than a zero — a zero would drag the pooled number toward "wasteful"
  83. // for a run that never spent a byte.
  84. allocUsed: a.envelope ? a.used : null,
  85. allocEnvelope: a.envelope || null,
  86. allocCalls: a.calls.length,
  87. };
  88. }
  89. /** median [min–max] over runs; the range is the point — never quote one run. */
  90. function span(runs, pick, fmt = (x) => String(Math.round(x))) {
  91. const xs = runs.map(pick).filter((x) => x !== null && x !== undefined && Number.isFinite(x));
  92. if (!xs.length) return '—';
  93. const m = fmt(median(xs));
  94. if (xs.length === 1) return m;
  95. const lo = fmt(Math.min(...xs)); const hi = fmt(Math.max(...xs));
  96. return lo === hi ? m : `${m} [${lo}–${hi}]`;
  97. }
  98. const int = (x) => Math.round(x).toLocaleString('en-US');
  99. const pct1 = (x) => `${x.toFixed(1)}%`;
  100. export function formatComparison(arms) {
  101. const W = 36; const C = 24;
  102. const out = [];
  103. // The leading space is a separator, not padding: a `median [min–max]` cell can
  104. // fill its column, and two of those with only padStart between them run
  105. // together into one unreadable number.
  106. const row = (label, cells) => out.push(' ' + label.padEnd(W) + cells.map((c) => ' ' + String(c).padStart(C - 1)).join(''));
  107. const rule = (title) => out.push(` ${title}`);
  108. row('', arms.map((a) => a.label));
  109. row('runs', arms.map((a) => a.runs.length));
  110. const anyFailed = arms.some((a) => a.runs.some((r) => !r.ok));
  111. if (anyFailed) row(' of which non-success', arms.map((a) => a.runs.filter((r) => !r.ok).length));
  112. if (arms.some((a) => a.runs.some((r) => r.raced))) {
  113. row(' MCP cold-start race', arms.map((a) => a.runs.filter((r) => r.raced).length));
  114. }
  115. out.push('');
  116. rule('behavior');
  117. row(' duration (s)', arms.map((a) => span(a.runs, (r) => r.dur)));
  118. row(' tool calls', arms.map((a) => span(a.runs, (r) => r.tools)));
  119. row(' Read', arms.map((a) => span(a.runs, (r) => r.reads)));
  120. row(' Grep/Glob', arms.map((a) => span(a.runs, (r) => r.grep)));
  121. row(' Bash', arms.map((a) => span(a.runs, (r) => r.bash)));
  122. row(' codegraph calls', arms.map((a) => span(a.runs, (r) => r.cg)));
  123. out.push('');
  124. rule('residual context occupancy (CG-7) — tokens still resident at end of run');
  125. row(' final context (tok)', arms.map((a) => span(a.runs, (r) => r.ctx, int)));
  126. row(' codegraph residual (tok)', arms.map((a) => span(a.runs, (r) => r.occCg, int)));
  127. row(' file-access residual (tok)', arms.map((a) => span(a.runs, (r) => r.occFile, int)));
  128. row(' → retrieval residual (tok)', arms.map((a) => span(a.runs, (r) => r.occSelf, int)));
  129. row(' → share of final context', arms.map((a) => span(a.runs, (r) => r.occShare, pct1)));
  130. out.push('');
  131. rule('explore sufficiency (CG-8) — pooled over every answered explore call');
  132. row(' answered explore calls', arms.map((a) => a.runs.reduce((s, r) => s + r.suffAnswered, 0)));
  133. for (const [key, label] of SUFFICIENCY) {
  134. row(` ${label}`, arms.map((a) => {
  135. const n = a.runs.reduce((s, r) => s + r.suffCounts[key], 0);
  136. const tot = a.runs.reduce((s, r) => s + r.suffAnswered, 0);
  137. return tot ? `${n} ${((n / tot) * 100).toFixed(0)}%` : '—';
  138. }));
  139. }
  140. if (arms.some((a) => a.runs.some((r) => r.suffErrors))) {
  141. row(' errored/unanswered (not bucketed)', arms.map((a) => a.runs.reduce((s, r) => s + r.suffErrors, 0)));
  142. }
  143. out.push('');
  144. rule('explore allocation efficiency (CG-9) — share of returned bytes the answer cited');
  145. row(' explore calls with source', arms.map((a) => a.runs.reduce((s, r) => s + r.allocCalls, 0)));
  146. row(' pooled efficiency', arms.map((a) => {
  147. const env = a.runs.reduce((s, r) => s + (r.allocEnvelope || 0), 0);
  148. const used = a.runs.reduce((s, r) => s + (r.allocUsed || 0), 0);
  149. return env ? pct1((used / env) * 100) : '—';
  150. }));
  151. row(' per-run efficiency', arms.map((a) =>
  152. span(a.runs, (r) => (r.allocEnvelope ? (r.allocUsed / r.allocEnvelope) * 100 : null), pct1)));
  153. row(' envelope (chars)', arms.map((a) => int(a.runs.reduce((s, r) => s + (r.allocEnvelope || 0), 0))));
  154. out.push('');
  155. rule('contamination — the CLI must never be how codegraph is reached');
  156. row(' CLI calls that RETURNED output', arms.map((a) => a.runs.reduce((s, r) => s + r.cliContaminated, 0)));
  157. row(' CLI attempts blocked', arms.map((a) => a.runs.reduce((s, r) => s + r.cliCalls, 0)));
  158. const contaminated = arms.filter((a) => a.runs.some((r) => r.cliContaminated));
  159. if (contaminated.length) {
  160. out.push(` !! ${contaminated.map((a) => a.label).join(', ')} reached codegraph through Bash — those runs are CONTAMINATED`);
  161. out.push(' (a without-arm was not without codegraph; a with-arm has bytes attributed to Bash, not codegraph)');
  162. }
  163. out.push('');
  164. out.push(' how to read this');
  165. out.push(' occupancy compare each arm\'s RETRIEVAL residual (codegraph in a with-arm,');
  166. out.push(' file-access in a without-arm). Shares are Claude Code on a 200k');
  167. out.push(' window and do NOT transfer to another host; the ratio does.');
  168. out.push(' sufficiency pooled across runs because it is per-CALL. "explore again" is');
  169. out.push(' ambiguous by construction; "Read a file we returned" is an');
  170. out.push(' allocation miss, the two recall rows are recall misses.');
  171. out.push(' allocation RELATIVE, not absolute — attribution is by citation, and an agent');
  172. out.push(' can use a file without naming it. Compare builds on the SAME');
  173. out.push(' question; never quote it as "codegraph wastes N% of what it returns."');
  174. out.push(' all three small-n. Runs make 1–5 explore calls, so read the range, not the');
  175. out.push(' median of one run. RUNS>=2, and the 7-repo campaign for a verdict.');
  176. return out.join('\n');
  177. }
  178. const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
  179. if (isMain) {
  180. const [dir, ...labels] = process.argv.slice(2);
  181. if (!dir || !labels.length) {
  182. console.error('usage: compare-arms.mjs <out-dir> <label> [<label> ...]');
  183. process.exit(1);
  184. }
  185. const arms = labels.map((label) => ({ label, runs: discoverRuns(dir, label).map(measure) }));
  186. const empty = arms.filter((a) => !a.runs.length);
  187. if (empty.length === arms.length) {
  188. console.error(`no run logs for ${labels.join('/')} in ${dir}`);
  189. process.exit(1);
  190. }
  191. for (const a of empty) console.error(` WARN: no run logs for arm '${a.label}' in ${dir}`);
  192. console.log(`\n====== ARM COMPARISON — ${dir} ======`);
  193. console.log(formatComparison(arms.filter((a) => a.runs.length)));
  194. }