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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +43 -55
- package/bun.lock +606 -0
- package/package.json +2 -1
- package/research/analyze-ledger.ts +173 -0
- package/research/apply-cost-tuning.ts +73 -0
- package/research/cost-analysis.ts +150 -0
- package/research/feed-check.ts +64 -0
- package/research/model-recommendations.ts +86 -0
- package/research/project-yield.ts +96 -0
- package/research/run-eval.ts +133 -0
- package/research/status.ts +55 -0
- package/research/tier-fill.ts +109 -0
- package/research/tier-map.ts +123 -0
- package/src/catalog/benchmark-feeds.ts +397 -0
- package/src/catalog/openrouter-catalog.ts +30 -0
- package/src/config/defaults.ts +30 -0
- package/src/config/load.ts +2 -0
- package/src/config/schema.ts +34 -0
- package/src/config/types.ts +106 -0
- package/src/cost/ledger.ts +23 -2
- package/src/cost/types.ts +24 -0
- package/src/eval/calibrate.ts +131 -0
- package/src/eval/grade.ts +115 -0
- package/src/eval/judge.ts +71 -0
- package/src/eval/run.ts +126 -0
- package/src/eval/tasks.ts +272 -0
- package/src/router/candidates.ts +13 -6
- package/src/router/explore.ts +59 -0
- package/src/router/select.ts +54 -4
- package/src/router/tier-plan.ts +57 -1
- package/src/router/types.ts +13 -0
- package/src/server/turn.ts +9 -2
- package/src/util/sqlite.ts +68 -1
- package/test/benchmark-feeds.test.ts +222 -0
- package/test/eval.test.ts +184 -0
- package/test/exploration.test.ts +251 -0
- package/test/failover.test.ts +4 -0
- package/test/hold-exploration.test.ts +124 -0
- package/test/tier-plan.test.ts +55 -1
- package/test/tokens.test.ts +7 -0
- package/test/trust-attribution.test.ts +96 -2
- package/test/turn.test.ts +45 -0
- package/tools/smoke.ts +2 -0
- package/tools/sync-marketplace-version.ts +60 -0
package/src/util/sqlite.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
});
|