auto-model-router 0.2.15 → 0.2.16

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.2.15",
10
+ "version": "0.2.16",
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.2.15",
17
+ "version": "0.2.16",
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.2.15",
3
+ "version": "0.2.16",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -167,6 +167,15 @@ export const DEFAULT_CONFIG: RouterConfig = {
167
167
  // unbounded, this scope reached 15 memory entries = 23.5k chars (~5.9k
168
168
  // tokens) injected into every turn, against a 24k cap it was about to hit.
169
169
  memoryLimit: 8,
170
+ // Docs are WHOLE DOCUMENTS, so they are the easiest way to blow the cap:
171
+ // this was left unbounded and a single ashlands note-doc measured 41,921
172
+ // chars — larger than maxBlockChars on its own, with three of them
173
+ // assembling a 104k-char block. Bounded rather than off, because a scope
174
+ // whose docs are genuinely short summaries benefits from them; set 0 where
175
+ // docs mirror whole repo files (agentdox ingest does this), since the
176
+ // content is retrievable on demand via docs_read and does not belong in
177
+ // every prompt's prefix.
178
+ docsLimit: 2,
170
179
  // Session messages are cheap today but grow once recordTurns is on, and
171
180
  // they feed straight back into the next assembly.
172
181
  sessionLimit: 6,
@@ -139,6 +139,7 @@ const context = z.strictObject({
139
139
  maxStalenessMs: z.number().int().nonnegative().optional(),
140
140
  maxBlockChars: z.number().int().positive().optional(),
141
141
  memoryLimit: z.number().int().positive().optional(),
142
+ docsLimit: z.number().int().nonnegative().optional(),
142
143
  sessionLimit: z.number().int().nonnegative().optional(),
143
144
  recordTurns: z.boolean().optional(),
144
145
  maxQueue: z.number().int().positive().optional(),
@@ -374,6 +374,9 @@ export interface ContextConfig {
374
374
  maxBlockChars: number;
375
375
  /** Max memory entries agentdox may select for the block. */
376
376
  memoryLimit: number;
377
+ /** Max docs agentdox may select for the block. Docs are whole documents, so
378
+ * this is the easiest way to blow `maxBlockChars`; 0 disables them. */
379
+ docsLimit: number;
377
380
  /** Max recent session messages agentdox may select for the block. */
378
381
  sessionLimit: number;
379
382
  /** Write settled turns back to agentdox sessions, tagged with the served model. */
@@ -18,6 +18,7 @@ export interface AgentDoxClientOptions {
18
18
  /** Bounds on what agentdox may select for one block. */
19
19
  export interface AssembleLimits {
20
20
  memoryLimit: number;
21
+ docsLimit: number;
21
22
  sessionLimit: number;
22
23
  }
23
24
 
@@ -88,6 +89,7 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
88
89
  scope,
89
90
  query,
90
91
  memoryLimit: limits.memoryLimit,
92
+ docsLimit: limits.docsLimit,
91
93
  sessionLimit: limits.sessionLimit,
92
94
  });
93
95
  if (res !== null && res.status === 200) {
@@ -36,6 +36,7 @@ export interface BridgeOptions {
36
36
  * useful entry instead of severing whatever straddles the cap.
37
37
  */
38
38
  memoryLimit: number;
39
+ docsLimit: number;
39
40
  sessionLimit: number;
40
41
  /** Record settled turns back into agentdox sessions. */
41
42
  recordTurns: boolean;
@@ -80,7 +81,7 @@ function appendFragment(prior: string, next: string): string {
80
81
  }
81
82
 
82
83
  export function createContextBridge(opts: BridgeOptions): ContextBridge {
83
- const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, sessionLimit, recordTurns, maxQueue } = opts;
84
+ const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, recordTurns, maxQueue } = opts;
84
85
 
85
86
  // Serialized write-back queue. Session appends for one conversation must
86
87
  // stay ordered, and agentdox is a local service — one worker is plenty.
@@ -118,7 +119,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
118
119
  return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
119
120
  }
120
121
 
121
- const raw = await client.assemble(input.scope, input.query, { memoryLimit, sessionLimit });
122
+ const raw = await client.assemble(input.scope, input.query, { memoryLimit, docsLimit, sessionLimit });
122
123
  if (raw === null) {
123
124
  // agentdox unreachable or empty. Keep serving the pinned block if we
124
125
  // have one: stale shared context beats none, and re-using it also
@@ -28,6 +28,7 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Context
28
28
  maxStalenessMs: c.maxStalenessMs,
29
29
  maxBlockChars: c.maxBlockChars,
30
30
  memoryLimit: c.memoryLimit,
31
+ docsLimit: c.docsLimit,
31
32
  sessionLimit: c.sessionLimit,
32
33
  recordTurns: c.recordTurns,
33
34
  maxQueue: c.maxQueue,
@@ -53,6 +53,7 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
53
53
  maxStalenessMs: 900_000,
54
54
  maxBlockChars: 24_000,
55
55
  memoryLimit: 8,
56
+ docsLimit: 2,
56
57
  sessionLimit: 6,
57
58
  recordTurns: true,
58
59
  maxQueue: 64,
@@ -95,16 +96,18 @@ describe("context bridge refresh policy", () => {
95
96
  });
96
97
 
97
98
  test("assembly is bounded, so the block cannot grow until bytes get severed", async () => {
98
- // The block reached 23.5k chars (~5.9k tokens, 15 memory entries) against a
99
- // 24k maxBlockChars cap, at which point renderBlock slices mid-entry. Byte
100
- // truncation is blind to relevance, so the server must be told to rank and
101
- // select instead. The REST endpoint ignores snake_case limit keys, which
102
- // silently reads as unboundedhence pinning that the limits are passed.
99
+ // The block reached 23.5k chars against a 24k maxBlockChars cap, at which
100
+ // point renderBlock slices mid-entry. Byte truncation is blind to relevance,
101
+ // so the server must be told to rank and select instead. `docsLimit`
102
+ // especially: docs are WHOLE documents and were left unbounded, and a single
103
+ // ashlands note-doc measured 41,921 chars over the whole cap by itself.
104
+ // The REST endpoint also ignores snake_case limit keys, which silently reads
105
+ // as unbounded, so pin that all three limits actually reach the client.
103
106
  const client = mkClient();
104
- const { bridge, db } = mkBridge(client, { memoryLimit: 5, sessionLimit: 2 });
107
+ const { bridge, db } = mkBridge(client, { memoryLimit: 5, docsLimit: 1, sessionLimit: 2 });
105
108
  try {
106
109
  await bridge.resolve(input());
107
- expect(client.lastLimits).toEqual({ memoryLimit: 5, sessionLimit: 2 });
110
+ expect(client.lastLimits).toEqual({ memoryLimit: 5, docsLimit: 1, sessionLimit: 2 });
108
111
  } finally {
109
112
  db.close();
110
113
  }
@@ -239,6 +242,7 @@ describe("context bridge refresh policy", () => {
239
242
  maxStalenessMs: 900_000,
240
243
  maxBlockChars: 24_000,
241
244
  memoryLimit: 8,
245
+ docsLimit: 2,
242
246
  sessionLimit: 6,
243
247
  recordTurns: true,
244
248
  maxQueue: 64,
@@ -69,7 +69,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
69
69
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
70
70
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
71
71
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
72
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
72
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
73
73
  compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
74
74
  budget: { onExceeded: "downgrade" },
75
75
  profiles: [],
package/test/turn.test.ts CHANGED
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
70
70
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
71
71
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
72
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
73
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
74
74
  compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
75
75
  budget: { onExceeded: "downgrade" },
76
76
  profiles: [],
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Offline decision replay — re-run REAL routing over recorded ledger rows.
3
+ *
4
+ * Every routing change is behavior-changing and cost-relevant, so the standing
5
+ * rule is to validate on the ledger before enabling. This is the tool for that:
6
+ * it feeds recorded `features` back through the real `scoreHeuristic` and
7
+ * `select`, under two config variants, and diffs the decisions.
8
+ *
9
+ * bun tools/replay.ts --limit 500
10
+ * bun tools/replay.ts --set tiers.hard.minQuality=70
11
+ * bun tools/replay.ts --set filters.latencyWeight=0 --verbose
12
+ * bun tools/replay.ts --where "task='coding'" --set classifier.ambiguityThreshold=0
13
+ *
14
+ * `--set` overrides variant B; `--a` overrides the baseline too (default:
15
+ * config as it currently stands on disk). Read-only: opens the ledger DB
16
+ * readonly and never writes.
17
+ *
18
+ * WHAT IT MODELS FAITHFULLY
19
+ * - The recorded `features` blob is the exact classifier input from that turn,
20
+ * so no re-tokenization or re-derivation is involved.
21
+ * - The real catalog snapshot is hydrated from `catalog_cache` via `peek()`,
22
+ * so pricing, context windows, capabilities and joined benchmark scores are
23
+ * the ones that were actually in play. No network.
24
+ * - The real `Ledger` supplies trust and latency, so the trust divisor and the
25
+ * throughput multiplier behave as they do live.
26
+ * - `explorationDraw` keys on `conversationKey:turn`, both recorded, so
27
+ * exploration reproduces deterministically and cancels out in a diff.
28
+ *
29
+ * WHAT IT DOES NOT MODEL — read this before trusting a conclusion
30
+ * - `messages` are not recorded, so compaction cannot be re-planned. Replay
31
+ * forces `compaction.enabled=false` and feeds the POST-compaction prompt
32
+ * size (`usage.promptTokens`), i.e. the prompt selection actually saw.
33
+ * - Conversation state is not recoverable historically (only the current row
34
+ * survives), so replay uses a neutral state: no sticky tier, no warm cache,
35
+ * no accumulated spend. Hysteresis, cache-warmth tie-breaks and the
36
+ * per-conversation budget guard are therefore NOT exercised.
37
+ * - `requestedReasoning` is the one `Features` field the ledger omits; it
38
+ * replays as undefined.
39
+ *
40
+ * Because of those gaps, the report leads with a FIDELITY figure: how often the
41
+ * baseline variant reproduces the model that actually served. Low fidelity means
42
+ * the unmodelled parts dominate and any delta below is weak evidence.
43
+ */
44
+
45
+ import { Database } from "bun:sqlite";
46
+
47
+ import { createCatalog } from "../src/catalog/openrouter-catalog.ts";
48
+ import type { CatalogModel } from "../src/catalog/types.ts";
49
+ import { loadConfig } from "../src/config/load.ts";
50
+ import type { RouterConfig } from "../src/config/types.ts";
51
+ import { computeCost } from "../src/cost/forecast.ts";
52
+ import { createLedger } from "../src/cost/ledger.ts";
53
+ import type { UsageCounts } from "../src/cost/types.ts";
54
+ import { scoreHeuristic } from "../src/router/classify.ts";
55
+ import { select } from "../src/router/select.ts";
56
+ import type { ConversationState, Decision, Features, Tier } from "../src/router/types.ts";
57
+ import type { UpstreamClient } from "../src/upstream/types.ts";
58
+ import type { NormMessage, NormRequest, NormTool } from "../src/wire/types.ts";
59
+
60
+ interface Args {
61
+ limit: number;
62
+ where: string;
63
+ setB: string[];
64
+ setA: string[];
65
+ verbose: boolean;
66
+ db: string;
67
+ }
68
+
69
+ function parseArgs(argv: string[]): Args {
70
+ const a: Args = { limit: 500, where: "", setB: [], setA: [], verbose: false, db: "" };
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const k = argv[i];
73
+ const v = argv[i + 1];
74
+ if (k === "--limit" && v !== undefined) (a.limit = Number.parseInt(v, 10)), i++;
75
+ else if (k === "--where" && v !== undefined) (a.where = v), i++;
76
+ else if (k === "--set" && v !== undefined) (a.setB.push(v), i++);
77
+ else if (k === "--a" && v !== undefined) (a.setA.push(v), i++);
78
+ else if (k === "--db" && v !== undefined) (a.db = v), i++;
79
+ else if (k === "--verbose") a.verbose = true;
80
+ }
81
+ return a;
82
+ }
83
+
84
+ /** Coerce a CLI string to the JSON-ish type the config field expects. */
85
+ function coerce(raw: string): unknown {
86
+ if (raw === "true") return true;
87
+ if (raw === "false") return false;
88
+ if (raw === "null") return null;
89
+ const n = Number(raw);
90
+ if (raw.trim() !== "" && !Number.isNaN(n)) return n;
91
+ return raw;
92
+ }
93
+
94
+ /** Applies `a.b.c=value` overrides onto a deep clone, so variants never alias. */
95
+ function withOverrides(cfg: RouterConfig, sets: readonly string[]): RouterConfig {
96
+ const next = structuredClone(cfg);
97
+ for (const entry of sets) {
98
+ const eq = entry.indexOf("=");
99
+ if (eq < 0) throw new Error(`--set expects path=value, got: ${entry}`);
100
+ const path = entry.slice(0, eq).split(".");
101
+ const value = coerce(entry.slice(eq + 1));
102
+ let node: Record<string, unknown> = next as unknown as Record<string, unknown>;
103
+ for (const seg of path.slice(0, -1)) {
104
+ const child = node[seg];
105
+ if (typeof child !== "object" || child === null) throw new Error(`--set path not found: ${entry}`);
106
+ node = child as Record<string, unknown>;
107
+ }
108
+ const leaf = path[path.length - 1];
109
+ if (leaf === undefined || !(leaf in node)) throw new Error(`--set path not found: ${entry}`);
110
+ node[leaf] = value;
111
+ }
112
+ return next;
113
+ }
114
+
115
+ interface Row {
116
+ id: string;
117
+ conversation_key: string;
118
+ turn: number;
119
+ requested_model: string;
120
+ harness_id: string;
121
+ served_slug: string | null;
122
+ tier: string;
123
+ features: string;
124
+ usage: string;
125
+ reported_usd: number | null;
126
+ predicted_usd: number;
127
+ }
128
+
129
+ /** Rebuilds the classifier input. The ledger stores 20 of 21 Features fields. */
130
+ function featuresOf(row: Row, promptTokens: number): Features {
131
+ const f = JSON.parse(row.features) as Partial<Features>;
132
+ return { ...(f as Features), promptTokens, requestedReasoning: undefined };
133
+ }
134
+
135
+ /**
136
+ * Minimal request carrying only what `select`/`buildCandidates` read: tool count
137
+ * and schema bytes, image presence, harness id (trust/latency scoping),
138
+ * conversation key and profile id.
139
+ */
140
+ function requestOf(row: Row, f: Features): NormRequest {
141
+ const perTool = f.toolCount > 0 ? Math.round(f.toolSchemaBytes / f.toolCount) : 0;
142
+ const tools: NormTool[] = Array.from({ length: f.toolCount }, (_v, i) => ({
143
+ name: `t${i}`,
144
+ description: "",
145
+ schemaBytes: perTool,
146
+ }));
147
+ const messages: NormMessage[] = [
148
+ { role: "user", text: "", images: f.hasImages ? 1 : 0, textBytes: f.promptTokens * 4, toolCalls: [] },
149
+ ];
150
+ return {
151
+ protocol: "openai-chat",
152
+ conversationKey: row.conversation_key,
153
+ harnessId: row.harness_id,
154
+ ompSessionId: "",
155
+ agentdoxScope: "",
156
+ requestedModel: row.requested_model,
157
+ messages,
158
+ tools,
159
+ forcedToolChoice: false,
160
+ stream: true,
161
+ hasImages: f.hasImages,
162
+ promptBytes: f.promptTokens * 4,
163
+ renderUpstreamBody: () => ({}),
164
+ };
165
+ }
166
+
167
+ /** Neutral state: no sticky tier, no warm cache, no prior spend. See header. */
168
+ function stateOf(row: Row): ConversationState {
169
+ return {
170
+ key: row.conversation_key,
171
+ sessionId: `omp-${row.conversation_key}`,
172
+ turn: row.turn,
173
+ currentSlug: null,
174
+ currentTier: null,
175
+ stickyUntilTurn: 0,
176
+ escalations: 0,
177
+ spentUsd: 0,
178
+ lastPromptTokens: 0,
179
+ cacheWarmSlug: null,
180
+ cacheWarmAtMs: 0,
181
+ contextVersion: null,
182
+ contextFetchedAtMs: 0,
183
+ updatedAtMs: 0,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Re-prices a decision against the tokens the turn ACTUALLY used, via the real
189
+ * `computeCost` so price tiers, the cache split and reasoning/request fees are
190
+ * handled exactly as they are live.
191
+ *
192
+ * Deliberately NOT the router's own forecast: `candidates.ts` hardcodes
193
+ * `cacheHitRate: 0`, so forecasts overstate absolute cost ~2.8x. Pricing both
194
+ * variants off recorded usage keeps the delta apples-to-apples and grounded.
195
+ */
196
+ function repriceUsd(model: CatalogModel | undefined, usage: UsageCounts): number {
197
+ if (model === undefined) return 0;
198
+ return computeCost(model, usage).total;
199
+ }
200
+
201
+ const DEAD_UPSTREAM: UpstreamClient = {
202
+ dispatch: () => Promise.reject(new Error("replay is offline")),
203
+ complete: () => Promise.reject(new Error("replay is offline")),
204
+ fetchModels: () => Promise.reject(new Error("replay is offline")),
205
+ fetchModelsForUser: () => Promise.reject(new Error("replay is offline")),
206
+ };
207
+
208
+ const args = parseArgs(process.argv.slice(2));
209
+ const baseCfg = await loadConfig();
210
+ // Compaction cannot be re-planned without messages; see the header.
211
+ const forced = ["compaction.enabled=false"];
212
+ const cfgA = withOverrides(baseCfg, [...forced, ...args.setA]);
213
+ const cfgB = withOverrides(baseCfg, [...forced, ...args.setB]);
214
+
215
+ const dbPath = args.db !== "" ? args.db : baseCfg.ledger.path;
216
+ const db = new Database(dbPath, { readonly: true });
217
+ const catalog = createCatalog(cfgA, DEAD_UPSTREAM, db);
218
+ const snapshot = catalog.peek();
219
+ if (snapshot === null) {
220
+ console.error(`no cached catalog in ${dbPath}; run the router once so it populates catalog_cache`);
221
+ process.exit(2);
222
+ }
223
+ const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
224
+ const ledger = createLedger(db, cfgA);
225
+
226
+ const predicate = args.where === "" ? "" : ` AND (${args.where})`;
227
+ const rows = db
228
+ .query(
229
+ `SELECT id, conversation_key, turn, requested_model, harness_id, served_slug, tier, features, usage, reported_usd, predicted_usd
230
+ FROM ledger
231
+ WHERE features IS NOT NULL AND wasted = 0${predicate}
232
+ ORDER BY created_at_ms DESC LIMIT ?`,
233
+ )
234
+ .all(args.limit) as Row[];
235
+
236
+ if (rows.length === 0) {
237
+ console.error("no rows matched; widen --where or --limit");
238
+ process.exit(2);
239
+ }
240
+
241
+ /** Profile resolution mirrors router/index.ts, which does not export it. */
242
+ function profileOf(cfg: RouterConfig, requested: string) {
243
+ const exact = cfg.profiles.find((p) => p.id === requested);
244
+ if (exact !== undefined) return exact;
245
+ const first = cfg.profiles[0];
246
+ if (first === undefined) throw new Error("no router profiles configured");
247
+ return first;
248
+ }
249
+
250
+ interface Outcome {
251
+ tier: Tier;
252
+ slug: string;
253
+ usd: number;
254
+ }
255
+
256
+ function run(cfg: RouterConfig, row: Row, usage: UsageCounts): Outcome {
257
+ const f = featuresOf(row, usage.promptTokens);
258
+ const req = requestOf(row, f);
259
+ const decision: Decision = select({
260
+ req,
261
+ features: f,
262
+ classification: scoreHeuristic(f, cfg),
263
+ profile: profileOf(cfg, row.requested_model),
264
+ state: stateOf(row),
265
+ snapshot,
266
+ ledger,
267
+ cfg,
268
+ nowMs: Date.now(),
269
+ });
270
+ return { tier: decision.tier, slug: decision.slug, usd: repriceUsd(bySlug.get(decision.slug), usage) };
271
+ }
272
+
273
+ const tallyA = new Map<string, number>();
274
+ const tallyB = new Map<string, number>();
275
+ const tallyRec = new Map<string, number>();
276
+ const tierA = new Map<string, number>();
277
+ const tierB = new Map<string, number>();
278
+ const tierRec = new Map<string, number>();
279
+ let usdA = 0;
280
+ let usdB = 0;
281
+ let usdRec = 0;
282
+ let fidelitySlug = 0;
283
+ let fidelityTier = 0;
284
+ let comparable = 0;
285
+ const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
286
+ const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
287
+
288
+ for (const row of rows) {
289
+ const u = JSON.parse(row.usage) as UsageCounts;
290
+ if (!(u.promptTokens > 0)) continue;
291
+ const a = run(cfgA, row, u);
292
+ const b = run(cfgB, row, u);
293
+ bump(tallyA, a.slug);
294
+ bump(tallyB, b.slug);
295
+ bump(tierA, a.tier);
296
+ bump(tierB, b.tier);
297
+ // The recorded outcome: what the router ACTUALLY did, under whatever code and
298
+ // config were live then. This is the yardstick for fidelity, and it is also
299
+ // how a shipped classifier change shows up — replay runs current code.
300
+ if (row.served_slug !== null) bump(tallyRec, row.served_slug);
301
+ bump(tierRec, row.tier);
302
+ usdA += a.usd;
303
+ usdB += b.usd;
304
+ usdRec += row.reported_usd ?? row.predicted_usd;
305
+ comparable++;
306
+ if (row.served_slug !== null && row.served_slug === a.slug) fidelitySlug++;
307
+ if (row.tier === a.tier) fidelityTier++;
308
+ if (a.slug !== b.slug || a.tier !== b.tier) {
309
+ flips.push({ id: row.id.slice(0, 8), tier: `${a.tier}->${b.tier}`, from: a.slug, to: b.slug, delta: b.usd - a.usd });
310
+ }
311
+ }
312
+
313
+ const pct = (n: number, d: number) => (d === 0 ? "0.0" : ((100 * n) / d).toFixed(1));
314
+ console.log(`\nreplayed ${comparable} dispatches from ${dbPath}`);
315
+ console.log(`variant A overrides: ${args.setA.length ? args.setA.join(" ") : "(config as-is)"}`);
316
+ console.log(`variant B overrides: ${args.setB.length ? args.setB.join(" ") : "(none — A and B identical)"}`);
317
+ console.log(`\nFIDELITY vs what actually ran:`);
318
+ console.log(` same model ${fidelitySlug}/${comparable} (${pct(fidelitySlug, comparable)}%) same tier ${fidelityTier}/${comparable} (${pct(fidelityTier, comparable)}%)`);
319
+ console.log(" Divergence is expected where code has changed since those rows were served");
320
+ console.log(" (replay runs CURRENT code); the rest is the unmodelled neutral state.");
321
+ console.log(" Low fidelity => treat the A/B delta below as weak evidence.");
322
+
323
+ function table(label: string, rec: Map<string, number>, A: Map<string, number>, B: Map<string, number>) {
324
+ const keys = [...new Set([...rec.keys(), ...A.keys(), ...B.keys()])].sort((x, y) => (B.get(y) ?? 0) - (B.get(x) ?? 0));
325
+ console.log(`\n${label.padEnd(32)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
326
+ for (const k of keys) {
327
+ const r = rec.get(k) ?? 0;
328
+ const a = A.get(k) ?? 0;
329
+ const b = B.get(k) ?? 0;
330
+ const d = b - a;
331
+ console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(a).padStart(7)}${String(b).padStart(7)}${(d > 0 ? `+${d}` : String(d)).padStart(7)}`);
332
+ }
333
+ }
334
+ table("tier", tierRec, tierA, tierB);
335
+ table("model", tallyRec, tallyA, tallyB);
336
+
337
+ console.log(`\nspend, re-priced on RECORDED usage via the real computeCost:`);
338
+ console.log(` actual (billed) $${usdRec.toFixed(4)} per dispatch $${(usdRec / comparable).toFixed(5)}`);
339
+ console.log(` A $${usdA.toFixed(4)} per dispatch $${(usdA / comparable).toFixed(5)}`);
340
+ console.log(` B $${usdB.toFixed(4)} per dispatch $${(usdB / comparable).toFixed(5)}`);
341
+ const delta = usdB - usdA;
342
+ console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (usdA || 1)).toFixed(1)}%`})`);
343
+ console.log(`\ndecisions changed: ${flips.length}/${comparable} (${pct(flips.length, comparable)}%)`);
344
+ if (args.verbose) {
345
+ for (const f of flips.slice(0, 40)) {
346
+ console.log(` ${f.id} ${f.tier.padEnd(22)} ${f.from} -> ${f.to} ${f.delta >= 0 ? "+" : ""}$${f.delta.toFixed(5)}`);
347
+ }
348
+ if (flips.length > 40) console.log(` … ${flips.length - 40} more`);
349
+ }
350
+ db.close();