auto-model-router 0.30.2 → 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.2",
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.2",
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.2",
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
@@ -67,6 +75,12 @@ export interface RunEvalArgs {
67
75
  concurrency?: number;
68
76
  /** Called as each model finishes, for progress logging. */
69
77
  onProgress?: (result: EvalResult, done: number, total: number) => void;
78
+ /**
79
+ * Called as each PASS of each model finishes. A run is `models x repeats` passes and takes
80
+ * the better part of an hour at ten repeats, so per-model progress is too coarse to show
81
+ * anyone: without this the only observable states are "running" and "finished".
82
+ */
83
+ onPass?: (slug: string, pass: number, of: number) => void;
70
84
  /**
71
85
  * A tool-capable completer. Absent ⇒ the agentic SCENARIOS are skipped and the axis falls
72
86
  * back to the text tasks, which only ever measured whether a model can format JSON.
@@ -138,6 +152,7 @@ async function scorePass(slug: string, args: RunEvalArgs): Promise<EvalResult> {
138
152
  }
139
153
  }
140
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 } };
141
156
  for (const o of [...objective, ...judgedOutcomes, ...scenarioOutcomes]) {
142
157
  if (!o.ok) {
143
158
  // An unobserved task is NOT a zero: a provider's throttle or outage would otherwise
@@ -148,11 +163,15 @@ async function scorePass(slug: string, args: RunEvalArgs): Promise<EvalResult> {
148
163
  }
149
164
  axes[o.axis].sum += o.grade;
150
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
+ }
151
170
  const band = (byComplexity[o.complexity] ??= { sum: 0, n: 0 });
152
171
  band.sum += o.grade;
153
172
  band.n += 1;
154
173
  }
155
- return { slug, axes, errors, repeats: 1, spread: {}, byComplexity };
174
+ return { slug, axes, errors, repeats: 1, spread: {}, byComplexity, axesHard };
156
175
  }
157
176
 
158
177
  const AXES: readonly QualityAxis[] = ["coding", "intelligence", "agentic"];
@@ -168,13 +187,17 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
168
187
  const axes: Record<QualityAxis, AxisScore> = { coding: { sum: 0, n: 0 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } };
169
188
  const means: Record<QualityAxis, number[]> = { coding: [], intelligence: [], agentic: [] };
170
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 } };
171
191
  let errors = 0;
172
192
  for (let i = 0; i < passes; i++) {
173
193
  const pass = await scorePass(slug, args);
194
+ args.onPass?.(slug, i + 1, passes);
174
195
  errors += pass.errors;
175
196
  for (const axis of AXES) {
176
197
  axes[axis].sum += pass.axes[axis].sum;
177
198
  axes[axis].n += pass.axes[axis].n;
199
+ axesHard[axis].sum += pass.axesHard[axis].sum;
200
+ axesHard[axis].n += pass.axesHard[axis].n;
178
201
  if (pass.axes[axis].n > 0) means[axis].push(pass.axes[axis].sum / pass.axes[axis].n);
179
202
  }
180
203
  for (const [band, score] of Object.entries(pass.byComplexity) as [Complexity, AxisScore][]) {
@@ -188,7 +211,7 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
188
211
  const m = means[axis];
189
212
  if (m.length > 1) spread[axis] = Math.max(...m) - Math.min(...m);
190
213
  }
191
- return { slug, axes, errors, repeats: passes, spread, byComplexity };
214
+ return { slug, axes, errors, repeats: passes, spread, byComplexity, axesHard };
192
215
  }
193
216
 
194
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";
@@ -182,6 +182,19 @@ export function ollamaRunway(
182
182
  return { dailyBurnUsd, creditsLeftUsd, days: dailyBurnUsd > 0 ? creditsLeftUsd / dailyBurnUsd : null };
183
183
  }
184
184
 
185
+ /**
186
+ * Progress for a local benchmark run, as the dashboard needs it. The ETA is measured from
187
+ * this run's own completed passes rather than assumed — passes vary with the model's speed,
188
+ * and a guess from a fixed per-pass constant would be wrong by minutes on a slow upstream.
189
+ * Null until a pass has finished, because until then there is nothing to extrapolate from.
190
+ */
191
+ function benchmarkProgress(job: { state: string; startedAtMs: number; donePasses: number; totalPasses: number; current: string }): Record<string, unknown> {
192
+ const elapsedMs = Date.now() - job.startedAtMs;
193
+ const fraction = job.totalPasses > 0 ? job.donePasses / job.totalPasses : 0;
194
+ const etaMs = job.state === "running" && job.donePasses > 0 ? Math.round((elapsedMs / job.donePasses) * (job.totalPasses - job.donePasses)) : null;
195
+ return { donePasses: job.donePasses, totalPasses: job.totalPasses, fraction, current: job.current, elapsedMs, etaMs };
196
+ }
197
+
185
198
  function json(data: unknown, status = 200): Response {
186
199
  return new Response(JSON.stringify(data), {
187
200
  status,
@@ -252,7 +265,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
252
265
  * Background local-benchmark runs. In memory and not persisted: a run is an explicit,
253
266
  * attended admin action, and its lasting output is the `local_scores` row it writes.
254
267
  */
255
- const benchmarkJobs = new Map<string, { state: "running" | "done" | "error"; slug: string; startedAtMs: number; result?: Record<string, unknown>; error?: string }>();
268
+ const benchmarkJobs = new Map<string, { state: "running" | "done" | "error"; slug: string; startedAtMs: number; donePasses: number; totalPasses: number; current: string; result?: Record<string, unknown>; error?: string }>();
256
269
  const digester = createDigester({ cfg, catalog, ledger, upstream, log });
257
270
  const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale, digester };
258
271
 
@@ -779,46 +792,56 @@ export function startServer(cfg: RouterConfig): StartedServer {
779
792
  // lost. The caller polls instead.
780
793
  const jobId = `bench_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
781
794
  const started = Date.now();
782
- benchmarkJobs.set(jobId, { state: "running", slug, startedAtMs: started });
795
+ const repeats = Math.min(Math.max(1, typeof body?.repeats === "number" ? Math.floor(body.repeats) : 1), 20);
796
+ // A pass of one model is the unit of progress: models x repeats, known up front.
797
+ const totalPasses = repeats * (1 + anchors.length);
798
+ benchmarkJobs.set(jobId, { state: "running", slug, startedAtMs: started, donePasses: 0, totalPasses, current: slug });
783
799
  void (async () => {
784
800
  try {
785
801
  const results = await runEval({
786
802
  slugs: [slug, ...anchors],
787
803
  complete,
788
804
  toolComplete,
789
- repeats: Math.min(Math.max(1, typeof body?.repeats === "number" ? Math.floor(body.repeats) : 1), 20),
805
+ repeats,
790
806
  concurrency: Math.min(Math.max(1, asked), 8),
807
+ onPass: (passSlug, _pass, _of) => {
808
+ const job = benchmarkJobs.get(jobId);
809
+ if (job !== undefined) benchmarkJobs.set(jobId, { ...job, donePasses: job.donePasses + 1, current: passSlug });
810
+ },
791
811
  ...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
792
812
  });
793
813
  const target = results[0]!;
794
814
  const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
795
- const cal = fitCalibration(results.slice(1), published);
815
+ const cal = fitCalibration(results.slice(1), published, hardRaw);
796
816
  const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
797
- const fresh = toLocalFeedScores([target], cal, authorOf);
798
- 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 };
799
822
  if (fresh.length === 0) {
800
- benchmarkJobs.set(jobId, { state: "done", slug, startedAtMs: started, 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` } });
801
824
  return;
802
825
  }
803
826
  // Merge, never replace: other models' measurements are not this run's to discard.
804
827
  const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
805
828
  saveLocalScores(db, [...kept, ...fresh]);
806
829
  log.info("benchmarked a model with the local eval suite", { slug, anchors: anchors.length, repeats: target.repeats, errors: target.errors, useLocalScores: cfg.benchmarks.useLocalScores });
807
- benchmarkJobs.set(jobId, { state: "done", slug, startedAtMs: started, result: { ...shared, calibrated: fresh[0], applied: cfg.benchmarks.useLocalScores } });
830
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: fresh[0], applied: cfg.benchmarks.useLocalScores } });
808
831
  } catch (err) {
809
832
  const message = err instanceof Error ? err.message : String(err);
810
833
  log.warn("local benchmark run failed", { slug, error: message });
811
- benchmarkJobs.set(jobId, { state: "error", slug, startedAtMs: started, error: message });
834
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "error", error: message });
812
835
  }
813
836
  })();
814
- return json({ jobId, state: "running", slug, anchors });
837
+ return json({ jobId, state: "running", slug, anchors, totalPasses });
815
838
  }
816
839
  if (req.method === "GET" && url.pathname === "/v1/router/benchmark") {
817
840
  const jobId = url.searchParams.get("job");
818
- if (jobId === null) return json({ jobs: [...benchmarkJobs.entries()].map(([id, j]) => ({ id, slug: j.slug, state: j.state, startedAtMs: j.startedAtMs })) });
841
+ if (jobId === null) return json({ jobs: [...benchmarkJobs.entries()].map(([id, j]) => ({ id, slug: j.slug, state: j.state, startedAtMs: j.startedAtMs, ...benchmarkProgress(j) })) });
819
842
  const job = benchmarkJobs.get(jobId);
820
843
  if (job === undefined) return wireErrorResponse({ status: 404, code: "invalid_request_error", message: `no benchmark job ${jobId}` });
821
- return json({ id: jobId, slug: job.slug, state: job.state, startedAtMs: job.startedAtMs, ...(job.result === undefined ? {} : { result: job.result }), ...(job.error === undefined ? {} : { error: job.error }) });
844
+ return json({ id: jobId, slug: job.slug, state: job.state, startedAtMs: job.startedAtMs, ...benchmarkProgress(job), ...(job.result === undefined ? {} : { result: job.result }), ...(job.error === undefined ? {} : { error: job.error }) });
822
845
  }
823
846
  if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
824
847
  // A user verdict on the newest routed turn of an omp session.
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", () => {