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.
- package/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/eval/calibrate.ts +47 -12
- package/src/eval/run.ts +25 -2
- package/src/server/http.ts +36 -13
- package/test/eval.test.ts +62 -5
|
@@ -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.
|
|
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.
|
|
17
|
+
"version": "0.31.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/eval/calibrate.ts
CHANGED
|
@@ -58,13 +58,26 @@ export interface AnchorPoint {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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 =
|
|
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
|
-
/**
|
|
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 =
|
|
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[]> {
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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", () => {
|