@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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path assertions over a JSON value, for the json grader.
|
|
3
|
+
*
|
|
4
|
+
* Grammar (one assertion): `<path> <op> <literal>` with whitespace around the
|
|
5
|
+
* operator. Paths are dot-separated identifiers with `[<index>]` and `[*]`
|
|
6
|
+
* steps; a trailing `.length` on an array or string reads its length. `[*]`
|
|
7
|
+
* is existential: the assertion passes if ANY element satisfies it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type JsonPathSegment =
|
|
11
|
+
| { kind: "key"; name: string }
|
|
12
|
+
| { kind: "index"; index: number }
|
|
13
|
+
| { kind: "any" };
|
|
14
|
+
|
|
15
|
+
export type JsonOp = "==" | "!=" | ">" | ">=" | "<" | "<=" | "contains";
|
|
16
|
+
|
|
17
|
+
export type JsonScalar = string | number | boolean | null;
|
|
18
|
+
|
|
19
|
+
export interface JsonAssertion {
|
|
20
|
+
source: string;
|
|
21
|
+
path: JsonPathSegment[];
|
|
22
|
+
op: JsonOp;
|
|
23
|
+
literal: JsonScalar;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ASSERTION_RE = /^(\S+)\s+(==|!=|>=|<=|>|<|contains)\s+(\S.*)$/;
|
|
27
|
+
const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/;
|
|
28
|
+
const RELATIONAL_OPS: ReadonlySet<JsonOp> = new Set([">", ">=", "<", "<="]);
|
|
29
|
+
|
|
30
|
+
/** Parses one assertion string; throws with a grammar-naming message on invalid input. */
|
|
31
|
+
export function parseAssertion(source: string): JsonAssertion {
|
|
32
|
+
const match = ASSERTION_RE.exec(source.trim());
|
|
33
|
+
if (!match) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'expected `<path> <op> <literal>` with spaces around the operator (ops: == != > >= < <= contains)',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const [, pathText = "", rawOp = "", literalText = ""] = match;
|
|
39
|
+
// The regex alternation only admits the seven operators.
|
|
40
|
+
const op = rawOp as JsonOp;
|
|
41
|
+
const path = parsePath(pathText);
|
|
42
|
+
const literal = parseLiteral(literalText);
|
|
43
|
+
if (RELATIONAL_OPS.has(op) && typeof literal !== "number") {
|
|
44
|
+
throw new Error(`operator ${op} needs a number literal, got ${literalText}`);
|
|
45
|
+
}
|
|
46
|
+
return { source, path, op, literal };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parsePath(text: string): JsonPathSegment[] {
|
|
50
|
+
const segments: JsonPathSegment[] = [];
|
|
51
|
+
let index = 0;
|
|
52
|
+
while (index < text.length) {
|
|
53
|
+
const char = text[index];
|
|
54
|
+
if (segments.length === 0) {
|
|
55
|
+
const match = IDENT_RE.exec(text);
|
|
56
|
+
if (!match) {
|
|
57
|
+
throw new Error(`path must start with an identifier, got ${JSON.stringify(text)}`);
|
|
58
|
+
}
|
|
59
|
+
segments.push({ kind: "key", name: match[0] });
|
|
60
|
+
index = match[0].length;
|
|
61
|
+
} else if (char === ".") {
|
|
62
|
+
const match = IDENT_RE.exec(text.slice(index + 1));
|
|
63
|
+
if (!match) {
|
|
64
|
+
throw new Error(`expected an identifier after "." in path ${JSON.stringify(text)}`);
|
|
65
|
+
}
|
|
66
|
+
segments.push({ kind: "key", name: match[0] });
|
|
67
|
+
index += 1 + match[0].length;
|
|
68
|
+
} else if (char === "[") {
|
|
69
|
+
const close = text.indexOf("]", index);
|
|
70
|
+
if (close === -1) {
|
|
71
|
+
throw new Error(`unclosed "[" in path ${JSON.stringify(text)}`);
|
|
72
|
+
}
|
|
73
|
+
const inner = text.slice(index + 1, close);
|
|
74
|
+
if (inner === "*") {
|
|
75
|
+
segments.push({ kind: "any" });
|
|
76
|
+
} else if (/^\d+$/.test(inner)) {
|
|
77
|
+
segments.push({ kind: "index", index: Number(inner) });
|
|
78
|
+
} else {
|
|
79
|
+
throw new Error(`brackets take a non-negative integer or *, got "[${inner}]"`);
|
|
80
|
+
}
|
|
81
|
+
index = close + 1;
|
|
82
|
+
} else {
|
|
83
|
+
throw new Error(`unexpected ${JSON.stringify(char)} in path ${JSON.stringify(text)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return segments;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseLiteral(text: string): JsonScalar {
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(text);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`literal must be a JSON scalar — a quoted string like "correctness", a number, true, false, or null — got ${text}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
99
|
+
throw new Error(`literal must be a JSON scalar (string, number, boolean, or null), got ${text}`);
|
|
100
|
+
}
|
|
101
|
+
return parsed as JsonScalar;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Evaluates one parsed assertion against a JSON value. Returns undefined on
|
|
106
|
+
* pass, or a failure message naming the assertion and what was found. Never
|
|
107
|
+
* throws: missing paths and type mismatches are grading failures.
|
|
108
|
+
*/
|
|
109
|
+
export function evaluateAssertion(assertion: JsonAssertion, root: unknown): string | undefined {
|
|
110
|
+
const { candidates, missing } = resolvePath(root, assertion.path);
|
|
111
|
+
if (candidates.length === 0) {
|
|
112
|
+
return `${assertion.source}: ${missing ?? "path matched no values"}`;
|
|
113
|
+
}
|
|
114
|
+
const details: string[] = [];
|
|
115
|
+
for (const candidate of candidates) {
|
|
116
|
+
const detail = compare(candidate, assertion.op, assertion.literal);
|
|
117
|
+
if (detail === undefined) return undefined;
|
|
118
|
+
details.push(detail);
|
|
119
|
+
}
|
|
120
|
+
if (candidates.length === 1) {
|
|
121
|
+
return `${assertion.source}: ${details[0]}`;
|
|
122
|
+
}
|
|
123
|
+
return `${assertion.source}: no element satisfied (${candidates.length} checked; e.g. ${details[0]})`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Walks the path, fanning out at `[*]`. A branch that dead-ends is dropped
|
|
128
|
+
* (existential semantics); the first dead end is kept for the failure message
|
|
129
|
+
* used when NO branch survives.
|
|
130
|
+
*/
|
|
131
|
+
function resolvePath(
|
|
132
|
+
root: unknown,
|
|
133
|
+
segments: JsonPathSegment[],
|
|
134
|
+
): { candidates: unknown[]; missing?: string } {
|
|
135
|
+
let current: unknown[] = [root];
|
|
136
|
+
let missing: string | undefined;
|
|
137
|
+
for (const segment of segments) {
|
|
138
|
+
const next: unknown[] = [];
|
|
139
|
+
for (const value of current) {
|
|
140
|
+
if (segment.kind === "any") {
|
|
141
|
+
if (Array.isArray(value)) {
|
|
142
|
+
next.push(...value);
|
|
143
|
+
if (value.length === 0) missing ??= "[*] found an empty array";
|
|
144
|
+
} else {
|
|
145
|
+
missing ??= `[*] needs an array, found ${show(value)}`;
|
|
146
|
+
}
|
|
147
|
+
} else if (segment.kind === "index") {
|
|
148
|
+
if (Array.isArray(value) && segment.index < value.length) {
|
|
149
|
+
next.push(value[segment.index]);
|
|
150
|
+
} else {
|
|
151
|
+
missing ??= Array.isArray(value)
|
|
152
|
+
? `index [${segment.index}] is out of range (${value.length} elements)`
|
|
153
|
+
: `index [${segment.index}] needs an array, found ${show(value)}`;
|
|
154
|
+
}
|
|
155
|
+
} else if (segment.name === "length" && (Array.isArray(value) || typeof value === "string")) {
|
|
156
|
+
next.push(value.length);
|
|
157
|
+
} else if (isRecord(value) && segment.name in value) {
|
|
158
|
+
next.push(value[segment.name]);
|
|
159
|
+
} else {
|
|
160
|
+
missing ??= `path segment "${segment.name}" not found (at ${show(value)})`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
current = next;
|
|
164
|
+
if (current.length === 0) return { candidates: [], missing };
|
|
165
|
+
}
|
|
166
|
+
return { candidates: current, missing };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Undefined when the candidate satisfies the op; otherwise a short failure detail. */
|
|
170
|
+
function compare(candidate: unknown, op: JsonOp, literal: JsonScalar): string | undefined {
|
|
171
|
+
switch (op) {
|
|
172
|
+
case "==":
|
|
173
|
+
return candidate === literal ? undefined : `found ${show(candidate)}`;
|
|
174
|
+
case "!=":
|
|
175
|
+
return candidate !== literal ? undefined : `found ${show(candidate)}`;
|
|
176
|
+
case ">":
|
|
177
|
+
case ">=":
|
|
178
|
+
case "<":
|
|
179
|
+
case "<=": {
|
|
180
|
+
if (typeof candidate !== "number") {
|
|
181
|
+
return `expected a number, found ${show(candidate)}`;
|
|
182
|
+
}
|
|
183
|
+
if (typeof literal !== "number") return `operator ${op} needs a number literal`;
|
|
184
|
+
const pass =
|
|
185
|
+
op === ">" ? candidate > literal
|
|
186
|
+
: op === ">=" ? candidate >= literal
|
|
187
|
+
: op === "<" ? candidate < literal
|
|
188
|
+
: candidate <= literal;
|
|
189
|
+
return pass ? undefined : `found ${show(candidate)}`;
|
|
190
|
+
}
|
|
191
|
+
case "contains": {
|
|
192
|
+
if (typeof candidate === "string") {
|
|
193
|
+
if (typeof literal !== "string") {
|
|
194
|
+
return `contains on a string needs a string literal, got ${show(literal)}`;
|
|
195
|
+
}
|
|
196
|
+
return candidate.includes(literal) ? undefined : `found ${show(candidate)}`;
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(candidate)) {
|
|
199
|
+
return candidate.some((element) => element === literal) ? undefined : `found ${show(candidate)}`;
|
|
200
|
+
}
|
|
201
|
+
return `contains needs a string or array, found ${show(candidate)}`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const SHOW_MAX_CHARS = 120;
|
|
207
|
+
|
|
208
|
+
function show(value: unknown): string {
|
|
209
|
+
const text = value === undefined ? "undefined" : JSON.stringify(value);
|
|
210
|
+
return text.length > SHOW_MAX_CHARS ? `${text.slice(0, SHOW_MAX_CHARS)}…` : text;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
214
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Finds the LAST parseable JSON value in the output — reasoning models emit
|
|
219
|
+
* prose before, after, and around JSON, and the final value is the answer.
|
|
220
|
+
* Balancing is string-aware: a `}` inside a string literal closes nothing.
|
|
221
|
+
* Undefined when the output holds no JSON value at all.
|
|
222
|
+
*/
|
|
223
|
+
export function extractLastJson(output: string): { value: unknown } | undefined {
|
|
224
|
+
const trimmed = output.trim();
|
|
225
|
+
if (trimmed.length > 0) {
|
|
226
|
+
try {
|
|
227
|
+
return { value: JSON.parse(trimmed) };
|
|
228
|
+
} catch {
|
|
229
|
+
// Not pure JSON — scan for embedded values below.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
let last: { value: unknown } | undefined;
|
|
233
|
+
let index = 0;
|
|
234
|
+
while (index < output.length) {
|
|
235
|
+
const char = output[index];
|
|
236
|
+
if (char === "{" || char === "[") {
|
|
237
|
+
const end = scanBalanced(output, index);
|
|
238
|
+
if (end !== -1) {
|
|
239
|
+
try {
|
|
240
|
+
last = { value: JSON.parse(output.slice(index, end + 1)) };
|
|
241
|
+
index = end + 1;
|
|
242
|
+
continue;
|
|
243
|
+
} catch {
|
|
244
|
+
// Balanced but not JSON (prose braces) — step inside and keep scanning.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
index += 1;
|
|
249
|
+
}
|
|
250
|
+
return last;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Index of the bracket closing the one at `start`, or -1 if never closed/mismatched. */
|
|
254
|
+
function scanBalanced(text: string, start: number): number {
|
|
255
|
+
const stack: string[] = [];
|
|
256
|
+
let inString = false;
|
|
257
|
+
let escaped = false;
|
|
258
|
+
for (let index = start; index < text.length; index += 1) {
|
|
259
|
+
const char = text[index];
|
|
260
|
+
if (inString) {
|
|
261
|
+
if (escaped) escaped = false;
|
|
262
|
+
else if (char === "\\") escaped = true;
|
|
263
|
+
else if (char === '"') inString = false;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (char === '"') {
|
|
267
|
+
inString = true;
|
|
268
|
+
} else if (char === "{" || char === "[") {
|
|
269
|
+
stack.push(char);
|
|
270
|
+
} else if (char === "}" || char === "]") {
|
|
271
|
+
const open = stack.pop();
|
|
272
|
+
if ((char === "}" && open !== "{") || (char === "]" && open !== "[")) return -1;
|
|
273
|
+
if (stack.length === 0) return index;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return -1;
|
|
277
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
4
|
+
import { createRunner, type CreateRunnerOptions, type RunnerName } from "../runner";
|
|
5
|
+
import type { Runner } from "../types";
|
|
6
|
+
import type { GradeResult } from "./grader";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* LLM-judge grader: an explicit judge model grades the run's output against a
|
|
10
|
+
* markdown rubric. The judge model never defaults to the arm's model —
|
|
11
|
+
* self-grading bias is the failure mode that explicitness closes.
|
|
12
|
+
*/
|
|
13
|
+
export interface JudgeGraderSpec {
|
|
14
|
+
type: "judge";
|
|
15
|
+
/** Absolute path to the rubric markdown (resolved at config load). */
|
|
16
|
+
rubric: string;
|
|
17
|
+
model: string;
|
|
18
|
+
runner: RunnerName;
|
|
19
|
+
/** OpenAI-compatible endpoint base URL for the judge; openai runner only. */
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
/** Per-class calibration accuracy the gate requires. Default 0.9. */
|
|
22
|
+
minAccuracy: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type JudgeRunnerFactory = (name: RunnerName, options: CreateRunnerOptions) => Runner;
|
|
26
|
+
|
|
27
|
+
let judgeRunnerFactory: JudgeRunnerFactory = createRunner;
|
|
28
|
+
|
|
29
|
+
/** Test seam: swap the judge's runner factory. Pass undefined to restore createRunner. */
|
|
30
|
+
export function setJudgeRunnerFactory(factory: JudgeRunnerFactory | undefined): void {
|
|
31
|
+
judgeRunnerFactory = factory ?? createRunner;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Appended verbatim after the rubric — the rubric author writes judgment
|
|
35
|
+
// criteria; the harness owns the reply contract.
|
|
36
|
+
const HARNESS_INSTRUCTION = [
|
|
37
|
+
"",
|
|
38
|
+
"---",
|
|
39
|
+
"You are grading one output (the user message) against the rubric above.",
|
|
40
|
+
'Reply with exactly one JSON object: {"verdict": "pass" | "fail", "reason": "<short>"}',
|
|
41
|
+
'"pass" means the output is clean per the rubric; "fail" means the rubric flags it.',
|
|
42
|
+
].join("\n");
|
|
43
|
+
|
|
44
|
+
export function judgeSystemPrompt(rubricContent: string): string {
|
|
45
|
+
return rubricContent + HARNESS_INSTRUCTION;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface JudgeVerdict {
|
|
49
|
+
verdict: "pass" | "fail";
|
|
50
|
+
reason: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extracts the judge's verdict from its reply. Reasoning models wrap the JSON
|
|
55
|
+
* in prose, so the LAST balanced JSON object carrying a valid verdict wins.
|
|
56
|
+
* No valid verdict → undefined; callers must fail the graded run, never pass it.
|
|
57
|
+
*/
|
|
58
|
+
export function parseJudgeVerdict(reply: string): JudgeVerdict | undefined {
|
|
59
|
+
let last: JudgeVerdict | undefined;
|
|
60
|
+
for (const candidate of balancedJsonObjects(reply)) {
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(candidate);
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
|
|
68
|
+
const record = parsed as Record<string, unknown>;
|
|
69
|
+
if (record.verdict !== "pass" && record.verdict !== "fail") continue;
|
|
70
|
+
last = { verdict: record.verdict, reason: typeof record.reason === "string" ? record.reason : "" };
|
|
71
|
+
}
|
|
72
|
+
return last;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Top-level {...} spans, tracked with string/escape awareness so braces inside reasons don't split objects. */
|
|
76
|
+
function balancedJsonObjects(text: string): string[] {
|
|
77
|
+
const spans: string[] = [];
|
|
78
|
+
let depth = 0;
|
|
79
|
+
let start = -1;
|
|
80
|
+
let inString = false;
|
|
81
|
+
let escaped = false;
|
|
82
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
83
|
+
const char = text[i];
|
|
84
|
+
if (inString) {
|
|
85
|
+
if (escaped) escaped = false;
|
|
86
|
+
else if (char === "\\") escaped = true;
|
|
87
|
+
else if (char === '"') inString = false;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (char === '"' && depth > 0) inString = true;
|
|
91
|
+
else if (char === "{") {
|
|
92
|
+
if (depth === 0) start = i;
|
|
93
|
+
depth += 1;
|
|
94
|
+
} else if (char === "}" && depth > 0) {
|
|
95
|
+
depth -= 1;
|
|
96
|
+
if (depth === 0) spans.push(text.slice(start, i + 1));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return spans;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface JudgeFixture {
|
|
103
|
+
/** "pass/clean-a.md" — class dir + file name, the identity used in reports. */
|
|
104
|
+
name: string;
|
|
105
|
+
expected: "pass" | "fail";
|
|
106
|
+
content: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Sibling fixture dir: rubrics/negate-restate.md → rubrics/negate-restate.fixtures/{pass,fail}/*.md */
|
|
110
|
+
export function judgeFixturesDir(rubricPath: string): string {
|
|
111
|
+
return join(dirname(rubricPath), `${basename(rubricPath, extname(rubricPath))}.fixtures`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function loadJudgeFixtures(rubricPath: string): JudgeFixture[] {
|
|
115
|
+
const dir = judgeFixturesDir(rubricPath);
|
|
116
|
+
const fixtures: JudgeFixture[] = [];
|
|
117
|
+
for (const expected of ["pass", "fail"] as const) {
|
|
118
|
+
const classDir = join(dir, expected);
|
|
119
|
+
const files = existsSync(classDir)
|
|
120
|
+
? readdirSync(classDir).filter((file) => file.endsWith(".md")).sort()
|
|
121
|
+
: [];
|
|
122
|
+
// A one-sided fixture set cannot catch a judge that always agrees with it.
|
|
123
|
+
if (files.length === 0) {
|
|
124
|
+
const meaning = expected === "pass" ? "call clean" : "flag";
|
|
125
|
+
throw new Error(
|
|
126
|
+
`judge calibration needs at least one ${expected}-class fixture in ${classDir} (outputs the judge must ${meaning})`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
fixtures.push({ name: `${expected}/${file}`, expected, content: readFileSync(join(classDir, file), "utf8") });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return fixtures;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface CalibrationRecord {
|
|
137
|
+
rubricSha256: string;
|
|
138
|
+
model: string;
|
|
139
|
+
runner: RunnerName;
|
|
140
|
+
baseUrl?: string;
|
|
141
|
+
ranAt: string;
|
|
142
|
+
fixtures: { pass: number; fail: number };
|
|
143
|
+
/** Per class: fraction of that class's fixtures the judge labeled correctly. */
|
|
144
|
+
accuracy: { pass: number; fail: number };
|
|
145
|
+
verdicts: CalibrationVerdict[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface CalibrationVerdict {
|
|
149
|
+
fixture: string;
|
|
150
|
+
expected: "pass" | "fail";
|
|
151
|
+
got: "pass" | "fail" | "invalid";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The record commits next to the rubric so calibration travels with it. */
|
|
155
|
+
export function calibrationRecordPath(rubricPath: string): string {
|
|
156
|
+
return `${rubricPath}.calibration.json`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function rubricSha256(rubricContent: string): string {
|
|
160
|
+
return createHash("sha256").update(rubricContent).digest("hex");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function writeCalibrationRecord(rubricPath: string, record: CalibrationRecord): string {
|
|
164
|
+
const path = calibrationRecordPath(rubricPath);
|
|
165
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
166
|
+
return path;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function readCalibrationRecord(path: string): CalibrationRecord {
|
|
170
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<CalibrationRecord>;
|
|
171
|
+
if (
|
|
172
|
+
typeof raw.rubricSha256 !== "string" ||
|
|
173
|
+
typeof raw.model !== "string" ||
|
|
174
|
+
typeof raw.runner !== "string" ||
|
|
175
|
+
typeof raw.accuracy !== "object" ||
|
|
176
|
+
raw.accuracy === null ||
|
|
177
|
+
typeof raw.accuracy.pass !== "number" ||
|
|
178
|
+
typeof raw.accuracy.fail !== "number"
|
|
179
|
+
) {
|
|
180
|
+
throw new Error(`calibration record ${path} is malformed — re-run promptdiff calibrate`);
|
|
181
|
+
}
|
|
182
|
+
return raw as CalibrationRecord;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The refuse-to-grade gate. An uncalibrated judge is worse than the regex it
|
|
187
|
+
* replaces — same wrongness, more confidence, higher cost — so every judge
|
|
188
|
+
* grader must present a fresh, matching, above-bar calibration record before
|
|
189
|
+
* any paid run. Per-class bars on purpose: a judge that passes everything is
|
|
190
|
+
* 100% on the pass class and 0% on the fail class; overall accuracy hides it.
|
|
191
|
+
*/
|
|
192
|
+
export function assertJudgeCalibrated(spec: JudgeGraderSpec): void {
|
|
193
|
+
const fix = `run: promptdiff calibrate --rubric ${spec.rubric} --model ${spec.model}${
|
|
194
|
+
spec.runner === "claude-p" ? "" : ` --runner ${spec.runner}`
|
|
195
|
+
}`;
|
|
196
|
+
const recordPath = calibrationRecordPath(spec.rubric);
|
|
197
|
+
if (!existsSync(recordPath)) {
|
|
198
|
+
throw new Error(`judge rubric ${spec.rubric} has no calibration record (${recordPath}) — ${fix}`);
|
|
199
|
+
}
|
|
200
|
+
const record = readCalibrationRecord(recordPath);
|
|
201
|
+
const currentSha = rubricSha256(readFileSync(spec.rubric, "utf8"));
|
|
202
|
+
if (record.rubricSha256 !== currentSha) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`judge rubric ${spec.rubric} changed since calibration (sha256 mismatch) — the record is stale; ${fix}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (record.model !== spec.model || record.runner !== spec.runner) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`judge rubric ${spec.rubric} was calibrated with model "${record.model}" via ${record.runner}, ` +
|
|
210
|
+
`but the grader specifies model "${spec.model}" via ${spec.runner} — ${fix}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
for (const cls of ["pass", "fail"] as const) {
|
|
214
|
+
if (record.accuracy[cls] < spec.minAccuracy) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`judge rubric ${spec.rubric} is below the calibration bar on the ${cls} class ` +
|
|
217
|
+
`(${formatPct(record.accuracy[cls])} < minAccuracy ${formatPct(spec.minAccuracy)}) — ` +
|
|
218
|
+
`refusing to grade; improve the rubric or fixtures, then ${fix}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface JudgeCallBounds {
|
|
225
|
+
cwd: string;
|
|
226
|
+
timeoutMs: number;
|
|
227
|
+
maxBudgetUsd: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface JudgeCallResult {
|
|
231
|
+
verdict: JudgeVerdict | undefined;
|
|
232
|
+
costUsd: number;
|
|
233
|
+
raw: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One judge invocation: rubric + harness contract as system prompt, the graded output as user prompt. */
|
|
237
|
+
async function callJudge(
|
|
238
|
+
spec: Pick<JudgeGraderSpec, "rubric" | "model" | "runner" | "baseUrl">,
|
|
239
|
+
output: string,
|
|
240
|
+
bounds: JudgeCallBounds,
|
|
241
|
+
): Promise<JudgeCallResult> {
|
|
242
|
+
const runner = judgeRunnerFactory(spec.runner, { baseUrl: spec.baseUrl });
|
|
243
|
+
const result = await runner.run({
|
|
244
|
+
systemPrompt: judgeSystemPrompt(readFileSync(spec.rubric, "utf8")),
|
|
245
|
+
userPrompt: output,
|
|
246
|
+
model: spec.model,
|
|
247
|
+
cwd: bounds.cwd,
|
|
248
|
+
timeoutMs: bounds.timeoutMs,
|
|
249
|
+
maxBudgetUsd: bounds.maxBudgetUsd,
|
|
250
|
+
tools: "",
|
|
251
|
+
addDirs: [],
|
|
252
|
+
// Pin the judge itself as close to deterministic as the endpoint allows —
|
|
253
|
+
// pass-rate deltas should be about the prompt under test, not judge noise.
|
|
254
|
+
requestParams: spec.runner === "openai" ? { temperature: 0 } : undefined,
|
|
255
|
+
});
|
|
256
|
+
return { verdict: parseJudgeVerdict(result.output), costUsd: result.costUsd, raw: result.output };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Grades one real run with the judge. Any outcome that is not a valid verdict
|
|
261
|
+
* — transport failure or unparseable reply — FAILS the graded run; a judge
|
|
262
|
+
* problem must never silently count as a pass.
|
|
263
|
+
*/
|
|
264
|
+
export async function gradeWithJudge(
|
|
265
|
+
spec: JudgeGraderSpec,
|
|
266
|
+
output: string,
|
|
267
|
+
bounds: JudgeCallBounds,
|
|
268
|
+
): Promise<GradeResult> {
|
|
269
|
+
let call: JudgeCallResult;
|
|
270
|
+
try {
|
|
271
|
+
call = await callJudge(spec, output, bounds);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
274
|
+
return { pass: false, message: `judge error: ${reason}` };
|
|
275
|
+
}
|
|
276
|
+
if (call.verdict === undefined) {
|
|
277
|
+
return { pass: false, message: "judge returned no valid verdict", stdout: call.raw, costUsd: call.costUsd };
|
|
278
|
+
}
|
|
279
|
+
const { verdict, reason } = call.verdict;
|
|
280
|
+
return {
|
|
281
|
+
pass: verdict === "pass",
|
|
282
|
+
message: `judge verdict: ${verdict}${reason ? ` — ${reason}` : ""}`,
|
|
283
|
+
costUsd: call.costUsd,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface CalibrateOptions {
|
|
288
|
+
/** Absolute rubric path. */
|
|
289
|
+
rubric: string;
|
|
290
|
+
model: string;
|
|
291
|
+
runner: RunnerName;
|
|
292
|
+
baseUrl?: string;
|
|
293
|
+
timeoutMs: number;
|
|
294
|
+
maxBudgetUsd: number;
|
|
295
|
+
onProgress?: (message: string) => void;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface CalibrateResult {
|
|
299
|
+
record: CalibrationRecord;
|
|
300
|
+
recordPath: string;
|
|
301
|
+
misses: CalibrationVerdict[];
|
|
302
|
+
totalCostUsd: number;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Runs the judge over every labeled fixture and writes the calibration record
|
|
307
|
+
* next to the rubric — ALWAYS, including failing calibrations: calibrate
|
|
308
|
+
* measures, the gate enforces. A below-bar record on disk is an honest "this
|
|
309
|
+
* judge cannot be trusted yet", not an error.
|
|
310
|
+
*/
|
|
311
|
+
export async function runCalibration(options: CalibrateOptions): Promise<CalibrateResult> {
|
|
312
|
+
if (!existsSync(options.rubric)) {
|
|
313
|
+
throw new Error(`rubric file not found: ${options.rubric}`);
|
|
314
|
+
}
|
|
315
|
+
const fixtures = loadJudgeFixtures(options.rubric);
|
|
316
|
+
const verdicts: CalibrationVerdict[] = [];
|
|
317
|
+
let totalCostUsd = 0;
|
|
318
|
+
|
|
319
|
+
for (const fixture of fixtures) {
|
|
320
|
+
options.onProgress?.(`judging fixture ${fixture.name}`);
|
|
321
|
+
const call = await callJudge(options, fixture.content, {
|
|
322
|
+
cwd: dirname(options.rubric),
|
|
323
|
+
timeoutMs: options.timeoutMs,
|
|
324
|
+
maxBudgetUsd: options.maxBudgetUsd,
|
|
325
|
+
});
|
|
326
|
+
totalCostUsd += call.costUsd;
|
|
327
|
+
verdicts.push({ fixture: fixture.name, expected: fixture.expected, got: call.verdict?.verdict ?? "invalid" });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const record: CalibrationRecord = {
|
|
331
|
+
rubricSha256: rubricSha256(readFileSync(options.rubric, "utf8")),
|
|
332
|
+
model: options.model,
|
|
333
|
+
runner: options.runner,
|
|
334
|
+
baseUrl: options.baseUrl,
|
|
335
|
+
ranAt: new Date().toISOString(),
|
|
336
|
+
fixtures: { pass: classCount(verdicts, "pass"), fail: classCount(verdicts, "fail") },
|
|
337
|
+
accuracy: { pass: classAccuracy(verdicts, "pass"), fail: classAccuracy(verdicts, "fail") },
|
|
338
|
+
verdicts,
|
|
339
|
+
};
|
|
340
|
+
const recordPath = writeCalibrationRecord(options.rubric, record);
|
|
341
|
+
return {
|
|
342
|
+
record,
|
|
343
|
+
recordPath,
|
|
344
|
+
misses: verdicts.filter((verdict) => verdict.got !== verdict.expected),
|
|
345
|
+
totalCostUsd,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function classCount(verdicts: CalibrationVerdict[], cls: "pass" | "fail"): number {
|
|
350
|
+
return verdicts.filter((verdict) => verdict.expected === cls).length;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function classAccuracy(verdicts: CalibrationVerdict[], cls: "pass" | "fail"): number {
|
|
354
|
+
const ofClass = verdicts.filter((verdict) => verdict.expected === cls);
|
|
355
|
+
if (ofClass.length === 0) return 0;
|
|
356
|
+
return ofClass.filter((verdict) => verdict.got === verdict.expected).length / ofClass.length;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function formatCalibrationReport(result: CalibrateResult, minAccuracyHint = 0.9): string {
|
|
360
|
+
const { record } = result;
|
|
361
|
+
const lines = [
|
|
362
|
+
`promptdiff calibrate: ${record.model} via ${record.runner}`,
|
|
363
|
+
"",
|
|
364
|
+
`pass class: ${correctOf(record, "pass")}/${record.fixtures.pass} correct (${formatPct(record.accuracy.pass)})`,
|
|
365
|
+
`fail class: ${correctOf(record, "fail")}/${record.fixtures.fail} correct (${formatPct(record.accuracy.fail)})`,
|
|
366
|
+
];
|
|
367
|
+
if (result.misses.length > 0) {
|
|
368
|
+
lines.push("", "misses:");
|
|
369
|
+
for (const miss of result.misses) {
|
|
370
|
+
lines.push(` ${miss.fixture}: expected ${miss.expected}, judge said ${miss.got}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
lines.push(
|
|
374
|
+
"",
|
|
375
|
+
`calibration record written: ${result.recordPath}`,
|
|
376
|
+
`judge cost: $${result.totalCostUsd.toFixed(4)}`,
|
|
377
|
+
`gate: compare/measure require BOTH classes >= minAccuracy (default ${formatPct(minAccuracyHint)})`,
|
|
378
|
+
);
|
|
379
|
+
return lines.join("\n");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function correctOf(record: CalibrationRecord, cls: "pass" | "fail"): number {
|
|
383
|
+
return record.verdicts.filter((verdict) => verdict.expected === cls && verdict.got === cls).length;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function formatPct(value: number): string {
|
|
387
|
+
return `${(value * 100).toFixed(0)}%`;
|
|
388
|
+
}
|