auto-model-router 0.29.0 → 0.30.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.
@@ -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.29.0",
10
+ "version": "0.30.0",
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.29.0",
17
+ "version": "0.30.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
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
- async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult> {
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,72 @@ 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
+ },
234
310
  ];
235
311
 
236
312
  /**
@@ -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,58 @@ 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
+ });
121
+
75
122
  test("fitCalibration + toLocalFeedScores place a target on the AA scale", () => {
76
123
  expect(MIN_ANCHORS).toBe(3);
77
124
  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 },
125
+ { 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: {} },
126
+ { 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: {} },
127
+ { 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
128
  ];
82
129
  const aaOf: Record<string, number> = { "a/one": 40, "a/two": 60, "a/three": 80 };
83
130
  const cal = fitCalibration(anchors, (slug, axis) => (axis === "coding" ? aaOf[slug] : undefined));
@@ -85,7 +132,7 @@ describe("calibration", () => {
85
132
  expect(cal.intelligence).toBeUndefined(); // no anchor data on that axis
86
133
 
87
134
  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 },
135
+ { 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
136
  ];
90
137
  const local = toLocalFeedScores(targets, cal, (s) => s.slice(0, s.indexOf("/")));
91
138
  expect(local).toHaveLength(1);