@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.
- package/README.md +15 -0
- package/package.json +35 -0
- package/src/adapters.ts +60 -0
- package/src/aec-gate.mts +217 -0
- package/src/atom-humaneval.mts +197 -0
- package/src/atom-mcp-e2e.mts +223 -0
- package/src/benchmarks/_harness.ts +206 -0
- package/src/benchmarks/aec-bench.test.mts +53 -0
- package/src/benchmarks/aec-bench.ts +319 -0
- package/src/benchmarks/appworld.test.mts +45 -0
- package/src/benchmarks/appworld.ts +426 -0
- package/src/benchmarks/cad-design.ts +429 -0
- package/src/benchmarks/cadbench.ts +135 -0
- package/src/benchmarks/cadgenbench.ts +121 -0
- package/src/benchmarks/commit0.test.mts +71 -0
- package/src/benchmarks/commit0.ts +266 -0
- package/src/benchmarks/enterpriseops-gym.test.mts +77 -0
- package/src/benchmarks/enterpriseops-gym.ts +343 -0
- package/src/benchmarks/finsearchcomp.ts +371 -0
- package/src/benchmarks/frames.ts +520 -0
- package/src/benchmarks/hotpotqa.ts +320 -0
- package/src/benchmarks/humaneval.ts +251 -0
- package/src/benchmarks/mind2web.ts +311 -0
- package/src/benchmarks/programbench.test.mts +60 -0
- package/src/benchmarks/programbench.ts +210 -0
- package/src/benchmarks/simpleqa.ts +390 -0
- package/src/benchmarks/swe-bench.ts +152 -0
- package/src/benchmarks/terminal-bench.ts +182 -0
- package/src/benchmarks/trata-hedge.ts +496 -0
- package/src/benchmarks/types.ts +58 -0
- package/src/browser/adapters/bad-design-audit.ts +153 -0
- package/src/browser/adapters/bad-design-audit.verify.ts +85 -0
- package/src/browser/adapters/bad.ts +165 -0
- package/src/browser/agent-adapter.ts +145 -0
- package/src/browser/process-adapter.ts +146 -0
- package/src/browser/run-to-spans.ts +105 -0
- package/src/browser/run-to-spans.verify.ts +89 -0
- package/src/browser/ui-reviewer.ts +200 -0
- package/src/browser/ui-reviewer.verify.ts +94 -0
- package/src/browser/verify.ts +96 -0
- package/src/clbench-codebase-gate.mts +314 -0
- package/src/clbench-context-gate.mts +305 -0
- package/src/cloud-loop.mts +138 -0
- package/src/coding-skills/minimal-diff.md +9 -0
- package/src/coding-skills/read-before-edit.md +9 -0
- package/src/coding-skills/reproduce-first.md +10 -0
- package/src/coding-skills/run-tests-after-edit.md +9 -0
- package/src/coding-skills/trace-the-failure.md +9 -0
- package/src/commit0-env-run.mts +59 -0
- package/src/commit0-env.ts +173 -0
- package/src/commit0-gate.mts +529 -0
- package/src/commit0-prereqs.sh +48 -0
- package/src/corpus-replay.mts +300 -0
- package/src/corpus-report.mts +504 -0
- package/src/corpus.test.mts +273 -0
- package/src/corpus.ts +325 -0
- package/src/decoder-live.mts +133 -0
- package/src/directives.ts +84 -0
- package/src/diverse-gate.mjs +112 -0
- package/src/egress-probe.mts +26 -0
- package/src/eops-skills/address-every-subtask.md +5 -0
- package/src/eops-skills/exact-tools-and-args.md +5 -0
- package/src/eops-skills/full-sequence-to-goal.md +5 -0
- package/src/eops-skills/ground-every-value.md +5 -0
- package/src/eops-skills/honor-the-policies.md +5 -0
- package/src/examples/README.md +58 -0
- package/src/examples/math-demo.mts +110 -0
- package/src/examples/strategy-demo.mts +119 -0
- package/src/fleet.mts +121 -0
- package/src/gate-cli.mts +101 -0
- package/src/gate.test.mts +129 -0
- package/src/gate.ts +460 -0
- package/src/generate-eval/certify.ts +178 -0
- package/src/generate-eval/schema.ts +78 -0
- package/src/humaneval-gate.mts +204 -0
- package/src/humaneval-repair-gate.mts +143 -0
- package/src/index.ts +19 -0
- package/src/mcp-mount-probe.mts +126 -0
- package/src/profile-coordinates.ts +134 -0
- package/src/profiles.ts +128 -0
- package/src/refine-loop.test.mts +106 -0
- package/src/refine-loop.ts +106 -0
- package/src/research-gate.mts +132 -0
- package/src/research-shot.ts +134 -0
- package/src/resolve-client.ts +58 -0
- package/src/router-executor.ts +51 -0
- package/src/run-pool.ts +48 -0
- package/src/runtime-hook-recorder.ts +137 -0
- package/src/sandbox-run.ts +125 -0
- package/src/search-bench/bridge.ts +124 -0
- package/src/search-bench/export.mts +0 -0
- package/src/search-bench/parametric-check.mts +63 -0
- package/src/search-bench/profiles.ts +98 -0
- package/src/search-bench/run.mts +287 -0
- package/src/search-bench/tasks-fresh.ts +688 -0
- package/src/search-bench/tasks.ts +129 -0
- package/src/search-tool.ts +95 -0
- package/src/selector.test.mts +189 -0
- package/src/selector.ts +366 -0
- package/src/skill-sandbox-smoke.mts +100 -0
- package/src/stats.mts +90 -0
- package/src/terminal-compare.ts +519 -0
- package/src/trajectory-assemble.mjs +130 -0
- package/src/trata-gate.mts +243 -0
- package/src/trata-gepa.mts +434 -0
- package/src/worker-blender.ts +230 -0
- package/src/worker-browser.ts +102 -0
- package/src/worker-build123d.ts +143 -0
- package/src/worker-cad.ts +451 -0
- package/src/worker.ts +136 -0
- 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
|
+
})
|