@crowandraven/evalkeeper 0.2.0 → 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.
- package/dist/importCases.d.ts +94 -0
- package/dist/importCases.js +185 -0
- package/dist/importCases.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/labels.d.ts +9 -3
- package/dist/labels.js +2 -0
- package/dist/labels.js.map +1 -1
- package/dist/memoryStore.js +3 -2
- package/dist/memoryStore.js.map +1 -1
- package/dist/models.d.ts +17 -6
- package/dist/models.js +29 -5
- package/dist/models.js.map +1 -1
- package/dist/runner.d.ts +16 -1
- package/dist/runner.js +34 -5
- package/dist/runner.js.map +1 -1
- package/dist/settings.js +1 -0
- package/dist/settings.js.map +1 -1
- package/dist/store.d.ts +13 -2
- package/dist/testing.js +27 -0
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.js.map +1 -1
- package/dist/weekly.js +3 -2
- package/dist/weekly.js.map +1 -1
- package/package.json +1 -1
- package/src/importCases.ts +252 -0
- package/src/index.ts +1 -0
- package/src/labels.ts +9 -4
- package/src/memoryStore.ts +6 -2
- package/src/models.ts +35 -7
- package/src/runner.ts +67 -19
- package/src/settings.ts +1 -0
- package/src/store.ts +14 -2
- package/src/testing.ts +33 -0
- package/src/types.ts +5 -0
- package/src/weekly.ts +3 -2
- package/templates/app/evals/[suite]/page.tsx +1 -2
- package/templates/app/evals/feedback/page.tsx +11 -6
- 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
package/src/labels.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
|
|
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
|
|
8
|
-
|
|
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:
|
|
21
|
+
export function isProvisional(labels: readonly LabelLike[]): boolean {
|
|
17
22
|
return confirmedLabel(labels)?.status === "PROVISIONAL";
|
|
18
23
|
}
|
package/src/memoryStore.ts
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
@@ -112,8 +129,10 @@ export function priceCall(
|
|
|
112
129
|
|
|
113
130
|
/**
|
|
114
131
|
* The effort a model actually gets for a requested one: the same if it
|
|
115
|
-
* takes it;
|
|
116
|
-
*
|
|
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.
|
|
117
136
|
*/
|
|
118
137
|
export function effortFor(
|
|
119
138
|
models: ModelCatalog,
|
|
@@ -123,5 +142,14 @@ export function effortFor(
|
|
|
123
142
|
const efforts = models[model]?.efforts;
|
|
124
143
|
if (!requested || !efforts) return undefined;
|
|
125
144
|
if (efforts.includes(requested)) return requested;
|
|
126
|
-
|
|
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;
|
|
127
155
|
}
|
package/src/runner.ts
CHANGED
|
@@ -105,11 +105,18 @@ export type RunnerOptions = {
|
|
|
105
105
|
models: string[];
|
|
106
106
|
fallbackModels?: string[];
|
|
107
107
|
};
|
|
108
|
+
/**
|
|
109
|
+
* Which retiring models (`ModelInfo.retiring`) the board lists.
|
|
110
|
+
* "always" (the default): every candidate, though a retiring one is never
|
|
111
|
+
* suggested. "inUse": only one a feature still runs on, as its live or
|
|
112
|
+
* fallback model, so nobody spends a test on a model that's going away.
|
|
113
|
+
*/
|
|
114
|
+
retiringOnBoard?: "always" | "inUse";
|
|
108
115
|
};
|
|
109
116
|
|
|
110
117
|
/**
|
|
111
118
|
* Which model a feature runs on now, its fallback, its reasoning setting,
|
|
112
|
-
* and its
|
|
119
|
+
* its temperature, and its output cap.
|
|
113
120
|
*/
|
|
114
121
|
export type Route = {
|
|
115
122
|
model: string;
|
|
@@ -117,6 +124,8 @@ export type Route = {
|
|
|
117
124
|
reasoningEffort: ReasoningEffort | null;
|
|
118
125
|
/** Null or unset: no temperature sent. */
|
|
119
126
|
temperature?: number | null;
|
|
127
|
+
/** The output-token cap; null or unset: the call's own default. */
|
|
128
|
+
maxOutputTokens?: number | null;
|
|
120
129
|
};
|
|
121
130
|
|
|
122
131
|
/**
|
|
@@ -149,6 +158,8 @@ export function requestHashFor(r: {
|
|
|
149
158
|
repeat: number;
|
|
150
159
|
/** Left out of the hash when null, so requests without one reuse as before. */
|
|
151
160
|
temperature?: number | null;
|
|
161
|
+
/** Left out of the hash when null, like temperature. */
|
|
162
|
+
maxOutputTokens?: number | null;
|
|
152
163
|
}): string {
|
|
153
164
|
return sha(
|
|
154
165
|
stableJson({
|
|
@@ -159,6 +170,9 @@ export function requestHashFor(r: {
|
|
|
159
170
|
input: r.input,
|
|
160
171
|
repeat: r.repeat,
|
|
161
172
|
...(r.temperature != null ? { temperature: r.temperature } : {}),
|
|
173
|
+
...(r.maxOutputTokens != null
|
|
174
|
+
? { maxOutputTokens: r.maxOutputTokens }
|
|
175
|
+
: {}),
|
|
162
176
|
}),
|
|
163
177
|
64,
|
|
164
178
|
);
|
|
@@ -267,6 +281,12 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
267
281
|
return (await opts.route(operation)).temperature ?? null;
|
|
268
282
|
}
|
|
269
283
|
|
|
284
|
+
/** The operation's live output cap: the route's, else the call's own. */
|
|
285
|
+
async function liveCapFor(operation: string): Promise<number | null> {
|
|
286
|
+
if (!opts.route) return null;
|
|
287
|
+
return (await opts.route(operation)).maxOutputTokens ?? null;
|
|
288
|
+
}
|
|
289
|
+
|
|
270
290
|
/**
|
|
271
291
|
* What a test would cost. Answers an identical request gave in the last
|
|
272
292
|
* 7 days are reused for free, so only the rest are priced: a retest
|
|
@@ -285,6 +305,8 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
285
305
|
reasoningEffort?: ReasoningEffort | null;
|
|
286
306
|
/** The temperature to test; undefined: the operation's live one. */
|
|
287
307
|
temperature?: number | null;
|
|
308
|
+
/** The output cap to test; undefined: the operation's live one. */
|
|
309
|
+
maxOutputTokens?: number | null;
|
|
288
310
|
} = {},
|
|
289
311
|
): Promise<{
|
|
290
312
|
cases: number;
|
|
@@ -295,17 +317,21 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
295
317
|
costUsd: number | null;
|
|
296
318
|
}> {
|
|
297
319
|
const suite = suiteOrThrow(suiteKey);
|
|
298
|
-
const [cases, perCall, prompt, effort, temperature] =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
320
|
+
const [cases, perCall, prompt, effort, temperature, cap] =
|
|
321
|
+
await Promise.all([
|
|
322
|
+
casesFor(suite.key, set),
|
|
323
|
+
opts.estimateCallCost?.(suite.operation, model) ?? null,
|
|
324
|
+
opts.promptVersion(suite.operation),
|
|
325
|
+
o.reasoningEffort !== undefined
|
|
326
|
+
? o.reasoningEffort
|
|
327
|
+
: liveEffortFor(suite.operation),
|
|
328
|
+
o.temperature !== undefined
|
|
329
|
+
? o.temperature
|
|
330
|
+
: liveTemperatureFor(suite.operation),
|
|
331
|
+
o.maxOutputTokens !== undefined
|
|
332
|
+
? o.maxOutputTokens
|
|
333
|
+
: liveCapFor(suite.operation),
|
|
334
|
+
]);
|
|
309
335
|
const answers = cases.length * repeats;
|
|
310
336
|
const since = new Date(Date.now() - REUSE_DAYS * 86_400_000);
|
|
311
337
|
const reused = o.fresh
|
|
@@ -323,6 +349,7 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
323
349
|
input: c.input,
|
|
324
350
|
repeat: i + 1,
|
|
325
351
|
temperature,
|
|
352
|
+
maxOutputTokens: cap,
|
|
326
353
|
}),
|
|
327
354
|
since,
|
|
328
355
|
),
|
|
@@ -350,6 +377,8 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
350
377
|
reasoningEffort?: ReasoningEffort | null;
|
|
351
378
|
/** A temperature to test; undefined: the operation's live one. */
|
|
352
379
|
temperature?: number | null;
|
|
380
|
+
/** An output cap to test; undefined: the operation's live one. */
|
|
381
|
+
maxOutputTokens?: number | null;
|
|
353
382
|
/** For the estimate: pass the same `fresh` to runEval. */
|
|
354
383
|
fresh?: boolean;
|
|
355
384
|
}) {
|
|
@@ -364,10 +393,15 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
364
393
|
args.temperature !== undefined
|
|
365
394
|
? args.temperature
|
|
366
395
|
: await liveTemperatureFor(suite.operation);
|
|
396
|
+
const maxOutputTokens =
|
|
397
|
+
args.maxOutputTokens !== undefined
|
|
398
|
+
? args.maxOutputTokens
|
|
399
|
+
: await liveCapFor(suite.operation);
|
|
367
400
|
const [estimate, promptVersion] = await Promise.all([
|
|
368
401
|
estimateRun(suite.key, args.model, set, repeats, {
|
|
369
402
|
reasoningEffort,
|
|
370
403
|
temperature,
|
|
404
|
+
maxOutputTokens,
|
|
371
405
|
fresh: Boolean(args.fresh),
|
|
372
406
|
}),
|
|
373
407
|
opts.promptVersion(suite.operation),
|
|
@@ -380,6 +414,7 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
380
414
|
reasoningEffort:
|
|
381
415
|
args.reasoningEffort !== undefined ? args.reasoningEffort : liveEffort,
|
|
382
416
|
temperature,
|
|
417
|
+
maxOutputTokens,
|
|
383
418
|
set,
|
|
384
419
|
repeats,
|
|
385
420
|
datasetVersion: null,
|
|
@@ -516,6 +551,7 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
516
551
|
input: c.input,
|
|
517
552
|
repeat,
|
|
518
553
|
temperature: run.temperature ?? null,
|
|
554
|
+
maxOutputTokens: run.maxOutputTokens ?? null,
|
|
519
555
|
});
|
|
520
556
|
const earlier = o.fresh
|
|
521
557
|
? null
|
|
@@ -543,9 +579,9 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
543
579
|
const output = await withTimeout(
|
|
544
580
|
suite.run(suite.input.parse(c.input), {
|
|
545
581
|
model: run.model,
|
|
546
|
-
reasoningEffort:
|
|
547
|
-
(run.reasoningEffort as ReasoningEffort | null) ?? null,
|
|
582
|
+
reasoningEffort: run.reasoningEffort ?? null,
|
|
548
583
|
temperature: run.temperature ?? null,
|
|
584
|
+
maxOutputTokens: run.maxOutputTokens ?? null,
|
|
549
585
|
onCost: (usd) => (cost += usd),
|
|
550
586
|
}),
|
|
551
587
|
timeout,
|
|
@@ -693,6 +729,7 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
693
729
|
startedBy: SCHEDULED,
|
|
694
730
|
reasoningEffort: route.reasoningEffort,
|
|
695
731
|
temperature: route.temperature ?? null,
|
|
732
|
+
maxOutputTokens: route.maxOutputTokens ?? null,
|
|
696
733
|
});
|
|
697
734
|
return run.id;
|
|
698
735
|
}
|
|
@@ -801,7 +838,7 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
801
838
|
// The setting a model actually gets: models without reasoning settings
|
|
802
839
|
// ignore it, so their rows don't split by it.
|
|
803
840
|
const realEffort = (model: string, effort: string | null) =>
|
|
804
|
-
effortFor(opts.models, model, effort
|
|
841
|
+
effortFor(opts.models, model, effort) ?? null;
|
|
805
842
|
const keyOf = (model: string, effort: string | null) =>
|
|
806
843
|
`${model}|${realEffort(model, effort) ?? ""}`;
|
|
807
844
|
const runKey = (r: RunRecord) => keyOf(r.model, r.reasoningEffort);
|
|
@@ -815,11 +852,14 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
815
852
|
latestDone.set(runKey(r), r);
|
|
816
853
|
}
|
|
817
854
|
const liveTemperature = route.temperature ?? null;
|
|
818
|
-
|
|
855
|
+
const liveCap = route.maxOutputTokens ?? null;
|
|
856
|
+
// A test at another temperature or output cap than the live ones
|
|
857
|
+
// answered differently.
|
|
819
858
|
const isCurrent = (r: RunRecord) =>
|
|
820
859
|
r.datasetVersion === current &&
|
|
821
860
|
r.promptVersion === prompt &&
|
|
822
|
-
(r.temperature ?? null) === liveTemperature
|
|
861
|
+
(r.temperature ?? null) === liveTemperature &&
|
|
862
|
+
(r.maxOutputTokens ?? null) === liveCap;
|
|
823
863
|
|
|
824
864
|
const liveKey = keyOf(route.model, defaultEffort);
|
|
825
865
|
const liveRun = latestDone.get(liveKey);
|
|
@@ -829,7 +869,8 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
829
869
|
liveRun !== undefined &&
|
|
830
870
|
r.datasetVersion === liveRun.datasetVersion &&
|
|
831
871
|
r.promptVersion === liveRun.promptVersion &&
|
|
832
|
-
(r.temperature ?? null) === (liveRun.temperature ?? null)
|
|
872
|
+
(r.temperature ?? null) === (liveRun.temperature ?? null) &&
|
|
873
|
+
(r.maxOutputTokens ?? null) === (liveRun.maxOutputTokens ?? null);
|
|
833
874
|
const outputs = new Map<string, RunOutput[]>();
|
|
834
875
|
for (const r of latestDone.values())
|
|
835
876
|
outputs.set(
|
|
@@ -846,7 +887,7 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
846
887
|
const checkKeys = suite.checks.map((c) => c.key);
|
|
847
888
|
|
|
848
889
|
const seen = new Set<string>();
|
|
849
|
-
|
|
890
|
+
let keys = [
|
|
850
891
|
...models.map((m) => ({
|
|
851
892
|
model: m,
|
|
852
893
|
effort: defaultEffort,
|
|
@@ -865,6 +906,13 @@ export function createRunner(opts: RunnerOptions) {
|
|
|
865
906
|
seen.add(k);
|
|
866
907
|
return true;
|
|
867
908
|
});
|
|
909
|
+
// A retiring model stays on the board only while a feature runs on it.
|
|
910
|
+
const inUse = (model: string) =>
|
|
911
|
+
model === route.model || model === route.fallbackModel;
|
|
912
|
+
if (opts.retiringOnBoard === "inUse")
|
|
913
|
+
keys = keys.filter(
|
|
914
|
+
({ model }) => !opts.models[model]?.retiring || inUse(model),
|
|
915
|
+
);
|
|
868
916
|
const complete = (r: RunRecord | undefined) => !(r?.metrics?.failed ?? 0);
|
|
869
917
|
const estimates = new Map(
|
|
870
918
|
await Promise.all(
|
package/src/settings.ts
CHANGED
package/src/store.ts
CHANGED
|
@@ -82,6 +82,11 @@ export type RunRecord = {
|
|
|
82
82
|
* test at another temperature than the live one shows as out of date.
|
|
83
83
|
*/
|
|
84
84
|
temperature?: number | null;
|
|
85
|
+
/**
|
|
86
|
+
* The output-token cap tested; null or unset: the call's own default.
|
|
87
|
+
* Kept like temperature: a test at another cap is out of date.
|
|
88
|
+
*/
|
|
89
|
+
maxOutputTokens?: number | null;
|
|
85
90
|
set: EvalSet;
|
|
86
91
|
repeats: number;
|
|
87
92
|
/** Changes when the cases or their answers do, so tests compare. */
|
|
@@ -139,8 +144,15 @@ export type NewCase = Omit<
|
|
|
139
144
|
* database, and run describeStoreContract against it.
|
|
140
145
|
*/
|
|
141
146
|
export interface EvalStore {
|
|
142
|
-
/**
|
|
143
|
-
|
|
147
|
+
/**
|
|
148
|
+
* A suite's cases that aren't retired, with their answers, oldest first.
|
|
149
|
+
* With `includeRetired`, retired ones too (an import checks them, so a
|
|
150
|
+
* case someone retired isn't added again).
|
|
151
|
+
*/
|
|
152
|
+
listCases(
|
|
153
|
+
suite: string,
|
|
154
|
+
opts?: { includeRetired?: boolean },
|
|
155
|
+
): Promise<CaseRecord[]>;
|
|
144
156
|
createCase(data: NewCase): Promise<CaseRecord>;
|
|
145
157
|
/** Adds an answer to a case (answers are never overwritten). */
|
|
146
158
|
addLabel(caseId: string, label: NewLabel): Promise<LabelRecord>;
|
package/src/testing.ts
CHANGED
|
@@ -90,6 +90,10 @@ export function describeStoreContract(
|
|
|
90
90
|
expect((await store.listCases("s")).map((c) => c.id)).toEqual([
|
|
91
91
|
second.id,
|
|
92
92
|
]);
|
|
93
|
+
// On request, retired ones too, still oldest first.
|
|
94
|
+
expect(
|
|
95
|
+
(await store.listCases("s", { includeRetired: true })).map((c) => c.id),
|
|
96
|
+
).toEqual([first.id, second.id]);
|
|
93
97
|
});
|
|
94
98
|
|
|
95
99
|
it("adds answers without overwriting, and changes their status", async () => {
|
|
@@ -135,6 +139,20 @@ export function describeStoreContract(
|
|
|
135
139
|
expect((await store.getRun(none.id))?.temperature ?? null).toBeNull();
|
|
136
140
|
});
|
|
137
141
|
|
|
142
|
+
it("keeps the output cap a run was tested at", async () => {
|
|
143
|
+
const store = await make();
|
|
144
|
+
const capped = await store.createRun({
|
|
145
|
+
...newRun("s"),
|
|
146
|
+
maxOutputTokens: 4000,
|
|
147
|
+
});
|
|
148
|
+
const none = await store.createRun({
|
|
149
|
+
...newRun("s"),
|
|
150
|
+
maxOutputTokens: null,
|
|
151
|
+
});
|
|
152
|
+
expect((await store.getRun(capped.id))?.maxOutputTokens).toBe(4000);
|
|
153
|
+
expect((await store.getRun(none.id))?.maxOutputTokens ?? null).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
138
156
|
it("updates a run only while it has the status it's guarded by", async () => {
|
|
139
157
|
const store = await make();
|
|
140
158
|
const r = await store.createRun(newRun("s"));
|
|
@@ -389,6 +407,21 @@ export function describeSettingsStoreContract(
|
|
|
389
407
|
expect(await store.listVersions("triage")).toHaveLength(2);
|
|
390
408
|
});
|
|
391
409
|
|
|
410
|
+
it("keeps a version's reasoning setting as named, even a newer one", async () => {
|
|
411
|
+
const store = await make();
|
|
412
|
+
const v1 = await store.switchTo("triage", {
|
|
413
|
+
...create("a", null),
|
|
414
|
+
create: {
|
|
415
|
+
settings: { ...settings("a"), reasoningEffort: "xhigh" },
|
|
416
|
+
promptVersion: "p1",
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
expect(v1.reasoningEffort).toBe("xhigh");
|
|
420
|
+
expect((await store.liveVersion("triage"))?.reasoningEffort).toBe(
|
|
421
|
+
"xhigh",
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
392
425
|
it("keeps a version's temperature", async () => {
|
|
393
426
|
const store = await make();
|
|
394
427
|
const v1 = await store.switchTo("triage", {
|