auto-model-router 0.1.3 → 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 +127 -46
- package/bun.lock +606 -0
- package/omp-extension/router-embed.ts +14 -6
- package/omp-extension/router-toast.ts +6 -1
- package/omp-extension/toast-logic.ts +7 -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 +27 -3
- package/src/cost/types.ts +30 -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/index.ts +0 -1
- 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 +10 -2
- package/src/util/sqlite.ts +79 -1
- package/src/wire/openai/request.ts +5 -0
- package/src/wire/types.ts +7 -0
- package/test/benchmark-feeds.test.ts +222 -0
- package/test/escalate.test.ts +1 -0
- package/test/eval.test.ts +184 -0
- package/test/exploration.test.ts +251 -0
- package/test/failover.test.ts +5 -0
- package/test/hold-exploration.test.ts +124 -0
- package/test/tier-plan.test.ts +55 -1
- package/test/toast-logic.test.ts +32 -0
- package/test/tokens.test.ts +8 -0
- package/test/trust-attribution.test.ts +110 -2
- package/test/turn.test.ts +46 -0
- package/test/wire-request.test.ts +11 -0
- package/tools/smoke.ts +2 -0
- package/tools/sync-marketplace-version.ts +60 -0
package/src/config/types.ts
CHANGED
|
@@ -56,6 +56,42 @@ export interface OpenRouterConfig {
|
|
|
56
56
|
catalogRefreshMs: number;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* External benchmark feeds that BACKFILL quality scores OpenRouter does not
|
|
61
|
+
* publish. OpenRouter embeds Artificial Analysis scores for the models it has,
|
|
62
|
+
* but returns many (GLM, MiniMax, smaller vendors) unscored — which strands
|
|
63
|
+
* them below every tier floor above `trivial`. These feeds fill only the axes
|
|
64
|
+
* a model is missing; a score OpenRouter already published is never overwritten.
|
|
65
|
+
*
|
|
66
|
+
* Refreshed on their OWN slow cadence (`refreshMs`, ~daily), independent of the
|
|
67
|
+
* catalog's minute-scale availability refresh, and cached in `benchmark_cache`.
|
|
68
|
+
* Every fetch is best-effort: a feed failure leaves the catalog on published
|
|
69
|
+
* scores rather than failing a refresh.
|
|
70
|
+
*/
|
|
71
|
+
export interface BenchmarksConfig {
|
|
72
|
+
/** Master switch. Off ⇒ the catalog carries only OpenRouter-published scores. */
|
|
73
|
+
enabled: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Artificial Analysis API key (v2 data API). Resolved from config, then
|
|
76
|
+
* `ARTIFICIAL_ANALYSIS_API_KEY`. Empty ⇒ the AA feed is skipped; BenchLM
|
|
77
|
+
* (keyless) still runs.
|
|
78
|
+
*/
|
|
79
|
+
artificialAnalysisApiKey: string;
|
|
80
|
+
/** Pull the keyless BenchLM leaderboard, which covers models AA omits. */
|
|
81
|
+
benchlm: boolean;
|
|
82
|
+
/** Feed cache freshness, ms: re-fetch the feeds only when older than this. */
|
|
83
|
+
refreshMs: number;
|
|
84
|
+
/** Per-feed HTTP timeout, ms. */
|
|
85
|
+
timeoutMs: number;
|
|
86
|
+
/**
|
|
87
|
+
* Apply calibrated scores from our own eval harness (`src/eval`, the
|
|
88
|
+
* `local_scores` table) as a last-resort source. Off by default: local
|
|
89
|
+
* scores change routing, so they stay inert until deliberately enabled —
|
|
90
|
+
* e.g. after a data-collection window closes.
|
|
91
|
+
*/
|
|
92
|
+
useLocalScores: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
59
95
|
/** Quality/price envelope for one complexity tier. */
|
|
60
96
|
export interface TierConfig {
|
|
61
97
|
/**
|
|
@@ -163,6 +199,65 @@ export interface HysteresisConfig {
|
|
|
163
199
|
maxDowngradePerTurn: number;
|
|
164
200
|
}
|
|
165
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Epsilon-greedy exploration: deliberately route a small fraction of turns
|
|
204
|
+
* one tier BELOW the classified tier, to learn whether the cheaper model
|
|
205
|
+
* would have sufficed.
|
|
206
|
+
*
|
|
207
|
+
* Without it the ledger only ever witnesses UNDER-routing: a tier that was
|
|
208
|
+
* too low escalates and is recorded, while over-routing stays invisible
|
|
209
|
+
* because the cheaper model was never run. Weights fit on that one-sided
|
|
210
|
+
* evidence can only ever ratchet toward more expensive routing.
|
|
211
|
+
*/
|
|
212
|
+
export interface ExplorationConfig {
|
|
213
|
+
/** Off by default: this deliberately degrades a slice of real turns. */
|
|
214
|
+
enabled: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Per-tier sampling rate, 0-1. A tier that is absent, or set to 0, is
|
|
217
|
+
* never explored. `trivial` is the floor and cannot drop, so a rate for
|
|
218
|
+
* it has no effect.
|
|
219
|
+
*
|
|
220
|
+
* Rates are per-tier because the tiers are wildly unequal as evidence.
|
|
221
|
+
* In one observed window 635 explorable turns were `simple` and 1 was
|
|
222
|
+
* `hard`, while `hard` carried ~30% of all spend. A single uniform rate
|
|
223
|
+
* therefore spends nearly the whole exploration budget on the cheapest
|
|
224
|
+
* question in the system.
|
|
225
|
+
*/
|
|
226
|
+
rates: Partial<Record<Tier, number>>;
|
|
227
|
+
/**
|
|
228
|
+
* Which hysteresis-held turns exploration may touch.
|
|
229
|
+
*
|
|
230
|
+
* `never` the held population is untouchable.
|
|
231
|
+
* `cold-cache` explore a hold only after its prompt cache has expired.
|
|
232
|
+
* `always` explore holds regardless, forfeiting a live cache read.
|
|
233
|
+
*
|
|
234
|
+
* This matters more than it sounds. ~95% of hard-tier spend arrives by
|
|
235
|
+
* hold rather than by classification, so `never` confines exploration to
|
|
236
|
+
* the cheapest boundary in the system. But held turns are consecutive
|
|
237
|
+
* turns of an active loop and are therefore warm BY CONSTRUCTION, so
|
|
238
|
+
* `cold-cache` barely reaches them either: on one real window it moved
|
|
239
|
+
* explorable hard turns from 1 to 11. Reaching that population in any
|
|
240
|
+
* useful volume means `always`, and paying the forfeited cache read --
|
|
241
|
+
* a real cost, but a bounded and directly measurable one.
|
|
242
|
+
*/
|
|
243
|
+
stickyPolicy: "never" | "cold-cache" | "always";
|
|
244
|
+
/**
|
|
245
|
+
* Randomise the POST-ESCALATION hold length per conversation, to learn
|
|
246
|
+
* what it should be.
|
|
247
|
+
*
|
|
248
|
+
* `holdTurnsAfterEscalation` is a hand-picked constant that nothing has
|
|
249
|
+
* ever validated, and it governs most expensive spend: a turn escalates
|
|
250
|
+
* once, then the hold bills the next several turns at the escalated
|
|
251
|
+
* tier. Assignment is per conversation, so each conversation is one
|
|
252
|
+
* clean randomised arm rather than a confounded mixture.
|
|
253
|
+
*/
|
|
254
|
+
holdTurns: {
|
|
255
|
+
enabled: boolean;
|
|
256
|
+
/** Candidate hold lengths. One is drawn per conversation. */
|
|
257
|
+
values: number[];
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
166
261
|
export interface CacheConfig {
|
|
167
262
|
/**
|
|
168
263
|
* Inject Anthropic-style `cache_control` breakpoints. OpenRouter translates
|
|
@@ -223,12 +318,14 @@ export interface LedgerConfig {
|
|
|
223
318
|
export interface RouterConfig {
|
|
224
319
|
server: ServerConfig;
|
|
225
320
|
openrouter: OpenRouterConfig;
|
|
321
|
+
benchmarks: BenchmarksConfig;
|
|
226
322
|
tiers: Record<Tier, TierConfig>;
|
|
227
323
|
tasks: Record<TaskType, TaskConfig>;
|
|
228
324
|
filters: FilterConfig;
|
|
229
325
|
classifier: ClassifierConfig;
|
|
230
326
|
escalation: EscalationConfig;
|
|
231
327
|
hysteresis: HysteresisConfig;
|
|
328
|
+
exploration: ExplorationConfig;
|
|
232
329
|
cache: CacheConfig;
|
|
233
330
|
budget: BudgetConfig;
|
|
234
331
|
profiles: ProfileConfig[];
|
|
@@ -240,5 +337,14 @@ export interface RouterConfig {
|
|
|
240
337
|
* `trivial` permanently empty and the router is stuck on the cheapest model.
|
|
241
338
|
*/
|
|
242
339
|
adaptiveTierFloors: boolean;
|
|
340
|
+
/**
|
|
341
|
+
* Derive each tier's input-price ceiling from the price spread of the models
|
|
342
|
+
* actually available at every catalog refresh (quantile bands), instead of
|
|
343
|
+
* fixed `tiers.*.maxInputPerMtok` dollars. Lets the same config self-tune to
|
|
344
|
+
* whatever models a key admits — a hard cap becomes "drop this catalog's
|
|
345
|
+
* priciest outliers", not a magic dollar value. An explicit ceiling still
|
|
346
|
+
* tightens further. Off by default (fixed ceilings).
|
|
347
|
+
*/
|
|
348
|
+
adaptivePriceCeilings: boolean;
|
|
243
349
|
logLevel: "silent" | "error" | "warn" | "info" | "debug";
|
|
244
350
|
}
|
package/src/cost/ledger.ts
CHANGED
|
@@ -32,11 +32,19 @@ interface LedgerRow {
|
|
|
32
32
|
turn: number;
|
|
33
33
|
requested_model: string;
|
|
34
34
|
harness_id: string;
|
|
35
|
+
omp_session_id: string;
|
|
35
36
|
slug: string;
|
|
36
37
|
served_slug: string | null;
|
|
37
38
|
tier: string;
|
|
38
39
|
classification_source: string;
|
|
39
40
|
reasons: string;
|
|
41
|
+
features: string | null;
|
|
42
|
+
score: number | null;
|
|
43
|
+
confidence: number | null;
|
|
44
|
+
task: string | null;
|
|
45
|
+
classifier_reasons: string | null;
|
|
46
|
+
explored_from: string | null;
|
|
47
|
+
hold_arm: number | null;
|
|
40
48
|
predicted_usd: number;
|
|
41
49
|
reported_usd: number | null;
|
|
42
50
|
usage: string;
|
|
@@ -128,11 +136,19 @@ function toEntry(row: LedgerRow): LedgerEntry {
|
|
|
128
136
|
turn: row.turn,
|
|
129
137
|
requestedModel: row.requested_model,
|
|
130
138
|
harnessId: row.harness_id,
|
|
139
|
+
ompSessionId: row.omp_session_id,
|
|
131
140
|
slug: row.slug,
|
|
132
141
|
servedSlug: row.served_slug,
|
|
133
142
|
tier: row.tier,
|
|
134
143
|
classificationSource: row.classification_source,
|
|
135
144
|
reasons: JSON.parse(row.reasons) as string[],
|
|
145
|
+
features: row.features === null ? null : (JSON.parse(row.features) as object),
|
|
146
|
+
score: row.score,
|
|
147
|
+
confidence: row.confidence,
|
|
148
|
+
task: row.task,
|
|
149
|
+
classifierReasons: row.classifier_reasons === null ? null : (JSON.parse(row.classifier_reasons) as string[]),
|
|
150
|
+
exploredFrom: row.explored_from,
|
|
151
|
+
holdArm: row.hold_arm,
|
|
136
152
|
predictedUsd: row.predicted_usd,
|
|
137
153
|
reportedUsd: row.reported_usd,
|
|
138
154
|
usage: JSON.parse(row.usage) as UsageCounts,
|
|
@@ -151,11 +167,11 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
151
167
|
// Prepared once: record() runs on every turn.
|
|
152
168
|
const insertStmt = db.query(
|
|
153
169
|
`INSERT INTO ledger (
|
|
154
|
-
id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, slug, served_slug,
|
|
170
|
+
id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id, slug, served_slug,
|
|
155
171
|
tier, classification_source, reasons, predicted_usd, reported_usd, usage, cost_breakdown,
|
|
156
172
|
attempt, escalation_signal, latency_ms, ttft_ms, finish_reason, wasted, upstream_generation_id, error,
|
|
157
|
-
error_kind
|
|
158
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
173
|
+
error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm
|
|
174
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
159
175
|
);
|
|
160
176
|
const calibrationStmt = db.query(
|
|
161
177
|
`INSERT INTO token_calibration (tokenizer, est_bytes, actual_tokens, samples) VALUES (?, ?, ?, 1)
|
|
@@ -215,6 +231,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
215
231
|
entry.turn,
|
|
216
232
|
entry.requestedModel,
|
|
217
233
|
entry.harnessId,
|
|
234
|
+
entry.ompSessionId,
|
|
218
235
|
entry.slug,
|
|
219
236
|
entry.servedSlug,
|
|
220
237
|
entry.tier,
|
|
@@ -233,6 +250,13 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
233
250
|
entry.upstreamGenerationId,
|
|
234
251
|
entry.error,
|
|
235
252
|
errorKindOf(entry.error),
|
|
253
|
+
entry.features === null ? null : JSON.stringify(entry.features),
|
|
254
|
+
entry.score,
|
|
255
|
+
entry.confidence,
|
|
256
|
+
entry.task,
|
|
257
|
+
entry.classifierReasons === null ? null : JSON.stringify(entry.classifierReasons),
|
|
258
|
+
entry.exploredFrom,
|
|
259
|
+
entry.holdArm,
|
|
236
260
|
);
|
|
237
261
|
// Always consume the pending estimate, even when the turn failed, so a
|
|
238
262
|
// dead turn's bytes can never pair with a later turn's tokens. Only
|
package/src/cost/types.ts
CHANGED
|
@@ -74,6 +74,12 @@ export interface LedgerEntry {
|
|
|
74
74
|
requestedModel: string;
|
|
75
75
|
/** Harness id from the request header; empty for the default harness. */
|
|
76
76
|
harnessId: string;
|
|
77
|
+
/**
|
|
78
|
+
* omp UI session id from the `X-Omp-Session` request header; empty when the
|
|
79
|
+
* client sends no header. Scopes toasts to a single interactive session so
|
|
80
|
+
* concurrent sessions sharing one ledger don't surface each other's choices.
|
|
81
|
+
*/
|
|
82
|
+
ompSessionId: string;
|
|
77
83
|
/** Concrete slug we dispatched to. */
|
|
78
84
|
slug: string;
|
|
79
85
|
/** Slug that actually served it, per the response `model` field. */
|
|
@@ -82,6 +88,30 @@ export interface LedgerEntry {
|
|
|
82
88
|
classificationSource: string;
|
|
83
89
|
/** Human-readable decision trail. */
|
|
84
90
|
reasons: string[];
|
|
91
|
+
/**
|
|
92
|
+
* Classifier inputs, persisted verbatim as JSON so any score is
|
|
93
|
+
* recomputable offline. Opaque here on purpose: the ledger sits below the
|
|
94
|
+
* router in the layering and must not import its types. NULL before v6.
|
|
95
|
+
*/
|
|
96
|
+
features: object | null;
|
|
97
|
+
/** Raw heuristic score, 0-1, before tier bucketing. NULL before v6. */
|
|
98
|
+
score: number | null;
|
|
99
|
+
/** Classifier confidence, 0-1. Drives adjudication. NULL before v6. */
|
|
100
|
+
confidence: number | null;
|
|
101
|
+
/** Task kind (coding, vision, ...), orthogonal to tier. NULL before v6. */
|
|
102
|
+
task: string | null;
|
|
103
|
+
/** Per-feature score breakdown, which the decision trail drops. NULL before v6. */
|
|
104
|
+
classifierReasons: string[] | null;
|
|
105
|
+
/**
|
|
106
|
+
* Tier the classifier chose on a turn that exploration deliberately routed
|
|
107
|
+
* one step cheaper. NULL when the turn was routed normally.
|
|
108
|
+
*/
|
|
109
|
+
exploredFrom: string | null;
|
|
110
|
+
/**
|
|
111
|
+
* Hold-length arm this conversation was assigned by hold exploration,
|
|
112
|
+
* or NULL when it was not part of that experiment.
|
|
113
|
+
*/
|
|
114
|
+
holdArm: number | null;
|
|
85
115
|
predictedUsd: number;
|
|
86
116
|
reportedUsd: number | null;
|
|
87
117
|
usage: UsageCounts;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calibration: map raw suite scores (mean grade, 0-1) onto the Artificial
|
|
3
|
+
* Analysis 0-100 index the tier floors are defined against.
|
|
4
|
+
*
|
|
5
|
+
* A home-grown "0.72" is meaningless next to AA's "72" unless the two scales are
|
|
6
|
+
* tied together. We do that empirically: run the SAME suite on models AA has
|
|
7
|
+
* already scored (the anchors), fit raw -> AA per axis by least squares, then
|
|
8
|
+
* apply that line to the unscored targets. Without enough anchors on an axis we
|
|
9
|
+
* emit nothing for it — honest degradation, never an imputed number.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { QualityAxis } from "../config/types.ts";
|
|
13
|
+
import type { FeedScore } from "../catalog/benchmark-feeds.ts";
|
|
14
|
+
import { normalizeModelKey } from "../catalog/benchmark-feeds.ts";
|
|
15
|
+
import type { AxisScore, EvalResult } from "./run.ts";
|
|
16
|
+
|
|
17
|
+
/** Minimum anchor models with a known AA score on an axis before we trust a fit. */
|
|
18
|
+
export const MIN_ANCHORS = 3;
|
|
19
|
+
|
|
20
|
+
/** Minimum Pearson correlation between raw suite scores and AA before a fit is trusted. */
|
|
21
|
+
export const MIN_R = 0.5;
|
|
22
|
+
|
|
23
|
+
export interface LineFit {
|
|
24
|
+
slope: number;
|
|
25
|
+
intercept: number;
|
|
26
|
+
/** Pearson correlation of the anchor fit, 0-1. A quality signal on the calibration itself. */
|
|
27
|
+
r: number;
|
|
28
|
+
/** Anchors used. */
|
|
29
|
+
n: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Calibration = Partial<Record<QualityAxis, LineFit>>;
|
|
33
|
+
|
|
34
|
+
export interface AnchorPoint {
|
|
35
|
+
/** Raw mean grade on the axis, 0-1. */
|
|
36
|
+
raw: number;
|
|
37
|
+
/** Known AA index on the axis, 0-100. */
|
|
38
|
+
aa: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* OLS fit, or null when the calibration cannot be trusted: too few points, no
|
|
43
|
+
* spread, a non-positive slope, or weak correlation. A suite that does not track
|
|
44
|
+
* AA positively and with real correlation would turn a target's score into noise
|
|
45
|
+
* dressed as signal, so we refuse it and emit nothing for that axis.
|
|
46
|
+
*/
|
|
47
|
+
export function fitAxis(points: readonly AnchorPoint[]): LineFit | null {
|
|
48
|
+
if (points.length < MIN_ANCHORS) return null;
|
|
49
|
+
const n = points.length;
|
|
50
|
+
let sx = 0;
|
|
51
|
+
let sy = 0;
|
|
52
|
+
for (const p of points) {
|
|
53
|
+
sx += p.raw;
|
|
54
|
+
sy += p.aa;
|
|
55
|
+
}
|
|
56
|
+
const mx = sx / n;
|
|
57
|
+
const my = sy / n;
|
|
58
|
+
let sxx = 0;
|
|
59
|
+
let syy = 0;
|
|
60
|
+
let sxy = 0;
|
|
61
|
+
for (const p of points) {
|
|
62
|
+
const dx = p.raw - mx;
|
|
63
|
+
const dy = p.aa - my;
|
|
64
|
+
sxx += dx * dx;
|
|
65
|
+
syy += dy * dy;
|
|
66
|
+
sxy += dx * dy;
|
|
67
|
+
}
|
|
68
|
+
// No spread on either axis ⇒ undefined slope or correlation.
|
|
69
|
+
if (sxx < 1e-9 || syy < 1e-9) return null;
|
|
70
|
+
const slope = sxy / sxx;
|
|
71
|
+
const r = sxy / Math.sqrt(sxx * syy);
|
|
72
|
+
// The suite must rank models the same way AA does, and meaningfully so.
|
|
73
|
+
if (slope <= 0 || r < MIN_R) return null;
|
|
74
|
+
return { slope, intercept: my - slope * mx, r, n };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function applyFit(fit: LineFit, raw: number): number {
|
|
78
|
+
const y = fit.slope * raw + fit.intercept;
|
|
79
|
+
return Math.min(100, Math.max(0, y));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const AXES: readonly QualityAxis[] = ["coding", "intelligence", "agentic"];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fit every axis from the anchors' raw suite scores paired with their known AA
|
|
86
|
+
* scores. `anchorAa` supplies the AA index per slug+axis (absent ⇒ that anchor
|
|
87
|
+
* is not used on that axis).
|
|
88
|
+
*/
|
|
89
|
+
export function fitCalibration(
|
|
90
|
+
anchors: readonly EvalResult[],
|
|
91
|
+
anchorAa: (slug: string, axis: QualityAxis) => number | undefined,
|
|
92
|
+
): Calibration {
|
|
93
|
+
const cal: Calibration = {};
|
|
94
|
+
for (const axis of AXES) {
|
|
95
|
+
const points: AnchorPoint[] = [];
|
|
96
|
+
for (const r of anchors) {
|
|
97
|
+
const raw = axisMean(r.axes[axis]);
|
|
98
|
+
const aa = anchorAa(r.slug, axis);
|
|
99
|
+
if (raw !== null && aa !== undefined) points.push({ raw, aa });
|
|
100
|
+
}
|
|
101
|
+
const fit = fitAxis(points);
|
|
102
|
+
if (fit !== null) cal[axis] = fit;
|
|
103
|
+
}
|
|
104
|
+
return cal;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function axisMean(a: AxisScore | undefined): number | null {
|
|
108
|
+
return a === undefined || a.n === 0 ? null : a.sum / a.n;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Calibrated local FeedScores for the targets, one axis at a time, skipping axes with no fit. */
|
|
112
|
+
export function toLocalFeedScores(
|
|
113
|
+
targets: readonly EvalResult[],
|
|
114
|
+
cal: Calibration,
|
|
115
|
+
authorOf: (slug: string) => string,
|
|
116
|
+
): FeedScore[] {
|
|
117
|
+
const out: FeedScore[] = [];
|
|
118
|
+
for (const r of targets) {
|
|
119
|
+
const entry: FeedScore = { key: normalizeModelKey(r.slug), creator: authorOf(r.slug), source: "local" };
|
|
120
|
+
let any = false;
|
|
121
|
+
for (const axis of AXES) {
|
|
122
|
+
const fit = cal[axis];
|
|
123
|
+
const raw = axisMean(r.axes[axis]);
|
|
124
|
+
if (fit === undefined || raw === null) continue;
|
|
125
|
+
entry[axis] = applyFit(fit, raw);
|
|
126
|
+
any = true;
|
|
127
|
+
}
|
|
128
|
+
if (any) out.push(entry);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Objective, deterministic grading proxies over an assistant's TEXT reply.
|
|
3
|
+
*
|
|
4
|
+
* These measure whether a model can follow an instruction, emit valid
|
|
5
|
+
* structured output, and hit a checkable answer — a FLOOR on quality, which is
|
|
6
|
+
* exactly what a tier gate needs ("is it good enough for this tier"). They are
|
|
7
|
+
* not an absolute capability index, and deliberately use no LLM judge: every
|
|
8
|
+
* grade is reproducible from the text alone.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const REFUSAL = /\b(?:i can(?:'|no)?t|i am unable|i'm unable|cannot help|as an ai)\b/i;
|
|
12
|
+
|
|
13
|
+
export function isRefusalOrEmpty(output: string): boolean {
|
|
14
|
+
const t = output.trim();
|
|
15
|
+
return t.length === 0 || REFUSAL.test(t);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Lowercased, whitespace-collapsed. Reused by every text comparator here. */
|
|
19
|
+
export function normalizeText(s: string): string {
|
|
20
|
+
return s.trim().toLowerCase().replace(/\s+/g, " ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lastLine(s: string): string {
|
|
24
|
+
const lines = s.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
25
|
+
return lines.length === 0 ? "" : (lines[lines.length - 1] ?? "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeRegex(text: string): string {
|
|
29
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 1 when the expected answer is the whole reply, the last non-empty line, or a
|
|
34
|
+
* standalone token in it; else 0. Standalone so "9.9" does not match "19.99".
|
|
35
|
+
*/
|
|
36
|
+
export function answerScore(output: string, expected: string): number {
|
|
37
|
+
if (isRefusalOrEmpty(output)) return 0;
|
|
38
|
+
const exp = normalizeText(expected);
|
|
39
|
+
if (normalizeText(output) === exp) return 1;
|
|
40
|
+
if (normalizeText(lastLine(output)) === exp) return 1;
|
|
41
|
+
const re = new RegExp(`(?:^|[^\\w.])${escapeRegex(exp)}(?:$|[^\\w.])`);
|
|
42
|
+
return re.test(normalizeText(output)) ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fraction of required tokens present, case-insensitive. Smooth partial credit. */
|
|
46
|
+
export function tokenCoverage(output: string, tokens: readonly string[]): number {
|
|
47
|
+
if (isRefusalOrEmpty(output) || tokens.length === 0) return 0;
|
|
48
|
+
const hay = normalizeText(output);
|
|
49
|
+
let hit = 0;
|
|
50
|
+
for (const t of tokens) if (hay.includes(t.toLowerCase())) hit += 1;
|
|
51
|
+
return hit / tokens.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The first balanced JSON value in the text, tolerating ``` fences and prose
|
|
56
|
+
* around it. Scans by bracket depth with string/escape awareness so a brace
|
|
57
|
+
* inside a string literal does not throw off the balance.
|
|
58
|
+
*/
|
|
59
|
+
export function extractJson(output: string): unknown {
|
|
60
|
+
const fenced = output.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
61
|
+
const body = fenced?.[1] ?? output;
|
|
62
|
+
const start = body.search(/[[{]/);
|
|
63
|
+
if (start === -1) return undefined;
|
|
64
|
+
const open = body[start];
|
|
65
|
+
const close = open === "{" ? "}" : "]";
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let inStr = false;
|
|
68
|
+
let esc = false;
|
|
69
|
+
for (let i = start; i < body.length; i++) {
|
|
70
|
+
const ch = body[i];
|
|
71
|
+
if (inStr) {
|
|
72
|
+
if (esc) esc = false;
|
|
73
|
+
else if (ch === "\\") esc = true;
|
|
74
|
+
else if (ch === '"') inStr = false;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === '"') inStr = true;
|
|
78
|
+
else if (ch === open) depth += 1;
|
|
79
|
+
else if (ch === close) {
|
|
80
|
+
depth -= 1;
|
|
81
|
+
if (depth === 0) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(body.slice(start, i + 1));
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A named field of a JSON object, or undefined. Guarded boundary cast, read is checked. */
|
|
94
|
+
export function jsonField(value: unknown, key: string): unknown {
|
|
95
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
96
|
+
// Narrowed to a non-null, non-array object; index as a string map at this boundary.
|
|
97
|
+
const rec = value as Record<string, unknown>;
|
|
98
|
+
return rec[key];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fraction of expected answers present as standalone tokens anywhere in the
|
|
103
|
+
* reply. Partial credit — the point of these is to SPREAD models by how many
|
|
104
|
+
* parts they get right, where an all-or-nothing grade would pin everyone at 1.
|
|
105
|
+
*/
|
|
106
|
+
export function multiAnswerCoverage(output: string, expected: readonly string[]): number {
|
|
107
|
+
if (isRefusalOrEmpty(output) || expected.length === 0) return 0;
|
|
108
|
+
const hay = normalizeText(output);
|
|
109
|
+
let hit = 0;
|
|
110
|
+
for (const e of expected) {
|
|
111
|
+
const re = new RegExp(`(?:^|[^\\w.])${escapeRegex(normalizeText(e))}(?:$|[^\\w.])`);
|
|
112
|
+
if (re.test(hay)) hit += 1;
|
|
113
|
+
}
|
|
114
|
+
return hit / expected.length;
|
|
115
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-judge grading for the open-ended tasks.
|
|
3
|
+
*
|
|
4
|
+
* Objective proxies pin frontier models at the ceiling; open-ended tasks graded
|
|
5
|
+
* by a strong model spread them. The judge is used ONLY to produce raw scores —
|
|
6
|
+
* whether those scores are trustworthy is decided downstream by the SAME
|
|
7
|
+
* calibration guard the objective path uses: if the judge's scores do not track
|
|
8
|
+
* AA on the anchors (Pearson r >= MIN_R, positive slope), no fit is emitted.
|
|
9
|
+
* So "validate the judge against AA" is not a separate step; it is enforced by
|
|
10
|
+
* refusing to ship an uncorrelated calibration.
|
|
11
|
+
*
|
|
12
|
+
* Absolute-rubric scoring (0-10), temperature 0, one judge model. A judge that
|
|
13
|
+
* errors or returns no parseable score yields null — treated as no observation,
|
|
14
|
+
* never a 0.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { QualityAxis } from "../config/types.ts";
|
|
18
|
+
import type { ChatMessage, Completer } from "./run.ts";
|
|
19
|
+
import type { JudgedTask } from "./tasks.ts";
|
|
20
|
+
|
|
21
|
+
export type Judge = (task: JudgedTask, answer: string) => Promise<number | null>;
|
|
22
|
+
|
|
23
|
+
const JUDGE_SYSTEM = [
|
|
24
|
+
"You are a strict, impartial grader of an AI assistant's answer to a developer task.",
|
|
25
|
+
"Judge ONLY the quality of the answer for the stated dimension: correctness, completeness,",
|
|
26
|
+
"and whether it followed the instruction. Ignore style and length.",
|
|
27
|
+
"Reply with a SINGLE integer from 0 to 10 and nothing else:",
|
|
28
|
+
"0 = wrong, empty, or ignores the instruction; 5 = partially correct or incomplete;",
|
|
29
|
+
"10 = fully correct, complete, and follows the instruction exactly.",
|
|
30
|
+
].join("\n");
|
|
31
|
+
|
|
32
|
+
const AXIS_LABEL: Record<QualityAxis, string> = {
|
|
33
|
+
coding: "coding / implementation quality",
|
|
34
|
+
intelligence: "reasoning quality",
|
|
35
|
+
agentic: "tool-use / planning quality",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function buildPrompt(task: JudgedTask, answer: string): string {
|
|
39
|
+
const ref = task.reference === undefined ? "" : `\n=== A STRONG REFERENCE ANSWER ===\n${task.reference}\n`;
|
|
40
|
+
return `Dimension: ${AXIS_LABEL[task.axis]}\n\n=== TASK ===\n${task.user}\n\n=== ANSWER TO GRADE ===\n${answer}\n${ref}\nScore (0-10):`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The 0-1 score in the judge's reply: the LAST standalone integer 0-10, since a
|
|
45
|
+
* model that reasons before answering tends to end on the score. Null when no
|
|
46
|
+
* such integer appears.
|
|
47
|
+
*/
|
|
48
|
+
export function parseScore(text: string): number | null {
|
|
49
|
+
const matches = [...text.matchAll(/\b(10|[0-9])\b/g)];
|
|
50
|
+
if (matches.length === 0) return null;
|
|
51
|
+
const last = matches[matches.length - 1];
|
|
52
|
+
const n = Number(last?.[1]);
|
|
53
|
+
if (!Number.isFinite(n)) return null;
|
|
54
|
+
return n / 10;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function makeJudge(complete: Completer, judgeSlug: string): Judge {
|
|
58
|
+
return async (task, answer) => {
|
|
59
|
+
const messages: ChatMessage[] = [
|
|
60
|
+
{ role: "system", content: JUDGE_SYSTEM },
|
|
61
|
+
{ role: "user", content: buildPrompt(task, answer) },
|
|
62
|
+
];
|
|
63
|
+
let text: string;
|
|
64
|
+
try {
|
|
65
|
+
text = await complete(judgeSlug, messages);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return parseScore(text);
|
|
70
|
+
};
|
|
71
|
+
}
|