@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,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
|
+
}
|