auto-model-router 0.29.0 → 0.30.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.
- package/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/eval/run.ts +75 -10
- package/src/eval/tasks.ts +116 -0
- package/src/server/http.ts +15 -2
- package/test/eval.test.ts +59 -4
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.30.1",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.30.1",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/eval/run.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { QualityAxis } from "../config/types.ts";
|
|
10
10
|
import type { Judge } from "./judge.ts";
|
|
11
|
-
import { EVAL_TASKS, JUDGED_TASKS, type EvalTask, type JudgedTask } from "./tasks.ts";
|
|
11
|
+
import { EVAL_TASKS, JUDGED_TASKS, type Complexity, type EvalTask, type JudgedTask } from "./tasks.ts";
|
|
12
12
|
import { AGENTIC_SCENARIOS, runScenario, type Scenario, type ToolSpec } from "./agentic.ts";
|
|
13
13
|
import type { ToolCall } from "../upstream/types.ts";
|
|
14
14
|
|
|
@@ -31,6 +31,20 @@ export interface EvalResult {
|
|
|
31
31
|
axes: Record<QualityAxis, AxisScore>;
|
|
32
32
|
/** Tasks whose completion threw (dispatch failure). Excluded from `axes`. */
|
|
33
33
|
errors: number;
|
|
34
|
+
/** Passes actually completed. 1 unless `repeats` was given. */
|
|
35
|
+
repeats: number;
|
|
36
|
+
/**
|
|
37
|
+
* Mean grade per difficulty band. This is where a model's ceiling shows: a suite of one
|
|
38
|
+
* difficulty reports a single number and cannot say whether a model is strong or the
|
|
39
|
+
* questions were easy.
|
|
40
|
+
*/
|
|
41
|
+
byComplexity: Partial<Record<Complexity, AxisScore>>;
|
|
42
|
+
/**
|
|
43
|
+
* Per-axis spread across passes: max pass mean minus min pass mean, or null under two
|
|
44
|
+
* passes. A wide spread means the headline is one sample of a noisy quantity, and is the
|
|
45
|
+
* honest counterpart to reporting a score at all.
|
|
46
|
+
*/
|
|
47
|
+
spread: Partial<Record<QualityAxis, number>>;
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
function messagesFor(task: { system?: string; user: string }): ChatMessage[] {
|
|
@@ -60,9 +74,17 @@ export interface RunEvalArgs {
|
|
|
60
74
|
toolComplete?: (slug: string, messages: Record<string, unknown>[], tools: ToolSpec[]) => Promise<{ text: string; toolCalls: ToolCall[] }>;
|
|
61
75
|
/** Agentic tool-loop scenarios. Defaults to the built-in set when `toolComplete` is given. */
|
|
62
76
|
scenarios?: readonly Scenario[];
|
|
77
|
+
/**
|
|
78
|
+
* How many times to run the whole suite per model, default 1. A single pass cannot tell a
|
|
79
|
+
* real difference from sampling noise — Artificial Analysis runs 3-5 repeats and spends
|
|
80
|
+
* >10 to claim a confidence interval. Every attempt is an independent observation, so the
|
|
81
|
+
* axis mean is over `tasks x repeats` and `spread` reports how much the passes disagreed.
|
|
82
|
+
*/
|
|
83
|
+
repeats?: number;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
|
|
86
|
+
/** One pass of the whole suite. Repeats call this and the observations are pooled. */
|
|
87
|
+
async function scorePass(slug: string, args: RunEvalArgs): Promise<EvalResult> {
|
|
66
88
|
const tasks = args.tasks ?? EVAL_TASKS;
|
|
67
89
|
const judge = args.judge;
|
|
68
90
|
const judged = judge !== undefined ? (args.judged ?? JUDGED_TASKS) : [];
|
|
@@ -72,7 +94,7 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
|
|
|
72
94
|
agentic: { sum: 0, n: 0 },
|
|
73
95
|
};
|
|
74
96
|
let errors = 0;
|
|
75
|
-
type Outcome = { axis: QualityAxis; grade: number; ok: boolean };
|
|
97
|
+
type Outcome = { axis: QualityAxis; grade: number; ok: boolean; complexity: Complexity };
|
|
76
98
|
// A THROW (or an unscorable judge reply) means the turn produced no usable
|
|
77
99
|
// observation — NOT a score of 0, which would poison an anchor whose model is
|
|
78
100
|
// merely unavailable. Such turns are tallied as errors and excluded.
|
|
@@ -80,9 +102,9 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
|
|
|
80
102
|
tasks.map(async (task) => {
|
|
81
103
|
try {
|
|
82
104
|
const text = await args.complete(slug, messagesFor(task));
|
|
83
|
-
return { axis: task.axis, grade: task.grade(text), ok: true };
|
|
105
|
+
return { axis: task.axis, grade: task.grade(text), ok: true, complexity: task.complexity ?? "easy" };
|
|
84
106
|
} catch {
|
|
85
|
-
return { axis: task.axis, grade: 0, ok: false };
|
|
107
|
+
return { axis: task.axis, grade: 0, ok: false, complexity: task.complexity ?? "easy" };
|
|
86
108
|
}
|
|
87
109
|
}),
|
|
88
110
|
);
|
|
@@ -94,9 +116,9 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
|
|
|
94
116
|
try {
|
|
95
117
|
const answer = await args.complete(slug, messagesFor(task));
|
|
96
118
|
const score = await judge(task, answer);
|
|
97
|
-
return score === null ? { axis: task.axis, grade: 0, ok: false } : { axis: task.axis, grade: score, ok: true };
|
|
119
|
+
return score === null ? { axis: task.axis, grade: 0, ok: false, complexity: "hard" } : { axis: task.axis, grade: score, ok: true, complexity: "hard" };
|
|
98
120
|
} catch {
|
|
99
|
-
return { axis: task.axis, grade: 0, ok: false };
|
|
121
|
+
return { axis: task.axis, grade: 0, ok: false, complexity: "hard" };
|
|
100
122
|
}
|
|
101
123
|
}),
|
|
102
124
|
);
|
|
@@ -109,21 +131,64 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
|
|
|
109
131
|
for (const scenario of scenarios) {
|
|
110
132
|
try {
|
|
111
133
|
const run = await runScenario(scenario, (messages, tools) => args.toolComplete!(slug, messages, tools));
|
|
112
|
-
scenarioOutcomes.push({ axis: "agentic", grade: scenario.grade(run), ok: true });
|
|
134
|
+
scenarioOutcomes.push({ axis: "agentic", grade: scenario.grade(run), ok: true, complexity: "moderate" });
|
|
113
135
|
} catch {
|
|
114
|
-
scenarioOutcomes.push({ axis: "agentic", grade: 0, ok: false });
|
|
136
|
+
scenarioOutcomes.push({ axis: "agentic", grade: 0, ok: false, complexity: "moderate" });
|
|
115
137
|
}
|
|
116
138
|
}
|
|
117
139
|
}
|
|
140
|
+
const byComplexity: Partial<Record<Complexity, AxisScore>> = {};
|
|
118
141
|
for (const o of [...objective, ...judgedOutcomes, ...scenarioOutcomes]) {
|
|
119
142
|
if (!o.ok) {
|
|
143
|
+
// An unobserved task is NOT a zero: a provider's throttle or outage would otherwise
|
|
144
|
+
// be recorded as the model answering wrongly. Artificial Analysis goes further and
|
|
145
|
+
// withholds a result whose failures persisted; we at least never score one.
|
|
120
146
|
errors += 1;
|
|
121
147
|
continue;
|
|
122
148
|
}
|
|
123
149
|
axes[o.axis].sum += o.grade;
|
|
124
150
|
axes[o.axis].n += 1;
|
|
151
|
+
const band = (byComplexity[o.complexity] ??= { sum: 0, n: 0 });
|
|
152
|
+
band.sum += o.grade;
|
|
153
|
+
band.n += 1;
|
|
154
|
+
}
|
|
155
|
+
return { slug, axes, errors, repeats: 1, spread: {}, byComplexity };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const AXES: readonly QualityAxis[] = ["coding", "intelligence", "agentic"];
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A model's score over `repeats` passes. Observations are POOLED rather than averaged over
|
|
162
|
+
* pass means, so a pass that lost tasks to dispatch errors weighs only what it observed.
|
|
163
|
+
* Passes run one after another: they are the same model, and firing them concurrently just
|
|
164
|
+
* trips a provider's throttle and buys errors instead of data.
|
|
165
|
+
*/
|
|
166
|
+
async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult> {
|
|
167
|
+
const passes = Math.max(1, Math.floor(args.repeats ?? 1));
|
|
168
|
+
const axes: Record<QualityAxis, AxisScore> = { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } };
|
|
169
|
+
const means: Record<QualityAxis, number[]> = { coding: [], intelligence: [], agentic: [] };
|
|
170
|
+
const byComplexity: Partial<Record<Complexity, AxisScore>> = {};
|
|
171
|
+
let errors = 0;
|
|
172
|
+
for (let i = 0; i < passes; i++) {
|
|
173
|
+
const pass = await scorePass(slug, args);
|
|
174
|
+
errors += pass.errors;
|
|
175
|
+
for (const axis of AXES) {
|
|
176
|
+
axes[axis].sum += pass.axes[axis].sum;
|
|
177
|
+
axes[axis].n += pass.axes[axis].n;
|
|
178
|
+
if (pass.axes[axis].n > 0) means[axis].push(pass.axes[axis].sum / pass.axes[axis].n);
|
|
179
|
+
}
|
|
180
|
+
for (const [band, score] of Object.entries(pass.byComplexity) as [Complexity, AxisScore][]) {
|
|
181
|
+
const acc = (byComplexity[band] ??= { sum: 0, n: 0 });
|
|
182
|
+
acc.sum += score.sum;
|
|
183
|
+
acc.n += score.n;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const spread: Partial<Record<QualityAxis, number>> = {};
|
|
187
|
+
for (const axis of AXES) {
|
|
188
|
+
const m = means[axis];
|
|
189
|
+
if (m.length > 1) spread[axis] = Math.max(...m) - Math.min(...m);
|
|
125
190
|
}
|
|
126
|
-
return { slug, axes, errors };
|
|
191
|
+
return { slug, axes, errors, repeats: passes, spread, byComplexity };
|
|
127
192
|
}
|
|
128
193
|
|
|
129
194
|
export async function runEval(args: RunEvalArgs): Promise<EvalResult[]> {
|
package/src/eval/tasks.ts
CHANGED
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
import type { QualityAxis } from "../config/types.ts";
|
|
13
13
|
import { answerScore, extractJson, jsonField, multiAnswerCoverage, tokenCoverage } from "./grade.ts";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* How hard a task is, reported separately so a model's score says WHERE it falls off
|
|
17
|
+
* rather than only how far. A suite of one difficulty cannot separate competent models:
|
|
18
|
+
* measured on a live catalog, every model above the floor scored 7/7 on the original set,
|
|
19
|
+
* which is why calibration could not fit the coding axis at all.
|
|
20
|
+
*/
|
|
21
|
+
export type Complexity = "easy" | "moderate" | "hard";
|
|
22
|
+
|
|
15
23
|
export interface EvalTask {
|
|
16
24
|
id: string;
|
|
17
25
|
axis: QualityAxis;
|
|
26
|
+
/** Absent ⇒ `easy`: the original suite was uniformly easy, and saying so is the point. */
|
|
27
|
+
complexity?: Complexity;
|
|
18
28
|
system?: string;
|
|
19
29
|
user: string;
|
|
20
30
|
/** Deterministic proxy grade in [0, 1]. */
|
|
@@ -231,6 +241,112 @@ export const EVAL_TASKS: readonly EvalTask[] = [
|
|
|
231
241
|
return hit / want.length;
|
|
232
242
|
},
|
|
233
243
|
},
|
|
244
|
+
|
|
245
|
+
// ---- hard: items competent models actually get wrong, so the axis has a top end.
|
|
246
|
+
// Every answer below is hand-derived; multi-part items give partial credit, which is what
|
|
247
|
+
// produces spread instead of a wall of 1.0s.
|
|
248
|
+
{
|
|
249
|
+
id: "coding/event-loop-order",
|
|
250
|
+
axis: "coding",
|
|
251
|
+
complexity: "hard",
|
|
252
|
+
system: JSON_ONLY,
|
|
253
|
+
user: "Given:\nconsole.log('a');\nsetTimeout(() => console.log('b'), 0);\nPromise.resolve().then(() => console.log('c'));\nconsole.log('d');\nReply with the four letters in the order they are printed, comma separated.",
|
|
254
|
+
// Microtasks drain before timers: a, d, c, b.
|
|
255
|
+
grade: (o) => multiAnswerCoverage(o, ["a", "d", "c", "b"]) === 1 ? (/a\W+d\W+c\W+b/i.test(o) ? 1 : 0.5) : 0,
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "coding/sort-lexicographic",
|
|
259
|
+
axis: "coding",
|
|
260
|
+
complexity: "hard",
|
|
261
|
+
system: JSON_ONLY,
|
|
262
|
+
user: "What does `[10, 9, 80].sort()` return in JavaScript? Reply with only the array.",
|
|
263
|
+
// Default sort compares as STRINGS: "10" < "80" < "9".
|
|
264
|
+
grade: (o) => (answerScore(o, "[10, 80, 9]") === 1 ? 1 : answerScore(o, "[10,80,9]")),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: "coding/trace-hard",
|
|
268
|
+
axis: "coding",
|
|
269
|
+
complexity: "hard",
|
|
270
|
+
system: JSON_ONLY,
|
|
271
|
+
user: "Give the result of each, one per line, in order:\n(1) [1,[2,[3,[4]]]].flat(2).length\n(2) 'abc'.padStart(5,'xy')\n(3) [...'aab'].filter((c,i,a)=>a.indexOf(c)===i).join('')\n(4) Number('')\n(5) [1,2,3].at(-1)",
|
|
272
|
+
// flat(2) leaves [1,2,3,[4]] ⇒ 4; padStart cycles the pad ⇒ xyabc; dedupe ⇒ ab; 0; 3.
|
|
273
|
+
grade: (o) => multiAnswerCoverage(o, ["4", "xyabc", "ab", "0", "3"]),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: "intel/collatz-steps",
|
|
277
|
+
axis: "intelligence",
|
|
278
|
+
complexity: "hard",
|
|
279
|
+
system: JSON_ONLY,
|
|
280
|
+
user: "Start with x = 7. Repeat exactly 7 times: if x is even, x = x / 2; otherwise x = 3x + 1. Reply with the final value of x alone.",
|
|
281
|
+
// 7 → 22 → 11 → 34 → 17 → 52 → 26 → 13
|
|
282
|
+
grade: (o) => answerScore(o, "13"),
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: "intel/arith-hard",
|
|
286
|
+
axis: "intelligence",
|
|
287
|
+
complexity: "hard",
|
|
288
|
+
system: JSON_ONLY,
|
|
289
|
+
user: "Answer each, one per line, in order:\n(1) 47 * 53\n(2) 2^13\n(3) the 17th prime number\n(4) LCM(12, 18)\n(5) how many 1 bits are in the binary form of 1000",
|
|
290
|
+
// 2491; 8192; 59; 36; 1000 = 1111101000 ⇒ six 1 bits.
|
|
291
|
+
grade: (o) => multiAnswerCoverage(o, ["2491", "8192", "59", "36", "6"]),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: "intel/strict-format",
|
|
295
|
+
axis: "intelligence",
|
|
296
|
+
complexity: "hard",
|
|
297
|
+
system: "Follow the output constraints exactly. Any extra text is a failure.",
|
|
298
|
+
user: "Name three primary colours. Reply with exactly three words, all lowercase, separated by single spaces, with no punctuation and no other text.",
|
|
299
|
+
// Instruction adherence under a negative constraint — what IFBench measures.
|
|
300
|
+
grade: (o) => {
|
|
301
|
+
const text = o.trim();
|
|
302
|
+
if (text === "" || /[.,;:!?"'`\n]/.test(text)) return 0;
|
|
303
|
+
const words = text.split(" ");
|
|
304
|
+
if (words.length !== 3) return 0;
|
|
305
|
+
if (words.some((w) => w !== w.toLowerCase())) return 0.5;
|
|
306
|
+
const known = ["red", "blue", "yellow", "green"];
|
|
307
|
+
return words.every((w) => known.includes(w)) ? 1 : 0.5;
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
// ---- harder still. The first hard band was aced 6/6 by deepseek-v4.1-flash, so it was
|
|
312
|
+
// not measuring a ceiling either. These need multi-step state tracking with no shortcut:
|
|
313
|
+
// the answer cannot be recalled, only computed, and one slip anywhere changes it.
|
|
314
|
+
{
|
|
315
|
+
id: "coding/stack-machine",
|
|
316
|
+
axis: "coding",
|
|
317
|
+
complexity: "hard",
|
|
318
|
+
system: JSON_ONLY,
|
|
319
|
+
user: "A stack machine starts with an empty stack. Execute: PUSH 4, PUSH 7, ADD, PUSH 3, SWAP, SUB, PUSH 2, MUL, DUP, ADD.\nSUB pops a then b and pushes b-a. SWAP exchanges the top two. DUP duplicates the top. Reply with the final stack, bottom to top, comma separated.",
|
|
320
|
+
// 4 | 4,7 | 11 | 11,3 | 3,11 | 3-11=-8 | -8,2 | -16 | -16,-16 | -32
|
|
321
|
+
grade: (o) => answerScore(o, "-32"),
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: "intel/ledger-balance",
|
|
325
|
+
axis: "intelligence",
|
|
326
|
+
complexity: "hard",
|
|
327
|
+
system: JSON_ONLY,
|
|
328
|
+
user: "An account starts at 0. Apply in order: +120, -45, then double the balance, -30, then halve the balance (round DOWN to a whole number), +7, then subtract a tenth of the balance (round DOWN), finally -1. Reply with the final balance alone.",
|
|
329
|
+
// 0→120→75→150→120→60→67; a tenth of 67 floors to 6 ⇒ 61; −1 ⇒ 60
|
|
330
|
+
grade: (o) => answerScore(o, "60"),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: "intel/constraint-conflict",
|
|
334
|
+
axis: "intelligence",
|
|
335
|
+
complexity: "hard",
|
|
336
|
+
system: "Follow every constraint. If two constraints cannot both hold, say IMPOSSIBLE and nothing else.",
|
|
337
|
+
user: "Give a single whole number that is greater than 10, less than 20, divisible by 4, and odd. Reply with the number, or IMPOSSIBLE.",
|
|
338
|
+
// No odd multiple of 4 exists: recognising the contradiction is the capability.
|
|
339
|
+
grade: (o) => (/\bimpossible\b/i.test(o) ? 1 : 0),
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: "coding/regex-backtrack",
|
|
343
|
+
axis: "coding",
|
|
344
|
+
complexity: "hard",
|
|
345
|
+
system: JSON_ONLY,
|
|
346
|
+
user: "In JavaScript, give the result of each, one per line, in order:\n(1) 'aaa'.replace(/a*/g, 'X')\n(2) 'a1b2'.match(/[a-z](?=\\d)/g).join('')\n(3) /^(a+)+$/.test('aaab')\n(4) 'x.y.z'.split('.', 2).join('|')\n(5) 'abc'.replace(/(b)/, '[$1$$]')",
|
|
347
|
+
// Empty match at end ⇒ "XX"; lookahead ⇒ "ab"; false; "x|y"; "$$" is a literal $ ⇒ "a[b$]c"
|
|
348
|
+
grade: (o) => multiAnswerCoverage(o, ["XX", "ab", "false", "x|y", "a[b$]c"]),
|
|
349
|
+
},
|
|
234
350
|
];
|
|
235
351
|
|
|
236
352
|
/**
|
package/src/server/http.ts
CHANGED
|
@@ -703,6 +703,18 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
703
703
|
if (result.deleted > 0) log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
|
|
704
704
|
return json({ ...result, retentionDays: cfg.ledger.retentionDays });
|
|
705
705
|
}
|
|
706
|
+
if (req.method === "DELETE" && url.pathname === "/v1/router/benchmark") {
|
|
707
|
+
// Purge local measurements. A stored score carries no provenance, so one taken
|
|
708
|
+
// under a broken harness is indistinguishable from a good one — measured: an
|
|
709
|
+
// `agentic: 0` for a model published at 41.7, left over from a run whose
|
|
710
|
+
// dispatch errors were being graded as wrong answers. `?key=` drops one.
|
|
711
|
+
const key = url.searchParams.get("key");
|
|
712
|
+
const before = loadLocalScores(db);
|
|
713
|
+
const kept = key === null ? [] : before.filter((s) => s.key !== key);
|
|
714
|
+
saveLocalScores(db, kept);
|
|
715
|
+
log.info("purged local eval scores", { removed: before.length - kept.length, kept: kept.length });
|
|
716
|
+
return json({ removed: before.length - kept.length, kept: kept.map((s) => s.key) });
|
|
717
|
+
}
|
|
706
718
|
if (req.method === "POST" && url.pathname === "/v1/router/benchmark") {
|
|
707
719
|
// Score one model with our OWN eval suite, for the models no feed covers: a
|
|
708
720
|
// third of a live catalog carries no published score on any axis, and a model
|
|
@@ -760,6 +772,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
760
772
|
slugs: [slug, ...anchors],
|
|
761
773
|
complete,
|
|
762
774
|
toolComplete,
|
|
775
|
+
repeats: Math.min(Math.max(1, typeof body?.repeats === "number" ? Math.floor(body.repeats) : 1), 20),
|
|
763
776
|
concurrency: Math.min(Math.max(1, asked), 8),
|
|
764
777
|
...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
|
|
765
778
|
});
|
|
@@ -770,13 +783,13 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
770
783
|
const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
|
|
771
784
|
const fresh = toLocalFeedScores([target], cal, authorOf);
|
|
772
785
|
if (fresh.length === 0) {
|
|
773
|
-
return json({ slug, anchors, calibrated: null, raw: target.axes, errors: target.errors, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" });
|
|
786
|
+
return json({ slug, anchors, calibrated: null, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" });
|
|
774
787
|
}
|
|
775
788
|
// Merge, never replace: other models' measurements are not this run's to discard.
|
|
776
789
|
const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
|
|
777
790
|
saveLocalScores(db, [...kept, ...fresh]);
|
|
778
791
|
log.info("benchmarked a model with the local eval suite", { slug, anchors: anchors.length, errors: target.errors, useLocalScores: cfg.benchmarks.useLocalScores });
|
|
779
|
-
return json({ slug, anchors, raw: target.axes, calibrated: fresh[0], errors: target.errors, applied: cfg.benchmarks.useLocalScores });
|
|
792
|
+
return json({ slug, anchors, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, calibrated: fresh[0], errors: target.errors, applied: cfg.benchmarks.useLocalScores });
|
|
780
793
|
}
|
|
781
794
|
if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
|
|
782
795
|
// A user verdict on the newest routed turn of an omp session.
|
package/test/eval.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { applyFeedScores, loadLocalScores, saveLocalScores, type FeedScore } fro
|
|
|
5
5
|
import { answerScore, extractJson, isRefusalOrEmpty, jsonField, tokenCoverage } from "../src/eval/grade.ts";
|
|
6
6
|
import { applyFit, fitAxis, fitCalibration, pickAnchors, toLocalFeedScores, MIN_ANCHORS } from "../src/eval/calibrate.ts";
|
|
7
7
|
import { runEval, type EvalResult } from "../src/eval/run.ts";
|
|
8
|
+
import { EVAL_TASKS } from "../src/eval/tasks.ts";
|
|
8
9
|
import { makeJudge, parseScore } from "../src/eval/judge.ts";
|
|
9
10
|
import type { EvalTask, JudgedTask } from "../src/eval/tasks.ts";
|
|
10
11
|
import { openDb } from "../src/util/sqlite.ts";
|
|
@@ -72,12 +73,66 @@ describe("calibration", () => {
|
|
|
72
73
|
expect(pickAnchors(catalog, "a/50")).not.toContain("a/50");
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
test("repeats pool observations, report spread, and split scores by complexity", async () => {
|
|
77
|
+
// A flaky model: the strict-format task passes on odd calls only. One pass cannot tell
|
|
78
|
+
// that apart from a model that always passes or always fails.
|
|
79
|
+
let call = 0;
|
|
80
|
+
const flaky = async (_slug: string, messages: { role: string; content: string }[]) => {
|
|
81
|
+
call += 1;
|
|
82
|
+
const user = messages[messages.length - 1]!.content;
|
|
83
|
+
if (user.includes("primary colours")) return call % 2 === 0 ? "red blue yellow" : "Red, Blue, and Yellow!";
|
|
84
|
+
return "";
|
|
85
|
+
};
|
|
86
|
+
const [single] = await runEval({ slugs: ["a/flaky"], complete: flaky, tasks: EVAL_TASKS.filter((t) => t.id === "intel/strict-format") });
|
|
87
|
+
expect(single!.repeats).toBe(1);
|
|
88
|
+
expect(single!.spread).toEqual({});
|
|
89
|
+
|
|
90
|
+
call = 0;
|
|
91
|
+
const [many] = await runEval({ slugs: ["a/flaky"], complete: flaky, tasks: EVAL_TASKS.filter((t) => t.id === "intel/strict-format"), repeats: 10 });
|
|
92
|
+
expect(many!.repeats).toBe(10);
|
|
93
|
+
expect(many!.axes.intelligence.n).toBe(10);
|
|
94
|
+
// Half the passes score 1 and half 0, so the pooled mean sits mid-range and the spread
|
|
95
|
+
// says plainly that the headline is one sample of something noisy.
|
|
96
|
+
expect(many!.axes.intelligence.sum / many!.axes.intelligence.n).toBeCloseTo(0.5, 1);
|
|
97
|
+
expect(many!.spread.intelligence).toBe(1);
|
|
98
|
+
// That task is in the hard band, so the breakdown attributes it there and nowhere else.
|
|
99
|
+
expect(many!.byComplexity.hard?.n).toBe(10);
|
|
100
|
+
expect(many!.byComplexity.easy).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the suite spans complexities, and hard items are not all pinned at the ceiling", () => {
|
|
104
|
+
const bands = new Set(EVAL_TASKS.map((t) => t.complexity ?? "easy"));
|
|
105
|
+
expect(bands.has("easy")).toBe(true);
|
|
106
|
+
expect(bands.has("hard")).toBe(true);
|
|
107
|
+
// A perfect model must still score 1 on every hard task: a task nobody can pass
|
|
108
|
+
// measures the grader, not the model.
|
|
109
|
+
const hard = EVAL_TASKS.filter((t) => t.complexity === "hard");
|
|
110
|
+
expect(hard.length).toBeGreaterThanOrEqual(6);
|
|
111
|
+
expect(hard.find((t) => t.id === "coding/sort-lexicographic")!.grade("[10, 80, 9]")).toBe(1);
|
|
112
|
+
expect(hard.find((t) => t.id === "coding/event-loop-order")!.grade("a, d, c, b")).toBe(1);
|
|
113
|
+
expect(hard.find((t) => t.id === "intel/collatz-steps")!.grade("13")).toBe(1);
|
|
114
|
+
expect(hard.find((t) => t.id === "intel/arith-hard")!.grade("2491\n8192\n59\n36\n6")).toBe(1);
|
|
115
|
+
expect(hard.find((t) => t.id === "coding/trace-hard")!.grade("4\nxyabc\nab\n0\n3")).toBe(1);
|
|
116
|
+
// And a plausible wrong answer must NOT score 1, or the task adds no signal.
|
|
117
|
+
expect(hard.find((t) => t.id === "coding/sort-lexicographic")!.grade("[9, 10, 80]")).toBeLessThan(1);
|
|
118
|
+
expect(hard.find((t) => t.id === "intel/collatz-steps")!.grade("1")).toBeLessThan(1);
|
|
119
|
+
expect(hard.find((t) => t.id === "intel/strict-format")!.grade("Red, blue, and yellow.")).toBeLessThan(1);
|
|
120
|
+
// The second hard band: computable only, no recall shortcut.
|
|
121
|
+
expect(hard.find((t) => t.id === "coding/stack-machine")!.grade("-32")).toBe(1);
|
|
122
|
+
expect(hard.find((t) => t.id === "coding/stack-machine")!.grade("-16")).toBeLessThan(1);
|
|
123
|
+
expect(hard.find((t) => t.id === "intel/ledger-balance")!.grade("60")).toBe(1);
|
|
124
|
+
expect(hard.find((t) => t.id === "intel/ledger-balance")!.grade("61")).toBeLessThan(1);
|
|
125
|
+
expect(hard.find((t) => t.id === "intel/constraint-conflict")!.grade("IMPOSSIBLE")).toBe(1);
|
|
126
|
+
expect(hard.find((t) => t.id === "intel/constraint-conflict")!.grade("12")).toBe(0);
|
|
127
|
+
expect(hard.find((t) => t.id === "coding/regex-backtrack")!.grade("XX\nab\nfalse\nx|y\na[b$]c")).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
75
130
|
test("fitCalibration + toLocalFeedScores place a target on the AA scale", () => {
|
|
76
131
|
expect(MIN_ANCHORS).toBe(3);
|
|
77
132
|
const anchors: EvalResult[] = [
|
|
78
|
-
{ slug: "a/one", axes: { coding: { sum: 0.2, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
|
|
79
|
-
{ slug: "a/two", axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
|
|
80
|
-
{ slug: "a/three", axes: { coding: { sum: 0.8, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
|
|
133
|
+
{ slug: "a/one", axes: { coding: { sum: 0.2, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0, repeats: 1, spread: {}, byComplexity: {} },
|
|
134
|
+
{ slug: "a/two", axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0, repeats: 1, spread: {}, byComplexity: {} },
|
|
135
|
+
{ slug: "a/three", axes: { coding: { sum: 0.8, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0, repeats: 1, spread: {}, byComplexity: {} },
|
|
81
136
|
];
|
|
82
137
|
const aaOf: Record<string, number> = { "a/one": 40, "a/two": 60, "a/three": 80 };
|
|
83
138
|
const cal = fitCalibration(anchors, (slug, axis) => (axis === "coding" ? aaOf[slug] : undefined));
|
|
@@ -85,7 +140,7 @@ describe("calibration", () => {
|
|
|
85
140
|
expect(cal.intelligence).toBeUndefined(); // no anchor data on that axis
|
|
86
141
|
|
|
87
142
|
const targets: EvalResult[] = [
|
|
88
|
-
{ slug: "z/gap", axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0.9, n: 1 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
|
|
143
|
+
{ slug: "z/gap", axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0.9, n: 1 }, agentic: { sum: 0, n: 0 } }, errors: 0, repeats: 1, spread: {}, byComplexity: {} },
|
|
89
144
|
];
|
|
90
145
|
const local = toLocalFeedScores(targets, cal, (s) => s.slice(0, s.indexOf("/")));
|
|
91
146
|
expect(local).toHaveLength(1);
|