@crowandraven/evalkeeper 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +2 -2
  2. package/dist/attention.js +2 -1
  3. package/dist/attention.js.map +1 -1
  4. package/dist/board.d.ts +2 -0
  5. package/dist/glossary.d.ts +1 -1
  6. package/dist/glossary.js +1 -1
  7. package/dist/glossary.js.map +1 -1
  8. package/dist/health.js +1 -1
  9. package/dist/health.js.map +1 -1
  10. package/dist/importCases.d.ts +94 -0
  11. package/dist/importCases.js +185 -0
  12. package/dist/importCases.js.map +1 -0
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/labels.d.ts +9 -3
  17. package/dist/labels.js +2 -0
  18. package/dist/labels.js.map +1 -1
  19. package/dist/memoryStore.js +3 -2
  20. package/dist/memoryStore.js.map +1 -1
  21. package/dist/models.d.ts +44 -7
  22. package/dist/models.js +48 -7
  23. package/dist/models.js.map +1 -1
  24. package/dist/runner.d.ts +25 -1
  25. package/dist/runner.js +64 -7
  26. package/dist/runner.js.map +1 -1
  27. package/dist/settings.d.ts +42 -3
  28. package/dist/settings.js +101 -11
  29. package/dist/settings.js.map +1 -1
  30. package/dist/store.d.ts +18 -2
  31. package/dist/suggest.js +4 -2
  32. package/dist/suggest.js.map +1 -1
  33. package/dist/testing.js +48 -0
  34. package/dist/testing.js.map +1 -1
  35. package/dist/types.d.ts +11 -0
  36. package/dist/types.js.map +1 -1
  37. package/dist/weekly.js +5 -2
  38. package/dist/weekly.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/attention.ts +2 -1
  41. package/src/board.ts +2 -0
  42. package/src/glossary.ts +1 -1
  43. package/src/health.ts +3 -1
  44. package/src/importCases.ts +252 -0
  45. package/src/index.ts +1 -0
  46. package/src/labels.ts +9 -4
  47. package/src/memoryStore.ts +6 -2
  48. package/src/models.ts +83 -10
  49. package/src/runner.ts +102 -15
  50. package/src/settings.ts +125 -14
  51. package/src/store.ts +19 -2
  52. package/src/suggest.ts +3 -1
  53. package/src/testing.ts +56 -0
  54. package/src/types.ts +11 -0
  55. package/src/weekly.ts +5 -2
  56. package/templates/app/evals/[suite]/page.tsx +1 -2
  57. package/templates/app/evals/feedback/page.tsx +11 -6
  58. package/templates/app/evals/page.tsx +40 -0
@@ -0,0 +1,252 @@
1
+ import { nextSplit } from "./quickSet.js";
2
+ import { stableJson } from "./stableJson.js";
3
+ import type { EvalStore, NewCase, NewLabel } from "./store.js";
4
+ import type { AnySuite } from "./types.js";
5
+
6
+ /** One row of an import, mapped to the suite's shapes. */
7
+ export type ImportedCase = {
8
+ /** Stable within its source (a row id in the file), so a rerun skips it. */
9
+ id: string;
10
+ /** The suite's input; checked against `suite.input`. */
11
+ input: unknown;
12
+ /** The answer, checked against `suite.labelValue`; null: none yet. */
13
+ label: unknown;
14
+ tags?: string[];
15
+ note?: string | null;
16
+ /** This row's set, instead of the import's `split`. */
17
+ split?: "DEV" | "TEST";
18
+ };
19
+
20
+ /**
21
+ * How imported answers arrive. Pending (the default): a person confirms
22
+ * each one on the pages before tests use it. Provisional: tests use them
23
+ * now, every score says it rests on unreviewed answers, and the rows carry
24
+ * `tag` so the review queue is findable. Use provisional only when the
25
+ * owner said so; `allowedBy` and `at` record who and when. Nothing is
26
+ * imported confirmed: only a person confirms an answer.
27
+ */
28
+ export type ImportAnswers =
29
+ | { status: "PENDING" }
30
+ | {
31
+ status: "PROVISIONAL";
32
+ /** Who drafted the answers: an agent's name, recorded as the rater. */
33
+ rater: string;
34
+ /** The person who allowed using them before review. */
35
+ allowedBy: string;
36
+ /** When they allowed it; default now. */
37
+ at?: Date;
38
+ /** Added to each row's tags; default "needs-review". */
39
+ tag?: string;
40
+ };
41
+
42
+ /** A row that couldn't be imported, and why. */
43
+ export type ImportProblem = { id: string; problem: string };
44
+
45
+ /** Rows that don't fit the suite. Nothing was written. */
46
+ export class CaseImportError extends Error {
47
+ readonly problems: ImportProblem[];
48
+ constructor(source: string, problems: ImportProblem[]) {
49
+ super(
50
+ `${problems.length} row${problems.length === 1 ? "" : "s"} in ${source} can't be imported, so none were: ${problems
51
+ .slice(0, 5)
52
+ .map((p) => `${p.id} (${p.problem})`)
53
+ .join("; ")}${problems.length > 5 ? "; …" : ""}`,
54
+ );
55
+ this.name = "CaseImportError";
56
+ this.problems = problems;
57
+ }
58
+ }
59
+
60
+ /** What an import did. */
61
+ export type ImportResult = {
62
+ /** Cases created. */
63
+ added: number;
64
+ /** Rows imported before (by source and id), left alone. */
65
+ skipped: number;
66
+ };
67
+
68
+ const sourceRefOf = (source: string, id: string) => `${source}#${id}`;
69
+
70
+ /**
71
+ * Imports existing test data as cases for one suite. Every row is mapped
72
+ * and checked against the suite's input and answer schemas first; if any
73
+ * row fails, nothing is written and `CaseImportError` lists them. Rows
74
+ * imported before (same source and id, retired ones included) are skipped,
75
+ * so it can run again after the file grows.
76
+ *
77
+ * Splits: "DEV" for cases you tune against, "TEST" for a held-out file,
78
+ * "auto" (the default) for new cases, split like cases added by hand.
79
+ *
80
+ * Run it against your development database; an app's own guard should
81
+ * refuse a production one.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const { added, skipped } = await importCases({
86
+ * store,
87
+ * suite: ticketTriage,
88
+ * source: "tickets-heldout.json",
89
+ * rows: JSON.parse(await readFile("tickets-heldout.json", "utf8")),
90
+ * map: (r) => ({ id: r.id, input: { body: r.text }, label: { queue: r.queue } }),
91
+ * split: "TEST",
92
+ * });
93
+ * ```
94
+ */
95
+ export async function importCases<Row>(opts: {
96
+ store: EvalStore;
97
+ suite: AnySuite;
98
+ /** Where the rows came from, such as a file name. */
99
+ source: string;
100
+ rows: readonly Row[];
101
+ map: (row: Row, index: number) => ImportedCase;
102
+ split?: "DEV" | "TEST" | "auto";
103
+ answers?: ImportAnswers;
104
+ }): Promise<ImportResult> {
105
+ const { store, suite, source } = opts;
106
+ const answers = opts.answers ?? { status: "PENDING" as const };
107
+
108
+ // 1. Map and check every row before writing anything.
109
+ const problems: ImportProblem[] = [];
110
+ const seen = new Set<string>();
111
+ const mapped: (ImportedCase & { input: unknown; label: unknown })[] = [];
112
+ opts.rows.forEach((row, i) => {
113
+ let c: ImportedCase;
114
+ try {
115
+ c = opts.map(row, i);
116
+ } catch (e) {
117
+ problems.push({
118
+ id: `row ${i + 1}`,
119
+ problem: e instanceof Error ? e.message : String(e),
120
+ });
121
+ return;
122
+ }
123
+ const id = c.id || `row ${i + 1}`;
124
+ if (!c.id) problems.push({ id, problem: "no id" });
125
+ else if (seen.has(c.id))
126
+ problems.push({ id, problem: "the same id as an earlier row" });
127
+ seen.add(c.id);
128
+ const input = suite.input.safeParse(c.input);
129
+ if (!input.success)
130
+ problems.push({
131
+ id,
132
+ problem: `input: ${input.error.issues[0]?.message ?? "invalid"}`,
133
+ });
134
+ let label: unknown = null;
135
+ if (c.label !== null && c.label !== undefined) {
136
+ const l = suite.labelValue.safeParse(c.label);
137
+ if (!l.success)
138
+ problems.push({
139
+ id,
140
+ problem: `answer: ${l.error.issues[0]?.message ?? "invalid"}`,
141
+ });
142
+ else label = l.data;
143
+ }
144
+ if (input.success) mapped.push({ ...c, input: input.data, label });
145
+ });
146
+ if (problems.length) throw new CaseImportError(source, problems);
147
+
148
+ // 2. Skip what's already there, retired cases included.
149
+ const existing = await store.listCases(suite.key, { includeRetired: true });
150
+ const refs = new Set(existing.map((c) => c.sourceRef));
151
+ const counts = { DEV: 0, TEST: 0 };
152
+ for (const c of existing) if (c.status !== "RETIRED") counts[c.split] += 1;
153
+
154
+ const at =
155
+ answers.status === "PROVISIONAL" ? (answers.at ?? new Date()) : null;
156
+ let added = 0;
157
+ let skipped = 0;
158
+ for (const c of mapped) {
159
+ const sourceRef = sourceRefOf(source, c.id);
160
+ if (refs.has(sourceRef)) {
161
+ skipped += 1;
162
+ continue;
163
+ }
164
+ const split =
165
+ c.split ??
166
+ (opts.split === "DEV" || opts.split === "TEST"
167
+ ? opts.split
168
+ : nextSplit(counts));
169
+ counts[split] += 1;
170
+ const labels: NewLabel[] = [];
171
+ if (c.label !== null)
172
+ labels.push(
173
+ answers.status === "PROVISIONAL"
174
+ ? {
175
+ kind: "EXPECTED",
176
+ status: "PROVISIONAL",
177
+ value: c.label,
178
+ raterRole: "AGENT",
179
+ raterId: answers.rater,
180
+ note: `Drafted by ${answers.rater}; ${answers.allowedBy} allowed testing on it before review (${at?.toISOString().slice(0, 10)}).`,
181
+ }
182
+ : {
183
+ kind: "EXPECTED",
184
+ status: "PENDING",
185
+ value: c.label,
186
+ raterRole: "IMPORT",
187
+ raterId: null,
188
+ note: null,
189
+ },
190
+ );
191
+ const tags = [...(c.tags ?? [])];
192
+ if (answers.status === "PROVISIONAL" && c.label !== null) {
193
+ const tag = answers.tag ?? "needs-review";
194
+ if (!tags.includes(tag)) tags.push(tag);
195
+ }
196
+ const data: NewCase = {
197
+ suite: suite.key,
198
+ input: c.input,
199
+ tags,
200
+ split,
201
+ // In use only with an answer tests may grade against.
202
+ status:
203
+ c.label !== null && answers.status === "PROVISIONAL"
204
+ ? "ACTIVE"
205
+ : "DRAFT",
206
+ source: "IMPORT",
207
+ sourceRef,
208
+ note: c.note ?? null,
209
+ labels,
210
+ };
211
+ await store.createCase(data);
212
+ refs.add(sourceRef);
213
+ added += 1;
214
+ }
215
+ return { added, skipped };
216
+ }
217
+
218
+ /** A short, stable id for a starter case: a hash of its input. */
219
+ function starterId(input: unknown): string {
220
+ // FNV-1a: the core stays free of node:crypto; this only names rows.
221
+ let h = 0x811c9dc5;
222
+ for (const ch of stableJson(input)) {
223
+ h ^= ch.charCodeAt(0);
224
+ h = Math.imul(h, 0x01000193) >>> 0;
225
+ }
226
+ return h.toString(16).padStart(8, "0");
227
+ }
228
+
229
+ /**
230
+ * Adds a suite's `starter` cases as drafts with pending answers, for a
231
+ * person to confirm on the pages. Safe to run again: starters already
232
+ * added (by their input) are skipped.
233
+ */
234
+ export function importStarters(
235
+ store: EvalStore,
236
+ suite: AnySuite,
237
+ ): Promise<ImportResult> {
238
+ return importCases({
239
+ store,
240
+ suite,
241
+ source: `starter:${suite.key}`,
242
+ rows: suite.starter ?? [],
243
+ map: (s) => ({
244
+ id: starterId(s.input),
245
+ input: s.input,
246
+ label: s.label,
247
+ tags: s.tags,
248
+ note: s.note,
249
+ split: s.split,
250
+ }),
251
+ });
252
+ }
package/src/index.ts CHANGED
@@ -31,3 +31,4 @@ export * from "./store.js";
31
31
  export * from "./labels.js";
32
32
  // Server-only (node:crypto): import from "@crowandraven/evalkeeper/runner".
33
33
  // In-memory store for tests and demos: "@crowandraven/evalkeeper/memory".
34
+ export * from "./importCases.js";
package/src/labels.ts CHANGED
@@ -1,11 +1,16 @@
1
- import type { LabelRecord } from "./store.js";
1
+ /** What the answer-in-use rule needs from a label: the store's, or an app's own row. */
2
+ export type LabelLike = { status: string; updatedAt: Date };
2
3
 
3
4
  /**
4
5
  * The EXPECTED answer tests use: the newest confirmed one, or failing that a
5
6
  * provisional one (put to use before review). Pending answers never count.
7
+ * Generic, so pages that read their own label rows (with extra columns)
8
+ * share the rule and keep their row type.
6
9
  */
7
- export function confirmedLabel(labels: LabelRecord[]): LabelRecord | null {
8
- const newest = (status: LabelRecord["status"]) =>
10
+ export function confirmedLabel<L extends LabelLike>(
11
+ labels: readonly L[],
12
+ ): L | null {
13
+ const newest = (status: string) =>
9
14
  labels
10
15
  .filter((l) => l.status === status)
11
16
  .sort((a, b) => +b.updatedAt - +a.updatedAt)[0] ?? null;
@@ -13,6 +18,6 @@ export function confirmedLabel(labels: LabelRecord[]): LabelRecord | null {
13
18
  }
14
19
 
15
20
  /** In use on an answer nobody has reviewed yet. */
16
- export function isProvisional(labels: LabelRecord[]): boolean {
21
+ export function isProvisional(labels: readonly LabelLike[]): boolean {
17
22
  return confirmedLabel(labels)?.status === "PROVISIONAL";
18
23
  }
@@ -163,9 +163,13 @@ export function memoryStore(): EvalStore &
163
163
  f.updatedAt = now();
164
164
  }
165
165
  },
166
- async listCases(suite) {
166
+ async listCases(suite, opts) {
167
167
  return cases
168
- .filter((c) => c.suite === suite && c.status !== "RETIRED")
168
+ .filter(
169
+ (c) =>
170
+ c.suite === suite &&
171
+ (opts?.includeRetired || c.status !== "RETIRED"),
172
+ )
169
173
  .sort((a, b) => +a.createdAt - +b.createdAt);
170
174
  },
171
175
  async createCase({ labels = [], ...data }) {
package/src/models.ts CHANGED
@@ -3,10 +3,27 @@
3
3
  * pass a catalog in; Evalkeeper never picks a provider itself. Pure.
4
4
  */
5
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];
6
+ /**
7
+ * The reasoning settings providers name, lowest first, so a setting one
8
+ * model lacks can be placed against the ones it has. A model's own
9
+ * `efforts` say which it takes.
10
+ */
11
+ export const REASONING_EFFORTS = [
12
+ "none",
13
+ "minimal",
14
+ "low",
15
+ "medium",
16
+ "high",
17
+ "xhigh",
18
+ "max",
19
+ ] as const;
20
+ /** A setting from REASONING_EFFORTS. */
21
+ export type KnownReasoningEffort = (typeof REASONING_EFFORTS)[number];
22
+ /**
23
+ * A reasoning setting a model may accept: one of REASONING_EFFORTS, or any
24
+ * other name a provider uses. The catalog's `efforts` order them per model.
25
+ */
26
+ export type ReasoningEffort = KnownReasoningEffort | (string & {});
10
27
 
11
28
  /**
12
29
  * What Evalkeeper needs to know about one model: its label, provider, prices, reasoning
@@ -19,6 +36,16 @@ export type ModelInfo = {
19
36
  /** USD per 1M tokens, for estimates and the cost of each answer. */
20
37
  inputPer1M?: number;
21
38
  outputPer1M?: number;
39
+ /**
40
+ * USD per 1M input tokens read from the provider's prompt cache. Unset:
41
+ * cached input costs `inputPer1M`, which overstates rather than flatters.
42
+ */
43
+ cachedInputPer1M?: number;
44
+ /**
45
+ * USD per 1M input tokens written to the prompt cache (Anthropic bills
46
+ * these above the input price). Unset: they cost `inputPer1M`.
47
+ */
48
+ cacheWriteInputPer1M?: number;
22
49
  /** Reasoning efforts it accepts, lowest first. */
23
50
  efforts?: ReasoningEffort[];
24
51
  /**
@@ -34,11 +61,23 @@ export type ModelInfo = {
34
61
  * model's own estimate written out, with no distribution for choices.
35
62
  */
36
63
  decisions?: "native" | "prompted";
64
+ /**
65
+ * Code your app runs instead of a model (a matcher, a rules engine) or
66
+ * alongside it, tested so the board shows whether a model beats it. A
67
+ * baseline is compared like any model but never suggested as live or as
68
+ * the fallback. Give it a label that says what it is.
69
+ */
70
+ baseline?: boolean;
37
71
  };
38
72
 
39
73
  /** Your app's models, by id. */
40
74
  export type ModelCatalog = Record<string, ModelInfo>;
41
75
 
76
+ /** Whether a catalog entry is a baseline (code, not a model): never suggested. */
77
+ export function isBaseline(models: ModelCatalog, model: string): boolean {
78
+ return models[model]?.baseline === true;
79
+ }
80
+
42
81
  /** "January 2027" for a retiring model; null when it isn't retiring. */
43
82
  export function retiresLabel(
44
83
  models: ModelCatalog,
@@ -54,21 +93,46 @@ export function retiresLabel(
54
93
  });
55
94
  }
56
95
 
57
- /** The cost of a call from its token counts; null when the price is unknown. */
96
+ /**
97
+ * The cost of a call from its token counts; null when the price is unknown.
98
+ * `cachedInput` and `cacheWriteInput` are the parts of `input` read from
99
+ * and written to the provider's prompt cache, priced at the model's cache
100
+ * prices when it has them.
101
+ */
58
102
  export function priceCall(
59
103
  models: ModelCatalog,
60
104
  model: string,
61
- tokens: { input: number; output: number },
105
+ tokens: {
106
+ input: number;
107
+ output: number;
108
+ cachedInput?: number;
109
+ cacheWriteInput?: number;
110
+ },
62
111
  ): number | null {
63
112
  const m = models[model];
64
113
  if (m?.inputPer1M === undefined || m.outputPer1M === undefined) return null;
65
- return (tokens.input * m.inputPer1M + tokens.output * m.outputPer1M) / 1e6;
114
+ // The cache parts are subsets of input: never count more than it has.
115
+ const cached = Math.min(Math.max(tokens.cachedInput ?? 0, 0), tokens.input);
116
+ const written = Math.min(
117
+ Math.max(tokens.cacheWriteInput ?? 0, 0),
118
+ tokens.input - cached,
119
+ );
120
+ const plain = tokens.input - cached - written;
121
+ return (
122
+ (plain * m.inputPer1M +
123
+ cached * (m.cachedInputPer1M ?? m.inputPer1M) +
124
+ written * (m.cacheWriteInputPer1M ?? m.inputPer1M) +
125
+ tokens.output * m.outputPer1M) /
126
+ 1e6
127
+ );
66
128
  }
67
129
 
68
130
  /**
69
131
  * The effort a model actually gets for a requested one: the same if it
70
- * takes it; for "none" on a model without it, its lowest; otherwise its own
71
- * default. Undefined: send nothing.
132
+ * takes it; one below the lowest it takes (say "none" on a model that
133
+ * starts at "minimal"), its lowest; one above its highest, its highest;
134
+ * otherwise (a name it doesn't place) its own default. Undefined: send
135
+ * nothing.
72
136
  */
73
137
  export function effortFor(
74
138
  models: ModelCatalog,
@@ -78,5 +142,14 @@ export function effortFor(
78
142
  const efforts = models[model]?.efforts;
79
143
  if (!requested || !efforts) return undefined;
80
144
  if (efforts.includes(requested)) return requested;
81
- return requested === "none" ? efforts[0] : undefined;
145
+ const rank = (e: string) =>
146
+ (REASONING_EFFORTS as readonly string[]).indexOf(e);
147
+ const want = rank(requested);
148
+ const lowest = efforts[0];
149
+ const highest = efforts.at(-1);
150
+ if (want < 0 || lowest === undefined || highest === undefined)
151
+ return undefined;
152
+ if (rank(lowest) >= 0 && want < rank(lowest)) return lowest;
153
+ if (rank(highest) >= 0 && want > rank(highest)) return highest;
154
+ return undefined;
82
155
  }