@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
package/src/gate.ts ADDED
@@ -0,0 +1,460 @@
1
+ /**
2
+ * The gate — run the open binding question THROUGH the recursive runtime.
3
+ *
4
+ * The bench unifier (`run-benchmarks.ts`) drives `runLoop`. This module drives the recursive atom
5
+ * instead: a `Persona` + the generic `fanout` combinator over the budget-conserving `Supervisor`,
6
+ * so the diverse-strategy-vs-blind gate is measured through the same recursive atom every
7
+ * personified loop uses — not a bespoke harness.
8
+ *
9
+ * The one specificity is the developer's `AgentProfile` + the strategy list. Everything else is
10
+ * free below it: orchestration, the conserved-budget equal-k guarantee, the trajectory ledger.
11
+ *
12
+ * The gate's two non-negotiables, enforced structurally here:
13
+ * - DEPLOYABLE selector. Each fanout child SOLVES via the router, then is graded by the
14
+ * benchmark's OWN deterministic `adapter.judge` (a runnable checker — NOT an LLM judge, NOT
15
+ * the answer oracle). That `BenchScore` becomes the child's `DefaultVerdict`, and `fanout`'s
16
+ * single-sourced `defaultSelectWinner` picks the best-scoring candidate. Selection therefore
17
+ * reads only the deployable verifier — selector ≠ oracle by construction.
18
+ * - EQUAL k. Both arms open the SAME number of children (k = strategies.length; blind = k
19
+ * identical copies), and the conserved pool reserves identical per-child budgets, so
20
+ * Σk(diverse) ≡ Σk(blind). `equalKOnCost` then proves the REALIZED token/usd spread is within
21
+ * tolerance (diverse prompts are longer, so the realized spend is checked, not assumed).
22
+ *
23
+ * `widen`/analyst stay out: this is the plumbing that makes the gate RUNNABLE through the
24
+ * runtime, not new mechanism. Per the repo discipline, no adaptive widening is wired until the
25
+ * gate returns positive.
26
+ */
27
+
28
+ import type { SandboxEvent } from '@tangle-network/sandbox'
29
+ import type {
30
+ AgentProfile,
31
+ AgentSpec,
32
+ Budget,
33
+ CombinatorShape,
34
+ DefaultVerdict,
35
+ EqualKArm,
36
+ EqualKVerdict,
37
+ ExecutorContext,
38
+ ExecutorRegistry,
39
+ Executor,
40
+ ExecutorFactory,
41
+ ExecutorResult,
42
+ Outcome,
43
+ Persona,
44
+ Runtime,
45
+ Spend,
46
+ SupervisedResult,
47
+ TrajectoryReport,
48
+ } from '@tangle-network/agent-runtime/loops'
49
+ import {
50
+ definePersona,
51
+ routerChatWithUsage,
52
+ equalKOnCost,
53
+ fanout,
54
+ InMemoryResultBlobStore,
55
+ InMemorySpawnJournal,
56
+ runPersonified,
57
+ trajectoryReport,
58
+ } from '@tangle-network/agent-runtime/loops'
59
+ import type { BenchmarkAdapter, BenchTask } from './benchmarks/types'
60
+
61
+ /** A fanout child's task: the prompt to solve with + the instance to grade against. The instance
62
+ * travels with the prompt so the solve-and-grade leaf can run `adapter.judge` without a closure
63
+ * over the per-task state (the registry closes only over the adapter + router config). */
64
+ export interface SolveTask {
65
+ readonly prompt: string
66
+ readonly instance: BenchTask
67
+ }
68
+
69
+ /** What `benchSolverRegistry` needs to build a solve-and-grade leaf. The router config is the
70
+ * cheapest leaf (one chat completion, no box); the adapter supplies the deployable judge. */
71
+ export interface BenchSolverOptions {
72
+ readonly adapter: BenchmarkAdapter
73
+ readonly routerBaseUrl: string
74
+ readonly routerKey: string
75
+ readonly model: string
76
+ /** Sampling temperature. >0 is required for the blind arm to be more than k identical samples
77
+ * (k copies at temperature 0 collapse to one answer — no compute control). Default 0.7. */
78
+ readonly temperature?: number
79
+ }
80
+
81
+ const fnv = (prefix: string, value: unknown): string => {
82
+ const str = (() => {
83
+ try {
84
+ return JSON.stringify(value) ?? String(value)
85
+ } catch {
86
+ return String(value)
87
+ }
88
+ })()
89
+ let h = 0x811c9dc5
90
+ for (let i = 0; i < str.length; i += 1) {
91
+ h ^= str.charCodeAt(i)
92
+ h = Math.imul(h, 0x01000193)
93
+ }
94
+ return `${prefix}:${(h >>> 0).toString(16).padStart(8, '0')}`
95
+ }
96
+
97
+ /** Extract the judged artifact from the model reply using the adapter's OWN deliverable parser
98
+ * (e.g. the last fenced ```json block for a transcript bench), so the gate leaf honors
99
+ * `benchmark = adapter owns its deliverable`. Falls back to the trimmed reply when the adapter
100
+ * defines no output parser (the research/QA case). */
101
+ function extractArtifact(adapter: BenchmarkAdapter, content: string): string {
102
+ if (!adapter.output) return content.trim()
103
+ const events = [{ type: 'agent', data: { finalText: content } }] as unknown as SandboxEvent[]
104
+ return adapter.output.parse(events)
105
+ }
106
+
107
+ /**
108
+ * A single solve-and-grade leaf executor. Solves the task with ONE router completion, extracts
109
+ * the deliverable via the adapter, then grades it with the benchmark's deterministic judge and
110
+ * surfaces that `BenchScore` as the child's `DefaultVerdict`. The verdict is what `fanout`'s
111
+ * deployable selector ranks on. Reports REAL token usage; a missing-usage provider records zero
112
+ * tokens but still one iteration (never a fabricated priced cost). Fail-loud: a router non-2xx or
113
+ * a judge throw rejects the leaf (the scope types it into a `down` settlement — never a silent 0).
114
+ */
115
+ export function benchSolveLeaf(opts: BenchSolverOptions, spec: AgentSpec, ctx: ExecutorContext): Executor<unknown> {
116
+ const controller = new AbortController()
117
+ const abortIfSignalled = () => {
118
+ if (ctx.signal.aborted) controller.abort()
119
+ }
120
+ abortIfSignalled()
121
+ if (!ctx.signal.aborted) ctx.signal.addEventListener('abort', abortIfSignalled, { once: true })
122
+
123
+ let artifact: ExecutorResult<unknown> | undefined
124
+
125
+ return {
126
+ runtime: 'bench-router' as Runtime,
127
+ async execute(task, signal): Promise<ExecutorResult<unknown>> {
128
+ const t = task as SolveTask
129
+ const system = spec.profile.prompt?.systemPrompt
130
+ const messages = [
131
+ ...(typeof system === 'string' && system.length > 0
132
+ ? [{ role: 'system', content: system }]
133
+ : []),
134
+ { role: 'user', content: t.prompt },
135
+ ]
136
+ const started = Date.now()
137
+ const linked = linkSignals(signal, controller.signal)
138
+ const chat = await routerChatWithUsage(
139
+ { routerBaseUrl: opts.routerBaseUrl, routerKey: opts.routerKey, model: opts.model },
140
+ messages,
141
+ { temperature: opts.temperature ?? 0.7, ...(linked ? { signal: linked } : {}) },
142
+ )
143
+ const candidate = extractArtifact(opts.adapter, chat.content)
144
+ const score = await opts.adapter.judge(t.instance, candidate)
145
+ const verdict: DefaultVerdict = {
146
+ valid: score.resolved,
147
+ score: score.score,
148
+ ...(score.detail ? { notes: score.detail } : {}),
149
+ }
150
+ const spent: Spend = {
151
+ iterations: 1,
152
+ tokens: chat.usage ? { input: chat.usage.input, output: chat.usage.output } : { input: 0, output: 0 },
153
+ usd: chat.costUsd ?? 0,
154
+ ms: Date.now() - started,
155
+ }
156
+ artifact = { outRef: fnv('bench', { id: t.instance.id, candidate }), out: candidate, verdict, spent }
157
+ return artifact
158
+ },
159
+ teardown(): Promise<{ destroyed: boolean }> {
160
+ controller.abort()
161
+ return Promise.resolve({ destroyed: true })
162
+ },
163
+ resultArtifact() {
164
+ if (!artifact) throw new Error('benchSolveLeaf: resultArtifact() read before execute()')
165
+ return artifact
166
+ },
167
+ }
168
+ }
169
+
170
+ /**
171
+ * A persona-supplied `ExecutorRegistry` whose `resolve` returns a FRESH solve-and-grade leaf per
172
+ * spawn — the documented "persona supplies a fully-built registry" path. A fresh instance per
173
+ * child is required because `fanout` spawns the children concurrently against ONE persona root
174
+ * spec; a shared BYO executor instance would race on its single result slot. `register` throws:
175
+ * this registry serves exactly one runtime (the bench solve leaf), by intent.
176
+ */
177
+ export function benchSolverRegistry(opts: BenchSolverOptions): ExecutorRegistry {
178
+ return {
179
+ register(): void {
180
+ throw new Error('benchSolverRegistry: register is unsupported (single bench-router runtime)')
181
+ },
182
+ resolve<Out>(spec: AgentSpec) {
183
+ const factory: ExecutorFactory<Out> = (s, ctx) =>
184
+ benchSolveLeaf(opts, s, ctx) as Executor<Out>
185
+ void spec
186
+ return { succeeded: true as const, value: factory }
187
+ },
188
+ }
189
+ }
190
+
191
+ /** Build the solver `Persona` from the developer's `AgentProfile` + a solve-and-grade registry.
192
+ * The deliverable type is the candidate text (`string`); `harness: null` is nominal — the
193
+ * supplied registry overrides resolution, so the root never falls through to the router/sandbox
194
+ * built-ins. */
195
+ export function defineSolverPersona(
196
+ profile: AgentProfile,
197
+ registry: ExecutorRegistry,
198
+ name = 'gate-solver',
199
+ ): Persona<string> {
200
+ const root: AgentSpec = { profile, harness: null }
201
+ return definePersona<string>({
202
+ name,
203
+ root,
204
+ directive: 'Produce the single best deliverable that the benchmark judge will accept.',
205
+ context: { role: 'benchmark solver' },
206
+ executors: { registry },
207
+ })
208
+ }
209
+
210
+ /** The blind/diverse `fanout` over k children. Each item is a strategy directive appended to the
211
+ * task prompt; the blind arm passes k empty strategies (k identical prompts = the compute
212
+ * control). No `synthesize` → the deployable `defaultSelectWinner` returns the best-graded child. */
213
+ function solveFanout(strategies: ReadonlyArray<string>, instance: BenchTask): CombinatorShape<unknown, string> {
214
+ return fanout<unknown, string, string>(strategies, {
215
+ itemTask: (strategy): SolveTask => ({
216
+ prompt: strategy.length > 0 ? `${instance.prompt}\n\n${strategy}` : instance.prompt,
217
+ instance,
218
+ }),
219
+ label: (_s, i) => `solve:${i}`,
220
+ })
221
+ }
222
+
223
+ export interface RunGateOptions {
224
+ readonly adapter: BenchmarkAdapter
225
+ /** The ONE specificity: who the solver is (prompt / model / tools). */
226
+ readonly profile: AgentProfile
227
+ /**
228
+ * The diverse arm's strategy directives. `k = strategies.length` fixes BOTH arms' child count
229
+ * (the blind arm runs k identical copies), so the two arms are equal-k by construction. Must be
230
+ * length >= 2 (a single child is not a fanout).
231
+ */
232
+ readonly strategies: ReadonlyArray<string>
233
+ readonly routerBaseUrl: string
234
+ readonly routerKey: string
235
+ readonly model: string
236
+ readonly temperature?: number
237
+ /** How many benchmark instances to run (the paired n). */
238
+ readonly n?: number
239
+ readonly ids?: string[]
240
+ readonly split?: string
241
+ /** Per-child token ceiling the conserved pool reserves. Default 60_000. */
242
+ readonly perChildTokens?: number
243
+ /** Per-child wall-clock deadline (ms) forwarded to the root budget. */
244
+ readonly deadlineMs?: number
245
+ /**
246
+ * Override the solve-and-grade registry (test seam — inject a deterministic stub so the gate
247
+ * plumbing runs offline). When omitted, `benchSolverRegistry` is built from the router config.
248
+ */
249
+ readonly solverRegistry?: ExecutorRegistry
250
+ }
251
+
252
+ /** One arm's aggregate over the n instances. `errored` = runs that ended `no-winner` for an
253
+ * infra reason (budget/abort) — excluded from the resolve denominator, like an infra-errored
254
+ * cell. A genuine all-children-down counts as not-resolved (a real failure, kept in n). */
255
+ export interface GateArmResult {
256
+ readonly label: string
257
+ readonly n: number
258
+ readonly resolved: number
259
+ readonly errored: number
260
+ /** resolved / (n - errored). */
261
+ readonly resolveRate: number
262
+ /** Mean of the SELECTED child's graded score (passes/total partial credit) over non-errored
263
+ * tasks — the middle-band signal a binary all-pass resolveRate hides. */
264
+ readonly meanScore: number
265
+ readonly totalSpend: Spend
266
+ /** First failure reason seen (blocked blockers / no-winner reason), when `errored > 0` —
267
+ * so a high error count is diagnosable, not a mute 0%. */
268
+ readonly sampleBlocker?: string
269
+ }
270
+
271
+ export interface GateReport {
272
+ readonly benchmark: string
273
+ readonly k: number
274
+ readonly n: number
275
+ /** Per-instance paired booleans — the input a paired-bootstrap / BH test consumes downstream. */
276
+ readonly perTask: ReadonlyArray<{ readonly id: string; readonly blind: boolean; readonly diverse: boolean }>
277
+ readonly arms: ReadonlyArray<GateArmResult>
278
+ /** diverse.resolveRate − blind.resolveRate, in percentage points (binary all-pass delta). */
279
+ readonly deltaPp: number
280
+ /** diverse.meanScore − blind.meanScore, in points (the graded middle-band delta — the more
281
+ * sensitive gate signal on multi-verifier domains). */
282
+ readonly deltaScorePp: number
283
+ /** Whether the two arms spent within tolerance on conserved cost — the equal-k proof. A `false`
284
+ * here means the delta is NOT at equal compute (a confound to report, never a win to publish). */
285
+ readonly equalK: EqualKVerdict
286
+ }
287
+
288
+ const zeroSpend = (): Spend => ({ iterations: 0, tokens: { input: 0, output: 0 }, usd: 0, ms: 0 })
289
+
290
+ function addSpend(a: Spend, b: Spend): Spend {
291
+ return {
292
+ iterations: a.iterations + b.iterations,
293
+ tokens: { input: a.tokens.input + b.tokens.input, output: a.tokens.output + b.tokens.output },
294
+ usd: a.usd + b.usd,
295
+ ms: a.ms + b.ms,
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Did the SELECTED candidate resolve? Reads the trajectory's `done` leaf nodes, replays the same
301
+ * best-valid-score, ties→earliest rule `defaultSelectWinner` used inside the fanout, and returns
302
+ * that node's `verdict.valid`. Reading the run's OWN evidence (the journaled per-child verdict)
303
+ * avoids a second judge pass over the deliverable — the deployable selector's chosen verdict IS
304
+ * the arm's outcome on this task.
305
+ */
306
+ function selectedOutcome(report: TrajectoryReport): { resolved: boolean; score: number } {
307
+ let best: { score: number; valid: boolean } | undefined
308
+ for (const node of report.nodes) {
309
+ if (node.status !== 'done' || !node.verdict) continue
310
+ const v = node.verdict
311
+ if (typeof v.score !== 'number') continue
312
+ if (best === undefined || (v.valid && !best.valid) || (v.valid === best.valid && v.score > best.score)) {
313
+ best = { score: v.score, valid: v.valid === true }
314
+ }
315
+ }
316
+ return { resolved: best?.valid === true, score: best?.score ?? 0 }
317
+ }
318
+
319
+ /**
320
+ * Run the diverse-vs-blind gate through the recursive atom over the adapter's tasks. For each
321
+ * instance, each arm runs a `fanout` of k children to a typed `SupervisedResult`; the winning
322
+ * child's deployable verdict decides resolution, the conserved pool guarantees equal k, and the
323
+ * trajectory ledger backs both the resolve metric and the cross-arm equal-k proof.
324
+ */
325
+ export async function runGate(opts: RunGateOptions): Promise<GateReport> {
326
+ if (opts.strategies.length < 2) {
327
+ throw new Error('runGate: need >= 2 strategies (k = strategies.length fixes both arms’ child count)')
328
+ }
329
+ const k = opts.strategies.length
330
+ await opts.adapter.preflight()
331
+ const tasks = await opts.adapter.loadTasks({
332
+ ...(opts.n !== undefined ? { limit: opts.n } : {}),
333
+ ...(opts.ids ? { ids: opts.ids } : {}),
334
+ ...(opts.split ? { split: opts.split } : {}),
335
+ })
336
+ if (tasks.length === 0) throw new Error('runGate: adapter.loadTasks returned no tasks')
337
+
338
+ const registry = opts.solverRegistry ?? benchSolverRegistry(opts)
339
+ const perChildTokens = opts.perChildTokens ?? 60_000
340
+ const perChild: Budget = {
341
+ maxIterations: 1,
342
+ maxTokens: perChildTokens,
343
+ ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}),
344
+ }
345
+ // The root pool must admit exactly k children (no synthesis/analyst on this flat surface).
346
+ const budget: Budget = {
347
+ maxIterations: k,
348
+ maxTokens: k * perChildTokens,
349
+ ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}),
350
+ }
351
+
352
+ const armDefs: ReadonlyArray<{ label: string; strategies: ReadonlyArray<string> }> = [
353
+ { label: 'blind', strategies: Array.from({ length: k }, () => '') },
354
+ { label: 'diverse', strategies: opts.strategies },
355
+ ]
356
+
357
+ const perTask: Array<{ id: string; blind: boolean; diverse: boolean }> = []
358
+ const acc = new Map<
359
+ string,
360
+ { resolved: number; scoreSum: number; errored: number; spend: Spend; sampleBlocker?: string }
361
+ >(armDefs.map((a) => [a.label, { resolved: 0, scoreSum: 0, errored: 0, spend: zeroSpend() }]))
362
+
363
+ for (const task of tasks) {
364
+ const row: { id: string; blind: boolean; diverse: boolean } = { id: task.id, blind: false, diverse: false }
365
+ for (const armDef of armDefs) {
366
+ const journal = new InMemorySpawnJournal()
367
+ const blobs = new InMemoryResultBlobStore()
368
+ const persona = defineSolverPersona(opts.profile, registry, `${armDef.label}-solver`)
369
+ const runId = `gate:${armDef.label}:${task.id}`
370
+ const result: SupervisedResult<Outcome<string>> = await runPersonified<unknown, string>({
371
+ persona,
372
+ shape: solveFanout(armDef.strategies, task),
373
+ task: undefined,
374
+ budget,
375
+ shapeBudget: { fanout: k, perChild },
376
+ runId,
377
+ journal,
378
+ blobs,
379
+ })
380
+ const report = await trajectoryReport(journal, blobs, runId, { withOutputs: true })
381
+ const entry = acc.get(armDef.label)!
382
+ entry.spend = addSpend(entry.spend, report.total)
383
+ // A run produces a GRADEABLE deliverable only when the shape returned `done`. A
384
+ // `winner` carrying a `blocked` Outcome (every child went down) or a `no-winner`
385
+ // means the arm produced NO candidate to grade on this task — that is an ERROR
386
+ // (excluded from the resolve denominator + surfaced), never a silent "not resolved",
387
+ // so a 0% that is really "everything failed" can't masquerade as a clean result.
388
+ const gradeable = result.kind === 'winner' && result.out.kind === 'done'
389
+ if (gradeable) {
390
+ const sel = selectedOutcome(report)
391
+ if (sel.resolved) entry.resolved += 1
392
+ entry.scoreSum += sel.score
393
+ if (armDef.label === 'blind') row.blind = sel.resolved
394
+ else row.diverse = sel.resolved
395
+ } else {
396
+ entry.errored += 1
397
+ if (entry.sampleBlocker === undefined) {
398
+ entry.sampleBlocker =
399
+ result.kind === 'winner' && result.out.kind === 'blocked'
400
+ ? result.out.blockers.slice(0, 2).join(' | ')
401
+ : `no-winner: ${(result as { reason?: string }).reason ?? 'unknown'}`
402
+ }
403
+ }
404
+ }
405
+ perTask.push(row)
406
+ }
407
+
408
+ const arms: GateArmResult[] = armDefs.map((a) => {
409
+ const e = acc.get(a.label)!
410
+ const denom = Math.max(1, tasks.length - e.errored)
411
+ return {
412
+ label: a.label,
413
+ n: tasks.length,
414
+ resolved: e.resolved,
415
+ errored: e.errored,
416
+ resolveRate: e.resolved / denom,
417
+ meanScore: e.scoreSum / denom,
418
+ totalSpend: e.spend,
419
+ ...(e.sampleBlocker !== undefined ? { sampleBlocker: e.sampleBlocker } : {}),
420
+ }
421
+ })
422
+
423
+ const equalKArms: EqualKArm[] = armDefs.map((a) => ({
424
+ label: a.label,
425
+ report: {
426
+ root: a.label,
427
+ nodes: [],
428
+ total: acc.get(a.label)!.spend,
429
+ statusCounts: { done: 0, failed: 0, cancelled: 0, pending: 0 },
430
+ },
431
+ }))
432
+ const equalK = equalKOnCost(equalKArms)
433
+
434
+ const blind = arms.find((a) => a.label === 'blind')!
435
+ const diverse = arms.find((a) => a.label === 'diverse')!
436
+ return {
437
+ benchmark: opts.adapter.name,
438
+ k,
439
+ n: tasks.length,
440
+ perTask,
441
+ arms,
442
+ deltaPp: (diverse.resolveRate - blind.resolveRate) * 100,
443
+ deltaScorePp: (diverse.meanScore - blind.meanScore) * 100,
444
+ equalK,
445
+ }
446
+ }
447
+
448
+ /** Link two abort signals into one that fires when either does; `undefined` when neither is set. */
449
+ function linkSignals(a: AbortSignal, b: AbortSignal): AbortSignal | undefined {
450
+ if (a.aborted || b.aborted) {
451
+ const c = new AbortController()
452
+ c.abort()
453
+ return c.signal
454
+ }
455
+ const c = new AbortController()
456
+ const onAbort = () => c.abort()
457
+ a.addEventListener('abort', onAbort, { once: true })
458
+ b.addEventListener('abort', onAbort, { once: true })
459
+ return c.signal
460
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The eval certifier — the runtime's verdict on a generated candidate. The
3
+ * authoring model is NEVER trusted: both gates re-run from scratch here.
4
+ *
5
+ * gate 1 (grounding) — fresh temp workspace → run `setup` (installs the
6
+ * pinned target) → write `reference.files` → run
7
+ * `reference.cmd` → must exit 0 (+ stdoutContains).
8
+ * gate 2 (discrimination) — the task prompt against a NO-TOOLS chat model;
9
+ * the oracle must FAIL it. A task the model already
10
+ * solves from memory measures nothing.
11
+ *
12
+ * Local-first by design: needs only node + a shell + (gate 2) any
13
+ * OpenAI-compatible endpoint — so any stack or CI can run it with zero
14
+ * platform dependency. Trust model: setup/reference run REAL shell commands;
15
+ * certify only candidates you authored or reviewed, or run inside a sandbox.
16
+ *
17
+ * CLI: tsx certify.ts <candidate.json> → verdict JSON on stdout
18
+ * exit 0 admitted · 1 rejected · 2 malformed/infra
19
+ * Env: EVAL_GATE_BASE_URL (default https://router.tangle.tools/v1)
20
+ * EVAL_GATE_API_KEY (default $TANGLE_API_KEY)
21
+ * EVAL_GATE_MODEL (default gpt-4.1)
22
+ */
23
+ import { execSync } from 'node:child_process'
24
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
25
+ import { tmpdir } from 'node:os'
26
+ import { dirname, join, resolve } from 'node:path'
27
+ import { routerChatWithUsage } from '@tangle-network/agent-runtime/loops'
28
+ import { scoreTask, taskToPrompt } from '../search-bench/tasks'
29
+ import { type EvalCertification, type GeneratedEval, generatedEvalSchemaVersion, parseCandidate } from './schema'
30
+
31
+ const certifierId = 'agent-runtime/generate-eval@1'
32
+
33
+ export interface GateDiagnostics {
34
+ admitted: boolean
35
+ grounding: { passed: boolean; detail: string }
36
+ discrimination: { passed: boolean; detail: string }
37
+ certification?: EvalCertification
38
+ }
39
+
40
+ export interface CertifyOpts {
41
+ gateBaseUrl?: string
42
+ gateApiKey?: string
43
+ gateModel?: string
44
+ /** Per-command timeout for setup/reference execution. Default 180s. */
45
+ cmdTimeoutMs?: number
46
+ /** Keep the workspace on failure for debugging (path goes in the detail). */
47
+ keepWorkspaceOnFailure?: boolean
48
+ }
49
+
50
+ /** First `pkg@x.y[.z]` token in setup — the declared pin, recorded as provenance. */
51
+ function declaredTarget(setup: string[]): string {
52
+ for (const cmd of setup) {
53
+ const m = cmd.match(/[\w@/.-]+@\d+[\w.-]*/)
54
+ if (m) return m[0]
55
+ }
56
+ return '(unpinned — setup declared no pkg@version)'
57
+ }
58
+
59
+ function runCmd(cmd: string, cwd: string, timeoutMs: number): { ok: boolean; output: string } {
60
+ try {
61
+ const out = execSync(cmd, { cwd, timeout: timeoutMs, stdio: 'pipe', encoding: 'utf-8' })
62
+ return { ok: true, output: out }
63
+ } catch (err) {
64
+ const e = err as { stdout?: string; stderr?: string; message?: string }
65
+ return { ok: false, output: `${e.stdout ?? ''}\n${e.stderr ?? ''}\n${e.message ?? ''}`.trim() }
66
+ }
67
+ }
68
+
69
+ /** Gate 1: re-execute the reference in a clean workspace. The detail string is
70
+ * the repair signal — it feeds back to the authoring loop as the steer. */
71
+ export function groundingGate(candidate: GeneratedEval, opts: CertifyOpts = {}): { passed: boolean; detail: string } {
72
+ const timeout = opts.cmdTimeoutMs ?? 180_000
73
+ const ws = mkdtempSync(join(tmpdir(), 'eval-certify-'))
74
+ let failDetail: string | null = null
75
+ try {
76
+ for (const cmd of candidate.setup) {
77
+ const r = runCmd(cmd, ws, timeout)
78
+ if (!r.ok) {
79
+ failDetail = `setup failed: \`${cmd}\`\n${r.output.slice(-1500)}`
80
+ return { passed: false, detail: failDetail }
81
+ }
82
+ }
83
+ for (const [path, content] of Object.entries(candidate.reference.files)) {
84
+ const abs = resolve(ws, path)
85
+ if (!abs.startsWith(ws)) {
86
+ failDetail = `reference file escapes workspace: ${path}`
87
+ return { passed: false, detail: failDetail }
88
+ }
89
+ mkdirSync(dirname(abs), { recursive: true })
90
+ writeFileSync(abs, content)
91
+ }
92
+ const r = runCmd(candidate.reference.cmd, ws, timeout)
93
+ if (!r.ok) {
94
+ failDetail = `reference cmd failed: \`${candidate.reference.cmd}\`\n${r.output.slice(-2000)}`
95
+ return { passed: false, detail: failDetail }
96
+ }
97
+ for (const must of candidate.reference.stdoutContains ?? []) {
98
+ if (!r.output.includes(must)) {
99
+ failDetail = `reference ran but stdout missing required "${must}"\nstdout tail:\n${r.output.slice(-800)}`
100
+ return { passed: false, detail: failDetail }
101
+ }
102
+ }
103
+ return { passed: true, detail: `reference passed against ${declaredTarget(candidate.setup)}` }
104
+ } finally {
105
+ if (failDetail && opts.keepWorkspaceOnFailure) {
106
+ // workspace intentionally retained; surface where.
107
+ // eslint-disable-next-line no-console
108
+ console.error(`workspace kept for debugging: ${ws}`)
109
+ } else {
110
+ rmSync(ws, { recursive: true, force: true })
111
+ }
112
+ }
113
+ }
114
+
115
+ /** Gate 2: the no-tools baseline must FAIL the oracle. */
116
+ export async function discriminationGate(
117
+ candidate: GeneratedEval,
118
+ opts: CertifyOpts = {},
119
+ ): Promise<{ passed: boolean; detail: string }> {
120
+ const baseUrl = opts.gateBaseUrl ?? process.env.EVAL_GATE_BASE_URL ?? 'https://router.tangle.tools/v1'
121
+ const apiKey = opts.gateApiKey ?? process.env.EVAL_GATE_API_KEY ?? process.env.TANGLE_API_KEY
122
+ const model = opts.gateModel ?? process.env.EVAL_GATE_MODEL ?? 'deepseek-v4-flash'
123
+ if (!apiKey) throw new Error('discrimination gate needs EVAL_GATE_API_KEY (or TANGLE_API_KEY)')
124
+ const res = await routerChatWithUsage({ routerBaseUrl: baseUrl, routerKey: apiKey, model }, [
125
+ { role: 'user', content: taskToPrompt(candidate) },
126
+ ])
127
+ const { score, reasons } = scoreTask(candidate, res.content)
128
+ return score === 0
129
+ ? { passed: true, detail: `parametric ${model} failed as required (${reasons.join('; ')})` }
130
+ : { passed: false, detail: `parametric ${model} SOLVED the task from memory — not search-discriminating` }
131
+ }
132
+
133
+ export async function certifyEval(candidate: GeneratedEval, opts: CertifyOpts = {}): Promise<GateDiagnostics> {
134
+ const grounding = groundingGate(candidate, opts)
135
+ // Run discrimination even when grounding fails — the authoring loop repairs
136
+ // faster when it sees both gate verdicts at once.
137
+ const discrimination = await discriminationGate(candidate, opts)
138
+ const admitted = grounding.passed && discrimination.passed
139
+ const out: GateDiagnostics = { admitted, grounding, discrimination }
140
+ if (admitted) {
141
+ out.certification = {
142
+ schemaVersion: generatedEvalSchemaVersion,
143
+ groundingPassed: true,
144
+ parametricFailed: true,
145
+ parametricModel: opts.gateModel ?? process.env.EVAL_GATE_MODEL ?? 'deepseek-v4-flash',
146
+ resolvedTarget: declaredTarget(candidate.setup),
147
+ certifiedAt: new Date().toISOString(),
148
+ certifier: certifierId,
149
+ }
150
+ }
151
+ return out
152
+ }
153
+
154
+ // ── CLI ──────────────────────────────────────────────────────────────────────
155
+ async function main(): Promise<void> {
156
+ const path = process.argv[2]
157
+ if (!path) {
158
+ console.error('usage: certify.ts <candidate.json>')
159
+ process.exit(2)
160
+ }
161
+ let candidate: GeneratedEval
162
+ try {
163
+ candidate = parseCandidate(readFileSync(path, 'utf-8'))
164
+ } catch (e) {
165
+ console.error(String(e))
166
+ process.exit(2)
167
+ }
168
+ const verdict = await certifyEval(candidate, { keepWorkspaceOnFailure: !!process.env.KEEP_WS })
169
+ console.log(JSON.stringify(verdict, null, 2))
170
+ process.exit(verdict.admitted ? 0 : 1)
171
+ }
172
+
173
+ if (import.meta.url === `file://${process.argv[1]}`) {
174
+ main().catch((e) => {
175
+ console.error(e)
176
+ process.exit(2)
177
+ })
178
+ }