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.
Files changed (54) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +127 -46
  3. package/bun.lock +606 -0
  4. package/omp-extension/router-embed.ts +14 -6
  5. package/omp-extension/router-toast.ts +6 -1
  6. package/omp-extension/toast-logic.ts +7 -0
  7. package/package.json +2 -1
  8. package/research/analyze-ledger.ts +173 -0
  9. package/research/apply-cost-tuning.ts +73 -0
  10. package/research/cost-analysis.ts +150 -0
  11. package/research/feed-check.ts +64 -0
  12. package/research/model-recommendations.ts +86 -0
  13. package/research/project-yield.ts +96 -0
  14. package/research/run-eval.ts +133 -0
  15. package/research/status.ts +55 -0
  16. package/research/tier-fill.ts +109 -0
  17. package/research/tier-map.ts +123 -0
  18. package/src/catalog/benchmark-feeds.ts +397 -0
  19. package/src/catalog/openrouter-catalog.ts +30 -0
  20. package/src/config/defaults.ts +30 -0
  21. package/src/config/load.ts +2 -0
  22. package/src/config/schema.ts +34 -0
  23. package/src/config/types.ts +106 -0
  24. package/src/cost/ledger.ts +27 -3
  25. package/src/cost/types.ts +30 -0
  26. package/src/eval/calibrate.ts +131 -0
  27. package/src/eval/grade.ts +115 -0
  28. package/src/eval/judge.ts +71 -0
  29. package/src/eval/run.ts +126 -0
  30. package/src/eval/tasks.ts +272 -0
  31. package/src/index.ts +0 -1
  32. package/src/router/candidates.ts +13 -6
  33. package/src/router/explore.ts +59 -0
  34. package/src/router/select.ts +54 -4
  35. package/src/router/tier-plan.ts +57 -1
  36. package/src/router/types.ts +13 -0
  37. package/src/server/turn.ts +10 -2
  38. package/src/util/sqlite.ts +79 -1
  39. package/src/wire/openai/request.ts +5 -0
  40. package/src/wire/types.ts +7 -0
  41. package/test/benchmark-feeds.test.ts +222 -0
  42. package/test/escalate.test.ts +1 -0
  43. package/test/eval.test.ts +184 -0
  44. package/test/exploration.test.ts +251 -0
  45. package/test/failover.test.ts +5 -0
  46. package/test/hold-exploration.test.ts +124 -0
  47. package/test/tier-plan.test.ts +55 -1
  48. package/test/toast-logic.test.ts +32 -0
  49. package/test/tokens.test.ts +8 -0
  50. package/test/trust-attribution.test.ts +110 -2
  51. package/test/turn.test.ts +46 -0
  52. package/test/wire-request.test.ts +11 -0
  53. package/tools/smoke.ts +2 -0
  54. package/tools/sync-marketplace-version.ts +60 -0
@@ -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
  }
@@ -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
+ }