@crowandraven/evalkeeper 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/bin/evalkeeper.mjs +9 -0
  4. package/dist/attention.d.ts +49 -0
  5. package/dist/attention.js +49 -0
  6. package/dist/attention.js.map +1 -0
  7. package/dist/board.d.ts +57 -0
  8. package/dist/board.js +2 -0
  9. package/dist/board.js.map +1 -0
  10. package/dist/cli/addPages.d.ts +79 -0
  11. package/dist/cli/addPages.js +181 -0
  12. package/dist/cli/addPages.js.map +1 -0
  13. package/dist/cli/main.d.ts +18 -0
  14. package/dist/cli/main.js +115 -0
  15. package/dist/cli/main.js.map +1 -0
  16. package/dist/common.d.ts +72 -0
  17. package/dist/common.js +276 -0
  18. package/dist/common.js.map +1 -0
  19. package/dist/compare.d.ts +85 -0
  20. package/dist/compare.js +185 -0
  21. package/dist/compare.js.map +1 -0
  22. package/dist/decisions.d.ts +84 -0
  23. package/dist/decisions.js +146 -0
  24. package/dist/decisions.js.map +1 -0
  25. package/dist/feedback.d.ts +114 -0
  26. package/dist/feedback.js +116 -0
  27. package/dist/feedback.js.map +1 -0
  28. package/dist/format.d.ts +6 -0
  29. package/dist/format.js +22 -0
  30. package/dist/format.js.map +1 -0
  31. package/dist/glossary.d.ts +46 -0
  32. package/dist/glossary.js +45 -0
  33. package/dist/glossary.js.map +1 -0
  34. package/dist/health.d.ts +40 -0
  35. package/dist/health.js +101 -0
  36. package/dist/health.js.map +1 -0
  37. package/dist/index.d.ts +31 -0
  38. package/dist/index.js +34 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/labels.d.ts +8 -0
  41. package/dist/labels.js +15 -0
  42. package/dist/labels.js.map +1 -0
  43. package/dist/legs.d.ts +23 -0
  44. package/dist/legs.js +22 -0
  45. package/dist/legs.js.map +1 -0
  46. package/dist/memoryStore.d.ts +21 -0
  47. package/dist/memoryStore.js +228 -0
  48. package/dist/memoryStore.js.map +1 -0
  49. package/dist/models.d.ts +76 -0
  50. package/dist/models.js +56 -0
  51. package/dist/models.js.map +1 -0
  52. package/dist/prune.d.ts +48 -0
  53. package/dist/prune.js +77 -0
  54. package/dist/prune.js.map +1 -0
  55. package/dist/quickSet.d.ts +26 -0
  56. package/dist/quickSet.js +44 -0
  57. package/dist/quickSet.js.map +1 -0
  58. package/dist/runner.d.ts +202 -0
  59. package/dist/runner.js +734 -0
  60. package/dist/runner.js.map +1 -0
  61. package/dist/settings.d.ts +196 -0
  62. package/dist/settings.js +272 -0
  63. package/dist/settings.js.map +1 -0
  64. package/dist/stableJson.d.ts +11 -0
  65. package/dist/stableJson.js +15 -0
  66. package/dist/stableJson.js.map +1 -0
  67. package/dist/stats.d.ts +98 -0
  68. package/dist/stats.js +119 -0
  69. package/dist/stats.js.map +1 -0
  70. package/dist/store.d.ts +151 -0
  71. package/dist/store.js +3 -0
  72. package/dist/store.js.map +1 -0
  73. package/dist/suggest.d.ts +75 -0
  74. package/dist/suggest.js +152 -0
  75. package/dist/suggest.js.map +1 -0
  76. package/dist/summary.d.ts +51 -0
  77. package/dist/summary.js +61 -0
  78. package/dist/summary.js.map +1 -0
  79. package/dist/testing.d.ts +26 -0
  80. package/dist/testing.js +362 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/timeout.d.ts +2 -0
  83. package/dist/timeout.js +15 -0
  84. package/dist/timeout.js.map +1 -0
  85. package/dist/types.d.ts +225 -0
  86. package/dist/types.js +46 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/upkeepRules.d.ts +28 -0
  89. package/dist/upkeepRules.js +40 -0
  90. package/dist/upkeepRules.js.map +1 -0
  91. package/dist/util.d.ts +8 -0
  92. package/dist/util.js +18 -0
  93. package/dist/util.js.map +1 -0
  94. package/dist/weekly.d.ts +80 -0
  95. package/dist/weekly.js +113 -0
  96. package/dist/weekly.js.map +1 -0
  97. package/package.json +81 -4
  98. package/src/attention.ts +89 -0
  99. package/src/board.ts +58 -0
  100. package/src/cli/addPages.ts +256 -0
  101. package/src/cli/main.ts +125 -0
  102. package/src/common.ts +338 -0
  103. package/src/compare.ts +261 -0
  104. package/src/decisions.ts +230 -0
  105. package/src/feedback.ts +225 -0
  106. package/src/format.ts +19 -0
  107. package/src/glossary.ts +75 -0
  108. package/src/health.ts +149 -0
  109. package/src/index.ts +33 -0
  110. package/src/labels.ts +18 -0
  111. package/src/legs.ts +38 -0
  112. package/src/memoryStore.ts +268 -0
  113. package/src/models.ts +127 -0
  114. package/src/prune.ts +95 -0
  115. package/src/quickSet.ts +55 -0
  116. package/src/runner.ts +1010 -0
  117. package/src/settings.ts +441 -0
  118. package/src/stableJson.ts +24 -0
  119. package/src/stats.ts +215 -0
  120. package/src/store.ts +172 -0
  121. package/src/suggest.ts +247 -0
  122. package/src/summary.ts +109 -0
  123. package/src/testing.ts +415 -0
  124. package/src/timeout.ts +14 -0
  125. package/src/types.ts +258 -0
  126. package/src/upkeepRules.ts +46 -0
  127. package/src/util.ts +17 -0
  128. package/src/weekly.ts +169 -0
  129. package/starter/index.ts +86 -0
  130. package/templates/app/api/cron/evals-weekly/route.ts +24 -0
  131. package/templates/app/evals/[suite]/page.tsx +449 -0
  132. package/templates/app/evals/[suite]/runs/[runId]/page.tsx +95 -0
  133. package/templates/app/evals/feedback/page.tsx +219 -0
  134. package/templates/app/evals/page.tsx +313 -0
  135. package/templates/components/evalkeeper/AddCase.tsx +62 -0
  136. package/templates/components/evalkeeper/AnswerKey.tsx +360 -0
  137. package/templates/components/evalkeeper/CaseMenu.tsx +122 -0
  138. package/templates/components/evalkeeper/Fields.tsx +110 -0
  139. package/templates/components/evalkeeper/RefreshWhile.tsx +25 -0
  140. package/templates/components/evalkeeper/SettingsHistory.tsx +191 -0
  141. package/templates/components/evalkeeper/Submit.tsx +58 -0
  142. package/templates/components/evalkeeper/Thumbs.tsx +117 -0
  143. package/templates/components/evalkeeper/Tip.tsx +98 -0
  144. package/templates/components/evalkeeper/WeeklyCheck.tsx +124 -0
  145. package/templates/components/evalkeeper/evalkeeper.css +71 -0
  146. package/templates/components/evalkeeper/ui.tsx +103 -0
  147. package/templates/lib/evalkeeper/actions.ts +263 -0
  148. package/templates/lib/evalkeeper/cronAuth.ts +18 -0
  149. package/templates/lib/evalkeeper/paths.ts +11 -0
  150. package/templates/lib/evalkeeper/time.ts +4 -0
  151. package/templates/lib/evalkeeper/weekly.ts +51 -0
@@ -0,0 +1,441 @@
1
+ import type { ReasoningEffort } from "./models.js";
2
+ import type { Route } from "./runner.js";
3
+
4
+ /**
5
+ * Versioned model settings. Each AI feature's settings (its model,
6
+ * fallback, reasoning setting, and output limit, with the instructions they
7
+ * ran with) are numbered versions that never change. Changing a setting
8
+ * makes a new version live; rolling back makes an earlier one live again;
9
+ * every switch is logged with who, when, why, and its evidence. A feature
10
+ * never changed runs on its default from code. Optional: use it, or keep
11
+ * your own routing and pass `route` to the runner yourself.
12
+ */
13
+
14
+ /**
15
+ * A feature's model settings: live model, fallback, reasoning setting,
16
+ * output limit, and temperature.
17
+ */
18
+ export type Settings = {
19
+ model: string;
20
+ fallbackModel: string | null;
21
+ /** Null: the model's own default. */
22
+ reasoningEffort: ReasoningEffort | null;
23
+ /** Null: the call's own default. */
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;
31
+ };
32
+
33
+ /**
34
+ * One numbered version of a feature's settings, with the instructions it ran with. Never changed.
35
+ */
36
+ export type SettingsVersion = Settings & {
37
+ id: string;
38
+ operation: string;
39
+ /** 1, 2, 3, … per feature. */
40
+ number: number;
41
+ /** The instructions' hash when this version was made. */
42
+ promptVersion: string | null;
43
+ createdAt: Date;
44
+ };
45
+
46
+ /**
47
+ * CHANGE: a person changed a setting. PROMOTE: a model was made live or
48
+ * the fallback from a test, with the test as evidence. ROLLBACK: an
49
+ * earlier version made live again. DEPLOY: the instructions changed in code
50
+ * under the live settings.
51
+ */
52
+ export type ChangeKind = "CHANGE" | "PROMOTE" | "ROLLBACK" | "DEPLOY";
53
+
54
+ /**
55
+ * One switch in a feature's history: its kind, the versions it went from and to, who, why,
56
+ * and the evidence.
57
+ */
58
+ export type SettingsChange = {
59
+ id: string;
60
+ operation: string;
61
+ kind: ChangeKind;
62
+ fromVersionId: string | null;
63
+ toVersionId: string;
64
+ reason: string | null;
65
+ /** Who made it; null for the system (a deploy, the first default). */
66
+ by: string | null;
67
+ /** Why it's safe: the tests compared, the verdict. */
68
+ evidence: unknown;
69
+ createdAt: Date;
70
+ };
71
+
72
+ /** One atomic switch: maybe a new version, then live, then the log. */
73
+ export type SettingsSwitch = {
74
+ /** Make a new version with these settings (numbered next). */
75
+ create?: { settings: Settings; promptVersion: string | null };
76
+ /** Or make this existing version live. */
77
+ versionId?: string;
78
+ kind: ChangeKind;
79
+ reason: string | null;
80
+ by: string | null;
81
+ evidence?: unknown;
82
+ /**
83
+ * The live version this switch was decided against (null: none). The
84
+ * store refuses the switch if another one got in first.
85
+ */
86
+ expectLive: string | null;
87
+ };
88
+
89
+ /** Where versions live. `@crowandraven/evalkeeper-prisma` and `memoryStore` implement it. */
90
+ export interface SettingsStore {
91
+ liveVersion(operation: string): Promise<SettingsVersion | null>;
92
+ /** Newest first. */
93
+ listVersions(operation: string): Promise<SettingsVersion[]>;
94
+ /** Newest first. */
95
+ listChanges(
96
+ operation: string,
97
+ opts?: { limit?: number },
98
+ ): Promise<SettingsChange[]>;
99
+ /**
100
+ * Applies a switch atomically. Throws SettingsConflict when the live
101
+ * version is no longer `expectLive`.
102
+ */
103
+ switchTo(operation: string, s: SettingsSwitch): Promise<SettingsVersion>;
104
+ }
105
+
106
+ /** Another change landed first: reload and decide again. */
107
+ export class SettingsConflict extends Error {
108
+ constructor(operation: string) {
109
+ super(
110
+ `The settings for ${operation} changed meanwhile. Reload and try again.`,
111
+ );
112
+ this.name = "SettingsConflict";
113
+ }
114
+ }
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
+
133
+ /** Whether two settings are the same in every field. */
134
+ export function sameSettings(a: Settings, b: Settings): boolean {
135
+ return (
136
+ a.model === b.model &&
137
+ a.fallbackModel === b.fallbackModel &&
138
+ a.reasoningEffort === b.reasoningEffort &&
139
+ a.maxOutputTokens === b.maxOutputTokens &&
140
+ (a.temperature ?? null) === (b.temperature ?? null)
141
+ );
142
+ }
143
+
144
+ /** Just the settings of a version. */
145
+ export function settingsOf(v: Settings): Settings {
146
+ return {
147
+ model: v.model,
148
+ fallbackModel: v.fallbackModel,
149
+ reasoningEffort: v.reasoningEffort,
150
+ maxOutputTokens: v.maxOutputTokens,
151
+ temperature: v.temperature ?? null,
152
+ };
153
+ }
154
+
155
+ /** What changed between two settings, in plain words. */
156
+ export function describeChange(
157
+ from: Settings | null,
158
+ to: Settings,
159
+ label: (model: string) => string = (m) => m,
160
+ ): string[] {
161
+ if (!from) return [`Live: ${label(to.model)}`];
162
+ const out: string[] = [];
163
+ if (from.model !== to.model)
164
+ out.push(`Live: ${label(from.model)} → ${label(to.model)}`);
165
+ if (from.fallbackModel !== to.fallbackModel)
166
+ out.push(
167
+ `Fallback: ${from.fallbackModel ? label(from.fallbackModel) : "none"} → ${to.fallbackModel ? label(to.fallbackModel) : "none"}`,
168
+ );
169
+ if (from.reasoningEffort !== to.reasoningEffort)
170
+ out.push(
171
+ `Reasoning: ${from.reasoningEffort ?? "default"} → ${to.reasoningEffort ?? "default"}`,
172
+ );
173
+ if (from.maxOutputTokens !== to.maxOutputTokens)
174
+ out.push(
175
+ `Output limit: ${from.maxOutputTokens ?? "default"} → ${to.maxOutputTokens ?? "default"}`,
176
+ );
177
+ if ((from.temperature ?? null) !== (to.temperature ?? null))
178
+ out.push(
179
+ `Temperature: ${from.temperature ?? "default"} → ${to.temperature ?? "default"}`,
180
+ );
181
+ return out;
182
+ }
183
+
184
+ /** A switch with the versions it went from and to, for a history page. */
185
+ export type HistoryEntry = SettingsChange & {
186
+ to: SettingsVersion;
187
+ from: SettingsVersion | null;
188
+ };
189
+
190
+ /**
191
+ * Versioned settings: each feature's settings as numbered versions, with every change,
192
+ * promotion, rollback, and deploy logged. Its `route` is the runner's route.
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * const settings = createSettings({
197
+ * store: prismaSettingsStore(prisma),
198
+ * defaults: (op) => DEFAULTS[op],
199
+ * promptVersion: (op) => PROMPT_VERSIONS[op],
200
+ * });
201
+ * await settings.update("ticket_triage", { model: "claude-haiku-5-5" }, {
202
+ * by: admin.name,
203
+ * reason: "About as accurate at half the cost",
204
+ * evidence: { runId },
205
+ * });
206
+ * await settings.rollBack("ticket_triage", versionId, { by: admin.name, reason: "Worse in production" });
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
+ * ```
221
+ */
222
+ export function createSettings(opts: {
223
+ store: SettingsStore;
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;
230
+ /** The instructions' current hash, so a version records what it ran. */
231
+ promptVersion?: (operation: string) => string | Promise<string>;
232
+ }) {
233
+ const { store } = opts;
234
+ const prompt = async (op: string) =>
235
+ opts.promptVersion ? await opts.promptVersion(op) : null;
236
+
237
+ /**
238
+ * The live settings: the live version's, else the default from code.
239
+ * Throws `NoSettingsError` when the feature has neither.
240
+ */
241
+ async function current(
242
+ operation: string,
243
+ ): Promise<{ settings: Settings; version: SettingsVersion | null }> {
244
+ const version = await store.liveVersion(operation);
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 };
249
+ }
250
+
251
+ /** The runner's route for a feature, from its live settings; throws `NoSettingsError` when it has none. */
252
+ async function route(operation: string): Promise<Route> {
253
+ const { settings } = await current(operation);
254
+ return {
255
+ model: settings.model,
256
+ fallbackModel: settings.fallbackModel,
257
+ reasoningEffort: settings.reasoningEffort,
258
+ temperature: settings.temperature ?? null,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Makes new settings live as a new version. The first change to a
264
+ * feature first records its default as version 1, so there's always
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.
268
+ */
269
+ async function change(
270
+ operation: string,
271
+ next: Settings,
272
+ by: { by: string | null; reason: string | null; evidence?: unknown },
273
+ ): Promise<SettingsVersion | null> {
274
+ const promptVersion = await prompt(operation);
275
+ let live = await store.liveVersion(operation);
276
+ if (!live) {
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
+ });
287
+ if (sameSettings(initial, next)) return null;
288
+ live = await store.switchTo(operation, {
289
+ create: { settings: initial, promptVersion },
290
+ kind: "CHANGE",
291
+ reason: "The default from code, before the first change",
292
+ by: null,
293
+ expectLive: null,
294
+ });
295
+ } else if (sameSettings(settingsOf(live), next)) return null;
296
+ return store.switchTo(operation, {
297
+ create: { settings: settingsOf(next), promptVersion },
298
+ kind: by.evidence === undefined ? "CHANGE" : "PROMOTE",
299
+ reason: by.reason,
300
+ by: by.by,
301
+ evidence: by.evidence,
302
+ expectLive: live.id,
303
+ });
304
+ }
305
+
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
+ */
311
+ async function update(
312
+ operation: string,
313
+ patch: Partial<Settings>,
314
+ by: { by: string | null; reason: string | null; evidence?: unknown },
315
+ ): Promise<SettingsVersion | null> {
316
+ const { settings } = await current(operation);
317
+ return change(operation, { ...settings, ...patch }, by);
318
+ }
319
+
320
+ /** Makes an earlier version live again. */
321
+ async function rollBack(
322
+ operation: string,
323
+ versionId: string,
324
+ by: { by: string | null; reason: string },
325
+ ): Promise<SettingsVersion> {
326
+ const versions = await store.listVersions(operation);
327
+ const to = versions.find((v) => v.id === versionId);
328
+ if (!to) throw new Error("No such version for this feature");
329
+ const live = await store.liveVersion(operation);
330
+ if (live?.id === to.id) return to;
331
+ return store.switchTo(operation, {
332
+ versionId: to.id,
333
+ kind: "ROLLBACK",
334
+ reason: by.reason,
335
+ by: by.by,
336
+ expectLive: live?.id ?? null,
337
+ });
338
+ }
339
+
340
+ /**
341
+ * Instructions live in code, so a deploy can change them under live
342
+ * settings. When it has, records a version with the same settings and the
343
+ * new instructions, so the history says what changed and when. Call it
344
+ * where the feature's settings are read (or after each deploy).
345
+ */
346
+ async function syncPrompt(
347
+ operation: string,
348
+ ): Promise<SettingsVersion | null> {
349
+ const live = await store.liveVersion(operation);
350
+ const now = await prompt(operation);
351
+ if (!live || !now || now === live.promptVersion) return live;
352
+ try {
353
+ return await store.switchTo(operation, {
354
+ create: { settings: settingsOf(live), promptVersion: now },
355
+ kind: "DEPLOY",
356
+ reason: live.promptVersion
357
+ ? `Instructions changed (${live.promptVersion} → ${now})`
358
+ : `Instructions recorded (${now})`,
359
+ by: null,
360
+ expectLive: live.id,
361
+ });
362
+ } catch (e) {
363
+ // Another request recorded the same deploy first.
364
+ if (e instanceof SettingsConflict) return store.liveVersion(operation);
365
+ throw e;
366
+ }
367
+ }
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
+
405
+ /** Every switch, newest first, with the versions it went from and to. */
406
+ async function history(
407
+ operation: string,
408
+ limit = 50,
409
+ ): Promise<HistoryEntry[]> {
410
+ const [changes, versions] = await Promise.all([
411
+ store.listChanges(operation, { limit }),
412
+ store.listVersions(operation),
413
+ ]);
414
+ const byId = new Map(versions.map((v) => [v.id, v]));
415
+ return changes.flatMap((c) => {
416
+ const to = byId.get(c.toVersionId);
417
+ if (!to) return [];
418
+ return [
419
+ {
420
+ ...c,
421
+ to,
422
+ from: c.fromVersionId ? (byId.get(c.fromVersionId) ?? null) : null,
423
+ },
424
+ ];
425
+ });
426
+ }
427
+
428
+ return {
429
+ current,
430
+ route,
431
+ change,
432
+ update,
433
+ rollBack,
434
+ syncPrompt,
435
+ seed,
436
+ history,
437
+ };
438
+ }
439
+
440
+ /** What createSettings returns. */
441
+ export type VersionedSettings = ReturnType<typeof createSettings>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * JSON with object keys sorted, so a case's input compares equal however
3
+ * its keys were ordered (Postgres jsonb reorders them on save).
4
+ */
5
+ export function stableJson(value: unknown): string {
6
+ return JSON.stringify(value, (_key, v: unknown) =>
7
+ v && typeof v === "object" && !Array.isArray(v)
8
+ ? Object.fromEntries(
9
+ Object.entries(v as Record<string, unknown>).sort(([a], [b]) =>
10
+ a < b ? -1 : a > b ? 1 : 0,
11
+ ),
12
+ )
13
+ : v,
14
+ );
15
+ }
16
+
17
+ /** Drafted cases not yet in the answer key. */
18
+ export function newStarters<T extends { input: unknown }>(
19
+ starter: T[] | undefined,
20
+ existing: { input: unknown }[],
21
+ ): T[] {
22
+ const have = new Set(existing.map((c) => stableJson(c.input)));
23
+ return (starter ?? []).filter((c) => !have.has(stableJson(c.input)));
24
+ }
package/src/stats.ts ADDED
@@ -0,0 +1,215 @@
1
+ import type { CheckResult } from "./types.js";
2
+ import { calibrationError } from "./decisions.js";
3
+
4
+ /**
5
+ * Summary numbers for a run: pass rates per check with a 95% range, so a
6
+ * small dataset doesn't look more certain than it is. Pure; no database.
7
+ */
8
+
9
+ /** A pass rate with its 95% range; null when nothing was measured. */
10
+ export type Rate = {
11
+ passed: number;
12
+ /** Outputs the check applied to. */
13
+ n: number;
14
+ rate: number | null;
15
+ /** 95% Wilson interval. */
16
+ low: number | null;
17
+ high: number | null;
18
+ };
19
+
20
+ /** Wilson score interval: honest for small n and rates near 0 or 1. */
21
+ export function wilson(passed: number, n: number, z = 1.96): Rate {
22
+ if (n === 0) return { passed, n, rate: null, low: null, high: null };
23
+ const p = passed / n;
24
+ const denom = 1 + (z * z) / n;
25
+ const center = (p + (z * z) / (2 * n)) / denom;
26
+ const half =
27
+ (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / denom;
28
+ return {
29
+ passed,
30
+ n,
31
+ rate: p,
32
+ low: Math.max(0, center - half),
33
+ high: Math.min(1, center + half),
34
+ };
35
+ }
36
+
37
+ /** One graded answer, as the metrics read it. */
38
+ export type ScoredOutput = {
39
+ caseId: string;
40
+ repeat: number;
41
+ /** null when the call failed. */
42
+ checks: CheckResult[] | null;
43
+ costUsd: number;
44
+ ms: number;
45
+ reused: boolean;
46
+ };
47
+
48
+ /**
49
+ * A test's numbers: answers right, partial credit, each check's pass rate, consistency,
50
+ * cost, time, and reuse.
51
+ */
52
+ export type SuiteMetrics = {
53
+ cases: number;
54
+ outputs: number;
55
+ failed: number;
56
+ /** Every applicable check passed. Failed calls count as not passing. */
57
+ allPass: Rate;
58
+ /**
59
+ * Partial credit summed over answers (e.g. items right of items
60
+ * expected), where the suite gives it; the headline when present.
61
+ */
62
+ units: { right: number; total: number } | null;
63
+ byCheck: Record<string, Rate>;
64
+ /**
65
+ * Of cases run more than once, the share whose all-pass result was the
66
+ * same every time. Low: the model is inconsistent on these cases.
67
+ */
68
+ consistency: Rate | null;
69
+ /** What grading's judge spent, apart from the model under test (runner). */
70
+ judgeCostUsd?: number;
71
+ /** The error most failed answers share (runner). */
72
+ failureReason?: string | null;
73
+ /**
74
+ * Per check, the mean of each measure its answers carried (brier,
75
+ * logLoss, …), and, where answers carried a confidence, the calibration
76
+ * error. Only checks with measures appear.
77
+ */
78
+ measures?: Record<string, CheckMeasures>;
79
+ /** What this run spent: reused outputs cost nothing. */
80
+ costUsd: number;
81
+ /** What one answer costs on this model, reused or not. */
82
+ costPerAnswer: number | null;
83
+ /** How long one answer takes on this model. */
84
+ avgMs: number;
85
+ reused: number;
86
+ };
87
+
88
+ /**
89
+ * Whether an answer passed every check that applied. A failed answer (null) didn't.
90
+ */
91
+ export function allPassed(checks: CheckResult[] | null): boolean {
92
+ return checks !== null && checks.every((c) => c.pass !== false);
93
+ }
94
+
95
+ /** Sums each answer's partial credit; null when the suite gives none. */
96
+ export function sumUnits(
97
+ outputs: { checks: CheckResult[] | null }[],
98
+ ): { right: number; total: number } | null {
99
+ let right = 0;
100
+ let total = 0;
101
+ let any = false;
102
+ for (const o of outputs)
103
+ for (const c of o.checks ?? [])
104
+ if (c.units) {
105
+ any = true;
106
+ right += c.units.right;
107
+ total += c.units.total;
108
+ }
109
+ return any ? { right, total } : null;
110
+ }
111
+
112
+ /** One answer's score from 0 to 1: its partial credit, else all-or-nothing. */
113
+ export function answerScore(checks: CheckResult[] | null): number {
114
+ if (!checks) return 0;
115
+ const u = checks.find((c) => c.units)?.units;
116
+ if (u) return u.total ? u.right / u.total : 1;
117
+ return allPassed(checks) ? 1 : 0;
118
+ }
119
+
120
+ /** A test's numbers from its graded answers. */
121
+ /** One check's measures across a test. */
122
+ export type CheckMeasures = {
123
+ /** Answers that carried measures. */
124
+ n: number;
125
+ /** The mean of each measure, over the answers that carried it. */
126
+ mean: Record<string, number>;
127
+ /** Expected calibration error, from confidence and correctness; null without both. */
128
+ calibrationError: number | null;
129
+ };
130
+
131
+ /** One check's measures across a test's answers; null when none carried any. */
132
+ export function measuresFor(
133
+ outputs: ScoredOutput[],
134
+ key: string,
135
+ ): CheckMeasures | null {
136
+ const all = outputs
137
+ .map((o) => o.checks?.find((c) => c.key === key)?.measures)
138
+ .filter((m): m is Record<string, number> => m !== undefined);
139
+ if (!all.length) return null;
140
+ const sums = new Map<string, { total: number; n: number }>();
141
+ for (const m of all)
142
+ for (const [name, v] of Object.entries(m)) {
143
+ const s = sums.get(name) ?? { total: 0, n: 0 };
144
+ s.total += v;
145
+ s.n++;
146
+ sums.set(name, s);
147
+ }
148
+ const pairs = all.flatMap((m) =>
149
+ m.confidence !== undefined && m.correct !== undefined
150
+ ? [{ confidence: m.confidence, correct: m.correct === 1 }]
151
+ : [],
152
+ );
153
+ return {
154
+ n: all.length,
155
+ mean: Object.fromEntries([...sums].map(([k, s]) => [k, s.total / s.n])),
156
+ calibrationError: calibrationError(pairs),
157
+ };
158
+ }
159
+
160
+ /**
161
+ *
162
+ */
163
+ export function suiteMetrics(
164
+ outputs: ScoredOutput[],
165
+ checkKeys: string[],
166
+ ): SuiteMetrics {
167
+ const byCheck: Record<string, Rate> = {};
168
+ for (const key of checkKeys) {
169
+ const applied = outputs
170
+ .map((o) => o.checks?.find((c) => c.key === key))
171
+ .filter((c): c is CheckResult => c !== undefined && c.pass !== null);
172
+ byCheck[key] = wilson(applied.filter((c) => c.pass).length, applied.length);
173
+ }
174
+ const measures: Record<string, CheckMeasures> = {};
175
+ for (const key of checkKeys) {
176
+ const m = measuresFor(outputs, key);
177
+ if (m) measures[key] = m;
178
+ }
179
+ const byCase = new Map<string, boolean[]>();
180
+ for (const o of outputs)
181
+ byCase.set(o.caseId, [
182
+ ...(byCase.get(o.caseId) ?? []),
183
+ allPassed(o.checks),
184
+ ]);
185
+ const repeated = [...byCase.values()].filter((r) => r.length > 1);
186
+ const called = outputs.filter((o) => !o.reused);
187
+ // Answers with a recorded time (older reused rows have none).
188
+ const timed = outputs.filter((o) => o.checks && o.ms > 0);
189
+ return {
190
+ cases: byCase.size,
191
+ outputs: outputs.length,
192
+ failed: outputs.filter((o) => !o.checks).length,
193
+ allPass: wilson(
194
+ outputs.filter((o) => allPassed(o.checks)).length,
195
+ outputs.length,
196
+ ),
197
+ units: sumUnits(outputs),
198
+ byCheck,
199
+ ...(Object.keys(measures).length ? { measures } : {}),
200
+ consistency: repeated.length
201
+ ? wilson(
202
+ repeated.filter((r) => r.every((x) => x === r[0])).length,
203
+ repeated.length,
204
+ )
205
+ : null,
206
+ costUsd: called.reduce((t, o) => t + o.costUsd, 0),
207
+ costPerAnswer: timed.length
208
+ ? timed.reduce((t, o) => t + o.costUsd, 0) / timed.length
209
+ : null,
210
+ avgMs: timed.length
211
+ ? Math.round(timed.reduce((t, o) => t + o.ms, 0) / timed.length)
212
+ : 0,
213
+ reused: outputs.length - called.length,
214
+ };
215
+ }