@crowandraven/evalkeeper 0.1.1 → 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.
Files changed (58) hide show
  1. package/README.md +2 -2
  2. package/dist/attention.js +2 -1
  3. package/dist/attention.js.map +1 -1
  4. package/dist/board.d.ts +2 -0
  5. package/dist/glossary.d.ts +1 -1
  6. package/dist/glossary.js +1 -1
  7. package/dist/glossary.js.map +1 -1
  8. package/dist/health.js +1 -1
  9. package/dist/health.js.map +1 -1
  10. package/dist/importCases.d.ts +94 -0
  11. package/dist/importCases.js +185 -0
  12. package/dist/importCases.js.map +1 -0
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/labels.d.ts +9 -3
  17. package/dist/labels.js +2 -0
  18. package/dist/labels.js.map +1 -1
  19. package/dist/memoryStore.js +3 -2
  20. package/dist/memoryStore.js.map +1 -1
  21. package/dist/models.d.ts +44 -7
  22. package/dist/models.js +48 -7
  23. package/dist/models.js.map +1 -1
  24. package/dist/runner.d.ts +25 -1
  25. package/dist/runner.js +64 -7
  26. package/dist/runner.js.map +1 -1
  27. package/dist/settings.d.ts +42 -3
  28. package/dist/settings.js +101 -11
  29. package/dist/settings.js.map +1 -1
  30. package/dist/store.d.ts +18 -2
  31. package/dist/suggest.js +4 -2
  32. package/dist/suggest.js.map +1 -1
  33. package/dist/testing.js +48 -0
  34. package/dist/testing.js.map +1 -1
  35. package/dist/types.d.ts +11 -0
  36. package/dist/types.js.map +1 -1
  37. package/dist/weekly.js +5 -2
  38. package/dist/weekly.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/attention.ts +2 -1
  41. package/src/board.ts +2 -0
  42. package/src/glossary.ts +1 -1
  43. package/src/health.ts +3 -1
  44. package/src/importCases.ts +252 -0
  45. package/src/index.ts +1 -0
  46. package/src/labels.ts +9 -4
  47. package/src/memoryStore.ts +6 -2
  48. package/src/models.ts +83 -10
  49. package/src/runner.ts +102 -15
  50. package/src/settings.ts +125 -14
  51. package/src/store.ts +19 -2
  52. package/src/suggest.ts +3 -1
  53. package/src/testing.ts +56 -0
  54. package/src/types.ts +11 -0
  55. package/src/weekly.ts +5 -2
  56. package/templates/app/evals/[suite]/page.tsx +1 -2
  57. package/templates/app/evals/feedback/page.tsx +11 -6
  58. package/templates/app/evals/page.tsx +40 -0
package/src/runner.ts CHANGED
@@ -9,6 +9,7 @@ import { compareRuns, type RunOutput } from "./compare.js";
9
9
  import { isStuck, shouldContinue } from "./legs.js";
10
10
  import {
11
11
  effortFor,
12
+ isBaseline,
12
13
  type ModelCatalog,
13
14
  type ReasoningEffort,
14
15
  } from "./models.js";
@@ -104,15 +105,27 @@ export type RunnerOptions = {
104
105
  models: string[];
105
106
  fallbackModels?: string[];
106
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";
107
115
  };
108
116
 
109
117
  /**
110
- * Which model a feature runs on now, its fallback, and its reasoning setting.
118
+ * Which model a feature runs on now, its fallback, its reasoning setting,
119
+ * its temperature, and its output cap.
111
120
  */
112
121
  export type Route = {
113
122
  model: string;
114
123
  fallbackModel: string | null;
115
124
  reasoningEffort: ReasoningEffort | null;
125
+ /** Null or unset: no temperature sent. */
126
+ temperature?: number | null;
127
+ /** The output-token cap; null or unset: the call's own default. */
128
+ maxOutputTokens?: number | null;
116
129
  };
117
130
 
118
131
  /**
@@ -143,6 +156,10 @@ export function requestHashFor(r: {
143
156
  prompt: string;
144
157
  input: unknown;
145
158
  repeat: number;
159
+ /** Left out of the hash when null, so requests without one reuse as before. */
160
+ temperature?: number | null;
161
+ /** Left out of the hash when null, like temperature. */
162
+ maxOutputTokens?: number | null;
146
163
  }): string {
147
164
  return sha(
148
165
  stableJson({
@@ -152,6 +169,10 @@ export function requestHashFor(r: {
152
169
  prompt: r.prompt,
153
170
  input: r.input,
154
171
  repeat: r.repeat,
172
+ ...(r.temperature != null ? { temperature: r.temperature } : {}),
173
+ ...(r.maxOutputTokens != null
174
+ ? { maxOutputTokens: r.maxOutputTokens }
175
+ : {}),
155
176
  }),
156
177
  64,
157
178
  );
@@ -254,6 +275,18 @@ export function createRunner(opts: RunnerOptions) {
254
275
  return null;
255
276
  }
256
277
 
278
+ /** The operation's live temperature: the route's, else none. */
279
+ async function liveTemperatureFor(operation: string): Promise<number | null> {
280
+ if (!opts.route) return null;
281
+ return (await opts.route(operation)).temperature ?? null;
282
+ }
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
+
257
290
  /**
258
291
  * What a test would cost. Answers an identical request gave in the last
259
292
  * 7 days are reused for free, so only the rest are priced: a retest
@@ -270,6 +303,10 @@ export function createRunner(opts: RunnerOptions) {
270
303
  fresh?: boolean;
271
304
  /** The setting to test; undefined: the operation's live one. */
272
305
  reasoningEffort?: ReasoningEffort | null;
306
+ /** The temperature to test; undefined: the operation's live one. */
307
+ temperature?: number | null;
308
+ /** The output cap to test; undefined: the operation's live one. */
309
+ maxOutputTokens?: number | null;
273
310
  } = {},
274
311
  ): Promise<{
275
312
  cases: number;
@@ -280,14 +317,21 @@ export function createRunner(opts: RunnerOptions) {
280
317
  costUsd: number | null;
281
318
  }> {
282
319
  const suite = suiteOrThrow(suiteKey);
283
- const [cases, perCall, prompt, effort] = await Promise.all([
284
- casesFor(suite.key, set),
285
- opts.estimateCallCost?.(suite.operation, model) ?? null,
286
- opts.promptVersion(suite.operation),
287
- o.reasoningEffort !== undefined
288
- ? o.reasoningEffort
289
- : liveEffortFor(suite.operation),
290
- ]);
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
+ ]);
291
335
  const answers = cases.length * repeats;
292
336
  const since = new Date(Date.now() - REUSE_DAYS * 86_400_000);
293
337
  const reused = o.fresh
@@ -304,6 +348,8 @@ export function createRunner(opts: RunnerOptions) {
304
348
  prompt,
305
349
  input: c.input,
306
350
  repeat: i + 1,
351
+ temperature,
352
+ maxOutputTokens: cap,
307
353
  }),
308
354
  since,
309
355
  ),
@@ -329,6 +375,10 @@ export function createRunner(opts: RunnerOptions) {
329
375
  startedBy?: string | null;
330
376
  /** A reasoning setting to test; undefined: the operation's own. */
331
377
  reasoningEffort?: ReasoningEffort | null;
378
+ /** A temperature to test; undefined: the operation's live one. */
379
+ temperature?: number | null;
380
+ /** An output cap to test; undefined: the operation's live one. */
381
+ maxOutputTokens?: number | null;
332
382
  /** For the estimate: pass the same `fresh` to runEval. */
333
383
  fresh?: boolean;
334
384
  }) {
@@ -339,9 +389,19 @@ export function createRunner(opts: RunnerOptions) {
339
389
  args.reasoningEffort !== undefined
340
390
  ? args.reasoningEffort
341
391
  : await liveEffortFor(suite.operation);
392
+ const temperature =
393
+ args.temperature !== undefined
394
+ ? args.temperature
395
+ : await liveTemperatureFor(suite.operation);
396
+ const maxOutputTokens =
397
+ args.maxOutputTokens !== undefined
398
+ ? args.maxOutputTokens
399
+ : await liveCapFor(suite.operation);
342
400
  const [estimate, promptVersion] = await Promise.all([
343
401
  estimateRun(suite.key, args.model, set, repeats, {
344
402
  reasoningEffort,
403
+ temperature,
404
+ maxOutputTokens,
345
405
  fresh: Boolean(args.fresh),
346
406
  }),
347
407
  opts.promptVersion(suite.operation),
@@ -353,6 +413,8 @@ export function createRunner(opts: RunnerOptions) {
353
413
  promptVersion,
354
414
  reasoningEffort:
355
415
  args.reasoningEffort !== undefined ? args.reasoningEffort : liveEffort,
416
+ temperature,
417
+ maxOutputTokens,
356
418
  set,
357
419
  repeats,
358
420
  datasetVersion: null,
@@ -488,6 +550,8 @@ export function createRunner(opts: RunnerOptions) {
488
550
  prompt: run.promptVersion,
489
551
  input: c.input,
490
552
  repeat,
553
+ temperature: run.temperature ?? null,
554
+ maxOutputTokens: run.maxOutputTokens ?? null,
491
555
  });
492
556
  const earlier = o.fresh
493
557
  ? null
@@ -515,8 +579,9 @@ export function createRunner(opts: RunnerOptions) {
515
579
  const output = await withTimeout(
516
580
  suite.run(suite.input.parse(c.input), {
517
581
  model: run.model,
518
- reasoningEffort:
519
- (run.reasoningEffort as ReasoningEffort | null) ?? null,
582
+ reasoningEffort: run.reasoningEffort ?? null,
583
+ temperature: run.temperature ?? null,
584
+ maxOutputTokens: run.maxOutputTokens ?? null,
520
585
  onCost: (usd) => (cost += usd),
521
586
  }),
522
587
  timeout,
@@ -663,6 +728,8 @@ export function createRunner(opts: RunnerOptions) {
663
728
  set: "SMOKE",
664
729
  startedBy: SCHEDULED,
665
730
  reasoningEffort: route.reasoningEffort,
731
+ temperature: route.temperature ?? null,
732
+ maxOutputTokens: route.maxOutputTokens ?? null,
666
733
  });
667
734
  return run.id;
668
735
  }
@@ -771,7 +838,7 @@ export function createRunner(opts: RunnerOptions) {
771
838
  // The setting a model actually gets: models without reasoning settings
772
839
  // ignore it, so their rows don't split by it.
773
840
  const realEffort = (model: string, effort: string | null) =>
774
- effortFor(opts.models, model, effort as ReasoningEffort | null) ?? null;
841
+ effortFor(opts.models, model, effort) ?? null;
775
842
  const keyOf = (model: string, effort: string | null) =>
776
843
  `${model}|${realEffort(model, effort) ?? ""}`;
777
844
  const runKey = (r: RunRecord) => keyOf(r.model, r.reasoningEffort);
@@ -784,8 +851,15 @@ export function createRunner(opts: RunnerOptions) {
784
851
  if (r.status === "SUCCEEDED" && !latestDone.has(runKey(r)))
785
852
  latestDone.set(runKey(r), r);
786
853
  }
854
+ const liveTemperature = route.temperature ?? null;
855
+ const liveCap = route.maxOutputTokens ?? null;
856
+ // A test at another temperature or output cap than the live ones
857
+ // answered differently.
787
858
  const isCurrent = (r: RunRecord) =>
788
- r.datasetVersion === current && r.promptVersion === prompt;
859
+ r.datasetVersion === current &&
860
+ r.promptVersion === prompt &&
861
+ (r.temperature ?? null) === liveTemperature &&
862
+ (r.maxOutputTokens ?? null) === liveCap;
789
863
 
790
864
  const liveKey = keyOf(route.model, defaultEffort);
791
865
  const liveRun = latestDone.get(liveKey);
@@ -794,7 +868,9 @@ export function createRunner(opts: RunnerOptions) {
794
868
  const sameAsLive = (r: RunRecord) =>
795
869
  liveRun !== undefined &&
796
870
  r.datasetVersion === liveRun.datasetVersion &&
797
- r.promptVersion === liveRun.promptVersion;
871
+ r.promptVersion === liveRun.promptVersion &&
872
+ (r.temperature ?? null) === (liveRun.temperature ?? null) &&
873
+ (r.maxOutputTokens ?? null) === (liveRun.maxOutputTokens ?? null);
798
874
  const outputs = new Map<string, RunOutput[]>();
799
875
  for (const r of latestDone.values())
800
876
  outputs.set(
@@ -811,7 +887,7 @@ export function createRunner(opts: RunnerOptions) {
811
887
  const checkKeys = suite.checks.map((c) => c.key);
812
888
 
813
889
  const seen = new Set<string>();
814
- const keys = [
890
+ let keys = [
815
891
  ...models.map((m) => ({
816
892
  model: m,
817
893
  effort: defaultEffort,
@@ -830,6 +906,13 @@ export function createRunner(opts: RunnerOptions) {
830
906
  seen.add(k);
831
907
  return true;
832
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
+ );
833
916
  const complete = (r: RunRecord | undefined) => !(r?.metrics?.failed ?? 0);
834
917
  const estimates = new Map(
835
918
  await Promise.all(
@@ -863,6 +946,7 @@ export function createRunner(opts: RunnerOptions) {
863
946
  .verdict
864
947
  : null;
865
948
  const label = opts.models[model]?.label ?? model;
949
+ const baseline = isBaseline(opts.models, model);
866
950
  return {
867
951
  key,
868
952
  model,
@@ -873,6 +957,7 @@ export function createRunner(opts: RunnerOptions) {
873
957
  : label,
874
958
  live: key === liveKey,
875
959
  fallback: !variant && model === route.fallbackModel,
960
+ baseline,
876
961
  testing: testing.has(key),
877
962
  failed:
878
963
  lastRun?.status === "FAILED"
@@ -900,6 +985,7 @@ export function createRunner(opts: RunnerOptions) {
900
985
  vsLive,
901
986
  // Only on up-to-date tests: never promote on an old answer key.
902
987
  canMakeLive:
988
+ !baseline &&
903
989
  models.includes(model) &&
904
990
  fresh &&
905
991
  complete(r) &&
@@ -907,6 +993,7 @@ export function createRunner(opts: RunnerOptions) {
907
993
  liveOnly: !fallbackModels.includes(model),
908
994
  fallbackOnly: !models.includes(model),
909
995
  canMakeFallback:
996
+ !baseline &&
910
997
  fallbackModels.includes(model) &&
911
998
  !variant &&
912
999
  model !== route.model &&
package/src/settings.ts CHANGED
@@ -12,7 +12,8 @@ import type { Route } from "./runner.js";
12
12
  */
13
13
 
14
14
  /**
15
- * A feature's model settings: live model, fallback, reasoning setting, and output limit.
15
+ * A feature's model settings: live model, fallback, reasoning setting,
16
+ * output limit, and temperature.
16
17
  */
17
18
  export type Settings = {
18
19
  model: string;
@@ -21,6 +22,12 @@ export type Settings = {
21
22
  reasoningEffort: ReasoningEffort | null;
22
23
  /** Null: the call's own default. */
23
24
  maxOutputTokens: number | null;
25
+ /**
26
+ * The sampling temperature the feature sends. Null or unset: none sent,
27
+ * so the model's own default. Tests record it, and a test at another
28
+ * temperature than the live one is out of date.
29
+ */
30
+ temperature?: number | null;
24
31
  };
25
32
 
26
33
  /**
@@ -106,13 +113,31 @@ export class SettingsConflict extends Error {
106
113
  }
107
114
  }
108
115
 
116
+ /**
117
+ * A feature has no settings: no version yet, and no default in code. Apps
118
+ * whose models are chosen in an admin page, not in code, see this until
119
+ * the feature's first version is recorded (`seed` or `change`).
120
+ */
121
+ export class NoSettingsError extends Error {
122
+ /** The feature with no settings. */
123
+ readonly operation: string;
124
+ constructor(operation: string) {
125
+ super(
126
+ `${operation} has no settings yet: no version, and no default in code.`,
127
+ );
128
+ this.name = "NoSettingsError";
129
+ this.operation = operation;
130
+ }
131
+ }
132
+
109
133
  /** Whether two settings are the same in every field. */
110
134
  export function sameSettings(a: Settings, b: Settings): boolean {
111
135
  return (
112
136
  a.model === b.model &&
113
137
  a.fallbackModel === b.fallbackModel &&
114
138
  a.reasoningEffort === b.reasoningEffort &&
115
- a.maxOutputTokens === b.maxOutputTokens
139
+ a.maxOutputTokens === b.maxOutputTokens &&
140
+ (a.temperature ?? null) === (b.temperature ?? null)
116
141
  );
117
142
  }
118
143
 
@@ -123,6 +148,7 @@ export function settingsOf(v: Settings): Settings {
123
148
  fallbackModel: v.fallbackModel,
124
149
  reasoningEffort: v.reasoningEffort,
125
150
  maxOutputTokens: v.maxOutputTokens,
151
+ temperature: v.temperature ?? null,
126
152
  };
127
153
  }
128
154
 
@@ -148,6 +174,10 @@ export function describeChange(
148
174
  out.push(
149
175
  `Output limit: ${from.maxOutputTokens ?? "default"} → ${to.maxOutputTokens ?? "default"}`,
150
176
  );
177
+ if ((from.temperature ?? null) !== (to.temperature ?? null))
178
+ out.push(
179
+ `Temperature: ${from.temperature ?? "default"} → ${to.temperature ?? "default"}`,
180
+ );
151
181
  return out;
152
182
  }
153
183
 
@@ -175,11 +205,28 @@ export type HistoryEntry = SettingsChange & {
175
205
  * });
176
206
  * await settings.rollBack("ticket_triage", versionId, { by: admin.name, reason: "Worse in production" });
177
207
  * ```
208
+ *
209
+ * When models are chosen in an admin page rather than in code, return null
210
+ * from `defaults` and record each feature's existing config as version 1
211
+ * with `seed`:
212
+ *
213
+ * ```ts
214
+ * const settings = createSettings({ store, defaults: () => null });
215
+ * for (const row of await db.aiConfig.findMany())
216
+ * await settings.seed(row.operation, settingsFromRow(row), {
217
+ * by: null,
218
+ * reason: "Imported from the admin config",
219
+ * });
220
+ * ```
178
221
  */
179
222
  export function createSettings(opts: {
180
223
  store: SettingsStore;
181
- /** Each feature's settings in code, used until it's first changed. */
182
- defaults: (operation: string) => Settings;
224
+ /**
225
+ * Each feature's settings in code, used until it's first changed. Null:
226
+ * the feature has no default, and reading its settings throws
227
+ * `NoSettingsError` until a version is recorded.
228
+ */
229
+ defaults: (operation: string) => Settings | null;
183
230
  /** The instructions' current hash, so a version records what it ran. */
184
231
  promptVersion?: (operation: string) => string | Promise<string>;
185
232
  }) {
@@ -187,32 +234,38 @@ export function createSettings(opts: {
187
234
  const prompt = async (op: string) =>
188
235
  opts.promptVersion ? await opts.promptVersion(op) : null;
189
236
 
190
- /** The live settings: the live version's, else the default from code. */
237
+ /**
238
+ * The live settings: the live version's, else the default from code.
239
+ * Throws `NoSettingsError` when the feature has neither.
240
+ */
191
241
  async function current(
192
242
  operation: string,
193
243
  ): Promise<{ settings: Settings; version: SettingsVersion | null }> {
194
244
  const version = await store.liveVersion(operation);
195
- return {
196
- settings: version ? settingsOf(version) : opts.defaults(operation),
197
- version,
198
- };
245
+ if (version) return { settings: settingsOf(version), version };
246
+ const fallback = opts.defaults(operation);
247
+ if (!fallback) throw new NoSettingsError(operation);
248
+ return { settings: fallback, version };
199
249
  }
200
250
 
201
- /** The runner's route for a feature, from its live settings. */
251
+ /** The runner's route for a feature, from its live settings; throws `NoSettingsError` when it has none. */
202
252
  async function route(operation: string): Promise<Route> {
203
253
  const { settings } = await current(operation);
204
254
  return {
205
255
  model: settings.model,
206
256
  fallbackModel: settings.fallbackModel,
207
257
  reasoningEffort: settings.reasoningEffort,
258
+ temperature: settings.temperature ?? null,
259
+ maxOutputTokens: settings.maxOutputTokens,
208
260
  };
209
261
  }
210
262
 
211
263
  /**
212
264
  * Makes new settings live as a new version. The first change to a
213
265
  * feature first records its default as version 1, so there's always
214
- * something to roll back to. With `evidence`, it's a promotion. Returns
215
- * null when nothing changed.
266
+ * something to roll back to; a feature with no default gets `next` as
267
+ * version 1. With `evidence`, it's a promotion. Returns null when
268
+ * nothing changed.
216
269
  */
217
270
  async function change(
218
271
  operation: string,
@@ -223,6 +276,15 @@ export function createSettings(opts: {
223
276
  let live = await store.liveVersion(operation);
224
277
  if (!live) {
225
278
  const initial = opts.defaults(operation);
279
+ if (!initial)
280
+ return store.switchTo(operation, {
281
+ create: { settings: settingsOf(next), promptVersion },
282
+ kind: by.evidence === undefined ? "CHANGE" : "PROMOTE",
283
+ reason: by.reason,
284
+ by: by.by,
285
+ evidence: by.evidence,
286
+ expectLive: null,
287
+ });
226
288
  if (sameSettings(initial, next)) return null;
227
289
  live = await store.switchTo(operation, {
228
290
  create: { settings: initial, promptVersion },
@@ -242,7 +304,11 @@ export function createSettings(opts: {
242
304
  });
243
305
  }
244
306
 
245
- /** Changes only some settings (e.g. the fallback), keeping the rest. */
307
+ /**
308
+ * Changes only some settings (e.g. the fallback), keeping the rest.
309
+ * Throws `NoSettingsError` for a feature with no settings to keep: use
310
+ * `change` or `seed` with full settings first.
311
+ */
246
312
  async function update(
247
313
  operation: string,
248
314
  patch: Partial<Settings>,
@@ -301,6 +367,42 @@ export function createSettings(opts: {
301
367
  }
302
368
  }
303
369
 
370
+ /**
371
+ * Records existing settings as a feature's version 1, for an app moving
372
+ * its model choices into versioned settings (an import from its own
373
+ * config table). Only when the feature has no version yet: otherwise it
374
+ * changes nothing and returns null, so an import can run again safely.
375
+ * `promptVersion` defaults to the instructions' current hash.
376
+ */
377
+ async function seed(
378
+ operation: string,
379
+ initial: Settings,
380
+ by: {
381
+ by: string | null;
382
+ reason: string | null;
383
+ promptVersion?: string | null;
384
+ },
385
+ ): Promise<SettingsVersion | null> {
386
+ if (await store.liveVersion(operation)) return null;
387
+ const promptVersion =
388
+ by.promptVersion !== undefined
389
+ ? by.promptVersion
390
+ : await prompt(operation);
391
+ try {
392
+ return await store.switchTo(operation, {
393
+ create: { settings: settingsOf(initial), promptVersion },
394
+ kind: "CHANGE",
395
+ reason: by.reason,
396
+ by: by.by,
397
+ expectLive: null,
398
+ });
399
+ } catch (e) {
400
+ // Another request seeded it first: that version stands.
401
+ if (e instanceof SettingsConflict) return null;
402
+ throw e;
403
+ }
404
+ }
405
+
304
406
  /** Every switch, newest first, with the versions it went from and to. */
305
407
  async function history(
306
408
  operation: string,
@@ -324,7 +426,16 @@ export function createSettings(opts: {
324
426
  });
325
427
  }
326
428
 
327
- return { current, route, change, update, rollBack, syncPrompt, history };
429
+ return {
430
+ current,
431
+ route,
432
+ change,
433
+ update,
434
+ rollBack,
435
+ syncPrompt,
436
+ seed,
437
+ history,
438
+ };
328
439
  }
329
440
 
330
441
  /** What createSettings returns. */
package/src/store.ts CHANGED
@@ -77,6 +77,16 @@ export type RunRecord = {
77
77
  promptVersion: string;
78
78
  /** The reasoning setting tested; null: the model's own default. */
79
79
  reasoningEffort: string | null;
80
+ /**
81
+ * The temperature tested; null or unset: none sent. Stores keep it, so a
82
+ * test at another temperature than the live one shows as out of date.
83
+ */
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;
80
90
  set: EvalSet;
81
91
  repeats: number;
82
92
  /** Changes when the cases or their answers do, so tests compare. */
@@ -134,8 +144,15 @@ export type NewCase = Omit<
134
144
  * database, and run describeStoreContract against it.
135
145
  */
136
146
  export interface EvalStore {
137
- /** A suite's cases that aren't retired, with their answers, oldest first. */
138
- 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[]>;
139
156
  createCase(data: NewCase): Promise<CaseRecord>;
140
157
  /** Adds an answer to a case (answers are never overwritten). */
141
158
  addLabel(caseId: string, label: NewLabel): Promise<LabelRecord>;
package/src/suggest.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { retiresLabel, type ModelCatalog } from "./models.js";
1
+ import { isBaseline, retiresLabel, type ModelCatalog } from "./models.js";
2
2
  import type { Rate } from "./stats.js";
3
3
  import type { CheckDef, SuitePriority } from "./types.js";
4
4
  import type { Verdict } from "./compare.js";
@@ -109,8 +109,10 @@ export function suggest<R extends SuggestRow>(
109
109
  const retiring = (r: SuggestRow) => retiresLabel(models, r.model);
110
110
  const limitMs = priority.maxSeconds ? priority.maxSeconds * 1000 : Infinity;
111
111
  const tooSlow = (r: SuggestRow) => ms(r) !== Infinity && ms(r) > limitMs;
112
+ // Baselines are code, compared on the board but never suggested.
112
113
  const tested = rows.filter(
113
114
  (r) =>
115
+ !isBaseline(models, r.model) &&
114
116
  r.test &&
115
117
  !r.test.stale &&
116
118
  !r.test.failedAnswers &&
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 () => {
@@ -127,6 +131,28 @@ export function describeStoreContract(
127
131
  expect(await store.getRun("missing")).toBeNull();
128
132
  });
129
133
 
134
+ it("keeps the temperature a run was tested at", async () => {
135
+ const store = await make();
136
+ const warm = await store.createRun({ ...newRun("s"), temperature: 0.7 });
137
+ const none = await store.createRun({ ...newRun("s"), temperature: null });
138
+ expect((await store.getRun(warm.id))?.temperature).toBe(0.7);
139
+ expect((await store.getRun(none.id))?.temperature ?? null).toBeNull();
140
+ });
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
+
130
156
  it("updates a run only while it has the status it's guarded by", async () => {
131
157
  const store = await make();
132
158
  const r = await store.createRun(newRun("s"));
@@ -381,6 +407,36 @@ export function describeSettingsStoreContract(
381
407
  expect(await store.listVersions("triage")).toHaveLength(2);
382
408
  });
383
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
+
425
+ it("keeps a version's temperature", async () => {
426
+ const store = await make();
427
+ const v1 = await store.switchTo("triage", {
428
+ ...create("a", null),
429
+ create: {
430
+ settings: { ...settings("a"), temperature: 0.4 },
431
+ promptVersion: "p1",
432
+ },
433
+ });
434
+ expect(v1.temperature).toBe(0.4);
435
+ expect((await store.liveVersion("triage"))?.temperature).toBe(0.4);
436
+ const v2 = await store.switchTo("triage", create("b", v1.id));
437
+ expect(v2.temperature ?? null).toBeNull();
438
+ });
439
+
384
440
  it("keeps each feature's versions and live one apart", async () => {
385
441
  const store = await make();
386
442
  await store.switchTo("triage", create("a", null));
package/src/types.ts CHANGED
@@ -108,6 +108,17 @@ export type Suite<Input, Output, Label> = {
108
108
  model: string;
109
109
  /** A reasoning setting to test; undefined: the operation's own. */
110
110
  reasoningEffort?: ReasoningEffort | null;
111
+ /**
112
+ * The temperature the test runs at: the operation's live one when the
113
+ * test was started. Null: send none. Undefined from runners before
114
+ * temperature was versioned.
115
+ */
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;
111
122
  onCost: (usd: number) => void;
112
123
  },
113
124
  ): Promise<Output>;
package/src/weekly.ts CHANGED
@@ -104,13 +104,16 @@ export function weeklySummary(args: {
104
104
  continue;
105
105
  }
106
106
  if (r.status !== "SUCCEEDED") continue;
107
- // The last check of the same model, instructions, and grading, a day or
108
- // more earlier: anything else changed for a known reason.
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
+ // reason.
109
110
  const prior = scheduled.find(
110
111
  (p) =>
111
112
  p.suite === r.suite &&
112
113
  p.status === "SUCCEEDED" &&
113
114
  p.model === r.model &&
115
+ (p.temperature ?? null) === (r.temperature ?? null) &&
116
+ (p.maxOutputTokens ?? null) === (r.maxOutputTokens ?? null) &&
114
117
  p.promptVersion === r.promptVersion &&
115
118
  p.graderVersion === r.graderVersion &&
116
119
  +r.startedAt - +p.startedAt >= DAY,