auto-model-router 0.2.15 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +12 -6
- package/docs/routing-benchmark-findings.md +346 -0
- package/omp-extension/embed-logic.ts +21 -0
- package/omp-extension/router-embed.ts +32 -5
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +39 -0
- package/src/config/defaults.ts +15 -0
- package/src/config/schema.ts +4 -0
- package/src/config/types.ts +39 -0
- package/src/context/agentdox.ts +8 -1
- package/src/context/bridge.ts +5 -2
- package/src/context/index.ts +2 -0
- package/src/cost/types.ts +6 -1
- package/src/router/candidates.ts +67 -1
- package/src/server/http.ts +9 -2
- package/src/wire/openai/sink.ts +15 -2
- package/test/context-bridge.test.ts +13 -7
- package/test/failover.test.ts +1 -1
- package/test/http-resilience.test.ts +58 -0
- package/test/tier-plan.test.ts +91 -0
- package/test/turn.test.ts +1 -1
- package/test/wire-sink.test.ts +31 -0
- package/tools/replay.ts +403 -0
package/tools/replay.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
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
|
+
* 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.
|
|
33
|
+
*
|
|
34
|
+
* WHAT IT DOES NOT MODEL — read this before trusting a conclusion
|
|
35
|
+
* - `messages` are not recorded, so compaction cannot be re-planned. Replay
|
|
36
|
+
* forces `compaction.enabled=false` and feeds the POST-compaction prompt
|
|
37
|
+
* size (`usage.promptTokens`), i.e. the prompt selection actually saw.
|
|
38
|
+
* - `stickyUntilTurn` was never persisted per turn, so the hysteresis hold
|
|
39
|
+
* window is absent. This is the main residual gap.
|
|
40
|
+
* - `requestedReasoning` is the one `Features` field the ledger omits; it
|
|
41
|
+
* replays as undefined.
|
|
42
|
+
* - Module constants are not config, so things like CAP_AUTONOMOUS_LOOP cannot
|
|
43
|
+
* be A/B'd via `--set` — only `RouterConfig` paths can.
|
|
44
|
+
*
|
|
45
|
+
* Because of those gaps the report leads with a FIDELITY figure. Read it with
|
|
46
|
+
* care: it conflates replay error with genuine code change, since replay always
|
|
47
|
+
* runs CURRENT code against rows served by whatever code was live then. Measured
|
|
48
|
+
* on rows served by matching code it is 90% model / 77% tier; across older
|
|
49
|
+
* history it drops to ~55%, and that drop is the shipped classifier changes
|
|
50
|
+
* showing up, not the tool being wrong. Isolate a population with `--where` when
|
|
51
|
+
* measuring one change.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { Database } from "bun:sqlite";
|
|
55
|
+
|
|
56
|
+
import { createCatalog } from "../src/catalog/openrouter-catalog.ts";
|
|
57
|
+
import type { CatalogModel } from "../src/catalog/types.ts";
|
|
58
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
59
|
+
import type { RouterConfig } from "../src/config/types.ts";
|
|
60
|
+
import { computeCost } from "../src/cost/forecast.ts";
|
|
61
|
+
import { createLedger } from "../src/cost/ledger.ts";
|
|
62
|
+
import type { UsageCounts } from "../src/cost/types.ts";
|
|
63
|
+
import { scoreHeuristic } from "../src/router/classify.ts";
|
|
64
|
+
import { select } from "../src/router/select.ts";
|
|
65
|
+
import type { ConversationState, Decision, Features, Tier } from "../src/router/types.ts";
|
|
66
|
+
import type { UpstreamClient } from "../src/upstream/types.ts";
|
|
67
|
+
import type { NormMessage, NormRequest, NormTool } from "../src/wire/types.ts";
|
|
68
|
+
|
|
69
|
+
interface Args {
|
|
70
|
+
limit: number;
|
|
71
|
+
where: string;
|
|
72
|
+
setB: string[];
|
|
73
|
+
setA: string[];
|
|
74
|
+
verbose: boolean;
|
|
75
|
+
db: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseArgs(argv: string[]): Args {
|
|
79
|
+
const a: Args = { limit: 500, where: "", setB: [], setA: [], verbose: false, db: "" };
|
|
80
|
+
for (let i = 0; i < argv.length; i++) {
|
|
81
|
+
const k = argv[i];
|
|
82
|
+
const v = argv[i + 1];
|
|
83
|
+
if (k === "--limit" && v !== undefined) (a.limit = Number.parseInt(v, 10)), i++;
|
|
84
|
+
else if (k === "--where" && v !== undefined) (a.where = v), i++;
|
|
85
|
+
else if (k === "--set" && v !== undefined) (a.setB.push(v), i++);
|
|
86
|
+
else if (k === "--a" && v !== undefined) (a.setA.push(v), i++);
|
|
87
|
+
else if (k === "--db" && v !== undefined) (a.db = v), i++;
|
|
88
|
+
else if (k === "--verbose") a.verbose = true;
|
|
89
|
+
}
|
|
90
|
+
return a;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Coerce a CLI string to the JSON-ish type the config field expects. */
|
|
94
|
+
function coerce(raw: string): unknown {
|
|
95
|
+
if (raw === "true") return true;
|
|
96
|
+
if (raw === "false") return false;
|
|
97
|
+
if (raw === "null") return null;
|
|
98
|
+
const n = Number(raw);
|
|
99
|
+
if (raw.trim() !== "" && !Number.isNaN(n)) return n;
|
|
100
|
+
return raw;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Applies `a.b.c=value` overrides onto a deep clone, so variants never alias. */
|
|
104
|
+
function withOverrides(cfg: RouterConfig, sets: readonly string[]): RouterConfig {
|
|
105
|
+
const next = structuredClone(cfg);
|
|
106
|
+
for (const entry of sets) {
|
|
107
|
+
const eq = entry.indexOf("=");
|
|
108
|
+
if (eq < 0) throw new Error(`--set expects path=value, got: ${entry}`);
|
|
109
|
+
const path = entry.slice(0, eq).split(".");
|
|
110
|
+
const value = coerce(entry.slice(eq + 1));
|
|
111
|
+
let node: Record<string, unknown> = next as unknown as Record<string, unknown>;
|
|
112
|
+
for (const seg of path.slice(0, -1)) {
|
|
113
|
+
const child = node[seg];
|
|
114
|
+
if (typeof child !== "object" || child === null) throw new Error(`--set path not found: ${entry}`);
|
|
115
|
+
node = child as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
const leaf = path[path.length - 1];
|
|
118
|
+
if (leaf === undefined) throw new Error(`--set expects a key, got: ${entry}`);
|
|
119
|
+
// An absent leaf is legitimate and required: optional config fields are
|
|
120
|
+
// simply missing until set (exactOptionalPropertyTypes), and introducing
|
|
121
|
+
// one is exactly what a variant does. A wrong PARENT path still throws,
|
|
122
|
+
// in the walk above, which is what catches typos.
|
|
123
|
+
node[leaf] = value;
|
|
124
|
+
}
|
|
125
|
+
return next;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface Row {
|
|
129
|
+
id: string;
|
|
130
|
+
conversation_key: string;
|
|
131
|
+
turn: number;
|
|
132
|
+
requested_model: string;
|
|
133
|
+
harness_id: string;
|
|
134
|
+
served_slug: string | null;
|
|
135
|
+
tier: string;
|
|
136
|
+
features: string;
|
|
137
|
+
usage: string;
|
|
138
|
+
reported_usd: number | null;
|
|
139
|
+
predicted_usd: number;
|
|
140
|
+
created_at_ms: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Rebuilds the classifier input. The ledger stores 20 of 21 Features fields. */
|
|
144
|
+
function featuresOf(row: Row, promptTokens: number): Features {
|
|
145
|
+
const f = JSON.parse(row.features) as Partial<Features>;
|
|
146
|
+
return { ...(f as Features), promptTokens, requestedReasoning: undefined };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Minimal request carrying only what `select`/`buildCandidates` read: tool count
|
|
151
|
+
* and schema bytes, image presence, harness id (trust/latency scoping),
|
|
152
|
+
* conversation key and profile id.
|
|
153
|
+
*/
|
|
154
|
+
function requestOf(row: Row, f: Features): NormRequest {
|
|
155
|
+
const perTool = f.toolCount > 0 ? Math.round(f.toolSchemaBytes / f.toolCount) : 0;
|
|
156
|
+
const tools: NormTool[] = Array.from({ length: f.toolCount }, (_v, i) => ({
|
|
157
|
+
name: `t${i}`,
|
|
158
|
+
description: "",
|
|
159
|
+
schemaBytes: perTool,
|
|
160
|
+
}));
|
|
161
|
+
const messages: NormMessage[] = [
|
|
162
|
+
{ role: "user", text: "", images: f.hasImages ? 1 : 0, textBytes: f.promptTokens * 4, toolCalls: [] },
|
|
163
|
+
];
|
|
164
|
+
return {
|
|
165
|
+
protocol: "openai-chat",
|
|
166
|
+
conversationKey: row.conversation_key,
|
|
167
|
+
harnessId: row.harness_id,
|
|
168
|
+
ompSessionId: "",
|
|
169
|
+
agentdoxScope: "",
|
|
170
|
+
requestedModel: row.requested_model,
|
|
171
|
+
messages,
|
|
172
|
+
tools,
|
|
173
|
+
forcedToolChoice: false,
|
|
174
|
+
stream: true,
|
|
175
|
+
hasImages: f.hasImages,
|
|
176
|
+
promptBytes: f.promptTokens * 4,
|
|
177
|
+
renderUpstreamBody: () => ({}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Conversation state reconstructed from the PRECEDING recorded dispatch in the
|
|
183
|
+
* same conversation, not simulated.
|
|
184
|
+
*
|
|
185
|
+
* A neutral state cannot validate anything that depends on cache warmth — every
|
|
186
|
+
* candidate looks cold, so a warm-cache change shows zero effect. But the
|
|
187
|
+
* ledger does carry what the previous dispatch actually did, so warmth is
|
|
188
|
+
* recoverable: `cacheWarmSlug` is the slug it served, `lastPromptTokens` its
|
|
189
|
+
* prompt size. Deriving state from the RECORDED outcome rather than the
|
|
190
|
+
* replayed one also stops replay error compounding down a conversation.
|
|
191
|
+
*
|
|
192
|
+
* Still not modelled: `stickyUntilTurn`, which was never persisted per turn, so
|
|
193
|
+
* the hysteresis hold window remains absent.
|
|
194
|
+
*/
|
|
195
|
+
function stateOf(row: Row, prior: PriorTurn | undefined): ConversationState {
|
|
196
|
+
return {
|
|
197
|
+
key: row.conversation_key,
|
|
198
|
+
sessionId: `omp-${row.conversation_key}`,
|
|
199
|
+
turn: row.turn,
|
|
200
|
+
currentSlug: prior?.slug ?? null,
|
|
201
|
+
currentTier: (prior?.tier as Tier | undefined) ?? null,
|
|
202
|
+
stickyUntilTurn: 0,
|
|
203
|
+
escalations: 0,
|
|
204
|
+
spentUsd: prior?.spentUsd ?? 0,
|
|
205
|
+
lastPromptTokens: prior?.promptTokens ?? 0,
|
|
206
|
+
cacheWarmSlug: prior?.cachedTokens !== undefined && prior.cachedTokens > 0 ? prior.slug : null,
|
|
207
|
+
cacheWarmAtMs: prior?.atMs ?? 0,
|
|
208
|
+
contextVersion: null,
|
|
209
|
+
contextFetchedAtMs: 0,
|
|
210
|
+
updatedAtMs: prior?.atMs ?? 0,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface PriorTurn {
|
|
215
|
+
slug: string | null;
|
|
216
|
+
tier: string;
|
|
217
|
+
promptTokens: number;
|
|
218
|
+
cachedTokens: number;
|
|
219
|
+
spentUsd: number;
|
|
220
|
+
atMs: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Re-prices a decision against the tokens the turn ACTUALLY used, via the real
|
|
225
|
+
* `computeCost` so price tiers, the cache split and reasoning/request fees are
|
|
226
|
+
* handled exactly as they are live.
|
|
227
|
+
*
|
|
228
|
+
* Deliberately NOT the router's own forecast: `candidates.ts` hardcodes
|
|
229
|
+
* `cacheHitRate: 0`, so forecasts overstate absolute cost ~2.8x. Pricing both
|
|
230
|
+
* variants off recorded usage keeps the delta apples-to-apples and grounded.
|
|
231
|
+
*/
|
|
232
|
+
function repriceUsd(model: CatalogModel | undefined, usage: UsageCounts): number {
|
|
233
|
+
if (model === undefined) return 0;
|
|
234
|
+
return computeCost(model, usage).total;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const DEAD_UPSTREAM: UpstreamClient = {
|
|
238
|
+
dispatch: () => Promise.reject(new Error("replay is offline")),
|
|
239
|
+
complete: () => Promise.reject(new Error("replay is offline")),
|
|
240
|
+
fetchModels: () => Promise.reject(new Error("replay is offline")),
|
|
241
|
+
fetchModelsForUser: () => Promise.reject(new Error("replay is offline")),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const args = parseArgs(process.argv.slice(2));
|
|
245
|
+
const baseCfg = await loadConfig();
|
|
246
|
+
// Compaction cannot be re-planned without messages; see the header.
|
|
247
|
+
const forced = ["compaction.enabled=false"];
|
|
248
|
+
const cfgA = withOverrides(baseCfg, [...forced, ...args.setA]);
|
|
249
|
+
const cfgB = withOverrides(baseCfg, [...forced, ...args.setB]);
|
|
250
|
+
|
|
251
|
+
const dbPath = args.db !== "" ? args.db : baseCfg.ledger.path;
|
|
252
|
+
const db = new Database(dbPath, { readonly: true });
|
|
253
|
+
const catalog = createCatalog(cfgA, DEAD_UPSTREAM, db);
|
|
254
|
+
const snapshot = catalog.peek();
|
|
255
|
+
if (snapshot === null) {
|
|
256
|
+
console.error(`no cached catalog in ${dbPath}; run the router once so it populates catalog_cache`);
|
|
257
|
+
process.exit(2);
|
|
258
|
+
}
|
|
259
|
+
const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
|
|
260
|
+
const ledger = createLedger(db, cfgA);
|
|
261
|
+
|
|
262
|
+
const predicate = args.where === "" ? "" : ` AND (${args.where})`;
|
|
263
|
+
// Newest-first to honour --limit, then flipped to chronological so each row can
|
|
264
|
+
// see the dispatch that preceded it in its conversation.
|
|
265
|
+
const rows = (
|
|
266
|
+
db
|
|
267
|
+
.query(
|
|
268
|
+
`SELECT id, conversation_key, turn, requested_model, harness_id, served_slug, tier, features, usage, reported_usd, predicted_usd, created_at_ms
|
|
269
|
+
FROM ledger
|
|
270
|
+
WHERE features IS NOT NULL AND wasted = 0${predicate}
|
|
271
|
+
ORDER BY created_at_ms DESC LIMIT ?`,
|
|
272
|
+
)
|
|
273
|
+
.all(args.limit) as Row[]
|
|
274
|
+
).reverse();
|
|
275
|
+
|
|
276
|
+
if (rows.length === 0) {
|
|
277
|
+
console.error("no rows matched; widen --where or --limit");
|
|
278
|
+
process.exit(2);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Profile resolution mirrors router/index.ts, which does not export it. */
|
|
282
|
+
function profileOf(cfg: RouterConfig, requested: string) {
|
|
283
|
+
const exact = cfg.profiles.find((p) => p.id === requested);
|
|
284
|
+
if (exact !== undefined) return exact;
|
|
285
|
+
const first = cfg.profiles[0];
|
|
286
|
+
if (first === undefined) throw new Error("no router profiles configured");
|
|
287
|
+
return first;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface Outcome {
|
|
291
|
+
tier: Tier;
|
|
292
|
+
slug: string;
|
|
293
|
+
usd: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function run(cfg: RouterConfig, row: Row, usage: UsageCounts, prior: PriorTurn | undefined): Outcome {
|
|
297
|
+
const f = featuresOf(row, usage.promptTokens);
|
|
298
|
+
const req = requestOf(row, f);
|
|
299
|
+
const decision: Decision = select({
|
|
300
|
+
req,
|
|
301
|
+
features: f,
|
|
302
|
+
classification: scoreHeuristic(f, cfg),
|
|
303
|
+
profile: profileOf(cfg, row.requested_model),
|
|
304
|
+
state: stateOf(row, prior),
|
|
305
|
+
snapshot,
|
|
306
|
+
ledger,
|
|
307
|
+
cfg,
|
|
308
|
+
nowMs: Date.now(),
|
|
309
|
+
});
|
|
310
|
+
return { tier: decision.tier, slug: decision.slug, usd: repriceUsd(bySlug.get(decision.slug), usage) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const tallyA = new Map<string, number>();
|
|
314
|
+
const tallyB = new Map<string, number>();
|
|
315
|
+
const tallyRec = new Map<string, number>();
|
|
316
|
+
const tierA = new Map<string, number>();
|
|
317
|
+
const tierB = new Map<string, number>();
|
|
318
|
+
const tierRec = new Map<string, number>();
|
|
319
|
+
let usdA = 0;
|
|
320
|
+
let usdB = 0;
|
|
321
|
+
let usdRec = 0;
|
|
322
|
+
let fidelitySlug = 0;
|
|
323
|
+
let fidelityTier = 0;
|
|
324
|
+
let comparable = 0;
|
|
325
|
+
const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
|
|
326
|
+
const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
|
|
327
|
+
|
|
328
|
+
// Carries the RECORDED outcome of each conversation's previous dispatch forward,
|
|
329
|
+
// so cache warmth and the prior slug are real rather than assumed absent.
|
|
330
|
+
const priorByConv = new Map<string, PriorTurn>();
|
|
331
|
+
|
|
332
|
+
for (const row of rows) {
|
|
333
|
+
const u = JSON.parse(row.usage) as UsageCounts;
|
|
334
|
+
if (!(u.promptTokens > 0)) continue;
|
|
335
|
+
const prior = priorByConv.get(row.conversation_key);
|
|
336
|
+
const a = run(cfgA, row, u, prior);
|
|
337
|
+
const b = run(cfgB, row, u, prior);
|
|
338
|
+
priorByConv.set(row.conversation_key, {
|
|
339
|
+
slug: row.served_slug,
|
|
340
|
+
tier: row.tier,
|
|
341
|
+
promptTokens: u.promptTokens,
|
|
342
|
+
cachedTokens: u.cachedTokens,
|
|
343
|
+
spentUsd: (prior?.spentUsd ?? 0) + (row.reported_usd ?? row.predicted_usd),
|
|
344
|
+
atMs: row.created_at_ms,
|
|
345
|
+
});
|
|
346
|
+
bump(tallyA, a.slug);
|
|
347
|
+
bump(tallyB, b.slug);
|
|
348
|
+
bump(tierA, a.tier);
|
|
349
|
+
bump(tierB, b.tier);
|
|
350
|
+
// The recorded outcome: what the router ACTUALLY did, under whatever code and
|
|
351
|
+
// config were live then. This is the yardstick for fidelity, and it is also
|
|
352
|
+
// how a shipped classifier change shows up — replay runs current code.
|
|
353
|
+
if (row.served_slug !== null) bump(tallyRec, row.served_slug);
|
|
354
|
+
bump(tierRec, row.tier);
|
|
355
|
+
usdA += a.usd;
|
|
356
|
+
usdB += b.usd;
|
|
357
|
+
usdRec += row.reported_usd ?? row.predicted_usd;
|
|
358
|
+
comparable++;
|
|
359
|
+
if (row.served_slug !== null && row.served_slug === a.slug) fidelitySlug++;
|
|
360
|
+
if (row.tier === a.tier) fidelityTier++;
|
|
361
|
+
if (a.slug !== b.slug || a.tier !== b.tier) {
|
|
362
|
+
flips.push({ id: row.id.slice(0, 8), tier: `${a.tier}->${b.tier}`, from: a.slug, to: b.slug, delta: b.usd - a.usd });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const pct = (n: number, d: number) => (d === 0 ? "0.0" : ((100 * n) / d).toFixed(1));
|
|
367
|
+
console.log(`\nreplayed ${comparable} dispatches from ${dbPath}`);
|
|
368
|
+
console.log(`variant A overrides: ${args.setA.length ? args.setA.join(" ") : "(config as-is)"}`);
|
|
369
|
+
console.log(`variant B overrides: ${args.setB.length ? args.setB.join(" ") : "(none — A and B identical)"}`);
|
|
370
|
+
console.log(`\nFIDELITY vs what actually ran:`);
|
|
371
|
+
console.log(` same model ${fidelitySlug}/${comparable} (${pct(fidelitySlug, comparable)}%) same tier ${fidelityTier}/${comparable} (${pct(fidelityTier, comparable)}%)`);
|
|
372
|
+
console.log(" Divergence is expected where code has changed since those rows were served");
|
|
373
|
+
console.log(" (replay runs CURRENT code); the rest is the unmodelled neutral state.");
|
|
374
|
+
console.log(" Low fidelity => treat the A/B delta below as weak evidence.");
|
|
375
|
+
|
|
376
|
+
function table(label: string, rec: Map<string, number>, A: Map<string, number>, B: Map<string, number>) {
|
|
377
|
+
const keys = [...new Set([...rec.keys(), ...A.keys(), ...B.keys()])].sort((x, y) => (B.get(y) ?? 0) - (B.get(x) ?? 0));
|
|
378
|
+
console.log(`\n${label.padEnd(32)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
|
|
379
|
+
for (const k of keys) {
|
|
380
|
+
const r = rec.get(k) ?? 0;
|
|
381
|
+
const a = A.get(k) ?? 0;
|
|
382
|
+
const b = B.get(k) ?? 0;
|
|
383
|
+
const d = b - a;
|
|
384
|
+
console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(a).padStart(7)}${String(b).padStart(7)}${(d > 0 ? `+${d}` : String(d)).padStart(7)}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
table("tier", tierRec, tierA, tierB);
|
|
388
|
+
table("model", tallyRec, tallyA, tallyB);
|
|
389
|
+
|
|
390
|
+
console.log(`\nspend, re-priced on RECORDED usage via the real computeCost:`);
|
|
391
|
+
console.log(` actual (billed) $${usdRec.toFixed(4)} per dispatch $${(usdRec / comparable).toFixed(5)}`);
|
|
392
|
+
console.log(` A $${usdA.toFixed(4)} per dispatch $${(usdA / comparable).toFixed(5)}`);
|
|
393
|
+
console.log(` B $${usdB.toFixed(4)} per dispatch $${(usdB / comparable).toFixed(5)}`);
|
|
394
|
+
const delta = usdB - usdA;
|
|
395
|
+
console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (usdA || 1)).toFixed(1)}%`})`);
|
|
396
|
+
console.log(`\ndecisions changed: ${flips.length}/${comparable} (${pct(flips.length, comparable)}%)`);
|
|
397
|
+
if (args.verbose) {
|
|
398
|
+
for (const f of flips.slice(0, 40)) {
|
|
399
|
+
console.log(` ${f.id} ${f.tier.padEnd(22)} ${f.from} -> ${f.to} ${f.delta >= 0 ? "+" : ""}$${f.delta.toFixed(5)}`);
|
|
400
|
+
}
|
|
401
|
+
if (flips.length > 40) console.log(` … ${flips.length - 40} more`);
|
|
402
|
+
}
|
|
403
|
+
db.close();
|