auto-model-router 0.3.4 → 0.4.1

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 (48) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +29 -4
  3. package/omp-extension/report-logic.ts +93 -0
  4. package/omp-extension/router-configure.ts +128 -2
  5. package/omp-extension/router-embed.ts +9 -6
  6. package/package.json +1 -1
  7. package/src/catalog/ollama-catalog.ts +30 -2
  8. package/src/cli/config-wizard.ts +11 -0
  9. package/src/cli/report.ts +7 -2
  10. package/src/config/defaults.ts +17 -0
  11. package/src/config/schema.ts +8 -0
  12. package/src/config/types.ts +65 -0
  13. package/src/cost/feedback.ts +81 -0
  14. package/src/cost/ledger.ts +110 -5
  15. package/src/cost/report.ts +118 -2
  16. package/src/cost/types.ts +32 -1
  17. package/src/router/candidates.ts +13 -4
  18. package/src/router/classify.ts +13 -0
  19. package/src/router/features.ts +59 -1
  20. package/src/router/index.ts +23 -5
  21. package/src/router/learned.ts +202 -0
  22. package/src/router/select.ts +64 -8
  23. package/src/router/types.ts +39 -1
  24. package/src/server/http.ts +82 -5
  25. package/src/server/overrides.ts +83 -0
  26. package/src/server/providers.ts +10 -2
  27. package/src/server/turn.ts +16 -1
  28. package/src/upstream/ollama-usage.ts +79 -2
  29. package/src/util/sqlite.ts +30 -0
  30. package/src/wire/openai/request.ts +4 -0
  31. package/src/wire/types.ts +2 -0
  32. package/test/classify.test.ts +13 -0
  33. package/test/config-wizard.test.ts +8 -6
  34. package/test/controls.test.ts +238 -0
  35. package/test/escalate.test.ts +1 -0
  36. package/test/failover.test.ts +6 -4
  37. package/test/features.test.ts +63 -0
  38. package/test/http-resilience.test.ts +1 -1
  39. package/test/learned.test.ts +61 -0
  40. package/test/ollama.test.ts +74 -2
  41. package/test/report-hub.test.ts +6 -2
  42. package/test/report-logic.test.ts +3 -0
  43. package/test/report.test.ts +57 -0
  44. package/test/select.test.ts +126 -1
  45. package/test/trust-attribution.test.ts +95 -0
  46. package/test/turn.test.ts +36 -4
  47. package/tools/replay.ts +267 -156
  48. package/tools/train-classifier.ts +111 -0
package/tools/replay.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * bun tools/replay.ts --limit 500
10
10
  * bun tools/replay.ts --set tiers.hard.minQuality=70
11
- * bun tools/replay.ts --set filters.latencyWeight=0 --verbose
11
+ * bun tools/replay.ts --set hysteresis.switchHorizonTurns=8 --verbose
12
12
  * bun tools/replay.ts --where "task='coding'" --set classifier.ambiguityThreshold=0
13
+ * bun tools/replay.ts --warmth recorded # the pre-2026-09-07 warmth model
13
14
  *
14
15
  * `--set` overrides variant B; `--a` overrides the baseline too (default:
15
16
  * config as it currently stands on disk). Read-only: opens the ledger DB
@@ -18,47 +19,52 @@
18
19
  * WHAT IT MODELS FAITHFULLY
19
20
  * - The recorded `features` blob is the exact classifier input from that turn,
20
21
  * 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.
22
+ * - The real catalog is hydrated from `catalog_cache` (OpenRouter) and
23
+ * `ollama_catalog_cache` (Ollama Cloud, when `ollama.enabled`) and merged
24
+ * exactly as `composite.ts` does, with each variant's own `ollama.costBias`
25
+ * stamped on its snapshot. No network.
24
26
  * - The real `Ledger` supplies trust and latency, so the trust divisor and the
25
27
  * throughput multiplier behave as they do live.
26
28
  * - `explorationDraw` keys on `conversationKey:turn`, both recorded, so
27
29
  * exploration reproduces deterministically and cancels out in a diff.
30
+ * - Escalations are replayed AS RECORDED: a served attempt > 0 routes with
31
+ * `escalateFrom` set to the tier of the probe-rejected attempt before it
32
+ * and `excludeSlugs` set to the slugs that failed, exactly as `turn.ts`
33
+ * calls `route()`. Hold re-arming uses the escalated hold length. What
34
+ * replay cannot do is decide whether a VARIANT's cheaper pick would have
35
+ * escalated — the probe needs the streamed output — so an escalation that
36
+ * happened stays happened in both variants.
28
37
  *
29
- * Conversation state is reconstructed from the PRECEDING recorded dispatch in
30
- * the same conversation prior slug, prior tier, cache warmth, cumulative
31
- * spend rather than simulated, so cache-warmth behaviour is exercised. Rows
32
- * are replayed chronologically for that reason.
38
+ * CACHE WARMTH (the part that makes switch policy measurable)
39
+ * - `--warmth variant` (default): each variant carries its OWN previous
40
+ * decision. The model it chose last turn is the warm one, and a dispatch is
41
+ * priced warm only when the variant stays on it within `cacheWarmTtlMs`,
42
+ * with the previous prompt as the cached prefix (the same rule
43
+ * `cache-estimate.ts` applies to Ollama). A variant that switches pays the
44
+ * cold read. This is what lets `switchMargin`, `switchHorizonTurns`,
45
+ * `confirmUpgradesBelowConfidence` and the hold lengths be priced.
46
+ * - `--warmth recorded`: warmth comes from the recorded outcome (what the
47
+ * previous dispatch actually served and cached). Keeps replay error from
48
+ * compounding down a conversation, but prices every variant's switch as if
49
+ * the cache followed it, so switch policies show as no-ops.
33
50
  *
34
51
  * WHAT IT DOES NOT MODEL — read this before trusting a conclusion
35
52
  * - `messages` are not recorded, so compaction cannot be re-planned. Replay
36
53
  * forces `compaction.enabled=false` and feeds the POST-compaction prompt
37
54
  * size (`usage.promptTokens`), i.e. the prompt selection actually saw.
38
- * - Hysteresis holds ARE modelled: the window is re-armed after each replayed
39
- * decision exactly as `turn.ts` does, and evolved PER VARIANT so a change
40
- * that stops arming an expensive tier also drops the holds that followed it.
41
- * What remains absent is escalation-lengthened holds, since replay does not
42
- * retry, and `hold_arm` exploration draws are reproduced from the
43
- * conversation key rather than read back from the row.
44
- * - `requestedReasoning` IS recorded and is now used. It was previously forced
45
- * to undefined here on the belief the ledger omitted it, which under-scored
46
- * ~42% of dispatches and reproduced 27 hard decisions against 120 served.
47
- * Treat replay numbers produced before that fix as biased toward cheap tiers.
55
+ * - The credit-aware Ollama bias is replayed at the CONFIGURED `costBias`,
56
+ * not the usage-dependent effective bias that was live at the time.
48
57
  * - Module constants are not config, so things like CAP_AUTONOMOUS_LOOP cannot
49
58
  * be A/B'd via `--set` — only `RouterConfig` paths can.
50
59
  *
51
- * Because of those gaps the report leads with a FIDELITY figure. Read it with
52
- * care: it conflates replay error with genuine code change, since replay always
53
- * runs CURRENT code against rows served by whatever code was live then. Measured
54
- * on rows served by matching code it is 90% model / 77% tier; across older
55
- * history it drops to ~55%, and that drop is the shipped classifier changes
56
- * showing up, not the tool being wrong. Isolate a population with `--where` when
57
- * measuring one change.
60
+ * FIDELITY LINE. "same model N/M" compares variant A against what actually
61
+ * ran. Divergence is expected where code has changed since those rows were
62
+ * served (replay runs CURRENT code); the rest is what replay cannot model.
58
63
  */
59
64
 
60
65
  import { Database } from "bun:sqlite";
61
66
 
67
+ import { loadOllamaCatalogCache, mergeSnapshots } from "../src/catalog/ollama-catalog.ts";
62
68
  import { createCatalog } from "../src/catalog/openrouter-catalog.ts";
63
69
  import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
64
70
  import { loadConfig } from "../src/config/load.ts";
@@ -66,10 +72,10 @@ import type { RouterConfig } from "../src/config/types.ts";
66
72
  import { computeCost } from "../src/cost/forecast.ts";
67
73
  import { createLedger } from "../src/cost/ledger.ts";
68
74
  import type { UsageCounts } from "../src/cost/types.ts";
69
- import { scoreHeuristic } from "../src/router/classify.ts";
75
+ import { classifyTask, scoreHeuristic } from "../src/router/classify.ts";
70
76
  import { resolveHoldTurns } from "../src/router/explore.ts";
71
77
  import { select } from "../src/router/select.ts";
72
- import type { ConversationState, Decision, Features, Tier } from "../src/router/types.ts";
78
+ import { TIER_ORDER, type Classification, type ConversationState, type Decision, type Features, type Tier } from "../src/router/types.ts";
73
79
  import type { UpstreamClient } from "../src/upstream/types.ts";
74
80
  import type { NormMessage, NormRequest, NormTool } from "../src/wire/types.ts";
75
81
 
@@ -80,10 +86,11 @@ interface Args {
80
86
  setA: string[];
81
87
  verbose: boolean;
82
88
  db: string;
89
+ warmth: "variant" | "recorded";
83
90
  }
84
91
 
85
92
  function parseArgs(argv: string[]): Args {
86
- const a: Args = { limit: 500, where: "", setB: [], setA: [], verbose: false, db: "" };
93
+ const a: Args = { limit: 500, where: "", setB: [], setA: [], verbose: false, db: "", warmth: "variant" };
87
94
  for (let i = 0; i < argv.length; i++) {
88
95
  const k = argv[i];
89
96
  const v = argv[i + 1];
@@ -92,6 +99,7 @@ function parseArgs(argv: string[]): Args {
92
99
  else if (k === "--set" && v !== undefined) (a.setB.push(v), i++);
93
100
  else if (k === "--a" && v !== undefined) (a.setA.push(v), i++);
94
101
  else if (k === "--db" && v !== undefined) (a.db = v), i++;
102
+ else if (k === "--warmth" && (v === "variant" || v === "recorded")) (a.warmth = v), i++;
95
103
  else if (k === "--verbose") a.verbose = true;
96
104
  }
97
105
  return a;
@@ -136,8 +144,12 @@ interface Row {
136
144
  id: string;
137
145
  conversation_key: string;
138
146
  turn: number;
147
+ attempt: number;
148
+ wasted: number;
149
+ escalation_signal: string | null;
139
150
  requested_model: string;
140
151
  harness_id: string;
152
+ slug: string;
141
153
  served_slug: string | null;
142
154
  tier: string;
143
155
  features: string;
@@ -185,6 +197,7 @@ function requestOf(row: Row, f: Features): NormRequest {
185
197
  harnessId: row.harness_id,
186
198
  ompSessionId: "",
187
199
  agentdoxScope: "",
200
+ isSubagent: false,
188
201
  requestedModel: row.requested_model,
189
202
  messages,
190
203
  tools,
@@ -196,23 +209,34 @@ function requestOf(row: Row, f: Features): NormRequest {
196
209
  };
197
210
  }
198
211
 
212
+ /** The recorded outcome of a conversation's previous dispatch. */
213
+ interface PriorTurn {
214
+ slug: string | null;
215
+ tier: string;
216
+ promptTokens: number;
217
+ cachedTokens: number;
218
+ spentUsd: number;
219
+ atMs: number;
220
+ }
221
+
199
222
  /**
200
- * Conversation state reconstructed from the PRECEDING recorded dispatch in the
201
- * same conversation, not simulated.
202
- *
203
- * A neutral state cannot validate anything that depends on cache warmth — every
204
- * candidate looks cold, so a warm-cache change shows zero effect. But the
205
- * ledger does carry what the previous dispatch actually did, so warmth is
206
- * recoverable: `cacheWarmSlug` is the slug it served, `lastPromptTokens` its
207
- * prompt size. Deriving state from the RECORDED outcome rather than the
208
- * replayed one also stops replay error compounding down a conversation.
209
- *
210
- * `stickyUntilTurn` and `currentTier` are the exception: they are SIMULATED per
211
- * variant, by re-arming the hold exactly as `turn.ts` does after each replayed
212
- * decision. Without that, replay never held a tier and every hysteresis change
213
- * priced as zero.
223
+ * A variant's own trail through a conversation: the model it chose last, the
224
+ * prompt it saw, the tier it holds and until when, and the upgrade it deferred.
225
+ * Evolved PER VARIANT, because every one of these follows from the variant's
226
+ * own decisions; reading them from the recorded outcome would charge a variant
227
+ * for holds and caches it never created and hide the switches it made.
214
228
  */
215
- function stateOf(row: Row, prior: PriorTurn | undefined, hold: HoldState | undefined): ConversationState {
229
+ interface VariantTrail {
230
+ slug: string | null;
231
+ promptTokens: number;
232
+ atMs: number;
233
+ tier: Tier | null;
234
+ stickyUntilTurn: number;
235
+ upgradeDeferredTier: Tier | null;
236
+ }
237
+
238
+ function stateOf(row: Row, prior: PriorTurn | undefined, trail: VariantTrail | undefined, warmth: Args["warmth"]): ConversationState {
239
+ const warmSlug = warmth === "variant" ? (trail?.slug ?? null) : prior?.cachedTokens !== undefined && prior.cachedTokens > 0 ? prior.slug : null;
216
240
  return {
217
241
  key: row.conversation_key,
218
242
  sessionId: `omp-${row.conversation_key}`,
@@ -220,64 +244,49 @@ function stateOf(row: Row, prior: PriorTurn | undefined, hold: HoldState | undef
220
244
  // state `select` sees carries the PREVIOUS turn number. Passing row.turn
221
245
  // would expire every hold a turn early.
222
246
  turn: row.turn - 1,
223
- currentSlug: prior?.slug ?? null,
224
- // Tier and hold window come from THIS VARIANT's own history (see
225
- // HoldState); everything else comes from the recorded outcome.
226
- currentTier: hold?.tier ?? ((prior?.tier as Tier | undefined) ?? null),
227
- stickyUntilTurn: hold?.stickyUntilTurn ?? 0,
247
+ currentSlug: warmth === "variant" ? (trail?.slug ?? null) : (prior?.slug ?? null),
248
+ currentTier: trail?.tier ?? ((prior?.tier as Tier | undefined) ?? null),
249
+ stickyUntilTurn: trail?.stickyUntilTurn ?? 0,
228
250
  escalations: 0,
229
251
  spentUsd: prior?.spentUsd ?? 0,
230
- lastPromptTokens: prior?.promptTokens ?? 0,
231
- cacheWarmSlug: prior?.cachedTokens !== undefined && prior.cachedTokens > 0 ? prior.slug : null,
232
- cacheWarmAtMs: prior?.atMs ?? 0,
252
+ lastPromptTokens: warmth === "variant" ? (trail?.promptTokens ?? 0) : (prior?.promptTokens ?? 0),
253
+ cacheWarmSlug: warmSlug,
254
+ cacheWarmAtMs: warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0),
233
255
  contextVersion: null,
234
256
  contextFetchedAtMs: 0,
235
257
  // Compaction cannot be replanned offline (messages are not recorded), so
236
258
  // replay carries no plan: forced off in the config it replays under.
237
259
  compactionPlan: null,
238
- updatedAtMs: prior?.atMs ?? 0,
260
+ upgradeDeferredTier: trail?.upgradeDeferredTier ?? null,
261
+ updatedAtMs: warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0),
239
262
  };
240
263
  }
241
264
 
242
- interface PriorTurn {
243
- slug: string | null;
244
- tier: string;
245
- promptTokens: number;
246
- cachedTokens: number;
247
- spentUsd: number;
248
- atMs: number;
249
- }
250
-
251
- /**
252
- * Hysteresis state, evolved PER VARIANT.
253
- *
254
- * A hold is a consequence of the decisions a variant made, so A and B must each
255
- * carry their own: if both read the recorded holds, a change that stops arming
256
- * `hard` would still be charged for the holds that followed it in production,
257
- * and the change would price as smaller than it is.
258
- *
259
- * This is the one place replay departs from "inputs come from the recorded
260
- * outcome". The cost is that hold state compounds a variant's own replay error
261
- * down a conversation; the benefit is that hold policy becomes measurable at
262
- * all, which it was not.
263
- */
264
- interface HoldState {
265
- tier: Tier | null;
266
- stickyUntilTurn: number;
267
- }
268
-
269
265
  /**
270
266
  * Re-prices a decision against the tokens the turn ACTUALLY used, via the real
271
- * `computeCost` so price tiers, the cache split and reasoning/request fees are
272
- * handled exactly as they are live.
267
+ * `computeCost`, with the cache split decided by the warmth model: under
268
+ * `variant` warmth a dispatch is warm only when the variant stayed on its own
269
+ * previous model within the TTL, and the previous prompt is the cached prefix.
273
270
  *
274
271
  * Deliberately NOT the router's own forecast: `candidates.ts` hardcodes
275
272
  * `cacheHitRate: 0`, so forecasts overstate absolute cost ~2.8x. Pricing both
276
273
  * variants off recorded usage keeps the delta apples-to-apples and grounded.
277
274
  */
278
- function repriceUsd(model: CatalogModel | undefined, usage: UsageCounts): number {
279
- if (model === undefined) return 0;
280
- return computeCost(model, usage).total;
275
+ function repriceUsd(
276
+ model: CatalogModel | undefined,
277
+ usage: UsageCounts,
278
+ warm: boolean,
279
+ prevPromptTokens: number,
280
+ recordedSlug: string | null,
281
+ ): { usd: number; cold: boolean } {
282
+ if (model === undefined) return { usd: 0, cold: false };
283
+ if (!warm) {
284
+ return { usd: computeCost(model, { ...usage, cachedTokens: 0, cacheWriteTokens: 0 }).total, cold: true };
285
+ }
286
+ // Warm: the recorded cache count when the recorded dispatch was this very
287
+ // model (the provider's own figure), else the previous prompt as prefix.
288
+ const cached = recordedSlug === model.slug && usage.cachedTokens > 0 ? usage.cachedTokens : Math.min(prevPromptTokens, usage.promptTokens);
289
+ return { usd: computeCost(model, { ...usage, cachedTokens: cached, cacheWriteTokens: 0 }).total, cold: false };
281
290
  }
282
291
 
283
292
  const DEAD_UPSTREAM: UpstreamClient = {
@@ -297,24 +306,33 @@ const cfgB = withOverrides(baseCfg, [...forced, ...args.setB]);
297
306
  const dbPath = args.db !== "" ? args.db : baseCfg.ledger.path;
298
307
  const db = new Database(dbPath, { readonly: true });
299
308
  const catalog = createCatalog(cfgA, DEAD_UPSTREAM, db);
300
- const snapshot = catalog.peek();
301
- if (snapshot === null) {
309
+ const openrouterSnapshot = catalog.peek();
310
+ if (openrouterSnapshot === null) {
302
311
  console.error(`no cached catalog in ${dbPath}; run the router once so it populates catalog_cache`);
303
312
  process.exit(2);
304
313
  }
305
- const catalogSnapshot: CatalogSnapshot = snapshot;
306
- const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
314
+ const ollamaCache = loadOllamaCatalogCache(db);
315
+
316
+ /** The composite snapshot a variant routes over, with its own Ollama bias stamped on. */
317
+ function snapshotFor(cfg: RouterConfig): CatalogSnapshot {
318
+ if (!cfg.ollama.enabled || ollamaCache.models.length === 0) return openrouterSnapshot as CatalogSnapshot;
319
+ return { ...mergeSnapshots(openrouterSnapshot as CatalogSnapshot, ollamaCache.models), providerBias: { ollama: cfg.ollama.costBias } };
320
+ }
321
+ const snapshotA = snapshotFor(cfgA);
322
+ const snapshotB = snapshotFor(cfgB);
323
+ const bySlug = new Map([...snapshotA.models, ...snapshotB.models].map((m) => [m.slug, m]));
307
324
  const ledger = createLedger(db, cfgA);
308
325
 
309
326
  const predicate = args.where === "" ? "" : ` AND (${args.where})`;
310
327
  // Newest-first to honour --limit, then flipped to chronological so each row can
311
- // see the dispatch that preceded it in its conversation.
328
+ // see the dispatch that preceded it in its conversation. Wasted attempts ride
329
+ // along so a served attempt > 0 can see what it escalated from.
312
330
  const rows = (
313
331
  db
314
332
  .query(
315
- `SELECT id, conversation_key, turn, requested_model, harness_id, served_slug, tier, features, usage, reported_usd, predicted_usd, created_at_ms, error_kind
333
+ `SELECT id, conversation_key, turn, attempt, wasted, escalation_signal, requested_model, harness_id, slug, served_slug, tier, features, usage, reported_usd, predicted_usd, created_at_ms, error_kind
316
334
  FROM ledger
317
- WHERE features IS NOT NULL AND wasted = 0${predicate}
335
+ WHERE features IS NOT NULL${predicate}
318
336
  ORDER BY created_at_ms DESC LIMIT ?`,
319
337
  )
320
338
  .all(args.limit) as Row[]
@@ -334,110 +352,193 @@ function profileOf(cfg: RouterConfig, requested: string) {
334
352
  return first;
335
353
  }
336
354
 
355
+ /** The escalation context `turn.ts` would have passed to `route()` for a served attempt > 0. */
356
+ interface EscalationContext {
357
+ escalateFrom: Tier | undefined;
358
+ excludeSlugs: string[];
359
+ }
360
+
337
361
  interface Outcome {
338
362
  tier: Tier;
339
363
  slug: string;
340
364
  usd: number;
341
- /** Whether the hysteresis hold bound this dispatch, for reporting. */
365
+ cold: boolean;
366
+ switched: boolean;
342
367
  held: boolean;
343
- /** Hold state to carry into this variant's next dispatch. */
344
- hold: HoldState;
368
+ trail: VariantTrail;
345
369
  }
346
370
 
347
- function run(cfg: RouterConfig, row: Row, usage: UsageCounts, prior: PriorTurn | undefined, hold: HoldState | undefined): Outcome {
371
+ function run(
372
+ cfg: RouterConfig,
373
+ snapshot: CatalogSnapshot,
374
+ row: Row,
375
+ usage: UsageCounts,
376
+ prior: PriorTurn | undefined,
377
+ trail: VariantTrail | undefined,
378
+ esc: EscalationContext,
379
+ ): Outcome {
348
380
  const f = featuresOf(row, usage.promptTokens);
349
381
  const req = requestOf(row, f);
350
- const state = stateOf(row, prior, hold);
382
+ const state = stateOf(row, prior, trail, args.warmth);
383
+ let classification: Classification;
384
+ if (esc.escalateFrom !== undefined) {
385
+ // Mirrors router/index.ts: an escalation forces strictly upward.
386
+ const nextIdx = Math.min(TIER_ORDER.indexOf(esc.escalateFrom) + 1, TIER_ORDER.length - 1);
387
+ const forcedTier = TIER_ORDER[nextIdx] ?? esc.escalateFrom;
388
+ classification = {
389
+ tier: forcedTier,
390
+ task: classifyTask(f),
391
+ confidence: 1,
392
+ source: "escalation",
393
+ score: 1,
394
+ reasons: [`escalated from ${esc.escalateFrom} after attempt ${row.attempt - 1} was rejected`],
395
+ };
396
+ } else {
397
+ classification = scoreHeuristic(f, cfg);
398
+ }
351
399
  const decision: Decision = select({
352
400
  req,
353
401
  features: f,
354
- classification: scoreHeuristic(f, cfg),
402
+ classification,
355
403
  profile: profileOf(cfg, row.requested_model),
356
404
  state,
357
- snapshot: catalogSnapshot,
405
+ snapshot,
358
406
  ledger,
359
407
  cfg,
360
- nowMs: Date.now(),
408
+ // The row's own clock: cache warmth and hold windows are judged against
409
+ // when the turn happened, not against today. Passing Date.now() here made
410
+ // every replayed cache cold and every switch policy a no-op.
411
+ nowMs: row.created_at_ms,
412
+ ...(esc.excludeSlugs.length === 0 ? {} : { excludeSlugs: esc.excludeSlugs }),
361
413
  });
362
414
 
363
415
  // Re-arm exactly as turn.ts does: only when the served tier CHANGED, because
364
416
  // re-arming every turn extends the window forever and the router then never
365
- // downgrades. `escalated` is false replay does not model escalation
366
- // retries, so escalation-lengthened holds are still absent.
367
- // Only a dispatch that reaches the COMMIT path re-arms, as in turn.ts: an
417
+ // downgrades. Only a dispatch that reaches the COMMIT path re-arms: an
368
418
  // aborted one never gets there, and 27% of rows abort (omp closing the
369
419
  // stream once it has the tool calls). Re-arming on those inflated the hold
370
420
  // count roughly 4x against what production recorded.
371
421
  const committed = row.error_kind === null;
372
- const tierChanged = committed && (hold?.tier ?? null) !== decision.tier;
373
- const next: HoldState = tierChanged
374
- ? { tier: decision.tier, stickyUntilTurn: row.turn + resolveHoldTurns(cfg, row.conversation_key, false).turns }
375
- : { tier: committed ? decision.tier : (hold?.tier ?? null), stickyUntilTurn: hold?.stickyUntilTurn ?? 0 };
422
+ const escalated = esc.escalateFrom !== undefined;
423
+ const tierChanged = committed && (trail?.tier ?? null) !== decision.tier;
424
+ const nextTier = committed ? decision.tier : (trail?.tier ?? null);
425
+ const stickyUntilTurn = tierChanged ? row.turn + resolveHoldTurns(cfg, row.conversation_key, escalated).turns : (trail?.stickyUntilTurn ?? 0);
426
+
427
+ const prevSlug = args.warmth === "variant" ? (trail?.slug ?? null) : (prior?.slug ?? null);
428
+ const prevPrompt = args.warmth === "variant" ? (trail?.promptTokens ?? 0) : (prior?.promptTokens ?? 0);
429
+ const prevAt = args.warmth === "variant" ? (trail?.atMs ?? 0) : (prior?.atMs ?? 0);
430
+ const warm = prevSlug === decision.slug && prevPrompt > 0 && row.created_at_ms - prevAt <= cfg.hysteresis.cacheWarmTtlMs;
431
+ const priced = repriceUsd(bySlug.get(decision.slug), usage, warm, prevPrompt, row.served_slug);
376
432
 
377
433
  return {
378
434
  tier: decision.tier,
379
435
  slug: decision.slug,
380
- usd: repriceUsd(bySlug.get(decision.slug), usage),
436
+ usd: priced.usd,
437
+ cold: priced.cold,
438
+ switched: prevSlug !== null && prevSlug !== decision.slug,
381
439
  held: decision.classification.source === "sticky",
382
- hold: next,
440
+ trail: {
441
+ slug: decision.slug,
442
+ promptTokens: usage.promptTokens,
443
+ atMs: row.created_at_ms,
444
+ tier: nextTier,
445
+ stickyUntilTurn,
446
+ upgradeDeferredTier: decision.upgradeDeferred,
447
+ },
383
448
  };
384
449
  }
385
450
 
386
- const tallyA = new Map<string, number>();
387
- const tallyB = new Map<string, number>();
388
- const tallyRec = new Map<string, number>();
389
- const tierA = new Map<string, number>();
390
- const tierB = new Map<string, number>();
391
- const tierRec = new Map<string, number>();
392
- let usdA = 0;
393
- let usdB = 0;
394
- let usdRec = 0;
451
+ interface Tally {
452
+ slugs: Map<string, number>;
453
+ tiers: Map<string, number>;
454
+ usd: number;
455
+ switches: number;
456
+ switchUsd: number;
457
+ cold: number;
458
+ held: number;
459
+ deferred: number;
460
+ }
461
+ const tally = (): Tally => ({ slugs: new Map(), tiers: new Map(), usd: 0, switches: 0, switchUsd: 0, cold: 0, held: 0, deferred: 0 });
462
+ const A = tally();
463
+ const B = tally();
464
+ const REC = tally();
395
465
  let fidelitySlug = 0;
396
466
  let fidelityTier = 0;
397
467
  let comparable = 0;
468
+ let escalationsReplayed = 0;
398
469
  const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
399
470
  const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
400
471
 
401
- // Carries the RECORDED outcome of each conversation's previous dispatch forward,
402
- // so cache warmth and the prior slug are real rather than assumed absent.
472
+ // Carries the RECORDED outcome of each conversation's previous served dispatch
473
+ // forward (spend, tier), plus each variant's own trail.
403
474
  const priorByConv = new Map<string, PriorTurn>();
404
- // Hold state is per VARIANT, since a hold follows from that variant's own
405
- // decisions. See HoldState.
406
- const holdA = new Map<string, HoldState>();
407
- const holdB = new Map<string, HoldState>();
408
- let heldA = 0;
409
- let heldB = 0;
475
+ const trailA = new Map<string, VariantTrail>();
476
+ const trailB = new Map<string, VariantTrail>();
477
+ // Wasted attempts of the turn being replayed, keyed by conversation:turn.
478
+ const wastedByTurn = new Map<string, Row[]>();
410
479
 
411
480
  for (const row of rows) {
481
+ const turnKey = `${row.conversation_key}:${row.turn}`;
482
+ if (row.wasted === 1) {
483
+ const list = wastedByTurn.get(turnKey) ?? [];
484
+ list.push(row);
485
+ wastedByTurn.set(turnKey, list);
486
+ continue;
487
+ }
412
488
  const u = JSON.parse(row.usage) as UsageCounts;
413
489
  if (!(u.promptTokens > 0)) continue;
414
490
  const prior = priorByConv.get(row.conversation_key);
415
- const a = run(cfgA, row, u, prior, holdA.get(row.conversation_key));
416
- const b = run(cfgB, row, u, prior, holdB.get(row.conversation_key));
417
- holdA.set(row.conversation_key, a.hold);
418
- holdB.set(row.conversation_key, b.hold);
419
- if (a.held) heldA++;
420
- if (b.held) heldB++;
491
+ // Escalation context as turn.ts would have passed it: the last probe-rejected
492
+ // attempt's tier, and every failed attempt's slug excluded.
493
+ const wasted = (wastedByTurn.get(turnKey) ?? []).filter((w) => w.attempt < row.attempt);
494
+ const rejected = wasted.filter((w) => w.escalation_signal !== null);
495
+ const esc: EscalationContext = {
496
+ escalateFrom: rejected.length > 0 ? (rejected[rejected.length - 1]!.tier as Tier) : undefined,
497
+ excludeSlugs: wasted.map((w) => w.served_slug ?? w.slug),
498
+ };
499
+ if (esc.escalateFrom !== undefined) escalationsReplayed++;
500
+ const a = run(cfgA, snapshotA, row, u, prior, trailA.get(row.conversation_key), esc);
501
+ const b = run(cfgB, snapshotB, row, u, prior, trailB.get(row.conversation_key), esc);
502
+ trailA.set(row.conversation_key, a.trail);
503
+ trailB.set(row.conversation_key, b.trail);
504
+
505
+ const recordedSwitch = prior?.slug !== undefined && prior.slug !== null && row.served_slug !== null && prior.slug !== row.served_slug;
506
+ const recordedUsd = row.reported_usd ?? row.predicted_usd;
421
507
  priorByConv.set(row.conversation_key, {
422
508
  slug: row.served_slug,
423
509
  tier: row.tier,
424
510
  promptTokens: u.promptTokens,
425
511
  cachedTokens: u.cachedTokens,
426
- spentUsd: (prior?.spentUsd ?? 0) + (row.reported_usd ?? row.predicted_usd),
512
+ spentUsd: (prior?.spentUsd ?? 0) + recordedUsd,
427
513
  atMs: row.created_at_ms,
428
514
  });
429
- bump(tallyA, a.slug);
430
- bump(tallyB, b.slug);
431
- bump(tierA, a.tier);
432
- bump(tierB, b.tier);
515
+
516
+ for (const [t, o] of [
517
+ [A, a],
518
+ [B, b],
519
+ ] as const) {
520
+ bump(t.slugs, o.slug);
521
+ bump(t.tiers, o.tier);
522
+ t.usd += o.usd;
523
+ if (o.switched) {
524
+ t.switches++;
525
+ t.switchUsd += o.usd;
526
+ }
527
+ if (o.cold) t.cold++;
528
+ if (o.held) t.held++;
529
+ if (o.trail.upgradeDeferredTier !== null) t.deferred++;
530
+ }
433
531
  // The recorded outcome: what the router ACTUALLY did, under whatever code and
434
532
  // config were live then. This is the yardstick for fidelity, and it is also
435
533
  // how a shipped classifier change shows up — replay runs current code.
436
- if (row.served_slug !== null) bump(tallyRec, row.served_slug);
437
- bump(tierRec, row.tier);
438
- usdA += a.usd;
439
- usdB += b.usd;
440
- usdRec += row.reported_usd ?? row.predicted_usd;
534
+ if (row.served_slug !== null) bump(REC.slugs, row.served_slug);
535
+ bump(REC.tiers, row.tier);
536
+ REC.usd += recordedUsd;
537
+ if (recordedSwitch) {
538
+ REC.switches++;
539
+ REC.switchUsd += recordedUsd;
540
+ }
541
+ if (u.promptTokens > 20_000 && u.cachedTokens < 0.2 * u.promptTokens) REC.cold++;
441
542
  comparable++;
442
543
  if (row.served_slug !== null && row.served_slug === a.slug) fidelitySlug++;
443
544
  if (row.tier === a.tier) fidelityTier++;
@@ -447,35 +548,45 @@ for (const row of rows) {
447
548
  }
448
549
 
449
550
  const pct = (n: number, d: number) => (d === 0 ? "0.0" : ((100 * n) / d).toFixed(1));
450
- console.log(`\nreplayed ${comparable} dispatches from ${dbPath}`);
551
+ console.log(`\nreplayed ${comparable} dispatches from ${dbPath} (${escalationsReplayed} escalations as recorded; warmth: ${args.warmth})`);
552
+ console.log(`catalog: ${openrouterSnapshot.models.length} OpenRouter models${ollamaCache.models.length > 0 ? ` + ${ollamaCache.models.length} Ollama Cloud models` : ""}${cfgA.ollama.enabled ? "" : " (ollama disabled in config)"}`);
451
553
  console.log(`variant A overrides: ${args.setA.length ? args.setA.join(" ") : "(config as-is)"}`);
452
554
  console.log(`variant B overrides: ${args.setB.length ? args.setB.join(" ") : "(none — A and B identical)"}`);
453
555
  console.log(`\nFIDELITY vs what actually ran:`);
454
556
  console.log(` same model ${fidelitySlug}/${comparable} (${pct(fidelitySlug, comparable)}%) same tier ${fidelityTier}/${comparable} (${pct(fidelityTier, comparable)}%)`);
455
557
  console.log(" Divergence is expected where code has changed since those rows were served");
456
558
  console.log(" (replay runs CURRENT code); the rest is what replay cannot model.");
457
- console.log(` hysteresis holds bound ${heldA} dispatches in A, ${heldB} in B (simulated per variant).`);
458
559
 
459
- function table(label: string, rec: Map<string, number>, A: Map<string, number>, B: Map<string, number>) {
460
- const keys = [...new Set([...rec.keys(), ...A.keys(), ...B.keys()])].sort((x, y) => (B.get(y) ?? 0) - (B.get(x) ?? 0));
560
+ function table(label: string, rec: Map<string, number>, a: Map<string, number>, b: Map<string, number>) {
561
+ const keys = [...new Set([...rec.keys(), ...a.keys(), ...b.keys()])].sort((x, y) => (b.get(y) ?? 0) - (b.get(x) ?? 0));
461
562
  console.log(`\n${label.padEnd(32)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
462
563
  for (const k of keys) {
463
564
  const r = rec.get(k) ?? 0;
464
- const a = A.get(k) ?? 0;
465
- const b = B.get(k) ?? 0;
466
- const d = b - a;
467
- console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(a).padStart(7)}${String(b).padStart(7)}${(d > 0 ? `+${d}` : String(d)).padStart(7)}`);
565
+ const av = a.get(k) ?? 0;
566
+ const bv = b.get(k) ?? 0;
567
+ const d = bv - av;
568
+ console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(av).padStart(7)}${String(bv).padStart(7)}${(d > 0 ? `+${d}` : String(d)).padStart(7)}`);
468
569
  }
469
570
  }
470
- table("tier", tierRec, tierA, tierB);
471
- table("model", tallyRec, tallyA, tallyB);
571
+ table("tier", REC.tiers, A.tiers, B.tiers);
572
+ table("model", REC.slugs, A.slugs, B.slugs);
573
+
574
+ console.log(`\ncache behaviour (per variant; "actual" cold = recorded <20% cached on a >20k prompt):`);
575
+ console.log(` ${"".padEnd(30)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
576
+ const line = (label: string, r: number, av: number, bv: number) =>
577
+ console.log(` ${label.padEnd(30)}${String(r).padStart(8)}${String(av).padStart(7)}${String(bv).padStart(7)}${(bv - av > 0 ? `+${bv - av}` : String(bv - av)).padStart(7)}`);
578
+ line("model switches", REC.switches, A.switches, B.switches);
579
+ line("cold-priced dispatches", REC.cold, A.cold, B.cold);
580
+ line("hysteresis holds", 0, A.held, B.held);
581
+ line("upgrades deferred", 0, A.deferred, B.deferred);
582
+ console.log(` spend on switch turns $${REC.switchUsd.toFixed(2).padStart(7)} $${A.switchUsd.toFixed(2).padStart(6)} $${B.switchUsd.toFixed(2).padStart(6)}`);
472
583
 
473
584
  console.log(`\nspend, re-priced on RECORDED usage via the real computeCost:`);
474
- console.log(` actual (billed) $${usdRec.toFixed(4)} per dispatch $${(usdRec / comparable).toFixed(5)}`);
475
- console.log(` A $${usdA.toFixed(4)} per dispatch $${(usdA / comparable).toFixed(5)}`);
476
- console.log(` B $${usdB.toFixed(4)} per dispatch $${(usdB / comparable).toFixed(5)}`);
477
- const delta = usdB - usdA;
478
- console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (usdA || 1)).toFixed(1)}%`})`);
585
+ console.log(` actual (billed) $${REC.usd.toFixed(4)} per dispatch $${(REC.usd / comparable).toFixed(5)}`);
586
+ console.log(` A $${A.usd.toFixed(4)} per dispatch $${(A.usd / comparable).toFixed(5)}`);
587
+ console.log(` B $${B.usd.toFixed(4)} per dispatch $${(B.usd / comparable).toFixed(5)}`);
588
+ const delta = B.usd - A.usd;
589
+ console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (A.usd || 1)).toFixed(1)}%`})`);
479
590
  console.log(`\ndecisions changed: ${flips.length}/${comparable} (${pct(flips.length, comparable)}%)`);
480
591
  if (args.verbose) {
481
592
  for (const f of flips.slice(0, 40)) {