@crowandraven/evalkeeper 0.2.0 → 0.3.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.
@@ -0,0 +1,252 @@
1
+ import { nextSplit } from "./quickSet.js";
2
+ import { stableJson } from "./stableJson.js";
3
+ import type { EvalStore, NewCase, NewLabel } from "./store.js";
4
+ import type { AnySuite } from "./types.js";
5
+
6
+ /** One row of an import, mapped to the suite's shapes. */
7
+ export type ImportedCase = {
8
+ /** Stable within its source (a row id in the file), so a rerun skips it. */
9
+ id: string;
10
+ /** The suite's input; checked against `suite.input`. */
11
+ input: unknown;
12
+ /** The answer, checked against `suite.labelValue`; null: none yet. */
13
+ label: unknown;
14
+ tags?: string[];
15
+ note?: string | null;
16
+ /** This row's set, instead of the import's `split`. */
17
+ split?: "DEV" | "TEST";
18
+ };
19
+
20
+ /**
21
+ * How imported answers arrive. Pending (the default): a person confirms
22
+ * each one on the pages before tests use it. Provisional: tests use them
23
+ * now, every score says it rests on unreviewed answers, and the rows carry
24
+ * `tag` so the review queue is findable. Use provisional only when the
25
+ * owner said so; `allowedBy` and `at` record who and when. Nothing is
26
+ * imported confirmed: only a person confirms an answer.
27
+ */
28
+ export type ImportAnswers =
29
+ | { status: "PENDING" }
30
+ | {
31
+ status: "PROVISIONAL";
32
+ /** Who drafted the answers: an agent's name, recorded as the rater. */
33
+ rater: string;
34
+ /** The person who allowed using them before review. */
35
+ allowedBy: string;
36
+ /** When they allowed it; default now. */
37
+ at?: Date;
38
+ /** Added to each row's tags; default "needs-review". */
39
+ tag?: string;
40
+ };
41
+
42
+ /** A row that couldn't be imported, and why. */
43
+ export type ImportProblem = { id: string; problem: string };
44
+
45
+ /** Rows that don't fit the suite. Nothing was written. */
46
+ export class CaseImportError extends Error {
47
+ readonly problems: ImportProblem[];
48
+ constructor(source: string, problems: ImportProblem[]) {
49
+ super(
50
+ `${problems.length} row${problems.length === 1 ? "" : "s"} in ${source} can't be imported, so none were: ${problems
51
+ .slice(0, 5)
52
+ .map((p) => `${p.id} (${p.problem})`)
53
+ .join("; ")}${problems.length > 5 ? "; …" : ""}`,
54
+ );
55
+ this.name = "CaseImportError";
56
+ this.problems = problems;
57
+ }
58
+ }
59
+
60
+ /** What an import did. */
61
+ export type ImportResult = {
62
+ /** Cases created. */
63
+ added: number;
64
+ /** Rows imported before (by source and id), left alone. */
65
+ skipped: number;
66
+ };
67
+
68
+ const sourceRefOf = (source: string, id: string) => `${source}#${id}`;
69
+
70
+ /**
71
+ * Imports existing test data as cases for one suite. Every row is mapped
72
+ * and checked against the suite's input and answer schemas first; if any
73
+ * row fails, nothing is written and `CaseImportError` lists them. Rows
74
+ * imported before (same source and id, retired ones included) are skipped,
75
+ * so it can run again after the file grows.
76
+ *
77
+ * Splits: "DEV" for cases you tune against, "TEST" for a held-out file,
78
+ * "auto" (the default) for new cases, split like cases added by hand.
79
+ *
80
+ * Run it against your development database; an app's own guard should
81
+ * refuse a production one.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const { added, skipped } = await importCases({
86
+ * store,
87
+ * suite: ticketTriage,
88
+ * source: "tickets-heldout.json",
89
+ * rows: JSON.parse(await readFile("tickets-heldout.json", "utf8")),
90
+ * map: (r) => ({ id: r.id, input: { body: r.text }, label: { queue: r.queue } }),
91
+ * split: "TEST",
92
+ * });
93
+ * ```
94
+ */
95
+ export async function importCases<Row>(opts: {
96
+ store: EvalStore;
97
+ suite: AnySuite;
98
+ /** Where the rows came from, such as a file name. */
99
+ source: string;
100
+ rows: readonly Row[];
101
+ map: (row: Row, index: number) => ImportedCase;
102
+ split?: "DEV" | "TEST" | "auto";
103
+ answers?: ImportAnswers;
104
+ }): Promise<ImportResult> {
105
+ const { store, suite, source } = opts;
106
+ const answers = opts.answers ?? { status: "PENDING" as const };
107
+
108
+ // 1. Map and check every row before writing anything.
109
+ const problems: ImportProblem[] = [];
110
+ const seen = new Set<string>();
111
+ const mapped: (ImportedCase & { input: unknown; label: unknown })[] = [];
112
+ opts.rows.forEach((row, i) => {
113
+ let c: ImportedCase;
114
+ try {
115
+ c = opts.map(row, i);
116
+ } catch (e) {
117
+ problems.push({
118
+ id: `row ${i + 1}`,
119
+ problem: e instanceof Error ? e.message : String(e),
120
+ });
121
+ return;
122
+ }
123
+ const id = c.id || `row ${i + 1}`;
124
+ if (!c.id) problems.push({ id, problem: "no id" });
125
+ else if (seen.has(c.id))
126
+ problems.push({ id, problem: "the same id as an earlier row" });
127
+ seen.add(c.id);
128
+ const input = suite.input.safeParse(c.input);
129
+ if (!input.success)
130
+ problems.push({
131
+ id,
132
+ problem: `input: ${input.error.issues[0]?.message ?? "invalid"}`,
133
+ });
134
+ let label: unknown = null;
135
+ if (c.label !== null && c.label !== undefined) {
136
+ const l = suite.labelValue.safeParse(c.label);
137
+ if (!l.success)
138
+ problems.push({
139
+ id,
140
+ problem: `answer: ${l.error.issues[0]?.message ?? "invalid"}`,
141
+ });
142
+ else label = l.data;
143
+ }
144
+ if (input.success) mapped.push({ ...c, input: input.data, label });
145
+ });
146
+ if (problems.length) throw new CaseImportError(source, problems);
147
+
148
+ // 2. Skip what's already there, retired cases included.
149
+ const existing = await store.listCases(suite.key, { includeRetired: true });
150
+ const refs = new Set(existing.map((c) => c.sourceRef));
151
+ const counts = { DEV: 0, TEST: 0 };
152
+ for (const c of existing) if (c.status !== "RETIRED") counts[c.split] += 1;
153
+
154
+ const at =
155
+ answers.status === "PROVISIONAL" ? (answers.at ?? new Date()) : null;
156
+ let added = 0;
157
+ let skipped = 0;
158
+ for (const c of mapped) {
159
+ const sourceRef = sourceRefOf(source, c.id);
160
+ if (refs.has(sourceRef)) {
161
+ skipped += 1;
162
+ continue;
163
+ }
164
+ const split =
165
+ c.split ??
166
+ (opts.split === "DEV" || opts.split === "TEST"
167
+ ? opts.split
168
+ : nextSplit(counts));
169
+ counts[split] += 1;
170
+ const labels: NewLabel[] = [];
171
+ if (c.label !== null)
172
+ labels.push(
173
+ answers.status === "PROVISIONAL"
174
+ ? {
175
+ kind: "EXPECTED",
176
+ status: "PROVISIONAL",
177
+ value: c.label,
178
+ raterRole: "AGENT",
179
+ raterId: answers.rater,
180
+ note: `Drafted by ${answers.rater}; ${answers.allowedBy} allowed testing on it before review (${at?.toISOString().slice(0, 10)}).`,
181
+ }
182
+ : {
183
+ kind: "EXPECTED",
184
+ status: "PENDING",
185
+ value: c.label,
186
+ raterRole: "IMPORT",
187
+ raterId: null,
188
+ note: null,
189
+ },
190
+ );
191
+ const tags = [...(c.tags ?? [])];
192
+ if (answers.status === "PROVISIONAL" && c.label !== null) {
193
+ const tag = answers.tag ?? "needs-review";
194
+ if (!tags.includes(tag)) tags.push(tag);
195
+ }
196
+ const data: NewCase = {
197
+ suite: suite.key,
198
+ input: c.input,
199
+ tags,
200
+ split,
201
+ // In use only with an answer tests may grade against.
202
+ status:
203
+ c.label !== null && answers.status === "PROVISIONAL"
204
+ ? "ACTIVE"
205
+ : "DRAFT",
206
+ source: "IMPORT",
207
+ sourceRef,
208
+ note: c.note ?? null,
209
+ labels,
210
+ };
211
+ await store.createCase(data);
212
+ refs.add(sourceRef);
213
+ added += 1;
214
+ }
215
+ return { added, skipped };
216
+ }
217
+
218
+ /** A short, stable id for a starter case: a hash of its input. */
219
+ function starterId(input: unknown): string {
220
+ // FNV-1a: the core stays free of node:crypto; this only names rows.
221
+ let h = 0x811c9dc5;
222
+ for (const ch of stableJson(input)) {
223
+ h ^= ch.charCodeAt(0);
224
+ h = Math.imul(h, 0x01000193) >>> 0;
225
+ }
226
+ return h.toString(16).padStart(8, "0");
227
+ }
228
+
229
+ /**
230
+ * Adds a suite's `starter` cases as drafts with pending answers, for a
231
+ * person to confirm on the pages. Safe to run again: starters already
232
+ * added (by their input) are skipped.
233
+ */
234
+ export function importStarters(
235
+ store: EvalStore,
236
+ suite: AnySuite,
237
+ ): Promise<ImportResult> {
238
+ return importCases({
239
+ store,
240
+ suite,
241
+ source: `starter:${suite.key}`,
242
+ rows: suite.starter ?? [],
243
+ map: (s) => ({
244
+ id: starterId(s.input),
245
+ input: s.input,
246
+ label: s.label,
247
+ tags: s.tags,
248
+ note: s.note,
249
+ split: s.split,
250
+ }),
251
+ });
252
+ }
package/src/index.ts CHANGED
@@ -31,3 +31,4 @@ export * from "./store.js";
31
31
  export * from "./labels.js";
32
32
  // Server-only (node:crypto): import from "@crowandraven/evalkeeper/runner".
33
33
  // In-memory store for tests and demos: "@crowandraven/evalkeeper/memory".
34
+ export * from "./importCases.js";
package/src/labels.ts CHANGED
@@ -1,11 +1,16 @@
1
- import type { LabelRecord } from "./store.js";
1
+ /** What the answer-in-use rule needs from a label: the store's, or an app's own row. */
2
+ export type LabelLike = { status: string; updatedAt: Date };
2
3
 
3
4
  /**
4
5
  * The EXPECTED answer tests use: the newest confirmed one, or failing that a
5
6
  * provisional one (put to use before review). Pending answers never count.
7
+ * Generic, so pages that read their own label rows (with extra columns)
8
+ * share the rule and keep their row type.
6
9
  */
7
- export function confirmedLabel(labels: LabelRecord[]): LabelRecord | null {
8
- const newest = (status: LabelRecord["status"]) =>
10
+ export function confirmedLabel<L extends LabelLike>(
11
+ labels: readonly L[],
12
+ ): L | null {
13
+ const newest = (status: string) =>
9
14
  labels
10
15
  .filter((l) => l.status === status)
11
16
  .sort((a, b) => +b.updatedAt - +a.updatedAt)[0] ?? null;
@@ -13,6 +18,6 @@ export function confirmedLabel(labels: LabelRecord[]): LabelRecord | null {
13
18
  }
14
19
 
15
20
  /** In use on an answer nobody has reviewed yet. */
16
- export function isProvisional(labels: LabelRecord[]): boolean {
21
+ export function isProvisional(labels: readonly LabelLike[]): boolean {
17
22
  return confirmedLabel(labels)?.status === "PROVISIONAL";
18
23
  }
@@ -163,9 +163,13 @@ export function memoryStore(): EvalStore &
163
163
  f.updatedAt = now();
164
164
  }
165
165
  },
166
- async listCases(suite) {
166
+ async listCases(suite, opts) {
167
167
  return cases
168
- .filter((c) => c.suite === suite && c.status !== "RETIRED")
168
+ .filter(
169
+ (c) =>
170
+ c.suite === suite &&
171
+ (opts?.includeRetired || c.status !== "RETIRED"),
172
+ )
169
173
  .sort((a, b) => +a.createdAt - +b.createdAt);
170
174
  },
171
175
  async createCase({ labels = [], ...data }) {
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
@@ -105,11 +105,18 @@ export type RunnerOptions = {
105
105
  models: string[];
106
106
  fallbackModels?: string[];
107
107
  };
108
+ /**
109
+ * Which retiring models (`ModelInfo.retiring`) the board lists.
110
+ * "always" (the default): every candidate, though a retiring one is never
111
+ * suggested. "inUse": only one a feature still runs on, as its live or
112
+ * fallback model, so nobody spends a test on a model that's going away.
113
+ */
114
+ retiringOnBoard?: "always" | "inUse";
108
115
  };
109
116
 
110
117
  /**
111
118
  * Which model a feature runs on now, its fallback, its reasoning setting,
112
- * and its temperature.
119
+ * its temperature, and its output cap.
113
120
  */
114
121
  export type Route = {
115
122
  model: string;
@@ -117,6 +124,8 @@ export type Route = {
117
124
  reasoningEffort: ReasoningEffort | null;
118
125
  /** Null or unset: no temperature sent. */
119
126
  temperature?: number | null;
127
+ /** The output-token cap; null or unset: the call's own default. */
128
+ maxOutputTokens?: number | null;
120
129
  };
121
130
 
122
131
  /**
@@ -149,6 +158,8 @@ export function requestHashFor(r: {
149
158
  repeat: number;
150
159
  /** Left out of the hash when null, so requests without one reuse as before. */
151
160
  temperature?: number | null;
161
+ /** Left out of the hash when null, like temperature. */
162
+ maxOutputTokens?: number | null;
152
163
  }): string {
153
164
  return sha(
154
165
  stableJson({
@@ -159,6 +170,9 @@ export function requestHashFor(r: {
159
170
  input: r.input,
160
171
  repeat: r.repeat,
161
172
  ...(r.temperature != null ? { temperature: r.temperature } : {}),
173
+ ...(r.maxOutputTokens != null
174
+ ? { maxOutputTokens: r.maxOutputTokens }
175
+ : {}),
162
176
  }),
163
177
  64,
164
178
  );
@@ -267,6 +281,12 @@ export function createRunner(opts: RunnerOptions) {
267
281
  return (await opts.route(operation)).temperature ?? null;
268
282
  }
269
283
 
284
+ /** The operation's live output cap: the route's, else the call's own. */
285
+ async function liveCapFor(operation: string): Promise<number | null> {
286
+ if (!opts.route) return null;
287
+ return (await opts.route(operation)).maxOutputTokens ?? null;
288
+ }
289
+
270
290
  /**
271
291
  * What a test would cost. Answers an identical request gave in the last
272
292
  * 7 days are reused for free, so only the rest are priced: a retest
@@ -285,6 +305,8 @@ export function createRunner(opts: RunnerOptions) {
285
305
  reasoningEffort?: ReasoningEffort | null;
286
306
  /** The temperature to test; undefined: the operation's live one. */
287
307
  temperature?: number | null;
308
+ /** The output cap to test; undefined: the operation's live one. */
309
+ maxOutputTokens?: number | null;
288
310
  } = {},
289
311
  ): Promise<{
290
312
  cases: number;
@@ -295,17 +317,21 @@ export function createRunner(opts: RunnerOptions) {
295
317
  costUsd: number | null;
296
318
  }> {
297
319
  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
- ]);
320
+ const [cases, perCall, prompt, effort, temperature, cap] =
321
+ await Promise.all([
322
+ casesFor(suite.key, set),
323
+ opts.estimateCallCost?.(suite.operation, model) ?? null,
324
+ opts.promptVersion(suite.operation),
325
+ o.reasoningEffort !== undefined
326
+ ? o.reasoningEffort
327
+ : liveEffortFor(suite.operation),
328
+ o.temperature !== undefined
329
+ ? o.temperature
330
+ : liveTemperatureFor(suite.operation),
331
+ o.maxOutputTokens !== undefined
332
+ ? o.maxOutputTokens
333
+ : liveCapFor(suite.operation),
334
+ ]);
309
335
  const answers = cases.length * repeats;
310
336
  const since = new Date(Date.now() - REUSE_DAYS * 86_400_000);
311
337
  const reused = o.fresh
@@ -323,6 +349,7 @@ export function createRunner(opts: RunnerOptions) {
323
349
  input: c.input,
324
350
  repeat: i + 1,
325
351
  temperature,
352
+ maxOutputTokens: cap,
326
353
  }),
327
354
  since,
328
355
  ),
@@ -350,6 +377,8 @@ export function createRunner(opts: RunnerOptions) {
350
377
  reasoningEffort?: ReasoningEffort | null;
351
378
  /** A temperature to test; undefined: the operation's live one. */
352
379
  temperature?: number | null;
380
+ /** An output cap to test; undefined: the operation's live one. */
381
+ maxOutputTokens?: number | null;
353
382
  /** For the estimate: pass the same `fresh` to runEval. */
354
383
  fresh?: boolean;
355
384
  }) {
@@ -364,10 +393,15 @@ export function createRunner(opts: RunnerOptions) {
364
393
  args.temperature !== undefined
365
394
  ? args.temperature
366
395
  : await liveTemperatureFor(suite.operation);
396
+ const maxOutputTokens =
397
+ args.maxOutputTokens !== undefined
398
+ ? args.maxOutputTokens
399
+ : await liveCapFor(suite.operation);
367
400
  const [estimate, promptVersion] = await Promise.all([
368
401
  estimateRun(suite.key, args.model, set, repeats, {
369
402
  reasoningEffort,
370
403
  temperature,
404
+ maxOutputTokens,
371
405
  fresh: Boolean(args.fresh),
372
406
  }),
373
407
  opts.promptVersion(suite.operation),
@@ -380,6 +414,7 @@ export function createRunner(opts: RunnerOptions) {
380
414
  reasoningEffort:
381
415
  args.reasoningEffort !== undefined ? args.reasoningEffort : liveEffort,
382
416
  temperature,
417
+ maxOutputTokens,
383
418
  set,
384
419
  repeats,
385
420
  datasetVersion: null,
@@ -516,6 +551,7 @@ export function createRunner(opts: RunnerOptions) {
516
551
  input: c.input,
517
552
  repeat,
518
553
  temperature: run.temperature ?? null,
554
+ maxOutputTokens: run.maxOutputTokens ?? null,
519
555
  });
520
556
  const earlier = o.fresh
521
557
  ? null
@@ -543,9 +579,9 @@ export function createRunner(opts: RunnerOptions) {
543
579
  const output = await withTimeout(
544
580
  suite.run(suite.input.parse(c.input), {
545
581
  model: run.model,
546
- reasoningEffort:
547
- (run.reasoningEffort as ReasoningEffort | null) ?? null,
582
+ reasoningEffort: run.reasoningEffort ?? null,
548
583
  temperature: run.temperature ?? null,
584
+ maxOutputTokens: run.maxOutputTokens ?? null,
549
585
  onCost: (usd) => (cost += usd),
550
586
  }),
551
587
  timeout,
@@ -693,6 +729,7 @@ export function createRunner(opts: RunnerOptions) {
693
729
  startedBy: SCHEDULED,
694
730
  reasoningEffort: route.reasoningEffort,
695
731
  temperature: route.temperature ?? null,
732
+ maxOutputTokens: route.maxOutputTokens ?? null,
696
733
  });
697
734
  return run.id;
698
735
  }
@@ -801,7 +838,7 @@ export function createRunner(opts: RunnerOptions) {
801
838
  // The setting a model actually gets: models without reasoning settings
802
839
  // ignore it, so their rows don't split by it.
803
840
  const realEffort = (model: string, effort: string | null) =>
804
- effortFor(opts.models, model, effort as ReasoningEffort | null) ?? null;
841
+ effortFor(opts.models, model, effort) ?? null;
805
842
  const keyOf = (model: string, effort: string | null) =>
806
843
  `${model}|${realEffort(model, effort) ?? ""}`;
807
844
  const runKey = (r: RunRecord) => keyOf(r.model, r.reasoningEffort);
@@ -815,11 +852,14 @@ export function createRunner(opts: RunnerOptions) {
815
852
  latestDone.set(runKey(r), r);
816
853
  }
817
854
  const liveTemperature = route.temperature ?? null;
818
- // A test at another temperature than the live one answered differently.
855
+ const liveCap = route.maxOutputTokens ?? null;
856
+ // A test at another temperature or output cap than the live ones
857
+ // answered differently.
819
858
  const isCurrent = (r: RunRecord) =>
820
859
  r.datasetVersion === current &&
821
860
  r.promptVersion === prompt &&
822
- (r.temperature ?? null) === liveTemperature;
861
+ (r.temperature ?? null) === liveTemperature &&
862
+ (r.maxOutputTokens ?? null) === liveCap;
823
863
 
824
864
  const liveKey = keyOf(route.model, defaultEffort);
825
865
  const liveRun = latestDone.get(liveKey);
@@ -829,7 +869,8 @@ export function createRunner(opts: RunnerOptions) {
829
869
  liveRun !== undefined &&
830
870
  r.datasetVersion === liveRun.datasetVersion &&
831
871
  r.promptVersion === liveRun.promptVersion &&
832
- (r.temperature ?? null) === (liveRun.temperature ?? null);
872
+ (r.temperature ?? null) === (liveRun.temperature ?? null) &&
873
+ (r.maxOutputTokens ?? null) === (liveRun.maxOutputTokens ?? null);
833
874
  const outputs = new Map<string, RunOutput[]>();
834
875
  for (const r of latestDone.values())
835
876
  outputs.set(
@@ -846,7 +887,7 @@ export function createRunner(opts: RunnerOptions) {
846
887
  const checkKeys = suite.checks.map((c) => c.key);
847
888
 
848
889
  const seen = new Set<string>();
849
- const keys = [
890
+ let keys = [
850
891
  ...models.map((m) => ({
851
892
  model: m,
852
893
  effort: defaultEffort,
@@ -865,6 +906,13 @@ export function createRunner(opts: RunnerOptions) {
865
906
  seen.add(k);
866
907
  return true;
867
908
  });
909
+ // A retiring model stays on the board only while a feature runs on it.
910
+ const inUse = (model: string) =>
911
+ model === route.model || model === route.fallbackModel;
912
+ if (opts.retiringOnBoard === "inUse")
913
+ keys = keys.filter(
914
+ ({ model }) => !opts.models[model]?.retiring || inUse(model),
915
+ );
868
916
  const complete = (r: RunRecord | undefined) => !(r?.metrics?.failed ?? 0);
869
917
  const estimates = new Map(
870
918
  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/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>;
package/src/testing.ts CHANGED
@@ -90,6 +90,10 @@ export function describeStoreContract(
90
90
  expect((await store.listCases("s")).map((c) => c.id)).toEqual([
91
91
  second.id,
92
92
  ]);
93
+ // On request, retired ones too, still oldest first.
94
+ expect(
95
+ (await store.listCases("s", { includeRetired: true })).map((c) => c.id),
96
+ ).toEqual([first.id, second.id]);
93
97
  });
94
98
 
95
99
  it("adds answers without overwriting, and changes their status", async () => {
@@ -135,6 +139,20 @@ export function describeStoreContract(
135
139
  expect((await store.getRun(none.id))?.temperature ?? null).toBeNull();
136
140
  });
137
141
 
142
+ it("keeps the output cap a run was tested at", async () => {
143
+ const store = await make();
144
+ const capped = await store.createRun({
145
+ ...newRun("s"),
146
+ maxOutputTokens: 4000,
147
+ });
148
+ const none = await store.createRun({
149
+ ...newRun("s"),
150
+ maxOutputTokens: null,
151
+ });
152
+ expect((await store.getRun(capped.id))?.maxOutputTokens).toBe(4000);
153
+ expect((await store.getRun(none.id))?.maxOutputTokens ?? null).toBeNull();
154
+ });
155
+
138
156
  it("updates a run only while it has the status it's guarded by", async () => {
139
157
  const store = await make();
140
158
  const r = await store.createRun(newRun("s"));
@@ -389,6 +407,21 @@ export function describeSettingsStoreContract(
389
407
  expect(await store.listVersions("triage")).toHaveLength(2);
390
408
  });
391
409
 
410
+ it("keeps a version's reasoning setting as named, even a newer one", async () => {
411
+ const store = await make();
412
+ const v1 = await store.switchTo("triage", {
413
+ ...create("a", null),
414
+ create: {
415
+ settings: { ...settings("a"), reasoningEffort: "xhigh" },
416
+ promptVersion: "p1",
417
+ },
418
+ });
419
+ expect(v1.reasoningEffort).toBe("xhigh");
420
+ expect((await store.liveVersion("triage"))?.reasoningEffort).toBe(
421
+ "xhigh",
422
+ );
423
+ });
424
+
392
425
  it("keeps a version's temperature", async () => {
393
426
  const store = await make();
394
427
  const v1 = await store.switchTo("triage", {