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