@crowandraven/evalkeeper 0.1.1 → 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.
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";
@@ -107,12 +108,15 @@ export type RunnerOptions = {
107
108
  };
108
109
 
109
110
  /**
110
- * Which model a feature runs on now, its fallback, and its reasoning setting.
111
+ * Which model a feature runs on now, its fallback, its reasoning setting,
112
+ * and its temperature.
111
113
  */
112
114
  export type Route = {
113
115
  model: string;
114
116
  fallbackModel: string | null;
115
117
  reasoningEffort: ReasoningEffort | null;
118
+ /** Null or unset: no temperature sent. */
119
+ temperature?: number | null;
116
120
  };
117
121
 
118
122
  /**
@@ -143,6 +147,8 @@ export function requestHashFor(r: {
143
147
  prompt: string;
144
148
  input: unknown;
145
149
  repeat: number;
150
+ /** Left out of the hash when null, so requests without one reuse as before. */
151
+ temperature?: number | null;
146
152
  }): string {
147
153
  return sha(
148
154
  stableJson({
@@ -152,6 +158,7 @@ export function requestHashFor(r: {
152
158
  prompt: r.prompt,
153
159
  input: r.input,
154
160
  repeat: r.repeat,
161
+ ...(r.temperature != null ? { temperature: r.temperature } : {}),
155
162
  }),
156
163
  64,
157
164
  );
@@ -254,6 +261,12 @@ export function createRunner(opts: RunnerOptions) {
254
261
  return null;
255
262
  }
256
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
+
257
270
  /**
258
271
  * What a test would cost. Answers an identical request gave in the last
259
272
  * 7 days are reused for free, so only the rest are priced: a retest
@@ -270,6 +283,8 @@ export function createRunner(opts: RunnerOptions) {
270
283
  fresh?: boolean;
271
284
  /** The setting to test; undefined: the operation's live one. */
272
285
  reasoningEffort?: ReasoningEffort | null;
286
+ /** The temperature to test; undefined: the operation's live one. */
287
+ temperature?: number | null;
273
288
  } = {},
274
289
  ): Promise<{
275
290
  cases: number;
@@ -280,13 +295,16 @@ export function createRunner(opts: RunnerOptions) {
280
295
  costUsd: number | null;
281
296
  }> {
282
297
  const suite = suiteOrThrow(suiteKey);
283
- const [cases, perCall, prompt, effort] = await Promise.all([
298
+ const [cases, perCall, prompt, effort, temperature] = await Promise.all([
284
299
  casesFor(suite.key, set),
285
300
  opts.estimateCallCost?.(suite.operation, model) ?? null,
286
301
  opts.promptVersion(suite.operation),
287
302
  o.reasoningEffort !== undefined
288
303
  ? o.reasoningEffort
289
304
  : liveEffortFor(suite.operation),
305
+ o.temperature !== undefined
306
+ ? o.temperature
307
+ : liveTemperatureFor(suite.operation),
290
308
  ]);
291
309
  const answers = cases.length * repeats;
292
310
  const since = new Date(Date.now() - REUSE_DAYS * 86_400_000);
@@ -304,6 +322,7 @@ export function createRunner(opts: RunnerOptions) {
304
322
  prompt,
305
323
  input: c.input,
306
324
  repeat: i + 1,
325
+ temperature,
307
326
  }),
308
327
  since,
309
328
  ),
@@ -329,6 +348,8 @@ export function createRunner(opts: RunnerOptions) {
329
348
  startedBy?: string | null;
330
349
  /** A reasoning setting to test; undefined: the operation's own. */
331
350
  reasoningEffort?: ReasoningEffort | null;
351
+ /** A temperature to test; undefined: the operation's live one. */
352
+ temperature?: number | null;
332
353
  /** For the estimate: pass the same `fresh` to runEval. */
333
354
  fresh?: boolean;
334
355
  }) {
@@ -339,9 +360,14 @@ export function createRunner(opts: RunnerOptions) {
339
360
  args.reasoningEffort !== undefined
340
361
  ? args.reasoningEffort
341
362
  : await liveEffortFor(suite.operation);
363
+ const temperature =
364
+ args.temperature !== undefined
365
+ ? args.temperature
366
+ : await liveTemperatureFor(suite.operation);
342
367
  const [estimate, promptVersion] = await Promise.all([
343
368
  estimateRun(suite.key, args.model, set, repeats, {
344
369
  reasoningEffort,
370
+ temperature,
345
371
  fresh: Boolean(args.fresh),
346
372
  }),
347
373
  opts.promptVersion(suite.operation),
@@ -353,6 +379,7 @@ export function createRunner(opts: RunnerOptions) {
353
379
  promptVersion,
354
380
  reasoningEffort:
355
381
  args.reasoningEffort !== undefined ? args.reasoningEffort : liveEffort,
382
+ temperature,
356
383
  set,
357
384
  repeats,
358
385
  datasetVersion: null,
@@ -488,6 +515,7 @@ export function createRunner(opts: RunnerOptions) {
488
515
  prompt: run.promptVersion,
489
516
  input: c.input,
490
517
  repeat,
518
+ temperature: run.temperature ?? null,
491
519
  });
492
520
  const earlier = o.fresh
493
521
  ? null
@@ -517,6 +545,7 @@ export function createRunner(opts: RunnerOptions) {
517
545
  model: run.model,
518
546
  reasoningEffort:
519
547
  (run.reasoningEffort as ReasoningEffort | null) ?? null,
548
+ temperature: run.temperature ?? null,
520
549
  onCost: (usd) => (cost += usd),
521
550
  }),
522
551
  timeout,
@@ -663,6 +692,7 @@ export function createRunner(opts: RunnerOptions) {
663
692
  set: "SMOKE",
664
693
  startedBy: SCHEDULED,
665
694
  reasoningEffort: route.reasoningEffort,
695
+ temperature: route.temperature ?? null,
666
696
  });
667
697
  return run.id;
668
698
  }
@@ -784,8 +814,12 @@ export function createRunner(opts: RunnerOptions) {
784
814
  if (r.status === "SUCCEEDED" && !latestDone.has(runKey(r)))
785
815
  latestDone.set(runKey(r), r);
786
816
  }
817
+ const liveTemperature = route.temperature ?? null;
818
+ // A test at another temperature than the live one answered differently.
787
819
  const isCurrent = (r: RunRecord) =>
788
- r.datasetVersion === current && r.promptVersion === prompt;
820
+ r.datasetVersion === current &&
821
+ r.promptVersion === prompt &&
822
+ (r.temperature ?? null) === liveTemperature;
789
823
 
790
824
  const liveKey = keyOf(route.model, defaultEffort);
791
825
  const liveRun = latestDone.get(liveKey);
@@ -794,7 +828,8 @@ export function createRunner(opts: RunnerOptions) {
794
828
  const sameAsLive = (r: RunRecord) =>
795
829
  liveRun !== undefined &&
796
830
  r.datasetVersion === liveRun.datasetVersion &&
797
- r.promptVersion === liveRun.promptVersion;
831
+ r.promptVersion === liveRun.promptVersion &&
832
+ (r.temperature ?? null) === (liveRun.temperature ?? null);
798
833
  const outputs = new Map<string, RunOutput[]>();
799
834
  for (const r of latestDone.values())
800
835
  outputs.set(
@@ -863,6 +898,7 @@ export function createRunner(opts: RunnerOptions) {
863
898
  .verdict
864
899
  : null;
865
900
  const label = opts.models[model]?.label ?? model;
901
+ const baseline = isBaseline(opts.models, model);
866
902
  return {
867
903
  key,
868
904
  model,
@@ -873,6 +909,7 @@ export function createRunner(opts: RunnerOptions) {
873
909
  : label,
874
910
  live: key === liveKey,
875
911
  fallback: !variant && model === route.fallbackModel,
912
+ baseline,
876
913
  testing: testing.has(key),
877
914
  failed:
878
915
  lastRun?.status === "FAILED"
@@ -900,6 +937,7 @@ export function createRunner(opts: RunnerOptions) {
900
937
  vsLive,
901
938
  // Only on up-to-date tests: never promote on an old answer key.
902
939
  canMakeLive:
940
+ !baseline &&
903
941
  models.includes(model) &&
904
942
  fresh &&
905
943
  complete(r) &&
@@ -907,6 +945,7 @@ export function createRunner(opts: RunnerOptions) {
907
945
  liveOnly: !fallbackModels.includes(model),
908
946
  fallbackOnly: !models.includes(model),
909
947
  canMakeFallback:
948
+ !baseline &&
910
949
  fallbackModels.includes(model) &&
911
950
  !variant &&
912
951
  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,37 @@ 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,
208
259
  };
209
260
  }
210
261
 
211
262
  /**
212
263
  * Makes new settings live as a new version. The first change to a
213
264
  * 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.
265
+ * something to roll back to; a feature with no default gets `next` as
266
+ * version 1. With `evidence`, it's a promotion. Returns null when
267
+ * nothing changed.
216
268
  */
217
269
  async function change(
218
270
  operation: string,
@@ -223,6 +275,15 @@ export function createSettings(opts: {
223
275
  let live = await store.liveVersion(operation);
224
276
  if (!live) {
225
277
  const initial = opts.defaults(operation);
278
+ if (!initial)
279
+ return store.switchTo(operation, {
280
+ create: { settings: settingsOf(next), promptVersion },
281
+ kind: by.evidence === undefined ? "CHANGE" : "PROMOTE",
282
+ reason: by.reason,
283
+ by: by.by,
284
+ evidence: by.evidence,
285
+ expectLive: null,
286
+ });
226
287
  if (sameSettings(initial, next)) return null;
227
288
  live = await store.switchTo(operation, {
228
289
  create: { settings: initial, promptVersion },
@@ -242,7 +303,11 @@ export function createSettings(opts: {
242
303
  });
243
304
  }
244
305
 
245
- /** Changes only some settings (e.g. the fallback), keeping the rest. */
306
+ /**
307
+ * Changes only some settings (e.g. the fallback), keeping the rest.
308
+ * Throws `NoSettingsError` for a feature with no settings to keep: use
309
+ * `change` or `seed` with full settings first.
310
+ */
246
311
  async function update(
247
312
  operation: string,
248
313
  patch: Partial<Settings>,
@@ -301,6 +366,42 @@ export function createSettings(opts: {
301
366
  }
302
367
  }
303
368
 
369
+ /**
370
+ * Records existing settings as a feature's version 1, for an app moving
371
+ * its model choices into versioned settings (an import from its own
372
+ * config table). Only when the feature has no version yet: otherwise it
373
+ * changes nothing and returns null, so an import can run again safely.
374
+ * `promptVersion` defaults to the instructions' current hash.
375
+ */
376
+ async function seed(
377
+ operation: string,
378
+ initial: Settings,
379
+ by: {
380
+ by: string | null;
381
+ reason: string | null;
382
+ promptVersion?: string | null;
383
+ },
384
+ ): Promise<SettingsVersion | null> {
385
+ if (await store.liveVersion(operation)) return null;
386
+ const promptVersion =
387
+ by.promptVersion !== undefined
388
+ ? by.promptVersion
389
+ : await prompt(operation);
390
+ try {
391
+ return await store.switchTo(operation, {
392
+ create: { settings: settingsOf(initial), promptVersion },
393
+ kind: "CHANGE",
394
+ reason: by.reason,
395
+ by: by.by,
396
+ expectLive: null,
397
+ });
398
+ } catch (e) {
399
+ // Another request seeded it first: that version stands.
400
+ if (e instanceof SettingsConflict) return null;
401
+ throw e;
402
+ }
403
+ }
404
+
304
405
  /** Every switch, newest first, with the versions it went from and to. */
305
406
  async function history(
306
407
  operation: string,
@@ -324,7 +425,16 @@ export function createSettings(opts: {
324
425
  });
325
426
  }
326
427
 
327
- return { current, route, change, update, rollBack, syncPrompt, history };
428
+ return {
429
+ current,
430
+ route,
431
+ change,
432
+ update,
433
+ rollBack,
434
+ syncPrompt,
435
+ seed,
436
+ history,
437
+ };
328
438
  }
329
439
 
330
440
  /** What createSettings returns. */
package/src/store.ts CHANGED
@@ -77,6 +77,11 @@ 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;
80
85
  set: EvalSet;
81
86
  repeats: number;
82
87
  /** Changes when the cases or their answers do, so tests compare. */
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
@@ -127,6 +127,14 @@ export function describeStoreContract(
127
127
  expect(await store.getRun("missing")).toBeNull();
128
128
  });
129
129
 
130
+ it("keeps the temperature a run was tested at", async () => {
131
+ const store = await make();
132
+ const warm = await store.createRun({ ...newRun("s"), temperature: 0.7 });
133
+ const none = await store.createRun({ ...newRun("s"), temperature: null });
134
+ expect((await store.getRun(warm.id))?.temperature).toBe(0.7);
135
+ expect((await store.getRun(none.id))?.temperature ?? null).toBeNull();
136
+ });
137
+
130
138
  it("updates a run only while it has the status it's guarded by", async () => {
131
139
  const store = await make();
132
140
  const r = await store.createRun(newRun("s"));
@@ -381,6 +389,21 @@ export function describeSettingsStoreContract(
381
389
  expect(await store.listVersions("triage")).toHaveLength(2);
382
390
  });
383
391
 
392
+ it("keeps a version's temperature", async () => {
393
+ const store = await make();
394
+ const v1 = await store.switchTo("triage", {
395
+ ...create("a", null),
396
+ create: {
397
+ settings: { ...settings("a"), temperature: 0.4 },
398
+ promptVersion: "p1",
399
+ },
400
+ });
401
+ expect(v1.temperature).toBe(0.4);
402
+ expect((await store.liveVersion("triage"))?.temperature).toBe(0.4);
403
+ const v2 = await store.switchTo("triage", create("b", v1.id));
404
+ expect(v2.temperature ?? null).toBeNull();
405
+ });
406
+
384
407
  it("keeps each feature's versions and live one apart", async () => {
385
408
  const store = await make();
386
409
  await store.switchTo("triage", create("a", null));
package/src/types.ts CHANGED
@@ -108,6 +108,12 @@ 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;
111
117
  onCost: (usd: number) => void;
112
118
  },
113
119
  ): Promise<Output>;
package/src/weekly.ts CHANGED
@@ -104,13 +104,15 @@ 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, instructions, and
108
+ // 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) &&
114
116
  p.promptVersion === r.promptVersion &&
115
117
  p.graderVersion === r.graderVersion &&
116
118
  +r.startedAt - +p.startedAt >= DAY,