auto-model-router 0.30.3 → 0.31.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.30.3",
10
+ "version": "0.31.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.30.3",
17
+ "version": "0.31.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.30.3",
3
+ "version": "0.31.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",
@@ -58,13 +58,26 @@ export interface AnchorPoint {
58
58
  }
59
59
 
60
60
  /**
61
- * OLS fit, or null when the calibration cannot be trusted: too few points, no
62
- * spread, a non-positive slope, or weak correlation. A suite that does not track
63
- * AA positively and with real correlation would turn a target's score into noise
64
- * dressed as signal, so we refuse it and emit nothing for that axis.
61
+ * The raw range the anchors must actually cover before a line through them means anything.
62
+ *
63
+ * Correlation alone does NOT catch a compressed fit: three anchors published 20/50/80 that
64
+ * our suite scores 0.96/0.97/0.98 correlate at r = 1.0, and the line they define has a slope
65
+ * of ~3000 index points per unit of raw score. That fit is arithmetically perfect and
66
+ * completely useless — it is how a model published at 39.5 was calibrated to 22.8. If the
67
+ * anchors barely differ on our suite, our suite cannot place anything between them.
68
+ */
69
+ export const MIN_RAW_SPREAD = 0.15;
70
+
71
+ /**
72
+ * OLS fit, or null when the calibration cannot be trusted: too few points, too narrow a raw
73
+ * range, no spread, a non-positive slope, or weak correlation. A suite that does not track
74
+ * AA positively, with real correlation, over a real range would turn a target's score into
75
+ * noise dressed as signal, so we refuse it and emit nothing for that axis.
65
76
  */
66
77
  export function fitAxis(points: readonly AnchorPoint[]): LineFit | null {
67
78
  if (points.length < MIN_ANCHORS) return null;
79
+ const raws = points.map((p) => p.raw);
80
+ if (Math.max(...raws) - Math.min(...raws) < MIN_RAW_SPREAD) return null;
68
81
  const n = points.length;
69
82
  let sx = 0;
70
83
  let sy = 0;
@@ -101,19 +114,32 @@ export function applyFit(fit: LineFit, raw: number): number {
101
114
  const AXES: readonly QualityAxis[] = ["coding", "intelligence", "agentic"];
102
115
 
103
116
  /**
104
- * Fit every axis from the anchors' raw suite scores paired with their known AA
105
- * scores. `anchorAa` supplies the AA index per slug+axis (absent that anchor
106
- * is not used on that axis).
117
+ * The correlation a fit must reach before its numbers are published as scores. `MIN_R` (0.5)
118
+ * is the bar for a fit being computable at all; this is the bar for TRUSTING one. The fit's
119
+ * `r` and `n` were previously computed and then discarded, so an r of 0.51 and one of 0.99
120
+ * produced indistinguishable output — and a shallow fit silently compressed every target.
121
+ */
122
+ export const PUBLISH_MIN_R = 0.8;
123
+
124
+ /**
125
+ * Fit every axis from the anchors' raw suite scores paired with their known AA scores.
126
+ * `anchorAa` supplies the AA index per slug+axis (absent ⇒ that anchor is not used there).
127
+ *
128
+ * `rawOf` selects which observations the fit is built from. It defaults to every band, but
129
+ * callers should pass the HARD band: easy and moderate sit at ~1.0 for every model worth
130
+ * ranking, so including them leaves the regression almost no variation in x against a wide
131
+ * spread in published y, and the slope collapses toward flat.
107
132
  */
108
133
  export function fitCalibration(
109
134
  anchors: readonly EvalResult[],
110
135
  anchorAa: (slug: string, axis: QualityAxis) => number | undefined,
136
+ rawOf: (result: EvalResult, axis: QualityAxis) => number | null = (r, axis) => axisMean(r.axes[axis]),
111
137
  ): Calibration {
112
138
  const cal: Calibration = {};
113
139
  for (const axis of AXES) {
114
140
  const points: AnchorPoint[] = [];
115
141
  for (const r of anchors) {
116
- const raw = axisMean(r.axes[axis]);
142
+ const raw = rawOf(r, axis);
117
143
  const aa = anchorAa(r.slug, axis);
118
144
  if (raw !== null && aa !== undefined) points.push({ raw, aa });
119
145
  }
@@ -123,15 +149,24 @@ export function fitCalibration(
123
149
  return cal;
124
150
  }
125
151
 
126
- function axisMean(a: AxisScore | undefined): number | null {
152
+ export function axisMean(a: AxisScore | undefined): number | null {
127
153
  return a === undefined || a.n === 0 ? null : a.sum / a.n;
128
154
  }
129
155
 
130
- /** Calibrated local FeedScores for the targets, one axis at a time, skipping axes with no fit. */
156
+ /** The hard band alone, for calibration. */
157
+ export const hardRaw = (r: EvalResult, axis: QualityAxis): number | null => axisMean(r.axesHard[axis]);
158
+
159
+ /**
160
+ * Calibrated local FeedScores for the targets, one axis at a time. An axis is skipped when it
161
+ * has no fit, when the fit is weaker than `minR`, or when the target made no observation on
162
+ * it — honest silence rather than a number nobody should act on.
163
+ */
131
164
  export function toLocalFeedScores(
132
165
  targets: readonly EvalResult[],
133
166
  cal: Calibration,
134
167
  authorOf: (slug: string) => string,
168
+ rawOf: (result: EvalResult, axis: QualityAxis) => number | null = (r, axis) => axisMean(r.axes[axis]),
169
+ minR = PUBLISH_MIN_R,
135
170
  ): FeedScore[] {
136
171
  const out: FeedScore[] = [];
137
172
  for (const r of targets) {
@@ -139,8 +174,8 @@ export function toLocalFeedScores(
139
174
  let any = false;
140
175
  for (const axis of AXES) {
141
176
  const fit = cal[axis];
142
- const raw = axisMean(r.axes[axis]);
143
- if (fit === undefined || raw === null) continue;
177
+ const raw = rawOf(r, axis);
178
+ if (fit === undefined || raw === null || fit.r < minR) continue;
144
179
  entry[axis] = applyFit(fit, raw);
145
180
  any = true;
146
181
  }
package/src/eval/run.ts CHANGED
@@ -39,6 +39,14 @@ export interface EvalResult {
39
39
  * questions were easy.
40
40
  */
41
41
  byComplexity: Partial<Record<Complexity, AxisScore>>;
42
+ /**
43
+ * Per-axis scores from the HARD band alone. Calibration fits against these: the easy and
44
+ * moderate bands sit at ~1.0 for every model worth ranking, so including them gives the
45
+ * regression almost no variation in x against a wide spread in published y — the fitted
46
+ * slope goes shallow and every target is dragged toward the middle. Measured: a model
47
+ * published at intelligence 39.5 calibrated to 22.8 across 10 passes.
48
+ */
49
+ axesHard: Record<QualityAxis, AxisScore>;
42
50
  /**
43
51
  * Per-axis spread across passes: max pass mean minus min pass mean, or null under two
44
52
  * passes. A wide spread means the headline is one sample of a noisy quantity, and is the
@@ -144,6 +152,7 @@ async function scorePass(slug: string, args: RunEvalArgs): Promise<EvalResult> {
144
152
  }
145
153
  }
146
154
  const byComplexity: Partial<Record<Complexity, AxisScore>> = {};
155
+ const axesHard: Record<QualityAxis, AxisScore> = { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } };
147
156
  for (const o of [...objective, ...judgedOutcomes, ...scenarioOutcomes]) {
148
157
  if (!o.ok) {
149
158
  // An unobserved task is NOT a zero: a provider's throttle or outage would otherwise
@@ -154,11 +163,15 @@ async function scorePass(slug: string, args: RunEvalArgs): Promise<EvalResult> {
154
163
  }
155
164
  axes[o.axis].sum += o.grade;
156
165
  axes[o.axis].n += 1;
166
+ if (o.complexity === "hard") {
167
+ axesHard[o.axis].sum += o.grade;
168
+ axesHard[o.axis].n += 1;
169
+ }
157
170
  const band = (byComplexity[o.complexity] ??= { sum: 0, n: 0 });
158
171
  band.sum += o.grade;
159
172
  band.n += 1;
160
173
  }
161
- return { slug, axes, errors, repeats: 1, spread: {}, byComplexity };
174
+ return { slug, axes, errors, repeats: 1, spread: {}, byComplexity, axesHard };
162
175
  }
163
176
 
164
177
  const AXES: readonly QualityAxis[] = ["coding", "intelligence", "agentic"];
@@ -174,6 +187,7 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
174
187
  const axes: Record<QualityAxis, AxisScore> = { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } };
175
188
  const means: Record<QualityAxis, number[]> = { coding: [], intelligence: [], agentic: [] };
176
189
  const byComplexity: Partial<Record<Complexity, AxisScore>> = {};
190
+ const axesHard: Record<QualityAxis, AxisScore> = { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } };
177
191
  let errors = 0;
178
192
  for (let i = 0; i < passes; i++) {
179
193
  const pass = await scorePass(slug, args);
@@ -182,6 +196,8 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
182
196
  for (const axis of AXES) {
183
197
  axes[axis].sum += pass.axes[axis].sum;
184
198
  axes[axis].n += pass.axes[axis].n;
199
+ axesHard[axis].sum += pass.axesHard[axis].sum;
200
+ axesHard[axis].n += pass.axesHard[axis].n;
185
201
  if (pass.axes[axis].n > 0) means[axis].push(pass.axes[axis].sum / pass.axes[axis].n);
186
202
  }
187
203
  for (const [band, score] of Object.entries(pass.byComplexity) as [Complexity, AxisScore][]) {
@@ -195,7 +211,7 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
195
211
  const m = means[axis];
196
212
  if (m.length > 1) spread[axis] = Math.max(...m) - Math.min(...m);
197
213
  }
198
- return { slug, axes, errors, repeats: passes, spread, byComplexity };
214
+ return { slug, axes, errors, repeats: passes, spread, byComplexity, axesHard };
199
215
  }
200
216
 
201
217
  export async function runEval(args: RunEvalArgs): Promise<EvalResult[]> {
@@ -18,7 +18,7 @@ import { runEval, type Completer } from "../eval/run.ts";
18
18
  import type { ToolSpec } from "../eval/agentic.ts";
19
19
  import type { ToolCall } from "../upstream/types.ts";
20
20
  import type { QualityAxis } from "../config/types.ts";
21
- import { fitCalibration, pickAnchors, toLocalFeedScores, MIN_ANCHORS } from "../eval/calibrate.ts";
21
+ import { fitCalibration, hardRaw, pickAnchors, toLocalFeedScores, MIN_ANCHORS, PUBLISH_MIN_R } from "../eval/calibrate.ts";
22
22
  import { makeJudge } from "../eval/judge.ts";
23
23
  import { loadLocalScores, saveLocalScores } from "../catalog/benchmark-feeds.ts";
24
24
  import { advise } from "./advise.ts";
@@ -812,12 +812,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
812
812
  });
813
813
  const target = results[0]!;
814
814
  const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
815
- const cal = fitCalibration(results.slice(1), published);
815
+ const cal = fitCalibration(results.slice(1), published, hardRaw);
816
816
  const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
817
- const fresh = toLocalFeedScores([target], cal, authorOf);
818
- const shared = { slug, anchors, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, tookMs: Date.now() - started };
817
+ const fresh = toLocalFeedScores([target], cal, authorOf, hardRaw);
818
+ // The fit is reported, not just used: `r` and `n` say whether the numbers deserve
819
+ // belief, and an axis dropped for a weak fit should say so rather than vanish.
820
+ const fitDetail = Object.fromEntries(Object.entries(cal).map(([axis, f]) => [axis, { r: Number(f.r.toFixed(3)), n: f.n, published: f.r >= PUBLISH_MIN_R }]));
821
+ const shared = { slug, anchors, raw: target.axes, rawHard: target.axesHard, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, fit: fitDetail, publishMinR: PUBLISH_MIN_R, tookMs: Date.now() - started };
819
822
  if (fresh.length === 0) {
820
- benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: null, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" } });
823
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: null, applied: false, reason: `no axis produced a fit at r >= ${PUBLISH_MIN_R} on the hard band; try more or better-spread anchors` } });
821
824
  return;
822
825
  }
823
826
  // Merge, never replace: other models' measurements are not this run's to discard.
package/test/eval.test.ts CHANGED
@@ -3,9 +3,10 @@ import { describe, expect, test } from "bun:test";
3
3
  import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
4
  import { applyFeedScores, loadLocalScores, saveLocalScores, type FeedScore } from "../src/catalog/benchmark-feeds.ts";
5
5
  import { answerScore, extractJson, isRefusalOrEmpty, jsonField, tokenCoverage } from "../src/eval/grade.ts";
6
- import { applyFit, fitAxis, fitCalibration, pickAnchors, toLocalFeedScores, MIN_ANCHORS } from "../src/eval/calibrate.ts";
6
+ import { applyFit, fitAxis, fitCalibration, hardRaw, pickAnchors, toLocalFeedScores, MIN_ANCHORS, MIN_R, PUBLISH_MIN_R } from "../src/eval/calibrate.ts";
7
7
  import { runEval, type EvalResult } from "../src/eval/run.ts";
8
8
  import { EVAL_TASKS } from "../src/eval/tasks.ts";
9
+ import type { QualityAxis } from "../src/config/types.ts";
9
10
  import { makeJudge, parseScore } from "../src/eval/judge.ts";
10
11
  import type { EvalTask, JudgedTask } from "../src/eval/tasks.ts";
11
12
  import { openDb } from "../src/util/sqlite.ts";
@@ -130,9 +131,9 @@ describe("calibration", () => {
130
131
  test("fitCalibration + toLocalFeedScores place a target on the AA scale", () => {
131
132
  expect(MIN_ANCHORS).toBe(3);
132
133
  const anchors: EvalResult[] = [
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: {} },
134
+ { 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: {}, axesHard: { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } } },
135
+ { 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: {}, axesHard: { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } } },
136
+ { 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: {}, axesHard: { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } } },
136
137
  ];
137
138
  const aaOf: Record<string, number> = { "a/one": 40, "a/two": 60, "a/three": 80 };
138
139
  const cal = fitCalibration(anchors, (slug, axis) => (axis === "coding" ? aaOf[slug] : undefined));
@@ -140,7 +141,7 @@ describe("calibration", () => {
140
141
  expect(cal.intelligence).toBeUndefined(); // no anchor data on that axis
141
142
 
142
143
  const targets: EvalResult[] = [
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: {} },
144
+ { 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: {}, axesHard: { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } } },
144
145
  ];
145
146
  const local = toLocalFeedScores(targets, cal, (s) => s.slice(0, s.indexOf("/")));
146
147
  expect(local).toHaveLength(1);
@@ -148,6 +149,62 @@ describe("calibration", () => {
148
149
  expect(local[0]!.coding).toBeCloseTo(60, 5); // calibrated from raw 0.5
149
150
  expect(local[0]!.intelligence).toBeUndefined(); // axis had no fit, so not emitted
150
151
  });
152
+
153
+ test("calibrating on the hard band beats calibrating on everything", () => {
154
+ // Three anchors published 20/50/80 apart. On the FULL suite they all score ~0.97
155
+ // because easy and moderate pin everyone at the ceiling; on the hard band alone they
156
+ // separate. Same models, same publishing, different x — and only one of them can fit.
157
+ const mk = (slug: string, full: number, hard: number): EvalResult => ({
158
+ slug,
159
+ axes: { coding: { sum: full, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } },
160
+ axesHard: { coding: { sum: hard, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } },
161
+ errors: 0,
162
+ repeats: 1,
163
+ spread: {},
164
+ byComplexity: {},
165
+ });
166
+ const anchors = [mk("a/low", 0.96, 0.2), mk("a/mid", 0.97, 0.5), mk("a/high", 0.98, 0.8)];
167
+ const aa: Record<string, number> = { "a/low": 20, "a/mid": 50, "a/high": 80 };
168
+ const published = (slug: string, axis: QualityAxis) => (axis === "coding" ? aa[slug] : undefined);
169
+ const target = mk("z/target", 0.97, 0.5);
170
+
171
+ const onHard = toLocalFeedScores([target], fitCalibration(anchors, published, hardRaw), () => "z", hardRaw);
172
+ // The hard band spans 0.2-0.8 against 20-80, so the fit is a real line: raw 0.5 ⇒ ~50.
173
+ expect(onHard[0]!.coding).toBeCloseTo(50, 0);
174
+
175
+ // On the full suite the anchors span 0.96-0.98: the same published spread compressed into
176
+ // a fiftieth of the range. That is the shallow slope that produced 22.8 for a model
177
+ // published at 39.5, so the fit must be refused rather than published.
178
+ const pooledFit = fitCalibration(anchors, published);
179
+ const pooled = toLocalFeedScores([target], pooledFit, () => "z");
180
+ expect(pooled).toEqual([]);
181
+ });
182
+
183
+ test("a weak fit is refused, not published", () => {
184
+ // Points with a real but noisy relationship: computable (r >= MIN_R) yet not worth
185
+ // acting on. `r` and `n` used to be computed and then thrown away.
186
+ const noisy = [
187
+ { raw: 0.1, aa: 20 },
188
+ { raw: 0.5, aa: 70 },
189
+ { raw: 0.6, aa: 30 },
190
+ { raw: 0.9, aa: 60 },
191
+ ];
192
+ const fit = fitAxis(noisy)!;
193
+ expect(fit.r).toBeGreaterThanOrEqual(MIN_R);
194
+ expect(fit.r).toBeLessThan(PUBLISH_MIN_R);
195
+ const target: EvalResult = {
196
+ slug: "z/t",
197
+ axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } },
198
+ axesHard: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } },
199
+ errors: 0,
200
+ repeats: 1,
201
+ spread: {},
202
+ byComplexity: {},
203
+ };
204
+ expect(toLocalFeedScores([target], { coding: fit }, () => "z", hardRaw)).toEqual([]);
205
+ // A caller that deliberately lowers the bar still can, so the gate is policy not dogma.
206
+ expect(toLocalFeedScores([target], { coding: fit }, () => "z", hardRaw, MIN_R)[0]!.coding).toBeGreaterThan(0);
207
+ });
151
208
  });
152
209
 
153
210
  describe("runEval", () => {