@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/dist/index.d.ts +216 -0
- package/dist/index.js +296 -0
- package/dist/sandboxed-eval.d.ts +67 -0
- package/dist/sandboxed-eval.js +142 -0
- package/package.json +11 -8
- package/src/index.test.ts +0 -431
- package/src/index.ts +0 -419
- package/src/sandboxed-eval.ts +0 -192
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
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 { MutationProvider, OptimizerState, ProviderMutation } from "@crewhaus/prompt-optimizer";
|
|
42
|
+
import { type Sandbox } from "@crewhaus/sandbox";
|
|
43
|
+
export declare class HarnessSynthesizerError extends CrewhausError {
|
|
44
|
+
readonly name = "HarnessSynthesizerError";
|
|
45
|
+
constructor(message: string, cause?: unknown);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* One sample of behavior the verifier should be measured against.
|
|
49
|
+
* `expected` is whether the verifier should accept (true) or reject
|
|
50
|
+
* (false) this sample. Both classes are required for non-degenerate
|
|
51
|
+
* search — a verifier that returns `true` for everything passes the
|
|
52
|
+
* `expected: true` set perfectly.
|
|
53
|
+
*
|
|
54
|
+
* NOTE: `input`/`output` cross an isolation boundary (see `runVerifier`)
|
|
55
|
+
* and are marshaled as JSON, so they must be JSON-serializable. The
|
|
56
|
+
* verifier observes structured copies, not live host references — an
|
|
57
|
+
* intentional consequence of running it out-of-process.
|
|
58
|
+
*/
|
|
59
|
+
export type VerifierSample = {
|
|
60
|
+
readonly input: unknown;
|
|
61
|
+
readonly output: unknown;
|
|
62
|
+
readonly expected: boolean;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* A candidate verifier — a code string + the per-sample score it
|
|
66
|
+
* achieved on the last evaluation. `code` is a function body string
|
|
67
|
+
* with the signature `(input: unknown, output: unknown) => boolean`.
|
|
68
|
+
* It's stored as a string so the search can mutate it and feed it
|
|
69
|
+
* back to the LLM.
|
|
70
|
+
*
|
|
71
|
+
* SECURITY: `code` is attacker-influenceable (caller seed candidates +
|
|
72
|
+
* refiner output, which a future model-backed refiner derives from
|
|
73
|
+
* skill/tool I/O that can carry injected content). It is NEVER compiled
|
|
74
|
+
* or invoked on this host. Execution happens via `runVerifier`, which
|
|
75
|
+
* evaluates the candidate inside a locked-down `@crewhaus/sandbox`
|
|
76
|
+
* container with no ambient authority — see the SECURITY note on
|
|
77
|
+
* `runVerifier`.
|
|
78
|
+
*/
|
|
79
|
+
export type VerifierCandidate = {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
readonly code: string;
|
|
82
|
+
readonly score: number;
|
|
83
|
+
/** AutoHarness's heuristic value — average over samples, in [0, 1]. */
|
|
84
|
+
readonly heuristic: number;
|
|
85
|
+
/** Beta posterior parameters for Thompson sampling. */
|
|
86
|
+
readonly alpha: number;
|
|
87
|
+
readonly beta: number;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* The Refiner: takes a failing candidate + concrete failure cases and
|
|
91
|
+
* returns a new code string. In production, this is a model call; for
|
|
92
|
+
* testing it's a deterministic rule-based mutation. Either way, the
|
|
93
|
+
* signature is the same.
|
|
94
|
+
*
|
|
95
|
+
* SECURITY: the string this returns is executed (in isolation — see
|
|
96
|
+
* `runVerifier`), so a model-backed refiner is effectively running
|
|
97
|
+
* model output as code. The container is the trust boundary, NOT the
|
|
98
|
+
* model — never relax `runVerifier`'s isolation on the assumption that
|
|
99
|
+
* refiner output is trustworthy.
|
|
100
|
+
*/
|
|
101
|
+
export type RefinerFn = (current: VerifierCandidate, failures: ReadonlyArray<VerifierSample>) => Promise<string>;
|
|
102
|
+
/** Isolation options shared by `runVerifier` and the inner search. */
|
|
103
|
+
export type RunVerifierOptions = {
|
|
104
|
+
/**
|
|
105
|
+
* Isolation backend. Defaults to `createSandbox()` (honors the
|
|
106
|
+
* `CREWHAUS_SANDBOX` env; `docker` by default). The non-isolating
|
|
107
|
+
* `noop` backend is REFUSED — see the SECURITY note on `runVerifier`.
|
|
108
|
+
*/
|
|
109
|
+
readonly sandbox?: Sandbox;
|
|
110
|
+
/** Per-evaluation wall-clock budget (ms). Default: 10_000. */
|
|
111
|
+
readonly timeoutMs?: number;
|
|
112
|
+
/** Container image. Default: `node:22-alpine` (on the sandbox allowlist). */
|
|
113
|
+
readonly image?: string;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* The Critic: runs `code` against a sample set and returns the per-
|
|
117
|
+
* sample verdict and the heuristic value. Deterministic given the same
|
|
118
|
+
* code + samples (modulo the verifier's own determinism).
|
|
119
|
+
*
|
|
120
|
+
* SECURITY (FR-007): `code` is untrusted and is NOT run in this process.
|
|
121
|
+
* `runVerifier` ships `{ code, samples }` to a locked-down
|
|
122
|
+
* `@crewhaus/sandbox` container (`--network none`, `--read-only`, no
|
|
123
|
+
* host env, cpu/mem caps, wall-clock kill, `no-new-privileges`) and
|
|
124
|
+
* scores the returned verdicts here on the host — the container never
|
|
125
|
+
* receives `expected`. Inside the jail the candidate is compiled with
|
|
126
|
+
* the runtime's dynamic-function constructor and invoked over the
|
|
127
|
+
* samples; an exfiltration/RCE attempt (e.g. a refiner emitting a
|
|
128
|
+
* network call) is contained by the jail. FAIL CLOSED: when no real
|
|
129
|
+
* isolation backend is available (the `noop` backend, or none),
|
|
130
|
+
* `runVerifier` throws rather than executing untrusted code unsandboxed,
|
|
131
|
+
* mirroring the `requiresSandbox` floor enforced elsewhere in the repo.
|
|
132
|
+
*
|
|
133
|
+
* Behavior contract: per-sample runtime errors are caught (verdict
|
|
134
|
+
* `false`, `errors++`), matching the prior in-process semantics. Code
|
|
135
|
+
* that fails to compile, times out, or otherwise crashes the harness
|
|
136
|
+
* throws a `HarnessSynthesizerError`.
|
|
137
|
+
*/
|
|
138
|
+
export declare function runVerifier(code: string, samples: ReadonlyArray<VerifierSample>, opts?: RunVerifierOptions): Promise<{
|
|
139
|
+
readonly verdicts: ReadonlyArray<boolean>;
|
|
140
|
+
readonly heuristic: number;
|
|
141
|
+
readonly errors: number;
|
|
142
|
+
}>;
|
|
143
|
+
/**
|
|
144
|
+
* Thompson sampling over a node population. Picks the index whose
|
|
145
|
+
* posterior sample is highest. Each node has a Beta(alpha, beta)
|
|
146
|
+
* posterior over its heuristic value; the alpha/beta are accumulated
|
|
147
|
+
* across iterations as the search refines.
|
|
148
|
+
*/
|
|
149
|
+
export declare function thompsonPick(nodes: ReadonlyArray<VerifierCandidate>, rng?: () => number): number;
|
|
150
|
+
export type SynthesizeOptions = {
|
|
151
|
+
/** Initial seed candidates. Must be non-empty; provides the starting tree. */
|
|
152
|
+
readonly seedCandidates: ReadonlyArray<string>;
|
|
153
|
+
/** Samples the verifier is scored against. */
|
|
154
|
+
readonly samples: ReadonlyArray<VerifierSample>;
|
|
155
|
+
/** The refiner — usually an LLM-backed function. */
|
|
156
|
+
readonly refiner: RefinerFn;
|
|
157
|
+
/** Maximum tree-search iterations. Default: 16 (paper's median is ~14). */
|
|
158
|
+
readonly maxIterations?: number;
|
|
159
|
+
/** Target heuristic value — stop when reached. Default: 1.0 (100% correct). */
|
|
160
|
+
readonly target?: number;
|
|
161
|
+
/** RNG for Thompson sampling. Default: Math.random. */
|
|
162
|
+
readonly rng?: () => number;
|
|
163
|
+
/**
|
|
164
|
+
* Isolation backend reused across every evaluation in the inner search.
|
|
165
|
+
* Defaults to one `createSandbox()` per call (closed when the search
|
|
166
|
+
* ends). See `runVerifier` for the isolation/fail-closed contract.
|
|
167
|
+
*/
|
|
168
|
+
readonly sandbox?: Sandbox;
|
|
169
|
+
/** Per-evaluation wall-clock budget (ms) forwarded to `runVerifier`. */
|
|
170
|
+
readonly timeoutMs?: number;
|
|
171
|
+
};
|
|
172
|
+
export type SynthesizeResult = {
|
|
173
|
+
readonly best: VerifierCandidate;
|
|
174
|
+
readonly iterations: number;
|
|
175
|
+
readonly converged: boolean;
|
|
176
|
+
readonly trajectory: ReadonlyArray<VerifierCandidate>;
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Run the tree search. Returns the best candidate found, the
|
|
180
|
+
* iteration count, and whether the target heuristic was reached.
|
|
181
|
+
* Pure with respect to randomness: pass `rng` for determinism.
|
|
182
|
+
*
|
|
183
|
+
* One isolation backend is created (or reused, if `opts.sandbox` is
|
|
184
|
+
* supplied) for the whole search and closed on exit — see `runVerifier`.
|
|
185
|
+
*/
|
|
186
|
+
export declare function synthesizeVerifier(opts: SynthesizeOptions): Promise<SynthesizeResult>;
|
|
187
|
+
/**
|
|
188
|
+
* `MutationProvider` adapter so verifier search can drop into the
|
|
189
|
+
* existing eval-optimizer-orchestrator loop. The provider's `next()`
|
|
190
|
+
* runs one iteration of the inner tree search and emits a
|
|
191
|
+
* prompt-edit that references the synthesized verifier.
|
|
192
|
+
*
|
|
193
|
+
* Typical wiring (programmatic): construct this provider with the
|
|
194
|
+
* spec's skill samples and a `refiner` function, then pass it to
|
|
195
|
+
* `optimizeSpec({ mutator: new VerifierMutationProvider(...) })`. The
|
|
196
|
+
* orchestrator runs the standard search loop, but each "mutation" is
|
|
197
|
+
* a freshly-synthesized verifier persisted to .crewhaus/verifiers/.
|
|
198
|
+
* (CLI `--mutator verifier-synthesis` wiring is a follow-up; the CLI
|
|
199
|
+
* today exposes `rule-based` and `claude` only.)
|
|
200
|
+
*
|
|
201
|
+
* SECURITY: the inner search executes refiner-produced code. Pass a
|
|
202
|
+
* `sandbox` (or rely on the default) — `synthesizeVerifier`/`runVerifier`
|
|
203
|
+
* isolate every evaluation and fail closed without a real backend. A
|
|
204
|
+
* model-backed `refiner` is only safe to wire because of that jail.
|
|
205
|
+
*/
|
|
206
|
+
export declare class VerifierMutationProvider implements MutationProvider {
|
|
207
|
+
private readonly samples;
|
|
208
|
+
private readonly refiner;
|
|
209
|
+
private readonly seedCandidates;
|
|
210
|
+
private readonly maxInnerIterations;
|
|
211
|
+
private readonly sandbox?;
|
|
212
|
+
readonly name = "verifier-synthesis";
|
|
213
|
+
private synthesisIterations;
|
|
214
|
+
constructor(samples: ReadonlyArray<VerifierSample>, refiner: RefinerFn, seedCandidates: ReadonlyArray<string>, maxInnerIterations?: number, sandbox?: Sandbox | undefined);
|
|
215
|
+
next(state: OptimizerState): Promise<ProviderMutation>;
|
|
216
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
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 { createSandbox } from "@crewhaus/sandbox";
|
|
42
|
+
import { runVerifierInSandbox } from "./sandboxed-eval";
|
|
43
|
+
export class HarnessSynthesizerError extends CrewhausError {
|
|
44
|
+
name = "HarnessSynthesizerError";
|
|
45
|
+
constructor(message, cause) {
|
|
46
|
+
super("config", message, cause);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The Critic: runs `code` against a sample set and returns the per-
|
|
51
|
+
* sample verdict and the heuristic value. Deterministic given the same
|
|
52
|
+
* code + samples (modulo the verifier's own determinism).
|
|
53
|
+
*
|
|
54
|
+
* SECURITY (FR-007): `code` is untrusted and is NOT run in this process.
|
|
55
|
+
* `runVerifier` ships `{ code, samples }` to a locked-down
|
|
56
|
+
* `@crewhaus/sandbox` container (`--network none`, `--read-only`, no
|
|
57
|
+
* host env, cpu/mem caps, wall-clock kill, `no-new-privileges`) and
|
|
58
|
+
* scores the returned verdicts here on the host — the container never
|
|
59
|
+
* receives `expected`. Inside the jail the candidate is compiled with
|
|
60
|
+
* the runtime's dynamic-function constructor and invoked over the
|
|
61
|
+
* samples; an exfiltration/RCE attempt (e.g. a refiner emitting a
|
|
62
|
+
* network call) is contained by the jail. FAIL CLOSED: when no real
|
|
63
|
+
* isolation backend is available (the `noop` backend, or none),
|
|
64
|
+
* `runVerifier` throws rather than executing untrusted code unsandboxed,
|
|
65
|
+
* mirroring the `requiresSandbox` floor enforced elsewhere in the repo.
|
|
66
|
+
*
|
|
67
|
+
* Behavior contract: per-sample runtime errors are caught (verdict
|
|
68
|
+
* `false`, `errors++`), matching the prior in-process semantics. Code
|
|
69
|
+
* that fails to compile, times out, or otherwise crashes the harness
|
|
70
|
+
* throws a `HarnessSynthesizerError`.
|
|
71
|
+
*/
|
|
72
|
+
export async function runVerifier(code, samples, opts = {}) {
|
|
73
|
+
const ownsSandbox = opts.sandbox === undefined;
|
|
74
|
+
const sandbox = opts.sandbox ?? createSandbox();
|
|
75
|
+
try {
|
|
76
|
+
// FR-007 — fail closed: never run untrusted verifier code without
|
|
77
|
+
// a real isolation boundary.
|
|
78
|
+
if (sandbox.backend === "noop") {
|
|
79
|
+
throw new HarnessSynthesizerError("refusing to evaluate verifier code in a noop sandbox (no isolation); set CREWHAUS_SANDBOX=docker|podman");
|
|
80
|
+
}
|
|
81
|
+
return await runVerifierInSandbox(sandbox, code, samples, {
|
|
82
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
83
|
+
...(opts.image !== undefined ? { image: opts.image } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
if (ownsSandbox)
|
|
88
|
+
await sandbox.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Thompson sampling over a node population. Picks the index whose
|
|
93
|
+
* posterior sample is highest. Each node has a Beta(alpha, beta)
|
|
94
|
+
* posterior over its heuristic value; the alpha/beta are accumulated
|
|
95
|
+
* across iterations as the search refines.
|
|
96
|
+
*/
|
|
97
|
+
export function thompsonPick(nodes, rng = Math.random) {
|
|
98
|
+
if (nodes.length === 0)
|
|
99
|
+
throw new HarnessSynthesizerError("thompsonPick called on empty list");
|
|
100
|
+
let bestIdx = 0;
|
|
101
|
+
let bestSample = Number.NEGATIVE_INFINITY;
|
|
102
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
103
|
+
const n = nodes[i];
|
|
104
|
+
if (n === undefined)
|
|
105
|
+
continue;
|
|
106
|
+
const sample = betaSample(n.alpha, n.beta, rng);
|
|
107
|
+
if (sample > bestSample) {
|
|
108
|
+
bestSample = sample;
|
|
109
|
+
bestIdx = i;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return bestIdx;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Quick-and-deterministic Beta sample using two gamma samples
|
|
116
|
+
* (Marsaglia–Tsang). For the sizes we deal with (alpha, beta < 100),
|
|
117
|
+
* the approximation is fast and stable.
|
|
118
|
+
*/
|
|
119
|
+
function betaSample(a, b, rng) {
|
|
120
|
+
const x = gammaSample(a, rng);
|
|
121
|
+
const y = gammaSample(b, rng);
|
|
122
|
+
return x / (x + y);
|
|
123
|
+
}
|
|
124
|
+
function gammaSample(shape, rng) {
|
|
125
|
+
// For shape >= 1 use Marsaglia-Tsang; for shape < 1 use Ahrens-Dieter.
|
|
126
|
+
if (shape < 1) {
|
|
127
|
+
// Use shape+1 then transform by U^(1/shape).
|
|
128
|
+
const x = gammaSample(shape + 1, rng);
|
|
129
|
+
const u = Math.max(rng(), 1e-12);
|
|
130
|
+
return x * u ** (1 / shape);
|
|
131
|
+
}
|
|
132
|
+
const d = shape - 1 / 3;
|
|
133
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
134
|
+
// Loop until a valid sample.
|
|
135
|
+
for (let i = 0; i < 64; i++) {
|
|
136
|
+
let x;
|
|
137
|
+
let v;
|
|
138
|
+
do {
|
|
139
|
+
const u1 = Math.max(rng(), 1e-12);
|
|
140
|
+
const u2 = Math.max(rng(), 1e-12);
|
|
141
|
+
// Box-Muller for standard normal.
|
|
142
|
+
x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
143
|
+
v = 1 + c * x;
|
|
144
|
+
} while (v <= 0);
|
|
145
|
+
v = v * v * v;
|
|
146
|
+
const u = rng();
|
|
147
|
+
if (u < 1 - 0.0331 * x * x * x * x)
|
|
148
|
+
return d * v;
|
|
149
|
+
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))
|
|
150
|
+
return d * v;
|
|
151
|
+
}
|
|
152
|
+
// Fallback — extremely rare. Return the deterministic mean.
|
|
153
|
+
return shape;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Run the tree search. Returns the best candidate found, the
|
|
157
|
+
* iteration count, and whether the target heuristic was reached.
|
|
158
|
+
* Pure with respect to randomness: pass `rng` for determinism.
|
|
159
|
+
*
|
|
160
|
+
* One isolation backend is created (or reused, if `opts.sandbox` is
|
|
161
|
+
* supplied) for the whole search and closed on exit — see `runVerifier`.
|
|
162
|
+
*/
|
|
163
|
+
export async function synthesizeVerifier(opts) {
|
|
164
|
+
if (opts.seedCandidates.length === 0) {
|
|
165
|
+
throw new HarnessSynthesizerError("at least one seed candidate is required");
|
|
166
|
+
}
|
|
167
|
+
if (opts.samples.length === 0) {
|
|
168
|
+
throw new HarnessSynthesizerError("at least one sample is required to score the verifier");
|
|
169
|
+
}
|
|
170
|
+
const target = opts.target ?? 1.0;
|
|
171
|
+
const rng = opts.rng ?? Math.random;
|
|
172
|
+
const maxIter = opts.maxIterations ?? 16;
|
|
173
|
+
// One isolation backend reused across the whole inner search; close it
|
|
174
|
+
// on exit only if we created it (the caller owns an injected one).
|
|
175
|
+
const ownsSandbox = opts.sandbox === undefined;
|
|
176
|
+
const sandbox = opts.sandbox ?? createSandbox();
|
|
177
|
+
const runOpts = {
|
|
178
|
+
sandbox,
|
|
179
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
180
|
+
};
|
|
181
|
+
try {
|
|
182
|
+
// Initialize the candidate pool from seeds.
|
|
183
|
+
const pool = [];
|
|
184
|
+
for (let i = 0; i < opts.seedCandidates.length; i++) {
|
|
185
|
+
const code = opts.seedCandidates[i];
|
|
186
|
+
const { heuristic } = await runVerifier(code, opts.samples, runOpts);
|
|
187
|
+
pool.push({
|
|
188
|
+
id: `seed_${i}`,
|
|
189
|
+
code,
|
|
190
|
+
score: heuristic,
|
|
191
|
+
heuristic,
|
|
192
|
+
// Beta starts uniform; update with observed correct/incorrect counts.
|
|
193
|
+
alpha: 1 + Math.round(heuristic * opts.samples.length),
|
|
194
|
+
beta: 1 + Math.round((1 - heuristic) * opts.samples.length),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const trajectory = [...pool];
|
|
198
|
+
// Early exit if a seed already satisfies the target.
|
|
199
|
+
let best = pool.reduce((a, b) => (a.heuristic >= b.heuristic ? a : b));
|
|
200
|
+
if (best.heuristic >= target) {
|
|
201
|
+
return { best, iterations: 0, converged: true, trajectory };
|
|
202
|
+
}
|
|
203
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
204
|
+
const pickIdx = thompsonPick(pool, rng);
|
|
205
|
+
const parent = pool[pickIdx];
|
|
206
|
+
// Compute concrete failures for the refiner.
|
|
207
|
+
const { verdicts } = await runVerifier(parent.code, opts.samples, runOpts);
|
|
208
|
+
const failures = [];
|
|
209
|
+
for (let i = 0; i < opts.samples.length; i++) {
|
|
210
|
+
const s = opts.samples[i];
|
|
211
|
+
const v = verdicts[i];
|
|
212
|
+
if (v !== s.expected)
|
|
213
|
+
failures.push(s);
|
|
214
|
+
}
|
|
215
|
+
let newCode;
|
|
216
|
+
try {
|
|
217
|
+
newCode = await opts.refiner(parent, failures);
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
throw new HarnessSynthesizerError(`refiner threw on iteration ${iter}: ${err.message}`, err);
|
|
221
|
+
}
|
|
222
|
+
const { heuristic } = await runVerifier(newCode, opts.samples, runOpts);
|
|
223
|
+
const child = {
|
|
224
|
+
id: `cand_${iter}`,
|
|
225
|
+
code: newCode,
|
|
226
|
+
score: heuristic,
|
|
227
|
+
heuristic,
|
|
228
|
+
alpha: 1 + Math.round(heuristic * opts.samples.length),
|
|
229
|
+
beta: 1 + Math.round((1 - heuristic) * opts.samples.length),
|
|
230
|
+
};
|
|
231
|
+
pool.push(child);
|
|
232
|
+
trajectory.push(child);
|
|
233
|
+
if (heuristic > best.heuristic)
|
|
234
|
+
best = child;
|
|
235
|
+
if (best.heuristic >= target) {
|
|
236
|
+
return { best, iterations: iter + 1, converged: true, trajectory };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { best, iterations: maxIter, converged: false, trajectory };
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
if (ownsSandbox)
|
|
243
|
+
await sandbox.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* `MutationProvider` adapter so verifier search can drop into the
|
|
248
|
+
* existing eval-optimizer-orchestrator loop. The provider's `next()`
|
|
249
|
+
* runs one iteration of the inner tree search and emits a
|
|
250
|
+
* prompt-edit that references the synthesized verifier.
|
|
251
|
+
*
|
|
252
|
+
* Typical wiring (programmatic): construct this provider with the
|
|
253
|
+
* spec's skill samples and a `refiner` function, then pass it to
|
|
254
|
+
* `optimizeSpec({ mutator: new VerifierMutationProvider(...) })`. The
|
|
255
|
+
* orchestrator runs the standard search loop, but each "mutation" is
|
|
256
|
+
* a freshly-synthesized verifier persisted to .crewhaus/verifiers/.
|
|
257
|
+
* (CLI `--mutator verifier-synthesis` wiring is a follow-up; the CLI
|
|
258
|
+
* today exposes `rule-based` and `claude` only.)
|
|
259
|
+
*
|
|
260
|
+
* SECURITY: the inner search executes refiner-produced code. Pass a
|
|
261
|
+
* `sandbox` (or rely on the default) — `synthesizeVerifier`/`runVerifier`
|
|
262
|
+
* isolate every evaluation and fail closed without a real backend. A
|
|
263
|
+
* model-backed `refiner` is only safe to wire because of that jail.
|
|
264
|
+
*/
|
|
265
|
+
export class VerifierMutationProvider {
|
|
266
|
+
samples;
|
|
267
|
+
refiner;
|
|
268
|
+
seedCandidates;
|
|
269
|
+
maxInnerIterations;
|
|
270
|
+
sandbox;
|
|
271
|
+
name = "verifier-synthesis";
|
|
272
|
+
synthesisIterations = 0;
|
|
273
|
+
constructor(samples, refiner, seedCandidates, maxInnerIterations = 4, sandbox) {
|
|
274
|
+
this.samples = samples;
|
|
275
|
+
this.refiner = refiner;
|
|
276
|
+
this.seedCandidates = seedCandidates;
|
|
277
|
+
this.maxInnerIterations = maxInnerIterations;
|
|
278
|
+
this.sandbox = sandbox;
|
|
279
|
+
}
|
|
280
|
+
async next(state) {
|
|
281
|
+
this.synthesisIterations++;
|
|
282
|
+
const result = await synthesizeVerifier({
|
|
283
|
+
seedCandidates: this.seedCandidates,
|
|
284
|
+
samples: this.samples,
|
|
285
|
+
refiner: this.refiner,
|
|
286
|
+
maxIterations: this.maxInnerIterations,
|
|
287
|
+
...(this.sandbox !== undefined ? { sandbox: this.sandbox } : {}),
|
|
288
|
+
});
|
|
289
|
+
const annotation = `\n\n[verifier ${result.best.id}, h=${result.best.heuristic.toFixed(3)}]`;
|
|
290
|
+
return {
|
|
291
|
+
prompt: state.best.prompt + annotation,
|
|
292
|
+
mutations: [{ kind: "rephrase-instruction" }],
|
|
293
|
+
rationale: `verifier-synthesis pass ${this.synthesisIterations}: ${result.best.id} reached heuristic ${result.best.heuristic.toFixed(3)} in ${result.iterations} inner iterations${result.converged ? " (converged)" : ""}`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { type VerifierSample } from "./index";
|
|
19
|
+
/** Sentinel that frames the harness's single JSON result line on stdout. */
|
|
20
|
+
export declare const VERIFIER_SENTINEL = "__CREWHAUS_VERIFIER__";
|
|
21
|
+
/** Input/output pair handed to the verifier (labels stripped). */
|
|
22
|
+
type VerifierIO = {
|
|
23
|
+
readonly input: unknown;
|
|
24
|
+
readonly output: unknown;
|
|
25
|
+
};
|
|
26
|
+
/** Payload marshaled across the isolation boundary. */
|
|
27
|
+
type VerifierPayload = {
|
|
28
|
+
readonly code: string;
|
|
29
|
+
readonly samples: ReadonlyArray<VerifierIO>;
|
|
30
|
+
};
|
|
31
|
+
/** Per-sample result from inside the jail (or the in-process test double). */
|
|
32
|
+
export type VerifierEvalResult = {
|
|
33
|
+
readonly verdicts: ReadonlyArray<boolean>;
|
|
34
|
+
readonly errors: number;
|
|
35
|
+
} | {
|
|
36
|
+
readonly compileError: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Compile `payload.code` and run it over `payload.samples`, collecting a
|
|
40
|
+
* boolean verdict per sample. A compile failure returns `{ compileError }`;
|
|
41
|
+
* a per-sample runtime throw is caught (verdict `false`, `errors++`).
|
|
42
|
+
*
|
|
43
|
+
* Single source of truth for verifier scoring: it runs both (a) inside the
|
|
44
|
+
* container — `VERIFIER_HARNESS` embeds this function's own source via
|
|
45
|
+
* `.toString()` — and (b) in unit tests via a fake sandbox. It MUST stay
|
|
46
|
+
* self-contained: reference no imports or module scope, or the in-container
|
|
47
|
+
* copy throws a ReferenceError.
|
|
48
|
+
*/
|
|
49
|
+
export declare function evalVerifierPayload(payload: VerifierPayload): VerifierEvalResult;
|
|
50
|
+
/**
|
|
51
|
+
* Marshal `{ code, samples }` (labels stripped) into the jail, run the
|
|
52
|
+
* harness, parse the framed result, and score verdicts against the
|
|
53
|
+
* host-held `expected` labels.
|
|
54
|
+
*
|
|
55
|
+
* Throws `HarnessSynthesizerError` on: non-serializable samples, compile
|
|
56
|
+
* failure, timeout, non-zero harness exit, or unparseable output. Caught
|
|
57
|
+
* per-sample runtime errors are reflected in `errors` (not thrown).
|
|
58
|
+
*/
|
|
59
|
+
export declare function runVerifierInSandbox(sandbox: Sandbox, code: string, samples: ReadonlyArray<VerifierSample>, opts?: {
|
|
60
|
+
readonly timeoutMs?: number;
|
|
61
|
+
readonly image?: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
readonly verdicts: ReadonlyArray<boolean>;
|
|
64
|
+
readonly heuristic: number;
|
|
65
|
+
readonly errors: number;
|
|
66
|
+
}>;
|
|
67
|
+
export {};
|