@crowandraven/evalkeeper 0.0.0-stage → 0.2.0

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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/bin/evalkeeper.mjs +9 -0
  4. package/dist/attention.d.ts +49 -0
  5. package/dist/attention.js +49 -0
  6. package/dist/attention.js.map +1 -0
  7. package/dist/board.d.ts +57 -0
  8. package/dist/board.js +2 -0
  9. package/dist/board.js.map +1 -0
  10. package/dist/cli/addPages.d.ts +79 -0
  11. package/dist/cli/addPages.js +181 -0
  12. package/dist/cli/addPages.js.map +1 -0
  13. package/dist/cli/main.d.ts +18 -0
  14. package/dist/cli/main.js +115 -0
  15. package/dist/cli/main.js.map +1 -0
  16. package/dist/common.d.ts +72 -0
  17. package/dist/common.js +276 -0
  18. package/dist/common.js.map +1 -0
  19. package/dist/compare.d.ts +85 -0
  20. package/dist/compare.js +185 -0
  21. package/dist/compare.js.map +1 -0
  22. package/dist/decisions.d.ts +84 -0
  23. package/dist/decisions.js +146 -0
  24. package/dist/decisions.js.map +1 -0
  25. package/dist/feedback.d.ts +114 -0
  26. package/dist/feedback.js +116 -0
  27. package/dist/feedback.js.map +1 -0
  28. package/dist/format.d.ts +6 -0
  29. package/dist/format.js +22 -0
  30. package/dist/format.js.map +1 -0
  31. package/dist/glossary.d.ts +46 -0
  32. package/dist/glossary.js +45 -0
  33. package/dist/glossary.js.map +1 -0
  34. package/dist/health.d.ts +40 -0
  35. package/dist/health.js +101 -0
  36. package/dist/health.js.map +1 -0
  37. package/dist/index.d.ts +31 -0
  38. package/dist/index.js +34 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/labels.d.ts +8 -0
  41. package/dist/labels.js +15 -0
  42. package/dist/labels.js.map +1 -0
  43. package/dist/legs.d.ts +23 -0
  44. package/dist/legs.js +22 -0
  45. package/dist/legs.js.map +1 -0
  46. package/dist/memoryStore.d.ts +21 -0
  47. package/dist/memoryStore.js +228 -0
  48. package/dist/memoryStore.js.map +1 -0
  49. package/dist/models.d.ts +76 -0
  50. package/dist/models.js +56 -0
  51. package/dist/models.js.map +1 -0
  52. package/dist/prune.d.ts +48 -0
  53. package/dist/prune.js +77 -0
  54. package/dist/prune.js.map +1 -0
  55. package/dist/quickSet.d.ts +26 -0
  56. package/dist/quickSet.js +44 -0
  57. package/dist/quickSet.js.map +1 -0
  58. package/dist/runner.d.ts +202 -0
  59. package/dist/runner.js +734 -0
  60. package/dist/runner.js.map +1 -0
  61. package/dist/settings.d.ts +196 -0
  62. package/dist/settings.js +272 -0
  63. package/dist/settings.js.map +1 -0
  64. package/dist/stableJson.d.ts +11 -0
  65. package/dist/stableJson.js +15 -0
  66. package/dist/stableJson.js.map +1 -0
  67. package/dist/stats.d.ts +98 -0
  68. package/dist/stats.js +119 -0
  69. package/dist/stats.js.map +1 -0
  70. package/dist/store.d.ts +151 -0
  71. package/dist/store.js +3 -0
  72. package/dist/store.js.map +1 -0
  73. package/dist/suggest.d.ts +75 -0
  74. package/dist/suggest.js +152 -0
  75. package/dist/suggest.js.map +1 -0
  76. package/dist/summary.d.ts +51 -0
  77. package/dist/summary.js +61 -0
  78. package/dist/summary.js.map +1 -0
  79. package/dist/testing.d.ts +26 -0
  80. package/dist/testing.js +362 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/timeout.d.ts +2 -0
  83. package/dist/timeout.js +15 -0
  84. package/dist/timeout.js.map +1 -0
  85. package/dist/types.d.ts +225 -0
  86. package/dist/types.js +46 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/upkeepRules.d.ts +28 -0
  89. package/dist/upkeepRules.js +40 -0
  90. package/dist/upkeepRules.js.map +1 -0
  91. package/dist/util.d.ts +8 -0
  92. package/dist/util.js +18 -0
  93. package/dist/util.js.map +1 -0
  94. package/dist/weekly.d.ts +80 -0
  95. package/dist/weekly.js +113 -0
  96. package/dist/weekly.js.map +1 -0
  97. package/package.json +81 -4
  98. package/src/attention.ts +89 -0
  99. package/src/board.ts +58 -0
  100. package/src/cli/addPages.ts +256 -0
  101. package/src/cli/main.ts +125 -0
  102. package/src/common.ts +338 -0
  103. package/src/compare.ts +261 -0
  104. package/src/decisions.ts +230 -0
  105. package/src/feedback.ts +225 -0
  106. package/src/format.ts +19 -0
  107. package/src/glossary.ts +75 -0
  108. package/src/health.ts +149 -0
  109. package/src/index.ts +33 -0
  110. package/src/labels.ts +18 -0
  111. package/src/legs.ts +38 -0
  112. package/src/memoryStore.ts +268 -0
  113. package/src/models.ts +127 -0
  114. package/src/prune.ts +95 -0
  115. package/src/quickSet.ts +55 -0
  116. package/src/runner.ts +1010 -0
  117. package/src/settings.ts +441 -0
  118. package/src/stableJson.ts +24 -0
  119. package/src/stats.ts +215 -0
  120. package/src/store.ts +172 -0
  121. package/src/suggest.ts +247 -0
  122. package/src/summary.ts +109 -0
  123. package/src/testing.ts +415 -0
  124. package/src/timeout.ts +14 -0
  125. package/src/types.ts +258 -0
  126. package/src/upkeepRules.ts +46 -0
  127. package/src/util.ts +17 -0
  128. package/src/weekly.ts +169 -0
  129. package/starter/index.ts +86 -0
  130. package/templates/app/api/cron/evals-weekly/route.ts +24 -0
  131. package/templates/app/evals/[suite]/page.tsx +449 -0
  132. package/templates/app/evals/[suite]/runs/[runId]/page.tsx +95 -0
  133. package/templates/app/evals/feedback/page.tsx +219 -0
  134. package/templates/app/evals/page.tsx +313 -0
  135. package/templates/components/evalkeeper/AddCase.tsx +62 -0
  136. package/templates/components/evalkeeper/AnswerKey.tsx +360 -0
  137. package/templates/components/evalkeeper/CaseMenu.tsx +122 -0
  138. package/templates/components/evalkeeper/Fields.tsx +110 -0
  139. package/templates/components/evalkeeper/RefreshWhile.tsx +25 -0
  140. package/templates/components/evalkeeper/SettingsHistory.tsx +191 -0
  141. package/templates/components/evalkeeper/Submit.tsx +58 -0
  142. package/templates/components/evalkeeper/Thumbs.tsx +117 -0
  143. package/templates/components/evalkeeper/Tip.tsx +98 -0
  144. package/templates/components/evalkeeper/WeeklyCheck.tsx +124 -0
  145. package/templates/components/evalkeeper/evalkeeper.css +71 -0
  146. package/templates/components/evalkeeper/ui.tsx +103 -0
  147. package/templates/lib/evalkeeper/actions.ts +263 -0
  148. package/templates/lib/evalkeeper/cronAuth.ts +18 -0
  149. package/templates/lib/evalkeeper/paths.ts +11 -0
  150. package/templates/lib/evalkeeper/time.ts +4 -0
  151. package/templates/lib/evalkeeper/weekly.ts +51 -0
package/src/runner.ts ADDED
@@ -0,0 +1,1010 @@
1
+ /**
2
+ * The runner: creates, runs, rescores, and compares tests, and builds each
3
+ * suite's model board. Server-only.
4
+ *
5
+ * @module evalkeeper/runner
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { compareRuns, type RunOutput } from "./compare.js";
9
+ import { isStuck, shouldContinue } from "./legs.js";
10
+ import {
11
+ effortFor,
12
+ isBaseline,
13
+ type ModelCatalog,
14
+ type ReasoningEffort,
15
+ } from "./models.js";
16
+ import type { BoardRow } from "./board.js";
17
+ import { allPassed } from "./stats.js";
18
+ import { pickQuickSet } from "./quickSet.js";
19
+ import { stableJson } from "./stableJson.js";
20
+ import { suiteMetrics, type ScoredOutput } from "./stats.js";
21
+ import type {
22
+ CaseRecord,
23
+ EvalSet,
24
+ EvalStore,
25
+ RunMetrics,
26
+ RunRecord,
27
+ } from "./store.js";
28
+ import { withTimeout } from "./timeout.js";
29
+ import { SCHEDULED } from "./weekly.js";
30
+ import { confirmedLabel, isProvisional } from "./labels.js";
31
+ export { confirmedLabel, isProvisional };
32
+ import type { AnySuite, CheckResult } from "./types.js";
33
+ import { at } from "./util.js";
34
+ import { mustGet } from "./util.js";
35
+
36
+ /**
37
+ * Runs eval suites: every case in a set, a number of times, on one model,
38
+ * with the operation's current instructions. Outputs are stored, so a
39
+ * grading change rescores them without calling the model again, and an
40
+ * identical request from the last week is reused instead of paid for
41
+ * twice. Server-only (it hashes with node:crypto).
42
+ */
43
+
44
+ /** An answer that takes longer counts as failed, unless a suite sets its own. */
45
+ export const ANSWER_TIMEOUT_MS = 60_000;
46
+ /** A test with no new answer for this long was stopped by the server. */
47
+ export const STUCK_MS = 6 * 60_000;
48
+ /** Identical requests within this many days are reused. */
49
+ export const REUSE_DAYS = 7;
50
+
51
+ /** A provider refusing calls for billing: every later call fails too. */
52
+ export const OUT_OF_CREDITS =
53
+ /no credits remaining|credit balance is too low|insufficient_quota|exceeded your current quota|billing/i;
54
+
55
+ /**
56
+ * What the runner needs from your app: storage, suites, models, instruction hashes, and,
57
+ * for the board and drift checks, routing.
58
+ */
59
+ export type RunnerOptions = {
60
+ store: EvalStore;
61
+ suites: AnySuite[];
62
+ /** Your models, for providers, prices, and retirements. */
63
+ models: ModelCatalog;
64
+ /**
65
+ * The current instructions' hash for an operation (see instructionsHash
66
+ * in `@crowandraven/evalkeeper-ai-sdk`). A test records it, so editing the instructions
67
+ * marks older tests out of date.
68
+ */
69
+ promptVersion: (operation: string) => string | Promise<string>;
70
+ /**
71
+ * The live reasoning setting for an operation; null: the model's own.
72
+ * Defaults to the route's, when `route` is given.
73
+ */
74
+ liveEffort?: (
75
+ operation: string,
76
+ ) => ReasoningEffort | null | Promise<ReasoningEffort | null>;
77
+ /** What one answer costs on a model, from your usage records; null: unknown. */
78
+ estimateCallCost?: (
79
+ operation: string,
80
+ model: string,
81
+ ) => Promise<number | null>;
82
+ /**
83
+ * How long one request may run, in ms. A test answers what fits, then
84
+ * hands the rest to `continueRun` (a fresh request). Default: no limit.
85
+ */
86
+ requestLimitMs?: number;
87
+ /**
88
+ * Starts a fresh request that calls runEval(runId, opts). True when one
89
+ * took it; false (or absent) lets the test fail as out of time.
90
+ */
91
+ continueRun?: (
92
+ runId: string,
93
+ opts: Required<RunEvalOptions>,
94
+ ) => Promise<boolean>;
95
+ /**
96
+ * Which model each operation runs on now, its fallback, and its
97
+ * reasoning setting. Your app owns routing; the board reads it.
98
+ */
99
+ route?: (operation: string) => Route | Promise<Route>;
100
+ /**
101
+ * The models an operation may use: `models` can be live; `fallbackModels`
102
+ * (default: the same) can stand in during an outage.
103
+ */
104
+ candidates?: (operation: string) => {
105
+ models: string[];
106
+ fallbackModels?: string[];
107
+ };
108
+ };
109
+
110
+ /**
111
+ * Which model a feature runs on now, its fallback, its reasoning setting,
112
+ * and its temperature.
113
+ */
114
+ export type Route = {
115
+ model: string;
116
+ fallbackModel: string | null;
117
+ reasoningEffort: ReasoningEffort | null;
118
+ /** Null or unset: no temperature sent. */
119
+ temperature?: number | null;
120
+ };
121
+
122
+ /**
123
+ * How to run a test: fresh (no reuse), and, for a test continued in a new request, its leg
124
+ * and the judge's spend so far.
125
+ */
126
+ export type RunEvalOptions = {
127
+ /** Never reuse an earlier test's output. */
128
+ fresh?: boolean;
129
+ /** Which leg this is (1 for a new test). */
130
+ leg?: number;
131
+ /** What earlier legs' judge spent. */
132
+ judgeCostUsd?: number;
133
+ };
134
+
135
+ const sha = (text: string, n = 8) =>
136
+ createHash("sha256").update(text).digest("hex").slice(0, n);
137
+
138
+ /**
139
+ * What makes two requests identical, so the second can reuse the first's
140
+ * answer: the suite, model, reasoning setting, instructions, input, and
141
+ * repeat. One function, so tests and estimates always agree.
142
+ */
143
+ export function requestHashFor(r: {
144
+ suite: string;
145
+ model: string;
146
+ effort: string | null;
147
+ prompt: string;
148
+ input: unknown;
149
+ repeat: number;
150
+ /** Left out of the hash when null, so requests without one reuse as before. */
151
+ temperature?: number | null;
152
+ }): string {
153
+ return sha(
154
+ stableJson({
155
+ suite: r.suite,
156
+ model: r.model,
157
+ effort: r.effort,
158
+ prompt: r.prompt,
159
+ input: r.input,
160
+ repeat: r.repeat,
161
+ ...(r.temperature != null ? { temperature: r.temperature } : {}),
162
+ }),
163
+ 64,
164
+ );
165
+ }
166
+
167
+ /** Changes whenever a case or its answer in use does. */
168
+ export function datasetVersion(cases: CaseRecord[]): string {
169
+ return sha(
170
+ cases
171
+ .map((c) => {
172
+ const l = confirmedLabel(c.labels);
173
+ return `${c.id}:${+c.updatedAt}:${l ? `${l.id}:${+l.updatedAt}` : "-"}`;
174
+ })
175
+ .sort()
176
+ .join("|"),
177
+ );
178
+ }
179
+
180
+ /** The error most failed answers share, in a line; null when none failed. */
181
+ export function commonError(errors: (string | null)[]): string | null {
182
+ const counts = new Map<string, number>();
183
+ for (const e of errors)
184
+ if (e) {
185
+ const line = (e.split(/\n/)[0] ?? "").slice(0, 160);
186
+ counts.set(line, (counts.get(line) ?? 0) + 1);
187
+ }
188
+ return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
189
+ }
190
+
191
+ /** Runs fn over items with at most `limit` in flight, preserving order. */
192
+ export async function mapLimit<T, R>(
193
+ items: T[],
194
+ limit: number,
195
+ fn: (item: T) => Promise<R>,
196
+ ): Promise<R[]> {
197
+ const results: R[] = [];
198
+ let next = 0;
199
+ async function worker() {
200
+ while (next < items.length) {
201
+ const i = next++;
202
+ results[i] = await fn(at(items, i));
203
+ }
204
+ }
205
+ await Promise.all(
206
+ Array.from({ length: Math.min(limit, items.length) }, worker),
207
+ );
208
+ return results;
209
+ }
210
+
211
+ /**
212
+ * The runner: creates and runs tests, rescores them, compares them, and builds each
213
+ * suite's model board, on your store.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * const runner = createRunner({
218
+ * store: prismaStore(prisma),
219
+ * suites: [ticketTriage],
220
+ * models: MODELS,
221
+ * promptVersion: (op) => PROMPT_VERSIONS[op] ?? "unregistered",
222
+ * route: settings.route,
223
+ * candidates: () => ({ models: Object.keys(MODELS) }),
224
+ * });
225
+ * const { run } = await runner.createRun({ suite: "ticket_triage", model: "claude-haiku-5-5" });
226
+ * await runner.runEval(run.id);
227
+ * const board = await runner.board("ticket_triage");
228
+ * ```
229
+ */
230
+ export function createRunner(opts: RunnerOptions) {
231
+ const { store } = opts;
232
+ const byKey = new Map(opts.suites.map((s) => [s.key, s]));
233
+ const suiteOrThrow = (key: string): AnySuite => {
234
+ const s = byKey.get(key);
235
+ if (!s) throw new Error(`Unknown suite ${key}`);
236
+ return s;
237
+ };
238
+ const providerName = (model: string) =>
239
+ opts.models[model]?.provider ?? "the provider";
240
+
241
+ /**
242
+ * The cases a test uses: in use means a confirmed (or provisional) answer
243
+ * and not retired. DEV or TEST by split; SMOKE is a quick set picked to
244
+ * cover the most tags.
245
+ */
246
+ async function casesFor(suite: string, set: EvalSet): Promise<CaseRecord[]> {
247
+ const active = (await store.listCases(suite)).filter((c) =>
248
+ confirmedLabel(c.labels),
249
+ );
250
+ if (set === "ALL") return active;
251
+ if (set !== "SMOKE") return active.filter((c) => c.split === set);
252
+ return pickQuickSet(active.map((c) => ({ ...c, confirmed: true })));
253
+ }
254
+
255
+ /** The operation's live reasoning setting: liveEffort's, else the route's. */
256
+ async function liveEffortFor(
257
+ operation: string,
258
+ ): Promise<ReasoningEffort | null> {
259
+ if (opts.liveEffort) return opts.liveEffort(operation);
260
+ if (opts.route) return (await opts.route(operation)).reasoningEffort;
261
+ return null;
262
+ }
263
+
264
+ /** The operation's live temperature: the route's, else none. */
265
+ async function liveTemperatureFor(operation: string): Promise<number | null> {
266
+ if (!opts.route) return null;
267
+ return (await opts.route(operation)).temperature ?? null;
268
+ }
269
+
270
+ /**
271
+ * What a test would cost. Answers an identical request gave in the last
272
+ * 7 days are reused for free, so only the rest are priced: a retest
273
+ * after rescoring, or after a test that ran out of time, often costs
274
+ * little or nothing. `costUsd` is null when the model has no price.
275
+ */
276
+ async function estimateRun(
277
+ suiteKey: string,
278
+ model: string,
279
+ set: EvalSet,
280
+ repeats: number,
281
+ o: {
282
+ /** Never reuse: every answer is a new call. */
283
+ fresh?: boolean;
284
+ /** The setting to test; undefined: the operation's live one. */
285
+ reasoningEffort?: ReasoningEffort | null;
286
+ /** The temperature to test; undefined: the operation's live one. */
287
+ temperature?: number | null;
288
+ } = {},
289
+ ): Promise<{
290
+ cases: number;
291
+ /** Answers the test will need: cases times repeats. */
292
+ answers: number;
293
+ /** Of those, answers an identical earlier request will give for free. */
294
+ reused: number;
295
+ costUsd: number | null;
296
+ }> {
297
+ const suite = suiteOrThrow(suiteKey);
298
+ const [cases, perCall, prompt, effort, temperature] = await Promise.all([
299
+ casesFor(suite.key, set),
300
+ opts.estimateCallCost?.(suite.operation, model) ?? null,
301
+ opts.promptVersion(suite.operation),
302
+ o.reasoningEffort !== undefined
303
+ ? o.reasoningEffort
304
+ : liveEffortFor(suite.operation),
305
+ o.temperature !== undefined
306
+ ? o.temperature
307
+ : liveTemperatureFor(suite.operation),
308
+ ]);
309
+ const answers = cases.length * repeats;
310
+ const since = new Date(Date.now() - REUSE_DAYS * 86_400_000);
311
+ const reused = o.fresh
312
+ ? 0
313
+ : (
314
+ await Promise.all(
315
+ cases.flatMap((c) =>
316
+ Array.from({ length: repeats }, (_, i) =>
317
+ store.findReusableOutput(
318
+ requestHashFor({
319
+ suite: suite.key,
320
+ model,
321
+ effort,
322
+ prompt,
323
+ input: c.input,
324
+ repeat: i + 1,
325
+ temperature,
326
+ }),
327
+ since,
328
+ ),
329
+ ),
330
+ ),
331
+ )
332
+ ).filter(Boolean).length;
333
+ return {
334
+ cases: cases.length,
335
+ answers,
336
+ reused,
337
+ costUsd: perCall === null ? null : perCall * (answers - reused),
338
+ };
339
+ }
340
+
341
+ /** Records a test to start; runEval does the work. */
342
+ async function createRun(args: {
343
+ suite: string;
344
+ model: string;
345
+ set?: EvalSet;
346
+ repeats?: number;
347
+ /** Who started it; null for a scheduled check. */
348
+ startedBy?: string | null;
349
+ /** A reasoning setting to test; undefined: the operation's own. */
350
+ reasoningEffort?: ReasoningEffort | null;
351
+ /** A temperature to test; undefined: the operation's live one. */
352
+ temperature?: number | null;
353
+ /** For the estimate: pass the same `fresh` to runEval. */
354
+ fresh?: boolean;
355
+ }) {
356
+ const suite = suiteOrThrow(args.suite);
357
+ const set = args.set ?? "ALL";
358
+ const repeats = args.repeats ?? 1;
359
+ const reasoningEffort =
360
+ args.reasoningEffort !== undefined
361
+ ? args.reasoningEffort
362
+ : await liveEffortFor(suite.operation);
363
+ const temperature =
364
+ args.temperature !== undefined
365
+ ? args.temperature
366
+ : await liveTemperatureFor(suite.operation);
367
+ const [estimate, promptVersion] = await Promise.all([
368
+ estimateRun(suite.key, args.model, set, repeats, {
369
+ reasoningEffort,
370
+ temperature,
371
+ fresh: Boolean(args.fresh),
372
+ }),
373
+ opts.promptVersion(suite.operation),
374
+ ]);
375
+ const liveEffort = reasoningEffort;
376
+ const run = await store.createRun({
377
+ suite: suite.key,
378
+ model: args.model,
379
+ promptVersion,
380
+ reasoningEffort:
381
+ args.reasoningEffort !== undefined ? args.reasoningEffort : liveEffort,
382
+ temperature,
383
+ set,
384
+ repeats,
385
+ datasetVersion: null,
386
+ graderVersion: suite.graderVersion,
387
+ estimatedCostUsd: estimate.costUsd,
388
+ startedBy: args.startedBy ?? null,
389
+ });
390
+ return { run, estimate };
391
+ }
392
+
393
+ /** Grades one stored output against the case's answer in use. */
394
+ async function gradeOutput(
395
+ suite: AnySuite,
396
+ c: CaseRecord,
397
+ output: unknown,
398
+ onCost?: (usd: number) => void,
399
+ ): Promise<CheckResult[]> {
400
+ const input = suite.input.parse(c.input);
401
+ const label = confirmedLabel(c.labels);
402
+ const parsed = label ? suite.labelValue.safeParse(label.value) : null;
403
+ return suite.grade(
404
+ input,
405
+ output,
406
+ parsed?.success ? parsed.data : null,
407
+ onCost ? { onCost } : undefined,
408
+ );
409
+ }
410
+
411
+ /**
412
+ * Adds the checks that compare a test's answers with each other (a
413
+ * suite's gradeTogether), per repeat. Replaces earlier ones.
414
+ */
415
+ async function gradeTogether(run: RunRecord, suite: AnySuite) {
416
+ if (!suite.gradeTogether) return;
417
+ const cases = new Map(
418
+ (await store.listCases(suite.key)).map((c) => [c.id, c]),
419
+ );
420
+ const outputs = (await store.listOutputs(run.id)).filter(
421
+ (o) => !o.error && cases.has(o.caseId),
422
+ );
423
+ const byRepeat = new Map<number, typeof outputs>();
424
+ for (const o of outputs)
425
+ byRepeat.set(o.repeat, [...(byRepeat.get(o.repeat) ?? []), o]);
426
+ for (const group of byRepeat.values()) {
427
+ const answers = group.map((o) => {
428
+ const c = mustGet(cases, o.caseId);
429
+ const label = confirmedLabel(c.labels);
430
+ const parsed = label ? suite.labelValue.safeParse(label.value) : null;
431
+ return {
432
+ input: suite.input.parse(c.input),
433
+ output: o.output,
434
+ label: parsed?.success ? parsed.data : null,
435
+ };
436
+ });
437
+ const extra = suite.gradeTogether(answers);
438
+ await mapLimit(
439
+ group.map((o, i) => ({ o, add: extra[i] ?? [] })),
440
+ 6,
441
+ async ({ o, add }) => {
442
+ const keys = new Set(add.map((c) => c.key));
443
+ const kept = (o.scores ?? []).filter((c) => !keys.has(c.key));
444
+ await store.setOutputScores(o.id, [...kept, ...add]);
445
+ },
446
+ );
447
+ }
448
+ }
449
+
450
+ async function metricsFor(
451
+ run: RunRecord,
452
+ suite: AnySuite,
453
+ judgeCostUsd: number,
454
+ ): Promise<RunMetrics> {
455
+ const outputs = await store.listOutputs(run.id);
456
+ const scored: ScoredOutput[] = outputs.map((o) => ({
457
+ caseId: o.caseId,
458
+ repeat: o.repeat,
459
+ checks: o.error ? null : (o.scores ?? []),
460
+ costUsd: o.costUsd,
461
+ ms: o.ms,
462
+ reused: o.reused,
463
+ }));
464
+ return {
465
+ ...suiteMetrics(
466
+ scored,
467
+ suite.checks.map((c) => c.key),
468
+ ),
469
+ judgeCostUsd,
470
+ failureReason: commonError(outputs.map((o) => o.error)),
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Calls the operation for every case and repeat, then grades. With
476
+ * `fresh`, never reuses an earlier test's output. A leg after the first
477
+ * skips what this test already answered. Never throws.
478
+ */
479
+ async function runEval(runId: string, o: RunEvalOptions = {}) {
480
+ const run = await store.getRun(runId);
481
+ // Stopped meanwhile (marked stuck, or a duplicate hand-off).
482
+ if (!run || run.status !== "RUNNING") return;
483
+ const leg = o.leg ?? 1;
484
+ try {
485
+ const suite = suiteOrThrow(run.suite);
486
+ const cases = await casesFor(suite.key, run.set);
487
+ const since = new Date(Date.now() - REUSE_DAYS * 86_400_000);
488
+ const jobs = cases.flatMap((c) =>
489
+ Array.from({ length: run.repeats }, (_, i) => ({ c, repeat: i + 1 })),
490
+ );
491
+ const answered = new Set(
492
+ (await store.listOutputs(runId)).map((x) => `${x.caseId}:${x.repeat}`),
493
+ );
494
+ const todo = jobs.filter((j) => !answered.has(`${j.c.id}:${j.repeat}`));
495
+
496
+ let judgeCost = o.judgeCostUsd ?? 0;
497
+ const onJudge = (usd: number) => (judgeCost += usd);
498
+ // Set inside the answers below, which TypeScript can't follow.
499
+ let outOfCredits = null as string | null;
500
+ const timeout = suite.answerTimeoutMs ?? ANSWER_TIMEOUT_MS;
501
+ const deadline = opts.requestLimitMs
502
+ ? Date.now() + opts.requestLimitMs - timeout
503
+ : Infinity;
504
+ let skipped = 0;
505
+
506
+ await mapLimit(todo, suite.concurrency ?? 6, async ({ c, repeat }) => {
507
+ if (Date.now() > deadline || outOfCredits) {
508
+ skipped++;
509
+ return;
510
+ }
511
+ const requestHash = requestHashFor({
512
+ suite: suite.key,
513
+ model: run.model,
514
+ effort: run.reasoningEffort,
515
+ prompt: run.promptVersion,
516
+ input: c.input,
517
+ repeat,
518
+ temperature: run.temperature ?? null,
519
+ });
520
+ const earlier = o.fresh
521
+ ? null
522
+ : await store.findReusableOutput(requestHash, since);
523
+ if (earlier) {
524
+ await store.createOutput({
525
+ runId,
526
+ caseId: c.id,
527
+ repeat,
528
+ requestHash,
529
+ output: earlier.output,
530
+ error: null,
531
+ scores: await gradeOutput(suite, c, earlier.output, onJudge),
532
+ // What the call cost and took when it was made, so per-answer
533
+ // numbers stay real; the run's spend leaves reused ones out.
534
+ costUsd: earlier.costUsd,
535
+ ms: earlier.ms,
536
+ reused: true,
537
+ });
538
+ return;
539
+ }
540
+ const started = Date.now();
541
+ let cost = 0;
542
+ try {
543
+ const output = await withTimeout(
544
+ suite.run(suite.input.parse(c.input), {
545
+ model: run.model,
546
+ reasoningEffort:
547
+ (run.reasoningEffort as ReasoningEffort | null) ?? null,
548
+ temperature: run.temperature ?? null,
549
+ onCost: (usd) => (cost += usd),
550
+ }),
551
+ timeout,
552
+ );
553
+ await store.createOutput({
554
+ runId,
555
+ caseId: c.id,
556
+ repeat,
557
+ requestHash,
558
+ output,
559
+ error: null,
560
+ scores: await gradeOutput(suite, c, output, onJudge),
561
+ costUsd: cost,
562
+ ms: Date.now() - started,
563
+ reused: false,
564
+ });
565
+ } catch (e) {
566
+ const message = e instanceof Error ? e.message : String(e);
567
+ if (OUT_OF_CREDITS.test(message)) outOfCredits = message;
568
+ await store.createOutput({
569
+ runId,
570
+ caseId: c.id,
571
+ repeat,
572
+ requestHash,
573
+ output: null,
574
+ error: message.slice(0, 1000),
575
+ scores: null,
576
+ costUsd: cost,
577
+ ms: Date.now() - started,
578
+ reused: false,
579
+ });
580
+ }
581
+ });
582
+
583
+ if (skipped) {
584
+ const next = {
585
+ fresh: !!o.fresh,
586
+ leg: leg + 1,
587
+ judgeCostUsd: judgeCost,
588
+ };
589
+ if (
590
+ shouldContinue({
591
+ left: skipped,
592
+ done: todo.length - skipped,
593
+ leg,
594
+ outOfCredits: !!outOfCredits,
595
+ }) &&
596
+ opts.continueRun &&
597
+ (await opts.continueRun(runId, next).catch(() => false))
598
+ )
599
+ return;
600
+ await store.updateRun(runId, {
601
+ status: "FAILED",
602
+ finishedAt: new Date(),
603
+ error: outOfCredits
604
+ ? `The ${providerName(run.model)} account is out of credits, so the test stopped after ${jobs.length - skipped} of ${jobs.length} answers. Add credits, then test again: answers already given are reused at no cost.`
605
+ : `Ran out of time after ${jobs.length - skipped} of ${jobs.length} answers. Test again to finish: answers already given are reused at no cost.`,
606
+ });
607
+ return;
608
+ }
609
+
610
+ await gradeTogether(run, suite);
611
+ await store.updateRun(runId, {
612
+ status: "SUCCEEDED",
613
+ finishedAt: new Date(),
614
+ datasetVersion: datasetVersion(cases),
615
+ metrics: await metricsFor(run, suite, judgeCost),
616
+ });
617
+ } catch (e) {
618
+ await store.updateRun(runId, {
619
+ status: "FAILED",
620
+ finishedAt: new Date(),
621
+ error: e instanceof Error ? e.message : String(e),
622
+ });
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Regrades a test's stored outputs with today's checks and answers. No
628
+ * model calls. It brings a test up to today's answers, but never up to
629
+ * inputs that changed after it ran.
630
+ */
631
+ async function rescoreRun(runId: string) {
632
+ const run = await store.getRun(runId);
633
+ if (!run) return;
634
+ const suite = suiteOrThrow(run.suite);
635
+ const cases = new Map(
636
+ (await store.listCases(suite.key)).map((c) => [c.id, c]),
637
+ );
638
+ const outputs = (await store.listOutputs(runId)).filter(
639
+ (x) => !x.error && cases.has(x.caseId),
640
+ );
641
+ let judgeCost = 0;
642
+ const onJudge = (usd: number) => (judgeCost += usd);
643
+ await mapLimit(outputs, 6, async (x) =>
644
+ store.setOutputScores(
645
+ x.id,
646
+ await gradeOutput(suite, mustGet(cases, x.caseId), x.output, onJudge),
647
+ ),
648
+ );
649
+ await gradeTogether(run, suite);
650
+ const used = [...new Set(outputs.map((x) => x.caseId))].map((id) =>
651
+ mustGet(cases, id),
652
+ );
653
+ const madeOnOldInputs = outputs.some(
654
+ (x) => x.createdAt < mustGet(cases, x.caseId).updatedAt,
655
+ );
656
+ await store.updateRun(runId, {
657
+ graderVersion: suite.graderVersion,
658
+ ...(madeOnOldInputs ? {} : { datasetVersion: datasetVersion(used) }),
659
+ metrics: await metricsFor(
660
+ run,
661
+ suite,
662
+ (run.metrics?.judgeCostUsd ?? 0) + judgeCost,
663
+ ),
664
+ });
665
+ }
666
+
667
+ /** Regrades a suite's recent finished tests, e.g. after answers change. */
668
+ async function rescoreRecent(suiteKey: string, limit = 10) {
669
+ for (const r of await store.listRuns(suiteKey, {
670
+ status: "SUCCEEDED",
671
+ limit,
672
+ }))
673
+ await rescoreRun(r.id);
674
+ }
675
+
676
+ /**
677
+ * Records one suite's weekly drift check without running it: its quick
678
+ * set on its live model, at its live reasoning setting, marked as
679
+ * started by the schedule. Record every suite's first, so a page can show
680
+ * them running, then run each with `runEval(id, { fresh: true })`.
681
+ * Returns the test's id, or null when the suite has no cases in use.
682
+ * Needs `route`.
683
+ */
684
+ async function startDriftCheck(suiteKey: string): Promise<string | null> {
685
+ if (!opts.route) throw new Error("The drift check needs the route option");
686
+ const suite = suiteOrThrow(suiteKey);
687
+ if (!(await casesFor(suite.key, "SMOKE")).length) return null;
688
+ const route = await opts.route(suite.operation);
689
+ const { run } = await createRun({
690
+ suite: suite.key,
691
+ model: route.model,
692
+ set: "SMOKE",
693
+ startedBy: SCHEDULED,
694
+ reasoningEffort: route.reasoningEffort,
695
+ temperature: route.temperature ?? null,
696
+ });
697
+ return run.id;
698
+ }
699
+
700
+ /**
701
+ * One suite's weekly drift check, start to finish, with fresh answers so
702
+ * a quietly changed model shows. Returns the test's id, or null when the
703
+ * suite has no cases in use.
704
+ */
705
+ async function runDriftCheck(suiteKey: string): Promise<string | null> {
706
+ const id = await startDriftCheck(suiteKey);
707
+ if (id) await runEval(id, { fresh: true });
708
+ return id;
709
+ }
710
+
711
+ /** Marks tests the server stopped mid-way as failed. */
712
+ async function stopStuckRuns(suiteKey: string, now = Date.now()) {
713
+ for (const r of await store.listRuns(suiteKey, { status: "RUNNING" })) {
714
+ if (now - +r.startedAt <= STUCK_MS) continue;
715
+ const outs = await store.listOutputs(r.id);
716
+ const last = outs.reduce<Date | null>(
717
+ (m, x) => (!m || x.createdAt > m ? x.createdAt : m),
718
+ null,
719
+ );
720
+ if (isStuck(r.startedAt, last, now, STUCK_MS))
721
+ await store.updateRun(
722
+ r.id,
723
+ {
724
+ status: "FAILED",
725
+ finishedAt: new Date(now),
726
+ error:
727
+ "The test stopped before it finished. Test again: answers already given are reused at no cost.",
728
+ },
729
+ { status: "RUNNING" },
730
+ );
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Two finished tests of a suite compared case by case: `a` the one in
736
+ * place, `b` the challenger. Null if either isn't a finished test.
737
+ */
738
+ async function compare(suiteKey: string, a: string, b: string) {
739
+ const suite = suiteOrThrow(suiteKey);
740
+ const [ra, rb] = await Promise.all([store.getRun(a), store.getRun(b)]);
741
+ if (
742
+ !ra ||
743
+ !rb ||
744
+ a === b ||
745
+ ra.suite !== suite.key ||
746
+ rb.suite !== suite.key ||
747
+ ra.status !== "SUCCEEDED" ||
748
+ rb.status !== "SUCCEEDED"
749
+ )
750
+ return null;
751
+ const asOutputs = async (r: RunRecord): Promise<RunOutput[]> =>
752
+ (await store.listOutputs(r.id)).map((x) => ({
753
+ caseId: x.caseId,
754
+ checks: x.error ? null : (x.scores ?? []),
755
+ costUsd: x.costUsd,
756
+ ms: x.ms,
757
+ reused: x.reused,
758
+ }));
759
+ const comparison = compareRuns(
760
+ await asOutputs(ra),
761
+ await asOutputs(rb),
762
+ suite.checks.map((c) => c.key),
763
+ );
764
+ // Comparable only when measured the same way.
765
+ const caveats = [
766
+ ra.set !== rb.set && `different sets (${ra.set} and ${rb.set})`,
767
+ ra.datasetVersion !== rb.datasetVersion &&
768
+ "cases or answers changed between the tests (rescore both to compare on today's answers)",
769
+ ra.graderVersion !== rb.graderVersion &&
770
+ "graded by different versions of the checks (rescore both)",
771
+ ra.promptVersion !== rb.promptVersion &&
772
+ `different instructions (${ra.promptVersion} and ${rb.promptVersion})`,
773
+ ].filter((x): x is string => Boolean(x));
774
+ return { suite, a: ra, b: rb, comparison, caveats };
775
+ }
776
+
777
+ /**
778
+ * The model board for one suite: every model the operation can use, with
779
+ * its latest test on every case in use, and how it compares with the
780
+ * live model. Needs `route` and `candidates`.
781
+ */
782
+ async function board(suiteKey: string) {
783
+ if (!opts.route || !opts.candidates)
784
+ throw new Error("The board needs the route and candidates options");
785
+ const suite = suiteOrThrow(suiteKey);
786
+ await stopStuckRuns(suite.key);
787
+ const [route, cases] = await Promise.all([
788
+ opts.route(suite.operation),
789
+ casesFor(suite.key, "ALL"),
790
+ ]);
791
+ const prompt = await opts.promptVersion(suite.operation);
792
+ const current = datasetVersion(cases);
793
+ const { models, fallbackModels = models } = opts.candidates(
794
+ suite.operation,
795
+ );
796
+ const allowed = new Set([...models, ...fallbackModels]);
797
+ const runs = (await store.listRuns(suite.key)).filter(
798
+ (r) => r.set === "ALL" && allowed.has(r.model),
799
+ );
800
+ const defaultEffort = route.reasoningEffort ?? null;
801
+ // The setting a model actually gets: models without reasoning settings
802
+ // ignore it, so their rows don't split by it.
803
+ const realEffort = (model: string, effort: string | null) =>
804
+ effortFor(opts.models, model, effort as ReasoningEffort | null) ?? null;
805
+ const keyOf = (model: string, effort: string | null) =>
806
+ `${model}|${realEffort(model, effort) ?? ""}`;
807
+ const runKey = (r: RunRecord) => keyOf(r.model, r.reasoningEffort);
808
+ const latestDone = new Map<string, RunRecord>();
809
+ const testing = new Set<string>();
810
+ const newest = new Map<string, RunRecord>();
811
+ for (const r of runs) if (!newest.has(runKey(r))) newest.set(runKey(r), r);
812
+ for (const r of runs) {
813
+ if (r.status === "RUNNING") testing.add(runKey(r));
814
+ if (r.status === "SUCCEEDED" && !latestDone.has(runKey(r)))
815
+ latestDone.set(runKey(r), r);
816
+ }
817
+ const liveTemperature = route.temperature ?? null;
818
+ // A test at another temperature than the live one answered differently.
819
+ const isCurrent = (r: RunRecord) =>
820
+ r.datasetVersion === current &&
821
+ r.promptVersion === prompt &&
822
+ (r.temperature ?? null) === liveTemperature;
823
+
824
+ const liveKey = keyOf(route.model, defaultEffort);
825
+ const liveRun = latestDone.get(liveKey);
826
+ // Tests on the same cases and instructions as the live model's compare
827
+ // with it, even if the answer key has changed since.
828
+ const sameAsLive = (r: RunRecord) =>
829
+ liveRun !== undefined &&
830
+ r.datasetVersion === liveRun.datasetVersion &&
831
+ r.promptVersion === liveRun.promptVersion &&
832
+ (r.temperature ?? null) === (liveRun.temperature ?? null);
833
+ const outputs = new Map<string, RunOutput[]>();
834
+ for (const r of latestDone.values())
835
+ outputs.set(
836
+ r.id,
837
+ (await store.listOutputs(r.id)).map((x) => ({
838
+ caseId: x.caseId,
839
+ checks: x.error ? null : (x.scores ?? []),
840
+ costUsd: x.costUsd,
841
+ ms: x.ms,
842
+ reused: x.reused,
843
+ })),
844
+ );
845
+ const outputsOf = (runId: string) => outputs.get(runId) ?? [];
846
+ const checkKeys = suite.checks.map((c) => c.key);
847
+
848
+ const seen = new Set<string>();
849
+ const keys = [
850
+ ...models.map((m) => ({
851
+ model: m,
852
+ effort: defaultEffort,
853
+ })),
854
+ ...fallbackModels.map((m) => ({
855
+ model: m,
856
+ effort: defaultEffort,
857
+ })),
858
+ ...[...newest.values()].map((r) => ({
859
+ model: r.model,
860
+ effort: r.reasoningEffort,
861
+ })),
862
+ ].filter(({ model, effort }) => {
863
+ const k = keyOf(model, effort);
864
+ if (seen.has(k)) return false;
865
+ seen.add(k);
866
+ return true;
867
+ });
868
+ const complete = (r: RunRecord | undefined) => !(r?.metrics?.failed ?? 0);
869
+ const estimates = new Map(
870
+ await Promise.all(
871
+ keys.map(
872
+ async ({ model }) =>
873
+ [
874
+ model,
875
+ (await opts.estimateCallCost?.(suite.operation, model)) ?? null,
876
+ ] as const,
877
+ ),
878
+ ),
879
+ );
880
+
881
+ const rows: BoardRow[] = keys.map(({ model, effort }) => {
882
+ const key = keyOf(model, effort);
883
+ const variant =
884
+ realEffort(model, effort) !== realEffort(model, defaultEffort);
885
+ const r = latestDone.get(key);
886
+ const lastRun = newest.get(key);
887
+ const m = r?.metrics ?? undefined;
888
+ const fresh = Boolean(r && isCurrent(r));
889
+ // Tests with failed answers are incomplete: never compared or promoted.
890
+ const vsLive =
891
+ r &&
892
+ liveRun &&
893
+ complete(r) &&
894
+ complete(liveRun) &&
895
+ sameAsLive(r) &&
896
+ key !== liveKey
897
+ ? compareRuns(outputsOf(liveRun.id), outputsOf(r.id), checkKeys)
898
+ .verdict
899
+ : null;
900
+ const label = opts.models[model]?.label ?? model;
901
+ const baseline = isBaseline(opts.models, model);
902
+ return {
903
+ key,
904
+ model,
905
+ effort,
906
+ variant,
907
+ label: variant
908
+ ? `${label} · ${realEffort(model, effort) ?? "default"} reasoning`
909
+ : label,
910
+ live: key === liveKey,
911
+ fallback: !variant && model === route.fallbackModel,
912
+ baseline,
913
+ testing: testing.has(key),
914
+ failed:
915
+ lastRun?.status === "FAILED"
916
+ ? (lastRun.error ?? "unknown error").slice(0, 160)
917
+ : null,
918
+ estCostPerAnswer: estimates.get(model) ?? null,
919
+ test:
920
+ r && m
921
+ ? {
922
+ runId: r.id,
923
+ at: r.startedAt,
924
+ right: m.units?.right ?? m.allPass.passed,
925
+ total: m.units?.total ?? m.allPass.n,
926
+ answersRight: m.allPass.passed,
927
+ answers: m.allPass.n,
928
+ byCheck: m.byCheck,
929
+ measures: m.measures ?? {},
930
+ avgMs: m.avgMs || null,
931
+ costPerAnswer: m.costPerAnswer ?? null,
932
+ stale: !fresh,
933
+ failedAnswers: m.failed,
934
+ failureReason: m.failureReason ?? null,
935
+ }
936
+ : null,
937
+ vsLive,
938
+ // Only on up-to-date tests: never promote on an old answer key.
939
+ canMakeLive:
940
+ !baseline &&
941
+ models.includes(model) &&
942
+ fresh &&
943
+ complete(r) &&
944
+ (vsLive === "BETTER" || vsLive === "SAME"),
945
+ liveOnly: !fallbackModels.includes(model),
946
+ fallbackOnly: !models.includes(model),
947
+ canMakeFallback:
948
+ !baseline &&
949
+ fallbackModels.includes(model) &&
950
+ !variant &&
951
+ model !== route.model &&
952
+ model !== route.fallbackModel &&
953
+ opts.models[model]?.provider !== opts.models[route.model]?.provider &&
954
+ // A fallback only runs during an outage: it needs to be tested on
955
+ // these cases, not to beat the live model.
956
+ fresh &&
957
+ complete(r),
958
+ };
959
+ });
960
+ // Live, then fallback, then tested rows by most right, then the rest.
961
+ rows.sort(
962
+ (a, b) =>
963
+ Number(b.live) - Number(a.live) ||
964
+ Number(b.fallback) - Number(a.fallback) ||
965
+ (b.test && !b.test.stale ? b.test.right / b.test.total : -1) -
966
+ (a.test && !a.test.stale ? a.test.right / a.test.total : -1) ||
967
+ a.label.localeCompare(b.label),
968
+ );
969
+ // How each case fared across current, complete tests: easy and split cases.
970
+ const caseResults: Record<string, { passed: number; models: number }> = {};
971
+ for (const row of rows) {
972
+ if (row.variant || !row.test || row.test.stale || row.test.failedAnswers)
973
+ continue;
974
+ for (const x of outputsOf(row.test.runId)) {
975
+ const c = (caseResults[x.caseId] ??= { passed: 0, models: 0 });
976
+ c.models++;
977
+ if (allPassed(x.checks)) c.passed++;
978
+ }
979
+ }
980
+ return {
981
+ suite,
982
+ route,
983
+ cases: cases.length,
984
+ caseResults,
985
+ /** Cases in use on an answer nobody has reviewed yet. */
986
+ provisional: cases.filter((c) => isProvisional(c.labels)).length,
987
+ rows,
988
+ liveRunId: liveRun?.id ?? null,
989
+ };
990
+ }
991
+
992
+ return {
993
+ startDriftCheck,
994
+ runDriftCheck,
995
+ board,
996
+ suiteOrThrow,
997
+ casesFor,
998
+ estimateRun,
999
+ createRun,
1000
+ runEval,
1001
+ gradeOutput,
1002
+ rescoreRun,
1003
+ rescoreRecent,
1004
+ stopStuckRuns,
1005
+ compare,
1006
+ };
1007
+ }
1008
+
1009
+ /** What createRunner returns. */
1010
+ export type Runner = ReturnType<typeof createRunner>;