@tangle-network/agent-bench 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +15 -0
  2. package/package.json +35 -0
  3. package/src/adapters.ts +60 -0
  4. package/src/aec-gate.mts +217 -0
  5. package/src/atom-humaneval.mts +197 -0
  6. package/src/atom-mcp-e2e.mts +223 -0
  7. package/src/benchmarks/_harness.ts +206 -0
  8. package/src/benchmarks/aec-bench.test.mts +53 -0
  9. package/src/benchmarks/aec-bench.ts +319 -0
  10. package/src/benchmarks/appworld.test.mts +45 -0
  11. package/src/benchmarks/appworld.ts +426 -0
  12. package/src/benchmarks/cad-design.ts +429 -0
  13. package/src/benchmarks/cadbench.ts +135 -0
  14. package/src/benchmarks/cadgenbench.ts +121 -0
  15. package/src/benchmarks/commit0.test.mts +71 -0
  16. package/src/benchmarks/commit0.ts +266 -0
  17. package/src/benchmarks/enterpriseops-gym.test.mts +77 -0
  18. package/src/benchmarks/enterpriseops-gym.ts +343 -0
  19. package/src/benchmarks/finsearchcomp.ts +371 -0
  20. package/src/benchmarks/frames.ts +520 -0
  21. package/src/benchmarks/hotpotqa.ts +320 -0
  22. package/src/benchmarks/humaneval.ts +251 -0
  23. package/src/benchmarks/mind2web.ts +311 -0
  24. package/src/benchmarks/programbench.test.mts +60 -0
  25. package/src/benchmarks/programbench.ts +210 -0
  26. package/src/benchmarks/simpleqa.ts +390 -0
  27. package/src/benchmarks/swe-bench.ts +152 -0
  28. package/src/benchmarks/terminal-bench.ts +182 -0
  29. package/src/benchmarks/trata-hedge.ts +496 -0
  30. package/src/benchmarks/types.ts +58 -0
  31. package/src/browser/adapters/bad-design-audit.ts +153 -0
  32. package/src/browser/adapters/bad-design-audit.verify.ts +85 -0
  33. package/src/browser/adapters/bad.ts +165 -0
  34. package/src/browser/agent-adapter.ts +145 -0
  35. package/src/browser/process-adapter.ts +146 -0
  36. package/src/browser/run-to-spans.ts +105 -0
  37. package/src/browser/run-to-spans.verify.ts +89 -0
  38. package/src/browser/ui-reviewer.ts +200 -0
  39. package/src/browser/ui-reviewer.verify.ts +94 -0
  40. package/src/browser/verify.ts +96 -0
  41. package/src/clbench-codebase-gate.mts +314 -0
  42. package/src/clbench-context-gate.mts +305 -0
  43. package/src/cloud-loop.mts +138 -0
  44. package/src/coding-skills/minimal-diff.md +9 -0
  45. package/src/coding-skills/read-before-edit.md +9 -0
  46. package/src/coding-skills/reproduce-first.md +10 -0
  47. package/src/coding-skills/run-tests-after-edit.md +9 -0
  48. package/src/coding-skills/trace-the-failure.md +9 -0
  49. package/src/commit0-env-run.mts +59 -0
  50. package/src/commit0-env.ts +173 -0
  51. package/src/commit0-gate.mts +529 -0
  52. package/src/commit0-prereqs.sh +48 -0
  53. package/src/corpus-replay.mts +300 -0
  54. package/src/corpus-report.mts +504 -0
  55. package/src/corpus.test.mts +273 -0
  56. package/src/corpus.ts +325 -0
  57. package/src/decoder-live.mts +133 -0
  58. package/src/directives.ts +84 -0
  59. package/src/diverse-gate.mjs +112 -0
  60. package/src/egress-probe.mts +26 -0
  61. package/src/eops-skills/address-every-subtask.md +5 -0
  62. package/src/eops-skills/exact-tools-and-args.md +5 -0
  63. package/src/eops-skills/full-sequence-to-goal.md +5 -0
  64. package/src/eops-skills/ground-every-value.md +5 -0
  65. package/src/eops-skills/honor-the-policies.md +5 -0
  66. package/src/examples/README.md +58 -0
  67. package/src/examples/math-demo.mts +110 -0
  68. package/src/examples/strategy-demo.mts +119 -0
  69. package/src/fleet.mts +121 -0
  70. package/src/gate-cli.mts +101 -0
  71. package/src/gate.test.mts +129 -0
  72. package/src/gate.ts +460 -0
  73. package/src/generate-eval/certify.ts +178 -0
  74. package/src/generate-eval/schema.ts +78 -0
  75. package/src/humaneval-gate.mts +204 -0
  76. package/src/humaneval-repair-gate.mts +143 -0
  77. package/src/index.ts +19 -0
  78. package/src/mcp-mount-probe.mts +126 -0
  79. package/src/profile-coordinates.ts +134 -0
  80. package/src/profiles.ts +128 -0
  81. package/src/refine-loop.test.mts +106 -0
  82. package/src/refine-loop.ts +106 -0
  83. package/src/research-gate.mts +132 -0
  84. package/src/research-shot.ts +134 -0
  85. package/src/resolve-client.ts +58 -0
  86. package/src/router-executor.ts +51 -0
  87. package/src/run-pool.ts +48 -0
  88. package/src/runtime-hook-recorder.ts +137 -0
  89. package/src/sandbox-run.ts +125 -0
  90. package/src/search-bench/bridge.ts +124 -0
  91. package/src/search-bench/export.mts +0 -0
  92. package/src/search-bench/parametric-check.mts +63 -0
  93. package/src/search-bench/profiles.ts +98 -0
  94. package/src/search-bench/run.mts +287 -0
  95. package/src/search-bench/tasks-fresh.ts +688 -0
  96. package/src/search-bench/tasks.ts +129 -0
  97. package/src/search-tool.ts +95 -0
  98. package/src/selector.test.mts +189 -0
  99. package/src/selector.ts +366 -0
  100. package/src/skill-sandbox-smoke.mts +100 -0
  101. package/src/stats.mts +90 -0
  102. package/src/terminal-compare.ts +519 -0
  103. package/src/trajectory-assemble.mjs +130 -0
  104. package/src/trata-gate.mts +243 -0
  105. package/src/trata-gepa.mts +434 -0
  106. package/src/worker-blender.ts +230 -0
  107. package/src/worker-browser.ts +102 -0
  108. package/src/worker-build123d.ts +143 -0
  109. package/src/worker-cad.ts +451 -0
  110. package/src/worker.ts +136 -0
  111. package/src/workspace-loop.mts +133 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Cross-benchmark corpus analysis instrument (docs/learning-flywheel.md - the
3
+ * "corpus" + "measurement" layers).
4
+ *
5
+ * The corpus (one JSONL RunRecord per condition-run) is the flywheel's only durable
6
+ * asset. This reads it back and reports, GROUPED BY benchmark x condition, the
7
+ * numbers the doc says must hold before any rung is "earned":
8
+ *
9
+ * 1. resolve rate (resolved/n) + blind rate (blindResolved/n) per condition.
10
+ * 2. the ABLATION / per-level lift, computed PAIRED across instanceIds present in
11
+ * BOTH conditions:
12
+ * more-compute effect = random@k_rate - blind_rate
13
+ * steering effect = refineX@k_rate - random@k_rate (per refine directive)
14
+ * 3. a 95% PAIRED BOOTSTRAP CI + two-sided bootstrap p on each lift (mulberry32
15
+ * resample of instanceIds), with the discordant-pair count (power scales
16
+ * with it, not with n).
17
+ * 4. a BENJAMINI-HOCHBERG family correction across the contrasts: the ablation
18
+ * tests many (steering arms x directives x benchmarks, plus compute controls),
19
+ * so each independent CI inflates the family-wise false-positive rate. The
20
+ * PRIMARY hypothesis (steering = refineX - random > 0) is pre-registered and
21
+ * BH-corrected within its own family; more-compute is EXPLORATORY, corrected
22
+ * separately. A contrast is significant ONLY if it clears its family FDR -
23
+ * never on its own CI (a lone "CI excludes 0" across the grid is forking-paths).
24
+ * 5. the MULTI-OBJECTIVE / clean-trace axis per condition: attempts-to-resolve,
25
+ * total tokens, costUsd, total eventCount - so we see correct-AND-clean, not just
26
+ * "did it resolve" (the doc's Pareto axis).
27
+ * 6. honest caveats: n per group, infra-errored excluded, small n -> wide CI,
28
+ * and a loud "UNINFORMATIVE" flag when a family has 0 discordant pairs.
29
+ *
30
+ * Fails loud on an empty/unreadable corpus - a silent zero would feed the flywheel noise.
31
+ *
32
+ * tsx src/corpus-report.mts [corpusPathOrDirOrGlob ...]
33
+ * default: bench/corpus/finsearch.jsonl
34
+ * accepts files, directories (merges every *.jsonl under them), and globs.
35
+ */
36
+ import { readFile, stat } from 'node:fs/promises'
37
+ import { glob } from 'node:fs/promises'
38
+ import { resolve } from 'node:path'
39
+ import type { RunRecord, AttemptRecord } from './corpus.ts'
40
+ import { benjaminiHochberg } from '@tangle-network/agent-eval'
41
+
42
+ const DEFAULT_CORPUS = resolve(import.meta.dirname, '..', 'corpus', 'finsearch.jsonl')
43
+ const BOOTSTRAP_N = Number(process.env.BOOTSTRAP_N ?? 10000)
44
+ /** Benjamini-Hochberg false-discovery rate for the multiple-comparison correction
45
+ * applied across the lift family. The ablation tests MANY contrasts (steering arms
46
+ * x refine directives x benchmarks, plus the compute controls); each independent CI
47
+ * inflates the family-wise false-positive rate (garden of forking paths). Correcting
48
+ * the family is what keeps a lone "CI excludes 0" from being read as a real effect.
49
+ * Override with FDR=… ; 0.05 by default. */
50
+ const FDR = Number(process.env.FDR ?? 0.05)
51
+
52
+ // -- corpus loading -------------------------------------------------------------
53
+
54
+ /** Resolve CLI args (files / dirs / globs) to a deduped, ordered list of .jsonl files. */
55
+ async function resolveCorpusFiles(args: string[]): Promise<string[]> {
56
+ const inputs = args.length > 0 ? args : [DEFAULT_CORPUS]
57
+ const found = new Set<string>()
58
+ for (const raw of inputs) {
59
+ const looksGlob = raw.includes('*') || raw.includes('?') || raw.includes('[')
60
+ if (looksGlob) {
61
+ for await (const m of glob(raw)) found.add(resolve(m))
62
+ continue
63
+ }
64
+ const abs = resolve(raw)
65
+ const info = await stat(abs).catch(() => undefined)
66
+ if (info === undefined) {
67
+ throw new Error(`corpus path does not exist: ${abs}`)
68
+ }
69
+ if (info.isDirectory()) {
70
+ for await (const m of glob('**/*.jsonl', { cwd: abs })) found.add(resolve(abs, m))
71
+ } else {
72
+ found.add(abs)
73
+ }
74
+ }
75
+ return [...found].sort()
76
+ }
77
+
78
+ /** Parse one JSONL file into RunRecords. Throws on a malformed line - corrupt fuel
79
+ * must surface, never be silently skipped. */
80
+ async function loadFile(file: string): Promise<RunRecord[]> {
81
+ const text = await readFile(file, 'utf8')
82
+ const out: RunRecord[] = []
83
+ const lines = text.split('\n')
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const line = lines[i]
86
+ if (line === undefined || line.trim() === '') continue
87
+ let rec: unknown
88
+ try {
89
+ rec = JSON.parse(line)
90
+ } catch (err) {
91
+ throw new Error(`malformed JSONL at ${file}:${i + 1}: ${(err as Error).message}`)
92
+ }
93
+ const r = rec as Partial<RunRecord>
94
+ if (typeof r.benchmark !== 'string' || typeof r.instanceId !== 'string' || typeof r.condition !== 'string') {
95
+ throw new Error(`not a RunRecord at ${file}:${i + 1} (missing benchmark/instanceId/condition)`)
96
+ }
97
+ out.push(rec as RunRecord)
98
+ }
99
+ return out
100
+ }
101
+
102
+ // -- deduplication: one record per (benchmark, instance, condition) --------------
103
+
104
+ /** Collapse multiple records for the same (benchmark, instance, condition) to the
105
+ * LATEST by ts. We keep the latest rather than averaging because resolved/blind are
106
+ * booleans (a mean would silently fabricate a fractional outcome the judge never
107
+ * emitted); the clean-trace cost axis below uses that same latest record so every
108
+ * reported number comes from one real run. Returns the kept records + how many were
109
+ * superseded (reported in caveats). */
110
+ function dedupeLatest(records: RunRecord[]): { kept: RunRecord[]; superseded: number } {
111
+ const byKey = new Map<string, RunRecord>()
112
+ let superseded = 0
113
+ for (const r of records) {
114
+ const key = `${r.benchmark} ${r.instanceId} ${r.condition}`
115
+ const prev = byKey.get(key)
116
+ if (prev === undefined) {
117
+ byKey.set(key, r)
118
+ } else {
119
+ superseded += 1
120
+ // ts is an ISO string; lexical compare is chronological for ISO-8601.
121
+ if (r.ts > prev.ts) byKey.set(key, r)
122
+ }
123
+ }
124
+ return { kept: [...byKey.values()], superseded }
125
+ }
126
+
127
+ // -- clean-trace (multi-objective) aggregation ----------------------------------
128
+
129
+ interface CleanTrace {
130
+ /** Mean rounds until the FIRST valid attempt, over resolved runs only (NaN if none). */
131
+ meanAttemptsToResolve: number
132
+ meanTotalTokens: number
133
+ meanCostUsd: number
134
+ meanEventCount: number
135
+ /** runs that contributed to meanAttemptsToResolve (resolved runs). */
136
+ resolvedRuns: number
137
+ }
138
+
139
+ /** Rounds (1-based) until the first valid attempt, or undefined if none was valid. */
140
+ function attemptsToResolve(attempts: AttemptRecord[]): number | undefined {
141
+ for (let i = 0; i < attempts.length; i++) {
142
+ if (attempts[i]?.valid === true) return i + 1
143
+ }
144
+ return undefined
145
+ }
146
+
147
+ /** Sum only the attempts where `pick` returns a real number — an unmeasured
148
+ * (`undefined`) cost/token field is skipped, never coerced to 0. A `0`
149
+ * re-coalesced here would understate per-resolve cost and read as "free". */
150
+ function sumAttempts(attempts: AttemptRecord[], pick: (a: AttemptRecord) => number | undefined): number {
151
+ return attempts.reduce((s, a) => {
152
+ const v = pick(a)
153
+ return s + (typeof v === 'number' ? v : 0)
154
+ }, 0)
155
+ }
156
+
157
+ /** Count attempts that carry a measured value for `pick` (the denominator the
158
+ * token/cost means should honestly divide by — not the full attempt count). */
159
+ function countMeasured(attempts: AttemptRecord[], pick: (a: AttemptRecord) => number | undefined): number {
160
+ return attempts.reduce((c, a) => c + (typeof pick(a) === 'number' ? 1 : 0), 0)
161
+ }
162
+
163
+ function cleanTrace(records: RunRecord[]): CleanTrace {
164
+ const n = records.length
165
+ // Tokens are measured as a PAIR (in+out) or not at all — an attempt counts as
166
+ // measured only when BOTH are present, and the sum includes only those (a
167
+ // one-sided `?? 0` would half-fabricate the total).
168
+ const bothTokens = (a: AttemptRecord): number | undefined =>
169
+ a.tokensIn === undefined || a.tokensOut === undefined ? undefined : a.tokensIn + a.tokensOut
170
+ let tokens = 0
171
+ let cost = 0
172
+ let events = 0
173
+ // Coverage: the denominator the means honestly divide by. A fully-unmeasured
174
+ // condition (e.g. the local raw-stdout path) → NaN ("—"); partial coverage
175
+ // never understates by counting unmeasured attempts in the denominator.
176
+ let tokensMeasured = 0
177
+ let costMeasured = 0
178
+ const resolveRounds: number[] = []
179
+ for (const r of records) {
180
+ tokens += sumAttempts(r.attempts, bothTokens)
181
+ tokensMeasured += countMeasured(r.attempts, bothTokens)
182
+ cost += sumAttempts(r.attempts, (a) => a.costUsd)
183
+ costMeasured += countMeasured(r.attempts, (a) => a.costUsd)
184
+ events += sumAttempts(r.attempts, (a) => a.eventCount)
185
+ const atr = attemptsToResolve(r.attempts)
186
+ if (atr !== undefined) resolveRounds.push(atr)
187
+ }
188
+ const meanOf = (xs: number[]) => (xs.length === 0 ? Number.NaN : xs.reduce((s, x) => s + x, 0) / xs.length)
189
+ return {
190
+ meanAttemptsToResolve: meanOf(resolveRounds),
191
+ // Per measured attempt (not per record) — dividing by `n` would understate
192
+ // whenever some attempts are unmeasured.
193
+ meanTotalTokens: tokensMeasured === 0 ? Number.NaN : tokens / tokensMeasured,
194
+ meanCostUsd: costMeasured === 0 ? Number.NaN : cost / costMeasured,
195
+ meanEventCount: n === 0 ? Number.NaN : events / n,
196
+ resolvedRuns: resolveRounds.length,
197
+ }
198
+ }
199
+
200
+ // -- paired bootstrap CI (mulberry32) ----
201
+
202
+ interface PairedLift {
203
+ point: number
204
+ low: number
205
+ high: number
206
+ median: number
207
+ /** Two-sided percentile bootstrap p-value for lift != 0. Fed to the
208
+ * Benjamini-Hochberg family correction — NOT read as an independent verdict. */
209
+ p: number
210
+ /** instanceIds present in BOTH conditions. */
211
+ pairs: number
212
+ /** pairs where the two conditions' outcomes differ - power scales with this. */
213
+ discordant: number
214
+ }
215
+
216
+ /** One hypothesis test in the ablation family, tagged by which family it belongs
217
+ * to so the BH correction is applied WITHIN a family (primary vs exploratory). */
218
+ interface TestEntry {
219
+ benchmark: string
220
+ label: string
221
+ /** 'steering' = the pre-registered PRIMARY contrast (refineX - random); the
222
+ * reason the experiment exists. 'more-compute' = EXPLORATORY (random - blind). */
223
+ family: 'steering' | 'more-compute'
224
+ lift: PairedLift
225
+ }
226
+
227
+ /** Deterministic mulberry32 - Math.imul, no >2^53 overflow (a naive LCG gives
228
+ * degenerate [0,0] CIs). Seeded constant for reproducible CIs across runs. */
229
+ function makeRng(seed: number): () => number {
230
+ let s = seed | 0
231
+ return () => {
232
+ s = (s + 0x6d2b79f5) | 0
233
+ let t = Math.imul(s ^ (s >>> 15), 1 | s)
234
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
235
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
236
+ }
237
+ }
238
+
239
+ /** Paired lift = mean over shared instances of (treatmentOutcome - baselineOutcome),
240
+ * with a 95% bootstrap CI from resampling the SHARED instances (the paired unit). */
241
+ function pairedLift(
242
+ baseline: Map<string, number>,
243
+ treatment: Map<string, number>,
244
+ ): PairedLift | undefined {
245
+ const ids: string[] = []
246
+ for (const id of treatment.keys()) if (baseline.has(id)) ids.push(id)
247
+ ids.sort()
248
+ const n = ids.length
249
+ if (n === 0) return undefined
250
+ const deltas = ids.map((id) => (treatment.get(id) as number) - (baseline.get(id) as number))
251
+ const mean = (a: number[]) => a.reduce((s, x) => s + x, 0) / a.length
252
+ const point = mean(deltas)
253
+ const discordant = deltas.filter((d) => d !== 0).length
254
+ const rng = makeRng(0x9e3779b9)
255
+ const rint = (m: number) => Math.floor(rng() * m)
256
+ const boots: number[] = []
257
+ for (let b = 0; b < BOOTSTRAP_N; b++) {
258
+ let acc = 0
259
+ for (let j = 0; j < n; j++) acc += deltas[rint(n)] as number
260
+ boots.push(acc / n)
261
+ }
262
+ boots.sort((x, y) => x - y)
263
+ const low = boots[Math.floor(0.025 * BOOTSTRAP_N)] ?? Number.NaN
264
+ const high = boots[Math.floor(0.975 * BOOTSTRAP_N)] ?? Number.NaN
265
+ const median = boots[Math.floor(0.5 * BOOTSTRAP_N)] ?? Number.NaN
266
+ // Two-sided percentile bootstrap p-value for lift != 0: twice the bootstrap mass
267
+ // on the side of 0 opposite the point estimate. This becomes a per-comparison
268
+ // p that the Benjamini-Hochberg pass corrects across the whole family — it is
269
+ // NOT a standalone verdict (a lone CI excluding 0 across many contrasts is the
270
+ // forking-paths false positive the correction exists to catch).
271
+ let le = 0
272
+ let ge = 0
273
+ for (const b of boots) {
274
+ if (b <= 0) le++
275
+ if (b >= 0) ge++
276
+ }
277
+ const p = Math.min(1, (2 * Math.min(le, ge)) / BOOTSTRAP_N)
278
+ return { point, low, high, median, p, pairs: n, discordant }
279
+ }
280
+
281
+ // -- formatting ------------------------------------------------------------------
282
+
283
+ const pct = (x: number) => (Number.isNaN(x) ? ' n/a' : `${(x * 100).toFixed(1)}%`)
284
+ const pp = (x: number) => (Number.isNaN(x) ? 'n/a' : `${x >= 0 ? '+' : ''}${(x * 100).toFixed(1)}pp`)
285
+ const num = (x: number, d = 2) => (Number.isNaN(x) ? 'n/a' : x.toFixed(d))
286
+
287
+ /** One FDR-corrected test row: point + CI (descriptive) + raw p + BH q-value +
288
+ * the FDR-controlled verdict. The verdict is NEVER a bare "CI excludes 0" — it
289
+ * is the family-corrected decision. `q = 0` with `discordant = 0` is flagged as
290
+ * uninformative (a degenerate zero-variance CI), not as a null result. */
291
+ function printTestRow(e: TestEntry, q: number, sig: boolean): void {
292
+ const verdict = sig
293
+ ? `SIGNIF @FDR<${FDR}`
294
+ : e.lift.discordant === 0
295
+ ? 'UNINFORMATIVE (0 discordant)'
296
+ : 'n.s.'
297
+ console.log(
298
+ ` ${e.benchmark.padEnd(13)} ${e.label.padEnd(30)} ${pp(e.lift.point).padStart(7)} CI [${pp(e.lift.low)}, ${pp(e.lift.high)}] p=${e.lift.p.toFixed(3)} q=${q.toFixed(3)} ${verdict} (paired ${e.lift.pairs}, disc ${e.lift.discordant})`,
299
+ )
300
+ }
301
+
302
+ /** Print one hypothesis family with a Benjamini-Hochberg correction applied
303
+ * WITHIN it — so the pre-registered primary test is not diluted by exploratory
304
+ * contrasts, and neither family's multiplicity inflates the false-discovery rate. */
305
+ function printFamily(title: string, entries: TestEntry[]): void {
306
+ console.log(`\n== ${title} ==`)
307
+ if (entries.length === 0) {
308
+ console.log(' (no measurable contrasts in this family)')
309
+ return
310
+ }
311
+ const { qValues, significant } = benjaminiHochberg(
312
+ entries.map((e) => e.lift.p),
313
+ FDR,
314
+ )
315
+ let nSig = 0
316
+ entries.forEach((e, i) => {
317
+ const sig = significant[i] === true
318
+ if (sig) nSig++
319
+ printTestRow(e, qValues[i] as number, sig)
320
+ })
321
+ const totalDiscordant = entries.reduce((s, e) => s + e.lift.discordant, 0)
322
+ console.log(
323
+ ` family: ${entries.length} contrast(s), ${nSig} significant at FDR<${FDR}; total discordant pairs ${totalDiscordant}`,
324
+ )
325
+ if (totalDiscordant === 0) {
326
+ console.log(
327
+ ' * 0 discordant pairs across the family — nothing is measurable; any CI is degenerate. Not a null: UNINFORMATIVE (need discordant pairs, i.e. cases where the conditions disagree).',
328
+ )
329
+ }
330
+ }
331
+
332
+ // -- main ----------------------------------------------------------------------
333
+
334
+ /** A condition label "refineHand@4" / "random@4" -> its family ("refineHand"/"random")
335
+ * and a stable sort rank (blind-most-control -> most-steered). */
336
+ function conditionFamily(condition: string): string {
337
+ const at = condition.indexOf('@')
338
+ return at >= 0 ? condition.slice(0, at) : condition
339
+ }
340
+
341
+ async function main(): Promise<void> {
342
+ const files = await resolveCorpusFiles(process.argv.slice(2))
343
+ if (files.length === 0) {
344
+ throw new Error('no corpus files resolved from the given paths')
345
+ }
346
+ const all: RunRecord[] = []
347
+ for (const f of files) all.push(...(await loadFile(f)))
348
+ if (all.length === 0) {
349
+ throw new Error(`corpus is empty (0 RunRecords across ${files.length} file(s)): ${files.join(', ')}`)
350
+ }
351
+
352
+ const { kept, superseded } = dedupeLatest(all)
353
+
354
+ console.log(`corpus-report - ${files.length} file(s), ${all.length} record(s) read, ${kept.length} after dedupe-to-latest`)
355
+ for (const f of files) console.log(` * ${f}`)
356
+ console.log(` bootstrap B=${BOOTSTRAP_N} (paired resample of shared instanceIds)`)
357
+
358
+ // Group by benchmark, then condition.
359
+ const benchmarks = new Map<string, RunRecord[]>()
360
+ for (const r of kept) {
361
+ const arr = benchmarks.get(r.benchmark) ?? []
362
+ arr.push(r)
363
+ benchmarks.set(r.benchmark, arr)
364
+ }
365
+
366
+ // The ablation contrasts accumulate ACROSS benchmarks into one family so the
367
+ // Benjamini-Hochberg correction sees the full set of tests, not one benchmark
368
+ // at a time (the forking-paths inflation is across the whole grid).
369
+ const tests: TestEntry[] = []
370
+
371
+ for (const benchmark of [...benchmarks.keys()].sort()) {
372
+ const benchRecords = benchmarks.get(benchmark) as RunRecord[]
373
+ console.log(`\n== benchmark: ${benchmark} ==`)
374
+
375
+ // Partition into clean (analyzed) vs infra-errored (excluded but counted).
376
+ const clean = benchRecords.filter((r) => r.infraError !== true)
377
+ const erroredCount = benchRecords.length - clean.length
378
+
379
+ const byCondition = new Map<string, RunRecord[]>()
380
+ for (const r of clean) {
381
+ const arr = byCondition.get(r.condition) ?? []
382
+ arr.push(r)
383
+ byCondition.set(r.condition, arr)
384
+ }
385
+ // Order conditions: random families first (compute control), then refine families.
386
+ const conditions = [...byCondition.keys()].sort((a, b) => {
387
+ const fa = conditionFamily(a)
388
+ const fb = conditionFamily(b)
389
+ const rank = (f: string) => (f.startsWith('random') ? 0 : f.startsWith('blind') ? -1 : 1)
390
+ return rank(fa) - rank(fb) || a.localeCompare(b)
391
+ })
392
+
393
+ // (1) + (4): per-condition rates and clean-trace cost.
394
+ console.log(' per-condition (resolve / blind rate * clean-trace cost):')
395
+ for (const condition of conditions) {
396
+ const recs = byCondition.get(condition) as RunRecord[]
397
+ const n = recs.length
398
+ const resolveRate = recs.filter((r) => r.resolved === true).length / n
399
+ const blindRate = recs.filter((r) => r.blindResolved === true).length / n
400
+ const ct = cleanTrace(recs)
401
+ console.log(
402
+ ` ${condition.padEnd(16)} n=${String(n).padStart(3)} resolve ${pct(resolveRate)} blind ${pct(blindRate)}`,
403
+ )
404
+ console.log(
405
+ ` clean-trace: attempts->resolve ${num(ct.meanAttemptsToResolve, 2)} (over ${ct.resolvedRuns} resolved) tokens ${num(ct.meanTotalTokens, 0)} cost $${num(ct.meanCostUsd, 4)} events ${num(ct.meanEventCount, 1)}`,
406
+ )
407
+ }
408
+
409
+ // (2) + (3): the ablation - paired lifts with bootstrap CIs.
410
+ // Outcome maps keyed by instanceId, per condition.
411
+ const resolvedById = (recs: RunRecord[]) => {
412
+ const m = new Map<string, number>()
413
+ for (const r of recs) m.set(r.instanceId, r.resolved === true ? 1 : 0)
414
+ return m
415
+ }
416
+ const blindById = (recs: RunRecord[]) => {
417
+ const m = new Map<string, number>()
418
+ for (const r of recs) m.set(r.instanceId, r.blindResolved === true ? 1 : 0)
419
+ return m
420
+ }
421
+
422
+ // more-compute = random@k.resolved - random@k.blind (blind is iter0 of the SAME
423
+ // random run, so the pairing is exact and perfectly matched).
424
+ const randomConditions = conditions.filter((c) => conditionFamily(c).startsWith('random'))
425
+ const refineConditions = conditions.filter((c) => conditionFamily(c).startsWith('refine'))
426
+
427
+ // Collect the contrasts into the cross-benchmark family. Significance is NOT
428
+ // decided here per-contrast — it is decided after the loop by one BH pass per
429
+ // family (printFamily), which is what controls the false-discovery rate.
430
+ if (randomConditions.length === 0) {
431
+ console.log(' ablation: (no random@k condition - cannot isolate the compute control; lifts unmeasurable)')
432
+ }
433
+ for (const rc of randomConditions) {
434
+ const recs = byCondition.get(rc) as RunRecord[]
435
+ const lift = pairedLift(blindById(recs), resolvedById(recs))
436
+ if (lift) tests.push({ benchmark, label: `more-compute (${rc} - blind)`, family: 'more-compute', lift })
437
+ }
438
+
439
+ // steering = refineX@k.resolved - random@k.resolved, paired across shared instances.
440
+ // Pair each refine condition against the random condition with the SAME @k suffix
441
+ // when present, else the lone random condition.
442
+ for (const refc of refineConditions) {
443
+ const k = refc.slice(refc.indexOf('@'))
444
+ const matchRandom =
445
+ randomConditions.find((c) => c.endsWith(k)) ?? (randomConditions.length === 1 ? randomConditions[0] : undefined)
446
+ if (matchRandom === undefined) {
447
+ console.log(` steering (${refc} - random?) - no matching random@k condition`)
448
+ continue
449
+ }
450
+ const baseline = resolvedById(byCondition.get(matchRandom) as RunRecord[])
451
+ const treatment = resolvedById(byCondition.get(refc) as RunRecord[])
452
+ const lift = pairedLift(baseline, treatment)
453
+ if (lift) tests.push({ benchmark, label: `steering (${refc} - ${matchRandom})`, family: 'steering', lift })
454
+ }
455
+
456
+ // (5) honest caveats for this benchmark.
457
+ const totalN = clean.length
458
+ const minCondN = conditions.length === 0 ? 0 : Math.min(...conditions.map((c) => (byCondition.get(c) as RunRecord[]).length))
459
+ console.log(' caveats:')
460
+ console.log(` * clean records: ${totalN} across ${conditions.length} condition(s); smallest condition n=${minCondN}`)
461
+ console.log(` * infra-errored excluded: ${erroredCount}`)
462
+ if (minCondN > 0 && minCondN < 20) {
463
+ console.log(` * small n (min ${minCondN}) -> wide CI; power scales with the discordant-pair count above, not n`)
464
+ }
465
+ }
466
+
467
+ // ── Family-corrected hypothesis tests (Benjamini-Hochberg, FDR<%) ──────────
468
+ // Pre-registration: the PRIMARY hypothesis the whole experiment exists to test
469
+ // is steering > 0 — does a steer (refineX) beat the compute-matched random@k
470
+ // control. Everything else (more-compute = random - blind) is EXPLORATORY. BH
471
+ // is applied WITHIN each family: the primary verdict is not diluted by the
472
+ // exploratory contrasts, and neither family's multiplicity inflates the FDR.
473
+ // A contrast is "significant" ONLY if it clears its family's BH threshold —
474
+ // never on its own CI alone (that's the forking-paths false positive).
475
+ console.log(`\n${'='.repeat(78)}`)
476
+ console.log(`HYPOTHESIS TESTS - Benjamini-Hochberg family correction, FDR<${FDR}`)
477
+ printFamily(
478
+ 'PRIMARY (pre-registered): steering = refineX@k - random@k > 0',
479
+ tests.filter((t) => t.family === 'steering'),
480
+ )
481
+ printFamily(
482
+ 'EXPLORATORY: more-compute = random@k - blind',
483
+ tests.filter((t) => t.family === 'more-compute'),
484
+ )
485
+
486
+ // What BH does NOT protect against — stated loud so a clean family table is not
487
+ // mistaken for more rigor than it carries.
488
+ console.log('\n what the BH correction does NOT defend against:')
489
+ console.log(
490
+ ' * optional stopping / holdout reuse: BH controls one evaluation of a FIXED contrast set. Re-running this report on a GROWING corpus (same instanceIds, more samples added over time) inflates the FDR across the sequence. Pre-register the contrast set; spend a FROZEN confirmation holdout ONCE per locked candidate (explore on a separate reusable set).',
491
+ )
492
+ console.log(
493
+ ' * family re-allocation: the steering/exploratory tag is derived from the condition label (refine* vs random*), not chosen per run, so it is not gameable at runtime — but moving a contrast between families post-hoc would dodge the within-family correction.',
494
+ )
495
+
496
+ if (superseded > 0) {
497
+ console.log(`\nnote: ${superseded} record(s) superseded by a later run for the same (benchmark, instance, condition) - kept the latest by ts`)
498
+ }
499
+ }
500
+
501
+ main().catch((err) => {
502
+ console.error(`corpus-report failed: ${(err as Error).message}`)
503
+ process.exit(1)
504
+ })