auto-model-router 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +43 -55
  3. package/bun.lock +606 -0
  4. package/package.json +2 -1
  5. package/research/analyze-ledger.ts +173 -0
  6. package/research/apply-cost-tuning.ts +73 -0
  7. package/research/cost-analysis.ts +150 -0
  8. package/research/feed-check.ts +64 -0
  9. package/research/model-recommendations.ts +86 -0
  10. package/research/project-yield.ts +96 -0
  11. package/research/run-eval.ts +133 -0
  12. package/research/status.ts +55 -0
  13. package/research/tier-fill.ts +109 -0
  14. package/research/tier-map.ts +123 -0
  15. package/src/catalog/benchmark-feeds.ts +397 -0
  16. package/src/catalog/openrouter-catalog.ts +30 -0
  17. package/src/config/defaults.ts +30 -0
  18. package/src/config/load.ts +2 -0
  19. package/src/config/schema.ts +34 -0
  20. package/src/config/types.ts +106 -0
  21. package/src/cost/ledger.ts +23 -2
  22. package/src/cost/types.ts +24 -0
  23. package/src/eval/calibrate.ts +131 -0
  24. package/src/eval/grade.ts +115 -0
  25. package/src/eval/judge.ts +71 -0
  26. package/src/eval/run.ts +126 -0
  27. package/src/eval/tasks.ts +272 -0
  28. package/src/router/candidates.ts +13 -6
  29. package/src/router/explore.ts +59 -0
  30. package/src/router/select.ts +54 -4
  31. package/src/router/tier-plan.ts +57 -1
  32. package/src/router/types.ts +13 -0
  33. package/src/server/turn.ts +9 -2
  34. package/src/util/sqlite.ts +68 -1
  35. package/test/benchmark-feeds.test.ts +222 -0
  36. package/test/eval.test.ts +184 -0
  37. package/test/exploration.test.ts +251 -0
  38. package/test/failover.test.ts +4 -0
  39. package/test/hold-exploration.test.ts +124 -0
  40. package/test/tier-plan.test.ts +55 -1
  41. package/test/tokens.test.ts +7 -0
  42. package/test/trust-attribution.test.ts +96 -2
  43. package/test/turn.test.ts +45 -0
  44. package/tools/smoke.ts +2 -0
  45. package/tools/sync-marketplace-version.ts +60 -0
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
 
20
20
  /** Bump when a migration is added; guarded below so reopening never regresses it. */
21
- const USER_VERSION = 5;
21
+ const USER_VERSION = 10;
22
22
 
23
23
  const MIGRATIONS = `
24
24
  CREATE TABLE IF NOT EXISTS catalog_cache (
@@ -29,6 +29,18 @@ CREATE TABLE IF NOT EXISTS catalog_cache (
29
29
  key_scoped INTEGER NOT NULL DEFAULT 0
30
30
  );
31
31
 
32
+ CREATE TABLE IF NOT EXISTS benchmark_cache (
33
+ id INTEGER PRIMARY KEY CHECK (id = 1),
34
+ payload TEXT NOT NULL,
35
+ fetched_at_ms INTEGER NOT NULL
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS local_scores (
39
+ id INTEGER PRIMARY KEY CHECK (id = 1),
40
+ payload TEXT NOT NULL,
41
+ fetched_at_ms INTEGER NOT NULL
42
+ );
43
+
32
44
  CREATE TABLE IF NOT EXISTS ledger (
33
45
  id TEXT PRIMARY KEY,
34
46
  created_at_ms INTEGER NOT NULL,
@@ -128,6 +140,58 @@ const MIGRATE_V5 = `
128
140
  ALTER TABLE ledger ADD COLUMN omp_session_id TEXT NOT NULL DEFAULT '';
129
141
  `;
130
142
 
143
+ // v6: classifier INPUTS. Before this the ledger recorded only what routing
144
+ // decided (tier, slug, cost, escalation) and never what it decided FROM, so a
145
+ // turn could not be replayed, and no weight could be fit offline. `features`
146
+ // is the verbatim feature vector as JSON; `score` and `confidence` are the
147
+ // classifier's own outputs, kept alongside because they are cheap and make
148
+ // weight drift detectable when the scorer changes under a fixed feature set.
149
+ // `classifier_reasons` is the per-feature breakdown, which `select.ts` drops
150
+ // from the decision trail on every path except a hysteresis hold.
151
+ //
152
+ // All nullable with no backfill: pre-v6 rows genuinely lack these inputs and
153
+ // NULL says so honestly. A DEFAULT would invent data that was never observed.
154
+ const MIGRATE_V6 = `
155
+ ALTER TABLE ledger ADD COLUMN features TEXT;
156
+ ALTER TABLE ledger ADD COLUMN score REAL;
157
+ ALTER TABLE ledger ADD COLUMN confidence REAL;
158
+ ALTER TABLE ledger ADD COLUMN task TEXT;
159
+ ALTER TABLE ledger ADD COLUMN classifier_reasons TEXT;
160
+ `;
161
+
162
+ // v7: records epsilon-greedy exploration. `explored_from` is the tier the
163
+ // classifier actually chose on a turn we deliberately routed one step
164
+ // cheaper; NULL means the turn was routed normally.
165
+ //
166
+ // This is the counterfactual the ledger could never observe before. Natural
167
+ // traffic only reveals UNDER-routing, because a tier that was too low
168
+ // escalates and leaves a trace, while a tier that was too high looks
169
+ // indistinguishable from a tier that was exactly right.
170
+ const MIGRATE_V7 = `
171
+ ALTER TABLE ledger ADD COLUMN explored_from TEXT;
172
+ `;
173
+
174
+ // v8: records the hold-length arm a conversation was assigned by hold
175
+ // exploration. NULL means the conversation was not part of the experiment.
176
+ //
177
+ // Recorded on EVERY turn of the conversation, not only the turns a hold
178
+ // actually affects, so arms can be compared on total conversation cost
179
+ // rather than on the subset the treatment happened to touch.
180
+ const MIGRATE_V8 = `
181
+ ALTER TABLE ledger ADD COLUMN hold_arm INTEGER;
182
+ `;
183
+
184
+ // v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
185
+ // BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
186
+ // whole new table, created idempotently by the MIGRATIONS block above, so there
187
+ // is no ALTER guard here — the version bump alone records that the schema now
188
+ // includes it.
189
+
190
+ // v10: local_scores holds calibrated scores from our OWN eval harness
191
+ // (src/eval), a `local`-source feed applied only when benchmarks.useLocalScores
192
+ // is on. Another new table via the idempotent MIGRATIONS block; version bump
193
+ // only, no ALTER guard.
194
+
131
195
  export function openDb(path: string): Database {
132
196
  // ":memory:" has no parent directory to create.
133
197
  if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
@@ -145,6 +209,9 @@ export function openDb(path: string): Database {
145
209
  if (!ledgerCols.some((c) => c.name === "harness_id")) db.exec(MIGRATE_V3);
146
210
  if (!ledgerCols.some((c) => c.name === "error_kind")) db.exec(MIGRATE_V4);
147
211
  if (!ledgerCols.some((c) => c.name === "omp_session_id")) db.exec(MIGRATE_V5);
212
+ if (!ledgerCols.some((c) => c.name === "features")) db.exec(MIGRATE_V6);
213
+ if (!ledgerCols.some((c) => c.name === "explored_from")) db.exec(MIGRATE_V7);
214
+ if (!ledgerCols.some((c) => c.name === "hold_arm")) db.exec(MIGRATE_V8);
148
215
  db.exec(`PRAGMA user_version = ${USER_VERSION}`);
149
216
  }
150
217
  return db;
@@ -0,0 +1,222 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
+ import {
5
+ applyFeedScores,
6
+ fetchBenchlmScores,
7
+ normalizeModelKey,
8
+ parseAaModels,
9
+ parseBenchlmModels,
10
+ refreshFeedScores,
11
+ type FeedScore,
12
+ type FetchLike,
13
+ } from "../src/catalog/benchmark-feeds.ts";
14
+ import { loadConfig } from "../src/config/load.ts";
15
+ import type { RouterConfig } from "../src/config/types.ts";
16
+ import { openDb } from "../src/util/sqlite.ts";
17
+
18
+ /** A bare OpenRouter `/models` record, optionally pre-scored. */
19
+ function raw(id: string, benchmarks?: Record<string, unknown>): Record<string, unknown> {
20
+ const record: Record<string, unknown> = {
21
+ id,
22
+ canonical_slug: id,
23
+ name: id,
24
+ context_length: 131_072,
25
+ pricing: { prompt: "0.0000003", completion: "0.0000011" },
26
+ supported_parameters: ["tools"],
27
+ architecture: { input_modalities: ["text"], tokenizer: "Other" },
28
+ created: 1_700_000_000,
29
+ };
30
+ if (benchmarks !== undefined) record.benchmarks = benchmarks;
31
+ return record;
32
+ }
33
+
34
+ function aaScore(over: Partial<FeedScore> & { key: string }): FeedScore {
35
+ return { creator: "", source: "artificial_analysis", ...over };
36
+ }
37
+ function blScore(over: Partial<FeedScore> & { key: string }): FeedScore {
38
+ return { creator: "", source: "benchlm", ...over };
39
+ }
40
+
41
+ describe("normalizeModelKey", () => {
42
+ test("strips provider, tilde, and release words but keeps the parameter size", () => {
43
+ expect(normalizeModelKey("z-ai/glm-5.3-flash")).toBe("glm-5-3-flash");
44
+ expect(normalizeModelKey("~deepseek/deepseek-v4-flash-latest")).toBe("deepseek-v4-flash");
45
+ expect(normalizeModelKey("meta/muse-glimmer-30b")).toBe("muse-glimmer-30b");
46
+ // The feed's own display spelling collapses onto the same key.
47
+ expect(normalizeModelKey("Muse Glimmer 30B")).toBe("muse-glimmer-30b");
48
+ expect(normalizeModelKey("MiniMax M3")).toBe("minimax-m3");
49
+ });
50
+ });
51
+
52
+ describe("parseAaModels", () => {
53
+ test("reads the three indices, keeps in-range values, and skips empty rows", () => {
54
+ const body = {
55
+ data: [
56
+ {
57
+ slug: "glm-5.3-flash",
58
+ model_creator: { slug: "z-ai" },
59
+ evaluations: {
60
+ artificial_analysis_coding_index: 61.2,
61
+ artificial_analysis_intelligence_index: 58.4,
62
+ artificial_analysis_agentic_index: 150, // out of range → dropped
63
+ },
64
+ },
65
+ { slug: "no-evals", model_creator: { slug: "x" }, evaluations: {} },
66
+ ],
67
+ };
68
+ const parsed = parseAaModels(body);
69
+ expect(parsed).toHaveLength(1);
70
+ expect(parsed[0]).toMatchObject({ key: "glm-5-3-flash", creator: "z-ai", coding: 61.2, intelligence: 58.4 });
71
+ expect(parsed[0]?.agentic).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe("parseBenchlmModels", () => {
76
+ test("maps categories to axes, drops estimated rows, and ignores out-of-range", () => {
77
+ const body = {
78
+ models: [
79
+ {
80
+ model: "Muse Glimmer 30B",
81
+ creator: "Meta",
82
+ evidenceStatus: "supported",
83
+ categoryScores: { coding: 55, reasoning: 52, agentic: 48 },
84
+ },
85
+ {
86
+ model: "Guessed Model",
87
+ creator: "x",
88
+ evidenceStatus: "estimated",
89
+ categoryScores: { coding: 90 },
90
+ },
91
+ ],
92
+ };
93
+ const parsed = parseBenchlmModels(body);
94
+ expect(parsed).toHaveLength(1);
95
+ expect(parsed[0]).toMatchObject({ key: "muse-glimmer-30b", coding: 55, intelligence: 52, agentic: 48 });
96
+ });
97
+ });
98
+
99
+ describe("applyFeedScores", () => {
100
+ test("fills the real gap models and reaches normalizeCatalogModel", () => {
101
+ const catalog = [
102
+ raw("meta/muse-glimmer-30b"),
103
+ raw("z-ai/glm-5.3-flash"),
104
+ // Already scored by OpenRouter on coding; a feed must not overwrite it.
105
+ raw("google/gemini-3.7-flash", { artificial_analysis: { coding_index: 76.1 } }),
106
+ ];
107
+ const feeds: FeedScore[] = [
108
+ aaScore({ key: "glm-5-3-flash", creator: "z-ai", coding: 61, intelligence: 58 }),
109
+ aaScore({ key: "gemini-3-7-flash", creator: "google", coding: 40, intelligence: 63 }),
110
+ blScore({ key: "muse-glimmer-30b", creator: "meta", coding: 55, agentic: 48 }),
111
+ blScore({ key: "glm-5-3-flash", creator: "z-ai", agentic: 44 }),
112
+ ];
113
+
114
+ const result = applyFeedScores(catalog, feeds);
115
+
116
+ // muse-glimmer: was empty, gains coding + agentic from BenchLM.
117
+ const muse = normalizeCatalogModel(catalog[0]);
118
+ expect(muse?.quality).toEqual({ coding: 55, agentic: 48 });
119
+
120
+ // glm: coding + intelligence from AA (stronger), agentic from BenchLM.
121
+ const glm = normalizeCatalogModel(catalog[1]);
122
+ expect(glm?.quality).toEqual({ coding: 61, intelligence: 58, agentic: 44 });
123
+
124
+ // gemini: published coding survives untouched; intelligence filled from AA.
125
+ const gemini = normalizeCatalogModel(catalog[2]);
126
+ expect(gemini?.quality.coding).toBe(76.1);
127
+ expect(gemini?.quality.intelligence).toBe(63);
128
+
129
+ // Provenance recorded, counts add up.
130
+ const museBench = catalog[0]?.benchmarks;
131
+ expect(museBench).toMatchObject({ fill_sources: { coding: "benchlm", agentic: "benchlm" } });
132
+ expect(result.modelsFilled).toBe(3);
133
+ expect(result.sources.artificial_analysis).toBe(3); // glm coding+intel, gemini intel
134
+ expect(result.sources.benchlm).toBe(3); // muse coding+agentic, glm agentic
135
+ });
136
+
137
+ test("AA wins over BenchLM for the same axis", () => {
138
+ const catalog = [raw("z-ai/glm-5.3-flash")];
139
+ const feeds: FeedScore[] = [
140
+ blScore({ key: "glm-5-3-flash", creator: "z-ai", coding: 10 }),
141
+ aaScore({ key: "glm-5-3-flash", creator: "z-ai", coding: 61 }),
142
+ ];
143
+ applyFeedScores(catalog, feeds);
144
+ expect(normalizeCatalogModel(catalog[0])?.quality.coding).toBe(61);
145
+ });
146
+
147
+ test("never fuzzy-matches a different model", () => {
148
+ const catalog = [raw("meta/muse-glimmer-30b")];
149
+ // Same family, different model — must not lend its score.
150
+ const feeds: FeedScore[] = [aaScore({ key: "muse-spark-1-2", creator: "meta", coding: 72 })];
151
+ const result = applyFeedScores(catalog, feeds);
152
+ expect(result.modelsFilled).toBe(0);
153
+ expect(normalizeCatalogModel(catalog[0])?.quality).toEqual({});
154
+ });
155
+
156
+ test("a shared key with conflicting creators fills only the creator that matches", () => {
157
+ const catalog = [raw("z-ai/glm-5.3-flash")];
158
+ const feeds: FeedScore[] = [
159
+ aaScore({ key: "glm-5-3-flash", creator: "someone-else", coding: 5 }),
160
+ aaScore({ key: "glm-5-3-flash", creator: "z-ai", coding: 61 }),
161
+ ];
162
+ applyFeedScores(catalog, feeds);
163
+ expect(normalizeCatalogModel(catalog[0])?.quality.coding).toBe(61);
164
+ });
165
+ });
166
+
167
+ describe("refreshFeedScores", () => {
168
+ function cfgWith(over: Partial<RouterConfig["benchmarks"]>): RouterConfig {
169
+ const base = loadConfig({});
170
+ return { ...base, benchmarks: { ...base.benchmarks, ...over } };
171
+ }
172
+
173
+ test("fetches once, then serves the cache within the TTL", async () => {
174
+ const db = openDb(":memory:");
175
+ let calls = 0;
176
+ const fakeFetch: FetchLike = async (url) => {
177
+ calls += 1;
178
+ const u = String(url);
179
+ if (u.includes("benchlm")) {
180
+ return Response.json({
181
+ models: [
182
+ { model: "MiniMax M3", creator: "MiniMax", evidenceStatus: "supported", categoryScores: { coding: 58 } },
183
+ ],
184
+ });
185
+ }
186
+ return Response.json({ data: [] });
187
+ };
188
+
189
+ const cfg = cfgWith({ enabled: true, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 1_000_000 });
190
+ const first = await refreshFeedScores(cfg, db, { fetchImpl: fakeFetch, now: 1000 });
191
+ expect(first).toHaveLength(1);
192
+ expect(first[0]).toMatchObject({ key: "minimax-m3", coding: 58, source: "benchlm" });
193
+ expect(calls).toBe(1); // AA skipped (no key), BenchLM fetched once
194
+
195
+ const second = await refreshFeedScores(cfg, db, { fetchImpl: fakeFetch, now: 2000 });
196
+ expect(second).toHaveLength(1);
197
+ expect(calls).toBe(1); // within TTL → no new fetch
198
+ db.close();
199
+ });
200
+
201
+ test("falls back to the stale cache when a refresh returns nothing", async () => {
202
+ const db = openDb(":memory:");
203
+ const seed = [{ key: "minimax-m3", creator: "minimax", coding: 58, source: "benchlm" }];
204
+ db.query("INSERT INTO benchmark_cache (id, payload, fetched_at_ms) VALUES (1, ?, ?)").run(JSON.stringify(seed), 0);
205
+ const emptyFetch: FetchLike = async () => Response.json({ models: [] });
206
+ const cfg = cfgWith({ enabled: true, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 10 });
207
+ const got = await refreshFeedScores(cfg, db, { fetchImpl: emptyFetch, now: 1_000_000 });
208
+ expect(got).toHaveLength(1);
209
+ expect(got[0]).toMatchObject({ key: "minimax-m3", coding: 58 });
210
+ db.close();
211
+ });
212
+ });
213
+
214
+ // fetchBenchlmScores over a fake fetch: the keyless path parses end to end.
215
+ test("fetchBenchlmScores parses a keyless leaderboard response", async () => {
216
+ const fake: FetchLike = async () =>
217
+ Response.json({
218
+ models: [{ model: "GLM 5.3 Flash", creator: "Z-AI", evidenceStatus: "supported", categoryScores: { coding: 61, reasoning: 58 } }],
219
+ });
220
+ const scores = await fetchBenchlmScores({ fetchImpl: fake });
221
+ expect(scores).toEqual([{ key: "glm-5-3-flash", creator: "z-ai", coding: 61, intelligence: 58, source: "benchlm" }]);
222
+ });
@@ -0,0 +1,184 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
+ import { applyFeedScores, loadLocalScores, saveLocalScores, type FeedScore } from "../src/catalog/benchmark-feeds.ts";
5
+ import { answerScore, extractJson, isRefusalOrEmpty, jsonField, tokenCoverage } from "../src/eval/grade.ts";
6
+ import { applyFit, fitAxis, fitCalibration, toLocalFeedScores, MIN_ANCHORS } from "../src/eval/calibrate.ts";
7
+ import { runEval, type EvalResult } from "../src/eval/run.ts";
8
+ import { makeJudge, parseScore } from "../src/eval/judge.ts";
9
+ import type { EvalTask, JudgedTask } from "../src/eval/tasks.ts";
10
+ import { openDb } from "../src/util/sqlite.ts";
11
+
12
+ describe("grade helpers", () => {
13
+ test("answerScore matches whole reply, last line, or a standalone token", () => {
14
+ expect(answerScore("9.9", "9.9")).toBe(1);
15
+ expect(answerScore("The answer is 9.9", "9.9")).toBe(1);
16
+ expect(answerScore("reasoning...\n9.9", "9.9")).toBe(1);
17
+ expect(answerScore("19.99", "9.9")).toBe(0); // not a substring match
18
+ expect(answerScore("", "9.9")).toBe(0);
19
+ });
20
+ test("tokenCoverage is the fraction of tokens present", () => {
21
+ expect(tokenCoverage("return a + b;", ["a + b"])).toBe(1);
22
+ expect(tokenCoverage("n * 2", ["n", "*", "2"])).toBe(1);
23
+ expect(tokenCoverage("n plus two", ["n", "*", "2"])).toBeCloseTo(1 / 3);
24
+ });
25
+ test("extractJson tolerates fences and prose; jsonField reads a key", () => {
26
+ expect(extractJson('here: {"answer": 8} ok')).toEqual({ answer: 8 });
27
+ expect(extractJson("```json\n[2,3,5]\n```")).toEqual([2, 3, 5]);
28
+ expect(extractJson("no json here")).toBeUndefined();
29
+ expect(jsonField({ tool: "read_file" }, "tool")).toBe("read_file");
30
+ expect(jsonField([1, 2], "tool")).toBeUndefined();
31
+ });
32
+ test("isRefusalOrEmpty flags empties and refusals", () => {
33
+ expect(isRefusalOrEmpty("")).toBe(true);
34
+ expect(isRefusalOrEmpty("I cannot help with that")).toBe(true);
35
+ expect(isRefusalOrEmpty("sure, here")).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe("calibration", () => {
40
+ test("fitAxis is OLS, needs MIN_ANCHORS points and some spread", () => {
41
+ const fit = fitAxis([
42
+ { raw: 0.2, aa: 40 },
43
+ { raw: 0.5, aa: 60 },
44
+ { raw: 0.8, aa: 80 },
45
+ ]);
46
+ expect(fit).not.toBeNull();
47
+ expect(fit!.slope).toBeCloseTo(66.67, 1);
48
+ expect(fit!.r).toBeCloseTo(1, 5);
49
+ expect(applyFit(fit!, 0.5)).toBeCloseTo(60, 5);
50
+ expect(applyFit(fit!, 5)).toBe(100); // clamped
51
+ expect(fitAxis([{ raw: 0.2, aa: 40 }, { raw: 0.5, aa: 60 }])).toBeNull(); // < MIN_ANCHORS
52
+ expect(fitAxis([{ raw: 0.5, aa: 40 }, { raw: 0.5, aa: 60 }, { raw: 0.5, aa: 80 }])).toBeNull(); // no spread
53
+ // Negative correlation (suite ranks models opposite to AA) is refused.
54
+ expect(fitAxis([{ raw: 0.8, aa: 40 }, { raw: 0.5, aa: 60 }, { raw: 0.2, aa: 80 }])).toBeNull();
55
+ });
56
+
57
+ test("fitCalibration + toLocalFeedScores place a target on the AA scale", () => {
58
+ expect(MIN_ANCHORS).toBe(3);
59
+ const anchors: EvalResult[] = [
60
+ { slug: "a/one", axes: { coding: { sum: 0.2, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
61
+ { slug: "a/two", axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
62
+ { slug: "a/three", axes: { coding: { sum: 0.8, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
63
+ ];
64
+ const aaOf: Record<string, number> = { "a/one": 40, "a/two": 60, "a/three": 80 };
65
+ const cal = fitCalibration(anchors, (slug, axis) => (axis === "coding" ? aaOf[slug] : undefined));
66
+ expect(cal.coding).toBeDefined();
67
+ expect(cal.intelligence).toBeUndefined(); // no anchor data on that axis
68
+
69
+ const targets: EvalResult[] = [
70
+ { slug: "z/gap", axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0.9, n: 1 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
71
+ ];
72
+ const local = toLocalFeedScores(targets, cal, (s) => s.slice(0, s.indexOf("/")));
73
+ expect(local).toHaveLength(1);
74
+ expect(local[0]).toMatchObject({ key: "gap", creator: "z", source: "local" });
75
+ expect(local[0]!.coding).toBeCloseTo(60, 5); // calibrated from raw 0.5
76
+ expect(local[0]!.intelligence).toBeUndefined(); // axis had no fit, so not emitted
77
+ });
78
+ });
79
+
80
+ describe("runEval", () => {
81
+ test("aggregates grades into per-axis means", async () => {
82
+ const tasks: EvalTask[] = [
83
+ { id: "c1", axis: "coding", user: "x", grade: (o) => (o === "good" ? 1 : 0) },
84
+ { id: "c2", axis: "coding", user: "y", grade: () => 0.5 },
85
+ { id: "a1", axis: "agentic", user: "z", grade: (o) => (o === "good" ? 1 : 0) },
86
+ ];
87
+ const results = await runEval({ slugs: ["good", "bad"], tasks, complete: async (slug) => slug });
88
+ const good = results.find((r) => r.slug === "good")!;
89
+ expect(good.axes.coding.sum).toBe(1.5); // 1 + 0.5
90
+ expect(good.axes.coding.n).toBe(2);
91
+ expect(good.axes.agentic.sum).toBe(1);
92
+ const bad = results.find((r) => r.slug === "bad")!;
93
+ expect(bad.axes.coding.sum).toBe(0.5); // 0 + 0.5
94
+ expect(bad.axes.agentic.sum).toBe(0);
95
+ });
96
+
97
+ test("a throwing completion is excluded, not scored 0", async () => {
98
+ const tasks: EvalTask[] = [{ id: "a", axis: "coding", user: "x", grade: () => 1 }];
99
+ const results = await runEval({
100
+ slugs: ["m"],
101
+ tasks,
102
+ complete: async () => {
103
+ throw new Error("boom");
104
+ },
105
+ });
106
+ expect(results[0]!.axes.coding.n).toBe(0); // no observation
107
+ expect(results[0]!.errors).toBe(1);
108
+ });
109
+ });
110
+
111
+ describe("local source integration", () => {
112
+ function raw(id: string): Record<string, unknown> {
113
+ return {
114
+ id,
115
+ canonical_slug: id,
116
+ name: id,
117
+ context_length: 131072,
118
+ pricing: { prompt: "0.0000003", completion: "0.0000011" },
119
+ supported_parameters: ["tools"],
120
+ architecture: { input_modalities: ["text"], tokenizer: "Other" },
121
+ created: 1_700_000_000,
122
+ };
123
+ }
124
+
125
+ test("local fills only where no stronger source has the axis", () => {
126
+ const catalog = [raw("z-ai/glm-5.3-flash")];
127
+ const feeds: FeedScore[] = [
128
+ { key: "glm-5-3-flash", creator: "z-ai", source: "artificial_analysis", coding: 61 },
129
+ { key: "glm-5-3-flash", creator: "z-ai", source: "local", coding: 20, intelligence: 55 },
130
+ ];
131
+ const result = applyFeedScores(catalog, feeds);
132
+ const q = normalizeCatalogModel(catalog[0])?.quality;
133
+ expect(q?.coding).toBe(61); // AA wins over local
134
+ expect(q?.intelligence).toBe(55); // local fills the axis nobody else had
135
+ expect(result.sources.local).toBe(1);
136
+ expect(result.sources.artificial_analysis).toBe(1);
137
+ });
138
+
139
+ test("saveLocalScores / loadLocalScores round-trip", () => {
140
+ const db = openDb(":memory:");
141
+ const scores: FeedScore[] = [{ key: "muse-glimmer-30b", creator: "meta", source: "local", coding: 42, agentic: 39 }];
142
+ saveLocalScores(db, scores, 123);
143
+ expect(loadLocalScores(db)).toEqual(scores);
144
+ db.close();
145
+ });
146
+ });
147
+
148
+ describe("llm judge", () => {
149
+ test("parseScore takes the last standalone 0-10 and scales to 0-1", () => {
150
+ expect(parseScore("8")).toBeCloseTo(0.8, 5);
151
+ expect(parseScore("Score: 10/10")).toBeCloseTo(1, 5);
152
+ expect(parseScore("I count 3 issues, so 7")).toBeCloseTo(0.7, 5); // last wins
153
+ expect(parseScore("no number here")).toBeNull();
154
+ });
155
+
156
+ test("makeJudge parses a score, and returns null on a thrown completion", async () => {
157
+ const task: JudgedTask = { id: "j", axis: "coding", user: "do a thing" };
158
+ const good = makeJudge(async () => "the answer earns 8", "judge/model");
159
+ expect(await good(task, "some answer")).toBeCloseTo(0.8, 5);
160
+ const bad = makeJudge(async () => {
161
+ throw new Error("judge down");
162
+ }, "judge/model");
163
+ expect(await bad(task, "some answer")).toBeNull();
164
+ });
165
+
166
+ test("runEval folds judged scores into the axis mean, and drops unscorable ones", async () => {
167
+ const judged: JudgedTask[] = [
168
+ { id: "j1", axis: "coding", user: "a" },
169
+ { id: "j2", axis: "coding", user: "b" },
170
+ ];
171
+ // j1 scores 0.6; j2 is unscorable (null) → excluded as an error.
172
+ const judge = async (t: JudgedTask) => (t.id === "j1" ? 0.6 : null);
173
+ const results = await runEval({ slugs: ["m"], tasks: [], judged, judge, complete: async () => "ans" });
174
+ expect(results[0]!.axes.coding).toEqual({ sum: 0.6, n: 1 });
175
+ expect(results[0]!.errors).toBe(1);
176
+ });
177
+
178
+ test("judged tasks are skipped entirely when no judge is supplied", async () => {
179
+ const judged: JudgedTask[] = [{ id: "j1", axis: "coding", user: "a" }];
180
+ const results = await runEval({ slugs: ["m"], tasks: [], judged, complete: async () => "ans" });
181
+ expect(results[0]!.axes.coding).toEqual({ sum: 0, n: 0 });
182
+ expect(results[0]!.errors).toBe(0);
183
+ });
184
+ });