@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,502 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import type { GraderSpec } from "./grader";
|
|
4
|
+
import { parseAssertion } from "./json-assert";
|
|
5
|
+
import { resolveRenderVars, type RenderVars } from "./render";
|
|
6
|
+
import { deliveryValue, type Delivery } from "./skill-install";
|
|
7
|
+
import { runnerNameValue, type RunnerName } from "../runner";
|
|
8
|
+
import type { ModelPricing } from "../runner/openai-compat";
|
|
9
|
+
import type { RunMode } from "../types";
|
|
10
|
+
|
|
11
|
+
export type ScenarioKind = "target" | "regression" | "compare";
|
|
12
|
+
|
|
13
|
+
export interface EvalCaseConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
kind: ScenarioKind;
|
|
16
|
+
prompt: string;
|
|
17
|
+
grader: GraderSpec;
|
|
18
|
+
/** Image file paths attached to the user message (vision evals; openai runner only). */
|
|
19
|
+
images: string[];
|
|
20
|
+
/** Per-scenario template bindings; merged over the top-level set (scenario wins). */
|
|
21
|
+
renderVars?: RenderVars;
|
|
22
|
+
runs?: number;
|
|
23
|
+
seed?: string;
|
|
24
|
+
addDirs: string[];
|
|
25
|
+
mode?: RunMode;
|
|
26
|
+
tools?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Model/runner/endpoint one arm runs against, resolved from per-arm and shared fields. */
|
|
30
|
+
export interface ArmConfig {
|
|
31
|
+
model: string;
|
|
32
|
+
runner: RunnerName;
|
|
33
|
+
/** OpenAI-compatible endpoint base URL; only meaningful for the openai runner. */
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CompareConfig {
|
|
38
|
+
name: string;
|
|
39
|
+
agent: string;
|
|
40
|
+
baselineSkills: string[];
|
|
41
|
+
proposedSkills: string[];
|
|
42
|
+
delivery: Delivery;
|
|
43
|
+
arms: { baseline: ArmConfig; proposed: ArmConfig };
|
|
44
|
+
/** Extra chat-request body fields (max_tokens, temperature, ...); openai runner only. */
|
|
45
|
+
requestParams?: Record<string, unknown>;
|
|
46
|
+
/** Transient-failure retries per model call; openai runner only. Default 2. */
|
|
47
|
+
retries?: number;
|
|
48
|
+
/**
|
|
49
|
+
* USD per million input/output tokens, keyed by model name — prices openai
|
|
50
|
+
* arms from response usage so maxBudgetUsd enforces. claude-p prices itself.
|
|
51
|
+
*/
|
|
52
|
+
pricing?: Record<string, ModelPricing>;
|
|
53
|
+
/**
|
|
54
|
+
* Template bindings for {{name}} placeholders in the agent, inlined skills,
|
|
55
|
+
* and scenario prompts. Presence (even empty) turns on strict rendering:
|
|
56
|
+
* unbound placeholders fail before any paid run.
|
|
57
|
+
*/
|
|
58
|
+
renderVars?: RenderVars;
|
|
59
|
+
/** Model that runs this prompt in production; arms testing a different model get flagged. */
|
|
60
|
+
productionModel?: string;
|
|
61
|
+
runs: number;
|
|
62
|
+
timeoutMs: number;
|
|
63
|
+
maxBudgetUsd: number;
|
|
64
|
+
mode?: RunMode;
|
|
65
|
+
tools?: string;
|
|
66
|
+
addDirs: string[];
|
|
67
|
+
sandboxRoot: string;
|
|
68
|
+
sandboxSeed?: string;
|
|
69
|
+
keepSandbox: boolean;
|
|
70
|
+
cases: EvalCaseConfig[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CompareOverrides {
|
|
74
|
+
agent?: string;
|
|
75
|
+
baselineSkills?: string[];
|
|
76
|
+
proposedSkills?: string[];
|
|
77
|
+
delivery?: Delivery;
|
|
78
|
+
runner?: RunnerName;
|
|
79
|
+
baselineRunner?: RunnerName;
|
|
80
|
+
proposedRunner?: RunnerName;
|
|
81
|
+
baseUrl?: string;
|
|
82
|
+
model?: string;
|
|
83
|
+
baselineModel?: string;
|
|
84
|
+
proposedModel?: string;
|
|
85
|
+
runs?: number;
|
|
86
|
+
timeoutMs?: number;
|
|
87
|
+
maxBudgetUsd?: number;
|
|
88
|
+
mode?: RunMode;
|
|
89
|
+
tools?: string;
|
|
90
|
+
addDirs?: string[];
|
|
91
|
+
sandboxRoot?: string;
|
|
92
|
+
sandboxSeed?: string;
|
|
93
|
+
keepSandbox?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface RawCompareConfig {
|
|
97
|
+
name?: unknown;
|
|
98
|
+
agent?: unknown;
|
|
99
|
+
skills?: unknown;
|
|
100
|
+
baselineSkills?: unknown;
|
|
101
|
+
proposedSkills?: unknown;
|
|
102
|
+
baseline?: unknown;
|
|
103
|
+
proposed?: unknown;
|
|
104
|
+
delivery?: unknown;
|
|
105
|
+
runner?: unknown;
|
|
106
|
+
baseUrl?: unknown;
|
|
107
|
+
requestParams?: unknown;
|
|
108
|
+
retries?: unknown;
|
|
109
|
+
pricing?: unknown;
|
|
110
|
+
render?: unknown;
|
|
111
|
+
model?: unknown;
|
|
112
|
+
productionModel?: unknown;
|
|
113
|
+
runs?: unknown;
|
|
114
|
+
timeoutMs?: unknown;
|
|
115
|
+
maxBudgetUsd?: unknown;
|
|
116
|
+
mode?: unknown;
|
|
117
|
+
tools?: unknown;
|
|
118
|
+
addDirs?: unknown;
|
|
119
|
+
sandbox?: unknown;
|
|
120
|
+
prompt?: unknown;
|
|
121
|
+
promptFile?: unknown;
|
|
122
|
+
grader?: unknown;
|
|
123
|
+
scenarios?: unknown;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface RawCase {
|
|
127
|
+
name?: unknown;
|
|
128
|
+
kind?: unknown;
|
|
129
|
+
prompt?: unknown;
|
|
130
|
+
promptFile?: unknown;
|
|
131
|
+
grader?: unknown;
|
|
132
|
+
images?: unknown;
|
|
133
|
+
render?: unknown;
|
|
134
|
+
runs?: unknown;
|
|
135
|
+
seed?: unknown;
|
|
136
|
+
addDirs?: unknown;
|
|
137
|
+
mode?: unknown;
|
|
138
|
+
tools?: unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface LoadOptions {
|
|
142
|
+
/**
|
|
143
|
+
* measure mode: only one instruction set is exercised, so a scenario with
|
|
144
|
+
* just `skills` or `baselineSkills` loads without a proposed set (the
|
|
145
|
+
* unused proposed arm mirrors baseline).
|
|
146
|
+
*/
|
|
147
|
+
singleArm?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function loadCompareConfig(
|
|
151
|
+
path: string,
|
|
152
|
+
overrides: CompareOverrides = {},
|
|
153
|
+
options: LoadOptions = {},
|
|
154
|
+
): CompareConfig {
|
|
155
|
+
const configPath = resolve(path);
|
|
156
|
+
const baseDir = dirname(configPath);
|
|
157
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8")) as RawCompareConfig;
|
|
158
|
+
|
|
159
|
+
const sharedSkills = raw.skills === undefined ? undefined : stringArray(raw.skills, "skills");
|
|
160
|
+
const rawBaseline = raw.baselineSkills ?? raw.baseline;
|
|
161
|
+
const rawProposed = raw.proposedSkills ?? raw.proposed;
|
|
162
|
+
const baselineSkills = overrides.baselineSkills ?? normalizeSkills(rawBaseline, "baseline", sharedSkills);
|
|
163
|
+
const proposedSkills =
|
|
164
|
+
overrides.proposedSkills ??
|
|
165
|
+
(options.singleArm && rawProposed === undefined && sharedSkills === undefined
|
|
166
|
+
? baselineSkills
|
|
167
|
+
: normalizeSkills(rawProposed, "proposed", sharedSkills));
|
|
168
|
+
|
|
169
|
+
const rawSandbox = isRecord(raw.sandbox) ? raw.sandbox : {};
|
|
170
|
+
const sandboxRoot = overrides.sandboxRoot ?? resolveFrom(baseDir, stringValue(rawSandbox.root, ".promptdiff/runs"));
|
|
171
|
+
const sandboxSeed =
|
|
172
|
+
overrides.sandboxSeed ??
|
|
173
|
+
optionalPath(baseDir, stringValue(rawSandbox.seed, undefined));
|
|
174
|
+
|
|
175
|
+
const topLevelCase = raw.prompt !== undefined || raw.promptFile !== undefined || raw.grader !== undefined;
|
|
176
|
+
const rawCases = Array.isArray(raw.scenarios)
|
|
177
|
+
? raw.scenarios
|
|
178
|
+
: topLevelCase
|
|
179
|
+
? [{ name: raw.name, prompt: raw.prompt, promptFile: raw.promptFile, grader: raw.grader, images: (raw as RawCase).images }]
|
|
180
|
+
: [];
|
|
181
|
+
|
|
182
|
+
if (rawCases.length === 0) {
|
|
183
|
+
throw new Error("compare scenario must define `scenarios` or top-level `prompt` + `grader`");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const config: CompareConfig = {
|
|
187
|
+
name: stringValue(raw.name, "promptdiff comparison"),
|
|
188
|
+
agent: resolveRequired(baseDir, overrides.agent ?? stringValue(raw.agent, undefined), "agent"),
|
|
189
|
+
baselineSkills: baselineSkills.map((skill) => resolveFrom(baseDir, skill)),
|
|
190
|
+
proposedSkills: proposedSkills.map((skill) => resolveFrom(baseDir, skill)),
|
|
191
|
+
delivery: overrides.delivery ?? deliveryValue(raw.delivery, "inline"),
|
|
192
|
+
arms: {
|
|
193
|
+
baseline: resolveArm("baseline", armRecord(rawBaseline), raw, overrides),
|
|
194
|
+
proposed: resolveArm("proposed", armRecord(rawProposed), raw, overrides),
|
|
195
|
+
},
|
|
196
|
+
requestParams: raw.requestParams === undefined ? undefined : recordOf(raw.requestParams, "requestParams"),
|
|
197
|
+
retries: raw.retries === undefined ? undefined : numberValue(raw.retries, 0),
|
|
198
|
+
pricing: pricingValue(raw.pricing),
|
|
199
|
+
renderVars: renderValue(raw.render, baseDir, "render"),
|
|
200
|
+
productionModel: stringValue(raw.productionModel, undefined),
|
|
201
|
+
runs: overrides.runs ?? numberValue(raw.runs, 5),
|
|
202
|
+
timeoutMs: overrides.timeoutMs ?? numberValue(raw.timeoutMs, 600_000),
|
|
203
|
+
maxBudgetUsd: overrides.maxBudgetUsd ?? numberValue(raw.maxBudgetUsd, 1),
|
|
204
|
+
mode: overrides.mode ?? modeValue(raw.mode, undefined),
|
|
205
|
+
tools: overrides.tools ?? stringValue(raw.tools, undefined),
|
|
206
|
+
addDirs: (overrides.addDirs ?? stringArray(raw.addDirs, "addDirs")).map((dir) => resolveFrom(baseDir, dir)),
|
|
207
|
+
sandboxRoot,
|
|
208
|
+
sandboxSeed,
|
|
209
|
+
keepSandbox: overrides.keepSandbox ?? booleanValue(rawSandbox.keep, false),
|
|
210
|
+
cases: rawCases.map((rawCase, index) => normalizeCase(baseDir, recordValue(rawCase, `scenarios[${index}]`), index)),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
validateCompareConfig(config);
|
|
214
|
+
return config;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeCase(baseDir: string, raw: RawCase, index: number): EvalCaseConfig {
|
|
218
|
+
const name = stringValue(raw.name, `scenario-${index + 1}`);
|
|
219
|
+
const prompt = raw.promptFile
|
|
220
|
+
? readFileSync(resolveFrom(baseDir, requiredString(raw.promptFile, `${name}.promptFile`)), "utf8")
|
|
221
|
+
: requiredString(raw.prompt, `${name}.prompt`);
|
|
222
|
+
return {
|
|
223
|
+
name,
|
|
224
|
+
kind: kindValue(raw.kind, index === 0 ? "target" : "regression"),
|
|
225
|
+
prompt,
|
|
226
|
+
grader: graderValue(raw.grader, name, baseDir),
|
|
227
|
+
images: stringArray(raw.images, `${name}.images`).map((image) => resolveFrom(baseDir, image)),
|
|
228
|
+
renderVars: renderValue(raw.render, baseDir, `${name}.render`),
|
|
229
|
+
runs: optionalNumber(raw.runs, `${name}.runs`),
|
|
230
|
+
seed: optionalPath(baseDir, stringValue(raw.seed, undefined)),
|
|
231
|
+
addDirs: stringArray(raw.addDirs, `${name}.addDirs`).map((dir) => resolveFrom(baseDir, dir)),
|
|
232
|
+
mode: modeValue(raw.mode, undefined),
|
|
233
|
+
tools: stringValue(raw.tools, undefined),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function validateCompareConfig(config: CompareConfig): void {
|
|
238
|
+
if (config.baselineSkills.length === 0) {
|
|
239
|
+
throw new Error("compare requires at least one baseline skill");
|
|
240
|
+
}
|
|
241
|
+
if (config.proposedSkills.length === 0) {
|
|
242
|
+
throw new Error("compare requires at least one proposed skill");
|
|
243
|
+
}
|
|
244
|
+
if (config.runs < 1) {
|
|
245
|
+
throw new Error("runs must be at least 1");
|
|
246
|
+
}
|
|
247
|
+
if (config.timeoutMs < 1) {
|
|
248
|
+
throw new Error("timeoutMs must be positive");
|
|
249
|
+
}
|
|
250
|
+
if (config.maxBudgetUsd <= 0) {
|
|
251
|
+
throw new Error("maxBudgetUsd must be positive");
|
|
252
|
+
}
|
|
253
|
+
for (const evalCase of config.cases) {
|
|
254
|
+
if (evalCase.runs !== undefined && evalCase.runs < 1) {
|
|
255
|
+
throw new Error(`${evalCase.name}.runs must be at least 1`);
|
|
256
|
+
}
|
|
257
|
+
for (const image of evalCase.images) {
|
|
258
|
+
// A missing image must fail at load time, not after the other arm's paid runs.
|
|
259
|
+
if (!existsSync(image)) {
|
|
260
|
+
throw new Error(`${evalCase.name}.images: file not found: ${image}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (config.pricing !== undefined) {
|
|
265
|
+
for (const armName of ["baseline", "proposed"] as const) {
|
|
266
|
+
const arm = config.arms[armName];
|
|
267
|
+
// Declared-but-incomplete pricing silently reverts an arm to $0 — the
|
|
268
|
+
// exact decorative-budget failure pricing exists to close.
|
|
269
|
+
if (arm.runner === "openai" && config.pricing[arm.model] === undefined) {
|
|
270
|
+
throw new Error(`pricing is declared but has no entry for ${armName} model ${JSON.stringify(arm.model)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (config.delivery === "install") {
|
|
275
|
+
if (config.tools === "" || config.cases.some((evalCase) => evalCase.tools === "")) {
|
|
276
|
+
throw new Error('delivery "install" needs tools enabled: skills are invoked via the Skill tool, so tools "" can never trigger them');
|
|
277
|
+
}
|
|
278
|
+
if (config.renderVars !== undefined || config.cases.some((evalCase) => evalCase.renderVars !== undefined)) {
|
|
279
|
+
throw new Error('render applies to inlined prompt text; delivery "install" copies skill files verbatim, so placeholders cannot be bound');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function pricingValue(value: unknown): Record<string, ModelPricing> | undefined {
|
|
285
|
+
if (value === undefined) return undefined;
|
|
286
|
+
const record = recordOf(value, "pricing");
|
|
287
|
+
const pricing: Record<string, ModelPricing> = {};
|
|
288
|
+
for (const [model, entry] of Object.entries(record)) {
|
|
289
|
+
const rates = recordOf(entry, `pricing.${JSON.stringify(model)}`);
|
|
290
|
+
const input = numberValue(rates.input, NaN);
|
|
291
|
+
const output = numberValue(rates.output, NaN);
|
|
292
|
+
if (!Number.isFinite(input) || !Number.isFinite(output) || input < 0 || output < 0) {
|
|
293
|
+
throw new Error(`pricing.${JSON.stringify(model)} needs non-negative "input" and "output" (USD per million tokens)`);
|
|
294
|
+
}
|
|
295
|
+
pricing[model] = { input, output };
|
|
296
|
+
}
|
|
297
|
+
return pricing;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function renderValue(value: unknown, baseDir: string, label: string): RenderVars | undefined {
|
|
301
|
+
if (value === undefined) return undefined;
|
|
302
|
+
const record = recordOf(value, label);
|
|
303
|
+
return resolveRenderVars(recordOf(record.vars ?? {}, `${label}.vars`), baseDir, `${label}.vars`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function normalizeSkills(value: unknown, label: string, sharedSkills: string[] | undefined): string[] {
|
|
307
|
+
if (Array.isArray(value)) return value.map((item) => requiredString(item, `${label} skill`));
|
|
308
|
+
if (isRecord(value) && Array.isArray(value.skills)) {
|
|
309
|
+
return value.skills.map((item) => requiredString(item, `${label}.skills item`));
|
|
310
|
+
}
|
|
311
|
+
// An arm without its own skills inherits the shared top-level set (model diffs).
|
|
312
|
+
if (sharedSkills !== undefined && (value === undefined || (isRecord(value) && value.skills === undefined))) {
|
|
313
|
+
return sharedSkills;
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`compare requires ${label} skill paths`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Resolves one arm's model/runner/baseUrl: per-arm override, per-arm field, shared override, shared field. */
|
|
319
|
+
function resolveArm(
|
|
320
|
+
arm: "baseline" | "proposed",
|
|
321
|
+
rawArm: Record<string, unknown>,
|
|
322
|
+
raw: RawCompareConfig,
|
|
323
|
+
overrides: CompareOverrides,
|
|
324
|
+
): ArmConfig {
|
|
325
|
+
const modelOverride = arm === "baseline" ? overrides.baselineModel : overrides.proposedModel;
|
|
326
|
+
const runnerOverride = arm === "baseline" ? overrides.baselineRunner : overrides.proposedRunner;
|
|
327
|
+
const model = modelOverride ?? stringValue(rawArm.model, overrides.model ?? stringValue(raw.model, undefined));
|
|
328
|
+
if (model === undefined) {
|
|
329
|
+
throw new Error(`model is required (top-level "model" or ${arm}.model)`);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
model,
|
|
333
|
+
runner: runnerOverride ?? runnerNameValue(rawArm.runner, overrides.runner ?? runnerNameValue(raw.runner, "claude-p")),
|
|
334
|
+
baseUrl: stringValue(rawArm.baseUrl, overrides.baseUrl ?? stringValue(raw.baseUrl, undefined)),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function armRecord(value: unknown): Record<string, unknown> {
|
|
339
|
+
return isRecord(value) && !Array.isArray(value) ? value : {};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function graderValue(value: unknown, scenarioName: string, baseDir: string): GraderSpec {
|
|
343
|
+
if (!isRecord(value)) {
|
|
344
|
+
throw new Error(`${scenarioName}.grader must be an object`);
|
|
345
|
+
}
|
|
346
|
+
if (value.type === "text") {
|
|
347
|
+
const regex = stringArray(value.regex, `${scenarioName}.grader.regex`);
|
|
348
|
+
for (const pattern of regex) {
|
|
349
|
+
try {
|
|
350
|
+
new RegExp(pattern);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
353
|
+
// Validate up front — a bad pattern must fail here, not after a paid run.
|
|
354
|
+
throw new Error(`${scenarioName}.grader.regex ${JSON.stringify(pattern)} is not a valid JS regex (${reason}); note inline flags like (?i) are unsupported`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
type: "text",
|
|
359
|
+
contains: stringArray(value.contains, `${scenarioName}.grader.contains`),
|
|
360
|
+
notContains: stringArray(value.notContains, `${scenarioName}.grader.notContains`),
|
|
361
|
+
regex,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (value.type === "json") {
|
|
365
|
+
const assert = stringArray(value.assert, `${scenarioName}.grader.assert`);
|
|
366
|
+
if (assert.length === 0) {
|
|
367
|
+
throw new Error(`${scenarioName}.grader.assert must list at least one assertion`);
|
|
368
|
+
}
|
|
369
|
+
for (const assertion of assert) {
|
|
370
|
+
try {
|
|
371
|
+
parseAssertion(assertion);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
374
|
+
// Validate up front — a bad assertion must fail here, not after a paid run.
|
|
375
|
+
throw new Error(`${scenarioName}.grader.assert ${JSON.stringify(assertion)} is invalid: ${reason}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { type: "json", assert };
|
|
379
|
+
}
|
|
380
|
+
if (value.type === "command") {
|
|
381
|
+
return {
|
|
382
|
+
type: "command",
|
|
383
|
+
command: requiredString(value.command, `${scenarioName}.grader.command`),
|
|
384
|
+
cwd: stringValue(value.cwd, undefined),
|
|
385
|
+
timeoutMs: optionalNumber(value.timeoutMs, `${scenarioName}.grader.timeoutMs`),
|
|
386
|
+
expectExitCode: optionalNumber(value.expectExitCode, `${scenarioName}.grader.expectExitCode`),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (value.type === "judge") {
|
|
390
|
+
const rubric = resolveFrom(baseDir, requiredString(value.rubric, `${scenarioName}.grader.rubric`));
|
|
391
|
+
// A missing rubric must fail at load time, not after the other arm's paid runs.
|
|
392
|
+
if (!existsSync(rubric)) {
|
|
393
|
+
throw new Error(`${scenarioName}.grader.rubric: file not found: ${rubric}`);
|
|
394
|
+
}
|
|
395
|
+
const minAccuracy = optionalNumber(value.minAccuracy, `${scenarioName}.grader.minAccuracy`) ?? 0.9;
|
|
396
|
+
if (minAccuracy < 0 || minAccuracy > 1) {
|
|
397
|
+
throw new Error(`${scenarioName}.grader.minAccuracy must be between 0 and 1`);
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
type: "judge",
|
|
401
|
+
rubric,
|
|
402
|
+
// The judge model is required and never defaults to the arm's model —
|
|
403
|
+
// a model grading its own output is the bias judges exist to avoid.
|
|
404
|
+
model: requiredString(value.model, `${scenarioName}.grader.model`),
|
|
405
|
+
runner: runnerNameValue(value.runner, "claude-p"),
|
|
406
|
+
baseUrl: stringValue(value.baseUrl, undefined),
|
|
407
|
+
minAccuracy,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
throw new Error(`${scenarioName}.grader.type must be "text", "json", "command", or "judge"`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function kindValue(value: unknown, fallback: ScenarioKind): ScenarioKind {
|
|
414
|
+
if (value === undefined) return fallback;
|
|
415
|
+
if (value === "target" || value === "regression" || value === "compare") return value;
|
|
416
|
+
throw new Error(`scenario kind must be "target", "regression", or "compare"`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function modeValue(value: unknown, fallback: RunMode | undefined): RunMode | undefined {
|
|
420
|
+
if (value === undefined) return fallback;
|
|
421
|
+
if (value === "text" || value === "artifact") return value;
|
|
422
|
+
throw new Error(`mode must be "text" or "artifact"`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function stringValue(value: unknown, fallback: string): string;
|
|
426
|
+
function stringValue(value: unknown, fallback: string | undefined): string | undefined;
|
|
427
|
+
function stringValue(value: unknown, fallback: string | undefined): string | undefined {
|
|
428
|
+
if (value === undefined) return fallback;
|
|
429
|
+
return requiredString(value, "string value");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function requiredString(value: unknown, label: string): string {
|
|
433
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
434
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
435
|
+
}
|
|
436
|
+
return value;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function stringArray(value: unknown, label: string): string[] {
|
|
440
|
+
if (value === undefined) return [];
|
|
441
|
+
if (!Array.isArray(value)) {
|
|
442
|
+
throw new Error(`${label} must be an array`);
|
|
443
|
+
}
|
|
444
|
+
return value.map((item) => requiredString(item, `${label} item`));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function numberValue(value: unknown, fallback: number): number {
|
|
448
|
+
if (value === undefined) return fallback;
|
|
449
|
+
const parsed = Number(value);
|
|
450
|
+
if (!Number.isFinite(parsed)) {
|
|
451
|
+
throw new Error(`expected number`);
|
|
452
|
+
}
|
|
453
|
+
return parsed;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function optionalNumber(value: unknown, label: string): number | undefined {
|
|
457
|
+
if (value === undefined) return undefined;
|
|
458
|
+
const parsed = Number(value);
|
|
459
|
+
if (!Number.isFinite(parsed)) {
|
|
460
|
+
throw new Error(`${label} must be a number`);
|
|
461
|
+
}
|
|
462
|
+
return parsed;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function booleanValue(value: unknown, fallback: boolean): boolean {
|
|
466
|
+
if (value === undefined) return fallback;
|
|
467
|
+
if (typeof value !== "boolean") {
|
|
468
|
+
throw new Error("boolean value expected");
|
|
469
|
+
}
|
|
470
|
+
return value;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function resolveRequired(baseDir: string, value: string | undefined, label: string): string {
|
|
474
|
+
if (!value) throw new Error(`${label} is required`);
|
|
475
|
+
return resolveFrom(baseDir, value);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function optionalPath(baseDir: string, value: string | undefined): string | undefined {
|
|
479
|
+
return value ? resolveFrom(baseDir, value) : undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function resolveFrom(baseDir: string, path: string): string {
|
|
483
|
+
return resolve(baseDir, path);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
487
|
+
return typeof value === "object" && value !== null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function recordOf(value: unknown, label: string): Record<string, unknown> {
|
|
491
|
+
if (!isRecord(value) || Array.isArray(value)) {
|
|
492
|
+
throw new Error(`${label} must be an object`);
|
|
493
|
+
}
|
|
494
|
+
return value as Record<string, unknown>;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function recordValue(value: unknown, label: string): RawCase {
|
|
498
|
+
if (!isRecord(value)) {
|
|
499
|
+
throw new Error(`${label} must be an object`);
|
|
500
|
+
}
|
|
501
|
+
return value;
|
|
502
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import type { RunResult } from "../types";
|
|
4
|
+
import { evaluateAssertion, extractLastJson, parseAssertion } from "./json-assert";
|
|
5
|
+
import { gradeWithJudge, type JudgeGraderSpec } from "./judge";
|
|
6
|
+
|
|
7
|
+
export type GraderSpec =
|
|
8
|
+
| {
|
|
9
|
+
type: "text";
|
|
10
|
+
contains?: string[];
|
|
11
|
+
notContains?: string[];
|
|
12
|
+
regex?: string[];
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
type: "json";
|
|
16
|
+
/** Path assertions over the run's last balanced JSON value; all must hold. */
|
|
17
|
+
assert: string[];
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
type: "command";
|
|
21
|
+
command: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
expectExitCode?: number;
|
|
25
|
+
}
|
|
26
|
+
| JudgeGraderSpec;
|
|
27
|
+
|
|
28
|
+
export interface GradeInput {
|
|
29
|
+
run: RunResult;
|
|
30
|
+
sandboxDir: string;
|
|
31
|
+
/** Bounds for graders that bill a model call (judge); deterministic graders ignore them. */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
maxBudgetUsd?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GradeResult {
|
|
37
|
+
pass: boolean;
|
|
38
|
+
message: string;
|
|
39
|
+
stdout?: string;
|
|
40
|
+
stderr?: string;
|
|
41
|
+
/** USD billed by the grader itself (judge graders); unset for deterministic graders. */
|
|
42
|
+
costUsd?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_JUDGE_TIMEOUT_MS = 600_000;
|
|
46
|
+
const DEFAULT_JUDGE_BUDGET_USD = 1;
|
|
47
|
+
|
|
48
|
+
export async function gradeRun(spec: GraderSpec, input: GradeInput): Promise<GradeResult> {
|
|
49
|
+
if (spec.type === "text") {
|
|
50
|
+
return gradeText(spec, input.run.output);
|
|
51
|
+
}
|
|
52
|
+
if (spec.type === "json") {
|
|
53
|
+
return gradeJson(spec, input.run.output);
|
|
54
|
+
}
|
|
55
|
+
if (spec.type === "judge") {
|
|
56
|
+
return gradeWithJudge(spec, input.run.output, {
|
|
57
|
+
cwd: input.sandboxDir,
|
|
58
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_JUDGE_TIMEOUT_MS,
|
|
59
|
+
maxBudgetUsd: input.maxBudgetUsd ?? DEFAULT_JUDGE_BUDGET_USD,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Command graders judge sandbox files — but for completion-style runs the model's
|
|
63
|
+
// text IS the artifact, so it lands in the sandbox too ($PROMPTDIFF_OUTPUT_FILE).
|
|
64
|
+
const outputFile = join(input.sandboxDir, ".promptdiff-output.txt");
|
|
65
|
+
writeFileSync(outputFile, input.run.output);
|
|
66
|
+
return gradeCommand(spec, input.sandboxDir, outputFile);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function gradeText(spec: Extract<GraderSpec, { type: "text" }>, output: string): GradeResult {
|
|
70
|
+
for (const expected of spec.contains ?? []) {
|
|
71
|
+
if (!output.includes(expected)) {
|
|
72
|
+
return { pass: false, message: `output did not contain ${JSON.stringify(expected)}` };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const forbidden of spec.notContains ?? []) {
|
|
77
|
+
if (output.includes(forbidden)) {
|
|
78
|
+
return { pass: false, message: `output contained forbidden text ${JSON.stringify(forbidden)}` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const pattern of spec.regex ?? []) {
|
|
83
|
+
if (!new RegExp(pattern).test(output)) {
|
|
84
|
+
return { pass: false, message: `output did not match /${pattern}/` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { pass: true, message: "text grader passed" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gradeJson(spec: Extract<GraderSpec, { type: "json" }>, output: string): GradeResult {
|
|
92
|
+
const extracted = extractLastJson(output);
|
|
93
|
+
if (extracted === undefined) {
|
|
94
|
+
return { pass: false, message: "no JSON value found in output" };
|
|
95
|
+
}
|
|
96
|
+
for (const source of spec.assert) {
|
|
97
|
+
// Assertion grammar was validated at config load; parsing here cannot throw.
|
|
98
|
+
const failure = evaluateAssertion(parseAssertion(source), extracted.value);
|
|
99
|
+
if (failure !== undefined) {
|
|
100
|
+
return { pass: false, message: failure };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { pass: true, message: "json grader passed" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function gradeCommand(
|
|
107
|
+
spec: Extract<GraderSpec, { type: "command" }>,
|
|
108
|
+
sandboxDir: string,
|
|
109
|
+
outputFile: string,
|
|
110
|
+
): Promise<GradeResult> {
|
|
111
|
+
const cwd = resolve(sandboxDir, spec.cwd ?? ".");
|
|
112
|
+
if (!existsSync(cwd)) {
|
|
113
|
+
return { pass: false, message: `grader cwd does not exist: ${cwd}` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const proc = Bun.spawn(["sh", "-lc", spec.command], {
|
|
117
|
+
cwd,
|
|
118
|
+
env: { ...process.env, PROMPTDIFF_OUTPUT_FILE: outputFile },
|
|
119
|
+
stdout: "pipe",
|
|
120
|
+
stderr: "pipe",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
let timedOut = false;
|
|
124
|
+
const timeoutMs = spec.timeoutMs ?? 120_000;
|
|
125
|
+
const timeout = setTimeout(() => {
|
|
126
|
+
timedOut = true;
|
|
127
|
+
proc.kill("SIGTERM");
|
|
128
|
+
setTimeout(() => proc.kill("SIGKILL"), 2_000);
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
|
|
131
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
132
|
+
new Response(proc.stdout).text(),
|
|
133
|
+
new Response(proc.stderr).text(),
|
|
134
|
+
proc.exited,
|
|
135
|
+
]);
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
|
|
138
|
+
if (timedOut) {
|
|
139
|
+
return { pass: false, message: `grader timed out after ${timeoutMs}ms`, stdout, stderr };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const expected = spec.expectExitCode ?? 0;
|
|
143
|
+
return {
|
|
144
|
+
pass: code === expected,
|
|
145
|
+
message: code === expected ? "command grader passed" : `command exited ${code}, expected ${expected}`,
|
|
146
|
+
stdout,
|
|
147
|
+
stderr,
|
|
148
|
+
};
|
|
149
|
+
}
|