@theaiteam/promptdiff 1.0.0-rc.1
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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/SPEC.md +314 -0
- package/package.json +54 -0
- package/promptdiff +4 -0
- package/src/args.ts +83 -0
- package/src/cli.ts +685 -0
- package/src/engine/cache.ts +150 -0
- package/src/engine/compare.ts +563 -0
- package/src/engine/config.ts +502 -0
- package/src/engine/grader.ts +149 -0
- package/src/engine/json-assert.ts +277 -0
- package/src/engine/judge.ts +388 -0
- package/src/engine/receipt.ts +150 -0
- package/src/engine/render.ts +59 -0
- package/src/engine/report.ts +49 -0
- package/src/engine/sandbox.ts +97 -0
- package/src/engine/skill-install.ts +112 -0
- package/src/engine/stats.ts +41 -0
- package/src/prompt.ts +16 -0
- package/src/runner/claude-p.ts +156 -0
- package/src/runner/index.ts +31 -0
- package/src/runner/openai-compat.ts +228 -0
- package/src/types.ts +65 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { assembleSystemPrompt } from "../prompt";
|
|
3
|
+
import type { Runner, RunnerRunOptions, RunResult } from "../types";
|
|
4
|
+
import { buildCacheKey, loadCachedArm, storeCachedArm } from "./cache";
|
|
5
|
+
import type { ArmConfig, CompareConfig, EvalCaseConfig, ScenarioKind } from "./config";
|
|
6
|
+
import { renderStrict, type RenderVars } from "./render";
|
|
7
|
+
import { fisherExactTwoTailedP } from "./stats";
|
|
8
|
+
import { gradeRun, type GradeResult } from "./grader";
|
|
9
|
+
import { assertJudgeCalibrated } from "./judge";
|
|
10
|
+
import { prepareSandbox } from "./sandbox";
|
|
11
|
+
import { installSkills } from "./skill-install";
|
|
12
|
+
|
|
13
|
+
export interface ArmRunSummary {
|
|
14
|
+
run: number;
|
|
15
|
+
pass: boolean;
|
|
16
|
+
grade: GradeResult;
|
|
17
|
+
costUsd: number;
|
|
18
|
+
turns: number;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
sandboxDir: string;
|
|
21
|
+
output: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ArmSummary {
|
|
25
|
+
name: "baseline" | "proposed";
|
|
26
|
+
passes: number;
|
|
27
|
+
totalRuns: number;
|
|
28
|
+
passRate: number;
|
|
29
|
+
totalCostUsd: number;
|
|
30
|
+
runs: ArmRunSummary[];
|
|
31
|
+
/** True when this summary was served from the baseline cache instead of fresh runs. */
|
|
32
|
+
cached?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CaseSummary {
|
|
36
|
+
name: string;
|
|
37
|
+
kind: ScenarioKind;
|
|
38
|
+
baseline: ArmSummary;
|
|
39
|
+
proposed: ArmSummary;
|
|
40
|
+
assertions: string[];
|
|
41
|
+
/** Two-tailed Fisher exact p for the pass/fail table — how easily noise explains the delta. */
|
|
42
|
+
samplingP?: number;
|
|
43
|
+
/** Hashes of each arm's rendered system prompt — pins a result to exact prompt content. */
|
|
44
|
+
promptSha256?: { baseline: string; proposed: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CompareSummary {
|
|
48
|
+
name: string;
|
|
49
|
+
arms: { baseline: ArmConfig; proposed: ArmConfig };
|
|
50
|
+
productionModel?: string;
|
|
51
|
+
cases: CaseSummary[];
|
|
52
|
+
failedAssertions: string[];
|
|
53
|
+
totalCostUsd: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CompareRunOptions {
|
|
57
|
+
config: CompareConfig;
|
|
58
|
+
runners: { baseline: Runner; proposed: Runner };
|
|
59
|
+
onProgress?: (message: string) => void;
|
|
60
|
+
/** Opt-in baseline-arm result cache; hits skip the baseline runs entirely. */
|
|
61
|
+
cache?: { dir: string };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runCompare(options: CompareRunOptions): Promise<CompareSummary> {
|
|
65
|
+
const { config, runners, onProgress, cache } = options;
|
|
66
|
+
// Validate each arm against its own runner — a mixed comparison fails on the
|
|
67
|
+
// violating arm before either arm's paid runs.
|
|
68
|
+
validateRunnerSupport(config, runners.baseline);
|
|
69
|
+
validateRunnerSupport(config, runners.proposed);
|
|
70
|
+
assertJudgeGradersCalibrated(config);
|
|
71
|
+
// Install delivery keeps skill text out of the system prompt entirely — the
|
|
72
|
+
// arms differ only by which skill directory lands in the sandbox registry.
|
|
73
|
+
const inline = config.delivery !== "install";
|
|
74
|
+
const baselinePrompt = assembleSystemPrompt(config.agent, inline ? config.baselineSkills : []);
|
|
75
|
+
const proposedPrompt = assembleSystemPrompt(config.agent, inline ? config.proposedSkills : []);
|
|
76
|
+
// Rendering all cases up front means an unbound placeholder in scenario 3
|
|
77
|
+
// fails here — before scenario 1 spends anything.
|
|
78
|
+
const renderedCases = config.cases.map((evalCase) => renderCase(evalCase, baselinePrompt, proposedPrompt, config));
|
|
79
|
+
const cases: CaseSummary[] = [];
|
|
80
|
+
|
|
81
|
+
for (const { evalCase, baselineSystem, proposedSystem } of renderedCases) {
|
|
82
|
+
onProgress?.(`scenario ${evalCase.name} (${evalCase.kind})`);
|
|
83
|
+
const baseline = await runBaselineArm(config, evalCase, baselineSystem, runners.baseline, cache, onProgress);
|
|
84
|
+
const proposed = await runArm(config, evalCase, "proposed", proposedSystem, runners.proposed, onProgress);
|
|
85
|
+
cases.push({
|
|
86
|
+
name: evalCase.name,
|
|
87
|
+
kind: evalCase.kind,
|
|
88
|
+
baseline,
|
|
89
|
+
proposed,
|
|
90
|
+
assertions: evaluateAssertions(evalCase, baseline, proposed),
|
|
91
|
+
samplingP: fisherExactTwoTailedP(baseline.passes, baseline.totalRuns, proposed.passes, proposed.totalRuns),
|
|
92
|
+
promptSha256: { baseline: sha256(baselineSystem), proposed: sha256(proposedSystem) },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const failedAssertions = cases.flatMap((caseSummary) =>
|
|
97
|
+
caseSummary.assertions.map((assertion) => `${caseSummary.name}: ${assertion}`),
|
|
98
|
+
);
|
|
99
|
+
const totalCostUsd = cases.reduce(
|
|
100
|
+
(sum, caseSummary) => sum + caseSummary.baseline.totalCostUsd + caseSummary.proposed.totalCostUsd,
|
|
101
|
+
0,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: config.name,
|
|
106
|
+
arms: config.arms,
|
|
107
|
+
productionModel: config.productionModel,
|
|
108
|
+
cases,
|
|
109
|
+
failedAssertions,
|
|
110
|
+
totalCostUsd,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatCompareSummary(summary: CompareSummary): string {
|
|
115
|
+
const lines = [`promptdiff compare: ${summary.name}`];
|
|
116
|
+
const baselineLabel = armLabel("baseline", summary.arms);
|
|
117
|
+
const proposedLabel = armLabel("proposed", summary.arms);
|
|
118
|
+
|
|
119
|
+
for (const caseSummary of summary.cases) {
|
|
120
|
+
const deltaPass = caseSummary.proposed.passRate - caseSummary.baseline.passRate;
|
|
121
|
+
const deltaCost = caseSummary.proposed.totalCostUsd - caseSummary.baseline.totalCostUsd;
|
|
122
|
+
lines.push(
|
|
123
|
+
"",
|
|
124
|
+
`${caseSummary.name} (${caseSummary.kind})`,
|
|
125
|
+
` ${baselineLabel}: ${caseSummary.baseline.passes}/${caseSummary.baseline.totalRuns} pass (${formatPct(caseSummary.baseline.passRate)}) | $${caseSummary.baseline.totalCostUsd.toFixed(4)}${caseSummary.baseline.cached ? " (cached)" : ""}`,
|
|
126
|
+
` ${proposedLabel}: ${caseSummary.proposed.passes}/${caseSummary.proposed.totalRuns} pass (${formatPct(caseSummary.proposed.passRate)}) | $${caseSummary.proposed.totalCostUsd.toFixed(4)}`,
|
|
127
|
+
` delta: ${deltaPass >= 0 ? "+" : ""}${formatPct(deltaPass)} pass | ${deltaCost >= 0 ? "+" : ""}$${deltaCost.toFixed(4)}`,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (caseSummary.assertions.length > 0) {
|
|
131
|
+
for (const assertion of caseSummary.assertions) {
|
|
132
|
+
lines.push(` FAIL: ${assertion}`);
|
|
133
|
+
}
|
|
134
|
+
} else if (caseSummary.kind === "compare") {
|
|
135
|
+
lines.push(" INFO: no assertion (kind \"compare\")");
|
|
136
|
+
} else {
|
|
137
|
+
lines.push(" PASS: assertions satisfied");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A pass-rate delta that noise explains must not read like a receipt:
|
|
141
|
+
// 1/3 → 2/3 passes the target assertion but is close to a coin flip.
|
|
142
|
+
if (
|
|
143
|
+
caseSummary.samplingP !== undefined &&
|
|
144
|
+
caseSummary.baseline.passes !== caseSummary.proposed.passes &&
|
|
145
|
+
caseSummary.samplingP > 0.05
|
|
146
|
+
) {
|
|
147
|
+
lines.push(
|
|
148
|
+
` NOTE: delta could be sampling noise (Fisher exact p=${caseSummary.samplingP.toFixed(2)}) — consider more runs`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Failing runs carry their grader evidence into the summary — diagnosing
|
|
153
|
+
// WHICH check missed used to require re-running with --keep-sandbox.
|
|
154
|
+
for (const arm of [caseSummary.baseline, caseSummary.proposed]) {
|
|
155
|
+
for (const run of arm.runs) {
|
|
156
|
+
if (run.pass) continue;
|
|
157
|
+
lines.push(` ${arm.name} run ${run.run} failed: ${run.grade.message}`);
|
|
158
|
+
lines.push(...graderEvidence(run.grade));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push("", `total cost: $${summary.totalCostUsd.toFixed(4)}`);
|
|
164
|
+
if ((summary.arms.baseline.runner === "openai") !== (summary.arms.proposed.runner === "openai")) {
|
|
165
|
+
lines.push('note: cost columns may not be comparable — openai arms report $0 unless "pricing" is set');
|
|
166
|
+
}
|
|
167
|
+
// A pass on the wrong model validates prompt logic, not production behavior —
|
|
168
|
+
// that divergence must be on the receipt, not in a README caveat.
|
|
169
|
+
for (const armName of ["baseline", "proposed"] as const) {
|
|
170
|
+
const model = summary.arms[armName].model;
|
|
171
|
+
if (summary.productionModel !== undefined && model !== summary.productionModel) {
|
|
172
|
+
lines.push(
|
|
173
|
+
`warning: ${armName} tests "${model}" but production model is "${summary.productionModel}" — this validates prompt logic, not production behavior`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (summary.failedAssertions.length > 0) {
|
|
178
|
+
lines.push("failed assertions:");
|
|
179
|
+
for (const assertion of summary.failedAssertions) {
|
|
180
|
+
lines.push(` - ${assertion}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sha256(text: string): string {
|
|
188
|
+
return createHash("sha256").update(text).digest("hex");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const EVIDENCE_TAIL_LINES = 6;
|
|
192
|
+
const EVIDENCE_MAX_CHARS = 700;
|
|
193
|
+
|
|
194
|
+
/** Last few lines of a failing run's grader stdout/stderr, indented for the summary. */
|
|
195
|
+
function graderEvidence(grade: GradeResult): string[] {
|
|
196
|
+
const streams: Array<[string, string | undefined]> = [
|
|
197
|
+
["stdout", grade.stdout],
|
|
198
|
+
["stderr", grade.stderr],
|
|
199
|
+
];
|
|
200
|
+
const lines: string[] = [];
|
|
201
|
+
for (const [stream, content] of streams) {
|
|
202
|
+
const trimmed = content?.trim();
|
|
203
|
+
if (!trimmed) continue;
|
|
204
|
+
const tail = trimmed.split("\n").slice(-EVIDENCE_TAIL_LINES).join("\n").slice(-EVIDENCE_MAX_CHARS);
|
|
205
|
+
lines.push(` grader ${stream}:`);
|
|
206
|
+
lines.push(...tail.split("\n").map((line) => ` ${line}`));
|
|
207
|
+
}
|
|
208
|
+
return lines;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Bare arm name when the arms match; annotated with model (and runner when runners differ) otherwise. */
|
|
212
|
+
function armLabel(name: "baseline" | "proposed", arms: { baseline: ArmConfig; proposed: ArmConfig }): string {
|
|
213
|
+
const runnersDiffer = arms.baseline.runner !== arms.proposed.runner;
|
|
214
|
+
if (!runnersDiffer && arms.baseline.model === arms.proposed.model) return name;
|
|
215
|
+
const arm = arms[name];
|
|
216
|
+
return runnersDiffer ? `${name} (${arm.model} via ${arm.runner})` : `${name} (${arm.model})`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The judge calibration gate, run alongside validateRunnerSupport — BEFORE any
|
|
221
|
+
* paid run. Missing, stale, mismatched, or below-bar calibration refuses the
|
|
222
|
+
* whole comparison; an unproven judge must never certify a result.
|
|
223
|
+
*/
|
|
224
|
+
function assertJudgeGradersCalibrated(config: CompareConfig): void {
|
|
225
|
+
for (const evalCase of config.cases) {
|
|
226
|
+
if (evalCase.grader.type === "judge") {
|
|
227
|
+
assertJudgeCalibrated(evalCase.grader);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Rejects scenario demands the runner cannot honor — before any paid run,
|
|
234
|
+
* instead of mid-comparison or (worse) via a silently tool-less arm.
|
|
235
|
+
*/
|
|
236
|
+
export function validateRunnerSupport(config: CompareConfig, runner: Runner): void {
|
|
237
|
+
if (config.delivery === "install" && !runner.capabilities.skillRegistry) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`delivery "install" needs a runner with a skill registry; runner "${runner.name}" has none (use claude-p)`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
for (const evalCase of config.cases) {
|
|
243
|
+
if (evalCase.images.length > 0 && !runner.capabilities.images) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`runner "${runner.name}" cannot attach images, but scenario "${evalCase.name}" has \`images\` — use the openai runner with a vision model`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (runner.capabilities.sandboxTools) return;
|
|
250
|
+
for (const evalCase of config.cases) {
|
|
251
|
+
if (effectiveTools(config, evalCase) !== "") {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`runner "${runner.name}" is text-only, but scenario "${evalCase.name}" needs sandbox tools ` +
|
|
254
|
+
`(artifact mode, a command grader, or an explicit tools list) — use claude-p or make the scenario text-graded`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface MeasureCaseSummary {
|
|
261
|
+
name: string;
|
|
262
|
+
kind: ScenarioKind;
|
|
263
|
+
result: ArmSummary;
|
|
264
|
+
/** Hash of the rendered system prompt this case measured. */
|
|
265
|
+
promptSha256: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface MeasureSummary {
|
|
269
|
+
name: string;
|
|
270
|
+
arm: ArmConfig;
|
|
271
|
+
productionModel?: string;
|
|
272
|
+
cases: MeasureCaseSummary[];
|
|
273
|
+
totalCostUsd: number;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface MeasureRunOptions {
|
|
277
|
+
config: CompareConfig;
|
|
278
|
+
runner: Runner;
|
|
279
|
+
onProgress?: (message: string) => void;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Characterizes ONE instruction set: per-case pass rates, no delta and no
|
|
284
|
+
* assertions. The "know the current survival rate before you change anything"
|
|
285
|
+
* half of prompt testing — faking it with identical compare arms produces
|
|
286
|
+
* nonsense verdicts from sampling noise.
|
|
287
|
+
*/
|
|
288
|
+
export async function runMeasure(options: MeasureRunOptions): Promise<MeasureSummary> {
|
|
289
|
+
const { config, runner, onProgress } = options;
|
|
290
|
+
validateRunnerSupport(config, runner);
|
|
291
|
+
assertJudgeGradersCalibrated(config);
|
|
292
|
+
const inline = config.delivery !== "install";
|
|
293
|
+
const basePrompt = assembleSystemPrompt(config.agent, inline ? config.baselineSkills : []);
|
|
294
|
+
// Same fail-before-any-paid-run guarantee as compare: render everything first.
|
|
295
|
+
const rendered = config.cases.map((evalCase) => {
|
|
296
|
+
const vars = mergedRenderVars(config, evalCase);
|
|
297
|
+
if (vars === undefined) return { evalCase, systemPrompt: basePrompt };
|
|
298
|
+
return {
|
|
299
|
+
evalCase: { ...evalCase, prompt: renderStrict(evalCase.prompt, vars, `scenario "${evalCase.name}" prompt`) },
|
|
300
|
+
systemPrompt: renderStrict(basePrompt, vars, `scenario "${evalCase.name}" system prompt`),
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const cases: MeasureCaseSummary[] = [];
|
|
305
|
+
for (const { evalCase, systemPrompt } of rendered) {
|
|
306
|
+
onProgress?.(`scenario ${evalCase.name}`);
|
|
307
|
+
const result = await runArm(config, evalCase, "baseline", systemPrompt, runner, onProgress, "measure");
|
|
308
|
+
cases.push({ name: evalCase.name, kind: evalCase.kind, result, promptSha256: sha256(systemPrompt) });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
name: config.name,
|
|
313
|
+
arm: config.arms.baseline,
|
|
314
|
+
productionModel: config.productionModel,
|
|
315
|
+
cases,
|
|
316
|
+
totalCostUsd: cases.reduce((sum, caseSummary) => sum + caseSummary.result.totalCostUsd, 0),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function formatMeasureSummary(summary: MeasureSummary): string {
|
|
321
|
+
const lines = [`promptdiff measure: ${summary.name} (${summary.arm.model} via ${summary.arm.runner})`];
|
|
322
|
+
|
|
323
|
+
for (const caseSummary of summary.cases) {
|
|
324
|
+
const result = caseSummary.result;
|
|
325
|
+
lines.push(
|
|
326
|
+
"",
|
|
327
|
+
caseSummary.name,
|
|
328
|
+
` ${result.passes}/${result.totalRuns} pass (${formatPct(result.passRate)}) | $${result.totalCostUsd.toFixed(4)}`,
|
|
329
|
+
);
|
|
330
|
+
for (const run of result.runs) {
|
|
331
|
+
if (run.pass) continue;
|
|
332
|
+
lines.push(` run ${run.run} failed: ${run.grade.message}`);
|
|
333
|
+
lines.push(...graderEvidence(run.grade));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
lines.push("", `total cost: $${summary.totalCostUsd.toFixed(4)}`);
|
|
338
|
+
if (summary.productionModel !== undefined && summary.arm.model !== summary.productionModel) {
|
|
339
|
+
lines.push(
|
|
340
|
+
`warning: measured "${summary.arm.model}" but production model is "${summary.productionModel}" — this characterizes prompt logic, not production behavior`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return lines.join("\n");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface RenderedCase {
|
|
347
|
+
evalCase: EvalCaseConfig;
|
|
348
|
+
baselineSystem: string;
|
|
349
|
+
proposedSystem: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Applies merged render vars (scenario over top-level) to both arms' system prompts and the scenario prompt. */
|
|
353
|
+
function renderCase(
|
|
354
|
+
evalCase: EvalCaseConfig,
|
|
355
|
+
baselinePrompt: string,
|
|
356
|
+
proposedPrompt: string,
|
|
357
|
+
config: CompareConfig,
|
|
358
|
+
): RenderedCase {
|
|
359
|
+
const vars = mergedRenderVars(config, evalCase);
|
|
360
|
+
if (vars === undefined) {
|
|
361
|
+
return { evalCase, baselineSystem: baselinePrompt, proposedSystem: proposedPrompt };
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
evalCase: { ...evalCase, prompt: renderStrict(evalCase.prompt, vars, `scenario "${evalCase.name}" prompt`) },
|
|
365
|
+
baselineSystem: renderStrict(baselinePrompt, vars, `scenario "${evalCase.name}" baseline system prompt`),
|
|
366
|
+
proposedSystem: renderStrict(proposedPrompt, vars, `scenario "${evalCase.name}" proposed system prompt`),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Undefined when neither level defines render — rendering (and its strictness) is opt-in. */
|
|
371
|
+
function mergedRenderVars(config: CompareConfig, evalCase: EvalCaseConfig): RenderVars | undefined {
|
|
372
|
+
if (config.renderVars === undefined && evalCase.renderVars === undefined) return undefined;
|
|
373
|
+
return { ...config.renderVars, ...evalCase.renderVars };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Baseline runs through the opt-in cache when one is configured: a hit reuses
|
|
378
|
+
* the recorded ArmSummary (real graded runs, just not re-paid), a miss runs
|
|
379
|
+
* normally and records the result. Cache mechanics stay out of runArm.
|
|
380
|
+
*/
|
|
381
|
+
async function runBaselineArm(
|
|
382
|
+
config: CompareConfig,
|
|
383
|
+
evalCase: EvalCaseConfig,
|
|
384
|
+
systemPrompt: string,
|
|
385
|
+
runner: Runner,
|
|
386
|
+
cache: { dir: string } | undefined,
|
|
387
|
+
onProgress?: (message: string) => void,
|
|
388
|
+
): Promise<ArmSummary> {
|
|
389
|
+
if (cache === undefined) {
|
|
390
|
+
return runArm(config, evalCase, "baseline", systemPrompt, runner, onProgress);
|
|
391
|
+
}
|
|
392
|
+
const key = buildCacheKey({
|
|
393
|
+
systemPrompt,
|
|
394
|
+
casePrompt: evalCase.prompt,
|
|
395
|
+
arm: config.arms.baseline,
|
|
396
|
+
runs: evalCase.runs ?? config.runs,
|
|
397
|
+
tools: effectiveTools(config, evalCase),
|
|
398
|
+
mode: effectiveMode(config, evalCase),
|
|
399
|
+
delivery: config.delivery,
|
|
400
|
+
grader: evalCase.grader,
|
|
401
|
+
images: evalCase.images,
|
|
402
|
+
seedDir: evalCase.seed ?? config.sandboxSeed,
|
|
403
|
+
baselineSkills: config.baselineSkills,
|
|
404
|
+
});
|
|
405
|
+
const hit = loadCachedArm(cache.dir, key);
|
|
406
|
+
if (hit !== undefined) {
|
|
407
|
+
onProgress?.(` baseline: cache hit (${key.slice(0, 12)})`);
|
|
408
|
+
return { ...hit, cached: true };
|
|
409
|
+
}
|
|
410
|
+
const summary = await runArm(config, evalCase, "baseline", systemPrompt, runner, onProgress);
|
|
411
|
+
storeCachedArm(cache.dir, key, summary);
|
|
412
|
+
return summary;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function runArm(
|
|
416
|
+
config: CompareConfig,
|
|
417
|
+
evalCase: EvalCaseConfig,
|
|
418
|
+
arm: "baseline" | "proposed",
|
|
419
|
+
systemPrompt: string,
|
|
420
|
+
runner: Runner,
|
|
421
|
+
onProgress?: (message: string) => void,
|
|
422
|
+
label: string = arm,
|
|
423
|
+
): Promise<ArmSummary> {
|
|
424
|
+
const runs = evalCase.runs ?? config.runs;
|
|
425
|
+
const summaries: ArmRunSummary[] = [];
|
|
426
|
+
const armSkills = arm === "baseline" ? config.baselineSkills : config.proposedSkills;
|
|
427
|
+
|
|
428
|
+
for (let index = 0; index < runs; index += 1) {
|
|
429
|
+
const sandbox = prepareSandbox({
|
|
430
|
+
root: config.sandboxRoot,
|
|
431
|
+
seed: evalCase.seed ?? config.sandboxSeed,
|
|
432
|
+
prefix: `${evalCase.name}-${label}-${index + 1}`,
|
|
433
|
+
keep: config.keepSandbox,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
onProgress?.(` ${label} run ${index + 1}/${runs}`);
|
|
438
|
+
if (config.delivery === "install") {
|
|
439
|
+
const { installed, warnings } = installSkills(armSkills, sandbox.dir);
|
|
440
|
+
if (index === 0) {
|
|
441
|
+
onProgress?.(` ${label} installed skills: ${installed.map((skill) => skill.name).join(", ")}`);
|
|
442
|
+
for (const warning of warnings) {
|
|
443
|
+
onProgress?.(` WARNING: ${warning}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const run = await runner.run(buildRunnerOptions(config, evalCase, arm, systemPrompt, sandbox.dir));
|
|
448
|
+
const grade = await gradeRun(evalCase.grader, {
|
|
449
|
+
run,
|
|
450
|
+
sandboxDir: sandbox.dir,
|
|
451
|
+
timeoutMs: config.timeoutMs,
|
|
452
|
+
maxBudgetUsd: config.maxBudgetUsd,
|
|
453
|
+
});
|
|
454
|
+
summaries.push(toArmRunSummary(index + 1, run, grade, sandbox.dir));
|
|
455
|
+
} finally {
|
|
456
|
+
sandbox.cleanup();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const passes = summaries.filter((summary) => summary.pass).length;
|
|
461
|
+
const totalCostUsd = summaries.reduce((sum, summary) => sum + summary.costUsd, 0);
|
|
462
|
+
return {
|
|
463
|
+
name: arm,
|
|
464
|
+
passes,
|
|
465
|
+
totalRuns: runs,
|
|
466
|
+
passRate: passes / runs,
|
|
467
|
+
totalCostUsd,
|
|
468
|
+
runs: summaries,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function buildRunnerOptions(
|
|
473
|
+
config: CompareConfig,
|
|
474
|
+
evalCase: EvalCaseConfig,
|
|
475
|
+
arm: "baseline" | "proposed",
|
|
476
|
+
systemPrompt: string,
|
|
477
|
+
cwd: string,
|
|
478
|
+
): RunnerRunOptions {
|
|
479
|
+
return {
|
|
480
|
+
systemPrompt,
|
|
481
|
+
systemPromptMode: config.delivery === "install" ? "append" : "replace",
|
|
482
|
+
userPrompt: evalCase.prompt,
|
|
483
|
+
images: evalCase.images,
|
|
484
|
+
requestParams: config.requestParams,
|
|
485
|
+
model: config.arms[arm].model,
|
|
486
|
+
cwd,
|
|
487
|
+
addDirs: [...config.addDirs, ...evalCase.addDirs],
|
|
488
|
+
tools: effectiveTools(config, evalCase),
|
|
489
|
+
timeoutMs: config.timeoutMs,
|
|
490
|
+
maxBudgetUsd: config.maxBudgetUsd,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function effectiveTools(config: CompareConfig, evalCase: EvalCaseConfig): string {
|
|
495
|
+
// Install delivery always needs tools (the Skill tool does the triggering),
|
|
496
|
+
// even when the grader is text-only and inline delivery would disable them.
|
|
497
|
+
const fallbackTools = config.delivery === "install" ? "default" : defaultTools(effectiveMode(config, evalCase));
|
|
498
|
+
return evalCase.tools ?? config.tools ?? fallbackTools;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function effectiveMode(config: CompareConfig, evalCase: EvalCaseConfig): "text" | "artifact" {
|
|
502
|
+
return evalCase.mode ?? config.mode ?? inferMode(evalCase);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function toArmRunSummary(
|
|
506
|
+
runNumber: number,
|
|
507
|
+
run: RunResult,
|
|
508
|
+
grade: GradeResult,
|
|
509
|
+
sandboxDir: string,
|
|
510
|
+
): ArmRunSummary {
|
|
511
|
+
return {
|
|
512
|
+
run: runNumber,
|
|
513
|
+
pass: grade.pass,
|
|
514
|
+
grade,
|
|
515
|
+
// Judge graders bill their own model call; totals stay honest only if it counts.
|
|
516
|
+
costUsd: run.costUsd + (grade.costUsd ?? 0),
|
|
517
|
+
turns: run.turns,
|
|
518
|
+
durationMs: run.durationMs,
|
|
519
|
+
sandboxDir,
|
|
520
|
+
output: run.output,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function evaluateAssertions(
|
|
525
|
+
evalCase: EvalCaseConfig,
|
|
526
|
+
baseline: ArmSummary,
|
|
527
|
+
proposed: ArmSummary,
|
|
528
|
+
): string[] {
|
|
529
|
+
// "compare" scenarios report pass rates and the delta with no directional claim.
|
|
530
|
+
if (evalCase.kind === "compare") {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (evalCase.kind === "target") {
|
|
535
|
+
const failures = [];
|
|
536
|
+
if (baseline.passRate >= 1) {
|
|
537
|
+
failures.push("baseline fully passed; the target gap was not reproduced");
|
|
538
|
+
}
|
|
539
|
+
if (proposed.passRate <= baseline.passRate) {
|
|
540
|
+
failures.push("proposed did not improve the target pass rate");
|
|
541
|
+
}
|
|
542
|
+
return failures;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (proposed.passRate < baseline.passRate) {
|
|
546
|
+
return ["proposed regressed below baseline pass rate"];
|
|
547
|
+
}
|
|
548
|
+
return [];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function inferMode(evalCase: EvalCaseConfig): "text" | "artifact" {
|
|
552
|
+
// text, json, and judge graders inspect the run's output only, so they
|
|
553
|
+
// demand no sandbox tools and stay valid on text-only runners (openai).
|
|
554
|
+
return evalCase.grader.type === "command" ? "artifact" : "text";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function defaultTools(mode: "text" | "artifact"): string {
|
|
558
|
+
return mode === "text" ? "" : "default";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function formatPct(value: number): string {
|
|
562
|
+
return `${(value * 100).toFixed(0)}%`;
|
|
563
|
+
}
|