auto-model-router 0.3.3 → 0.3.4

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.3.3",
10
+ "version": "0.3.4",
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.3.3",
17
+ "version": "0.3.4",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -709,6 +709,7 @@ event has already arrived is treated as a completed turn, not an error.
709
709
  | `holdTurnsAfterEscalation` | `4` | Hold longer after an escalation. |
710
710
  | `switchMargin` | `1.3` | Switching must beat the warm-cache discount by this factor. Lower = switch away from a warm model more readily. |
711
711
  | `switchHorizonTurns` | `1` | Turns the stay/switch comparison is amortised over: `H × stayWarm` vs `switchCold + (H − 1) × newWarm`. `1` is the one-turn comparison, which can keep a dear model warm indefinitely when the cheaper winner is itself dear cold; a small `H` lets a switch that pays for itself within a few turns go ahead. |
712
+ | `confirmUpgradesBelowConfidence` | `0.6` | A heuristic tier upgrade classified below this confidence waits one turn while the current model's cache is warm; a second consecutive upgrade classification confirms it. Escalations, explicit high reasoning and failing tool loops bypass the wait. `0` disables. Measured: 65 of 67 moderate→hard upgrades in a week bounced back within 3 turns, each paying a cold hard-tier read of a ~120k prompt. |
712
713
  | `cacheWarmTtlMs` | `300000` (5 min) | How long a model's prompt cache is considered warm. |
713
714
  | `maxDowngradePerTurn` | `1` | Max tiers a turn may drop in one step (avoids quality cliffs). |
714
715
  | `breakHoldOnMechanical` | `false` | Let a tool-result continuation that classifies *below* the held tier escape the hold (still bounded by `maxDowngradePerTurn`). Worth enabling when the held tier is expensive. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -232,6 +232,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
232
232
  { path: "hysteresis.holdTurnsAfterEscalation", label: "Hold turns after escalation", kind: "number", min: 0 },
233
233
  { path: "hysteresis.switchMargin", label: "Switch margin", kind: "number", min: 0 },
234
234
  { path: "hysteresis.switchHorizonTurns", label: "Switch horizon", kind: "number", min: 1, hint: "turns amortised" },
235
+ { path: "hysteresis.confirmUpgradesBelowConfidence", label: "Confirm upgrades below confidence", kind: "number", min: 0, max: 1, hint: "0=off; low-confidence tier-ups wait a turn" },
235
236
  { path: "hysteresis.cacheWarmTtlMs", label: "Cache-warm TTL", kind: "number", min: 0, hint: "ms" },
236
237
  { path: "hysteresis.maxDowngradePerTurn", label: "Max downgrade per turn", kind: "number", min: 0, hint: "tiers" },
237
238
  { path: "hysteresis.breakHoldOnMechanical", label: "Break hold on mechanical turns", kind: "boolean" },
@@ -174,6 +174,9 @@ export const DEFAULT_CONFIG: RouterConfig = {
174
174
  // 1 = the shipped one-turn comparison. Raise to amortise a switch over the
175
175
  // turns that follow it; see HysteresisConfig.switchHorizonTurns.
176
176
  switchHorizonTurns: 1,
177
+ // Low-confidence heuristic upgrades from a warm model wait one turn; see
178
+ // HysteresisConfig.confirmUpgradesBelowConfidence for the measurement.
179
+ confirmUpgradesBelowConfidence: 0.6,
177
180
  // OpenRouter sticky sessions expire in 5-10 minutes.
178
181
  cacheWarmTtlMs: 300_000,
179
182
  maxDowngradePerTurn: 1,
@@ -136,6 +136,7 @@ const hysteresis = z.strictObject({
136
136
  holdTurnsAfterEscalation: z.number().int().nonnegative().optional(),
137
137
  switchMargin: z.number().positive().optional(),
138
138
  switchHorizonTurns: z.number().int().positive().optional(),
139
+ confirmUpgradesBelowConfidence: z.number().min(0).max(1).optional(),
139
140
  cacheWarmTtlMs: z.number().nonnegative().optional(),
140
141
  maxDowngradePerTurn: z.number().int().nonnegative().optional(),
141
142
  breakHoldOnMechanical: z.boolean().optional(),
@@ -392,6 +392,19 @@ export interface HysteresisConfig {
392
392
  * dispatches per user-visible turn, so single digits are conservative.
393
393
  */
394
394
  switchHorizonTurns: number;
395
+ /**
396
+ * A heuristic tier UPGRADE whose classification confidence is below this
397
+ * waits one turn when the current model's cache is warm; a second
398
+ * consecutive upgrade classification confirms it. 0 disables. Escalations,
399
+ * explicit high reasoning and failing tool loops bypass the wait.
400
+ *
401
+ * Measured on 7 days of live traffic: 65 of 67 moderate→hard upgrades
402
+ * bounced back within 3 turns, 50 of them below 0.6 confidence, costing
403
+ * $17.90 in cold hard-tier prompt reads against $0.23 for staying warm.
404
+ * The stay/switch comparison never sees these because the warm cheap
405
+ * model is below the new tier's floor.
406
+ */
407
+ confirmUpgradesBelowConfidence: number;
395
408
  /** Assume a warm cache expires after this long. OpenRouter sticky sessions: 5-10 min. */
396
409
  cacheWarmTtlMs: number;
397
410
  /** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
@@ -102,6 +102,12 @@ function wideningOrder(tier: Tier, minTier: Tier, maxTier: Tier): Tier[] {
102
102
  return out;
103
103
  }
104
104
 
105
+ /** Evidence that an upgrade will stick: the conversation is failing or asked for deep reasoning. */
106
+ function hardSignal(f: Features): boolean {
107
+ const reasoning: string = f.requestedReasoning ?? "";
108
+ return f.lastToolFailed || f.circularToolCall || f.repeatedToolCall || reasoning === "high" || reasoning === "xhigh" || reasoning === "max";
109
+ }
110
+
105
111
  export function select(args: SelectArgs): Decision {
106
112
  const { req, features, classification, profile, state, snapshot, ledger, cfg, nowMs } = args;
107
113
  const reasons: string[] = [];
@@ -161,6 +167,42 @@ export function select(args: SelectArgs): Decision {
161
167
  // exploration (2c) and candidate building (3) so both agree on the term.
162
168
  const cacheWarm = state.cacheWarmSlug !== null && nowMs - state.cacheWarmAtMs <= cfg.hysteresis.cacheWarmTtlMs;
163
169
 
170
+ // 2a. Cache-aware upgrade confirmation. A low-confidence heuristic upgrade
171
+ // from a warm model waits one turn; the next turn's classification
172
+ // confirms or forgets it. Measured on 7 days of live traffic: 65 of 67
173
+ // moderate→hard upgrades bounced back within 3 turns, 50 of them below
174
+ // 0.6 confidence, and each paid a cold hard-tier read of a ~120k prompt
175
+ // ($17.90 in total against $0.23 for staying warm). Step 4's stay/switch
176
+ // comparison never sees these — the warm cheap model is below the new
177
+ // tier's floor, so it is not a candidate there. Escalations, explicit
178
+ // high reasoning and failing tool loops bypass the wait: those are the
179
+ // upgrades that stick.
180
+ let upgradeDeferred: Tier | null = null;
181
+ const confirmBelow = cfg.hysteresis.confirmUpgradesBelowConfidence;
182
+ if (
183
+ confirmBelow > 0 &&
184
+ cls.source === "heuristic" &&
185
+ classification.confidence < confirmBelow &&
186
+ state.currentTier !== null &&
187
+ state.currentSlug !== null &&
188
+ tierIdx(effective) > tierIdx(clampTier(state.currentTier)) &&
189
+ cacheWarm &&
190
+ state.cacheWarmSlug === state.currentSlug &&
191
+ (args.excludeSlugs === undefined || args.excludeSlugs.length === 0) &&
192
+ !hardSignal(features)
193
+ ) {
194
+ const held = clampTier(state.currentTier);
195
+ if (state.upgradeDeferredTier !== undefined && state.upgradeDeferredTier !== null) {
196
+ reasons.push(`upgrade ${held} → ${effective} confirmed: classified above ${held} on consecutive turns`);
197
+ } else {
198
+ reasons.push(
199
+ `upgrade ${held} → ${effective} deferred one turn: heuristic confidence ${classification.confidence.toFixed(2)} < ${confirmBelow} with ${state.currentSlug} warm`,
200
+ );
201
+ upgradeDeferred = effective;
202
+ effective = held;
203
+ }
204
+ }
205
+
164
206
  // 2b. Context compaction: shrink stale tool output before dispatch when the
165
207
  // prompt exceeds the token budget (or would overflow the profile window).
166
208
  // Deterministic and content-only (never removes a message), so downstream
@@ -531,5 +573,6 @@ export function select(args: SelectArgs): Decision {
531
573
  reasons,
532
574
  explored,
533
575
  budgetDowngraded,
576
+ upgradeDeferred,
534
577
  };
535
578
  }
@@ -32,6 +32,7 @@ interface Row {
32
32
  context_fetched_at_ms: number;
33
33
  compaction_plan: string | null;
34
34
  compaction_plan_tokens: number;
35
+ upgrade_deferred_tier: string | null;
35
36
  updated_at_ms: number;
36
37
  }
37
38
 
@@ -53,6 +54,7 @@ function toState(row: Row): ConversationState {
53
54
  contextFetchedAtMs: row.context_fetched_at_ms,
54
55
  compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
55
56
  compactionPlanTokens: row.compaction_plan_tokens,
57
+ upgradeDeferredTier: row.upgrade_deferred_tier as Tier | null,
56
58
  updatedAtMs: row.updated_at_ms,
57
59
  };
58
60
  }
@@ -71,10 +73,10 @@ export function createConversationStore(db: Database): ConversationStore {
71
73
  INSERT INTO conversations (
72
74
  key, session_id, turn, current_slug, current_tier, sticky_until_turn,
73
75
  last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
74
- context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, updated_at_ms
76
+ context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, upgrade_deferred_tier, updated_at_ms
75
77
  ) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
76
78
  $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
77
- $contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $updatedAtMs)
79
+ $contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $upgradeDeferredTier, $updatedAtMs)
78
80
  ON CONFLICT(key) DO UPDATE SET
79
81
  session_id = excluded.session_id,
80
82
  turn = excluded.turn,
@@ -88,6 +90,7 @@ export function createConversationStore(db: Database): ConversationStore {
88
90
  context_fetched_at_ms = excluded.context_fetched_at_ms,
89
91
  compaction_plan = excluded.compaction_plan,
90
92
  compaction_plan_tokens = excluded.compaction_plan_tokens,
93
+ upgrade_deferred_tier = excluded.upgrade_deferred_tier,
91
94
  updated_at_ms = excluded.updated_at_ms
92
95
  `);
93
96
  // Read-modify-write in JS lost money: an aborted or failed dispatch is still
@@ -137,6 +140,7 @@ export function createConversationStore(db: Database): ConversationStore {
137
140
  $lastPromptTokens: state.lastPromptTokens,
138
141
  $compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
139
142
  $compactionPlanTokens: state.compactionPlanTokens ?? 0,
143
+ $upgradeDeferredTier: state.upgradeDeferredTier ?? null,
140
144
  $cacheWarmSlug: state.cacheWarmSlug,
141
145
  $cacheWarmAtMs: state.cacheWarmAtMs,
142
146
  $contextVersion: state.contextVersion,
@@ -186,6 +186,13 @@ export interface ConversationState {
186
186
  compactionPlanTokens?: number;
187
187
  /** When that block was fetched, for the staleness TTL. */
188
188
  contextFetchedAtMs: number;
189
+ /**
190
+ * The tier a low-confidence upgrade was deferred to on the previous turn
191
+ * (`hysteresis.confirmUpgradesBelowConfidence`), or null. One-turn memory:
192
+ * every turn overwrites it, so a second consecutive upgrade classification
193
+ * confirms the switch and anything else forgets it.
194
+ */
195
+ upgradeDeferredTier?: Tier | null;
189
196
  updatedAtMs: number;
190
197
  }
191
198
 
@@ -270,6 +277,8 @@ export interface Decision {
270
277
  explored: Exploration | null;
271
278
  /** Budget guard forced a cheaper tier than the classifier asked for. */
272
279
  budgetDowngraded: boolean;
280
+ /** A low-confidence upgrade to this tier was deferred one turn to keep the warm model. */
281
+ upgradeDeferred: Tier | null;
273
282
  }
274
283
 
275
284
  /** Why a guarded probe rejected an attempt. */
@@ -567,6 +567,8 @@ export async function runTurn(
567
567
  // shrunk so the prompt cache survives and the savings compound.
568
568
  state.compactionPlan = decision.compactionPlan.length > 0 ? decision.compactionPlan : null;
569
569
  state.compactionPlanTokens = decision.compactionPlanTokens;
570
+ // One-turn memory: a deferred upgrade is confirmed or forgotten next turn.
571
+ state.upgradeDeferredTier = decision.upgradeDeferred;
570
572
  if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
571
573
  // Non-zero cache traffic is direct evidence the upstream cache exists.
572
574
  state.cacheWarmSlug = servedSlug ?? decision.slug;
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
 
20
20
  /** Bump when a migration is added; guarded below so reopening never regresses it. */
21
- const USER_VERSION = 16;
21
+ const USER_VERSION = 17;
22
22
 
23
23
  const MIGRATIONS = `
24
24
  CREATE TABLE IF NOT EXISTS catalog_cache (
@@ -243,6 +243,12 @@ ALTER TABLE conversations ADD COLUMN compaction_plan_tokens INTEGER NOT NULL DEF
243
243
  DELETE FROM token_calibration;
244
244
  `;
245
245
 
246
+ // v17: one-turn memory of a deferred low-confidence upgrade
247
+ // (hysteresis.confirmUpgradesBelowConfidence).
248
+ const MIGRATE_V17 = `
249
+ ALTER TABLE conversations ADD COLUMN upgrade_deferred_tier TEXT;
250
+ `;
251
+
246
252
  // v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
247
253
  // BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
248
254
  // whole new table, created idempotently by the MIGRATIONS block above, so there
@@ -288,6 +294,7 @@ export function openDb(path: string): Database {
288
294
  if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
289
295
  if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
290
296
  if (!convCols.some((c) => c.name === "compaction_plan_tokens")) db.exec(MIGRATE_V16);
297
+ if (!convCols.some((c) => c.name === "upgrade_deferred_tier")) db.exec(MIGRATE_V17);
291
298
  db.exec(`PRAGMA user_version = ${USER_VERSION}`);
292
299
  }
293
300
  return db;
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
70
70
  escalateOnLengthStop: false,
71
71
  ...escalation,
72
72
  },
73
- hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1 },
73
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
74
74
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
75
75
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
76
76
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
@@ -160,6 +160,7 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
160
160
  reasons: ["test decision"],
161
161
  explored: null,
162
162
  budgetDowngraded: false,
163
+ upgradeDeferred: null,
163
164
  };
164
165
  }
165
166
 
@@ -996,3 +996,57 @@ describe("compaction.replanGrowthRatio (review 2026-09-05 §7)", () => {
996
996
  });
997
997
  });
998
998
 
999
+
1000
+ describe("hysteresis.confirmUpgradesBelowConfidence", () => {
1001
+ // A low-confidence heuristic upgrade from a warm model waits one turn.
1002
+ // Measured: 65 of 67 moderate→hard upgrades in a week bounced back within
1003
+ // 3 turns, each paying a cold hard-tier read of a ~120k prompt.
1004
+ const warmSlug = run({ tier: "moderate" }).slug;
1005
+ function upgrade(opts: { confidence?: number; source?: "heuristic" | "escalation"; st?: Partial<ConversationState>; cfg?: RouterConfig; lastToolFailed?: boolean }) {
1006
+ const cfg = opts.cfg ?? BASE;
1007
+ const req = request("now rework the whole scheduler");
1008
+ const base = extractFeatures(req, 120_000);
1009
+ const features = opts.lastToolFailed === true ? { ...base, lastToolFailed: true } : base;
1010
+ const heuristic = scoreHeuristic(features, cfg);
1011
+ return select({
1012
+ req,
1013
+ features,
1014
+ classification: { ...heuristic, tier: "hard", confidence: opts.confidence ?? 0.45, source: opts.source ?? "heuristic" },
1015
+ profile: PROFILE,
1016
+ state: state({ turn: 4, currentTier: "moderate", currentSlug: warmSlug, cacheWarmSlug: warmSlug, cacheWarmAtMs: Date.now(), lastPromptTokens: 110_000, ...opts.st }),
1017
+ snapshot: SNAPSHOT,
1018
+ ledger: null,
1019
+ cfg,
1020
+ nowMs: Date.now(),
1021
+ });
1022
+ }
1023
+
1024
+ test("a low-confidence upgrade from a warm model is deferred to the held tier", () => {
1025
+ const d = upgrade({});
1026
+ expect(d.tier).toBe("moderate");
1027
+ expect(d.upgradeDeferred).toBe("hard");
1028
+ expect(d.reasons.some((r) => r.includes("upgrade moderate → hard deferred one turn"))).toBe(true);
1029
+ });
1030
+
1031
+ test("a second consecutive upgrade classification confirms it", () => {
1032
+ const d = upgrade({ st: { upgradeDeferredTier: "hard" } });
1033
+ expect(d.tier).toBe("hard");
1034
+ expect(d.upgradeDeferred).toBeNull();
1035
+ expect(d.reasons.some((r) => r.includes("upgrade moderate → hard confirmed"))).toBe(true);
1036
+ });
1037
+
1038
+ test("confident classifications, cold caches, escalations, failing tools and the off switch all upgrade at once", () => {
1039
+ expect(upgrade({ confidence: 0.9 }).tier).toBe("hard");
1040
+ expect(upgrade({ st: { cacheWarmAtMs: Date.now() - 3_600_000 } }).tier).toBe("hard");
1041
+ expect(upgrade({ source: "escalation" }).tier).toBe("hard");
1042
+ expect(upgrade({ lastToolFailed: true }).tier).toBe("hard");
1043
+ const off: RouterConfig = { ...BASE, hysteresis: { ...BASE.hysteresis, confirmUpgradesBelowConfidence: 0 } };
1044
+ expect(upgrade({ cfg: off }).tier).toBe("hard");
1045
+ for (const d of [upgrade({ confidence: 0.9 }), upgrade({ source: "escalation" })]) expect(d.upgradeDeferred).toBeNull();
1046
+ });
1047
+
1048
+ test("a downgrade or a same-tier turn is never deferred", () => {
1049
+ const d = run({ tier: "simple", st: state({ turn: 4, currentTier: "moderate", currentSlug: warmSlug, cacheWarmSlug: warmSlug, cacheWarmAtMs: Date.now() }) });
1050
+ expect(d.upgradeDeferred).toBeNull();
1051
+ });
1052
+ });
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
3
3
  import { loadConfig } from "../src/config/load.ts";
4
4
  import { createLedger, LATENCY_WINDOW_ROWS } from "../src/cost/ledger.ts";
5
5
  import { EMPTY_USAGE, type LedgerEntry } from "../src/cost/types.ts";
6
+ import { createConversationStore } from "../src/router/state.ts";
6
7
  import { openDb } from "../src/util/sqlite.ts";
7
8
 
8
9
  const cfg = loadConfig({});
@@ -244,11 +245,24 @@ describe("v4 migration", () => {
244
245
  }
245
246
  });
246
247
 
247
- test("schema is at user_version 16", () => {
248
+ test("a deferred upgrade tier survives a save/load round trip", () => {
249
+ const db = openDb(":memory:");
250
+ const store = createConversationStore(db);
251
+ const st = store.load("conv-defer");
252
+ st.upgradeDeferredTier = "hard";
253
+ store.save(st);
254
+ expect(store.load("conv-defer").upgradeDeferredTier).toBe("hard");
255
+ st.upgradeDeferredTier = null;
256
+ store.save(st);
257
+ expect(store.load("conv-defer").upgradeDeferredTier).toBeNull();
258
+ db.close();
259
+ });
260
+
261
+ test("schema is at user_version 17", () => {
248
262
  const db = openDb(":memory:");
249
263
  try {
250
264
  const row = db.query("PRAGMA user_version").get() as { user_version: number };
251
- expect(row.user_version).toBe(16);
265
+ expect(row.user_version).toBe(17);
252
266
  } finally {
253
267
  db.close();
254
268
  }
package/test/turn.test.ts CHANGED
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
70
70
  escalateOnLengthStop: false,
71
71
  ...escalation,
72
72
  },
73
- hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1 },
73
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
74
74
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
75
75
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
76
76
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
@@ -160,6 +160,7 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
160
160
  reasons: ["test decision"],
161
161
  explored: null,
162
162
  budgetDowngraded: false,
163
+ upgradeDeferred: null,
163
164
  };
164
165
  }
165
166