@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,273 @@
1
+ import assert from 'node:assert/strict'
2
+ import { isRunRecord } from '@tangle-network/agent-eval'
3
+ import {
4
+ type AttemptRecord,
5
+ benchRecordToCorpusRecords,
6
+ buildRunRecord,
7
+ buildRunRecordFromAttempts,
8
+ type RunRecord,
9
+ } from './corpus'
10
+ import { createRuntimeHookRecorder } from './runtime-hook-recorder'
11
+
12
+ const measuredAttempt = (round: number, output: string, valid: boolean): AttemptRecord => ({
13
+ round,
14
+ prompt: `q${round}`,
15
+ output,
16
+ valid,
17
+ score: valid ? 1 : 0,
18
+ costUsd: 0.01 + round / 1000,
19
+ tokensIn: 100 + round,
20
+ tokensOut: 30 + round,
21
+ wallMs: 500 + round,
22
+ eventCount: 2,
23
+ eventTypes: { llm_call: 1, tool_call: 1 },
24
+ })
25
+
26
+ const baseRec = (attempts: AttemptRecord[], over: Partial<RunRecord> = {}): RunRecord => ({
27
+ ts: '2026-06-03T00:00:00.000Z',
28
+ benchmark: 'finsearchcomp',
29
+ instanceId: 'i1',
30
+ condition: 'random@3',
31
+ // canonical RunRecord requires a snapshot-pinned model; bench writes bare
32
+ // aliases, so the happy path supplies the resolved snapshot via opts.model.
33
+ model: 'gpt-5',
34
+ blindResolved: attempts[0]?.valid === true,
35
+ resolved: attempts.some((a) => a.valid === true),
36
+ attempts,
37
+ infraError: false,
38
+ ...over,
39
+ })
40
+
41
+ // --- runtime recorder snapshots decision points before persistent corpus storage ---
42
+ {
43
+ const recorder = createRuntimeHookRecorder()
44
+ const largeContext = `Bearer abc.def.ghi ${'ctx'.repeat(10_000)}`
45
+ const largeDetail = `token=supersecret ${'detail'.repeat(1_000)}`
46
+ recorder.hooks.onDecisionPoint?.(
47
+ {
48
+ id: 'run-1:agent.turn:0:failure-recovery',
49
+ runId: 'run-1',
50
+ scenarioId: 'task-1',
51
+ stepIndex: 0,
52
+ kind: 'retry',
53
+ candidateActions: Array.from({ length: 75 }, (_, index) => `candidate-${index}`),
54
+ context: largeContext,
55
+ evidence: [
56
+ {
57
+ source: 'tool_result',
58
+ id: 'tool-1:result',
59
+ detail: largeDetail,
60
+ metadata: { authorization: 'Bearer should-not-survive', nested: { apiKey: 'also-redacted' } },
61
+ },
62
+ ],
63
+ metadata: { token: 'should-not-survive', safe: 'kept' },
64
+ },
65
+ {},
66
+ )
67
+
68
+ const [point] = recorder.decisionPoints
69
+ assert.ok(point, 'decision point recorded')
70
+ assert.notEqual(point, undefined)
71
+ assert.equal(point.candidateActions.length, 50, 'candidate actions are bounded')
72
+ assert.equal(point.context?.length, 20_000, 'context is bounded')
73
+ assert.equal(point.evidence[0]?.detail?.length, 2_000, 'evidence detail is bounded')
74
+ assert.equal(point.context?.includes('abc.def.ghi'), false, 'context secrets are redacted')
75
+ assert.equal(point.evidence[0]?.detail?.includes('supersecret'), false, 'evidence detail secrets are redacted')
76
+ assert.equal(point.metadata?.token, '[REDACTED]', 'top-level sensitive metadata is redacted')
77
+ assert.equal(point.metadata?.safe, 'kept', 'non-sensitive metadata is preserved')
78
+ assert.equal(point.evidence[0]?.metadata?.authorization, '[REDACTED]', 'evidence metadata is redacted')
79
+ assert.equal(
80
+ (point.evidence[0]?.metadata?.nested as { apiKey?: unknown } | undefined)?.apiKey,
81
+ '[REDACTED]',
82
+ 'nested sensitive metadata is redacted',
83
+ )
84
+ }
85
+
86
+ // --- happy path: a measured run projects to one canonical CorpusRecord per attempt ---
87
+ {
88
+ const rec = baseRec([
89
+ measuredAttempt(0, 'alpha', false),
90
+ measuredAttempt(1, 'beta', true),
91
+ ])
92
+ const { records, unmappable } = await benchRecordToCorpusRecords(rec, {
93
+ commitSha: 'abc123',
94
+ model: 'gpt-5-2025-08-07',
95
+ seed: 42,
96
+ })
97
+ assert.equal(unmappable.length, 0, 'all measured attempts map')
98
+ assert.equal(records.length, 2, 'one record per attempt')
99
+ for (const r of records) {
100
+ assert.ok(isRunRecord(r), 'each is a valid canonical RunRecord')
101
+ }
102
+ const [r0, r1] = records
103
+ assert.equal(r0?.candidateId, 'random@3', 'candidateId = condition (the gate-pairing arm)')
104
+ assert.equal(r0?.scenarioId, 'i1', 'scenarioId = instanceId (the pairing key)')
105
+ assert.equal(r0?.seed, 42, 'opts.seed is the base seed for attempt 0')
106
+ assert.equal(r1?.seed, 43, 'each attempt offsets the base → DISTINCT seeds')
107
+ assert.notEqual(r0?.seed, r1?.seed, 'no (scenarioId, seed) collision across a run\'s attempts')
108
+ assert.equal(r0?.model, 'gpt-5-2025-08-07', 'snapshot-pinned model override applied')
109
+ assert.equal(r0?.commitSha, 'abc123')
110
+ assert.equal(r0?.tokenUsage.input, 100, 'real tokens carried (not zeroed)')
111
+ assert.equal(r0?.costUsd, 0.01, 'real cost carried')
112
+ assert.equal(r0?.prompt, 'q0', 'verbatim prompt survives the validator')
113
+ assert.equal(r0?.completion, 'alpha', 'verbatim completion survives the validator')
114
+ assert.equal(r0?.outcome.searchScore, 0, 'search split → searchScore from attempt score')
115
+ assert.equal(r0?.outcome.raw.valid, 0)
116
+ assert.equal(r1?.outcome.raw.valid, 1)
117
+ assert.notEqual(r0?.runId, r1?.runId, 'per-attempt runIds are distinct')
118
+ }
119
+
120
+ // --- unmeasured economics → unmappable, never forged with phantom zeros ---
121
+ {
122
+ const bare: AttemptRecord = { round: 0, prompt: 'q', output: 'x', valid: true, eventCount: 0, eventTypes: {} }
123
+ const { records, unmappable } = await benchRecordToCorpusRecords(baseRec([bare]), {
124
+ commitSha: 'abc',
125
+ model: 'gpt-5-2025-08-07',
126
+ })
127
+ assert.equal(records.length, 0, 'no record forged from unmeasured economics')
128
+ assert.equal(unmappable.length, 1)
129
+ assert.match(unmappable[0]!.reason, /unmeasured/, 'reason names the missing measurement')
130
+ assert.match(unmappable[0]!.reason, /costUsd/, 'reason lists the missing fields')
131
+ }
132
+
133
+ // --- bare-alias model (no override) → unmappable with the validator's reason ---
134
+ {
135
+ const { records, unmappable } = await benchRecordToCorpusRecords(
136
+ baseRec([measuredAttempt(0, 'alpha', true)]),
137
+ { commitSha: 'abc' },
138
+ )
139
+ assert.equal(records.length, 0, 'a bare-alias model is not a reproducibility artifact')
140
+ assert.equal(unmappable.length, 1)
141
+ assert.match(unmappable[0]!.reason, /invalid RunRecord/, 'surfaces the validator rejection')
142
+ assert.match(unmappable[0]!.reason, /snapshot/, 'reason explains the snapshot-pin requirement')
143
+ }
144
+
145
+ // --- holdout split routes the score to holdoutScore ---
146
+ {
147
+ const { records } = await benchRecordToCorpusRecords(baseRec([measuredAttempt(0, 'alpha', true)]), {
148
+ commitSha: 'abc',
149
+ model: 'gpt-5-2025-08-07',
150
+ splitTag: 'holdout',
151
+ })
152
+ assert.equal(records.length, 1)
153
+ assert.equal(records[0]?.splitTag, 'holdout')
154
+ assert.equal(records[0]?.outcome.holdoutScore, 1, 'holdout split → holdoutScore')
155
+ assert.equal(records[0]?.outcome.searchScore, undefined, 'no searchScore on a holdout record')
156
+ }
157
+
158
+ // --- bench writer preserves runtime trajectory evidence and semantic decision points ---
159
+ {
160
+ const record = buildRunRecord({
161
+ benchmark: 'commit0',
162
+ instanceId: 'task-1',
163
+ condition: 'random@2',
164
+ model: 'gpt-5',
165
+ resolved: true,
166
+ infraError: false,
167
+ now: () => new Date('2026-06-03T00:00:00.000Z'),
168
+ iterations: [
169
+ {
170
+ index: 0,
171
+ task: 'prompt',
172
+ agentRunName: 'worker',
173
+ output: 'completion',
174
+ verdict: { valid: true, score: 1 },
175
+ events: [],
176
+ startedAt: 10,
177
+ endedAt: 20,
178
+ costUsd: 0.01,
179
+ tokenUsage: { input: 10, output: 5 },
180
+ },
181
+ ],
182
+ runtimeEvents: [
183
+ {
184
+ id: 'run-1:agent.run:before',
185
+ runId: 'run-1',
186
+ scenarioId: 'task-1',
187
+ target: 'agent.run',
188
+ phase: 'before',
189
+ timestamp: 1,
190
+ },
191
+ ],
192
+ runtimeDecisionPoints: [
193
+ {
194
+ id: 'run-1:agent.turn:0:failure-recovery',
195
+ runId: 'run-1',
196
+ scenarioId: 'task-1',
197
+ stepIndex: 0,
198
+ kind: 'retry',
199
+ candidateActions: ['retry', 'verify', 'stop'],
200
+ evidence: [{ source: 'tool_result', id: 'tool-1:result' }],
201
+ metadata: { target: 'failure-recovery' },
202
+ },
203
+ ],
204
+ })
205
+ assert.equal(record.runtimeEvents?.length, 1, 'runtime lifecycle events survive the writer')
206
+ assert.equal(record.runtimeDecisionPoints?.length, 1, 'runtime decision points survive the writer')
207
+ assert.equal(record.runtimeDecisionPoints?.[0]?.metadata?.target, 'failure-recovery')
208
+ }
209
+
210
+ // --- buildRunRecordFromAttempts: default derivations from the attempts ---
211
+ {
212
+ const rec = buildRunRecordFromAttempts([measuredAttempt(0, 'a', false), measuredAttempt(1, 'b', true)], {
213
+ benchmark: 'aec-bench',
214
+ instanceId: 'i9',
215
+ condition: 'random@2',
216
+ model: 'gpt-5',
217
+ now: () => new Date('2026-06-06T00:00:00.000Z'),
218
+ runtimeEvents: [
219
+ {
220
+ id: 'run-2:agent.run:before',
221
+ runId: 'run-2',
222
+ target: 'agent.run',
223
+ phase: 'before',
224
+ timestamp: 1,
225
+ },
226
+ ],
227
+ runtimeDecisionPoints: [
228
+ {
229
+ id: 'run-2:agent.turn:0:failure-recovery',
230
+ runId: 'run-2',
231
+ stepIndex: 0,
232
+ kind: 'retry',
233
+ candidateActions: ['retry', 'verify', 'stop'],
234
+ evidence: [{ source: 'tool_result', id: 'tool-2:result' }],
235
+ },
236
+ ],
237
+ })
238
+ assert.equal(rec.ts, '2026-06-06T00:00:00.000Z', 'now() seam stamps ts')
239
+ assert.equal(rec.blindResolved, false, 'blindResolved = attempts[0].valid === true')
240
+ assert.equal(rec.resolved, true, 'resolved = any attempt valid')
241
+ assert.equal(rec.infraError, false, 'scored+valid attempts ⇒ not infra')
242
+ assert.equal(rec.attempts.length, 2)
243
+ assert.equal(rec.runtimeEvents?.length, 1, 'attempt writer preserves lifecycle events')
244
+ assert.equal(rec.runtimeDecisionPoints?.length, 1, 'attempt writer preserves decision points')
245
+ }
246
+
247
+ // --- no scored + no valid attempt ⇒ derived infraError ---
248
+ {
249
+ const bare: AttemptRecord = { round: 0, prompt: 'q', output: '', eventCount: 0, eventTypes: {} }
250
+ const rec = buildRunRecordFromAttempts([bare], { benchmark: 'aec-bench', instanceId: 'i', condition: 'random@1', model: 'gpt-5' })
251
+ assert.equal(rec.infraError, true, 'no scored + no valid ⇒ infraError true')
252
+ assert.equal(rec.blindResolved, false)
253
+ assert.equal(rec.resolved, false)
254
+ }
255
+
256
+ // --- explicit overrides preserve a gate's bespoke recorded values ---
257
+ {
258
+ const partial: AttemptRecord = { round: 0, prompt: 'q', output: 'x', valid: true, score: 0.5, costUsd: 0.01, tokensIn: 1, tokensOut: 1, wallMs: 1, eventCount: 1, eventTypes: {} }
259
+ const rec = buildRunRecordFromAttempts([partial], {
260
+ benchmark: 'clbench-codebase',
261
+ instanceId: 'i',
262
+ condition: 'random@1',
263
+ model: 'gpt-5',
264
+ // a partial-credit (score 0.5) first shot is valid but NOT a full blind-resolve.
265
+ blindResolved: false,
266
+ infraError: false,
267
+ })
268
+ assert.equal(rec.blindResolved, false, 'override beats the attempts[0].valid default')
269
+ assert.equal(rec.resolved, true, 'resolved still derives from valid when not overridden')
270
+ assert.equal(rec.infraError, false)
271
+ }
272
+
273
+ console.log('corpus.test.mts: all assertions passed')
package/src/corpus.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * The learning-flywheel corpus (docs/learning-flywheel.md).
3
+ *
4
+ * Every bench run persists the FULL tuple per attempt — not a boolean. Boolean
5
+ * scorecards delete the fuel the flywheel needs; this captures state · steer ·
6
+ * trace · output · verdict · cost so the accumulated corpus can drive
7
+ * cross-benchmark controller learning later (offline replay / GEPA / meta-harness).
8
+ *
9
+ * One JSONL line per condition-run (a controller steering a worker over k attempts).
10
+ * Append-only, durable, queryable. The trace is summarized (event count + types +
11
+ * tail) to stay bounded while keeping the failure signal — store more if a
12
+ * trace-aware optimizer needs it.
13
+ */
14
+
15
+ import { appendFile, mkdir } from 'node:fs/promises'
16
+ import { dirname } from 'node:path'
17
+ import { hashContent, type RunSplitTag, validateRunRecord } from '@tangle-network/agent-eval'
18
+ import type { CorpusRecord } from '@tangle-network/agent-eval/rl'
19
+ import type { Iteration } from '@tangle-network/agent-runtime/loops'
20
+ import type { BenchRuntimeDecisionPoint, BenchRuntimeHookEvent } from './runtime-hook-recorder'
21
+
22
+ /** One attempt within a condition-run: the prompt/steer sent, the output, the
23
+ * verdict, the measured economics, and a bounded trace summary.
24
+ *
25
+ * `costUsd`/`tokensIn`/`tokensOut`/`wallMs` are OPTIONAL on purpose: they are
26
+ * present only when the worker actually reported them (the `runLoop`/kernel
27
+ * path). A worker that reports no usage (e.g. a raw opencode-stdout shot)
28
+ * OMITS them — it never writes a fabricated `0`. Absence means "unmeasured",
29
+ * which is honest and lets the canonical bridge below refuse to forge a
30
+ * RunRecord with phantom economics. (no-fallback: a silent `0` cost reads as a
31
+ * free run downstream, which is a lie the gate would then act on.) */
32
+ export interface AttemptRecord {
33
+ round: number
34
+ prompt: string
35
+ output?: string
36
+ valid?: boolean
37
+ score?: number
38
+ /** Measured USD cost of this attempt. Absent ⇒ the worker reported none. */
39
+ costUsd?: number
40
+ /** Measured input/output tokens. Absent ⇒ the worker reported none. */
41
+ tokensIn?: number
42
+ tokensOut?: number
43
+ /** Measured wall time (endedAt − startedAt). Absent ⇒ not timed. */
44
+ wallMs?: number
45
+ eventCount: number
46
+ eventTypes: Record<string, number>
47
+ traceTail?: string
48
+ error?: string
49
+ }
50
+
51
+ /** One controller-run over a single benchmark instance under one condition. */
52
+ export interface RunRecord {
53
+ ts: string
54
+ benchmark: string
55
+ instanceId: string
56
+ /** Condition / controller label (random@k, refineHand@k, refineGepa@k, …). */
57
+ condition: string
58
+ model: string
59
+ /** iteration[0] verdict — the blind (1-attempt) outcome. */
60
+ blindResolved: boolean
61
+ /** winner verdict — the condition's k-attempt outcome. */
62
+ resolved: boolean
63
+ attempts: AttemptRecord[]
64
+ infraError: boolean
65
+ /** Canonical-pairing provenance (optional; writers set when known). These are
66
+ * what let a bench record project onto the substrate's `RunRecord` so it can
67
+ * pair across sweeps and feed `analyzeRuns`/`HeldOutGate`/the RL exporters. */
68
+ seed?: number
69
+ splitTag?: RunSplitTag
70
+ commitSha?: string
71
+ /** Passive runtime hook evidence captured during the run. Optional and bounded by producers. */
72
+ runtimeEvents?: BenchRuntimeHookEvent[]
73
+ /** Semantic runtime decision points captured during the run. Optional and producer-defined. */
74
+ runtimeDecisionPoints?: BenchRuntimeDecisionPoint[]
75
+ }
76
+
77
+ const TRACE_TAIL_MAX = 600
78
+
79
+ function summarizeAttempt<Task, Output>(iter: Iteration<Task, Output>): AttemptRecord {
80
+ const types: Record<string, number> = {}
81
+ let tail: string | undefined
82
+ for (const ev of iter.events) {
83
+ const t = String((ev as { type?: unknown }).type ?? 'unknown')
84
+ types[t] = (types[t] ?? 0) + 1
85
+ const d = (ev as { data?: Record<string, unknown> }).data
86
+ const txt = d?.finalText ?? d?.text ?? d?.result
87
+ if (typeof txt === 'string' && txt.length > 0) tail = txt
88
+ }
89
+ // The kernel measures these for every Iteration — carry them verbatim. A
90
+ // kernel-reported 0 (e.g. a loop that made no priced LLM call) is an honest
91
+ // measurement, NOT the fabricated 0 the raw-stdout path used to write.
92
+ return {
93
+ round: iter.index,
94
+ prompt: typeof iter.task === 'string' ? iter.task : JSON.stringify(iter.task),
95
+ output: iter.output !== undefined ? (typeof iter.output === 'string' ? iter.output : JSON.stringify(iter.output)) : undefined,
96
+ valid: iter.verdict?.valid,
97
+ score: iter.verdict?.score,
98
+ costUsd: iter.costUsd,
99
+ tokensIn: iter.tokenUsage.input,
100
+ tokensOut: iter.tokenUsage.output,
101
+ wallMs: Math.max(0, iter.endedAt - iter.startedAt),
102
+ eventCount: iter.events.length,
103
+ eventTypes: types,
104
+ traceTail: tail ? tail.slice(-TRACE_TAIL_MAX) : undefined,
105
+ error: iter.error?.message,
106
+ }
107
+ }
108
+
109
+ /** Build a RunRecord from a runLoop result. `now` injected for determinism in tests. */
110
+ export function buildRunRecord<Task, Output>(args: {
111
+ benchmark: string
112
+ instanceId: string
113
+ condition: string
114
+ model: string
115
+ iterations: ReadonlyArray<Iteration<Task, Output>>
116
+ resolved: boolean
117
+ infraError: boolean
118
+ now?: () => Date
119
+ /** Canonical-pairing provenance — set when the caller knows it, so the
120
+ * record projects cleanly onto the substrate without the bridge guessing. */
121
+ seed?: number
122
+ splitTag?: RunSplitTag
123
+ commitSha?: string
124
+ runtimeEvents?: BenchRuntimeHookEvent[]
125
+ runtimeDecisionPoints?: BenchRuntimeDecisionPoint[]
126
+ }): RunRecord {
127
+ const attempts = args.iterations.map(summarizeAttempt)
128
+ return {
129
+ ts: (args.now ? args.now() : new Date()).toISOString(),
130
+ benchmark: args.benchmark,
131
+ instanceId: args.instanceId,
132
+ condition: args.condition,
133
+ model: args.model,
134
+ blindResolved: args.iterations[0]?.verdict?.valid === true,
135
+ resolved: args.resolved,
136
+ attempts,
137
+ infraError: args.infraError,
138
+ ...(args.seed !== undefined ? { seed: args.seed } : {}),
139
+ ...(args.splitTag !== undefined ? { splitTag: args.splitTag } : {}),
140
+ ...(args.commitSha !== undefined ? { commitSha: args.commitSha } : {}),
141
+ ...(args.runtimeEvents !== undefined && args.runtimeEvents.length > 0
142
+ ? { runtimeEvents: args.runtimeEvents }
143
+ : {}),
144
+ ...(args.runtimeDecisionPoints !== undefined && args.runtimeDecisionPoints.length > 0
145
+ ? { runtimeDecisionPoints: args.runtimeDecisionPoints }
146
+ : {}),
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Build a RunRecord from a hand-assembled `AttemptRecord[]` (the gate runners'
152
+ * shape — they score attempts directly rather than carrying `Iteration`s).
153
+ *
154
+ * The three run-level verdicts derive from the attempts by default:
155
+ * - `blindResolved = attempts[0]?.valid === true` (the 1-attempt outcome)
156
+ * - `resolved = attempts.some(a => a.valid === true)` (the k-attempt outcome)
157
+ * - `infraError = no attempt is scored AND none is valid` (the whole run errored)
158
+ * A gate whose recorded value differs from a derivation (e.g. a binary-pass gate
159
+ * that reads `blindResolved` off `score === 1`, or one that pins `infraError`)
160
+ * passes that field explicitly to preserve its exact recorded value. `now` is
161
+ * injected for determinism in tests (mirrors `buildRunRecord`).
162
+ */
163
+ export function buildRunRecordFromAttempts(
164
+ attempts: AttemptRecord[],
165
+ meta: {
166
+ benchmark: string
167
+ instanceId: string
168
+ condition: string
169
+ model: string
170
+ blindResolved?: boolean
171
+ resolved?: boolean
172
+ infraError?: boolean
173
+ now?: () => Date
174
+ seed?: number
175
+ splitTag?: RunSplitTag
176
+ commitSha?: string
177
+ runtimeEvents?: BenchRuntimeHookEvent[]
178
+ runtimeDecisionPoints?: BenchRuntimeDecisionPoint[]
179
+ },
180
+ ): RunRecord {
181
+ const anyScored = attempts.some((a) => a.score !== undefined)
182
+ const anyValid = attempts.some((a) => a.valid !== undefined)
183
+ return {
184
+ ts: (meta.now ? meta.now() : new Date()).toISOString(),
185
+ benchmark: meta.benchmark,
186
+ instanceId: meta.instanceId,
187
+ condition: meta.condition,
188
+ model: meta.model,
189
+ blindResolved: meta.blindResolved ?? attempts[0]?.valid === true,
190
+ resolved: meta.resolved ?? attempts.some((a) => a.valid === true),
191
+ attempts,
192
+ infraError: meta.infraError ?? (!anyScored && !anyValid),
193
+ ...(meta.seed !== undefined ? { seed: meta.seed } : {}),
194
+ ...(meta.splitTag !== undefined ? { splitTag: meta.splitTag } : {}),
195
+ ...(meta.commitSha !== undefined ? { commitSha: meta.commitSha } : {}),
196
+ ...(meta.runtimeEvents !== undefined && meta.runtimeEvents.length > 0
197
+ ? { runtimeEvents: meta.runtimeEvents }
198
+ : {}),
199
+ ...(meta.runtimeDecisionPoints !== undefined && meta.runtimeDecisionPoints.length > 0
200
+ ? { runtimeDecisionPoints: meta.runtimeDecisionPoints }
201
+ : {}),
202
+ }
203
+ }
204
+
205
+ /** Run-level provenance the caller asserts when projecting a bench record onto
206
+ * the substrate. `commitSha` is required: a canonical `RunRecord` is a
207
+ * reproducibility artifact and must name the code that produced it — the
208
+ * bridge will not invent one. */
209
+ export interface CorpusProjectionOpts {
210
+ commitSha: string
211
+ /** Defaults to the record's `benchmark`. */
212
+ experimentId?: string
213
+ /** Overrides the record's `seed`; falls back to the attempt ordinal. */
214
+ seed?: number
215
+ /** Which split these attempts belong to. Defaults to the record's `splitTag`,
216
+ * else `'search'`. */
217
+ splitTag?: RunSplitTag
218
+ /** Snapshot-pinned model id (`name@YYYY-MM-DD` / `name-YYYYMMDD`) the caller
219
+ * resolved for this run. `validateRunRecord` REJECTS bare aliases (`gpt-5`),
220
+ * so a record whose `model` is unpinned — and that has no override here —
221
+ * lands in `unmappable` rather than being forged with a guessed snapshot.
222
+ * (Bench writers should record the resolved snapshot so this is unneeded.) */
223
+ model?: string
224
+ }
225
+
226
+ /** One attempt that could not become a canonical record, with the reason. */
227
+ export interface UnmappableAttempt {
228
+ round: number
229
+ reason: string
230
+ }
231
+
232
+ export interface CorpusProjection {
233
+ records: CorpusRecord[]
234
+ /** Attempts dropped because they lacked the canonical-mandatory signal
235
+ * (measured economics / an output). The caller decides whether a non-empty
236
+ * list is acceptable — it is surfaced, never silently swallowed. */
237
+ unmappable: UnmappableAttempt[]
238
+ }
239
+
240
+ /**
241
+ * Project a bench condition-run onto the substrate's canonical `CorpusRecord[]`
242
+ * (one record per ATTEMPT — the rollout granularity the RL/replay/gate layers
243
+ * pair on). This is the bridge that turns the bench's experiment-shaped corpus
244
+ * into substrate fuel WITHOUT rewriting the bench readers.
245
+ *
246
+ * Fail-loud, never fabricate: an attempt missing the canonical-mandatory signal
247
+ * (`costUsd`/`tokensIn`/`tokensOut`/`wallMs`/`output`) is reported in
248
+ * `unmappable` rather than backfilled with phantom zeros. This is WHY the local
249
+ * raw-stdout path (which omits economics) cannot feed the gate — only the
250
+ * measured `runLoop`/sandbox path can, which is the correct, honest constraint.
251
+ */
252
+ export async function benchRecordToCorpusRecords(
253
+ rec: RunRecord,
254
+ opts: CorpusProjectionOpts,
255
+ ): Promise<CorpusProjection> {
256
+ const records: CorpusRecord[] = []
257
+ const unmappable: UnmappableAttempt[] = []
258
+ const experimentId = opts.experimentId ?? rec.benchmark
259
+ const splitTag: RunSplitTag = opts.splitTag ?? rec.splitTag ?? 'search'
260
+ const configHash = await hashContent(`${rec.condition}|${rec.model}`)
261
+
262
+ for (const a of rec.attempts) {
263
+ const missing: string[] = []
264
+ if (typeof a.output !== 'string' || a.output.length === 0) missing.push('output')
265
+ if (a.costUsd === undefined) missing.push('costUsd')
266
+ if (a.tokensIn === undefined) missing.push('tokensIn')
267
+ if (a.tokensOut === undefined) missing.push('tokensOut')
268
+ if (a.wallMs === undefined) missing.push('wallMs')
269
+ if (missing.length > 0) {
270
+ unmappable.push({ round: a.round, reason: `unmeasured: ${missing.join(', ')}` })
271
+ continue
272
+ }
273
+
274
+ const score = a.score ?? (a.valid === true ? 1 : 0)
275
+ const promptHash = await hashContent(a.prompt)
276
+ const candidate: CorpusRecord = {
277
+ runId: `${rec.benchmark}:${rec.instanceId}:${rec.condition}:r${a.round}`,
278
+ experimentId,
279
+ candidateId: rec.condition,
280
+ // Each attempt needs a DISTINCT seed: the RL/preference layer pairs by
281
+ // (scenarioId, seed), so identical seeds across a run's k attempts would
282
+ // collapse them into one cell. A run-level seed is a BASE; the attempt
283
+ // ordinal offsets it. (rec.seed/ordinal fallbacks keep the same property.)
284
+ seed: opts.seed !== undefined ? opts.seed + a.round : (rec.seed ?? a.round),
285
+ model: opts.model ?? rec.model,
286
+ promptHash,
287
+ configHash,
288
+ commitSha: opts.commitSha,
289
+ wallMs: a.wallMs as number,
290
+ costUsd: a.costUsd as number,
291
+ tokenUsage: { input: a.tokensIn as number, output: a.tokensOut as number },
292
+ outcome: {
293
+ ...(splitTag === 'holdout' ? { holdoutScore: score } : { searchScore: score }),
294
+ raw: { valid: a.valid === true ? 1 : 0, score },
295
+ },
296
+ splitTag,
297
+ scenarioId: rec.instanceId,
298
+ prompt: a.prompt,
299
+ completion: a.output as string,
300
+ }
301
+ try {
302
+ // Validate for the assertion (throws on a malformed record) but keep the
303
+ // CorpusRecord superset — the validator returns a RunRecord and may drop
304
+ // the `prompt`/`completion` extras the RL exporters want.
305
+ validateRunRecord(candidate)
306
+ records.push(candidate)
307
+ } catch (err) {
308
+ unmappable.push({
309
+ round: a.round,
310
+ reason: `invalid RunRecord: ${err instanceof Error ? err.message : String(err)}`,
311
+ })
312
+ }
313
+ }
314
+
315
+ return { records, unmappable }
316
+ }
317
+
318
+ /** Append one RunRecord to the durable corpus (creating the dir if needed). */
319
+ export async function appendRunRecord(corpusPath: string, record: RunRecord): Promise<void> {
320
+ // Fail loud on a real mkdir failure (EACCES, disk-full): recursive:true is
321
+ // idempotent when the dir already exists, so this throws ONLY on genuine errors
322
+ // — a silent swallow just made the append fail later with a confusing message.
323
+ await mkdir(dirname(corpusPath), { recursive: true })
324
+ await appendFile(corpusPath, `${JSON.stringify(record)}\n`)
325
+ }