evals-lab 0.1.2
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/LICENSE.md +57 -0
- package/README.md +98 -0
- package/bin/evals-lab.js +196 -0
- package/lab/demo/CREDITS.md +92 -0
- package/lab/demo/datasets/demo-1.json +1839 -0
- package/lab/demo/datasets/demo-2.json +1683 -0
- package/lab/demo/manifest.json +12 -0
- package/lab/demo/pipelines/demo-1.json +104 -0
- package/lab/demo/pipelines/demo-2.json +104 -0
- package/lab/demo/sources/Demo 1/basketball-hoop.jpg +0 -0
- package/lab/demo/sources/Demo 1/blue-boardwalk.jpg +0 -0
- package/lab/demo/sources/Demo 1/busy-beach.jpg +0 -0
- package/lab/demo/sources/Demo 1/butcher-sign.jpg +0 -0
- package/lab/demo/sources/Demo 1/cactus-flower.jpg +0 -0
- package/lab/demo/sources/Demo 1/corner-shop.jpg +0 -0
- package/lab/demo/sources/Demo 1/crosswalk-cyclist-panorama.jpg +0 -0
- package/lab/demo/sources/Demo 1/cyanotype-room.jpg +0 -0
- package/lab/demo/sources/Demo 1/dead-end-sign.jpg +0 -0
- package/lab/demo/sources/Demo 1/dog-in-snow.jpg +0 -0
- package/lab/demo/sources/Demo 1/empty-bedroom.jpg +0 -0
- package/lab/demo/sources/Demo 1/farmers-market-stall.jpg +0 -0
- package/lab/demo/sources/Demo 1/four-jets.jpg +0 -0
- package/lab/demo/sources/Demo 1/german-shepherd.jpg +0 -0
- package/lab/demo/sources/Demo 1/harbour-bridge-dusk.jpg +0 -0
- package/lab/demo/sources/Demo 1/harrow-on-the-hill-sign.jpg +0 -0
- package/lab/demo/sources/Demo 1/hong-kong-street.jpg +0 -0
- package/lab/demo/sources/Demo 1/house-salisbury-street.jpg +0 -0
- package/lab/demo/sources/Demo 1/infrared-orchard.jpg +0 -0
- package/lab/demo/sources/Demo 1/keyboard-desk.jpg +0 -0
- package/lab/demo/sources/Demo 1/lighthouse-dunes.jpg +0 -0
- package/lab/demo/sources/Demo 1/mountain-lake.jpg +0 -0
- package/lab/demo/sources/Demo 1/museum-skeleton.jpg +0 -0
- package/lab/demo/sources/Demo 1/music-room.jpg +0 -0
- package/lab/demo/sources/Demo 1/old-red-car.jpg +0 -0
- package/lab/demo/sources/Demo 1/parked-car-plate.jpg +0 -0
- package/lab/demo/sources/Demo 1/phone-and-wallet.png +0 -0
- package/lab/demo/sources/Demo 1/phone-box.jpg +0 -0
- package/lab/demo/sources/Demo 1/pink-flamingo.jpg +0 -0
- package/lab/demo/sources/Demo 1/postcards.jpg +0 -0
- package/lab/demo/sources/Demo 1/red-roof-church.jpg +0 -0
- package/lab/demo/sources/Demo 1/roadside-mailboxes.jpg +0 -0
- package/lab/demo/sources/Demo 1/rodeo.jpg +0 -0
- package/lab/demo/sources/Demo 1/running-tap.tif +0 -0
- package/lab/demo/sources/Demo 1/snail-on-stem.jpg +0 -0
- package/lab/demo/sources/Demo 1/two-horses-field.jpg +0 -0
- package/lab/demo/sources/Demo 1/university-sign.jpg +0 -0
- package/lab/demo/sources/Demo 1/vegetable-crates.jpg +0 -0
- package/lab/demo/sources/Demo 1/watermarked-car.jpg +0 -0
- package/lab/demo/sources/Demo 1/watermarked-pills.jpg +0 -0
- package/lab/demo/sources/Demo 1/whiteboard-delegate.jpg +0 -0
- package/lab/demo/sources/Demo 1/whiteboard-germ-layers.jpg +0 -0
- package/lab/demo/sources/Demo 2/aerial-city.jpg +0 -0
- package/lab/demo/sources/Demo 2/alligator-pen.jpg +0 -0
- package/lab/demo/sources/Demo 2/arm-tattoo.jpg +0 -0
- package/lab/demo/sources/Demo 2/bed-and-plant.jpg +0 -0
- package/lab/demo/sources/Demo 2/bright-bedroom.jpg +0 -0
- package/lab/demo/sources/Demo 2/car-headlight.jpg +0 -0
- package/lab/demo/sources/Demo 2/child-in-surf.jpg +0 -0
- package/lab/demo/sources/Demo 2/city-highway.jpg +0 -0
- package/lab/demo/sources/Demo 2/corner-bakery.jpg +0 -0
- package/lab/demo/sources/Demo 2/cyclist-yellow-jacket.jpg +0 -0
- package/lab/demo/sources/Demo 2/excavator-street-signs.jpg +0 -0
- package/lab/demo/sources/Demo 2/globe-closeup.jpg +0 -0
- package/lab/demo/sources/Demo 2/harbour-village.jpg +0 -0
- package/lab/demo/sources/Demo 2/high-street-walkers.jpg +0 -0
- package/lab/demo/sources/Demo 2/hillside-rooftops.jpg +0 -0
- package/lab/demo/sources/Demo 2/house-at-night.jpg +0 -0
- package/lab/demo/sources/Demo 2/ivy-leaves.jpg +0 -0
- package/lab/demo/sources/Demo 2/man-with-alligator.jpg +0 -0
- package/lab/demo/sources/Demo 2/orange-mailbox-hedge.jpg +0 -0
- package/lab/demo/sources/Demo 2/pedal-boats.jpg +0 -0
- package/lab/demo/sources/Demo 2/pink-trees-vignette.jpg +0 -0
- package/lab/demo/sources/Demo 2/please-leave-quietly-sign.jpg +0 -0
- package/lab/demo/sources/Demo 2/railroad-crossing-sign.jpg +0 -0
- package/lab/demo/sources/Demo 2/roadworks-sign.jpg +0 -0
- package/lab/demo/sources/Demo 2/roundabout-sign.jpg +0 -0
- package/lab/demo/sources/Demo 2/sea-cave.jpg +0 -0
- package/lab/demo/sources/Demo 2/shadow-on-sand.jpg +0 -0
- package/lab/demo/sources/Demo 2/signpost-a404.jpg +0 -0
- package/lab/demo/sources/Demo 2/street-fruit-cart.jpg +0 -0
- package/lab/demo/sources/Demo 2/street-sign-parnassusweg.jpg +0 -0
- package/lab/demo/sources/Demo 2/two-horses-close.jpg +0 -0
- package/lab/demo/sources/Demo 2/victorian-house.jpg +0 -0
- package/lab/demo/sources/Demo 2/vintage-dashboard.jpg +0 -0
- package/lab/demo/sources/Demo 2/volcano-at-dusk.jpg +0 -0
- package/lab/demo/sources/Demo 2/wall-camera.jpg +0 -0
- package/lab/demo/sources/Demo 2/watch-for-rocks-sign.jpg +0 -0
- package/lab/demo/sources/Demo 2/waterfall.jpg +0 -0
- package/lab/demo/sources/Demo 2/white-domes.jpg +0 -0
- package/lab/demo/sources/Demo 2/whiteboard-meeting-notes.jpg +0 -0
- package/lab/demo/sources/Demo 2/whiteboard-messages.jpg +0 -0
- package/lab/demo/sources/Demo 2/wooden-house-fence.jpg +0 -0
- package/lab/evals-core.mjs +4526 -0
- package/lab/flows/wdl.mjs +373 -0
- package/lab/js-yaml.mjs +3851 -0
- package/lab/kinds/list.mjs +411 -0
- package/lab/metrics/builtin.mjs +353 -0
- package/lab/presets.json +62 -0
- package/lab/run-evals.js +1262 -0
- package/lab/server.py +5044 -0
- package/lab/web/dist/assets/dist-DI3ewZYj.js +1 -0
- package/lab/web/dist/assets/gallery-DipkRvqJ.js +3 -0
- package/lab/web/dist/assets/gallery-o7c4lfpn.css +1 -0
- package/lab/web/dist/assets/main-CpVssvrb.js +18 -0
- package/lab/web/dist/assets/main-DjQQums6.css +1 -0
- package/lab/web/dist/assets/tokens-B9intIuT.js +51 -0
- package/lab/web/dist/assets/tokens-s6I-RMVq.css +1 -0
- package/lab/web/dist/gallery.html +18 -0
- package/lab/web/dist/index.html +23 -0
- package/package.json +20 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// The lab's own metrics (docs/pipeline-model.md § Metrics): checks of a
|
|
2
|
+
// reply, in the manner of promptfoo's assertions -- deterministic ones that
|
|
3
|
+
// read the text, ones that read it against what production replied, and
|
|
4
|
+
// model-graded ones that ask a grader. Each is one METRICS entry the core
|
|
5
|
+
// registers, as it registers kinds/list.ts; a plugin adds more the same way.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const ok = (pass , reason , score = pass ? 1 : 0) => ({ pass, score, reason });
|
|
9
|
+
const str = (v ) => (v == null ? "" : String(v));
|
|
10
|
+
const num = (v ) => (typeof v === "number" ? v : str(v).trim() && !Number.isNaN(Number(v)) ? Number(v) : null);
|
|
11
|
+
/** A list option: one entry a line. */
|
|
12
|
+
const lines = (v ) => str(v).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
13
|
+
const short = (s ) => (s.length > 60 ? `${s.slice(0, 57)}…` : s);
|
|
14
|
+
const json = (s ) => {
|
|
15
|
+
try { return { value: JSON.parse(s) }; } catch (e) { return { error: e instanceof Error ? e.message : String(e) }; }
|
|
16
|
+
};
|
|
17
|
+
/** JSON values equal as values: an object's keys in any order. */
|
|
18
|
+
const sorted = (v ) => Array.isArray(v) ? v.map(sorted)
|
|
19
|
+
: v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted((v )[k])])) : v;
|
|
20
|
+
const same = (a , b ) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
|
|
21
|
+
const needs = (m , keys , at , bad ) => {
|
|
22
|
+
for (const k of keys) if (!str(m[k]).trim()) bad.push(`${at} needs its ${k}`);
|
|
23
|
+
};
|
|
24
|
+
const validRegex = (m , at , bad ) => {
|
|
25
|
+
try { new RegExp(str(m.pattern), str(m.flags)); } catch { bad.push(`${at}: ${str(m.pattern)} is not a regular expression`); }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A value at a path in [v]: `tip`, `items[0].name`. */
|
|
29
|
+
export function atPath(v , path ) {
|
|
30
|
+
let at = v;
|
|
31
|
+
for (const part of path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean)) {
|
|
32
|
+
if (at == null || typeof at !== "object") return undefined;
|
|
33
|
+
at = (at )[part];
|
|
34
|
+
}
|
|
35
|
+
return at;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Where [v] breaks [schema] -- the parts of JSON Schema a Parse JSON uses --
|
|
39
|
+
or null where it holds. */
|
|
40
|
+
export function schemaProblem(v , schema , at = "the reply") {
|
|
41
|
+
if (!schema || typeof schema !== "object") return null;
|
|
42
|
+
const s = schema ;
|
|
43
|
+
const type = s.type;
|
|
44
|
+
const kind = v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" && Number.isInteger(v) ? "integer" : typeof v;
|
|
45
|
+
const types = Array.isArray(type) ? type : type ? [type] : [];
|
|
46
|
+
if (types.length && !types.some((t) => t === kind || (t === "number" && kind === "integer"))) {
|
|
47
|
+
return `${at} is ${kind}, and the schema wants ${types.join(" or ")}`;
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(s.enum) && !s.enum.some((e) => same(e, v))) return `${at} is ${JSON.stringify(v)}, not one of ${s.enum.map((e) => JSON.stringify(e)).join(", ")}`;
|
|
50
|
+
if (kind === "object") {
|
|
51
|
+
const o = v ;
|
|
52
|
+
for (const r of Array.isArray(s.required) ? s.required : []) if (!(String(r) in o)) return `${at} has no ${r}`;
|
|
53
|
+
const props = (s.properties ?? {}) ;
|
|
54
|
+
for (const [k, sub] of Object.entries(props)) {
|
|
55
|
+
if (k in o) { const p = schemaProblem(o[k], sub, `${at}.${k}`); if (p) return p; }
|
|
56
|
+
}
|
|
57
|
+
if (s.additionalProperties === false) {
|
|
58
|
+
const extra = Object.keys(o).find((k) => !(k in props));
|
|
59
|
+
if (extra) return `${at} has ${extra}, which the schema does not allow`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (kind === "array" && s.items) {
|
|
63
|
+
const a = v ;
|
|
64
|
+
for (let i = 0; i < a.length; i++) { const p = schemaProblem(a[i], s.items, `${at}[${i}]`); if (p) return p; }
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Levenshtein distance, for the near-enough metric. */
|
|
70
|
+
function distance(a , b ) {
|
|
71
|
+
const d = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
72
|
+
for (let i = 1; i <= a.length; i++) {
|
|
73
|
+
let prev = d[0] ;
|
|
74
|
+
d[0] = i;
|
|
75
|
+
for (let j = 1; j <= b.length; j++) {
|
|
76
|
+
const was = d[j] ;
|
|
77
|
+
d[j] = Math.min(d[j] + 1, d[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
78
|
+
prev = was;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return d[b.length] ;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Production's reply, or the reason a metric that compares with it cannot. */
|
|
85
|
+
const production = (input ) => {
|
|
86
|
+
if (input.production == null) throw new Error("this item has no production reply to compare with");
|
|
87
|
+
return input.production;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ---- model-graded -----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/** What a grader said, read from the first JSON object in its reply. */
|
|
93
|
+
function verdictOf(said ) {
|
|
94
|
+
const m = /\{[\s\S]*\}/.exec(said);
|
|
95
|
+
const read = m ? json(m[0]) : { error: "no JSON" };
|
|
96
|
+
if (read.error || !read.value || typeof read.value !== "object") throw new Error(`the grader's reply could not be read: ${short(said)}`);
|
|
97
|
+
return read.value ;
|
|
98
|
+
}
|
|
99
|
+
const ask = async (ctx , prompt ) => {
|
|
100
|
+
if (!ctx.ask) throw new Error("a model-graded metric needs a grader, and this run has none");
|
|
101
|
+
return ctx.ask(prompt);
|
|
102
|
+
};
|
|
103
|
+
const gradedPass = (v , threshold ) => {
|
|
104
|
+
const score = typeof v.score === "number" ? Math.max(0, Math.min(1, v.score)) : v.pass === true ? 1 : 0;
|
|
105
|
+
const pass = typeof v.pass === "boolean" ? v.pass && score >= threshold : score >= threshold;
|
|
106
|
+
return { pass, score, reason: str(v.reason) || (pass ? "the grader passed it" : "the grader failed it") };
|
|
107
|
+
};
|
|
108
|
+
const REPLY = 'Reply with one JSON object and nothing else: {"pass": true or false, "score": a number from 0 to 1, "reason": "one sentence"}.';
|
|
109
|
+
|
|
110
|
+
const metrics = {
|
|
111
|
+
equals: {
|
|
112
|
+
label: "Equals",
|
|
113
|
+
description: "Passes when the reply is exactly the value; as JSON, the same value with keys in any order.",
|
|
114
|
+
// As JSON, a reply equals the value as a value -- keys in any order;
|
|
115
|
+
// otherwise, as the text, once trimmed.
|
|
116
|
+
options: [{ key: "value", label: "Value", type: "textarea" }, { key: "json", label: "As JSON", type: "checkbox" }],
|
|
117
|
+
defaults: () => ({ value: "", json: true }),
|
|
118
|
+
score: (input, m) => {
|
|
119
|
+
const want = str(m.value).trim(), got = input.text.trim();
|
|
120
|
+
const a = json(got), b = json(want);
|
|
121
|
+
const equal = m.json !== false && !a.error && !b.error ? same(a.value, b.value) : got === want;
|
|
122
|
+
return ok(equal, equal ? "equal" : `${short(got)} is not ${short(want)}`);
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
contains: {
|
|
126
|
+
label: "Contains",
|
|
127
|
+
description: "Passes when the reply contains the value.",
|
|
128
|
+
options: [{ key: "value", label: "Value", type: "text" }, { key: "ignoreCase", label: "Ignore case", type: "checkbox" }],
|
|
129
|
+
defaults: () => ({ value: "", ignoreCase: false }),
|
|
130
|
+
validate: (m, at, bad) => needs(m, ["value"], at, bad),
|
|
131
|
+
score: (input, m) => {
|
|
132
|
+
const fold = (s ) => (m.ignoreCase ? s.toLowerCase() : s);
|
|
133
|
+
const hit = fold(input.text).includes(fold(str(m.value)));
|
|
134
|
+
return ok(hit, hit ? `has ${short(str(m.value))}` : `has no ${short(str(m.value))}`);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
"contains-any": {
|
|
138
|
+
label: "Contains any",
|
|
139
|
+
description: "Passes when the reply contains at least one of the values, one a line.",
|
|
140
|
+
options: [{ key: "values", label: "Values", type: "textarea" }, { key: "ignoreCase", label: "Ignore case", type: "checkbox" }],
|
|
141
|
+
defaults: () => ({ values: "", ignoreCase: false }),
|
|
142
|
+
validate: (m, at, bad) => needs(m, ["values"], at, bad),
|
|
143
|
+
score: (input, m) => {
|
|
144
|
+
const fold = (s ) => (m.ignoreCase ? s.toLowerCase() : s);
|
|
145
|
+
const hit = lines(m.values).find((v) => fold(input.text).includes(fold(v)));
|
|
146
|
+
return ok(!!hit, hit ? `has ${short(hit)}` : `has none of ${lines(m.values).join(", ")}`);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
"contains-all": {
|
|
150
|
+
label: "Contains all",
|
|
151
|
+
description: "Passes when the reply contains every value, one a line.",
|
|
152
|
+
options: [{ key: "values", label: "Values", type: "textarea" }, { key: "ignoreCase", label: "Ignore case", type: "checkbox" }],
|
|
153
|
+
defaults: () => ({ values: "", ignoreCase: false }),
|
|
154
|
+
validate: (m, at, bad) => needs(m, ["values"], at, bad),
|
|
155
|
+
score: (input, m) => {
|
|
156
|
+
const fold = (s ) => (m.ignoreCase ? s.toLowerCase() : s);
|
|
157
|
+
const all = lines(m.values);
|
|
158
|
+
const missing = all.filter((v) => !fold(input.text).includes(fold(v)));
|
|
159
|
+
return ok(!missing.length, missing.length ? `has no ${missing.join(", ")}` : "has them all",
|
|
160
|
+
all.length ? (all.length - missing.length) / all.length : 1);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
regex: {
|
|
164
|
+
label: "Matches",
|
|
165
|
+
description: "Passes when the reply matches the regular expression.",
|
|
166
|
+
options: [{ key: "pattern", label: "Pattern", type: "text" }, { key: "flags", label: "Flags", type: "text" }],
|
|
167
|
+
defaults: () => ({ pattern: "", flags: "" }),
|
|
168
|
+
validate: (m, at, bad) => { needs(m, ["pattern"], at, bad); validRegex(m, at, bad); },
|
|
169
|
+
score: (input, m) => {
|
|
170
|
+
const hit = new RegExp(str(m.pattern), str(m.flags)).test(input.text);
|
|
171
|
+
return ok(hit, hit ? `matches /${str(m.pattern)}/` : `does not match /${str(m.pattern)}/`);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
"starts-with": {
|
|
175
|
+
label: "Starts with",
|
|
176
|
+
description: "Passes when the reply starts with the value.",
|
|
177
|
+
options: [{ key: "value", label: "Value", type: "text" }],
|
|
178
|
+
defaults: () => ({ value: "" }),
|
|
179
|
+
validate: (m, at, bad) => needs(m, ["value"], at, bad),
|
|
180
|
+
score: (input, m) => {
|
|
181
|
+
const hit = input.text.startsWith(str(m.value));
|
|
182
|
+
return ok(hit, hit ? `starts with ${short(str(m.value))}` : `starts ${JSON.stringify(input.text.slice(0, 12))}`);
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
"is-json": {
|
|
186
|
+
label: "Is JSON",
|
|
187
|
+
description: "Passes when the reply is JSON and, with a schema, holds to it.",
|
|
188
|
+
options: [{ key: "schema", label: "Schema", type: "textarea" }],
|
|
189
|
+
defaults: () => ({ schema: "" }),
|
|
190
|
+
validate: (m, at, bad) => { if (str(m.schema).trim() && json(str(m.schema)).error) bad.push(`${at}: the schema is not JSON`); },
|
|
191
|
+
score: (input, m) => {
|
|
192
|
+
const read = json(input.text);
|
|
193
|
+
if (read.error) return ok(false, `not JSON: ${read.error}`);
|
|
194
|
+
const schema = str(m.schema).trim() ? json(str(m.schema)).value : null;
|
|
195
|
+
const why = schemaProblem(read.value, schema);
|
|
196
|
+
return ok(!why, why ?? (schema ? "as the schema says" : "valid"));
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
"json-field": {
|
|
200
|
+
label: "JSON field",
|
|
201
|
+
description: "Reads one field of a JSON reply and compares it.",
|
|
202
|
+
options: [
|
|
203
|
+
{ key: "path", label: "Field", type: "text" },
|
|
204
|
+
{ key: "op", label: "Is", type: "select", choices: [
|
|
205
|
+
{ value: "equals", label: "equal to" }, { value: "one-of", label: "one of" }, { value: "matches", label: "matching" }] },
|
|
206
|
+
{ key: "value", label: "Value", type: "textarea" },
|
|
207
|
+
],
|
|
208
|
+
defaults: () => ({ path: "", op: "equals", value: "" }),
|
|
209
|
+
validate: (m, at, bad) => { needs(m, ["path"], at, bad); if (m.op === "matches") validRegex({ ...m, pattern: m.value }, at, bad); },
|
|
210
|
+
score: (input, m) => {
|
|
211
|
+
const read = json(input.text);
|
|
212
|
+
if (read.error) return ok(false, `not JSON: ${read.error}`);
|
|
213
|
+
const v = atPath(read.value, str(m.path));
|
|
214
|
+
if (v === undefined) return ok(false, `has no ${str(m.path)}`);
|
|
215
|
+
const text = typeof v === "string" ? v : JSON.stringify(v);
|
|
216
|
+
const pass = m.op === "one-of" ? lines(m.value).includes(text)
|
|
217
|
+
: m.op === "matches" ? new RegExp(str(m.value)).test(text)
|
|
218
|
+
: text === str(m.value).trim();
|
|
219
|
+
return ok(pass, `${str(m.path)} is ${short(text)}`);
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
"item-count": {
|
|
223
|
+
label: "Item count",
|
|
224
|
+
description: "Passes when the number of items is within the bounds.",
|
|
225
|
+
options: [{ key: "min", label: "At least", type: "number" }, { key: "max", label: "At most", type: "number" }],
|
|
226
|
+
defaults: () => ({ min: null, max: null }),
|
|
227
|
+
score: (input, m) => {
|
|
228
|
+
const n = input.terms.length, min = num(m.min), max = num(m.max);
|
|
229
|
+
const pass = (min == null || n >= min) && (max == null || n <= max);
|
|
230
|
+
return ok(pass, `${n} items`);
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
"count-matches": {
|
|
234
|
+
label: "Count matches",
|
|
235
|
+
description: "Counts matches of the pattern: points, for the weighted mode.",
|
|
236
|
+
counts: true,
|
|
237
|
+
options: [{ key: "pattern", label: "Pattern", type: "text" }, { key: "flags", label: "Flags", type: "text" },
|
|
238
|
+
{ key: "min", label: "At least", type: "number" }],
|
|
239
|
+
defaults: () => ({ pattern: "", flags: "i", min: null }),
|
|
240
|
+
validate: (m, at, bad) => { needs(m, ["pattern"], at, bad); validRegex(m, at, bad); },
|
|
241
|
+
score: (input, m) => {
|
|
242
|
+
const flags = str(m.flags).includes("g") ? str(m.flags) : `${str(m.flags)}g`;
|
|
243
|
+
const n = [...input.text.matchAll(new RegExp(str(m.pattern), flags))].length;
|
|
244
|
+
const min = num(m.min);
|
|
245
|
+
return { pass: min == null ? n > 0 : n >= min, score: n, reason: `${n} ${n === 1 ? "match" : "matches"}` };
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
latency: {
|
|
249
|
+
label: "Latency",
|
|
250
|
+
description: "Passes when the reply came back within the time set.",
|
|
251
|
+
options: [{ key: "max", label: "At most (ms)", type: "number" }],
|
|
252
|
+
defaults: () => ({ max: 5000 }),
|
|
253
|
+
validate: (m, at, bad) => { if (num(m.max) == null) bad.push(`${at} needs its most, in ms`); },
|
|
254
|
+
score: (input, m) => ok(input.ms <= (num(m.max) ?? 0), `${input.ms} ms`),
|
|
255
|
+
},
|
|
256
|
+
levenshtein: {
|
|
257
|
+
label: "Near",
|
|
258
|
+
description: "Passes when the reply is within a few edits of the value.",
|
|
259
|
+
options: [{ key: "value", label: "Value", type: "textarea" }, { key: "max", label: "Edits at most", type: "number" }],
|
|
260
|
+
defaults: () => ({ value: "", max: 5 }),
|
|
261
|
+
score: (input, m) => {
|
|
262
|
+
const want = str(m.value).trim(), got = input.text.trim();
|
|
263
|
+
const d = distance(got, want);
|
|
264
|
+
return ok(d <= (num(m.max) ?? 0), `${d} ${d === 1 ? "edit" : "edits"} away`,
|
|
265
|
+
1 - d / Math.max(1, Math.max(got.length, want.length)));
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
// ---- against production ----------------------------------------------------
|
|
269
|
+
"equals-production": {
|
|
270
|
+
label: "Same as production",
|
|
271
|
+
description: "Passes when the reply is what production replied to the same item.",
|
|
272
|
+
options: [],
|
|
273
|
+
defaults: () => ({}),
|
|
274
|
+
score: (input) => {
|
|
275
|
+
const p = production(input).trim(), got = input.text.trim();
|
|
276
|
+
const a = json(got), b = json(p);
|
|
277
|
+
const equal = !a.error && !b.error ? same(a.value, b.value) : got === p;
|
|
278
|
+
return ok(equal, equal ? "as production replied" : `production replied ${short(p)}`);
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
"fields-equal-production": {
|
|
282
|
+
label: "Fields as production",
|
|
283
|
+
description: "Passes when the fields listed are what production's reply had.",
|
|
284
|
+
options: [{ key: "fields", label: "Fields", type: "textarea" }],
|
|
285
|
+
defaults: () => ({ fields: "" }),
|
|
286
|
+
validate: (m, at, bad) => needs(m, ["fields"], at, bad),
|
|
287
|
+
score: (input, m) => {
|
|
288
|
+
const a = json(input.text), b = json(production(input));
|
|
289
|
+
if (a.error) return ok(false, `not JSON: ${a.error}`);
|
|
290
|
+
if (b.error) return ok(false, "production's reply was not JSON");
|
|
291
|
+
const fields = lines(m.fields);
|
|
292
|
+
const differ = fields.filter((f) => !same(atPath(a.value, f), atPath(b.value, f)));
|
|
293
|
+
return ok(!differ.length, differ.length ? `${differ.join(", ")} differ from production` : "the fields match production",
|
|
294
|
+
fields.length ? (fields.length - differ.length) / fields.length : 1);
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
"same-parse-outcome": {
|
|
298
|
+
label: "Parses as production",
|
|
299
|
+
description: "Passes when the reply parses as JSON exactly when production's did.",
|
|
300
|
+
options: [],
|
|
301
|
+
defaults: () => ({}),
|
|
302
|
+
score: (input) => {
|
|
303
|
+
const mine = !json(input.text).error, theirs = !json(production(input)).error;
|
|
304
|
+
return ok(mine === theirs, `${mine ? "parses" : "does not parse"}, production's ${theirs ? "parsed" : "did not"}`);
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
// ---- model-graded -------------------------------------------------------------
|
|
308
|
+
"llm-rubric": {
|
|
309
|
+
label: "Rubric",
|
|
310
|
+
description: "Asks the grader whether the reply meets the rubric.",
|
|
311
|
+
graded: true,
|
|
312
|
+
options: [{ key: "rubric", label: "Rubric", type: "textarea" }, { key: "threshold", label: "Pass at", type: "number" }],
|
|
313
|
+
defaults: () => ({ rubric: "", threshold: 0.5 }),
|
|
314
|
+
validate: (m, at, bad) => needs(m, ["rubric"], at, bad),
|
|
315
|
+
score: async (input, m, ctx) => gradedPass(verdictOf(await ask(ctx,
|
|
316
|
+
`You are grading an output against a rubric.\n\nRubric:\n${str(m.rubric)}\n\nOutput:\n${input.text}\n\n${REPLY}`)),
|
|
317
|
+
num(m.threshold) ?? 0.5),
|
|
318
|
+
},
|
|
319
|
+
factuality: {
|
|
320
|
+
label: "Factual",
|
|
321
|
+
description: "Asks the grader whether the reply agrees with the reference, or production's reply.",
|
|
322
|
+
graded: true,
|
|
323
|
+
options: [{ key: "reference", label: "Reference", type: "textarea" }, { key: "threshold", label: "Pass at", type: "number" }],
|
|
324
|
+
defaults: () => ({ reference: "", threshold: 0.5 }),
|
|
325
|
+
// A blank reference is production's reply.
|
|
326
|
+
score: async (input, m, ctx) => gradedPass(verdictOf(await ask(ctx,
|
|
327
|
+
`You are checking an output for factual consistency with a reference. Differences in wording or detail are `
|
|
328
|
+
+ `fine; a contradiction, or a fact the reference does not support, is not.\n\nReference:\n`
|
|
329
|
+
+ `${str(m.reference).trim() || production(input)}\n\nOutput:\n${input.text}\n\n${REPLY}`)), num(m.threshold) ?? 0.5),
|
|
330
|
+
},
|
|
331
|
+
"judge-vs-production": {
|
|
332
|
+
label: "Judged against production",
|
|
333
|
+
description: "Asks the grader whether the reply is better than, the same as or worse than production's.",
|
|
334
|
+
graded: true,
|
|
335
|
+
options: [{ key: "task", label: "Task", type: "textarea" }],
|
|
336
|
+
defaults: () => ({ task: "" }),
|
|
337
|
+
score: async (input, m, ctx) => {
|
|
338
|
+
const v = verdictOf(await ask(ctx,
|
|
339
|
+
`Two outputs answer the same task. Say whether the NEW one is better than, the same as, or worse than the `
|
|
340
|
+
+ `PRODUCTION one.${str(m.task).trim() ? `\n\nTask:\n${str(m.task)}` : ""}\n\nPRODUCTION:\n${production(input)}\n\n`
|
|
341
|
+
+ `NEW:\n${input.text}\n\nReply with one JSON object and nothing else: {"verdict": "better" or "same" or "worse", `
|
|
342
|
+
+ `"reason": "one sentence"}.`));
|
|
343
|
+
const verdict = str(v.verdict).toLowerCase();
|
|
344
|
+
if (!["better", "same", "worse"].includes(verdict)) throw new Error(`the grader said ${short(JSON.stringify(v))}`);
|
|
345
|
+
return { pass: verdict !== "worse", score: verdict === "better" ? 1 : verdict === "same" ? 0.5 : 0,
|
|
346
|
+
reason: `${verdict}: ${str(v.reason)}` };
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export default function register(core ) {
|
|
352
|
+
core.registerKinds({ metrics });
|
|
353
|
+
}
|
package/lab/presets.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "repeat-1.2",
|
|
4
|
+
"label": "repeat 1.2",
|
|
5
|
+
"opts": {
|
|
6
|
+
"repeatPenalty": "1.2"
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "repeat-1.3",
|
|
11
|
+
"label": "repeat 1.3",
|
|
12
|
+
"opts": {
|
|
13
|
+
"repeatPenalty": "1.3"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "frequency-0.5",
|
|
18
|
+
"label": "frequency 0.5",
|
|
19
|
+
"opts": {
|
|
20
|
+
"frequencyPenalty": "0.5"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "presence-0.5",
|
|
25
|
+
"label": "presence 0.5",
|
|
26
|
+
"opts": {
|
|
27
|
+
"presencePenalty": "0.5"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "dry",
|
|
32
|
+
"label": "DRY 0.8 / 1.75 / 2",
|
|
33
|
+
"opts": {
|
|
34
|
+
"dryMultiplier": "0.8",
|
|
35
|
+
"dryBase": "1.75",
|
|
36
|
+
"dryAllowedLength": "2"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "dry-strong",
|
|
41
|
+
"label": "DRY 2 / 2 / 2",
|
|
42
|
+
"opts": {
|
|
43
|
+
"dryMultiplier": "2",
|
|
44
|
+
"dryBase": "2",
|
|
45
|
+
"dryAllowedLength": "2"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "grammar",
|
|
50
|
+
"label": "grammar",
|
|
51
|
+
"opts": {
|
|
52
|
+
"grammar": "root ::= \" \"? item (\",\" \" \"? item){0,23}\nitem ::= word (\" \" word){0,3}\nword ::= [A-Za-z] [-A-Za-z']{0,23}"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "grammar-numbered",
|
|
57
|
+
"label": "grammar + numbers",
|
|
58
|
+
"opts": {
|
|
59
|
+
"grammar": "root ::= \" \"? item (\",\" \" \"? item){0,23}\nitem ::= (num \". \")? word (\" \" word){0,3}\nnum ::= [0-9] [0-9]?\nword ::= [A-Za-z] [-A-Za-z']{0,23}"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
]
|