@crewhaus/eval-runner 0.1.4 → 0.1.5
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/aggregate.d.ts +15 -0
- package/dist/aggregate.js +35 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +216 -0
- package/dist/run-sample.d.ts +19 -0
- package/dist/run-sample.js +187 -0
- package/dist/semaphore.d.ts +15 -0
- package/dist/semaphore.js +39 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +1 -0
- package/dist/wire-once.d.ts +42 -0
- package/dist/wire-once.js +150 -0
- package/package.json +37 -34
- package/src/aggregate.ts +0 -48
- package/src/errors.ts +0 -7
- package/src/index.default-invoker.test.ts +0 -172
- package/src/index.interrupt.test.ts +0 -99
- package/src/index.judge.test.ts +0 -167
- package/src/index.test.ts +0 -254
- package/src/index.ts +0 -264
- package/src/run-sample.eventlog.test.ts +0 -124
- package/src/run-sample.test.ts +0 -273
- package/src/run-sample.ts +0 -232
- package/src/semaphore.test.ts +0 -58
- package/src/semaphore.ts +0 -35
- package/src/tenant-roots.test.ts +0 -30
- package/src/types.ts +0 -93
- package/src/wire-once.test.ts +0 -445
- package/src/wire-once.ts +0 -204
package/src/index.judge.test.ts
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Isolated test for the `llm_judge` grader-resolution branch in `runEval`.
|
|
3
|
-
*
|
|
4
|
-
* The runner replaces any compiled grader carrying a `judgeSpec` with a real
|
|
5
|
-
* judge grader bound to the run's `judgeModel` (or the per-grader override).
|
|
6
|
-
* We stub `@crewhaus/eval-judge` so no LLM/network is touched: `loadRubric`
|
|
7
|
-
* echoes its input and `createJudgeGrader` returns a deterministic grader that
|
|
8
|
-
* records the model it was bound to. `mock.module` is process-global, so this
|
|
9
|
-
* lives in its own file (Bun gives each test file a fresh module graph).
|
|
10
|
-
*/
|
|
11
|
-
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
12
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import { lower } from "@crewhaus/compiler";
|
|
16
|
-
import type { Sample } from "@crewhaus/eval-dataset";
|
|
17
|
-
import type { CompiledGrader, GradeResult } from "@crewhaus/eval-grader";
|
|
18
|
-
import type { IrNode, IrV0 } from "@crewhaus/ir";
|
|
19
|
-
import { parseSpec } from "@crewhaus/spec";
|
|
20
|
-
|
|
21
|
-
const boundModels: Array<string | undefined> = [];
|
|
22
|
-
const loadedRubrics: unknown[] = [];
|
|
23
|
-
|
|
24
|
-
// Capture the real module so `afterAll` can restore it — `mock.module` is
|
|
25
|
-
// process-global and does not auto-restore across test files. The capture is
|
|
26
|
-
// a plain-object SNAPSHOT (`{ ...ns }`): an ESM namespace is a live view that
|
|
27
|
-
// resolves to the stubs once mock.module patches the module, so restoring
|
|
28
|
-
// from the namespace itself would silently reinstall the stubs.
|
|
29
|
-
const realEvalJudge = { ...(await import("@crewhaus/eval-judge")) };
|
|
30
|
-
|
|
31
|
-
mock.module("@crewhaus/eval-judge", () => ({
|
|
32
|
-
...realEvalJudge,
|
|
33
|
-
loadRubric: (input: unknown) => {
|
|
34
|
-
loadedRubrics.push(input);
|
|
35
|
-
return { criteria: [{ name: "quality", weight: 1, description: "is it good" }] };
|
|
36
|
-
},
|
|
37
|
-
createJudgeGrader: (_rubric: unknown, opts: { model?: string } = {}) => {
|
|
38
|
-
boundModels.push(opts.model);
|
|
39
|
-
// Deterministic grader: always passes, no network.
|
|
40
|
-
return async (): Promise<GradeResult> => ({
|
|
41
|
-
passed: true,
|
|
42
|
-
score: 1,
|
|
43
|
-
rationale: `judged with ${opts.model ?? "(default)"}`,
|
|
44
|
-
});
|
|
45
|
-
},
|
|
46
|
-
}));
|
|
47
|
-
|
|
48
|
-
const { runEval } = await import("./index");
|
|
49
|
-
|
|
50
|
-
function narrowToAgent(ir: IrNode): IrV0 {
|
|
51
|
-
if (ir.target !== "cli") throw new Error(`expected target:cli, got ${ir.target}`);
|
|
52
|
-
return ir;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const SPEC = `name: judge-test
|
|
56
|
-
target: cli
|
|
57
|
-
agent:
|
|
58
|
-
model: claude-opus-4-7
|
|
59
|
-
instructions: hi
|
|
60
|
-
`;
|
|
61
|
-
|
|
62
|
-
async function* yieldSamples(samples: Sample[]): AsyncIterable<Sample> {
|
|
63
|
-
for (const s of samples) yield s;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const TMP_ROOTS: string[] = [];
|
|
67
|
-
function newTempRoot(): string {
|
|
68
|
-
const dir = mkdtempSync(join(tmpdir(), "crewhaus-judge-"));
|
|
69
|
-
TMP_ROOTS.push(dir);
|
|
70
|
-
return dir;
|
|
71
|
-
}
|
|
72
|
-
afterAll(() => {
|
|
73
|
-
for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
|
|
74
|
-
mock.module("@crewhaus/eval-judge", () => realEvalJudge);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
// A compiled grader with a judgeSpec but no per-grader model → uses opts.judgeModel.
|
|
78
|
-
function judgeGraderNoModel(name: string): CompiledGrader {
|
|
79
|
-
return {
|
|
80
|
-
name,
|
|
81
|
-
grader: async () => {
|
|
82
|
-
throw new Error("placeholder must be replaced");
|
|
83
|
-
},
|
|
84
|
-
weight: 1,
|
|
85
|
-
judgeSpec: { rubric: { criteria: [{ name: "q", weight: 1, description: "d" }] } } as never,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// A compiled grader whose judgeSpec carries its own model override.
|
|
90
|
-
function judgeGraderWithModel(name: string, model: string): CompiledGrader {
|
|
91
|
-
return {
|
|
92
|
-
name,
|
|
93
|
-
grader: async () => {
|
|
94
|
-
throw new Error("placeholder must be replaced");
|
|
95
|
-
},
|
|
96
|
-
weight: 1,
|
|
97
|
-
judgeSpec: {
|
|
98
|
-
rubric: { criteria: [{ name: "q", weight: 1, description: "d" }] },
|
|
99
|
-
model,
|
|
100
|
-
} as never,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
describe("runEval — llm_judge resolution", () => {
|
|
105
|
-
test("binds judge grader to opts.judgeModel when no per-grader override", async () => {
|
|
106
|
-
const outDir = newTempRoot();
|
|
107
|
-
boundModels.length = 0;
|
|
108
|
-
loadedRubrics.length = 0;
|
|
109
|
-
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
110
|
-
const samples: Sample[] = [{ id: "q1", input: "hi", expected_output: "y" }];
|
|
111
|
-
const invoker = async () => ({ agentOutput: "anything", events: [] });
|
|
112
|
-
|
|
113
|
-
const summary = await runEval({
|
|
114
|
-
ir,
|
|
115
|
-
dataset: { name: "judged", samples: yieldSamples(samples) },
|
|
116
|
-
compiledGraders: [judgeGraderNoModel("rubricA")],
|
|
117
|
-
opts: { invoker, outDir, judgeModel: "claude-judge-x" },
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
expect(loadedRubrics).toHaveLength(1);
|
|
121
|
-
expect(boundModels).toEqual(["claude-judge-x"]);
|
|
122
|
-
expect(summary.config.graderNames).toEqual(["rubricA"]);
|
|
123
|
-
expect(summary.config.judgeModel).toBe("claude-judge-x");
|
|
124
|
-
expect(summary.aggregates.passRate).toBe(1);
|
|
125
|
-
|
|
126
|
-
// judgeModel surfaced in the persisted run.json snapshot.
|
|
127
|
-
const runJson = JSON.parse(readFileSync(join(outDir, "run.json"), "utf-8"));
|
|
128
|
-
expect(runJson.judgeModel).toBe("claude-judge-x");
|
|
129
|
-
expect(runJson.graderNames).toEqual(["rubricA"]);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("per-grader judgeSpec.model overrides the run judgeModel", async () => {
|
|
133
|
-
const outDir = newTempRoot();
|
|
134
|
-
boundModels.length = 0;
|
|
135
|
-
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
136
|
-
const samples: Sample[] = [{ id: "q1", input: "hi", expected_output: "y" }];
|
|
137
|
-
const invoker = async () => ({ agentOutput: "anything", events: [] });
|
|
138
|
-
|
|
139
|
-
await runEval({
|
|
140
|
-
ir,
|
|
141
|
-
dataset: { name: "judged2", samples: yieldSamples(samples) },
|
|
142
|
-
compiledGraders: [judgeGraderWithModel("rubricB", "claude-override")],
|
|
143
|
-
// No opts.judgeModel → the per-grader override must win.
|
|
144
|
-
opts: { invoker, outDir },
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
expect(boundModels).toEqual(["claude-override"]);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("a judge grader with neither override nor judgeModel binds the default", async () => {
|
|
151
|
-
const outDir = newTempRoot();
|
|
152
|
-
boundModels.length = 0;
|
|
153
|
-
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
154
|
-
const samples: Sample[] = [{ id: "q1", input: "hi", expected_output: "y" }];
|
|
155
|
-
const invoker = async () => ({ agentOutput: "anything", events: [] });
|
|
156
|
-
|
|
157
|
-
await runEval({
|
|
158
|
-
ir,
|
|
159
|
-
dataset: { name: "judged3", samples: yieldSamples(samples) },
|
|
160
|
-
compiledGraders: [judgeGraderNoModel("rubricC")],
|
|
161
|
-
opts: { invoker, outDir },
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// model is undefined → createJudgeGrader called with `{}` (no model key).
|
|
165
|
-
expect(boundModels).toEqual([undefined]);
|
|
166
|
-
});
|
|
167
|
-
});
|
package/src/index.test.ts
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { lower } from "@crewhaus/compiler";
|
|
6
|
-
import type { Sample } from "@crewhaus/eval-dataset";
|
|
7
|
-
import { parseGradersConfig } from "@crewhaus/eval-grader";
|
|
8
|
-
import type { IrNode, IrV0 } from "@crewhaus/ir";
|
|
9
|
-
import { parseSpec } from "@crewhaus/spec";
|
|
10
|
-
|
|
11
|
-
function narrowToAgent(ir: IrNode): IrV0 {
|
|
12
|
-
if (ir.target !== "cli") throw new Error(`test fixture must be target:cli, got ${ir.target}`);
|
|
13
|
-
return ir;
|
|
14
|
-
}
|
|
15
|
-
import { aggregate, quantile } from "./aggregate";
|
|
16
|
-
import { type AgentInvoker, runEval } from "./index";
|
|
17
|
-
import { Semaphore } from "./semaphore";
|
|
18
|
-
|
|
19
|
-
const TMP_ROOTS: string[] = [];
|
|
20
|
-
function newTempRoot(): string {
|
|
21
|
-
const dir = mkdtempSync(join(tmpdir(), "crewhaus-eval-runner-"));
|
|
22
|
-
TMP_ROOTS.push(dir);
|
|
23
|
-
return dir;
|
|
24
|
-
}
|
|
25
|
-
afterAll(() => {
|
|
26
|
-
for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const HELLO_SPEC = `name: hello-test
|
|
30
|
-
target: cli
|
|
31
|
-
agent:
|
|
32
|
-
model: claude-opus-4-7
|
|
33
|
-
instructions: |
|
|
34
|
-
You are a helpful, concise assistant.
|
|
35
|
-
`;
|
|
36
|
-
|
|
37
|
-
async function* yieldSamples(samples: Sample[]): AsyncIterable<Sample> {
|
|
38
|
-
for (const s of samples) yield s;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const FIXED_GRADERS = `
|
|
42
|
-
graders:
|
|
43
|
-
- name: math
|
|
44
|
-
type: exact_match
|
|
45
|
-
`;
|
|
46
|
-
|
|
47
|
-
describe("Semaphore", () => {
|
|
48
|
-
test("respects capacity", async () => {
|
|
49
|
-
const sem = new Semaphore(2);
|
|
50
|
-
let active = 0;
|
|
51
|
-
let maxActive = 0;
|
|
52
|
-
const tasks = Array.from({ length: 10 }).map(async () => {
|
|
53
|
-
const release = await sem.acquire();
|
|
54
|
-
active += 1;
|
|
55
|
-
maxActive = Math.max(maxActive, active);
|
|
56
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
57
|
-
active -= 1;
|
|
58
|
-
release();
|
|
59
|
-
});
|
|
60
|
-
await Promise.all(tasks);
|
|
61
|
-
expect(maxActive).toBeLessThanOrEqual(2);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("rejects capacity < 1", () => {
|
|
65
|
-
expect(() => new Semaphore(0)).toThrow();
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("aggregate", () => {
|
|
70
|
-
test("quantile handles edge cases", () => {
|
|
71
|
-
expect(quantile([], 0.5)).toBe(0);
|
|
72
|
-
expect(quantile([42], 0.5)).toBe(42);
|
|
73
|
-
expect(quantile([1, 2, 3, 4, 5], 0.5)).toBe(3);
|
|
74
|
-
expect(quantile([1, 2, 3, 4, 5], 0.95)).toBeCloseTo(4.8);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("computes pass rate, mean score, percentiles", () => {
|
|
78
|
-
const samples = [
|
|
79
|
-
mockSample("s1", true, 1, 100, 1),
|
|
80
|
-
mockSample("s2", true, 0.5, 200, 2),
|
|
81
|
-
mockSample("s3", false, 0, 300, 3),
|
|
82
|
-
];
|
|
83
|
-
const agg = aggregate(samples);
|
|
84
|
-
expect(agg.passRate).toBeCloseTo(2 / 3);
|
|
85
|
-
expect(agg.meanScore).toBeCloseTo(0.5);
|
|
86
|
-
expect(agg.p50LatencyMs).toBe(200);
|
|
87
|
-
expect(agg.errorCount).toBe(0);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe("runEval — T3 5-sample fixture", () => {
|
|
92
|
-
test("runs 5 samples, persists artifacts, computes aggregates", async () => {
|
|
93
|
-
const outDir = newTempRoot();
|
|
94
|
-
const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
|
|
95
|
-
const samples: Sample[] = [
|
|
96
|
-
{ id: "q1", input: "What is 2+2?", expected_output: "4" },
|
|
97
|
-
{ id: "q2", input: "What is 5-3?", expected_output: "2" },
|
|
98
|
-
{ id: "q3", input: "What is 6/2?", expected_output: "3" },
|
|
99
|
-
{ id: "q4", input: "What is 3*3?", expected_output: "9" },
|
|
100
|
-
{ id: "q5", input: "What is 10-7?", expected_output: "3" },
|
|
101
|
-
];
|
|
102
|
-
|
|
103
|
-
// Stub invoker: returns the expected_output verbatim. exact_match grader passes.
|
|
104
|
-
const invoker: AgentInvoker = async ({ sample }) => ({
|
|
105
|
-
agentOutput: sample.expected_output ?? "",
|
|
106
|
-
transcript: [],
|
|
107
|
-
events: [],
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
const { compiled } = parseGradersConfig(FIXED_GRADERS);
|
|
111
|
-
const summary = await runEval({
|
|
112
|
-
ir,
|
|
113
|
-
dataset: { name: "fixture5", samples: yieldSamples(samples) },
|
|
114
|
-
compiledGraders: compiled,
|
|
115
|
-
opts: { invoker, outDir, concurrency: 2 },
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
expect(summary.samples).toHaveLength(5);
|
|
119
|
-
expect(summary.aggregates.passRate).toBe(1);
|
|
120
|
-
expect(summary.aggregates.meanScore).toBe(1);
|
|
121
|
-
|
|
122
|
-
// Per-sample artifacts
|
|
123
|
-
for (const id of ["q1", "q2", "q3", "q4", "q5"]) {
|
|
124
|
-
expect(existsSync(join(outDir, id, "transcript.jsonl"))).toBe(true);
|
|
125
|
-
expect(existsSync(join(outDir, id, "events.jsonl"))).toBe(true);
|
|
126
|
-
expect(existsSync(join(outDir, id, "grades.json"))).toBe(true);
|
|
127
|
-
expect(existsSync(join(outDir, id, "meta.json"))).toBe(true);
|
|
128
|
-
}
|
|
129
|
-
// Run-level artifacts
|
|
130
|
-
expect(existsSync(join(outDir, "run.json"))).toBe(true);
|
|
131
|
-
expect(existsSync(join(outDir, "results.json"))).toBe(true);
|
|
132
|
-
const results = JSON.parse(readFileSync(join(outDir, "results.json"), "utf-8"));
|
|
133
|
-
expect(results.samples).toHaveLength(5);
|
|
134
|
-
expect(results.aggregates.passRate).toBe(1);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("captures grader failures correctly", async () => {
|
|
138
|
-
const outDir = newTempRoot();
|
|
139
|
-
const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
|
|
140
|
-
const samples: Sample[] = [
|
|
141
|
-
{ id: "ok", input: "x", expected_output: "y" },
|
|
142
|
-
{ id: "wrong", input: "x", expected_output: "y" },
|
|
143
|
-
];
|
|
144
|
-
const invoker: AgentInvoker = async ({ sample }) => ({
|
|
145
|
-
agentOutput: sample.id === "ok" ? "y" : "z",
|
|
146
|
-
transcript: [],
|
|
147
|
-
events: [],
|
|
148
|
-
});
|
|
149
|
-
const { compiled } = parseGradersConfig(FIXED_GRADERS);
|
|
150
|
-
const summary = await runEval({
|
|
151
|
-
ir,
|
|
152
|
-
dataset: { name: "mixed", samples: yieldSamples(samples) },
|
|
153
|
-
compiledGraders: compiled,
|
|
154
|
-
opts: { invoker, outDir },
|
|
155
|
-
});
|
|
156
|
-
expect(summary.aggregates.passRate).toBe(0.5);
|
|
157
|
-
expect(summary.samples.find((s) => s.sampleId === "wrong")?.grades.overall.passed).toBe(false);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("invoker errors do not kill the rest of the run", async () => {
|
|
161
|
-
const outDir = newTempRoot();
|
|
162
|
-
const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
|
|
163
|
-
const samples: Sample[] = [
|
|
164
|
-
{ id: "ok", input: "x", expected_output: "y" },
|
|
165
|
-
{ id: "boom", input: "x", expected_output: "y" },
|
|
166
|
-
];
|
|
167
|
-
const invoker: AgentInvoker = async ({ sample }) => {
|
|
168
|
-
if (sample.id === "boom") throw new Error("invoker exploded");
|
|
169
|
-
return { agentOutput: sample.expected_output ?? "", transcript: [], events: [] };
|
|
170
|
-
};
|
|
171
|
-
const { compiled } = parseGradersConfig(FIXED_GRADERS);
|
|
172
|
-
const summary = await runEval({
|
|
173
|
-
ir,
|
|
174
|
-
dataset: { name: "withErr", samples: yieldSamples(samples) },
|
|
175
|
-
compiledGraders: compiled,
|
|
176
|
-
opts: { invoker, outDir },
|
|
177
|
-
});
|
|
178
|
-
expect(summary.samples).toHaveLength(2);
|
|
179
|
-
expect(summary.aggregates.errorCount).toBe(1);
|
|
180
|
-
const boom = summary.samples.find((s) => s.sampleId === "boom");
|
|
181
|
-
expect(boom?.error).toBe("invoker exploded");
|
|
182
|
-
expect(boom?.grades.overall.passed).toBe(false);
|
|
183
|
-
// The good sample still completed and was scored.
|
|
184
|
-
expect(summary.samples.find((s) => s.sampleId === "ok")?.grades.overall.passed).toBe(true);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("rejects empty dataset", async () => {
|
|
188
|
-
const outDir = newTempRoot();
|
|
189
|
-
const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
|
|
190
|
-
const invoker: AgentInvoker = async () => ({
|
|
191
|
-
agentOutput: "",
|
|
192
|
-
transcript: [],
|
|
193
|
-
events: [],
|
|
194
|
-
});
|
|
195
|
-
const { compiled } = parseGradersConfig(FIXED_GRADERS);
|
|
196
|
-
await expect(
|
|
197
|
-
runEval({
|
|
198
|
-
ir,
|
|
199
|
-
dataset: { name: "empty", samples: yieldSamples([]) },
|
|
200
|
-
compiledGraders: compiled,
|
|
201
|
-
opts: { invoker, outDir },
|
|
202
|
-
}),
|
|
203
|
-
).rejects.toThrow(/yielded zero samples/);
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
describe("runEval — T7 200-sample concurrency-8 SLO", () => {
|
|
208
|
-
test("completes 200 samples at concurrency 8 well under 60s", async () => {
|
|
209
|
-
const outDir = newTempRoot();
|
|
210
|
-
const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
|
|
211
|
-
const samples: Sample[] = Array.from({ length: 200 }).map((_, i) => ({
|
|
212
|
-
id: `s${i.toString().padStart(3, "0")}`,
|
|
213
|
-
input: `q${i}`,
|
|
214
|
-
expected_output: `a${i}`,
|
|
215
|
-
}));
|
|
216
|
-
const invoker: AgentInvoker = async ({ sample }) => {
|
|
217
|
-
// Tiny delay to mimic real-world async work without burning CPU.
|
|
218
|
-
await new Promise((r) => setTimeout(r, 1));
|
|
219
|
-
return { agentOutput: sample.expected_output ?? "", transcript: [], events: [] };
|
|
220
|
-
};
|
|
221
|
-
const { compiled } = parseGradersConfig(FIXED_GRADERS);
|
|
222
|
-
|
|
223
|
-
const t0 = performance.now();
|
|
224
|
-
const summary = await runEval({
|
|
225
|
-
ir,
|
|
226
|
-
dataset: { name: "load200", samples: yieldSamples(samples) },
|
|
227
|
-
compiledGraders: compiled,
|
|
228
|
-
opts: { invoker, outDir, concurrency: 8 },
|
|
229
|
-
});
|
|
230
|
-
const elapsedMs = performance.now() - t0;
|
|
231
|
-
|
|
232
|
-
expect(summary.samples).toHaveLength(200);
|
|
233
|
-
expect(summary.aggregates.passRate).toBe(1);
|
|
234
|
-
expect(elapsedMs).toBeLessThan(60_000);
|
|
235
|
-
}, 90_000);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
function mockSample(id: string, passed: boolean, score: number, latencyMs: number, turns: number) {
|
|
239
|
-
return {
|
|
240
|
-
sampleId: id,
|
|
241
|
-
sessionId: `sess_${id.padEnd(16, "0")}`,
|
|
242
|
-
startedAt: "2026-01-01T00:00:00Z",
|
|
243
|
-
endedAt: "2026-01-01T00:00:01Z",
|
|
244
|
-
latencyMs,
|
|
245
|
-
turns,
|
|
246
|
-
tokens: { input: 10, output: 20 },
|
|
247
|
-
model: "claude-opus-4-7",
|
|
248
|
-
agentOutput: "x",
|
|
249
|
-
grades: {
|
|
250
|
-
overall: { passed, score, rationale: "" },
|
|
251
|
-
perGrader: [],
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catalog R-eval `eval-runner` — run an agent against a dataset.
|
|
3
|
-
*
|
|
4
|
-
* Per-sample isolation: fresh `RunContext` (and therefore fresh
|
|
5
|
-
* `TraceEventBus` + `sessionId`) so concurrent samples don't see each
|
|
6
|
-
* other's events. Concurrency is enforced by a tiny in-package semaphore
|
|
7
|
-
* (default 4). Per-sample artifacts persist to
|
|
8
|
-
* `.crewhaus/evals/<runId>/<sampleId>/{transcript.jsonl, events.jsonl,
|
|
9
|
-
* grades.json, meta.json}`. Run-level: `run.json` (config snapshot)
|
|
10
|
-
* + `results.json` (aggregates).
|
|
11
|
-
*
|
|
12
|
-
* `--seed` is honored only for providers that surface temperature
|
|
13
|
-
* reproducibility (Anthropic does not). Document divergence; do not
|
|
14
|
-
* promise byte-identical reruns.
|
|
15
|
-
*
|
|
16
|
-
* `permissionMode` is forced to `"auto"` so `alwaysAsk` rules auto-deny
|
|
17
|
-
* rather than blocking on stdin in a non-interactive eval run.
|
|
18
|
-
*
|
|
19
|
-
* MCP servers are shared across samples (read-mostly assumption). An
|
|
20
|
-
* `isolateMcpPerSample` escape hatch is reserved for future use.
|
|
21
|
-
*
|
|
22
|
-
* Reference: build-roadmap.md §16.
|
|
23
|
-
*/
|
|
24
|
-
import { mkdirSync } from "node:fs";
|
|
25
|
-
import { join } from "node:path";
|
|
26
|
-
import type { Sample } from "@crewhaus/eval-dataset";
|
|
27
|
-
import type { CompiledGrader, Grader } from "@crewhaus/eval-grader";
|
|
28
|
-
import { createJudgeGrader, loadRubric } from "@crewhaus/eval-judge";
|
|
29
|
-
import type { IrV0 } from "@crewhaus/ir";
|
|
30
|
-
import { runChatLoop } from "@crewhaus/runtime-core";
|
|
31
|
-
import { currentTenantContext } from "@crewhaus/tenancy";
|
|
32
|
-
import { aggregate } from "./aggregate";
|
|
33
|
-
import { RunnerError } from "./errors";
|
|
34
|
-
import { runSample } from "./run-sample";
|
|
35
|
-
import { Semaphore } from "./semaphore";
|
|
36
|
-
import type {
|
|
37
|
-
AgentInvoker,
|
|
38
|
-
EvalRunSummary,
|
|
39
|
-
GraderEntry,
|
|
40
|
-
RunEvalOptions,
|
|
41
|
-
SampleResult,
|
|
42
|
-
} from "./types";
|
|
43
|
-
import { type SharedAgentDeps, wireRunOnce } from "./wire-once";
|
|
44
|
-
|
|
45
|
-
export type { AgentInvoker, EvalRunSummary, GraderEntry, RunEvalOptions, SampleResult };
|
|
46
|
-
export type { SharedAgentDeps };
|
|
47
|
-
export { wireRunOnce };
|
|
48
|
-
export { Semaphore };
|
|
49
|
-
export { aggregate };
|
|
50
|
-
export { RunnerError };
|
|
51
|
-
export { runSample };
|
|
52
|
-
|
|
53
|
-
const DEFAULT_CONCURRENCY = 4;
|
|
54
|
-
|
|
55
|
-
export type RunEvalArgs = {
|
|
56
|
-
/** Lowered agent IR (target: cli). Caller is responsible for narrowing. */
|
|
57
|
-
readonly ir: IrV0;
|
|
58
|
-
readonly dataset: { name: string; samples: AsyncIterable<Sample> };
|
|
59
|
-
readonly compiledGraders: ReadonlyArray<CompiledGrader>;
|
|
60
|
-
readonly opts?: RunEvalOptions;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Resolve the eval output directory. When an eval runs inside a tenant scope
|
|
65
|
-
* (e.g. a cloud/managed eval), the tenant's rebased `evalRoot` is used so one
|
|
66
|
-
* tenant's eval artifacts never share a directory with another's; the global
|
|
67
|
-
* default is only used outside any tenant scope (#150). An explicit
|
|
68
|
-
* `optsOutDir` always wins for trusted callers.
|
|
69
|
-
*/
|
|
70
|
-
export function resolveEvalOutDir(runId: string, optsOutDir?: string): string {
|
|
71
|
-
if (optsOutDir !== undefined) return optsOutDir;
|
|
72
|
-
const tenant = currentTenantContext()?.tenant;
|
|
73
|
-
if (tenant !== undefined) return join(tenant.evalRoot, runId);
|
|
74
|
-
return join(".crewhaus", "evals", runId);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Execute an evaluation. Returns a summary; per-sample artifacts and
|
|
79
|
-
* the summary itself are also persisted under `outDir`.
|
|
80
|
-
*/
|
|
81
|
-
export async function runEval(args: RunEvalArgs): Promise<EvalRunSummary> {
|
|
82
|
-
const { ir, dataset, compiledGraders } = args;
|
|
83
|
-
const opts = args.opts ?? {};
|
|
84
|
-
|
|
85
|
-
const runId = opts.runId ?? generateRunId();
|
|
86
|
-
const outDir = resolveEvalOutDir(runId, opts.outDir);
|
|
87
|
-
mkdirSync(outDir, { recursive: true });
|
|
88
|
-
|
|
89
|
-
// Resolve graders. Replace any `llm_judge` placeholder with a real judge
|
|
90
|
-
// grader bound to the runner's judgeModel (or the per-grader override).
|
|
91
|
-
const graders: GraderEntry[] = compiledGraders.map((g) => {
|
|
92
|
-
if (g.judgeSpec) {
|
|
93
|
-
const rubric = loadRubric(g.judgeSpec.rubric);
|
|
94
|
-
const model = g.judgeSpec.model ?? opts.judgeModel;
|
|
95
|
-
const grader = createJudgeGrader(rubric, model !== undefined ? { model } : {});
|
|
96
|
-
return { name: g.name, grader };
|
|
97
|
-
}
|
|
98
|
-
return { name: g.name, grader: g.grader };
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// The default invoker calls runChatLoop with the per-sample fresh runContext.
|
|
102
|
-
const invoker = opts.invoker ?? (await defaultInvoker(ir, opts));
|
|
103
|
-
|
|
104
|
-
const startedAt = new Date().toISOString();
|
|
105
|
-
const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
|
|
106
|
-
const sem = new Semaphore(concurrency);
|
|
107
|
-
|
|
108
|
-
// Persist run-level config snapshot up front so SIGINT mid-run still leaves
|
|
109
|
-
// a usable directory.
|
|
110
|
-
const specHash = await hashSpec(ir);
|
|
111
|
-
await Bun.write(
|
|
112
|
-
join(outDir, "run.json"),
|
|
113
|
-
JSON.stringify(
|
|
114
|
-
{
|
|
115
|
-
runId,
|
|
116
|
-
startedAt,
|
|
117
|
-
specHash,
|
|
118
|
-
datasetName: dataset.name,
|
|
119
|
-
graderNames: graders.map((g) => g.name),
|
|
120
|
-
model: ir.agent.model,
|
|
121
|
-
...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
|
|
122
|
-
concurrency,
|
|
123
|
-
...(opts.seed !== undefined ? { seed: opts.seed } : {}),
|
|
124
|
-
},
|
|
125
|
-
null,
|
|
126
|
-
2,
|
|
127
|
-
),
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
// Materialize the dataset into a list so we can run samples concurrently.
|
|
131
|
-
// For very large datasets the streaming loaders still avoid loading the
|
|
132
|
-
// whole file into memory — but eventually all sample objects sit in RAM.
|
|
133
|
-
const samples: Sample[] = [];
|
|
134
|
-
for await (const s of dataset.samples) samples.push(s);
|
|
135
|
-
if (samples.length === 0) {
|
|
136
|
-
throw new RunnerError(`dataset "${dataset.name}" yielded zero samples`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
let interrupted = false;
|
|
140
|
-
const abortHandler = () => {
|
|
141
|
-
interrupted = true;
|
|
142
|
-
};
|
|
143
|
-
process.once("SIGINT", abortHandler);
|
|
144
|
-
|
|
145
|
-
const settled = await Promise.allSettled(
|
|
146
|
-
samples.map(async (sample) => {
|
|
147
|
-
const release = await sem.acquire();
|
|
148
|
-
// Check *after* acquiring the slot: every callback's synchronous prefix
|
|
149
|
-
// runs during `.map()` (before any SIGINT can fire), so a pre-acquire
|
|
150
|
-
// check would never observe a mid-run interrupt. Samples still queued on
|
|
151
|
-
// the semaphore when SIGINT arrives are skipped here as their turn comes.
|
|
152
|
-
if (interrupted) {
|
|
153
|
-
release();
|
|
154
|
-
throw new RunnerError(`run interrupted before sample "${sample.id}"`);
|
|
155
|
-
}
|
|
156
|
-
try {
|
|
157
|
-
return await runSample({
|
|
158
|
-
sample,
|
|
159
|
-
invoker,
|
|
160
|
-
graders,
|
|
161
|
-
outDir,
|
|
162
|
-
model: ir.agent.model,
|
|
163
|
-
...(opts.seed !== undefined ? { seed: opts.seed } : {}),
|
|
164
|
-
});
|
|
165
|
-
} finally {
|
|
166
|
-
release();
|
|
167
|
-
}
|
|
168
|
-
}),
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
process.removeListener("SIGINT", abortHandler);
|
|
172
|
-
|
|
173
|
-
const results: SampleResult[] = settled.map((r, i) => {
|
|
174
|
-
if (r.status === "fulfilled") return r.value;
|
|
175
|
-
const sample = samples[i];
|
|
176
|
-
return {
|
|
177
|
-
sampleId: sample?.id ?? `unknown-${i}`,
|
|
178
|
-
sessionId: "(unset)",
|
|
179
|
-
startedAt,
|
|
180
|
-
endedAt: new Date().toISOString(),
|
|
181
|
-
latencyMs: 0,
|
|
182
|
-
turns: 0,
|
|
183
|
-
tokens: { input: 0, output: 0 },
|
|
184
|
-
model: ir.agent.model,
|
|
185
|
-
agentOutput: "",
|
|
186
|
-
grades: {
|
|
187
|
-
overall: { passed: false, score: 0, rationale: "sample failed entirely" },
|
|
188
|
-
perGrader: [],
|
|
189
|
-
},
|
|
190
|
-
error: r.reason instanceof Error ? r.reason.message : String(r.reason),
|
|
191
|
-
};
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
const endedAt = new Date().toISOString();
|
|
195
|
-
const aggregates = aggregate(results);
|
|
196
|
-
|
|
197
|
-
const summary: EvalRunSummary = {
|
|
198
|
-
runId,
|
|
199
|
-
startedAt,
|
|
200
|
-
endedAt,
|
|
201
|
-
samples: results,
|
|
202
|
-
aggregates,
|
|
203
|
-
config: {
|
|
204
|
-
specHash,
|
|
205
|
-
datasetName: dataset.name,
|
|
206
|
-
graderNames: graders.map((g) => g.name),
|
|
207
|
-
model: ir.agent.model,
|
|
208
|
-
...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
|
|
209
|
-
concurrency,
|
|
210
|
-
...(opts.seed !== undefined ? { seed: opts.seed } : {}),
|
|
211
|
-
},
|
|
212
|
-
outDir,
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
await Bun.write(join(outDir, "results.json"), JSON.stringify(summary, null, 2));
|
|
216
|
-
|
|
217
|
-
return summary;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async function defaultInvoker(ir: IrV0, opts: RunEvalOptions): Promise<AgentInvoker> {
|
|
221
|
-
const wired: SharedAgentDeps = await wireRunOnce(
|
|
222
|
-
ir,
|
|
223
|
-
opts.cwd !== undefined ? { cwd: opts.cwd } : {},
|
|
224
|
-
);
|
|
225
|
-
return async (req) => {
|
|
226
|
-
const agentOutput = await runChatLoop({
|
|
227
|
-
model: wired.model,
|
|
228
|
-
instructions: wired.instructions,
|
|
229
|
-
tools: [...wired.tools],
|
|
230
|
-
hooks: wired.hooks,
|
|
231
|
-
skills: wired.skills,
|
|
232
|
-
slashCommands: wired.slashCommands,
|
|
233
|
-
...(wired.subAgents !== undefined && wired.spawnSubAgent !== undefined
|
|
234
|
-
? { subAgents: wired.subAgents, spawnSubAgent: wired.spawnSubAgent }
|
|
235
|
-
: {}),
|
|
236
|
-
permissionRules: wired.permissionRules,
|
|
237
|
-
permissionMode: "auto",
|
|
238
|
-
sessionName: `${wired.sessionName}_${req.sample.id}`,
|
|
239
|
-
sessionTarget: wired.sessionTarget,
|
|
240
|
-
runContext: req.runContext,
|
|
241
|
-
sessionRootDir: req.sessionRootDir,
|
|
242
|
-
singleTurn: true,
|
|
243
|
-
seedMessages: [{ role: "user", content: req.sample.input }],
|
|
244
|
-
});
|
|
245
|
-
return { agentOutput };
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function generateRunId(): string {
|
|
250
|
-
const bytes = new Uint8Array(8);
|
|
251
|
-
crypto.getRandomValues(bytes);
|
|
252
|
-
return `run_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
async function hashSpec(ir: IrV0): Promise<string> {
|
|
256
|
-
const text = JSON.stringify({
|
|
257
|
-
name: ir.name,
|
|
258
|
-
target: ir.target,
|
|
259
|
-
model: ir.agent.model,
|
|
260
|
-
instructions: ir.agent.instructions,
|
|
261
|
-
tools: ir.tools,
|
|
262
|
-
});
|
|
263
|
-
return Bun.hash(text).toString(16);
|
|
264
|
-
}
|