auto-model-router 0.31.0 → 0.33.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 (116) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +61 -2
  3. package/omp-extension/router-configure.ts +9 -7
  4. package/package.json +1 -1
  5. package/src/cli/config-cmd.ts +8 -7
  6. package/src/cli/explain.ts +10 -5
  7. package/src/cli/export.ts +6 -5
  8. package/src/cli/models.ts +10 -7
  9. package/src/cli/report.ts +6 -1
  10. package/src/cli/stats.ts +7 -7
  11. package/src/config/load.ts +10 -1
  12. package/src/config/types.ts +10 -1
  13. package/src/context/bridge.ts +7 -7
  14. package/src/context/index.ts +3 -3
  15. package/src/context/store.ts +39 -56
  16. package/src/context/types.ts +7 -6
  17. package/src/cost/blended.ts +28 -7
  18. package/src/cost/feedback.ts +33 -37
  19. package/src/cost/ledger-sql.ts +547 -0
  20. package/src/cost/ledger.ts +30 -459
  21. package/src/cost/report.ts +171 -129
  22. package/src/cost/retention.ts +10 -10
  23. package/src/cost/summary.ts +15 -10
  24. package/src/cost/types.ts +43 -62
  25. package/src/cost/views.ts +79 -49
  26. package/src/lib.ts +6 -2
  27. package/src/router/candidates.ts +16 -15
  28. package/src/router/classify.ts +6 -4
  29. package/src/router/index.ts +95 -9
  30. package/src/router/select.ts +38 -21
  31. package/src/router/state.ts +90 -102
  32. package/src/router/types.ts +13 -5
  33. package/src/server/advise.ts +6 -4
  34. package/src/server/compaction-digest.ts +1 -1
  35. package/src/server/digest.ts +9 -10
  36. package/src/server/http.ts +107 -41
  37. package/src/server/providers.ts +18 -4
  38. package/src/server/turn.ts +35 -10
  39. package/src/tokens/estimate.ts +16 -6
  40. package/src/upstream/anthropic.ts +5 -4
  41. package/src/upstream/compat.ts +13 -7
  42. package/src/upstream/ollama-usage.ts +21 -11
  43. package/src/upstream/ollama.ts +4 -3
  44. package/src/upstream/openrouter.ts +4 -3
  45. package/src/upstream/types.ts +7 -0
  46. package/src/util/schema.ts +201 -0
  47. package/src/util/sql.ts +246 -0
  48. package/src/wire/anthropic/messages.ts +3 -4
  49. package/src/wire/openai/request.ts +30 -0
  50. package/src/wire/types.ts +22 -0
  51. package/test/anthropic-wire.test.ts +9 -9
  52. package/test/benchmark-feeds.test.ts +7 -7
  53. package/test/cache-control.test.ts +7 -7
  54. package/test/cache-estimate.test.ts +5 -5
  55. package/test/catalog-view.test.ts +4 -4
  56. package/test/catalog.test.ts +11 -11
  57. package/test/classify.test.ts +24 -24
  58. package/test/compaction.test.ts +20 -20
  59. package/test/config-wizard.test.ts +32 -32
  60. package/test/config.test.ts +10 -10
  61. package/test/connect-harnesses.test.ts +11 -11
  62. package/test/context-bridge.test.ts +40 -30
  63. package/test/context-prune.test.ts +43 -36
  64. package/test/context-query.test.ts +8 -8
  65. package/test/controls.test.ts +54 -27
  66. package/test/cost.test.ts +12 -12
  67. package/test/digest.test.ts +55 -44
  68. package/test/embed-lifecycle.test.ts +5 -5
  69. package/test/embed-logic.test.ts +26 -26
  70. package/test/escalate.test.ts +17 -17
  71. package/test/eval.test.ts +13 -13
  72. package/test/executable.test.ts +6 -6
  73. package/test/exploration.test.ts +19 -20
  74. package/test/failover.test.ts +63 -21
  75. package/test/fakes.ts +105 -0
  76. package/test/features.test.ts +21 -21
  77. package/test/harness-requests.test.ts +3 -3
  78. package/test/harness-switch.test.ts +5 -5
  79. package/test/hold-exploration.test.ts +13 -13
  80. package/test/hot-reload.test.ts +5 -5
  81. package/test/learned.test.ts +5 -5
  82. package/test/ledger-sql.test.ts +342 -0
  83. package/test/mcp-entry.test.ts +5 -5
  84. package/test/migrations.test.ts +28 -22
  85. package/test/models-yml.test.ts +18 -18
  86. package/test/ollama.test.ts +40 -34
  87. package/test/omp-credentials.test.ts +16 -16
  88. package/test/policy.test.ts +3 -3
  89. package/test/reconfigure.test.ts +4 -4
  90. package/test/redaction.test.ts +41 -35
  91. package/test/remote.test.ts +12 -12
  92. package/test/report-logic.test.ts +8 -8
  93. package/test/report.test.ts +95 -87
  94. package/test/retention.test.ts +79 -66
  95. package/test/schema.test.ts +123 -0
  96. package/test/scope.test.ts +8 -8
  97. package/test/select.test.ts +216 -257
  98. package/test/skills.test.ts +3 -3
  99. package/test/sql-shim.test.ts +154 -0
  100. package/test/state.test.ts +43 -36
  101. package/test/summary.test.ts +38 -27
  102. package/test/tier-plan.test.ts +45 -62
  103. package/test/toast-logic.test.ts +31 -31
  104. package/test/tokens.test.ts +95 -80
  105. package/test/trust-attribution.test.ts +217 -187
  106. package/test/trust-window.test.ts +37 -32
  107. package/test/turn.test.ts +55 -23
  108. package/test/upstream-keys.test.ts +240 -0
  109. package/test/upstreams.test.ts +13 -13
  110. package/test/views.test.ts +81 -59
  111. package/test/wire-request.test.ts +17 -17
  112. package/test/wire-responses.test.ts +4 -4
  113. package/tools/agentdox-e2e.ts +5 -2
  114. package/tools/export-benchmarks.ts +5 -5
  115. package/tools/ledger-parity.ts +266 -0
  116. package/tools/replay.ts +16 -8
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Parity: the one ledger must answer identically on both engines, reading the
3
+ * same production rows.
4
+ *
5
+ * The point is not that the SQL looks portable — it is that trust, latency,
6
+ * spend, blend, escalation cost and every report come out EQUAL, because
7
+ * routing decisions are made from those numbers and a front door may read a
8
+ * SQLite file while the router writes Postgres. Every silent bug found while
9
+ * porting (a `SUM()` read as a string skewing a trust score, a double-encoded
10
+ * JSON column nulling an escalation term, a nested `json_extract` path that
11
+ * SQLite tolerates and Postgres reads as NULL) was caught by comparing
12
+ * computed values rather than by a type error.
13
+ *
14
+ * Usage: bun tools/ledger-parity.ts <sqlite-path> [postgres-url]
15
+ */
16
+ import { Database } from "bun:sqlite";
17
+
18
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
19
+ import type { RouterConfig } from "../src/config/types.ts";
20
+ import { createSqlLedger } from "../src/cost/ledger-sql.ts";
21
+ import { migrateStore } from "../src/util/schema.ts";
22
+ import type { AsyncLedger } from "../src/cost/types.ts";
23
+ import { openSqlDb, type SqlDb } from "../src/util/sql.ts";
24
+ import { decisionEntries, exportRows, feedbackView, spendUsdSince } from "../src/cost/views.ts";
25
+ import { buildUsageReport } from "../src/cost/report.ts";
26
+ import { buildDailySummary, countTierChanges } from "../src/cost/summary.ts";
27
+
28
+ const sqlitePath = process.argv[2] ?? "/data/router/router.db";
29
+ const pgUrl = process.argv[3] ?? "";
30
+
31
+ // Windows wide open so every row counts, and the scoped query shapes exercised.
32
+ const cfg: RouterConfig = structuredClone(DEFAULT_CONFIG);
33
+ cfg.filters.trustWindowDays = 0;
34
+ cfg.filters.feedbackWeight = 1;
35
+ cfg.filters.cacheReliabilityMinSamples = 1;
36
+ cfg.filters.escalationCostWeight = 1;
37
+ cfg.filters.latencyMinSamples = 1;
38
+
39
+ // The source of real rows. Read-only: the harness copies out of it and
40
+ // never writes to a live ledger.
41
+ const db = new Database(sqlitePath, { readonly: true });
42
+
43
+ const slugs = (db.query("SELECT DISTINCT slug FROM ledger WHERE slug IS NOT NULL").all() as { slug: string }[]).map((r) => r.slug);
44
+ const harnesses = (db.query("SELECT DISTINCT harness_id FROM ledger WHERE harness_id <> '' LIMIT 2").all() as { harness_id: string }[]).map(
45
+ (r) => r.harness_id,
46
+ );
47
+ const sessions = (
48
+ db.query("SELECT DISTINCT omp_session_id FROM ledger WHERE omp_session_id <> '' LIMIT 2").all() as { omp_session_id: string }[]
49
+ ).map((r) => r.omp_session_id);
50
+
51
+ /**
52
+ * Key-order-insensitive comparison. A JSON column round-tripped through
53
+ * Postgres' jsonb comes back with its keys reordered — jsonb does not preserve
54
+ * input order — which is not a difference in the value.
55
+ */
56
+ function stable(value: unknown): string {
57
+ return JSON.stringify(value, (_key, v: unknown) =>
58
+ v !== null && typeof v === "object" && !Array.isArray(v)
59
+ ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)))
60
+ : v,
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Structural comparison with a tolerance on every number, at any depth.
66
+ *
67
+ * Two engines summing the same rows in a different order land a few ULPs apart
68
+ * ($39.722225609861425 against ...56), which is arithmetic, not divergence. A
69
+ * strict compare on a nested total would report that as a mismatch and bury
70
+ * the real ones.
71
+ */
72
+ function deepEqual(a: unknown, b: unknown, tol: number): boolean {
73
+ if (typeof a === "number" && typeof b === "number") return Math.abs(a - b) <= tol * Math.max(1, Math.abs(a));
74
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return stable(a) === stable(b);
75
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
76
+ if (Array.isArray(a) && Array.isArray(b)) {
77
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i], tol));
78
+ }
79
+ const ra = a as Record<string, unknown>;
80
+ const rb = b as Record<string, unknown>;
81
+ const keys = new Set([...Object.keys(ra), ...Object.keys(rb)]);
82
+ for (const key of keys) if (!deepEqual(ra[key], rb[key], tol)) return false;
83
+ return true;
84
+ }
85
+
86
+ let checks = 0;
87
+ let bad = 0;
88
+ /** Which side is which in a mismatch report; set before each comparison pass. */
89
+ let labelA = "a";
90
+ let labelB = "b";
91
+ function eq(label: string, a: unknown, b: unknown, tol = 1e-9): void {
92
+ checks++;
93
+ const same = deepEqual(a, b, tol);
94
+ if (!same) {
95
+ bad++;
96
+ console.log(` MISMATCH ${label}\n ${labelA}=${JSON.stringify(a)}\n ${labelB}=${JSON.stringify(b)}`);
97
+ }
98
+ }
99
+
100
+ /** Copies the real rows into whichever store the unified ledger will read. */
101
+ async function load(target: SqlDb): Promise<void> {
102
+ await migrateStore(target);
103
+ for (const table of ["ledger", "feedback", "token_calibration"]) {
104
+ await target.sql.unsafe(`DELETE FROM ${table}`);
105
+ }
106
+ // The json columns are TEXT in the source; Postgres wants objects, so they
107
+ // are parsed when the destination stores JSONB.
108
+ const jsonCols = new Set(["usage", "cost_breakdown", "features", "classifier_reasons", "reasons"]);
109
+ const copy = async (table: string): Promise<number> => {
110
+ const cols = (db.query(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name);
111
+ const rows = db.query(`SELECT ${cols.join(", ")} FROM ${table}`).all() as Record<string, unknown>[];
112
+ for (let i = 0; i < rows.length; i += 500) {
113
+ const batch = rows.slice(i, i + 500).map((r) => {
114
+ const o: Record<string, unknown> = {};
115
+ for (const c of cols) {
116
+ const v = r[c] ?? null;
117
+ o[c] = target.dialect === "postgres" && jsonCols.has(c) && typeof v === "string" ? JSON.parse(v) : v;
118
+ }
119
+ return o;
120
+ });
121
+ await target.sql`INSERT INTO ${target.sql.unsafe(table)} ${target.sql(batch, ...cols)}`;
122
+ }
123
+ return rows.length;
124
+ };
125
+ const ledgerRows = await copy("ledger");
126
+ // tokenRatio reads this table, so without it the unified side reports null
127
+ // against a real calibrated ratio — another harness-shaped "mismatch".
128
+ await copy("token_calibration");
129
+ let feedbackRows = 0;
130
+ try {
131
+ feedbackRows = await copy("feedback");
132
+ } catch (err) {
133
+ // Never swallowed: a failed feedback copy makes the unified side read no
134
+ // verdicts and shows up as a trust-score "mismatch" that is really a
135
+ // harness bug. It cost a diagnosis once already.
136
+ console.log(` feedback copy FAILED: ${err instanceof Error ? err.message : String(err)}`);
137
+ }
138
+ console.log(` loaded ${ledgerRows} ledger rows, ${feedbackRows} feedback rows`);
139
+ }
140
+
141
+ /**
142
+ * Every ported view, as one comparable value. The views moved to the shim in
143
+ * the same change as the ledger, so there is no synchronous version left to
144
+ * diff against — what matters is that the two ENGINES agree, since a front
145
+ * door may read a file while the router writes a database.
146
+ */
147
+ async function viewsSnapshot(db: SqlDb): Promise<Record<string, unknown>> {
148
+ const harness = harnesses[0] ?? null;
149
+ // A FIXED instant: the windows are relative to "now", so two engines read a
150
+ // moment apart would legitimately disagree and hide a real difference.
151
+ const nowMs = 1_789_000_000_000;
152
+ const report = await buildUsageReport(db, { windowDays: 3650, nowMs });
153
+ const summary = await buildDailySummary(db, { nowMs });
154
+ return {
155
+ reportTotals: report.totals,
156
+ reportProviders: report.providers.map((r) => [r.key, r.dispatches, Number(r.spendUsd.toFixed(9)), Number(r.cacheHitRate.toFixed(9)), r.escalations, r.errors]),
157
+ reportModels: report.models.map((m) => [m.key, m.provider, m.dispatches, Number(m.spendUsd.toFixed(9)), m.tiers, m.feedback]),
158
+ reportTiers: report.tiers.map((r) => [r.key, r.dispatches, Number(r.spendUsd.toFixed(9))]),
159
+ reportDays: report.days.map((d) => [d.day, d.dispatches, Number(d.spendUsd.toFixed(9)), Number(d.cacheHitRate.toFixed(9))]),
160
+ reportAnatomy: report.anatomy,
161
+ summaryCurrent: summary.current,
162
+ summaryPrevious: summary.previous,
163
+ summaryTopModels: summary.topModels.map((m) => [m.slug, m.dispatches, Number(m.spendUsd.toFixed(9))]),
164
+ tierChanges: await countTierChanges(db, 0, ""),
165
+ spendAll: await spendUsdSince(db, 0, null),
166
+ spendHarness: harness === null ? 0 : await spendUsdSince(db, 0, [harness]),
167
+ spendNone: await spendUsdSince(db, 0, []),
168
+ exportRows: (await exportRows(db, 0, null)).map((r) => [r.day, r.harnessId, r.slug, r.scope, r.dispatches, r.promptTokens, r.cachedTokens, r.completionTokens, Number(r.spendUsd.toFixed(9)), r.escalations, r.errors]),
169
+ feedback: await feedbackView(db, 0, null),
170
+ decisions: (await decisionEntries(db, { sinceMs: 0, harness: null, limit: 25 })).map((e) => [e.id, e.slug, e.servedSlug, e.tier, e.wasted, e.feedback.length, e.costBreakdown?.total ?? null]),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Every signal the router reads off the ledger, as one comparable value.
176
+ *
177
+ * A FIXED instant throughout: `spendSince` and the spike windows are relative
178
+ * to "now", so two engines read a moment apart would legitimately disagree and
179
+ * hide a real difference.
180
+ */
181
+ async function ledgerSnapshot(unified: AsyncLedger): Promise<Record<string, unknown>> {
182
+ const nowMs = 1_789_000_000_000;
183
+ const out: Record<string, unknown> = {};
184
+
185
+ for (const days of [0, 1, 7]) {
186
+ const since = days === 0 ? 0 : nowMs - days * 86_400_000;
187
+ out[`spendSince(${days}d)`] = await unified.spendSince(since);
188
+ for (const h of harnesses) out[`spendSince(${days}d,${h})`] = await unified.spendSince(since, h);
189
+ }
190
+
191
+ const esc = await unified.escalationCost(cfg.ledger.blendWindowDays);
192
+ out.escalationCost = esc === null ? null : [esc.samples, esc.usdPerPromptToken];
193
+ const blend = await unified.blendedRate(cfg.ledger.blendWindowDays);
194
+ out.blendedRate = blend === null ? null : [blend.sampleCount, blend.inputPerMtok, blend.outputPerMtok, blend.cacheReadPerMtok];
195
+
196
+ // The prefetch the turn path actually uses, global and per harness.
197
+ for (const harness of [undefined, ...harnesses]) {
198
+ const tag = harness ?? "global";
199
+ const signals = await unified.signals(slugs, harness);
200
+ for (const slug of slugs) {
201
+ const s = signals.get(slug);
202
+ const trust = s?.trust ?? null;
203
+ const latency = s?.latency ?? null;
204
+ out[`trust[${tag}][${slug}]`] = trust === null
205
+ ? null
206
+ : [trust.attempts, trust.escalations, trust.errors, trust.successRate, trust.meanCostError];
207
+ out[`latency[${tag}][${slug}]`] = latency === null ? null : [latency.samples, latency.ttftMs, latency.tokensPerSec];
208
+ }
209
+ }
210
+
211
+ // Keyed by slug: row order is not part of the meaning here.
212
+ out.allTrust = Object.fromEntries((await unified.allTrust()).map((t) => [t.slug, [t.attempts, t.successRate]]));
213
+ const cache = await unified.cacheReliability(slugs);
214
+ out.cacheReliability = Object.fromEntries(slugs.map((s) => [s, cache.get(s) === undefined ? null : [cache.get(s)?.samples, cache.get(s)?.hitRate]]));
215
+
216
+ for (const prefix of ["ollama/", "deepseek/", "anthropic-subscription/"]) {
217
+ out[`providerSpendSince(${prefix})`] = await unified.providerSpendSince(prefix, 0);
218
+ }
219
+
220
+ // Entry round-trip: the JSON columns are where a dialect difference shows.
221
+ out.recentEntries = (await unified.recentEntries(25)).map((e) => [e.id, e.usage, e.reasons, e.costBreakdown ?? null, e.scope ?? null, e.wasted]);
222
+
223
+ for (const session of sessions) {
224
+ out[`latestForSession(${session})`] = (await unified.latestForSession(session))?.id ?? null;
225
+ out[`entriesForSession(${session})`] = (await unified.entriesForSession(session, 5)).map((e) => e.id);
226
+ }
227
+
228
+ out.softFailureSpikes = (await unified.softFailureSpikes(nowMs)).map((s) => [s.slug, s.recentRate, s.recentDispatches, s.baselineRate]);
229
+ for (const tokenizer of ["gpt", "claude", "qwen", "llama"]) {
230
+ out[`tokenRatio(${tokenizer})`] = await unified.tokenRatio(tokenizer);
231
+ }
232
+ return out;
233
+ }
234
+
235
+ const targets: { engine: string; url: string }[] = [
236
+ { engine: "sqlite", url: `sqlite:///tmp/ledger-parity-${Date.now()}.db` },
237
+ ...(pgUrl === "" ? [] : [{ engine: "postgres", url: pgUrl }]),
238
+ ];
239
+
240
+ const snapshots = new Map<string, Record<string, unknown>>();
241
+ for (const target of targets) {
242
+ console.log(`\n=== loading ${target.engine} ===`);
243
+ const store = openSqlDb(target.url);
244
+ await load(store);
245
+ const ledger = createSqlLedger(store, cfg, { findModel: () => null });
246
+ snapshots.set(target.engine, { ...(await ledgerSnapshot(ledger)), ...(await viewsSnapshot(store)) });
247
+ await store.close();
248
+ }
249
+
250
+ // Every signal and every view, engine against engine. One engine alone still
251
+ // proves nothing was thrown: it loads the rows and computes the lot.
252
+ const [first, ...rest] = [...snapshots.keys()];
253
+ if (first !== undefined) {
254
+ const a = snapshots.get(first) as Record<string, unknown>;
255
+ labelA = first;
256
+ for (const other of rest) {
257
+ console.log(`\n=== ${other} vs ${first} ===`);
258
+ labelB = other;
259
+ const b = snapshots.get(other) as Record<string, unknown>;
260
+ for (const key of Object.keys(a)) eq(key, a[key], b[key], 1e-9);
261
+ }
262
+ if (rest.length === 0) console.log(`\n=== ${first} only: ${Object.keys(a).length} values computed, nothing to compare against ===`);
263
+ }
264
+
265
+ console.log(`\n${checks - bad}/${checks} checks equal${bad === 0 ? "" : `, ${bad} MISMATCHED`}`);
266
+ process.exit(bad === 0 ? 0 : 1);
package/tools/replay.ts CHANGED
@@ -70,7 +70,9 @@ import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
70
70
  import { loadConfig } from "../src/config/load.ts";
71
71
  import type { RouterConfig } from "../src/config/types.ts";
72
72
  import { computeCost } from "../src/cost/forecast.ts";
73
- import { createLedger } from "../src/cost/ledger.ts";
73
+ import { createSqlLedger } from "../src/cost/ledger-sql.ts";
74
+ import { prefetchTurnReads } from "../src/router/index.ts";
75
+ import { openSqlDb } from "../src/util/sql.ts";
74
76
  import type { UsageCounts } from "../src/cost/types.ts";
75
77
  import { classifyTask, scoreHeuristic } from "../src/router/classify.ts";
76
78
  import { resolveHoldTurns } from "../src/router/explore.ts";
@@ -324,7 +326,11 @@ function snapshotFor(cfg: RouterConfig): CatalogSnapshot {
324
326
  const snapshotA = snapshotFor(cfgA);
325
327
  const snapshotB = snapshotFor(cfgB);
326
328
  const bySlug = new Map([...snapshotA.models, ...snapshotB.models].map((m) => [m.slug, m]));
327
- const ledger = createLedger(db, cfgA);
329
+ // Replay reads through the engine-agnostic handle: `select` takes the ledger's
330
+ // answers as DATA now, so each replayed turn prefetches the same reads a live
331
+ // turn would. Read-only — nothing here writes.
332
+ const sqlDb = openSqlDb(dbPath);
333
+ const ledger = createSqlLedger(sqlDb, cfgA, { findModel: (slug: string) => bySlug.get(slug) ?? null });
328
334
 
329
335
  const predicate = args.where === "" ? "" : ` AND (${args.where})`;
330
336
  // Newest-first to honour --limit, then flipped to chronological so each row can
@@ -371,7 +377,7 @@ interface Outcome {
371
377
  trail: VariantTrail;
372
378
  }
373
379
 
374
- function run(
380
+ async function run(
375
381
  cfg: RouterConfig,
376
382
  snapshot: CatalogSnapshot,
377
383
  row: Row,
@@ -379,7 +385,7 @@ function run(
379
385
  prior: PriorTurn | undefined,
380
386
  trail: VariantTrail | undefined,
381
387
  esc: EscalationContext,
382
- ): Outcome {
388
+ ): Promise<Outcome> {
383
389
  const f = featuresOf(row, usage.promptTokens);
384
390
  const req = requestOf(row, f);
385
391
  const state = stateOf(row, prior, trail, args.warmth);
@@ -399,14 +405,16 @@ function run(
399
405
  } else {
400
406
  classification = scoreHeuristic(f, cfg);
401
407
  }
408
+ const profile = profileOf(cfg, row.requested_model);
409
+ const reads = await prefetchTurnReads(ledger, req, profile, cfg, snapshot, classification.task, row.created_at_ms);
402
410
  const decision: Decision = select({
403
411
  req,
404
412
  features: f,
405
413
  classification,
406
- profile: profileOf(cfg, row.requested_model),
414
+ profile,
407
415
  state,
408
416
  snapshot,
409
- ledger,
417
+ reads,
410
418
  cfg,
411
419
  // The row's own clock: cache warmth and hold windows are judged against
412
420
  // when the turn happened, not against today. Passing Date.now() here made
@@ -500,8 +508,8 @@ for (const row of rows) {
500
508
  excludeSlugs: wasted.map((w) => w.served_slug ?? w.slug),
501
509
  };
502
510
  if (esc.escalateFrom !== undefined) escalationsReplayed++;
503
- const a = run(cfgA, snapshotA, row, u, prior, trailA.get(row.conversation_key), esc);
504
- const b = run(cfgB, snapshotB, row, u, prior, trailB.get(row.conversation_key), esc);
511
+ const a = await run(cfgA, snapshotA, row, u, prior, trailA.get(row.conversation_key), esc);
512
+ const b = await run(cfgB, snapshotB, row, u, prior, trailB.get(row.conversation_key), esc);
505
513
  trailA.set(row.conversation_key, a.trail);
506
514
  trailB.set(row.conversation_key, b.trail);
507
515