@crowandraven/evalkeeper 0.0.0-stage → 0.1.1

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 +48 -0
  6. package/dist/attention.js.map +1 -0
  7. package/dist/board.d.ts +55 -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 +50 -0
  50. package/dist/models.js +39 -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 +193 -0
  59. package/dist/runner.js +706 -0
  60. package/dist/runner.js.map +1 -0
  61. package/dist/settings.d.ts +157 -0
  62. package/dist/settings.js +183 -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 +146 -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 +150 -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 +341 -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 +219 -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 +111 -0
  96. package/dist/weekly.js.map +1 -0
  97. package/package.json +81 -4
  98. package/src/attention.ts +88 -0
  99. package/src/board.ts +56 -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 +147 -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 +82 -0
  114. package/src/prune.ts +95 -0
  115. package/src/quickSet.ts +55 -0
  116. package/src/runner.ts +971 -0
  117. package/src/settings.ts +331 -0
  118. package/src/stableJson.ts +24 -0
  119. package/src/stats.ts +215 -0
  120. package/src/store.ts +167 -0
  121. package/src/suggest.ts +245 -0
  122. package/src/summary.ts +109 -0
  123. package/src/testing.ts +392 -0
  124. package/src/timeout.ts +14 -0
  125. package/src/types.ts +252 -0
  126. package/src/upkeepRules.ts +46 -0
  127. package/src/util.ts +17 -0
  128. package/src/weekly.ts +167 -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
package/src/testing.ts ADDED
@@ -0,0 +1,392 @@
1
+ /**
2
+ * The contracts every store must meet, as Vitest tests.
3
+ *
4
+ * @module evalkeeper/testing
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { z } from "zod";
8
+ import type { FeedbackStore, NewRating } from "./feedback.js";
9
+ import { createRunner } from "./runner.js";
10
+ import {
11
+ SettingsConflict,
12
+ type Settings,
13
+ type SettingsStore,
14
+ } from "./settings.js";
15
+ import type { EvalStore, NewCase } from "./store.js";
16
+ import type { Suite } from "./types.js";
17
+
18
+ /**
19
+ * The contract every EvalStore must meet, as Vitest tests. Run it against
20
+ * your store to know the runner will work on it. `make` must return an
21
+ * empty store each time it's called.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { describeStoreContract } from "@crowandraven/evalkeeper/testing";
26
+ *
27
+ * describeStoreContract("my store", () => myStore(freshDatabase()));
28
+ * ```
29
+ */
30
+ export function describeStoreContract(
31
+ name: string,
32
+ make: () => EvalStore | Promise<EvalStore>,
33
+ ) {
34
+ const newCase = (suite: string, over: Partial<NewCase> = {}): NewCase => ({
35
+ suite,
36
+ input: { text: "hello", nested: { n: 1 } },
37
+ tags: ["a", "b"],
38
+ split: "DEV",
39
+ status: "ACTIVE",
40
+ source: "OWNER",
41
+ sourceRef: null,
42
+ note: null,
43
+ labels: [
44
+ {
45
+ kind: "EXPECTED",
46
+ status: "CONFIRMED",
47
+ value: { want: "HELLO" },
48
+ raterRole: "OWNER",
49
+ raterId: null,
50
+ note: null,
51
+ },
52
+ ],
53
+ ...over,
54
+ });
55
+ const newRun = (suite: string) => ({
56
+ suite,
57
+ model: "model-a",
58
+ promptVersion: "v1",
59
+ reasoningEffort: null,
60
+ set: "ALL" as const,
61
+ repeats: 1,
62
+ datasetVersion: null,
63
+ graderVersion: "1",
64
+ estimatedCostUsd: null,
65
+ startedBy: null,
66
+ });
67
+
68
+ describe(`EvalStore contract: ${name}`, () => {
69
+ it("keeps a case's input, tags, and answers as given", async () => {
70
+ const store = await make();
71
+ const c = await store.createCase(newCase("s"));
72
+ const [listed] = await store.listCases("s");
73
+ expect(listed?.id).toBe(c.id);
74
+ expect(listed?.input).toEqual({ text: "hello", nested: { n: 1 } });
75
+ expect(listed?.tags).toEqual(["a", "b"]);
76
+ expect(listed?.labels).toHaveLength(1);
77
+ expect(listed?.labels[0]).toMatchObject({
78
+ kind: "EXPECTED",
79
+ status: "CONFIRMED",
80
+ value: { want: "HELLO" },
81
+ });
82
+ });
83
+
84
+ it("lists a suite's cases oldest first, leaving out retired ones", async () => {
85
+ const store = await make();
86
+ const first = await store.createCase(newCase("s"));
87
+ const second = await store.createCase(newCase("s"));
88
+ await store.createCase(newCase("other"));
89
+ await store.setCaseStatus(first.id, "RETIRED");
90
+ expect((await store.listCases("s")).map((c) => c.id)).toEqual([
91
+ second.id,
92
+ ]);
93
+ });
94
+
95
+ it("adds answers without overwriting, and changes their status", async () => {
96
+ const store = await make();
97
+ const c = await store.createCase(newCase("s", { labels: [] }));
98
+ const l = await store.addLabel(c.id, {
99
+ kind: "EXPECTED",
100
+ status: "PENDING",
101
+ value: { want: "X" },
102
+ raterRole: "MODEL",
103
+ raterId: null,
104
+ note: null,
105
+ });
106
+ await store.setLabelStatus(l.id, "CONFIRMED");
107
+ const [listed] = await store.listCases("s");
108
+ expect(listed?.labels).toHaveLength(1);
109
+ expect(listed?.labels[0]?.status).toBe("CONFIRMED");
110
+ });
111
+
112
+ it("starts runs as running, lists them newest first, and filters", async () => {
113
+ const store = await make();
114
+ const a = await store.createRun(newRun("s"));
115
+ await new Promise((r) => setTimeout(r, 5));
116
+ const b = await store.createRun(newRun("s"));
117
+ expect(a.status).toBe("RUNNING");
118
+ expect((await store.listRuns("s")).map((r) => r.id)).toEqual([
119
+ b.id,
120
+ a.id,
121
+ ]);
122
+ expect(await store.listRuns("s", { limit: 1 })).toHaveLength(1);
123
+ await store.updateRun(a.id, { status: "SUCCEEDED" });
124
+ expect(
125
+ (await store.listRuns("s", { status: "SUCCEEDED" })).map((r) => r.id),
126
+ ).toEqual([a.id]);
127
+ expect(await store.getRun("missing")).toBeNull();
128
+ });
129
+
130
+ it("updates a run only while it has the status it's guarded by", async () => {
131
+ const store = await make();
132
+ const r = await store.createRun(newRun("s"));
133
+ await store.updateRun(r.id, { status: "FAILED" }, { status: "RUNNING" });
134
+ await store.updateRun(
135
+ r.id,
136
+ { status: "SUCCEEDED" },
137
+ { status: "RUNNING" },
138
+ );
139
+ expect((await store.getRun(r.id))?.status).toBe("FAILED");
140
+ });
141
+
142
+ it("stores outputs and scores, and finds only originals to reuse", async () => {
143
+ const store = await make();
144
+ const c = await store.createCase(newCase("s"));
145
+ const r = await store.createRun(newRun("s"));
146
+ const base = {
147
+ runId: r.id,
148
+ caseId: c.id,
149
+ repeat: 1,
150
+ requestHash: "h",
151
+ output: { text: "HELLO" },
152
+ error: null,
153
+ scores: null,
154
+ costUsd: 0.01,
155
+ ms: 12,
156
+ reused: false,
157
+ };
158
+ const o = await store.createOutput(base);
159
+ await store.createOutput({ ...base, reused: true });
160
+ await store.createOutput({ ...base, error: "boom", output: null });
161
+ await store.setOutputScores(o.id, [{ key: "match", pass: true }]);
162
+ const outs = await store.listOutputs(r.id);
163
+ expect(outs).toHaveLength(3);
164
+ expect(outs.find((x) => x.id === o.id)?.scores).toEqual([
165
+ { key: "match", pass: true },
166
+ ]);
167
+ const found = await store.findReusableOutput("h", new Date(0));
168
+ expect(found?.id).toBe(o.id);
169
+ expect(found?.output).toEqual({ text: "HELLO" });
170
+ expect(
171
+ await store.findReusableOutput("h", new Date(Date.now() + 60_000)),
172
+ ).toBeNull();
173
+ });
174
+
175
+ it("runs a whole test through the runner", async () => {
176
+ const store = await make();
177
+ const echo: Suite<{ text: string }, { text: string }, { want: string }> =
178
+ {
179
+ key: "contract",
180
+ operation: "contract",
181
+ priority: { favor: "quality" },
182
+ label: "Contract",
183
+ description: "Uppercases text.",
184
+ graderVersion: "1",
185
+ input: z.object({ text: z.string() }),
186
+ labelValue: z.object({ want: z.string() }),
187
+ checks: [
188
+ {
189
+ key: "match",
190
+ label: "Matches",
191
+ description: "",
192
+ kind: "label",
193
+ severity: "serious",
194
+ },
195
+ ],
196
+ run: async (i) => ({ text: i.text.toUpperCase() }),
197
+ grade: async (_i, out, want) => [
198
+ { key: "match", pass: want ? out.text === want.want : null },
199
+ ],
200
+ inputFromForm: () => ({ text: "" }),
201
+ title: (i) => i.text,
202
+ };
203
+ await store.createCase(newCase("contract"));
204
+ const runner = createRunner({
205
+ store,
206
+ suites: [echo],
207
+ models: { "model-a": { label: "Model A", provider: "acme" } },
208
+ promptVersion: () => "v1",
209
+ });
210
+ const { run } = await runner.createRun({
211
+ suite: "contract",
212
+ model: "model-a",
213
+ });
214
+ await runner.runEval(run.id);
215
+ const done = await store.getRun(run.id);
216
+ expect(done?.status).toBe("SUCCEEDED");
217
+ expect(done?.metrics?.allPass.passed).toBe(1);
218
+ });
219
+ });
220
+ }
221
+
222
+ /**
223
+ * The contract every FeedbackStore must meet. `make` must return an empty
224
+ * store each time it's called.
225
+ */
226
+ export function describeFeedbackStoreContract(
227
+ name: string,
228
+ make: () => FeedbackStore | Promise<FeedbackStore>,
229
+ ) {
230
+ const rating = (over: Partial<NewRating> = {}): NewRating => ({
231
+ operation: "triage",
232
+ targetId: "answer-1",
233
+ raterId: "person-1",
234
+ rating: "DOWN",
235
+ reason: "Wrong queue",
236
+ excerpt: "billing",
237
+ input: { subject: "Refund", body: "Please refund me." },
238
+ output: { category: "bug" },
239
+ ...over,
240
+ });
241
+
242
+ describe(`FeedbackStore contract: ${name}`, () => {
243
+ it("keeps a rating with its reason, input, and output, open", async () => {
244
+ const store = await make();
245
+ const f = await store.rate(rating());
246
+ expect(f).toMatchObject({
247
+ operation: "triage",
248
+ rating: "DOWN",
249
+ reason: "Wrong queue",
250
+ excerpt: "billing",
251
+ input: { subject: "Refund", body: "Please refund me." },
252
+ output: { category: "bug" },
253
+ status: "OPEN",
254
+ caseId: null,
255
+ });
256
+ });
257
+
258
+ it("keeps one rating per person per answer, and reopens a changed one", async () => {
259
+ const store = await make();
260
+ const first = await store.rate(rating());
261
+ await store.setFeedbackStatus(first.id, "DISMISSED");
262
+ const changed = await store.rate(rating({ rating: "UP" }));
263
+ expect(changed.id).toBe(first.id);
264
+ expect(changed.status).toBe("OPEN");
265
+ expect(changed.reason).toBeNull();
266
+ await store.rate(rating({ raterId: "person-2" }));
267
+ expect(await store.listFeedback()).toHaveLength(2);
268
+ });
269
+
270
+ it("takes a rating back", async () => {
271
+ const store = await make();
272
+ await store.rate(rating());
273
+ await store.clearRating("person-1", "triage", "answer-1");
274
+ expect(await store.listFeedback()).toHaveLength(0);
275
+ });
276
+
277
+ it("filters by feature, rating, status, and date, newest first", async () => {
278
+ const store = await make();
279
+ const a = await store.rate(rating({ targetId: "a" }));
280
+ await new Promise((r) => setTimeout(r, 5));
281
+ const b = await store.rate(rating({ targetId: "b", rating: "UP" }));
282
+ await store.rate(rating({ targetId: "c", operation: "summary" }));
283
+ expect(
284
+ (await store.listFeedback({ operation: "triage" })).map((f) => f.id),
285
+ ).toEqual([b.id, a.id]);
286
+ expect(await store.listFeedback({ rating: "UP" })).toHaveLength(1);
287
+ await store.setFeedbackStatus(a.id, "CASED", "case-1");
288
+ const cased = await store.listFeedback({ status: "CASED" });
289
+ expect(cased.map((f) => [f.id, f.caseId])).toEqual([[a.id, "case-1"]]);
290
+ expect(
291
+ await store.listFeedback({ since: new Date(Date.now() + 60_000) }),
292
+ ).toHaveLength(0);
293
+ expect(await store.listFeedback({ limit: 1 })).toHaveLength(1);
294
+ });
295
+ });
296
+ }
297
+
298
+ /**
299
+ * The contract every SettingsStore must meet. `make` must return an empty
300
+ * store each time it's called.
301
+ */
302
+ export function describeSettingsStoreContract(
303
+ name: string,
304
+ make: () => SettingsStore | Promise<SettingsStore>,
305
+ ) {
306
+ const settings = (model: string): Settings => ({
307
+ model,
308
+ fallbackModel: null,
309
+ reasoningEffort: null,
310
+ maxOutputTokens: null,
311
+ });
312
+ const create = (model: string, expectLive: string | null) => ({
313
+ create: { settings: settings(model), promptVersion: "p1" },
314
+ kind: "CHANGE" as const,
315
+ reason: `to ${model}`,
316
+ by: "person-1",
317
+ expectLive,
318
+ });
319
+
320
+ describe(`SettingsStore contract: ${name}`, () => {
321
+ it("has nothing live until the first switch, then numbers versions", async () => {
322
+ const store = await make();
323
+ expect(await store.liveVersion("triage")).toBeNull();
324
+ const v1 = await store.switchTo("triage", create("a", null));
325
+ const v2 = await store.switchTo("triage", create("b", v1.id));
326
+ expect([v1.number, v2.number]).toEqual([1, 2]);
327
+ expect(v2).toMatchObject({
328
+ operation: "triage",
329
+ model: "b",
330
+ promptVersion: "p1",
331
+ });
332
+ expect((await store.liveVersion("triage"))?.id).toBe(v2.id);
333
+ expect((await store.listVersions("triage")).map((v) => v.number)).toEqual(
334
+ [2, 1],
335
+ );
336
+ });
337
+
338
+ it("logs every switch, newest first, with who, why, and evidence", async () => {
339
+ const store = await make();
340
+ const v1 = await store.switchTo("triage", create("a", null));
341
+ await store.switchTo("triage", {
342
+ ...create("b", v1.id),
343
+ kind: "PROMOTE",
344
+ evidence: { runId: "r1", verdict: "BETTER" },
345
+ });
346
+ const [latest, first] = await store.listChanges("triage");
347
+ expect(latest).toMatchObject({
348
+ kind: "PROMOTE",
349
+ fromVersionId: v1.id,
350
+ reason: "to b",
351
+ by: "person-1",
352
+ evidence: { runId: "r1", verdict: "BETTER" },
353
+ });
354
+ expect(first).toMatchObject({ kind: "CHANGE", fromVersionId: null });
355
+ expect(await store.listChanges("triage", { limit: 1 })).toHaveLength(1);
356
+ });
357
+
358
+ it("makes an earlier version live again without a new one", async () => {
359
+ const store = await make();
360
+ const v1 = await store.switchTo("triage", create("a", null));
361
+ const v2 = await store.switchTo("triage", create("b", v1.id));
362
+ await store.switchTo("triage", {
363
+ versionId: v1.id,
364
+ kind: "ROLLBACK",
365
+ reason: "b was worse",
366
+ by: "person-1",
367
+ expectLive: v2.id,
368
+ });
369
+ expect((await store.liveVersion("triage"))?.id).toBe(v1.id);
370
+ expect(await store.listVersions("triage")).toHaveLength(2);
371
+ });
372
+
373
+ it("refuses a switch decided against a live version that changed", async () => {
374
+ const store = await make();
375
+ const v1 = await store.switchTo("triage", create("a", null));
376
+ await store.switchTo("triage", create("b", v1.id));
377
+ await expect(
378
+ store.switchTo("triage", create("c", v1.id)),
379
+ ).rejects.toBeInstanceOf(SettingsConflict);
380
+ expect((await store.liveVersion("triage"))?.model).toBe("b");
381
+ expect(await store.listVersions("triage")).toHaveLength(2);
382
+ });
383
+
384
+ it("keeps each feature's versions and live one apart", async () => {
385
+ const store = await make();
386
+ await store.switchTo("triage", create("a", null));
387
+ const other = await store.switchTo("summary", create("x", null));
388
+ expect(other.number).toBe(1);
389
+ expect((await store.liveVersion("triage"))?.model).toBe("a");
390
+ });
391
+ });
392
+ }
package/src/timeout.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Rejects if the call takes longer than `ms`. Pure. */
2
+ export function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
3
+ let timer: ReturnType<typeof setTimeout>;
4
+ return Promise.race([
5
+ p,
6
+ new Promise<never>((_, reject) => {
7
+ timer = setTimeout(() => {
8
+ reject(new Error(`No answer within ${ms / 1000}s`));
9
+ }, ms);
10
+ }),
11
+ ]).finally(() => {
12
+ clearTimeout(timer);
13
+ });
14
+ }
package/src/types.ts ADDED
@@ -0,0 +1,252 @@
1
+ import type { z } from "zod";
2
+ import type { ReasoningEffort } from "./models.js";
3
+
4
+ /**
5
+ * An eval suite: how to run one AI operation on a frozen case and how to
6
+ * grade what comes back. Code says how to evaluate; the database holds the
7
+ * cases and labels (EvalCase, EvalCaseLabel). See docs/evals.md.
8
+ */
9
+
10
+ /** One check on one output. null: doesn't apply to this case. */
11
+ export type CheckResult = {
12
+ key: string;
13
+ pass: boolean | null;
14
+ /** Why it failed (or a detail worth showing), in plain words. */
15
+ note?: string | undefined;
16
+ /** For matching checks: which expected item matched which output, and why. */
17
+ matches?: { expected: string; actual: string; why: string }[] | undefined;
18
+ /** "meaning": a judge model matched them; "words": shared words did. */
19
+ matchedBy?: "meaning" | "words" | undefined;
20
+ /**
21
+ * Partial credit: of the parts this answer should have (e.g. expected
22
+ * items), how many it got right. The headline score sums these.
23
+ */
24
+ units?: { right: number; total: number } | undefined;
25
+ /**
26
+ * Numbers measured on this answer, averaged across a test: a decision's
27
+ * `brier`, `logLoss`, `confidence`, and `correct` (see decisionCheck).
28
+ */
29
+ measures?: Record<string, number> | undefined;
30
+ };
31
+
32
+ /**
33
+ * One check a suite runs: what it checks, whether it needs an answer, and how serious a
34
+ * miss is.
35
+ */
36
+ export type CheckDef = {
37
+ key: string;
38
+ label: string;
39
+ /** What it checks, for the page that explains the suite. */
40
+ description: string;
41
+ /** "code": needs no label. "label": compares with a confirmed label. */
42
+ kind: "code" | "label";
43
+ /**
44
+ * Serious: changes what the product does (which queue a ticket lands
45
+ * in, what a summary claims). Minor: a fair difference of judgment (a category, how finely
46
+ * to split). Models are ranked on serious mistakes.
47
+ */
48
+ severity: "serious" | "minor";
49
+ /** Pass rate it should reach; default by severity (checkBar). */
50
+ bar?: number;
51
+ };
52
+
53
+ /** The pass rate a check should reach: its own, else 95% serious, 75% minor. */
54
+ export function checkBar(c: CheckDef): number {
55
+ return c.bar ?? (c.severity === "serious" ? 0.95 : 0.75);
56
+ }
57
+
58
+ /** Meets the bar, is within 10 points of it, or is further below. */
59
+ export function barStatus(
60
+ rate: number,
61
+ bar: number,
62
+ ): "meets" | "close" | "below" {
63
+ return rate >= bar ? "meets" : rate >= bar - 0.1 ? "close" : "below";
64
+ }
65
+
66
+ /**
67
+ * What a suite cares about once accuracy holds. Accuracy is always the
68
+ * floor; this is the one thing worth switching models for beyond it.
69
+ */
70
+ export type SuitePriority = {
71
+ /** speed: someone waits on it. cost: runs in the background, often.
72
+ * quality: people read and act on it, so only accuracy counts. */
73
+ favor: "speed" | "cost" | "quality";
74
+ /** Models slower than this on average are never suggested. */
75
+ maxSeconds?: number;
76
+ };
77
+
78
+ /** Each priority, explained for the suite page. */
79
+ export const FAVOR_TEXT: Record<SuitePriority["favor"], string> = {
80
+ speed:
81
+ "speed: someone waits on this, so a model that scores as well and answers clearly faster is worth switching to, and so is one about as accurate (within 10 points) in half the time",
82
+ cost: "cost: this runs in the background, often, so a model that scores as well and is clearly cheaper is worth switching to, and so is one about as accurate (within 10 points) at half the cost",
83
+ quality:
84
+ "quality: people read and act on this, so only clearly better accuracy or fewer serious mistakes is worth switching for",
85
+ };
86
+
87
+ /**
88
+ * An eval suite: how to call one AI feature on a model and how to grade what comes back.
89
+ * Code says how to evaluate; the store holds the cases and answers.
90
+ */
91
+ export type Suite<Input, Output, Label> = {
92
+ key: string;
93
+ /** The AI feature it tests, as your app names it ("summarize_ticket"). */
94
+ operation: string;
95
+ priority: SuitePriority;
96
+ label: string;
97
+ description: string;
98
+ /** Bump when checks change, so runs say which grading they used. */
99
+ graderVersion: string;
100
+ input: z.ZodType<Input>;
101
+ /** The EXPECTED label's value. */
102
+ labelValue: z.ZodType<Label>;
103
+ checks: CheckDef[];
104
+ /** Calls the operation on exactly this model, recorded as an eval. */
105
+ run(
106
+ input: Input,
107
+ ctx: {
108
+ model: string;
109
+ /** A reasoning setting to test; undefined: the operation's own. */
110
+ reasoningEffort?: ReasoningEffort | null;
111
+ onCost: (usd: number) => void;
112
+ },
113
+ ): Promise<Output>;
114
+ /**
115
+ * Grades one output. `label` is the confirmed one, or null. A check that
116
+ * asks a judge model reports its cost through `ctx.onCost`.
117
+ */
118
+ grade(
119
+ input: Input,
120
+ output: Output,
121
+ label: Label | null,
122
+ ctx?: { onCost: (usd: number) => void },
123
+ ): Promise<CheckResult[]>;
124
+ /**
125
+ * Checks that compare a test's answers with each other, such as ranking.
126
+ * Gets every graded answer of one repeat; returns extra checks for each,
127
+ * in the same order. Pure; runs after a test and on every regrade.
128
+ */
129
+ gradeTogether?(
130
+ answers: { input: Input; output: Output; label: Label | null }[],
131
+ ): CheckResult[][];
132
+ /** Reads a case's input from the add/edit form; throws on bad input. */
133
+ inputFromForm(form: FormData): Input;
134
+ /** Reads an answer from the add/edit form; throws on bad input. */
135
+ labelFromForm?(form: FormData): Label;
136
+ /**
137
+ * The operation's output as an answer, so a thumbs-up can propose it
138
+ * (pending until a person confirms it). Omit when outputs and answers
139
+ * differ too much to propose one.
140
+ */
141
+ labelFromOutput?(output: Output): Label;
142
+ /**
143
+ * The form's fields, so pages can render one for any suite. Every field
144
+ * has a plain label and help: people confirming answers weeks later
145
+ * won't remember what a field means.
146
+ */
147
+ fields?: { input: FormField[]; label: FormField[] };
148
+ /** Drafted cases to start from, added as drafts with PENDING labels. */
149
+ starter?: {
150
+ input: Input;
151
+ label: Label;
152
+ tags: string[];
153
+ split: "DEV" | "TEST";
154
+ smoke?: boolean;
155
+ note: string;
156
+ }[];
157
+ /**
158
+ * Drafts new cases aimed at what the answer key is missing, for a person
159
+ * to review (they arrive with PENDING answers).
160
+ */
161
+ draftMore?(args: {
162
+ existing: { input: Input; tags: string[] }[];
163
+ count: number;
164
+ focus: string | null;
165
+ }): Promise<{ input: Input; label: Label; tags: string[]; note: string }[]>;
166
+ /**
167
+ * How long one answer may take before it counts as failed. Default 60s;
168
+ * multi-step agent turns need more. Keep it under the run budget.
169
+ */
170
+ answerTimeoutMs?: number;
171
+ /**
172
+ * How many answers a test asks for at once. Default 6; slow operations
173
+ * need more to finish before the platform's time limit.
174
+ */
175
+ concurrency?: number;
176
+ /**
177
+ * Graded as decisions (typed answers with probabilities, as from the AI
178
+ * SDK's `experimental_decide`), so pages show probability quality and
179
+ * calibration next to accuracy, and which models' probabilities are
180
+ * native or prompted.
181
+ */
182
+ decisions?: boolean;
183
+ /** What the headline counts: "items", "tickets", … Default "answers". */
184
+ unitNoun?: string;
185
+ /**
186
+ * "typed": cases are text someone types (added by hand, or drafted).
187
+ * "imported": cases are frozen from the app's own data, then rated by a
188
+ * person.
189
+ * "fixture": cases are structured inputs, shown with describe() and
190
+ * edited as JSON.
191
+ */
192
+ caseKind?: "typed" | "imported" | "fixture";
193
+ /** A short line naming the case in lists. */
194
+ title(input: Input): string;
195
+ /** Fixture suites: the whole input, readable, for reviewing a case. */
196
+ describe?(input: Input): string;
197
+ /** Fixture suites: what the expected answer's fields mean. */
198
+ labelHelp?: string;
199
+ };
200
+
201
+ /**
202
+ * One field of a suite's add/edit form. `name` is the form key (and the
203
+ * input or answer property it fills). "lines": one item per line, read as
204
+ * a list.
205
+ */
206
+ export type FormField = {
207
+ name: string;
208
+ label: string;
209
+ help: string;
210
+ kind: "text" | "textarea" | "select" | "lines";
211
+ /** For "select". */
212
+ options?: readonly string[];
213
+ required?: boolean;
214
+ };
215
+
216
+ /** A value as plain text: strings as they are, other values as JSON. */
217
+ export function asText(v: unknown): string {
218
+ if (v === undefined || v === null) return "";
219
+ if (typeof v === "string") return v;
220
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
221
+ return JSON.stringify(v);
222
+ }
223
+
224
+ /** A form value as text: "" when missing or a file. */
225
+ export function formText(form: FormData, name: string): string {
226
+ const v = form.get(name);
227
+ return typeof v === "string" ? v.trim() : "";
228
+ }
229
+
230
+ /** A field's value from a stored input or answer, as the form shows it. */
231
+ export function fieldValue(field: FormField, value: unknown): string {
232
+ const v =
233
+ value && typeof value === "object"
234
+ ? (value as Record<string, unknown>)[field.name]
235
+ : undefined;
236
+ if (Array.isArray(v)) return v.map(asText).join("\n");
237
+ return asText(v);
238
+ }
239
+
240
+ /** A "lines" field's value as a list: one item per non-empty line. */
241
+ export function linesOf(form: FormData, name: string): string[] {
242
+ return formText(form, name)
243
+ .split("\n")
244
+ .map((l) => l.trim())
245
+ .filter(Boolean);
246
+ }
247
+
248
+ /**
249
+ * A suite of any shape, for registries. Its methods take and return
250
+ * `unknown`; a suite's own code keeps its precise types.
251
+ */
252
+ export type AnySuite = Suite<unknown, unknown, unknown>;