@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,520 @@
1
+ /**
2
+ * FRAMES adapter (google/frames-benchmark). Worker artifact = a single free-text
3
+ * final answer string (optionally preceded by a citations block). FRAMES ships,
4
+ * per item, a short gold `Answer` PLUS gold `wiki_links` (the Wikipedia URLs
5
+ * needed to answer) — the gold-citation column is what lets the loop's critic
6
+ * check citation coverage deterministically.
7
+ *
8
+ * Judge is two-tier, deterministic-first:
9
+ * Tier 1 — normalized exact / token-boundary containment match (no model tokens).
10
+ * Tier 2 — a constrained binary equivalence gate via a pinned LLM (JUDGE_MODEL,
11
+ * temperature 0), fired ONLY when Tier 1 misses, to absorb the
12
+ * paraphrase/alias equivalence FRAMES intends to allow.
13
+ *
14
+ * score is binary (resolved ? 1 : 0) — FRAMES has no partial credit. The judge
15
+ * resolves strictly on the Answer value; wiki_links are a SOFT signal surfaced
16
+ * in `detail` for the critic, never a hard pass criterion.
17
+ *
18
+ * Requires for a live run: the bench `.venv` with `datasets` installed and a
19
+ * JUDGE_MODEL router key. For offline/CI verification set FRAMES_FIXTURES=1 to
20
+ * load the committed fixtures (bench/fixtures/frames.json) — no HF download.
21
+ */
22
+
23
+ import { execFile } from 'node:child_process'
24
+ import { readFile } from 'node:fs/promises'
25
+ import { join } from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { promisify } from 'node:util'
28
+ import type { BenchmarkAdapter, BenchScore, BenchTask, LoadOptions } from './types'
29
+
30
+ const execFileAsync = promisify(execFile)
31
+ const BENCH_ROOT = fileURLToPath(new URL('../..', import.meta.url))
32
+ const PY = join(BENCH_ROOT, '.venv', 'bin', 'python')
33
+ const FIXTURES = join(BENCH_ROOT, 'fixtures', 'frames.json')
34
+
35
+ const DATASET = 'google/frames-benchmark'
36
+ /** Pin the dataset revision: ids are derived from row order (no native id column),
37
+ * so a reorder would break opts.ids selection + scorecard joins. */
38
+ const DATASET_REVISION = 'main'
39
+ const FINAL_ANSWER_SENTINEL = 'FINAL ANSWER:'
40
+
41
+ /** Worker contract appended to every task prompt. Tier-1 parsing keys off the sentinel. */
42
+ const WORKER_CONTRACT = [
43
+ '',
44
+ 'Research the question using live web sources and answer it.',
45
+ 'Cite the sources you used as full URLs, one per line, in a `CITATIONS:` block.',
46
+ `End your response with a single final line: \`${FINAL_ANSWER_SENTINEL} <answer>\``,
47
+ 'The answer after the sentinel must be the bare value only (no explanation on that line).',
48
+ ].join('\n')
49
+
50
+ /**
51
+ * Typed seam for the future dynamic-topology research worker. The benchmark
52
+ * adapter scores a plain `string` artifact (the BenchmarkAdapter contract); the
53
+ * loop worker decodes its agent runs into a {@link ResearchAnswer} and serializes
54
+ * `finalAnswer` (+ optional `CITATIONS:` block) into that string before judging.
55
+ */
56
+ export interface ResearchTask {
57
+ id: string
58
+ question: string
59
+ /** Gold short answer — judge resolves strictly against this. */
60
+ gold: string
61
+ /** Gold Wikipedia URLs — SOFT citation-coverage signal for the critic, never a hard gate. */
62
+ goldSources: string[]
63
+ /** FRAMES reasoning_types, e.g. 'numerical | temporal' — slices the scorecard by hop-type. */
64
+ reasoningTypes: string
65
+ }
66
+
67
+ export interface ResearchAnswer {
68
+ finalAnswer: string
69
+ citations: string[]
70
+ }
71
+
72
+ interface FramesRow {
73
+ Prompt: string
74
+ Answer: string
75
+ wiki_links: string
76
+ reasoning_types: string
77
+ }
78
+
79
+ interface FramesMeta {
80
+ gold: string
81
+ goldSources: string[]
82
+ reasoningTypes: string
83
+ rawPrompt: string
84
+ }
85
+
86
+ /** Run the bench venv python with a script on stdin; return stdout (throws on nonzero). */
87
+ async function py(script: string, args: string[] = []): Promise<string> {
88
+ const { stdout } = await execFileAsync(PY, ['-c', script, ...args], {
89
+ maxBuffer: 1024 * 1024 * 256,
90
+ })
91
+ return stdout
92
+ }
93
+
94
+ const ARTICLES = new Set(['a', 'an', 'the'])
95
+ const UNIT_WORDS = new Set([
96
+ 'years',
97
+ 'year',
98
+ 'months',
99
+ 'month',
100
+ 'days',
101
+ 'day',
102
+ 'people',
103
+ 'percent',
104
+ 'dollars',
105
+ 'meters',
106
+ 'metres',
107
+ 'kilometers',
108
+ 'kilometres',
109
+ 'miles',
110
+ 'km',
111
+ 'm',
112
+ ])
113
+
114
+ const WRITTEN_NUMBERS: Record<string, number> = {
115
+ zero: 0,
116
+ one: 1,
117
+ two: 2,
118
+ three: 3,
119
+ four: 4,
120
+ five: 5,
121
+ six: 6,
122
+ seven: 7,
123
+ eight: 8,
124
+ nine: 9,
125
+ ten: 10,
126
+ eleven: 11,
127
+ twelve: 12,
128
+ thirteen: 13,
129
+ fourteen: 14,
130
+ fifteen: 15,
131
+ sixteen: 16,
132
+ seventeen: 17,
133
+ eighteen: 18,
134
+ nineteen: 19,
135
+ twenty: 20,
136
+ thirty: 30,
137
+ forty: 40,
138
+ fifty: 50,
139
+ sixty: 60,
140
+ seventy: 70,
141
+ eighty: 80,
142
+ ninety: 90,
143
+ hundred: 100,
144
+ thousand: 1000,
145
+ million: 1_000_000,
146
+ billion: 1_000_000_000,
147
+ }
148
+
149
+ const MONTHS: Record<string, string> = {
150
+ january: '01',
151
+ february: '02',
152
+ march: '03',
153
+ april: '04',
154
+ may: '05',
155
+ june: '06',
156
+ july: '07',
157
+ august: '08',
158
+ september: '09',
159
+ october: '10',
160
+ november: '11',
161
+ december: '12',
162
+ }
163
+
164
+ const SCALES = new Set(['hundred', 'thousand', 'million', 'billion'])
165
+
166
+ /** Fold a run of written-number words into one integer ("twenty eight"→28, "two hundred"→200). */
167
+ function composeWrittenRun(words: string[]): number {
168
+ let total = 0
169
+ let current = 0
170
+ for (const w of words) {
171
+ const v = WRITTEN_NUMBERS[w]
172
+ if (v === undefined) continue
173
+ if (w === 'hundred') {
174
+ current = (current === 0 ? 1 : current) * 100
175
+ } else if (SCALES.has(w)) {
176
+ current = (current === 0 ? 1 : current) * v
177
+ total += current
178
+ current = 0
179
+ } else {
180
+ current += v
181
+ }
182
+ }
183
+ return total + current
184
+ }
185
+
186
+ /** Canonicalize "1,234" / "1.2 million" / written numerals to a single numeric token. */
187
+ function canonicalizeNumbers(s: string): string {
188
+ // "1,234,567" → "1234567"
189
+ let out = s.replace(/(\d),(?=\d{3}\b)/g, '$1')
190
+ // "1.2 million" / "3 billion" → expanded integer
191
+ out = out.replace(/\b(\d+(?:\.\d+)?)\s+(hundred|thousand|million|billion)\b/g, (_m, num: string, scaleWord: string) => {
192
+ const scale = WRITTEN_NUMBERS[scaleWord] ?? 1
193
+ return String(Number(num) * scale)
194
+ })
195
+ // fold contiguous runs of written-number words into one integer
196
+ out = out.replace(/\b[a-z]+(?:[\s-]+[a-z]+)*\b/g, (run) => {
197
+ const words = run.split(/[\s-]+/)
198
+ if (!words.every((w) => w in WRITTEN_NUMBERS)) return run
199
+ return String(composeWrittenRun(words))
200
+ })
201
+ return out
202
+ }
203
+
204
+ /** "March 3, 1879" / "3 March 1879" → "1879-03-03"; year-only stays as the year. */
205
+ function canonicalizeDates(s: string): string {
206
+ let out = s.replace(
207
+ /\b(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2}),?\s+(\d{4})\b/g,
208
+ (_m, mon: string, day: string, year: string) => `${year}-${MONTHS[mon]}-${day.padStart(2, '0')}`,
209
+ )
210
+ out = out.replace(
211
+ /\b(\d{1,2})\s+(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{4})\b/g,
212
+ (_m, day: string, mon: string, year: string) => `${year}-${MONTHS[mon]}-${day.padStart(2, '0')}`,
213
+ )
214
+ return out
215
+ }
216
+
217
+ /** Port of the SQuAD/GAIA normalize_answer routine, plus number/date canonicalization. */
218
+ export function normalizeAnswer(input: string): string {
219
+ let s = input.toLowerCase()
220
+ s = canonicalizeDates(s)
221
+ s = canonicalizeNumbers(s)
222
+ // strip punctuation (keep alphanumerics, ISO-date hyphens collapse to space-free below)
223
+ s = s.replace(/[^\w\s-]/g, ' ')
224
+ // tokenize, drop articles + trailing unit words, keep order
225
+ const tokens = s
226
+ .split(/\s+/)
227
+ .filter((t) => t.length > 0)
228
+ .filter((t) => !ARTICLES.has(t))
229
+ .filter((t) => !UNIT_WORDS.has(t))
230
+ return tokens.join(' ').trim()
231
+ }
232
+
233
+ /** Tier-1 match: normalized equality OR normalized gold as a token-boundary substring. */
234
+ function tier1Match(candidate: string, gold: string): boolean {
235
+ const nc = normalizeAnswer(candidate)
236
+ const ng = normalizeAnswer(gold)
237
+ if (ng.length === 0) return false
238
+ if (nc === ng) return true
239
+ // token-boundary containment: gold appears as a whole-token run inside candidate
240
+ const re = new RegExp(`(^|\\s)${ng.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`)
241
+ return re.test(nc)
242
+ }
243
+
244
+ /**
245
+ * Parse the worker artifact into the final answer string.
246
+ * Order: the `FINAL ANSWER:` sentinel first (deterministic-extraction discipline);
247
+ * fall back to the last non-empty line. Returns '' when nothing is parseable
248
+ * (fail-closed — never guess), which judge() counts as resolved=false.
249
+ */
250
+ export function parseFinalAnswer(artifact: string): string {
251
+ const lines = artifact.split(/\r?\n/)
252
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
253
+ const line = lines[i] ?? ''
254
+ const idx = line.toUpperCase().indexOf(FINAL_ANSWER_SENTINEL)
255
+ if (idx !== -1) return line.slice(idx + FINAL_ANSWER_SENTINEL.length).trim()
256
+ }
257
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
258
+ const t = (lines[i] ?? '').trim()
259
+ if (t.length > 0) return t
260
+ }
261
+ return ''
262
+ }
263
+
264
+ /** Extract cited URLs from a `CITATIONS:` block and any bare URLs in the artifact. */
265
+ export function parseCitations(artifact: string): string[] {
266
+ const urls = new Set<string>()
267
+ for (const m of artifact.matchAll(/https?:\/\/[^\s)<>"']+/g)) {
268
+ urls.add(m[0].replace(/[.,;]+$/, ''))
269
+ }
270
+ return [...urls]
271
+ }
272
+
273
+ /** Fraction of gold Wikipedia article slugs touched by the candidate's citations. SOFT signal. */
274
+ function citationCoverage(citations: string[], goldSources: string[]): number {
275
+ if (goldSources.length === 0) return 1
276
+ const slug = (u: string) => {
277
+ const m = u.match(/\/wiki\/([^#?]+)/)
278
+ return m ? decodeURIComponent(m[1] ?? '').toLowerCase() : u.toLowerCase()
279
+ }
280
+ const got = new Set(citations.map(slug))
281
+ let hit = 0
282
+ for (const g of goldSources) if (got.has(slug(g))) hit += 1
283
+ return hit / goldSources.length
284
+ }
285
+
286
+ const JUDGE_PROMPT = (question: string, gold: string, candidate: string): string =>
287
+ [
288
+ 'You are a strict answer-equivalence checker for a factual question-answering benchmark.',
289
+ 'Decide ONLY whether the candidate answer is semantically equivalent to the gold answer for this question.',
290
+ 'It is CORRECT iff it contains the same factual value as the gold answer. Extra correct detail is fine.',
291
+ 'A different value, a missing value, or a wrong value is INCORRECT.',
292
+ '',
293
+ `Question: ${question}`,
294
+ `Gold answer: ${gold}`,
295
+ `Candidate answer: ${candidate}`,
296
+ '',
297
+ 'Respond with ONLY a fenced JSON block and nothing else:',
298
+ '```json',
299
+ '{"verdict": "correct" | "incorrect"}',
300
+ '```',
301
+ ].join('\n')
302
+
303
+ interface JudgeRouter {
304
+ baseUrl: string
305
+ key: string
306
+ model: string
307
+ }
308
+
309
+ function judgeRouter(): JudgeRouter {
310
+ const model = process.env.JUDGE_MODEL
311
+ if (!model) throw new Error('JUDGE_MODEL is required for the FRAMES Tier-2 equivalence judge')
312
+ const key = process.env.TANGLE_API_KEY
313
+ if (!key) throw new Error('TANGLE_API_KEY is required for the FRAMES Tier-2 judge')
314
+ const baseUrl = process.env.JUDGE_ROUTER_BASE ?? process.env.ROUTER_BASE ?? 'https://router.tangle.tools/v1'
315
+ return { baseUrl, key, model }
316
+ }
317
+
318
+ /** Constrained binary equivalence gate. Pinned model, temperature 0; fail loud on unparseable output. */
319
+ async function tier2Judge(
320
+ question: string,
321
+ gold: string,
322
+ candidate: string,
323
+ router: JudgeRouter,
324
+ ): Promise<boolean> {
325
+ const res = await fetch(`${router.baseUrl}/chat/completions`, {
326
+ method: 'POST',
327
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${router.key}` },
328
+ body: JSON.stringify({
329
+ model: router.model,
330
+ temperature: 0,
331
+ seed: 0,
332
+ messages: [{ role: 'user', content: JUDGE_PROMPT(question, gold, candidate) }],
333
+ }),
334
+ })
335
+ if (!res.ok) {
336
+ throw new Error(`FRAMES Tier-2 judge HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`)
337
+ }
338
+ const body = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }
339
+ const content = body.choices?.[0]?.message?.content
340
+ if (typeof content !== 'string') {
341
+ throw new Error(`FRAMES Tier-2 judge returned no message content: ${JSON.stringify(body).slice(0, 300)}`)
342
+ }
343
+ const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/)
344
+ const raw = (fenced ? fenced[1] : content)?.trim() ?? ''
345
+ let parsed: { verdict?: unknown }
346
+ try {
347
+ parsed = JSON.parse(raw) as { verdict?: unknown }
348
+ } catch {
349
+ throw new Error(`FRAMES Tier-2 judge produced unparseable output (no JSON verdict): ${content.slice(0, 300)}`)
350
+ }
351
+ if (parsed.verdict === 'correct') return true
352
+ if (parsed.verdict === 'incorrect') return false
353
+ throw new Error(`FRAMES Tier-2 judge verdict not in {correct,incorrect}: ${JSON.stringify(parsed).slice(0, 200)}`)
354
+ }
355
+
356
+ /** FRAMES wiki_links is a python-repr list string, e.g. "['https://…', 'https://…']". */
357
+ function parseWikiLinks(raw: string): string[] {
358
+ if (!raw) return []
359
+ const out: string[] = []
360
+ for (const m of raw.matchAll(/https?:\/\/[^\s'"\]]+/g)) out.push(m[0])
361
+ return out
362
+ }
363
+
364
+ function rowToTask(row: FramesRow, index: number): BenchTask {
365
+ const goldSources = parseWikiLinks(row.wiki_links)
366
+ const meta: FramesMeta = {
367
+ gold: row.Answer,
368
+ goldSources,
369
+ reasoningTypes: row.reasoning_types ?? '',
370
+ rawPrompt: row.Prompt,
371
+ }
372
+ return {
373
+ id: `frames-${index}`,
374
+ split: 'test',
375
+ prompt: row.Prompt + WORKER_CONTRACT,
376
+ metadata: meta as unknown as Record<string, unknown>,
377
+ }
378
+ }
379
+
380
+ function readMeta(task: BenchTask): FramesMeta {
381
+ const md = task.metadata
382
+ if (!md || typeof md.gold !== 'string') {
383
+ throw new Error(`FRAMES task ${task.id} missing metadata.gold — loadTasks did not populate it`)
384
+ }
385
+ return md as unknown as FramesMeta
386
+ }
387
+
388
+ async function loadFixtures(opts: LoadOptions): Promise<BenchTask[]> {
389
+ const rows = JSON.parse(await readFile(FIXTURES, 'utf8')) as FramesRow[]
390
+ console.log(`[frames] FRAMES_FIXTURES=1 — loading ${rows.length} committed fixtures (no HF download)`)
391
+ let tasks = rows.map(rowToTask)
392
+ if (opts.ids) {
393
+ const want = new Set(opts.ids)
394
+ tasks = tasks.filter((t) => want.has(t.id))
395
+ } else if (opts.limit !== undefined) {
396
+ tasks = tasks.slice(0, opts.limit)
397
+ }
398
+ return tasks
399
+ }
400
+
401
+ export function createFramesAdapter(): BenchmarkAdapter {
402
+ const fixturesMode = process.env.FRAMES_FIXTURES === '1'
403
+
404
+ return {
405
+ name: 'frames',
406
+
407
+ async preflight() {
408
+ // Tier-2 judge router config must be present in both modes — the loop's
409
+ // citation-coverage stop gate is meaningless without the equivalence judge.
410
+ judgeRouter()
411
+ if (fixturesMode) {
412
+ await readFile(FIXTURES, 'utf8').catch((err) => {
413
+ throw new Error(`FRAMES_FIXTURES=1 but ${FIXTURES} unreadable: ${err instanceof Error ? err.message : err}`)
414
+ })
415
+ return
416
+ }
417
+ try {
418
+ await py(
419
+ `from datasets import load_dataset
420
+ load_dataset(${JSON.stringify(DATASET)}, split='test', revision=${JSON.stringify(DATASET_REVISION)})
421
+ print('ok')`,
422
+ )
423
+ } catch (err) {
424
+ const msg = err instanceof Error ? err.message : String(err)
425
+ throw new Error(
426
+ `frames preflight failed: ${msg}\n` +
427
+ `Fix: (1) python3 -m venv bench/.venv && bench/.venv/bin/pip install datasets ; ` +
428
+ `(2) ensure network access to Hugging Face for ${DATASET} ; ` +
429
+ `or set FRAMES_FIXTURES=1 to run against the committed fixtures offline.`,
430
+ )
431
+ }
432
+ },
433
+
434
+ async loadTasks(opts: LoadOptions = {}) {
435
+ if (fixturesMode) return loadFixtures(opts)
436
+ const limit = opts.limit ?? 10
437
+ const script = `
438
+ import json, sys
439
+ from datasets import load_dataset
440
+ ds = load_dataset(${JSON.stringify(DATASET)}, split='test', revision=${JSON.stringify(DATASET_REVISION)})
441
+ ids = set(json.loads(sys.argv[1])) if len(sys.argv) > 1 and sys.argv[1] else None
442
+ out = []
443
+ for i, r in enumerate(ds):
444
+ rid = f"frames-{i}"
445
+ if ids is not None and rid not in ids:
446
+ continue
447
+ out.append({
448
+ "_index": i,
449
+ "Prompt": r.get("Prompt", ""),
450
+ "Answer": r.get("Answer", ""),
451
+ "wiki_links": str(r.get("wiki_links", "")),
452
+ "reasoning_types": str(r.get("reasoning_types", "")),
453
+ })
454
+ if ids is None and len(out) >= ${limit}:
455
+ break
456
+ print(json.dumps(out))
457
+ `
458
+ const stdout = await py(script, [opts.ids ? JSON.stringify(opts.ids) : ''])
459
+ const rows = JSON.parse(stdout) as Array<FramesRow & { _index: number }>
460
+ return rows.map((r) => rowToTask(r, r._index))
461
+ },
462
+
463
+ async goldArtifact(task: BenchTask) {
464
+ // Gold artifact = the worker-contract serialization of the gold Answer, so
465
+ // verify-judge proves gold→resolved through the SAME parse path the real
466
+ // artifact takes (Tier-1 short-circuits with no model tokens).
467
+ const meta = readMeta(task)
468
+ return `${FINAL_ANSWER_SENTINEL} ${meta.gold}`
469
+ },
470
+
471
+ async judge(task: BenchTask, artifact: string): Promise<BenchScore> {
472
+ const meta = readMeta(task)
473
+ const finalAnswer = parseFinalAnswer(artifact)
474
+ const citations = parseCitations(artifact)
475
+ const coverage = citationCoverage(citations, meta.goldSources)
476
+
477
+ if (finalAnswer.length === 0) {
478
+ // Fail-closed: distinguish prompt-adherence failure from a wrong answer.
479
+ return {
480
+ resolved: false,
481
+ score: 0,
482
+ detail: JSON.stringify({
483
+ tier: 'none',
484
+ reason: 'no parseable answer',
485
+ normalizedGold: normalizeAnswer(meta.gold),
486
+ citationCoverage: coverage,
487
+ }),
488
+ }
489
+ }
490
+
491
+ // Tier 1 — free, deterministic.
492
+ if (tier1Match(finalAnswer, meta.gold)) {
493
+ return {
494
+ resolved: true,
495
+ score: 1,
496
+ detail: JSON.stringify({
497
+ tier: 1,
498
+ normalizedAnswer: normalizeAnswer(finalAnswer),
499
+ normalizedGold: normalizeAnswer(meta.gold),
500
+ citationCoverage: coverage,
501
+ }),
502
+ }
503
+ }
504
+
505
+ // Tier 2 — constrained LLM equivalence gate (fail loud on unparseable output).
506
+ const verdict = await tier2Judge(meta.rawPrompt, meta.gold, finalAnswer, judgeRouter())
507
+ return {
508
+ resolved: verdict,
509
+ score: verdict ? 1 : 0,
510
+ detail: JSON.stringify({
511
+ tier: 2,
512
+ normalizedAnswer: normalizeAnswer(finalAnswer),
513
+ normalizedGold: normalizeAnswer(meta.gold),
514
+ judgeVerdict: verdict ? 'correct' : 'incorrect',
515
+ citationCoverage: coverage,
516
+ }),
517
+ }
518
+ },
519
+ }
520
+ }