@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
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The core: suites, checks, stats, comparisons, suggestions, health, feedback,
3
+ * versioned settings, and the store interfaces. Pure: no database,
4
+ * framework, or provider.
5
+ *
6
+ * @module evalkeeper
7
+ */
8
+ export * from "./types.js";
9
+ export * from "./models.js";
10
+ export * from "./board.js";
11
+ export * from "./stats.js";
12
+ export * from "./compare.js";
13
+ export * from "./summary.js";
14
+ export * from "./suggest.js";
15
+ export * from "./health.js";
16
+ export * from "./attention.js";
17
+ export * from "./common.js";
18
+ export * from "./legs.js";
19
+ export * from "./timeout.js";
20
+ export * from "./prune.js";
21
+ export * from "./quickSet.js";
22
+ export * from "./stableJson.js";
23
+ export * from "./upkeepRules.js";
24
+ export * from "./format.js";
25
+ export * from "./glossary.js";
26
+ export * from "./feedback.js";
27
+ export * from "./weekly.js";
28
+ export * from "./settings.js";
29
+ export * from "./decisions.js";
30
+ export * from "./store.js";
31
+ export * from "./labels.js";
32
+ // Server-only (node:crypto): import from "@crowandraven/evalkeeper/runner".
33
+ // In-memory store for tests and demos: "@crowandraven/evalkeeper/memory".
package/src/labels.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { LabelRecord } from "./store.js";
2
+
3
+ /**
4
+ * The EXPECTED answer tests use: the newest confirmed one, or failing that a
5
+ * provisional one (put to use before review). Pending answers never count.
6
+ */
7
+ export function confirmedLabel(labels: LabelRecord[]): LabelRecord | null {
8
+ const newest = (status: LabelRecord["status"]) =>
9
+ labels
10
+ .filter((l) => l.status === status)
11
+ .sort((a, b) => +b.updatedAt - +a.updatedAt)[0] ?? null;
12
+ return newest("CONFIRMED") ?? newest("PROVISIONAL");
13
+ }
14
+
15
+ /** In use on an answer nobody has reviewed yet. */
16
+ export function isProvisional(labels: LabelRecord[]): boolean {
17
+ return confirmedLabel(labels)?.status === "PROVISIONAL";
18
+ }
package/src/legs.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A test longer than one request's time limit runs in legs: each leg
3
+ * answers what it can, then hands the rest to a fresh request. Pure.
4
+ */
5
+
6
+ /** At most this many legs (about half an hour) before a test gives up. */
7
+ export const MAX_LEGS = 6;
8
+
9
+ /**
10
+ * Whether a leg that ran out of time should hand off: only when it made
11
+ * progress (so a stuck case can't loop) and legs remain.
12
+ */
13
+ export function shouldContinue(opts: {
14
+ /** Answers left for a later leg. */
15
+ left: number;
16
+ /** Answers this leg gave. */
17
+ done: number;
18
+ leg: number;
19
+ outOfCredits: boolean;
20
+ }): boolean {
21
+ return (
22
+ opts.left > 0 && opts.done > 0 && !opts.outOfCredits && opts.leg < MAX_LEGS
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Whether a test still marked running was stopped by the server: nothing
28
+ * started or answered within `stuckMs`. Legs keep it alive.
29
+ */
30
+ export function isStuck(
31
+ startedAt: Date,
32
+ lastAnswerAt: Date | null,
33
+ now: number,
34
+ stuckMs: number,
35
+ ): boolean {
36
+ const last = Math.max(+startedAt, lastAnswerAt ? +lastAnswerAt : 0);
37
+ return now - last > stuckMs;
38
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * An in-memory store, for tests, demos, and trying Evalkeeper before adding
3
+ * tables.
4
+ *
5
+ * @module evalkeeper/memory
6
+ */
7
+ import type { FeedbackRecord, FeedbackStore } from "./feedback.js";
8
+ import { cleanRating } from "./feedback.js";
9
+ import {
10
+ SettingsConflict,
11
+ type SettingsChange,
12
+ type SettingsStore,
13
+ type SettingsVersion,
14
+ } from "./settings.js";
15
+ import type {
16
+ CaseRecord,
17
+ EvalStore,
18
+ OutputRecord,
19
+ RunRecord,
20
+ } from "./store.js";
21
+
22
+ /**
23
+ * An EvalStore in memory: for tests, demos, and trying Evalkeeper before
24
+ * adding tables. Everything is gone when the process ends.
25
+ */
26
+ export function memoryStore(): EvalStore &
27
+ FeedbackStore &
28
+ SettingsStore & {
29
+ cases: CaseRecord[];
30
+ runs: RunRecord[];
31
+ outputs: OutputRecord[];
32
+ feedback: FeedbackRecord[];
33
+ versions: SettingsVersion[];
34
+ changes: SettingsChange[];
35
+ } {
36
+ const cases: CaseRecord[] = [];
37
+ const runs: RunRecord[] = [];
38
+ const outputs: OutputRecord[] = [];
39
+ const feedback: FeedbackRecord[] = [];
40
+ const versions: SettingsVersion[] = [];
41
+ const changes: SettingsChange[] = [];
42
+ const live = new Map<string, string>();
43
+ let n = 0;
44
+ const id = (prefix: string) => `${prefix}_${(++n).toString(36)}`;
45
+ const now = () => new Date();
46
+
47
+ return {
48
+ cases,
49
+ runs,
50
+ outputs,
51
+ feedback,
52
+ versions,
53
+ changes,
54
+ async liveVersion(operation) {
55
+ const id = live.get(operation);
56
+ return versions.find((v) => v.id === id) ?? null;
57
+ },
58
+ async listVersions(operation) {
59
+ return versions
60
+ .filter((v) => v.operation === operation)
61
+ .sort((a, b) => b.number - a.number);
62
+ },
63
+ async listChanges(operation, opts = {}) {
64
+ // Kept in the order they happened, so newest first is the reverse.
65
+ return changes
66
+ .filter((c) => c.operation === operation)
67
+ .reverse()
68
+ .slice(0, opts.limit ?? Infinity);
69
+ },
70
+ async switchTo(operation, sw) {
71
+ // Synchronous from here on, so it's atomic in memory.
72
+ const fromId = live.get(operation) ?? null;
73
+ if (fromId !== sw.expectLive) throw new SettingsConflict(operation);
74
+ let to: SettingsVersion | undefined;
75
+ if (sw.create) {
76
+ const last = versions
77
+ .filter((v) => v.operation === operation)
78
+ .reduce((n, v) => Math.max(n, v.number), 0);
79
+ to = {
80
+ ...sw.create.settings,
81
+ id: id("ver"),
82
+ operation,
83
+ number: last + 1,
84
+ promptVersion: sw.create.promptVersion,
85
+ createdAt: now(),
86
+ };
87
+ versions.push(to);
88
+ } else {
89
+ to = versions.find(
90
+ (v) => v.id === sw.versionId && v.operation === operation,
91
+ );
92
+ if (!to) throw new Error("No such version for this feature");
93
+ }
94
+ live.set(operation, to.id);
95
+ changes.push({
96
+ id: id("chg"),
97
+ operation,
98
+ kind: sw.kind,
99
+ fromVersionId: fromId,
100
+ toVersionId: to.id,
101
+ reason: sw.reason,
102
+ by: sw.by,
103
+ evidence: sw.evidence ?? null,
104
+ createdAt: now(),
105
+ });
106
+ return to;
107
+ },
108
+ async rate(r) {
109
+ const c = cleanRating(r);
110
+ const at = now();
111
+ const prior = feedback.find(
112
+ (f) =>
113
+ f.raterId === c.raterId &&
114
+ f.operation === c.operation &&
115
+ f.targetId === c.targetId,
116
+ );
117
+ if (prior) {
118
+ // A changed rating needs a fresh look.
119
+ if (prior.rating !== c.rating) {
120
+ prior.status = "OPEN";
121
+ prior.caseId = null;
122
+ }
123
+ Object.assign(prior, c, { updatedAt: at });
124
+ return prior;
125
+ }
126
+ const f: FeedbackRecord = {
127
+ ...c,
128
+ id: id("fb"),
129
+ status: "OPEN",
130
+ caseId: null,
131
+ createdAt: at,
132
+ updatedAt: at,
133
+ };
134
+ feedback.push(f);
135
+ return f;
136
+ },
137
+ async clearRating(raterId, operation, targetId) {
138
+ const i = feedback.findIndex(
139
+ (f) =>
140
+ f.raterId === raterId &&
141
+ f.operation === operation &&
142
+ f.targetId === targetId,
143
+ );
144
+ if (i >= 0) feedback.splice(i, 1);
145
+ },
146
+ async listFeedback(opts = {}) {
147
+ return feedback
148
+ .filter(
149
+ (f) =>
150
+ (!opts.operation || f.operation === opts.operation) &&
151
+ (!opts.rating || f.rating === opts.rating) &&
152
+ (!opts.status || f.status === opts.status) &&
153
+ (!opts.since || f.updatedAt >= opts.since),
154
+ )
155
+ .sort((a, b) => +b.updatedAt - +a.updatedAt)
156
+ .slice(0, opts.limit ?? Infinity);
157
+ },
158
+ async setFeedbackStatus(feedbackId, status, caseId = null) {
159
+ const f = feedback.find((x) => x.id === feedbackId);
160
+ if (f) {
161
+ f.status = status;
162
+ f.caseId = caseId;
163
+ f.updatedAt = now();
164
+ }
165
+ },
166
+ async listCases(suite) {
167
+ return cases
168
+ .filter((c) => c.suite === suite && c.status !== "RETIRED")
169
+ .sort((a, b) => +a.createdAt - +b.createdAt);
170
+ },
171
+ async createCase({ labels = [], ...data }) {
172
+ const at = now();
173
+ const c: CaseRecord = {
174
+ ...data,
175
+ id: id("case"),
176
+ createdAt: at,
177
+ updatedAt: at,
178
+ labels: labels.map((l) => ({
179
+ ...l,
180
+ id: id("label"),
181
+ createdAt: at,
182
+ updatedAt: at,
183
+ })),
184
+ };
185
+ cases.push(c);
186
+ return c;
187
+ },
188
+ async addLabel(caseId, data) {
189
+ const c = cases.find((x) => x.id === caseId);
190
+ if (!c) throw new Error(`Unknown case ${caseId}`);
191
+ const at = now();
192
+ const l = { ...data, id: id("label"), createdAt: at, updatedAt: at };
193
+ c.labels.push(l);
194
+ c.updatedAt = at;
195
+ return l;
196
+ },
197
+ async setLabelStatus(labelId, status) {
198
+ for (const c of cases)
199
+ for (const l of c.labels)
200
+ if (l.id === labelId) {
201
+ l.status = status;
202
+ l.updatedAt = now();
203
+ }
204
+ },
205
+ async setCaseStatus(caseId, status) {
206
+ const c = cases.find((x) => x.id === caseId);
207
+ if (c) {
208
+ c.status = status;
209
+ c.updatedAt = now();
210
+ }
211
+ },
212
+ async getRun(runId) {
213
+ return runs.find((r) => r.id === runId) ?? null;
214
+ },
215
+ async listRuns(suite, opts = {}) {
216
+ return runs
217
+ .filter(
218
+ (r) =>
219
+ r.suite === suite && (!opts.status || r.status === opts.status),
220
+ )
221
+ .sort((a, b) => +b.startedAt - +a.startedAt)
222
+ .slice(0, opts.limit ?? Infinity);
223
+ },
224
+ async createRun(data) {
225
+ const r: RunRecord = {
226
+ ...data,
227
+ id: id("run"),
228
+ status: "RUNNING",
229
+ metrics: null,
230
+ error: null,
231
+ startedAt: now(),
232
+ finishedAt: null,
233
+ };
234
+ runs.push(r);
235
+ return r;
236
+ },
237
+ async updateRun(runId, patch, onlyIf) {
238
+ const r = runs.find((x) => x.id === runId);
239
+ if (!r || (onlyIf && r.status !== onlyIf.status)) return;
240
+ Object.assign(r, patch);
241
+ },
242
+ async listOutputs(runId) {
243
+ return outputs.filter((o) => o.runId === runId);
244
+ },
245
+ async findReusableOutput(requestHash, since) {
246
+ return (
247
+ outputs
248
+ .filter(
249
+ (o) =>
250
+ o.requestHash === requestHash &&
251
+ !o.reused &&
252
+ !o.error &&
253
+ o.createdAt >= since,
254
+ )
255
+ .sort((a, b) => +b.createdAt - +a.createdAt)[0] ?? null
256
+ );
257
+ },
258
+ async createOutput(data) {
259
+ const o: OutputRecord = { ...data, id: id("out"), createdAt: now() };
260
+ outputs.push(o);
261
+ return o;
262
+ },
263
+ async setOutputScores(outputId, scores) {
264
+ const o = outputs.find((x) => x.id === outputId);
265
+ if (o) o.scores = scores;
266
+ },
267
+ };
268
+ }
package/src/models.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * What Evalkeeper needs to know about the models your app can use. You
3
+ * pass a catalog in; Evalkeeper never picks a provider itself. Pure.
4
+ */
5
+
6
+ /** How hard a reasoning model thinks, where the model takes a setting. */
7
+ export const REASONING_EFFORTS = ["none", "low", "medium", "high"] as const;
8
+ /** A reasoning setting a model may accept. */
9
+ export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
10
+
11
+ /**
12
+ * What Evalkeeper needs to know about one model: its label, provider, prices, reasoning
13
+ * settings, and retirement date.
14
+ */
15
+ export type ModelInfo = {
16
+ label: string;
17
+ /** Who serves it ("anthropic", "openai", …): the fallback must differ. */
18
+ provider: string;
19
+ /** USD per 1M tokens, for estimates and the cost of each answer. */
20
+ inputPer1M?: number;
21
+ outputPer1M?: number;
22
+ /**
23
+ * USD per 1M input tokens read from the provider's prompt cache. Unset:
24
+ * cached input costs `inputPer1M`, which overstates rather than flatters.
25
+ */
26
+ cachedInputPer1M?: number;
27
+ /**
28
+ * USD per 1M input tokens written to the prompt cache (Anthropic bills
29
+ * these above the input price). Unset: they cost `inputPer1M`.
30
+ */
31
+ cacheWriteInputPer1M?: number;
32
+ /** Reasoning efforts it accepts, lowest first. */
33
+ efforts?: ReasoningEffort[];
34
+ /**
35
+ * When the provider retires it, as "YYYY-MM" or "YYYY-MM-DD". A retiring
36
+ * model is never suggested, and a retiring live model gets replaced.
37
+ */
38
+ retiring?: string;
39
+ /** Where the price came from, so it can be checked. */
40
+ source?: string;
41
+ /**
42
+ * As a decision model: "native" probabilities come from the model itself
43
+ * (a full distribution for choices and scales); "prompted" ones are the
44
+ * model's own estimate written out, with no distribution for choices.
45
+ */
46
+ decisions?: "native" | "prompted";
47
+ /**
48
+ * Code your app runs instead of a model (a matcher, a rules engine) or
49
+ * alongside it, tested so the board shows whether a model beats it. A
50
+ * baseline is compared like any model but never suggested as live or as
51
+ * the fallback. Give it a label that says what it is.
52
+ */
53
+ baseline?: boolean;
54
+ };
55
+
56
+ /** Your app's models, by id. */
57
+ export type ModelCatalog = Record<string, ModelInfo>;
58
+
59
+ /** Whether a catalog entry is a baseline (code, not a model): never suggested. */
60
+ export function isBaseline(models: ModelCatalog, model: string): boolean {
61
+ return models[model]?.baseline === true;
62
+ }
63
+
64
+ /** "January 2027" for a retiring model; null when it isn't retiring. */
65
+ export function retiresLabel(
66
+ models: ModelCatalog,
67
+ model: string,
68
+ ): string | null {
69
+ const when = models[model]?.retiring;
70
+ if (!when) return null;
71
+ const [y = 1970, m = 1] = when.split("-").map(Number);
72
+ return new Date(Date.UTC(y, (m || 1) - 1, 1)).toLocaleDateString("en-US", {
73
+ month: "long",
74
+ year: "numeric",
75
+ timeZone: "UTC",
76
+ });
77
+ }
78
+
79
+ /**
80
+ * The cost of a call from its token counts; null when the price is unknown.
81
+ * `cachedInput` and `cacheWriteInput` are the parts of `input` read from
82
+ * and written to the provider's prompt cache, priced at the model's cache
83
+ * prices when it has them.
84
+ */
85
+ export function priceCall(
86
+ models: ModelCatalog,
87
+ model: string,
88
+ tokens: {
89
+ input: number;
90
+ output: number;
91
+ cachedInput?: number;
92
+ cacheWriteInput?: number;
93
+ },
94
+ ): number | null {
95
+ const m = models[model];
96
+ if (m?.inputPer1M === undefined || m.outputPer1M === undefined) return null;
97
+ // The cache parts are subsets of input: never count more than it has.
98
+ const cached = Math.min(Math.max(tokens.cachedInput ?? 0, 0), tokens.input);
99
+ const written = Math.min(
100
+ Math.max(tokens.cacheWriteInput ?? 0, 0),
101
+ tokens.input - cached,
102
+ );
103
+ const plain = tokens.input - cached - written;
104
+ return (
105
+ (plain * m.inputPer1M +
106
+ cached * (m.cachedInputPer1M ?? m.inputPer1M) +
107
+ written * (m.cacheWriteInputPer1M ?? m.inputPer1M) +
108
+ tokens.output * m.outputPer1M) /
109
+ 1e6
110
+ );
111
+ }
112
+
113
+ /**
114
+ * The effort a model actually gets for a requested one: the same if it
115
+ * takes it; for "none" on a model without it, its lowest; otherwise its own
116
+ * default. Undefined: send nothing.
117
+ */
118
+ export function effortFor(
119
+ models: ModelCatalog,
120
+ model: string,
121
+ requested: ReasoningEffort | null | undefined,
122
+ ): ReasoningEffort | undefined {
123
+ const efforts = models[model]?.efforts;
124
+ if (!requested || !efforts) return undefined;
125
+ if (efforts.includes(requested)) return requested;
126
+ return requested === "none" ? efforts[0] : undefined;
127
+ }
package/src/prune.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { stableJson } from "./stableJson.js";
2
+ import { at } from "./util.js";
3
+
4
+ /**
5
+ * Keeping an answer key useful: cases every model gets right (easy), cases
6
+ * models split on (worth a review), near-duplicates, drafts nobody
7
+ * reviewed, and how the tags spread. Pure; the suite page shows these and
8
+ * the person decides, except that stale drafts retire themselves weekly
9
+ * (retiring is reversible).
10
+ */
11
+
12
+ /** Easy needs at least this many current models, all fully right. */
13
+ export const EASY_MIN_MODELS = 3;
14
+ /** Split: this many models or more, with 40–60% fully right. */
15
+ export const SPLIT_MIN_MODELS = 4;
16
+ /** Easy cases kept as a regression floor, as a share of cases in use. */
17
+ export const EASY_FLOOR = 0.1;
18
+ /** Drafts unreviewed this long retire. */
19
+ export const STALE_DRAFT_DAYS = 30;
20
+ /** Inputs this similar (shared words) are near-duplicates. */
21
+ export const DUPLICATE_SIMILARITY = 0.9;
22
+
23
+ /** How one case did across the models tested on it. */
24
+ export type CaseResult = { passed: number; models: number };
25
+
26
+ /**
27
+ * A case every tested model got right (with enough models): it no longer tells them apart.
28
+ */
29
+ export function isEasy(r: CaseResult | undefined): boolean {
30
+ return (
31
+ r !== undefined && r.models >= EASY_MIN_MODELS && r.passed === r.models
32
+ );
33
+ }
34
+
35
+ /** A case some models get right and others wrong: the most useful kind. */
36
+ export function isSplit(r: CaseResult | undefined): boolean {
37
+ if (!r || r.models < SPLIT_MIN_MODELS) return false;
38
+ const share = r.passed / r.models;
39
+ return share >= 0.4 && share <= 0.6;
40
+ }
41
+
42
+ /**
43
+ * Easy cases to retire, keeping a floor (smoke cases first, then the
44
+ * oldest) so the suite still catches a regression on the basics.
45
+ */
46
+ export function easyToRetire<
47
+ C extends { id: string; smoke: boolean; createdAt: Date },
48
+ >(easy: C[], inUse: number): C[] {
49
+ const keep = Math.max(1, Math.ceil(inUse * EASY_FLOOR));
50
+ const ordered = [...easy].sort(
51
+ (a, b) => Number(b.smoke) - Number(a.smoke) || +a.createdAt - +b.createdAt,
52
+ );
53
+ return ordered.slice(keep);
54
+ }
55
+
56
+ const words = (input: unknown) =>
57
+ new Set(
58
+ stableJson(input)
59
+ .toLowerCase()
60
+ .match(/[a-z0-9]{3,}/g) ?? [],
61
+ );
62
+
63
+ /** Pairs of cases whose inputs share nearly all their words. */
64
+ export function nearDuplicates<C extends { id: string; input: unknown }>(
65
+ cases: C[],
66
+ ): [C, C][] {
67
+ const sets = cases.map((c) => ({ c, w: words(c.input) }));
68
+ const pairs: [C, C][] = [];
69
+ for (let i = 0; i < sets.length; i++)
70
+ for (let j = i + 1; j < sets.length; j++) {
71
+ const x = at(sets, i);
72
+ const y = at(sets, j);
73
+ const a = x.w;
74
+ const b = y.w;
75
+ let shared = 0;
76
+ for (const w of a) if (b.has(w)) shared++;
77
+ const union = a.size + b.size - shared;
78
+ if (union && shared / union >= DUPLICATE_SIMILARITY)
79
+ pairs.push([x.c, y.c]);
80
+ }
81
+ return pairs;
82
+ }
83
+
84
+ /** A draft nobody has reviewed for long enough to retire. */
85
+ export function isStaleDraft(createdAt: Date, today: Date): boolean {
86
+ return +today - +createdAt > STALE_DRAFT_DAYS * 86_400_000;
87
+ }
88
+
89
+ /** How many cases in use carry each tag, most first. */
90
+ export function tagCounts(cases: { tags: string[] }[]): [string, number][] {
91
+ const counts = new Map<string, number>();
92
+ for (const c of cases)
93
+ for (const t of c.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
94
+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
95
+ }
@@ -0,0 +1,55 @@
1
+ import { at } from "./util.js";
2
+ /**
3
+ * The quick-check set (internally SMOKE): a few cases for a cheap first
4
+ * look, picked by the app rather than by hand. Greedy: confirmed cases
5
+ * first, each pick covering the most tags not yet covered, oldest first
6
+ * on ties, so the set is stable and spans what the dataset tests. Pure.
7
+ */
8
+
9
+ /** How many cases the quick set holds. */
10
+ export const QUICK_SET_SIZE = 8;
11
+
12
+ /**
13
+ * Picks the quick set: confirmed cases first, each pick covering the most tags not yet
14
+ * covered, oldest first on ties, so the set is stable.
15
+ */
16
+ export function pickQuickSet<
17
+ T extends { id: string; tags: string[]; createdAt: Date; confirmed: boolean },
18
+ >(cases: T[], size = QUICK_SET_SIZE): T[] {
19
+ const pool = [...cases].sort(
20
+ (a, b) =>
21
+ Number(b.confirmed) - Number(a.confirmed) ||
22
+ +a.createdAt - +b.createdAt ||
23
+ a.id.localeCompare(b.id),
24
+ );
25
+ const covered = new Set<string>();
26
+ const picked: T[] = [];
27
+ while (picked.length < size && pool.length) {
28
+ let best = 0;
29
+ let bestGain = -1;
30
+ pool.forEach((c, i) => {
31
+ const gain =
32
+ (c.confirmed ? 100 : 0) + c.tags.filter((t) => !covered.has(t)).length;
33
+ if (gain > bestGain) {
34
+ bestGain = gain;
35
+ best = i;
36
+ }
37
+ });
38
+ const c = at(pool.splice(best, 1), 0);
39
+ c.tags.forEach((t) => covered.add(t));
40
+ picked.push(c);
41
+ }
42
+ return picked;
43
+ }
44
+
45
+ /**
46
+ * DEV or TEST for a new case: keeps about 40% of a suite's cases in TEST,
47
+ * the set decisions are made on and instructions are never tuned against.
48
+ */
49
+ export function nextSplit(counts: {
50
+ DEV: number;
51
+ TEST: number;
52
+ }): "DEV" | "TEST" {
53
+ const total = counts.DEV + counts.TEST;
54
+ return counts.TEST < Math.round((total + 1) * 0.4) ? "TEST" : "DEV";
55
+ }