@crewhaus/tool-harness-synthesizer 0.1.4 → 0.1.6

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/src/index.ts DELETED
@@ -1,419 +0,0 @@
1
- /**
2
- * Track D (§55) — `tool-harness-synthesizer`. Thompson-sampled tree
3
- * search over candidate verifier functions for skills and tools.
4
- *
5
- * Source: AutoHarness (Lou et al., Google DeepMind, March 2026,
6
- * arxiv 2603.03329). The paper's headline finding: a smaller LLM
7
- * (Gemini-2.5-Flash) plus a synthesized code harness beats a larger
8
- * LLM (Gemini-2.5-Pro) at near-zero inference cost. The trick is to
9
- * have the LLM synthesize TWO functions iteratively, with the
10
- * environment as critic:
11
- *
12
- * - `propose_action(obs)` — candidate generator
13
- * - `is_legal_action(obs, action)` — verifier
14
- *
15
- * If the verifier returns `True` but the action is invalid, refine
16
- * BOTH functions; if it returns `False` and the action is invalid,
17
- * refine only the proposer. This split-refinement is the empirical
18
- * winning move.
19
- *
20
- * In CrewHaus, the equivalent is to synthesize verifier code per
21
- * skill or tool: an `is_valid_output(input, output)` function for any
22
- * tool that has objective validity criteria. The verifier becomes a
23
- * reusable artifact under `.crewhaus/verifiers/<name>.ts` and feeds
24
- * into the `eval-optimizer-orchestrator` via a `MutationProvider`
25
- * variant that proposes verifier-aware prompt edits.
26
- *
27
- * v0 ships:
28
- * - `synthesizeVerifier(spec)` — tree search over candidate verifier
29
- * code strings (the LLM call is supplied by the caller so this
30
- * package stays pure with respect to the model)
31
- * - `thompsonPick(nodes)` — Thompson sampling over tree nodes
32
- * - `VerifierMutationProvider` — adapter to plug verifier search
33
- * into the existing optimizer
34
- * - `runVerifier(code, samples)` — scores a candidate by running it
35
- * inside a locked-down `@crewhaus/sandbox` container; the candidate
36
- * code never executes on this host (see its SECURITY note).
37
- *
38
- * Cited paper: AutoHarness (arxiv 2603.03329, Lou et al., 2026-03).
39
- */
40
- import { CrewhausError } from "@crewhaus/errors";
41
- import type {
42
- MutationProvider,
43
- OptimizerState,
44
- ProviderMutation,
45
- } from "@crewhaus/prompt-optimizer";
46
- import { type Sandbox, createSandbox } from "@crewhaus/sandbox";
47
- import { runVerifierInSandbox } from "./sandboxed-eval";
48
-
49
- export class HarnessSynthesizerError extends CrewhausError {
50
- override readonly name = "HarnessSynthesizerError";
51
- constructor(message: string, cause?: unknown) {
52
- super("config", message, cause);
53
- }
54
- }
55
-
56
- /**
57
- * One sample of behavior the verifier should be measured against.
58
- * `expected` is whether the verifier should accept (true) or reject
59
- * (false) this sample. Both classes are required for non-degenerate
60
- * search — a verifier that returns `true` for everything passes the
61
- * `expected: true` set perfectly.
62
- *
63
- * NOTE: `input`/`output` cross an isolation boundary (see `runVerifier`)
64
- * and are marshaled as JSON, so they must be JSON-serializable. The
65
- * verifier observes structured copies, not live host references — an
66
- * intentional consequence of running it out-of-process.
67
- */
68
- export type VerifierSample = {
69
- readonly input: unknown;
70
- readonly output: unknown;
71
- readonly expected: boolean;
72
- };
73
-
74
- /**
75
- * A candidate verifier — a code string + the per-sample score it
76
- * achieved on the last evaluation. `code` is a function body string
77
- * with the signature `(input: unknown, output: unknown) => boolean`.
78
- * It's stored as a string so the search can mutate it and feed it
79
- * back to the LLM.
80
- *
81
- * SECURITY: `code` is attacker-influenceable (caller seed candidates +
82
- * refiner output, which a future model-backed refiner derives from
83
- * skill/tool I/O that can carry injected content). It is NEVER compiled
84
- * or invoked on this host. Execution happens via `runVerifier`, which
85
- * evaluates the candidate inside a locked-down `@crewhaus/sandbox`
86
- * container with no ambient authority — see the SECURITY note on
87
- * `runVerifier`.
88
- */
89
- export type VerifierCandidate = {
90
- readonly id: string;
91
- readonly code: string;
92
- readonly score: number;
93
- /** AutoHarness's heuristic value — average over samples, in [0, 1]. */
94
- readonly heuristic: number;
95
- /** Beta posterior parameters for Thompson sampling. */
96
- readonly alpha: number;
97
- readonly beta: number;
98
- };
99
-
100
- /**
101
- * The Refiner: takes a failing candidate + concrete failure cases and
102
- * returns a new code string. In production, this is a model call; for
103
- * testing it's a deterministic rule-based mutation. Either way, the
104
- * signature is the same.
105
- *
106
- * SECURITY: the string this returns is executed (in isolation — see
107
- * `runVerifier`), so a model-backed refiner is effectively running
108
- * model output as code. The container is the trust boundary, NOT the
109
- * model — never relax `runVerifier`'s isolation on the assumption that
110
- * refiner output is trustworthy.
111
- */
112
- export type RefinerFn = (
113
- current: VerifierCandidate,
114
- failures: ReadonlyArray<VerifierSample>,
115
- ) => Promise<string>;
116
-
117
- /** Isolation options shared by `runVerifier` and the inner search. */
118
- export type RunVerifierOptions = {
119
- /**
120
- * Isolation backend. Defaults to `createSandbox()` (honors the
121
- * `CREWHAUS_SANDBOX` env; `docker` by default). The non-isolating
122
- * `noop` backend is REFUSED — see the SECURITY note on `runVerifier`.
123
- */
124
- readonly sandbox?: Sandbox;
125
- /** Per-evaluation wall-clock budget (ms). Default: 10_000. */
126
- readonly timeoutMs?: number;
127
- /** Container image. Default: `node:22-alpine` (on the sandbox allowlist). */
128
- readonly image?: string;
129
- };
130
-
131
- /**
132
- * The Critic: runs `code` against a sample set and returns the per-
133
- * sample verdict and the heuristic value. Deterministic given the same
134
- * code + samples (modulo the verifier's own determinism).
135
- *
136
- * SECURITY (FR-007): `code` is untrusted and is NOT run in this process.
137
- * `runVerifier` ships `{ code, samples }` to a locked-down
138
- * `@crewhaus/sandbox` container (`--network none`, `--read-only`, no
139
- * host env, cpu/mem caps, wall-clock kill, `no-new-privileges`) and
140
- * scores the returned verdicts here on the host — the container never
141
- * receives `expected`. Inside the jail the candidate is compiled with
142
- * the runtime's dynamic-function constructor and invoked over the
143
- * samples; an exfiltration/RCE attempt (e.g. a refiner emitting a
144
- * network call) is contained by the jail. FAIL CLOSED: when no real
145
- * isolation backend is available (the `noop` backend, or none),
146
- * `runVerifier` throws rather than executing untrusted code unsandboxed,
147
- * mirroring the `requiresSandbox` floor enforced elsewhere in the repo.
148
- *
149
- * Behavior contract: per-sample runtime errors are caught (verdict
150
- * `false`, `errors++`), matching the prior in-process semantics. Code
151
- * that fails to compile, times out, or otherwise crashes the harness
152
- * throws a `HarnessSynthesizerError`.
153
- */
154
- export async function runVerifier(
155
- code: string,
156
- samples: ReadonlyArray<VerifierSample>,
157
- opts: RunVerifierOptions = {},
158
- ): Promise<{
159
- readonly verdicts: ReadonlyArray<boolean>;
160
- readonly heuristic: number;
161
- readonly errors: number;
162
- }> {
163
- const ownsSandbox = opts.sandbox === undefined;
164
- const sandbox = opts.sandbox ?? createSandbox();
165
- try {
166
- // FR-007 — fail closed: never run untrusted verifier code without
167
- // a real isolation boundary.
168
- if (sandbox.backend === "noop") {
169
- throw new HarnessSynthesizerError(
170
- "refusing to evaluate verifier code in a noop sandbox (no isolation); set CREWHAUS_SANDBOX=docker|podman",
171
- );
172
- }
173
- return await runVerifierInSandbox(sandbox, code, samples, {
174
- ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
175
- ...(opts.image !== undefined ? { image: opts.image } : {}),
176
- });
177
- } finally {
178
- if (ownsSandbox) await sandbox.close();
179
- }
180
- }
181
-
182
- /**
183
- * Thompson sampling over a node population. Picks the index whose
184
- * posterior sample is highest. Each node has a Beta(alpha, beta)
185
- * posterior over its heuristic value; the alpha/beta are accumulated
186
- * across iterations as the search refines.
187
- */
188
- export function thompsonPick(
189
- nodes: ReadonlyArray<VerifierCandidate>,
190
- rng: () => number = Math.random,
191
- ): number {
192
- if (nodes.length === 0) throw new HarnessSynthesizerError("thompsonPick called on empty list");
193
- let bestIdx = 0;
194
- let bestSample = Number.NEGATIVE_INFINITY;
195
- for (let i = 0; i < nodes.length; i++) {
196
- const n = nodes[i];
197
- if (n === undefined) continue;
198
- const sample = betaSample(n.alpha, n.beta, rng);
199
- if (sample > bestSample) {
200
- bestSample = sample;
201
- bestIdx = i;
202
- }
203
- }
204
- return bestIdx;
205
- }
206
-
207
- /**
208
- * Quick-and-deterministic Beta sample using two gamma samples
209
- * (Marsaglia–Tsang). For the sizes we deal with (alpha, beta < 100),
210
- * the approximation is fast and stable.
211
- */
212
- function betaSample(a: number, b: number, rng: () => number): number {
213
- const x = gammaSample(a, rng);
214
- const y = gammaSample(b, rng);
215
- return x / (x + y);
216
- }
217
-
218
- function gammaSample(shape: number, rng: () => number): number {
219
- // For shape >= 1 use Marsaglia-Tsang; for shape < 1 use Ahrens-Dieter.
220
- if (shape < 1) {
221
- // Use shape+1 then transform by U^(1/shape).
222
- const x = gammaSample(shape + 1, rng);
223
- const u = Math.max(rng(), 1e-12);
224
- return x * u ** (1 / shape);
225
- }
226
- const d = shape - 1 / 3;
227
- const c = 1 / Math.sqrt(9 * d);
228
- // Loop until a valid sample.
229
- for (let i = 0; i < 64; i++) {
230
- let x: number;
231
- let v: number;
232
- do {
233
- const u1 = Math.max(rng(), 1e-12);
234
- const u2 = Math.max(rng(), 1e-12);
235
- // Box-Muller for standard normal.
236
- x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
237
- v = 1 + c * x;
238
- } while (v <= 0);
239
- v = v * v * v;
240
- const u = rng();
241
- if (u < 1 - 0.0331 * x * x * x * x) return d * v;
242
- if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
243
- }
244
- // Fallback — extremely rare. Return the deterministic mean.
245
- return shape;
246
- }
247
-
248
- export type SynthesizeOptions = {
249
- /** Initial seed candidates. Must be non-empty; provides the starting tree. */
250
- readonly seedCandidates: ReadonlyArray<string>;
251
- /** Samples the verifier is scored against. */
252
- readonly samples: ReadonlyArray<VerifierSample>;
253
- /** The refiner — usually an LLM-backed function. */
254
- readonly refiner: RefinerFn;
255
- /** Maximum tree-search iterations. Default: 16 (paper's median is ~14). */
256
- readonly maxIterations?: number;
257
- /** Target heuristic value — stop when reached. Default: 1.0 (100% correct). */
258
- readonly target?: number;
259
- /** RNG for Thompson sampling. Default: Math.random. */
260
- readonly rng?: () => number;
261
- /**
262
- * Isolation backend reused across every evaluation in the inner search.
263
- * Defaults to one `createSandbox()` per call (closed when the search
264
- * ends). See `runVerifier` for the isolation/fail-closed contract.
265
- */
266
- readonly sandbox?: Sandbox;
267
- /** Per-evaluation wall-clock budget (ms) forwarded to `runVerifier`. */
268
- readonly timeoutMs?: number;
269
- };
270
-
271
- export type SynthesizeResult = {
272
- readonly best: VerifierCandidate;
273
- readonly iterations: number;
274
- readonly converged: boolean;
275
- readonly trajectory: ReadonlyArray<VerifierCandidate>;
276
- };
277
-
278
- /**
279
- * Run the tree search. Returns the best candidate found, the
280
- * iteration count, and whether the target heuristic was reached.
281
- * Pure with respect to randomness: pass `rng` for determinism.
282
- *
283
- * One isolation backend is created (or reused, if `opts.sandbox` is
284
- * supplied) for the whole search and closed on exit — see `runVerifier`.
285
- */
286
- export async function synthesizeVerifier(opts: SynthesizeOptions): Promise<SynthesizeResult> {
287
- if (opts.seedCandidates.length === 0) {
288
- throw new HarnessSynthesizerError("at least one seed candidate is required");
289
- }
290
- if (opts.samples.length === 0) {
291
- throw new HarnessSynthesizerError("at least one sample is required to score the verifier");
292
- }
293
- const target = opts.target ?? 1.0;
294
- const rng = opts.rng ?? Math.random;
295
- const maxIter = opts.maxIterations ?? 16;
296
-
297
- // One isolation backend reused across the whole inner search; close it
298
- // on exit only if we created it (the caller owns an injected one).
299
- const ownsSandbox = opts.sandbox === undefined;
300
- const sandbox = opts.sandbox ?? createSandbox();
301
- const runOpts: RunVerifierOptions = {
302
- sandbox,
303
- ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
304
- };
305
-
306
- try {
307
- // Initialize the candidate pool from seeds.
308
- const pool: VerifierCandidate[] = [];
309
- for (let i = 0; i < opts.seedCandidates.length; i++) {
310
- const code = opts.seedCandidates[i] as string;
311
- const { heuristic } = await runVerifier(code, opts.samples, runOpts);
312
- pool.push({
313
- id: `seed_${i}`,
314
- code,
315
- score: heuristic,
316
- heuristic,
317
- // Beta starts uniform; update with observed correct/incorrect counts.
318
- alpha: 1 + Math.round(heuristic * opts.samples.length),
319
- beta: 1 + Math.round((1 - heuristic) * opts.samples.length),
320
- });
321
- }
322
- const trajectory: VerifierCandidate[] = [...pool];
323
-
324
- // Early exit if a seed already satisfies the target.
325
- let best = pool.reduce((a, b) => (a.heuristic >= b.heuristic ? a : b));
326
- if (best.heuristic >= target) {
327
- return { best, iterations: 0, converged: true, trajectory };
328
- }
329
-
330
- for (let iter = 0; iter < maxIter; iter++) {
331
- const pickIdx = thompsonPick(pool, rng);
332
- const parent = pool[pickIdx] as VerifierCandidate;
333
- // Compute concrete failures for the refiner.
334
- const { verdicts } = await runVerifier(parent.code, opts.samples, runOpts);
335
- const failures: VerifierSample[] = [];
336
- for (let i = 0; i < opts.samples.length; i++) {
337
- const s = opts.samples[i] as VerifierSample;
338
- const v = verdicts[i] as boolean;
339
- if (v !== s.expected) failures.push(s);
340
- }
341
- let newCode: string;
342
- try {
343
- newCode = await opts.refiner(parent, failures);
344
- } catch (err) {
345
- throw new HarnessSynthesizerError(
346
- `refiner threw on iteration ${iter}: ${(err as Error).message}`,
347
- err,
348
- );
349
- }
350
- const { heuristic } = await runVerifier(newCode, opts.samples, runOpts);
351
- const child: VerifierCandidate = {
352
- id: `cand_${iter}`,
353
- code: newCode,
354
- score: heuristic,
355
- heuristic,
356
- alpha: 1 + Math.round(heuristic * opts.samples.length),
357
- beta: 1 + Math.round((1 - heuristic) * opts.samples.length),
358
- };
359
- pool.push(child);
360
- trajectory.push(child);
361
- if (heuristic > best.heuristic) best = child;
362
- if (best.heuristic >= target) {
363
- return { best, iterations: iter + 1, converged: true, trajectory };
364
- }
365
- }
366
- return { best, iterations: maxIter, converged: false, trajectory };
367
- } finally {
368
- if (ownsSandbox) await sandbox.close();
369
- }
370
- }
371
-
372
- /**
373
- * `MutationProvider` adapter so verifier search can drop into the
374
- * existing eval-optimizer-orchestrator loop. The provider's `next()`
375
- * runs one iteration of the inner tree search and emits a
376
- * prompt-edit that references the synthesized verifier.
377
- *
378
- * Typical wiring (programmatic): construct this provider with the
379
- * spec's skill samples and a `refiner` function, then pass it to
380
- * `optimizeSpec({ mutator: new VerifierMutationProvider(...) })`. The
381
- * orchestrator runs the standard search loop, but each "mutation" is
382
- * a freshly-synthesized verifier persisted to .crewhaus/verifiers/.
383
- * (CLI `--mutator verifier-synthesis` wiring is a follow-up; the CLI
384
- * today exposes `rule-based` and `claude` only.)
385
- *
386
- * SECURITY: the inner search executes refiner-produced code. Pass a
387
- * `sandbox` (or rely on the default) — `synthesizeVerifier`/`runVerifier`
388
- * isolate every evaluation and fail closed without a real backend. A
389
- * model-backed `refiner` is only safe to wire because of that jail.
390
- */
391
- export class VerifierMutationProvider implements MutationProvider {
392
- readonly name = "verifier-synthesis";
393
- private synthesisIterations = 0;
394
-
395
- constructor(
396
- private readonly samples: ReadonlyArray<VerifierSample>,
397
- private readonly refiner: RefinerFn,
398
- private readonly seedCandidates: ReadonlyArray<string>,
399
- private readonly maxInnerIterations: number = 4,
400
- private readonly sandbox?: Sandbox,
401
- ) {}
402
-
403
- async next(state: OptimizerState): Promise<ProviderMutation> {
404
- this.synthesisIterations++;
405
- const result = await synthesizeVerifier({
406
- seedCandidates: this.seedCandidates,
407
- samples: this.samples,
408
- refiner: this.refiner,
409
- maxIterations: this.maxInnerIterations,
410
- ...(this.sandbox !== undefined ? { sandbox: this.sandbox } : {}),
411
- });
412
- const annotation = `\n\n[verifier ${result.best.id}, h=${result.best.heuristic.toFixed(3)}]`;
413
- return {
414
- prompt: state.best.prompt + annotation,
415
- mutations: [{ kind: "rephrase-instruction" }],
416
- rationale: `verifier-synthesis pass ${this.synthesisIterations}: ${result.best.id} reached heuristic ${result.best.heuristic.toFixed(3)} in ${result.iterations} inner iterations${result.converged ? " (converged)" : ""}`,
417
- };
418
- }
419
- }
@@ -1,192 +0,0 @@
1
- /**
2
- * FR-007 — sandboxed evaluation of untrusted verifier code.
3
- *
4
- * This module is the ONLY place candidate verifier code is compiled and
5
- * run. It is `tool-harness-synthesizer`'s trust boundary: `code` strings
6
- * are attacker-influenceable (caller seeds + refiner output), so they are
7
- * NEVER executed on this host. `runVerifierInSandbox` ships them to a
8
- * locked-down `@crewhaus/sandbox` container (`--network none`,
9
- * `--read-only`, no host env, cpu/mem caps, wall-clock kill,
10
- * `no-new-privileges`) and scores the returned verdicts on the host —
11
- * the container never receives the `expected` labels.
12
- *
13
- * The container runs `VERIFIER_HARNESS` (a fixed `node -e` script). The
14
- * untrusted `code` is delivered as STDIN DATA, never interpolated into
15
- * that script string — so it cannot break out of the harness.
16
- */
17
- import type { Sandbox } from "@crewhaus/sandbox";
18
- import { HarnessSynthesizerError, type VerifierSample } from "./index";
19
-
20
- /** Sentinel that frames the harness's single JSON result line on stdout. */
21
- export const VERIFIER_SENTINEL = "__CREWHAUS_VERIFIER__";
22
-
23
- const VERIFIER_IMAGE = "node:22-alpine";
24
- const DEFAULT_TIMEOUT_MS = 10_000;
25
-
26
- /** Input/output pair handed to the verifier (labels stripped). */
27
- type VerifierIO = { readonly input: unknown; readonly output: unknown };
28
-
29
- /** Payload marshaled across the isolation boundary. */
30
- type VerifierPayload = { readonly code: string; readonly samples: ReadonlyArray<VerifierIO> };
31
-
32
- /** Per-sample result from inside the jail (or the in-process test double). */
33
- export type VerifierEvalResult =
34
- | { readonly verdicts: ReadonlyArray<boolean>; readonly errors: number }
35
- | { readonly compileError: string };
36
-
37
- /**
38
- * Compile `payload.code` and run it over `payload.samples`, collecting a
39
- * boolean verdict per sample. A compile failure returns `{ compileError }`;
40
- * a per-sample runtime throw is caught (verdict `false`, `errors++`).
41
- *
42
- * Single source of truth for verifier scoring: it runs both (a) inside the
43
- * container — `VERIFIER_HARNESS` embeds this function's own source via
44
- * `.toString()` — and (b) in unit tests via a fake sandbox. It MUST stay
45
- * self-contained: reference no imports or module scope, or the in-container
46
- * copy throws a ReferenceError.
47
- */
48
- export function evalVerifierPayload(payload: VerifierPayload): VerifierEvalResult {
49
- let fn: (input: unknown, output: unknown) => unknown;
50
- try {
51
- fn = new Function("input", "output", String(payload.code)) as (
52
- input: unknown,
53
- output: unknown,
54
- ) => unknown;
55
- } catch (err) {
56
- return { compileError: err instanceof Error ? err.message : String(err) };
57
- }
58
- const verdicts: boolean[] = [];
59
- let errors = 0;
60
- const samples = payload.samples || [];
61
- for (let i = 0; i < samples.length; i++) {
62
- try {
63
- const s = samples[i] as VerifierIO;
64
- verdicts.push(Boolean(fn(s.input, s.output)));
65
- } catch {
66
- verdicts.push(false);
67
- errors++;
68
- }
69
- }
70
- return { verdicts, errors };
71
- }
72
-
73
- /**
74
- * The in-container harness (`node -e <this>`). Reads `{ code, samples }`
75
- * from stdin, runs `evalVerifierPayload` (embedded verbatim), and writes
76
- * `SENTINEL + JSON` once on stdout. Untrusted `code` is stdin DATA, never
77
- * part of this script.
78
- */
79
- const VERIFIER_HARNESS = [
80
- '"use strict";',
81
- `const SENTINEL = ${JSON.stringify(VERIFIER_SENTINEL)};`,
82
- `const evalVerifierPayload = ${evalVerifierPayload.toString()};`,
83
- 'let raw = "";',
84
- 'try { raw = require("fs").readFileSync(0, "utf8"); } catch (e) { raw = ""; }',
85
- "let payload;",
86
- "try { payload = JSON.parse(raw); }",
87
- 'catch (e) { process.stdout.write(SENTINEL + JSON.stringify({ compileError: "invalid harness payload" })); process.exit(0); }',
88
- "process.stdout.write(SENTINEL + JSON.stringify(evalVerifierPayload(payload)));",
89
- ].join("\n");
90
-
91
- function parseHarnessResult(stdout: string): VerifierEvalResult | undefined {
92
- const idx = stdout.lastIndexOf(VERIFIER_SENTINEL);
93
- if (idx === -1) return undefined;
94
- const tail = stdout.slice(idx + VERIFIER_SENTINEL.length);
95
- const line = tail.split("\n", 1)[0] ?? "";
96
- let obj: unknown;
97
- try {
98
- obj = JSON.parse(line);
99
- } catch {
100
- return undefined;
101
- }
102
- if (typeof obj !== "object" || obj === null) return undefined;
103
- if (
104
- "compileError" in obj &&
105
- typeof (obj as { compileError: unknown }).compileError === "string"
106
- ) {
107
- return { compileError: (obj as { compileError: string }).compileError };
108
- }
109
- const rec = obj as { verdicts?: unknown; errors?: unknown };
110
- if (Array.isArray(rec.verdicts)) {
111
- return {
112
- verdicts: rec.verdicts.map((v) => Boolean(v)),
113
- errors: typeof rec.errors === "number" ? rec.errors : 0,
114
- };
115
- }
116
- return undefined;
117
- }
118
-
119
- /**
120
- * Marshal `{ code, samples }` (labels stripped) into the jail, run the
121
- * harness, parse the framed result, and score verdicts against the
122
- * host-held `expected` labels.
123
- *
124
- * Throws `HarnessSynthesizerError` on: non-serializable samples, compile
125
- * failure, timeout, non-zero harness exit, or unparseable output. Caught
126
- * per-sample runtime errors are reflected in `errors` (not thrown).
127
- */
128
- export async function runVerifierInSandbox(
129
- sandbox: Sandbox,
130
- code: string,
131
- samples: ReadonlyArray<VerifierSample>,
132
- opts: { readonly timeoutMs?: number; readonly image?: string } = {},
133
- ): Promise<{
134
- readonly verdicts: ReadonlyArray<boolean>;
135
- readonly heuristic: number;
136
- readonly errors: number;
137
- }> {
138
- // Strip `expected` — the jail produces verdicts; the host scores them.
139
- const payload: VerifierPayload = {
140
- code,
141
- samples: samples.map((s) => ({ input: s.input, output: s.output })),
142
- };
143
- let stdin: string;
144
- try {
145
- // JSON.stringify throws on BigInt and circular references — surface
146
- // that as a clear error rather than shipping a broken payload.
147
- stdin = JSON.stringify(payload);
148
- } catch (err) {
149
- throw new HarnessSynthesizerError(
150
- `verifier samples are not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
151
- );
152
- }
153
-
154
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
155
- const result = await sandbox.exec({
156
- image: opts.image ?? VERIFIER_IMAGE,
157
- argv: ["node", "-e", VERIFIER_HARNESS],
158
- stdin,
159
- timeoutMs,
160
- });
161
-
162
- if (result.timedOut) {
163
- throw new HarnessSynthesizerError(`verifier evaluation timed out after ${timeoutMs}ms`);
164
- }
165
- const parsed = parseHarnessResult(result.stdout);
166
- if (parsed === undefined) {
167
- if (result.exitCode !== 0) {
168
- throw new HarnessSynthesizerError(
169
- `verifier harness exited ${result.exitCode}: ${result.stderr.slice(-500)}`,
170
- );
171
- }
172
- throw new HarnessSynthesizerError("verifier harness produced no result");
173
- }
174
- if ("compileError" in parsed) {
175
- throw new HarnessSynthesizerError(`verifier code did not compile: ${parsed.compileError}`);
176
- }
177
- if (parsed.verdicts.length !== samples.length) {
178
- throw new HarnessSynthesizerError(
179
- `verifier harness returned ${parsed.verdicts.length} verdicts for ${samples.length} samples`,
180
- );
181
- }
182
-
183
- let correct = 0;
184
- const verdicts: boolean[] = [];
185
- for (let i = 0; i < samples.length; i++) {
186
- const v = parsed.verdicts[i] === true;
187
- verdicts.push(v);
188
- if (v === (samples[i] as VerifierSample).expected) correct++;
189
- }
190
- const heuristic = samples.length === 0 ? 0 : correct / samples.length;
191
- return { verdicts, heuristic, errors: parsed.errors };
192
- }