@crowandraven/evalkeeper 0.2.0 → 0.4.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 (55) hide show
  1. package/dist/common.js +4 -2
  2. package/dist/common.js.map +1 -1
  3. package/dist/importCases.d.ts +94 -0
  4. package/dist/importCases.js +185 -0
  5. package/dist/importCases.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/labels.d.ts +9 -3
  10. package/dist/labels.js +2 -0
  11. package/dist/labels.js.map +1 -1
  12. package/dist/memoryStore.js +16 -2
  13. package/dist/memoryStore.js.map +1 -1
  14. package/dist/models.d.ts +17 -6
  15. package/dist/models.js +29 -5
  16. package/dist/models.js.map +1 -1
  17. package/dist/runner.d.ts +16 -1
  18. package/dist/runner.js +68 -18
  19. package/dist/runner.js.map +1 -1
  20. package/dist/settings.js +1 -0
  21. package/dist/settings.js.map +1 -1
  22. package/dist/stats.d.ts +5 -1
  23. package/dist/stats.js +21 -6
  24. package/dist/stats.js.map +1 -1
  25. package/dist/store.d.ts +24 -2
  26. package/dist/testing.js +77 -0
  27. package/dist/testing.js.map +1 -1
  28. package/dist/types.d.ts +5 -0
  29. package/dist/types.js.map +1 -1
  30. package/dist/weekly.js +3 -2
  31. package/dist/weekly.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/common.ts +5 -2
  34. package/src/importCases.ts +252 -0
  35. package/src/index.ts +1 -0
  36. package/src/labels.ts +9 -4
  37. package/src/memoryStore.ts +24 -2
  38. package/src/models.ts +35 -7
  39. package/src/runner.ts +126 -49
  40. package/src/settings.ts +1 -0
  41. package/src/stats.ts +19 -6
  42. package/src/store.ts +28 -2
  43. package/src/testing.ts +91 -0
  44. package/src/types.ts +5 -0
  45. package/src/weekly.ts +3 -2
  46. package/templates/app/evals/[suite]/page.tsx +15 -10
  47. package/templates/app/evals/feedback/page.tsx +11 -6
  48. package/templates/app/evals/page.tsx +59 -15
  49. package/templates/components/evalkeeper/AddCase.tsx +2 -1
  50. package/templates/components/evalkeeper/AnswerKey.tsx +14 -15
  51. package/templates/components/evalkeeper/ConfirmAnswer.tsx +46 -0
  52. package/templates/components/evalkeeper/RefreshWhile.tsx +16 -7
  53. package/templates/components/evalkeeper/RollBack.tsx +62 -0
  54. package/templates/components/evalkeeper/SettingsHistory.tsx +6 -20
  55. package/templates/components/evalkeeper/Submit.tsx +13 -4
package/src/models.ts CHANGED
@@ -3,10 +3,27 @@
3
3
  * pass a catalog in; Evalkeeper never picks a provider itself. Pure.
4
4
  */
5
5
 
6
- /** How hard a reasoning model thinks, where the model takes a setting. */
7
- export const REASONING_EFFORTS = ["none", "low", "medium", "high"] as const;
8
- /** A reasoning setting a model may accept. */
9
- export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
6
+ /**
7
+ * The reasoning settings providers name, lowest first, so a setting one
8
+ * model lacks can be placed against the ones it has. A model's own
9
+ * `efforts` say which it takes.
10
+ */
11
+ export const REASONING_EFFORTS = [
12
+ "none",
13
+ "minimal",
14
+ "low",
15
+ "medium",
16
+ "high",
17
+ "xhigh",
18
+ "max",
19
+ ] as const;
20
+ /** A setting from REASONING_EFFORTS. */
21
+ export type KnownReasoningEffort = (typeof REASONING_EFFORTS)[number];
22
+ /**
23
+ * A reasoning setting a model may accept: one of REASONING_EFFORTS, or any
24
+ * other name a provider uses. The catalog's `efforts` order them per model.
25
+ */
26
+ export type ReasoningEffort = KnownReasoningEffort | (string & {});
10
27
 
11
28
  /**
12
29
  * What Evalkeeper needs to know about one model: its label, provider, prices, reasoning
@@ -112,8 +129,10 @@ export function priceCall(
112
129
 
113
130
  /**
114
131
  * The effort a model actually gets for a requested one: the same if it
115
- * takes it; for "none" on a model without it, its lowest; otherwise its own
116
- * default. Undefined: send nothing.
132
+ * takes it; one below the lowest it takes (say "none" on a model that
133
+ * starts at "minimal"), its lowest; one above its highest, its highest;
134
+ * otherwise (a name it doesn't place) its own default. Undefined: send
135
+ * nothing.
117
136
  */
118
137
  export function effortFor(
119
138
  models: ModelCatalog,
@@ -123,5 +142,14 @@ export function effortFor(
123
142
  const efforts = models[model]?.efforts;
124
143
  if (!requested || !efforts) return undefined;
125
144
  if (efforts.includes(requested)) return requested;
126
- return requested === "none" ? efforts[0] : undefined;
145
+ const rank = (e: string) =>
146
+ (REASONING_EFFORTS as readonly string[]).indexOf(e);
147
+ const want = rank(requested);
148
+ const lowest = efforts[0];
149
+ const highest = efforts.at(-1);
150
+ if (want < 0 || lowest === undefined || highest === undefined)
151
+ return undefined;
152
+ if (rank(lowest) >= 0 && want < rank(lowest)) return lowest;
153
+ if (rank(highest) >= 0 && want > rank(highest)) return highest;
154
+ return undefined;
127
155
  }
package/src/runner.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  EvalStore,
25
25
  RunMetrics,
26
26
  RunRecord,
27
+ OutputRecord,
27
28
  } from "./store.js";
28
29
  import { withTimeout } from "./timeout.js";
29
30
  import { SCHEDULED } from "./weekly.js";
@@ -105,11 +106,18 @@ export type RunnerOptions = {
105
106
  models: string[];
106
107
  fallbackModels?: string[];
107
108
  };
109
+ /**
110
+ * Which retiring models (`ModelInfo.retiring`) the board lists.
111
+ * "always" (the default): every candidate, though a retiring one is never
112
+ * suggested. "inUse": only one a feature still runs on, as its live or
113
+ * fallback model, so nobody spends a test on a model that's going away.
114
+ */
115
+ retiringOnBoard?: "always" | "inUse";
108
116
  };
109
117
 
110
118
  /**
111
119
  * Which model a feature runs on now, its fallback, its reasoning setting,
112
- * and its temperature.
120
+ * its temperature, and its output cap.
113
121
  */
114
122
  export type Route = {
115
123
  model: string;
@@ -117,6 +125,8 @@ export type Route = {
117
125
  reasoningEffort: ReasoningEffort | null;
118
126
  /** Null or unset: no temperature sent. */
119
127
  temperature?: number | null;
128
+ /** The output-token cap; null or unset: the call's own default. */
129
+ maxOutputTokens?: number | null;
120
130
  };
121
131
 
122
132
  /**
@@ -149,6 +159,8 @@ export function requestHashFor(r: {
149
159
  repeat: number;
150
160
  /** Left out of the hash when null, so requests without one reuse as before. */
151
161
  temperature?: number | null;
162
+ /** Left out of the hash when null, like temperature. */
163
+ maxOutputTokens?: number | null;
152
164
  }): string {
153
165
  return sha(
154
166
  stableJson({
@@ -159,6 +171,9 @@ export function requestHashFor(r: {
159
171
  input: r.input,
160
172
  repeat: r.repeat,
161
173
  ...(r.temperature != null ? { temperature: r.temperature } : {}),
174
+ ...(r.maxOutputTokens != null
175
+ ? { maxOutputTokens: r.maxOutputTokens }
176
+ : {}),
162
177
  }),
163
178
  64,
164
179
  );
@@ -267,6 +282,12 @@ export function createRunner(opts: RunnerOptions) {
267
282
  return (await opts.route(operation)).temperature ?? null;
268
283
  }
269
284
 
285
+ /** The operation's live output cap: the route's, else the call's own. */
286
+ async function liveCapFor(operation: string): Promise<number | null> {
287
+ if (!opts.route) return null;
288
+ return (await opts.route(operation)).maxOutputTokens ?? null;
289
+ }
290
+
270
291
  /**
271
292
  * What a test would cost. Answers an identical request gave in the last
272
293
  * 7 days are reused for free, so only the rest are priced: a retest
@@ -285,6 +306,8 @@ export function createRunner(opts: RunnerOptions) {
285
306
  reasoningEffort?: ReasoningEffort | null;
286
307
  /** The temperature to test; undefined: the operation's live one. */
287
308
  temperature?: number | null;
309
+ /** The output cap to test; undefined: the operation's live one. */
310
+ maxOutputTokens?: number | null;
288
311
  } = {},
289
312
  ): Promise<{
290
313
  cases: number;
@@ -295,41 +318,53 @@ export function createRunner(opts: RunnerOptions) {
295
318
  costUsd: number | null;
296
319
  }> {
297
320
  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
- ]);
321
+ const [cases, perCall, prompt, effort, temperature, cap] =
322
+ await Promise.all([
323
+ casesFor(suite.key, set),
324
+ opts.estimateCallCost?.(suite.operation, model) ?? null,
325
+ opts.promptVersion(suite.operation),
326
+ o.reasoningEffort !== undefined
327
+ ? o.reasoningEffort
328
+ : liveEffortFor(suite.operation),
329
+ o.temperature !== undefined
330
+ ? o.temperature
331
+ : liveTemperatureFor(suite.operation),
332
+ o.maxOutputTokens !== undefined
333
+ ? o.maxOutputTokens
334
+ : liveCapFor(suite.operation),
335
+ ]);
309
336
  const answers = cases.length * repeats;
310
337
  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
- ),
338
+ const hashes = o.fresh
339
+ ? []
340
+ : cases.flatMap((c) =>
341
+ Array.from({ length: repeats }, (_, i) =>
342
+ requestHashFor({
343
+ suite: suite.key,
344
+ model,
345
+ effort,
346
+ prompt,
347
+ input: c.input,
348
+ repeat: i + 1,
349
+ temperature,
350
+ maxOutputTokens: cap,
351
+ }),
352
+ ),
353
+ );
354
+ // One query where the store can: a lookup per answer is slow on a
355
+ // remote database.
356
+ const found = store.findReusableHashes
357
+ ? await store.findReusableHashes(hashes, since)
358
+ : new Set(
359
+ (
360
+ await Promise.all(
361
+ hashes.map(async (h) =>
362
+ (await store.findReusableOutput(h, since)) ? h : null,
329
363
  ),
330
- ),
331
- )
332
- ).filter(Boolean).length;
364
+ )
365
+ ).filter((h): h is string => h !== null),
366
+ );
367
+ const reused = hashes.filter((h) => found.has(h)).length;
333
368
  return {
334
369
  cases: cases.length,
335
370
  answers,
@@ -350,6 +385,8 @@ export function createRunner(opts: RunnerOptions) {
350
385
  reasoningEffort?: ReasoningEffort | null;
351
386
  /** A temperature to test; undefined: the operation's live one. */
352
387
  temperature?: number | null;
388
+ /** An output cap to test; undefined: the operation's live one. */
389
+ maxOutputTokens?: number | null;
353
390
  /** For the estimate: pass the same `fresh` to runEval. */
354
391
  fresh?: boolean;
355
392
  }) {
@@ -364,10 +401,15 @@ export function createRunner(opts: RunnerOptions) {
364
401
  args.temperature !== undefined
365
402
  ? args.temperature
366
403
  : await liveTemperatureFor(suite.operation);
404
+ const maxOutputTokens =
405
+ args.maxOutputTokens !== undefined
406
+ ? args.maxOutputTokens
407
+ : await liveCapFor(suite.operation);
367
408
  const [estimate, promptVersion] = await Promise.all([
368
409
  estimateRun(suite.key, args.model, set, repeats, {
369
410
  reasoningEffort,
370
411
  temperature,
412
+ maxOutputTokens,
371
413
  fresh: Boolean(args.fresh),
372
414
  }),
373
415
  opts.promptVersion(suite.operation),
@@ -380,6 +422,7 @@ export function createRunner(opts: RunnerOptions) {
380
422
  reasoningEffort:
381
423
  args.reasoningEffort !== undefined ? args.reasoningEffort : liveEffort,
382
424
  temperature,
425
+ maxOutputTokens,
383
426
  set,
384
427
  repeats,
385
428
  datasetVersion: null,
@@ -516,6 +559,7 @@ export function createRunner(opts: RunnerOptions) {
516
559
  input: c.input,
517
560
  repeat,
518
561
  temperature: run.temperature ?? null,
562
+ maxOutputTokens: run.maxOutputTokens ?? null,
519
563
  });
520
564
  const earlier = o.fresh
521
565
  ? null
@@ -543,9 +587,9 @@ export function createRunner(opts: RunnerOptions) {
543
587
  const output = await withTimeout(
544
588
  suite.run(suite.input.parse(c.input), {
545
589
  model: run.model,
546
- reasoningEffort:
547
- (run.reasoningEffort as ReasoningEffort | null) ?? null,
590
+ reasoningEffort: run.reasoningEffort ?? null,
548
591
  temperature: run.temperature ?? null,
592
+ maxOutputTokens: run.maxOutputTokens ?? null,
549
593
  onCost: (usd) => (cost += usd),
550
594
  }),
551
595
  timeout,
@@ -693,6 +737,7 @@ export function createRunner(opts: RunnerOptions) {
693
737
  startedBy: SCHEDULED,
694
738
  reasoningEffort: route.reasoningEffort,
695
739
  temperature: route.temperature ?? null,
740
+ maxOutputTokens: route.maxOutputTokens ?? null,
696
741
  });
697
742
  return run.id;
698
743
  }
@@ -708,11 +753,30 @@ export function createRunner(opts: RunnerOptions) {
708
753
  return id;
709
754
  }
710
755
 
756
+ /**
757
+ * Several tests' outputs by test id: one call where the store can, so a
758
+ * remote database isn't asked once per test.
759
+ */
760
+ async function outputsByRun(
761
+ runIds: string[],
762
+ ): Promise<Map<string, OutputRecord[]>> {
763
+ const byRun = new Map(runIds.map((id) => [id, [] as OutputRecord[]]));
764
+ if (!runIds.length) return byRun;
765
+ const rows = store.listOutputsFor
766
+ ? await store.listOutputsFor(runIds)
767
+ : (await Promise.all(runIds.map((id) => store.listOutputs(id)))).flat();
768
+ for (const o of rows) byRun.get(o.runId)?.push(o);
769
+ return byRun;
770
+ }
771
+
711
772
  /** Marks tests the server stopped mid-way as failed. */
712
773
  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);
774
+ const old = (await store.listRuns(suiteKey, { status: "RUNNING" })).filter(
775
+ (r) => now - +r.startedAt > STUCK_MS,
776
+ );
777
+ const outputs = await outputsByRun(old.map((r) => r.id));
778
+ for (const r of old) {
779
+ const outs = outputs.get(r.id) ?? [];
716
780
  const last = outs.reduce<Date | null>(
717
781
  (m, x) => (!m || x.createdAt > m ? x.createdAt : m),
718
782
  null,
@@ -783,12 +847,12 @@ export function createRunner(opts: RunnerOptions) {
783
847
  if (!opts.route || !opts.candidates)
784
848
  throw new Error("The board needs the route and candidates options");
785
849
  const suite = suiteOrThrow(suiteKey);
786
- await stopStuckRuns(suite.key);
787
- const [route, cases] = await Promise.all([
850
+ const [route, cases, prompt] = await Promise.all([
788
851
  opts.route(suite.operation),
789
852
  casesFor(suite.key, "ALL"),
853
+ opts.promptVersion(suite.operation),
854
+ stopStuckRuns(suite.key),
790
855
  ]);
791
- const prompt = await opts.promptVersion(suite.operation);
792
856
  const current = datasetVersion(cases);
793
857
  const { models, fallbackModels = models } = opts.candidates(
794
858
  suite.operation,
@@ -801,7 +865,7 @@ export function createRunner(opts: RunnerOptions) {
801
865
  // The setting a model actually gets: models without reasoning settings
802
866
  // ignore it, so their rows don't split by it.
803
867
  const realEffort = (model: string, effort: string | null) =>
804
- effortFor(opts.models, model, effort as ReasoningEffort | null) ?? null;
868
+ effortFor(opts.models, model, effort) ?? null;
805
869
  const keyOf = (model: string, effort: string | null) =>
806
870
  `${model}|${realEffort(model, effort) ?? ""}`;
807
871
  const runKey = (r: RunRecord) => keyOf(r.model, r.reasoningEffort);
@@ -815,11 +879,14 @@ export function createRunner(opts: RunnerOptions) {
815
879
  latestDone.set(runKey(r), r);
816
880
  }
817
881
  const liveTemperature = route.temperature ?? null;
818
- // A test at another temperature than the live one answered differently.
882
+ const liveCap = route.maxOutputTokens ?? null;
883
+ // A test at another temperature or output cap than the live ones
884
+ // answered differently.
819
885
  const isCurrent = (r: RunRecord) =>
820
886
  r.datasetVersion === current &&
821
887
  r.promptVersion === prompt &&
822
- (r.temperature ?? null) === liveTemperature;
888
+ (r.temperature ?? null) === liveTemperature &&
889
+ (r.maxOutputTokens ?? null) === liveCap;
823
890
 
824
891
  const liveKey = keyOf(route.model, defaultEffort);
825
892
  const liveRun = latestDone.get(liveKey);
@@ -829,12 +896,15 @@ export function createRunner(opts: RunnerOptions) {
829
896
  liveRun !== undefined &&
830
897
  r.datasetVersion === liveRun.datasetVersion &&
831
898
  r.promptVersion === liveRun.promptVersion &&
832
- (r.temperature ?? null) === (liveRun.temperature ?? null);
899
+ (r.temperature ?? null) === (liveRun.temperature ?? null) &&
900
+ (r.maxOutputTokens ?? null) === (liveRun.maxOutputTokens ?? null);
833
901
  const outputs = new Map<string, RunOutput[]>();
834
- for (const r of latestDone.values())
902
+ for (const [id, outs] of await outputsByRun(
903
+ [...latestDone.values()].map((r) => r.id),
904
+ ))
835
905
  outputs.set(
836
- r.id,
837
- (await store.listOutputs(r.id)).map((x) => ({
906
+ id,
907
+ outs.map((x) => ({
838
908
  caseId: x.caseId,
839
909
  checks: x.error ? null : (x.scores ?? []),
840
910
  costUsd: x.costUsd,
@@ -846,7 +916,7 @@ export function createRunner(opts: RunnerOptions) {
846
916
  const checkKeys = suite.checks.map((c) => c.key);
847
917
 
848
918
  const seen = new Set<string>();
849
- const keys = [
919
+ let keys = [
850
920
  ...models.map((m) => ({
851
921
  model: m,
852
922
  effort: defaultEffort,
@@ -865,6 +935,13 @@ export function createRunner(opts: RunnerOptions) {
865
935
  seen.add(k);
866
936
  return true;
867
937
  });
938
+ // A retiring model stays on the board only while a feature runs on it.
939
+ const inUse = (model: string) =>
940
+ model === route.model || model === route.fallbackModel;
941
+ if (opts.retiringOnBoard === "inUse")
942
+ keys = keys.filter(
943
+ ({ model }) => !opts.models[model]?.retiring || inUse(model),
944
+ );
868
945
  const complete = (r: RunRecord | undefined) => !(r?.metrics?.failed ?? 0);
869
946
  const estimates = new Map(
870
947
  await Promise.all(
package/src/settings.ts CHANGED
@@ -256,6 +256,7 @@ export function createSettings(opts: {
256
256
  fallbackModel: settings.fallbackModel,
257
257
  reasoningEffort: settings.reasoningEffort,
258
258
  temperature: settings.temperature ?? null,
259
+ maxOutputTokens: settings.maxOutputTokens,
259
260
  };
260
261
  }
261
262
 
package/src/stats.ts CHANGED
@@ -92,21 +92,34 @@ export function allPassed(checks: CheckResult[] | null): boolean {
92
92
  return checks !== null && checks.every((c) => c.pass !== false);
93
93
  }
94
94
 
95
- /** Sums each answer's partial credit; null when the suite gives none. */
95
+ /**
96
+ * Sums each answer's partial credit; null when the suite gives none. A
97
+ * failed answer (no checks) earns nothing and costs what a graded answer
98
+ * is worth on average, so a model that fails answers can't score 100%.
99
+ */
96
100
  export function sumUnits(
97
101
  outputs: { checks: CheckResult[] | null }[],
98
102
  ): { right: number; total: number } | null {
99
103
  let right = 0;
100
104
  let total = 0;
101
- let any = false;
102
- for (const o of outputs)
103
- for (const c of o.checks ?? [])
105
+ let graded = 0;
106
+ let failed = 0;
107
+ for (const o of outputs) {
108
+ if (!o.checks) {
109
+ failed++;
110
+ continue;
111
+ }
112
+ let counted = false;
113
+ for (const c of o.checks)
104
114
  if (c.units) {
105
- any = true;
115
+ counted = true;
106
116
  right += c.units.right;
107
117
  total += c.units.total;
108
118
  }
109
- return any ? { right, total } : null;
119
+ if (counted) graded++;
120
+ }
121
+ if (!graded) return null;
122
+ return { right, total: total + failed * (total / graded) };
110
123
  }
111
124
 
112
125
  /** One answer's score from 0 to 1: its partial credit, else all-or-nothing. */
package/src/store.ts CHANGED
@@ -82,6 +82,11 @@ export type RunRecord = {
82
82
  * test at another temperature than the live one shows as out of date.
83
83
  */
84
84
  temperature?: number | null;
85
+ /**
86
+ * The output-token cap tested; null or unset: the call's own default.
87
+ * Kept like temperature: a test at another cap is out of date.
88
+ */
89
+ maxOutputTokens?: number | null;
85
90
  set: EvalSet;
86
91
  repeats: number;
87
92
  /** Changes when the cases or their answers do, so tests compare. */
@@ -139,8 +144,15 @@ export type NewCase = Omit<
139
144
  * database, and run describeStoreContract against it.
140
145
  */
141
146
  export interface EvalStore {
142
- /** A suite's cases that aren't retired, with their answers, oldest first. */
143
- listCases(suite: string): Promise<CaseRecord[]>;
147
+ /**
148
+ * A suite's cases that aren't retired, with their answers, oldest first.
149
+ * With `includeRetired`, retired ones too (an import checks them, so a
150
+ * case someone retired isn't added again).
151
+ */
152
+ listCases(
153
+ suite: string,
154
+ opts?: { includeRetired?: boolean },
155
+ ): Promise<CaseRecord[]>;
144
156
  createCase(data: NewCase): Promise<CaseRecord>;
145
157
  /** Adds an answer to a case (answers are never overwritten). */
146
158
  addLabel(caseId: string, label: NewLabel): Promise<LabelRecord>;
@@ -167,6 +179,20 @@ export interface EvalStore {
167
179
  requestHash: string,
168
180
  since: Date,
169
181
  ): Promise<OutputRecord | null>;
182
+ /**
183
+ * The outputs of several tests in one call, for the board on a remote
184
+ * database. Optional: without it the runner calls listOutputs per test.
185
+ */
186
+ listOutputsFor?(runIds: string[]): Promise<OutputRecord[]>;
187
+ /**
188
+ * Which of these request hashes have an output findReusableOutput would
189
+ * return, in one call, for estimating a test's cost. Optional: without it
190
+ * the runner looks each hash up.
191
+ */
192
+ findReusableHashes?(
193
+ requestHashes: string[],
194
+ since: Date,
195
+ ): Promise<Set<string>>;
170
196
  createOutput(data: NewOutput): Promise<OutputRecord>;
171
197
  setOutputScores(id: string, scores: CheckResult[]): Promise<void>;
172
198
  }
package/src/testing.ts CHANGED
@@ -84,12 +84,19 @@ export function describeStoreContract(
84
84
  it("lists a suite's cases oldest first, leaving out retired ones", async () => {
85
85
  const store = await make();
86
86
  const first = await store.createCase(newCase("s"));
87
+ // Apart in time: two cases made in the same millisecond have no
88
+ // oldest one.
89
+ await new Promise((r) => setTimeout(r, 5));
87
90
  const second = await store.createCase(newCase("s"));
88
91
  await store.createCase(newCase("other"));
89
92
  await store.setCaseStatus(first.id, "RETIRED");
90
93
  expect((await store.listCases("s")).map((c) => c.id)).toEqual([
91
94
  second.id,
92
95
  ]);
96
+ // On request, retired ones too, still oldest first.
97
+ expect(
98
+ (await store.listCases("s", { includeRetired: true })).map((c) => c.id),
99
+ ).toEqual([first.id, second.id]);
93
100
  });
94
101
 
95
102
  it("adds answers without overwriting, and changes their status", async () => {
@@ -135,6 +142,20 @@ export function describeStoreContract(
135
142
  expect((await store.getRun(none.id))?.temperature ?? null).toBeNull();
136
143
  });
137
144
 
145
+ it("keeps the output cap a run was tested at", async () => {
146
+ const store = await make();
147
+ const capped = await store.createRun({
148
+ ...newRun("s"),
149
+ maxOutputTokens: 4000,
150
+ });
151
+ const none = await store.createRun({
152
+ ...newRun("s"),
153
+ maxOutputTokens: null,
154
+ });
155
+ expect((await store.getRun(capped.id))?.maxOutputTokens).toBe(4000);
156
+ expect((await store.getRun(none.id))?.maxOutputTokens ?? null).toBeNull();
157
+ });
158
+
138
159
  it("updates a run only while it has the status it's guarded by", async () => {
139
160
  const store = await make();
140
161
  const r = await store.createRun(newRun("s"));
@@ -180,6 +201,61 @@ export function describeStoreContract(
180
201
  ).toBeNull();
181
202
  });
182
203
 
204
+ it("lists several tests' outputs and reusable hashes in one call, where it can", async () => {
205
+ const store = await make();
206
+ const c = await store.createCase(newCase("s"));
207
+ const [r1, r2, r3] = await Promise.all([
208
+ store.createRun(newRun("s")),
209
+ store.createRun(newRun("s")),
210
+ store.createRun(newRun("s")),
211
+ ]);
212
+ const base = {
213
+ caseId: c.id,
214
+ repeat: 1,
215
+ output: { text: "HELLO" },
216
+ error: null,
217
+ scores: null,
218
+ costUsd: 0.01,
219
+ ms: 12,
220
+ reused: false,
221
+ };
222
+ await store.createOutput({ ...base, runId: r1.id, requestHash: "a" });
223
+ await store.createOutput({ ...base, runId: r2.id, requestHash: "b" });
224
+ await store.createOutput({
225
+ ...base,
226
+ runId: r2.id,
227
+ requestHash: "c",
228
+ reused: true,
229
+ });
230
+ await store.createOutput({
231
+ ...base,
232
+ runId: r3.id,
233
+ requestHash: "d",
234
+ error: "boom",
235
+ output: null,
236
+ });
237
+ if (store.listOutputsFor) {
238
+ const outs = await store.listOutputsFor([r1.id, r2.id]);
239
+ expect(outs.map((o) => o.requestHash).sort()).toEqual(["a", "b", "c"]);
240
+ expect(await store.listOutputsFor([])).toEqual([]);
241
+ }
242
+ if (store.findReusableHashes) {
243
+ expect(
244
+ [
245
+ ...(await store.findReusableHashes(
246
+ ["a", "b", "c", "d", "e"],
247
+ new Date(0),
248
+ )),
249
+ ].sort(),
250
+ ).toEqual(["a", "b"]);
251
+ expect(
252
+ (await store.findReusableHashes(["a"], new Date(Date.now() + 60_000)))
253
+ .size,
254
+ ).toBe(0);
255
+ expect((await store.findReusableHashes([], new Date(0))).size).toBe(0);
256
+ }
257
+ });
258
+
183
259
  it("runs a whole test through the runner", async () => {
184
260
  const store = await make();
185
261
  const echo: Suite<{ text: string }, { text: string }, { want: string }> =
@@ -389,6 +465,21 @@ export function describeSettingsStoreContract(
389
465
  expect(await store.listVersions("triage")).toHaveLength(2);
390
466
  });
391
467
 
468
+ it("keeps a version's reasoning setting as named, even a newer one", async () => {
469
+ const store = await make();
470
+ const v1 = await store.switchTo("triage", {
471
+ ...create("a", null),
472
+ create: {
473
+ settings: { ...settings("a"), reasoningEffort: "xhigh" },
474
+ promptVersion: "p1",
475
+ },
476
+ });
477
+ expect(v1.reasoningEffort).toBe("xhigh");
478
+ expect((await store.liveVersion("triage"))?.reasoningEffort).toBe(
479
+ "xhigh",
480
+ );
481
+ });
482
+
392
483
  it("keeps a version's temperature", async () => {
393
484
  const store = await make();
394
485
  const v1 = await store.switchTo("triage", {
package/src/types.ts CHANGED
@@ -114,6 +114,11 @@ export type Suite<Input, Output, Label> = {
114
114
  * temperature was versioned.
115
115
  */
116
116
  temperature?: number | null;
117
+ /**
118
+ * The output-token cap the test runs at, like temperature. Null: send
119
+ * none (the call's own default).
120
+ */
121
+ maxOutputTokens?: number | null;
117
122
  onCost: (usd: number) => void;
118
123
  },
119
124
  ): Promise<Output>;
package/src/weekly.ts CHANGED
@@ -104,8 +104,8 @@ export function weeklySummary(args: {
104
104
  continue;
105
105
  }
106
106
  if (r.status !== "SUCCEEDED") continue;
107
- // The last check of the same model, temperature, instructions, and
108
- // grading, a day or more earlier: anything else changed for a known
107
+ // The last check of the same model, temperature, output cap,
108
+ // instructions, and grading, a day or more earlier: anything else changed for a known
109
109
  // reason.
110
110
  const prior = scheduled.find(
111
111
  (p) =>
@@ -113,6 +113,7 @@ export function weeklySummary(args: {
113
113
  p.status === "SUCCEEDED" &&
114
114
  p.model === r.model &&
115
115
  (p.temperature ?? null) === (r.temperature ?? null) &&
116
+ (p.maxOutputTokens ?? null) === (r.maxOutputTokens ?? null) &&
116
117
  p.promptVersion === r.promptVersion &&
117
118
  p.graderVersion === r.graderVersion &&
118
119
  +r.startedAt - +p.startedAt >= DAY,