@crowandraven/evalkeeper 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +6 -2
- package/bin/evalkeeper.mjs +9 -0
- package/dist/attention.d.ts +49 -0
- package/dist/attention.js +48 -0
- package/dist/attention.js.map +1 -0
- package/dist/board.d.ts +55 -0
- package/dist/board.js +2 -0
- package/dist/board.js.map +1 -0
- package/dist/cli/addPages.d.ts +79 -0
- package/dist/cli/addPages.js +181 -0
- package/dist/cli/addPages.js.map +1 -0
- package/dist/cli/main.d.ts +18 -0
- package/dist/cli/main.js +115 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/common.d.ts +72 -0
- package/dist/common.js +276 -0
- package/dist/common.js.map +1 -0
- package/dist/compare.d.ts +85 -0
- package/dist/compare.js +185 -0
- package/dist/compare.js.map +1 -0
- package/dist/decisions.d.ts +84 -0
- package/dist/decisions.js +146 -0
- package/dist/decisions.js.map +1 -0
- package/dist/feedback.d.ts +114 -0
- package/dist/feedback.js +116 -0
- package/dist/feedback.js.map +1 -0
- package/dist/format.d.ts +6 -0
- package/dist/format.js +22 -0
- package/dist/format.js.map +1 -0
- package/dist/glossary.d.ts +46 -0
- package/dist/glossary.js +45 -0
- package/dist/glossary.js.map +1 -0
- package/dist/health.d.ts +40 -0
- package/dist/health.js +101 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.d.ts +8 -0
- package/dist/labels.js +15 -0
- package/dist/labels.js.map +1 -0
- package/dist/legs.d.ts +23 -0
- package/dist/legs.js +22 -0
- package/dist/legs.js.map +1 -0
- package/dist/memoryStore.d.ts +21 -0
- package/dist/memoryStore.js +228 -0
- package/dist/memoryStore.js.map +1 -0
- package/dist/models.d.ts +50 -0
- package/dist/models.js +39 -0
- package/dist/models.js.map +1 -0
- package/dist/prune.d.ts +48 -0
- package/dist/prune.js +77 -0
- package/dist/prune.js.map +1 -0
- package/dist/quickSet.d.ts +26 -0
- package/dist/quickSet.js +44 -0
- package/dist/quickSet.js.map +1 -0
- package/dist/runner.d.ts +193 -0
- package/dist/runner.js +706 -0
- package/dist/runner.js.map +1 -0
- package/dist/settings.d.ts +157 -0
- package/dist/settings.js +183 -0
- package/dist/settings.js.map +1 -0
- package/dist/stableJson.d.ts +11 -0
- package/dist/stableJson.js +15 -0
- package/dist/stableJson.js.map +1 -0
- package/dist/stats.d.ts +98 -0
- package/dist/stats.js +119 -0
- package/dist/stats.js.map +1 -0
- package/dist/store.d.ts +146 -0
- package/dist/store.js +3 -0
- package/dist/store.js.map +1 -0
- package/dist/suggest.d.ts +75 -0
- package/dist/suggest.js +150 -0
- package/dist/suggest.js.map +1 -0
- package/dist/summary.d.ts +51 -0
- package/dist/summary.js +61 -0
- package/dist/summary.js.map +1 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +341 -0
- package/dist/testing.js.map +1 -0
- package/dist/timeout.d.ts +2 -0
- package/dist/timeout.js +15 -0
- package/dist/timeout.js.map +1 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.js +46 -0
- package/dist/types.js.map +1 -0
- package/dist/upkeepRules.d.ts +28 -0
- package/dist/upkeepRules.js +40 -0
- package/dist/upkeepRules.js.map +1 -0
- package/dist/util.d.ts +8 -0
- package/dist/util.js +18 -0
- package/dist/util.js.map +1 -0
- package/dist/weekly.d.ts +80 -0
- package/dist/weekly.js +111 -0
- package/dist/weekly.js.map +1 -0
- package/package.json +81 -4
- package/src/attention.ts +88 -0
- package/src/board.ts +56 -0
- package/src/cli/addPages.ts +256 -0
- package/src/cli/main.ts +125 -0
- package/src/common.ts +338 -0
- package/src/compare.ts +261 -0
- package/src/decisions.ts +230 -0
- package/src/feedback.ts +225 -0
- package/src/format.ts +19 -0
- package/src/glossary.ts +75 -0
- package/src/health.ts +147 -0
- package/src/index.ts +33 -0
- package/src/labels.ts +18 -0
- package/src/legs.ts +38 -0
- package/src/memoryStore.ts +268 -0
- package/src/models.ts +82 -0
- package/src/prune.ts +95 -0
- package/src/quickSet.ts +55 -0
- package/src/runner.ts +971 -0
- package/src/settings.ts +331 -0
- package/src/stableJson.ts +24 -0
- package/src/stats.ts +215 -0
- package/src/store.ts +167 -0
- package/src/suggest.ts +245 -0
- package/src/summary.ts +109 -0
- package/src/testing.ts +392 -0
- package/src/timeout.ts +14 -0
- package/src/types.ts +252 -0
- package/src/upkeepRules.ts +46 -0
- package/src/util.ts +17 -0
- package/src/weekly.ts +167 -0
- package/starter/index.ts +86 -0
- package/templates/app/api/cron/evals-weekly/route.ts +24 -0
- package/templates/app/evals/[suite]/page.tsx +449 -0
- package/templates/app/evals/[suite]/runs/[runId]/page.tsx +95 -0
- package/templates/app/evals/feedback/page.tsx +219 -0
- package/templates/app/evals/page.tsx +313 -0
- package/templates/components/evalkeeper/AddCase.tsx +62 -0
- package/templates/components/evalkeeper/AnswerKey.tsx +360 -0
- package/templates/components/evalkeeper/CaseMenu.tsx +122 -0
- package/templates/components/evalkeeper/Fields.tsx +110 -0
- package/templates/components/evalkeeper/RefreshWhile.tsx +25 -0
- package/templates/components/evalkeeper/SettingsHistory.tsx +191 -0
- package/templates/components/evalkeeper/Submit.tsx +58 -0
- package/templates/components/evalkeeper/Thumbs.tsx +117 -0
- package/templates/components/evalkeeper/Tip.tsx +98 -0
- package/templates/components/evalkeeper/WeeklyCheck.tsx +124 -0
- package/templates/components/evalkeeper/evalkeeper.css +71 -0
- package/templates/components/evalkeeper/ui.tsx +103 -0
- package/templates/lib/evalkeeper/actions.ts +263 -0
- package/templates/lib/evalkeeper/cronAuth.ts +18 -0
- package/templates/lib/evalkeeper/paths.ts +11 -0
- package/templates/lib/evalkeeper/time.ts +4 -0
- package/templates/lib/evalkeeper/weekly.ts +51 -0
package/src/common.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { CheckDef, CheckResult } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checks shared by suites: rules many operations' instructions state the
|
|
5
|
+
* same way (lib/ai/voice.ts, "don't invent numbers"). Pure, so they're
|
|
6
|
+
* unit-tested and rerun on stored outputs for free.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// He, she, and the like never belong in text addressed to the person.
|
|
10
|
+
// They, them, and their often mean things, so they count only when they
|
|
11
|
+
// open a sentence the way writing about the person would ("They want").
|
|
12
|
+
const PRONOUNS = /\b(he|she|him|her|his|hers|himself|herself)\b/i;
|
|
13
|
+
const THIRD_PERSON_START = /(^|[.!?]\s+)(they|their|them)\b/i;
|
|
14
|
+
|
|
15
|
+
/** Text that refers to the person as he, she, or they; null when none. */
|
|
16
|
+
export function thirdPerson(text: string): string | null {
|
|
17
|
+
const m = text.match(PRONOUNS) ?? text.match(THIRD_PERSON_START);
|
|
18
|
+
return m ? m[0].trim().replace(/^[.!?]\s*/, "") : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Standard phrasing whose numbers aren't claims about the person: a "first
|
|
23
|
+
* 90 days" plan, "30-60-90", "2 to 3 minutes", a "top 5", a quarter (Q3),
|
|
24
|
+
* fact ids ("[f9]"), and list markers ("(1)", "2.").
|
|
25
|
+
*/
|
|
26
|
+
export function withoutStockPhrases(text: string): string {
|
|
27
|
+
return (
|
|
28
|
+
text
|
|
29
|
+
.replace(/\b30\s*[-–/]\s*60\s*[-–/]\s*90\b/g, " ")
|
|
30
|
+
.replace(/\b(?:first|next|initial)\s+(?:30|60|90|100)\s+days\b/gi, " ")
|
|
31
|
+
.replace(/\b(?:30|60|90)\s+days?\s*[:\-–]/gi, " ")
|
|
32
|
+
.replace(
|
|
33
|
+
/\b\d+\s*(?:to|-|–|or)\s*\d+\s*(?:minutes?|mins?|sentences?|seconds?)\b/gi,
|
|
34
|
+
" ",
|
|
35
|
+
)
|
|
36
|
+
.replace(
|
|
37
|
+
/\b(?:under|about|in|within)\s+\d+\s+(?:minutes?|seconds?)\b/gi,
|
|
38
|
+
" ",
|
|
39
|
+
)
|
|
40
|
+
.replace(/\btop\s+(?:3|5|10)\b/gi, " ")
|
|
41
|
+
.replace(/\bQ[1-4]\b/g, " ")
|
|
42
|
+
// References, not figures: fact ids ("[f9]", "f10") and list markers.
|
|
43
|
+
.replace(/\bf\d+\b/g, " ")
|
|
44
|
+
.replace(/\(\d{1,2}\)/g, " ")
|
|
45
|
+
.replace(/(^|\n)\s*\d{1,2}[.)]\s/g, "$1 ")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Nouns a number counts in a claim ("twenty years", "five reports"). */
|
|
50
|
+
const COUNTED =
|
|
51
|
+
"years?|months?|weeks?|hours?|people|employees|staff|clients|customers|direct reports|reports|students|patients|accounts|projects|sites|schools|percent";
|
|
52
|
+
|
|
53
|
+
const NUMBER_WORDS =
|
|
54
|
+
"(?:(?:twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)(?:[\\s-](?:one|two|three|four|five|six|seven|eight|nine))?|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|two|three|four|five|six|seven|eight|nine|ten)";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Number words that count something in an answer ("twenty years", "a
|
|
58
|
+
* decade", "five direct reports") as digits, so an invented figure written
|
|
59
|
+
* out is caught too. Number words that count nothing ("one idea", "two
|
|
60
|
+
* things to try") stay words.
|
|
61
|
+
*/
|
|
62
|
+
export function withCountedWords(text: string): string {
|
|
63
|
+
return text
|
|
64
|
+
.replace(/\b(?:a|one)\s+decade\b/gi, "10 years")
|
|
65
|
+
.replace(
|
|
66
|
+
new RegExp(`\\b${NUMBER_WORDS}(?=\\s+(?:${COUNTED})\\b)`, "gi"),
|
|
67
|
+
(w) => withNumberWords(w),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Numbers in `text` that appear in none of the sources (150k and 150,000
|
|
73
|
+
* count as the same). Years and small counts are numbers too: a letter
|
|
74
|
+
* that says "12 years" when the profile says 10 has invented one.
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* A number with an optional "k" or "m" ("150k", "1.5m"). It starts only
|
|
78
|
+
* where a number starts and reads it once (the lookahead and backreference
|
|
79
|
+
* keep the digits from being retried), so long digit runs in model output
|
|
80
|
+
* stay linear. A number glued to a word ("3rd", "10am") doesn't count.
|
|
81
|
+
*/
|
|
82
|
+
const NUMBER = /(?<![\d,.])(?=(\d[\d,]*(?:\.\d+)?))\1\s*([km])?\b/gi;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Numbers the text states that appear in none of the sources: likely invented.
|
|
86
|
+
* Standard phrasing (a 30-60-90 day plan, list markers, fact ids) doesn't count.
|
|
87
|
+
*/
|
|
88
|
+
export function inventedNumbers(text: string, sources: string[]): string[] {
|
|
89
|
+
const known = new Set(
|
|
90
|
+
sources.flatMap((s) => [...numbersIn(withNumberWords(s))]),
|
|
91
|
+
);
|
|
92
|
+
const out: string[] = [];
|
|
93
|
+
const claims = withCountedWords(withoutStockPhrases(text));
|
|
94
|
+
for (const m of claims.matchAll(NUMBER)) {
|
|
95
|
+
const found = numbersIn(m[0]);
|
|
96
|
+
if (![...found].some((n) => known.has(n))) out.push(m[0].trim());
|
|
97
|
+
}
|
|
98
|
+
return [...new Set(out)];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** How many words the text has. */
|
|
102
|
+
export function wordCount(text: string): number {
|
|
103
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Sentences, counted by their ending punctuation (a last one without it counts). */
|
|
107
|
+
export function sentenceCount(text: string): number {
|
|
108
|
+
const t = text.trim();
|
|
109
|
+
if (!t) return 0;
|
|
110
|
+
// The last mark before a space or the end, so "?!" counts once. Linear:
|
|
111
|
+
// a run of marks is never retried from each position.
|
|
112
|
+
const ends = t.match(/[.!?](?=\s|$)/g)?.length ?? 0;
|
|
113
|
+
return /[.!?]["')\]]?$/.test(t) ? ends : ends + 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Pass when n is within [min, max]; the note says what it was. */
|
|
117
|
+
export function inRange(
|
|
118
|
+
key: string,
|
|
119
|
+
n: number,
|
|
120
|
+
min: number,
|
|
121
|
+
max: number,
|
|
122
|
+
noun: string,
|
|
123
|
+
): CheckResult {
|
|
124
|
+
const pass = n >= min && n <= max;
|
|
125
|
+
return {
|
|
126
|
+
key,
|
|
127
|
+
pass,
|
|
128
|
+
...(pass
|
|
129
|
+
? {}
|
|
130
|
+
: {
|
|
131
|
+
note: `${n} ${noun}; should be ${min === max ? min : `${min} to ${max}`}`,
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The "addressed as you" check over several pieces of text. */
|
|
137
|
+
export function voiceCheck(key: string, texts: string[]): CheckResult {
|
|
138
|
+
for (const t of texts) {
|
|
139
|
+
const hit = thirdPerson(t);
|
|
140
|
+
if (hit) return { key, pass: false, note: `"${hit}" in: ${t}` };
|
|
141
|
+
}
|
|
142
|
+
return { key, pass: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The "no invented numbers" check over several pieces of text. */
|
|
146
|
+
export function numbersCheck(
|
|
147
|
+
key: string,
|
|
148
|
+
texts: string[],
|
|
149
|
+
sources: string[],
|
|
150
|
+
): CheckResult {
|
|
151
|
+
const invented = [
|
|
152
|
+
...new Set(texts.flatMap((t) => inventedNumbers(t, sources))),
|
|
153
|
+
];
|
|
154
|
+
return invented.length
|
|
155
|
+
? {
|
|
156
|
+
key,
|
|
157
|
+
pass: false,
|
|
158
|
+
note: `Not in what it was given: ${invented.join(", ")}`,
|
|
159
|
+
}
|
|
160
|
+
: { key, pass: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A minor check that answers speak to the person ("you"), not about them.
|
|
165
|
+
*/
|
|
166
|
+
export const VOICE_CHECK: CheckDef = {
|
|
167
|
+
key: "voice",
|
|
168
|
+
label: 'Addressed as "you"',
|
|
169
|
+
description:
|
|
170
|
+
'Never "he", "she", or "they" for the person (lib/ai/voice.ts): they read it about themselves.',
|
|
171
|
+
kind: "code",
|
|
172
|
+
severity: "serious",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A serious check that every number in an answer appears in what the model was given.
|
|
177
|
+
*/
|
|
178
|
+
export const NUMBERS_CHECK: CheckDef = {
|
|
179
|
+
key: "numbers",
|
|
180
|
+
label: "No invented numbers",
|
|
181
|
+
description:
|
|
182
|
+
"Every number appears in what the model was given (150k and 150,000 count as the same).",
|
|
183
|
+
kind: "code",
|
|
184
|
+
severity: "serious",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Numbers in text, with "150k" and "1.5m" also read as 150000 and 1500000. */
|
|
188
|
+
export function numbersIn(text: string): Set<string> {
|
|
189
|
+
const out = new Set<string>();
|
|
190
|
+
for (const m of text.matchAll(NUMBER)) {
|
|
191
|
+
const n = Number((m[1] ?? "").replace(/,/g, ""));
|
|
192
|
+
if (!Number.isFinite(n)) continue;
|
|
193
|
+
out.add(String(n));
|
|
194
|
+
const suffix = m[2]?.toLowerCase();
|
|
195
|
+
if (suffix === "k") out.add(String(Math.round(n * 1_000)));
|
|
196
|
+
if (suffix === "m") out.add(String(Math.round(n * 1_000_000)));
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const ONES = [
|
|
202
|
+
"zero",
|
|
203
|
+
"one",
|
|
204
|
+
"two",
|
|
205
|
+
"three",
|
|
206
|
+
"four",
|
|
207
|
+
"five",
|
|
208
|
+
"six",
|
|
209
|
+
"seven",
|
|
210
|
+
"eight",
|
|
211
|
+
"nine",
|
|
212
|
+
"ten",
|
|
213
|
+
"eleven",
|
|
214
|
+
"twelve",
|
|
215
|
+
"thirteen",
|
|
216
|
+
"fourteen",
|
|
217
|
+
"fifteen",
|
|
218
|
+
"sixteen",
|
|
219
|
+
"seventeen",
|
|
220
|
+
"eighteen",
|
|
221
|
+
"nineteen",
|
|
222
|
+
];
|
|
223
|
+
const TENS = [
|
|
224
|
+
"",
|
|
225
|
+
"",
|
|
226
|
+
"twenty",
|
|
227
|
+
"thirty",
|
|
228
|
+
"forty",
|
|
229
|
+
"fifty",
|
|
230
|
+
"sixty",
|
|
231
|
+
"seventy",
|
|
232
|
+
"eighty",
|
|
233
|
+
"ninety",
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Number words up to ninety-nine as digits ("sixteen weeks" → "16 weeks",
|
|
238
|
+
* "twenty-one" → "21"), for the evidence side of a number check: sources
|
|
239
|
+
* often spell numbers out where answers use digits.
|
|
240
|
+
*/
|
|
241
|
+
export function withNumberWords(text: string): string {
|
|
242
|
+
const tens = TENS.filter(Boolean).join("|");
|
|
243
|
+
return text
|
|
244
|
+
.replace(
|
|
245
|
+
new RegExp(`\\b(${tens})[\\s-](${ONES.slice(1, 10).join("|")})\\b`, "gi"),
|
|
246
|
+
(_, t: string, o: string) =>
|
|
247
|
+
String(
|
|
248
|
+
TENS.indexOf(t.toLowerCase()) * 10 + ONES.indexOf(o.toLowerCase()),
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
.replace(new RegExp(`\\b(${tens})\\b`, "gi"), (t: string) =>
|
|
252
|
+
String(TENS.indexOf(t.toLowerCase()) * 10),
|
|
253
|
+
)
|
|
254
|
+
.replace(new RegExp(`\\b(${ONES.join("|")})\\b`, "gi"), (o: string) =>
|
|
255
|
+
String(ONES.indexOf(o.toLowerCase())),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A word's stem for matching: words of 5 letters or more lose one common
|
|
261
|
+
* ending ("training", "trained" → "train"); shorter words and codes ("AR",
|
|
262
|
+
* "CPT") match as written, so "AR" never matches "are".
|
|
263
|
+
*/
|
|
264
|
+
export function stem(word: string): string {
|
|
265
|
+
const w = word.toLowerCase();
|
|
266
|
+
if (w.length < 5) return w;
|
|
267
|
+
const cut = w
|
|
268
|
+
.replace(/(ings|ing|ers|er|ed|es|ions|ion|ly|s)$/, "")
|
|
269
|
+
// "drone" and "drones" both become "dron".
|
|
270
|
+
.replace(/(.{4,})e$/, "$1");
|
|
271
|
+
return cut.length >= 4 ? cut : w;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const wordsOf = (text: string) => text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether every word of `phrase` appears in `text`, by stem ("trained new
|
|
278
|
+
* staff" has "training"). "a|b" accepts either phrase.
|
|
279
|
+
*/
|
|
280
|
+
export function hasPhrase(text: string, phrase: string): boolean {
|
|
281
|
+
const have = new Set(wordsOf(text).map(stem));
|
|
282
|
+
return phrase
|
|
283
|
+
.split("|")
|
|
284
|
+
.some((p) => wordsOf(p).every((w) => have.has(stem(w))));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Words that carry meaning, stemmed: for matching by shared words. */
|
|
288
|
+
export function contentWords(text: string): Set<string> {
|
|
289
|
+
return new Set(
|
|
290
|
+
(text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
|
|
291
|
+
.filter((w) => w.length > 2 && !STOP_WORDS.has(w))
|
|
292
|
+
.map(stem),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const STOP_WORDS = new Set(
|
|
297
|
+
"the and for with that this from into onto over under about after before their there them they you your our its are was were will would should could has have had not but all any can each per out off than then when what who how why".split(
|
|
298
|
+
" ",
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Pairs expected items with actual ones by shared content words, best
|
|
304
|
+
* overlap first, one to one. The fallback when no judge model is set: it
|
|
305
|
+
* misses paraphrases, so a meaning judge is better.
|
|
306
|
+
*/
|
|
307
|
+
export function wordPairs(
|
|
308
|
+
expected: string[],
|
|
309
|
+
actual: string[],
|
|
310
|
+
/** Share of an expected item's words the actual one must have. */
|
|
311
|
+
minShare = 0.5,
|
|
312
|
+
): { expected: number; actual: number; why: string }[] {
|
|
313
|
+
const scored: { e: number; a: number; share: number }[] = [];
|
|
314
|
+
expected.forEach((et, e) => {
|
|
315
|
+
const ew = contentWords(et);
|
|
316
|
+
if (!ew.size) return;
|
|
317
|
+
actual.forEach((at, a) => {
|
|
318
|
+
const aw = contentWords(at);
|
|
319
|
+
const shared = [...ew].filter((w) => aw.has(w)).length;
|
|
320
|
+
if (shared / ew.size >= minShare)
|
|
321
|
+
scored.push({ e, a, share: shared / ew.size });
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
const usedE = new Set<number>();
|
|
325
|
+
const usedA = new Set<number>();
|
|
326
|
+
const out: { expected: number; actual: number; why: string }[] = [];
|
|
327
|
+
for (const s of scored.sort((x, y) => y.share - x.share)) {
|
|
328
|
+
if (usedE.has(s.e) || usedA.has(s.a)) continue;
|
|
329
|
+
usedE.add(s.e);
|
|
330
|
+
usedA.add(s.a);
|
|
331
|
+
out.push({
|
|
332
|
+
expected: s.e,
|
|
333
|
+
actual: s.a,
|
|
334
|
+
why: `${Math.round(s.share * 100)}% of the words match`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return out.sort((x, y) => x.expected - y.expected);
|
|
338
|
+
}
|
package/src/compare.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { answerScore, wilson, type Rate } from "./stats.js";
|
|
2
|
+
import type { CheckResult } from "./types.js";
|
|
3
|
+
import { at } from "./util.js";
|
|
4
|
+
import { mustGet } from "./util.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Comparing two runs of a suite, case by case: the champion (usually the
|
|
8
|
+
* live version) and a challenger (another model, effort, or instructions).
|
|
9
|
+
* Each case scores the share of its repeats where every check passed; the
|
|
10
|
+
* challenger's average difference gets a 95% range from a paired
|
|
11
|
+
* bootstrap, and the verdict is read off that range. Pure; no database.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One stored answer, as a comparison reads it. */
|
|
15
|
+
export type RunOutput = {
|
|
16
|
+
caseId: string;
|
|
17
|
+
checks: CheckResult[] | null;
|
|
18
|
+
costUsd: number;
|
|
19
|
+
ms: number;
|
|
20
|
+
reused: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How a challenger compares with the model in place: more accurate, about as accurate,
|
|
25
|
+
* less accurate, or too few cases to tell.
|
|
26
|
+
*/
|
|
27
|
+
export type Verdict = "BETTER" | "SAME" | "WORSE" | "TOO_FEW";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Two tests compared case by case: the verdict, the difference and its range, the cases
|
|
31
|
+
* the challenger fixed and broke, and each check's pass rates.
|
|
32
|
+
*/
|
|
33
|
+
export type Comparison = {
|
|
34
|
+
/** Cases both runs have. */
|
|
35
|
+
n: number;
|
|
36
|
+
/** Challenger minus champion, in pass share (−1 to 1). */
|
|
37
|
+
mean: number;
|
|
38
|
+
low: number;
|
|
39
|
+
high: number;
|
|
40
|
+
verdict: Verdict;
|
|
41
|
+
/** Passed every time on the challenger, not on the champion. */
|
|
42
|
+
fixed: string[];
|
|
43
|
+
/** Passed every time on the champion, not on the challenger. */
|
|
44
|
+
broke: string[];
|
|
45
|
+
byCheck: Record<string, { champion: Rate; challenger: Rate }>;
|
|
46
|
+
/** Per output, called (not reused). */
|
|
47
|
+
cost: { champion: number | null; challenger: number | null };
|
|
48
|
+
avgMs: { champion: number | null; challenger: number | null };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Fewer shared cases than this: no verdict. */
|
|
52
|
+
export const MIN_CASES = 5;
|
|
53
|
+
|
|
54
|
+
/** A small seeded generator, so the same runs always give the same range. */
|
|
55
|
+
function mulberry32(seed: number) {
|
|
56
|
+
let a = seed;
|
|
57
|
+
return () => {
|
|
58
|
+
a |= 0;
|
|
59
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
60
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
61
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
62
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 95% range of the mean of paired differences, by resampling cases. */
|
|
67
|
+
export function bootstrapMean(
|
|
68
|
+
diffs: number[],
|
|
69
|
+
iterations = 4000,
|
|
70
|
+
seed = 1,
|
|
71
|
+
): { mean: number; low: number; high: number } {
|
|
72
|
+
const n = diffs.length;
|
|
73
|
+
const mean = n ? diffs.reduce((t, d) => t + d, 0) / n : 0;
|
|
74
|
+
if (n < 2) return { mean, low: mean, high: mean };
|
|
75
|
+
const rand = mulberry32(seed);
|
|
76
|
+
const means: number[] = [];
|
|
77
|
+
for (let i = 0; i < iterations; i++) {
|
|
78
|
+
let sum = 0;
|
|
79
|
+
for (let j = 0; j < n; j++) sum += at(diffs, Math.floor(rand() * n));
|
|
80
|
+
means.push(sum / n);
|
|
81
|
+
}
|
|
82
|
+
means.sort((a, b) => a - b);
|
|
83
|
+
return {
|
|
84
|
+
mean,
|
|
85
|
+
low: at(means, Math.floor(iterations * 0.025)),
|
|
86
|
+
high: at(means, Math.ceil(iterations * 0.975) - 1),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The verdict for a difference's 95% range on `n` shared cases: too few under 5, else
|
|
92
|
+
* better or worse only when the whole range is on one side of zero.
|
|
93
|
+
*/
|
|
94
|
+
export function verdictFor(n: number, low: number, high: number): Verdict {
|
|
95
|
+
if (n < MIN_CASES) return "TOO_FEW";
|
|
96
|
+
if (low > 0) return "BETTER";
|
|
97
|
+
if (high < 0) return "WORSE";
|
|
98
|
+
return "SAME";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Each case's average score over its repeats (partial credit if any). */
|
|
102
|
+
function passShareByCase(outputs: RunOutput[]): Map<string, number> {
|
|
103
|
+
const by = new Map<string, number[]>();
|
|
104
|
+
for (const o of outputs)
|
|
105
|
+
by.set(o.caseId, [...(by.get(o.caseId) ?? []), answerScore(o.checks)]);
|
|
106
|
+
return new Map(
|
|
107
|
+
[...by].map(([id, r]) => [id, r.reduce((t, x) => t + x, 0) / r.length]),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Each case's parts right and parts in all, averaged over its repeats, or
|
|
113
|
+
* null when the suite gives no partial credit.
|
|
114
|
+
*/
|
|
115
|
+
function unitsByCase(
|
|
116
|
+
outputs: RunOutput[],
|
|
117
|
+
): Map<string, { right: number; total: number }> | null {
|
|
118
|
+
const by = new Map<string, { right: number; total: number; n: number }>();
|
|
119
|
+
for (const o of outputs) {
|
|
120
|
+
const u = o.checks?.find((c) => c.units)?.units;
|
|
121
|
+
if (o.checks && !u) return null;
|
|
122
|
+
const cur = by.get(o.caseId) ?? { right: 0, total: 0, n: 0 };
|
|
123
|
+
// A failed call: nothing right, as many parts as the case has (known
|
|
124
|
+
// from another repeat or run; 0 here, set below).
|
|
125
|
+
by.set(o.caseId, {
|
|
126
|
+
right: cur.right + (u?.right ?? 0),
|
|
127
|
+
total: cur.total + (u?.total ?? 0),
|
|
128
|
+
n: cur.n + 1,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return new Map(
|
|
132
|
+
[...by].map(([id, v]) => [
|
|
133
|
+
id,
|
|
134
|
+
{ right: v.right / v.n, total: v.total / v.n },
|
|
135
|
+
]),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 95% range of the difference in parts-right rate (b minus a), resampling
|
|
141
|
+
* whole cases: the same measure as the headline (parts right).
|
|
142
|
+
*/
|
|
143
|
+
export function bootstrapRate(
|
|
144
|
+
cases: { a: number; b: number; total: number }[],
|
|
145
|
+
iterations = 4000,
|
|
146
|
+
seed = 1,
|
|
147
|
+
): { mean: number; low: number; high: number } {
|
|
148
|
+
const rate = (cs: typeof cases) => {
|
|
149
|
+
const total = cs.reduce((t, c) => t + c.total, 0);
|
|
150
|
+
return total ? cs.reduce((t, c) => t + c.b - c.a, 0) / total : 0;
|
|
151
|
+
};
|
|
152
|
+
const mean = rate(cases);
|
|
153
|
+
if (cases.length < 2) return { mean, low: mean, high: mean };
|
|
154
|
+
const rand = mulberry32(seed);
|
|
155
|
+
const rates: number[] = [];
|
|
156
|
+
for (let i = 0; i < iterations; i++) {
|
|
157
|
+
const sample = Array.from({ length: cases.length }, () =>
|
|
158
|
+
at(cases, Math.floor(rand() * cases.length)),
|
|
159
|
+
);
|
|
160
|
+
rates.push(rate(sample));
|
|
161
|
+
}
|
|
162
|
+
rates.sort((x, y) => x - y);
|
|
163
|
+
return {
|
|
164
|
+
mean,
|
|
165
|
+
low: at(rates, Math.floor(iterations * 0.025)),
|
|
166
|
+
high: at(rates, Math.ceil(iterations * 0.975) - 1),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rates(outputs: RunOutput[], key: string): Rate {
|
|
171
|
+
const applied = outputs
|
|
172
|
+
.map((o) => o.checks?.find((c) => c.key === key))
|
|
173
|
+
.filter((c): c is CheckResult => c !== undefined && c.pass !== null);
|
|
174
|
+
return wilson(applied.filter((c) => c.pass).length, applied.length);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function perCall(outputs: RunOutput[], pick: (o: RunOutput) => number) {
|
|
178
|
+
const called = outputs.filter((o) => !o.reused);
|
|
179
|
+
return called.length
|
|
180
|
+
? called.reduce((t, o) => t + pick(o), 0) / called.length
|
|
181
|
+
: null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Compares two tests on the cases they share, with a paired bootstrap, using the same
|
|
186
|
+
* partial credit as the headline.
|
|
187
|
+
*/
|
|
188
|
+
export function compareRuns(
|
|
189
|
+
champion: RunOutput[],
|
|
190
|
+
challenger: RunOutput[],
|
|
191
|
+
checkKeys: string[],
|
|
192
|
+
): Comparison {
|
|
193
|
+
const a = passShareByCase(champion);
|
|
194
|
+
const b = passShareByCase(challenger);
|
|
195
|
+
const shared = [...a.keys()].filter((id) => b.has(id)).sort();
|
|
196
|
+
// Parts right where the suite gives partial credit (the headline
|
|
197
|
+
// measure), else each case's share of passing answers.
|
|
198
|
+
const ua = unitsByCase(champion);
|
|
199
|
+
const ub = unitsByCase(challenger);
|
|
200
|
+
const { mean, low, high } =
|
|
201
|
+
ua && ub
|
|
202
|
+
? bootstrapRate(
|
|
203
|
+
shared.map((id) => ({
|
|
204
|
+
a: mustGet(ua, id).right,
|
|
205
|
+
b: mustGet(ub, id).right,
|
|
206
|
+
total: Math.max(mustGet(ua, id).total, mustGet(ub, id).total),
|
|
207
|
+
})),
|
|
208
|
+
)
|
|
209
|
+
: bootstrapMean(shared.map((id) => mustGet(b, id) - mustGet(a, id)));
|
|
210
|
+
const inShared = (os: RunOutput[]) =>
|
|
211
|
+
os.filter((o) => shared.includes(o.caseId));
|
|
212
|
+
return {
|
|
213
|
+
n: shared.length,
|
|
214
|
+
mean,
|
|
215
|
+
low,
|
|
216
|
+
high,
|
|
217
|
+
verdict: verdictFor(shared.length, low, high),
|
|
218
|
+
fixed: shared.filter((id) => b.get(id) === 1 && mustGet(a, id) < 1),
|
|
219
|
+
broke: shared.filter((id) => a.get(id) === 1 && mustGet(b, id) < 1),
|
|
220
|
+
byCheck: Object.fromEntries(
|
|
221
|
+
checkKeys.map((k) => [
|
|
222
|
+
k,
|
|
223
|
+
{
|
|
224
|
+
champion: rates(inShared(champion), k),
|
|
225
|
+
challenger: rates(inShared(challenger), k),
|
|
226
|
+
},
|
|
227
|
+
]),
|
|
228
|
+
),
|
|
229
|
+
cost: {
|
|
230
|
+
champion: perCall(champion, (o) => o.costUsd),
|
|
231
|
+
challenger: perCall(challenger, (o) => o.costUsd),
|
|
232
|
+
},
|
|
233
|
+
avgMs: {
|
|
234
|
+
champion: perCall(champion, (o) => o.ms),
|
|
235
|
+
challenger: perCall(challenger, (o) => o.ms),
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The verdict in plain words, with what it costs. */
|
|
241
|
+
export function verdictSentence(c: Comparison, challengerName: string): string {
|
|
242
|
+
const pts = (x: number) => `${Math.round(Math.abs(x) * 100)} points`;
|
|
243
|
+
const cost =
|
|
244
|
+
c.cost.champion && c.cost.challenger
|
|
245
|
+
? c.cost.challenger < c.cost.champion * 0.9
|
|
246
|
+
? `, and costs ${Math.round((1 - c.cost.challenger / c.cost.champion) * 100)}% less per call`
|
|
247
|
+
: c.cost.challenger > c.cost.champion * 1.1
|
|
248
|
+
? `, and costs ${Math.round((c.cost.challenger / c.cost.champion - 1) * 100)}% more per call`
|
|
249
|
+
: ", at about the same cost"
|
|
250
|
+
: "";
|
|
251
|
+
switch (c.verdict) {
|
|
252
|
+
case "TOO_FEW":
|
|
253
|
+
return `Only ${c.n} cases in common: too few to say. Run both on the same set of at least ${MIN_CASES} cases.`;
|
|
254
|
+
case "BETTER":
|
|
255
|
+
return `${challengerName} is better: it passes ${pts(c.mean)} more often (likely ${pts(c.low)} to ${pts(c.high)})${cost}.`;
|
|
256
|
+
case "WORSE":
|
|
257
|
+
return `${challengerName} is worse: it passes ${pts(c.mean)} less often (likely ${pts(c.high)} to ${pts(c.low)})${cost}.`;
|
|
258
|
+
default:
|
|
259
|
+
return `No clear difference: ${challengerName} is somewhere between ${Math.round(c.low * 100)} and ${c.high > 0 ? "+" : ""}${Math.round(c.high * 100)} points${cost}.`;
|
|
260
|
+
}
|
|
261
|
+
}
|