Skip to main content

firstpass_core/
config.rs

1//! Routing configuration (SPEC §8.4) — declarative, agent-first.
2//!
3//! Config is the policy an operator (or agent) hands Firstpass: an ordered list of routes,
4//! each matching a slice of traffic to a mode, a model ladder, and gates; plus budget caps
5//! and escalation rules. The first matching route wins, so specific routes go first and a
6//! bare `match = {}` catch-all goes last.
7
8use crate::error::{Error, Result};
9use crate::features::{Features, TaskKind};
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12
13/// Serving mode for a route (SPEC glossary).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Mode {
17    /// Gate before serving; escalate on fail. The output is proven before the caller sees it.
18    Enforce,
19    /// Serve immediately, gate asynchronously — verdicts feed learning only. Zero added latency,
20    /// zero risk; the default for onboarding unfamiliar traffic.
21    Observe,
22}
23
24/// Named routing-mode presets: coherent (accuracy/cost/latency) constraint bundles layered
25/// on top of the existing escalation knobs. A mode is a **resolver** over existing config,
26/// not a new engine — it overrides only the knobs it has an opinion on; everything else
27/// comes from the route/global config unchanged.
28///
29/// `Balanced` (the default) is a strict no-op: with no mode set anywhere, behaviour is
30/// byte-identical to existing behaviour. All other modes are opt-in overlays.
31///
32/// Set per-request via the `x-firstpass-mode` header, per-route via `routing_mode = "..."`,
33/// or globally via the `FIRSTPASS_MODE_PROFILE` env var.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum RoutingMode {
37    /// Forward without routing or gating; verdicts are asynchronous learning signals only.
38    /// Maps to the existing observe passthrough path. Zero added latency, zero quality proof.
39    Observe,
40    /// Prefer cheapest start; no speculative prefetch.
41    /// Tradeoff: lowest token spend; can serve lower quality on hard or heavy traffic.
42    Cost,
43    /// Today's default — bandit start, configured thresholds, configured speculation.
44    /// **This preset is a strict no-op: byte-identical to existing behaviour when applied.**
45    #[default]
46    Balanced,
47    /// One extra escalation rung allowed; serial (no speculative prefetch waste).
48    /// Tradeoff: higher quality ceiling at higher cost; still bounded by gate verification.
49    Quality,
50    /// `speculation = 1` always: always prefetch one rung ahead to cut p95 latency.
51    /// Tradeoff: pays 1 wasted speculative call when the cheap rung passes.
52    Latency,
53    /// Start at the top (most expensive) ladder rung; verification as insurance.
54    /// Tradeoff: highest quality, highest cost per call; bandit is bypassed; savings are minimal.
55    Max,
56}
57
58impl RoutingMode {
59    /// Lowercase string name, for use in headers and trace fields.
60    #[must_use]
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Observe => "observe",
64            Self::Cost => "cost",
65            Self::Balanced => "balanced",
66            Self::Quality => "quality",
67            Self::Latency => "latency",
68            Self::Max => "max",
69        }
70    }
71
72    /// All named modes in display order, for discovery surfaces (capabilities, MCP, CLI).
73    pub const ALL: &'static [Self] = &[
74        Self::Observe,
75        Self::Cost,
76        Self::Balanced,
77        Self::Quality,
78        Self::Latency,
79        Self::Max,
80    ];
81
82    /// The knob overrides this mode applies on top of the route/global escalation config.
83    /// `None` fields are left unchanged — modes only override where they have an opinion.
84    ///
85    /// **`Balanced` returns all `None`/`false`: it is a strict no-op by invariant.**
86    #[must_use]
87    pub fn preset(self) -> ModePreset {
88        match self {
89            // Observe: handled as a passthrough redirect before EnforceCtx; preset is unused.
90            Self::Observe => ModePreset {
91                speculation: None,
92                max_rungs_delta: None,
93                start_at_top: false,
94                description: "shadow mode: forward without gating; verdicts are async learning signals only",
95                tradeoff: "zero added latency, zero quality proof; use to observe before enforcing",
96            },
97            // Cost: kill speculative spend; let the bandit start cheap.
98            Self::Cost => ModePreset {
99                speculation: Some(0),
100                max_rungs_delta: None,
101                start_at_top: false,
102                description: "no speculative prefetch; bandit start on cheapest rung",
103                tradeoff: "lowest token spend; can serve lower quality on hard or heavy traffic",
104            },
105            // Balanced: all None — strict no-op so existing behaviour is preserved.
106            Self::Balanced => ModePreset {
107                speculation: None,
108                max_rungs_delta: None,
109                start_at_top: false,
110                description: "bandit start, configured thresholds and speculation (default — no behavioral change)",
111                tradeoff: "today's tuned balance; add a mode only when you need a different point",
112            },
113            // Quality: one more rung; no speculative waste.
114            Self::Quality => ModePreset {
115                speculation: Some(0),
116                max_rungs_delta: Some(1),
117                start_at_top: false,
118                description: "one extra escalation rung; serial (no speculative waste)",
119                tradeoff: "higher quality ceiling at higher cost; still bounded by gate verification",
120            },
121            // Latency: speculation=1 always; pay 1 wasted call to cut p95.
122            Self::Latency => ModePreset {
123                speculation: Some(1),
124                max_rungs_delta: None,
125                start_at_top: false,
126                description: "speculation=1: always prefetch one rung ahead to cut p95 latency",
127                tradeoff: "pays 1 wasted speculative call when cheap rung passes; speculation_band is overridden",
128            },
129            // Max: jump to top rung immediately; verification as insurance.
130            Self::Max => ModePreset {
131                speculation: Some(0),
132                max_rungs_delta: None,
133                start_at_top: true,
134                description: "start at the top (most expensive) ladder rung; verification as insurance",
135                tradeoff: "highest quality, highest cost per call; bandit bypassed; savings minimal",
136            },
137        }
138    }
139}
140
141/// Knob overrides a [`RoutingMode`] applies on top of the route/global escalation config.
142/// Only non-`None` fields override; absent fields leave the config value unchanged.
143///
144/// `Balanced` returns all `None`/`false` — it is a strict no-op.
145#[derive(Debug, Clone, Copy)]
146pub struct ModePreset {
147    /// Override for `escalation.speculation`. `None` = no override (config value unchanged).
148    pub speculation: Option<u32>,
149    /// Additive delta applied to `max_rungs_per_request`. `None` = no change.
150    /// Applied as `(current + delta).max(1)`.
151    pub max_rungs_delta: Option<i32>,
152    /// When `true`, start at the last ladder rung (top quality), bypassing the bandit.
153    pub start_at_top: bool,
154    /// One-line description of what this mode targets.
155    pub description: &'static str,
156    /// Honest tradeoff: what you gain and what you give up.
157    pub tradeoff: &'static str,
158}
159
160/// Top-level configuration document.
161#[derive(Debug, Clone, Default, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct Config {
164    /// Ordered routes; first match wins.
165    #[serde(rename = "route", default)]
166    pub routes: Vec<Route>,
167    /// Spend caps.
168    #[serde(default)]
169    pub budget: Budget,
170    /// Escalation limits and promotion rules.
171    #[serde(default)]
172    pub escalation: Escalation,
173    /// Served-failure guardrail (ADR 0009 D3). Absent (the default) means nothing watches the
174    /// served-failure rate and behaviour is byte-identical to before.
175    #[serde(default)]
176    pub guardrail: Option<crate::guardrail::Guardrail>,
177    /// Seconds a demoted route stays demoted before the guardrail will consider it again.
178    /// Deliberately long: re-promoting on one good window makes routing flap, and every flap is
179    /// a visible behaviour change for real users.
180    #[serde(default = "default_guardrail_cooldown")]
181    pub guardrail_cooldown_secs: i64,
182    /// User-defined subprocess gates (SPEC §8.1), referenced by `id` from a route's `gates` /
183    /// `deferred_gates`. Declared as `[[gate]]` sections in TOML.
184    #[serde(rename = "gate", default)]
185    pub gate_defs: Vec<GateDef>,
186    /// Per-deployment price overrides, replacing the built-in defaults for the named models.
187    /// List prices drift and enterprise contracts differ — savings math is only honest when the
188    /// operator can pin THEIR prices. Declared as `[[price]]` sections in TOML.
189    #[serde(rename = "price", default)]
190    pub price_defs: Vec<PriceDef>,
191    /// Extra model providers a ladder can route to, beyond the built-in `anthropic` / `openai`.
192    /// Any OpenAI-compatible endpoint (Groq, Together, Fireworks, DeepSeek, Mistral, xAI,
193    /// OpenRouter, Ollama, vLLM, Azure, …) is one `[[provider]]` entry — no rebuild. Declared as
194    /// `[[provider]]` sections; a ladder rung is then `<id>/<model>`.
195    #[serde(rename = "provider", default)]
196    pub providers: Vec<ProviderDef>,
197}
198
199/// The wire API a provider speaks. `anthropic` = Messages API; `openai` = Chat Completions API
200/// (the de-facto standard that nearly every hosted and open-source model host implements);
201/// `gemini` = Google's Generative Language API (`generateContent`).
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
203#[serde(rename_all = "lowercase")]
204pub enum Dialect {
205    /// Anthropic Messages API (`POST /v1/messages`).
206    Anthropic,
207    /// OpenAI Chat Completions API (`POST /v1/chat/completions`).
208    Openai,
209    /// Google Gemini Generative Language API (`POST /v1beta/models/<model>:generateContent`),
210    /// authenticated with an API key in the `x-goog-api-key` header.
211    Gemini,
212}
213
214/// How a provider call is credentialed — orthogonal to [`Dialect`] (ADR 0006): dialect shapes the
215/// request/response body, auth scheme shapes how the request is signed/credentialed. Default
216/// (`api_key_env` / BYOK header) is unchanged for every existing `[[provider]]` entry.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
218#[serde(rename_all = "snake_case")]
219pub enum AuthScheme {
220    /// API key in a header (`x-api-key`, `Authorization: Bearer`, or `x-goog-api-key`) — today's
221    /// behavior for `anthropic` / `openai` / `gemini`.
222    #[default]
223    ApiKey,
224    /// AWS SigV4 request signing (Bedrock) — credentials from `AWS_ACCESS_KEY_ID` /
225    /// `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, region-scoped.
226    AwsSigv4,
227    /// GCP OAuth2 bearer token (Vertex AI) — minted and cached by `gcp_auth` from
228    /// `GOOGLE_APPLICATION_CREDENTIALS` or the ambient environment.
229    GcpOauth,
230}
231
232/// A model provider a ladder can route to. Declared as `[[provider]]` in TOML; referenced from a
233/// ladder as `<id>/<model>` (e.g. `groq/llama-3.3-70b-versatile`).
234#[derive(Debug, Clone, Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct ProviderDef {
237    /// Ladder prefix for this provider, e.g. `"groq"`. Overrides a built-in of the same id.
238    pub id: String,
239    /// Which wire API it speaks.
240    pub dialect: Dialect,
241    /// Base URL, e.g. `"https://api.groq.com/openai"` or `"http://localhost:11434"` for Ollama.
242    /// Unused for `aws_sigv4/gcp_oauth` auth (those construct the URL from `region`/`project`).
243    #[serde(default)]
244    pub base_url: String,
245    /// Env var the API key is read from at call time, e.g. `"GROQ_API_KEY"`. Omit for a keyless
246    /// endpoint (local Ollama / vLLM). Per-request BYOK headers still apply to the built-in
247    /// `anthropic` / `openai` providers.
248    #[serde(default)]
249    pub api_key_env: Option<String>,
250    /// How this provider's requests are credentialed. `api_key` (default) is byte-identical to
251    /// today; `aws_sigv4` / `gcp_oauth` are the Bedrock/Vertex auth schemes (ADR 0006).
252    #[serde(default)]
253    pub auth: AuthScheme,
254    /// Cloud region, e.g. `"us-east-1"` — required for `aws_sigv4` (Bedrock) and `gcp_oauth`
255    /// (Vertex).
256    #[serde(default)]
257    pub region: Option<String>,
258    /// GCP project id — required for `gcp_oauth` (Vertex).
259    #[serde(default)]
260    pub project: Option<String>,
261}
262
263/// A per-deployment price override for one model (`provider/model`), USD per 1M tokens.
264#[derive(Debug, Clone, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct PriceDef {
267    /// The ladder id this price applies to, e.g. `"anthropic/claude-haiku-4-5"`.
268    pub model: String,
269    /// USD per 1M input (prompt) tokens.
270    pub input_per_mtok: f64,
271    /// USD per 1M output (completion) tokens.
272    pub output_per_mtok: f64,
273}
274
275/// A user-defined gate (SPEC §8.1). Exactly one kind per definition:
276/// - **subprocess** (`cmd`): any executable that reads the candidate as JSON on **stdin** (never
277///   argv — injection-resistant) and emits `{"verdict":"pass|fail|abstain", ...}` on stdout.
278/// - **judge** (`judge`): a native LLM-judge gate that grades the candidate against a rubric.
279/// - **consistency** (`consistency`): a self-consistency uncertainty gate that scores the
280///   candidate by agreement with k fresh samples of the same model (Wang et al. 2022).
281/// - **schema** (`schema`): validates the candidate (parsed as JSON) against a JSON-Schema
282///   subset (top-level `type` / `required` / per-property `type`).
283#[derive(Debug, Clone, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct GateDef {
286    /// The id a route references this gate by (must be unique and not shadow a built-in gate id).
287    pub id: String,
288    /// Subprocess command: program first, then its args — e.g. `["pytest", "-q"]`. Set this **or**
289    /// `judge` / `consistency` / `schema`, not both.
290    #[serde(default)]
291    pub cmd: Vec<String>,
292    /// Hard timeout in milliseconds for a subprocess gate; it abstains (`timeout`) if the process
293    /// runs longer.
294    #[serde(default = "default_gate_timeout_ms")]
295    pub timeout_ms: u64,
296    /// LLM-judge configuration. Set this **or** `cmd` / `consistency` / `schema`, not both.
297    #[serde(default)]
298    pub judge: Option<JudgeDef>,
299    /// Self-consistency configuration. Set this **or** `cmd` / `judge` / `schema`, not both.
300    #[serde(default)]
301    pub consistency: Option<ConsistencyDef>,
302    /// JSON-Schema (subset) the candidate must satisfy. Set this **or** `cmd` / `judge` /
303    /// `consistency`, not both.
304    #[serde(default)]
305    pub schema: Option<serde_json::Value>,
306    /// What an **abstain** from this gate means for serving (§7.2). `fail_open` (default): an
307    /// abstaining gate never blocks serving — availability over strictness, today's behavior.
308    /// `fail_closed`: an abstain blocks serving exactly like a `Fail` — strictness over
309    /// availability, for gates whose silence must never be mistaken for approval.
310    #[serde(default)]
311    pub on_abstain: AbstainPolicy,
312}
313
314/// Per-gate abstain policy (§7.2): what happens to serving when the gate can't produce a verdict.
315#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
316#[serde(rename_all = "snake_case")]
317pub enum AbstainPolicy {
318    /// An abstain never blocks serving (the historical behavior).
319    #[default]
320    FailOpen,
321    /// An abstain blocks serving exactly like a `Fail`.
322    FailClosed,
323}
324
325/// Configuration for a native LLM-judge gate (SPEC §8.3): a separate model grades the candidate
326/// against a rubric. The runner enforces maker ≠ checker (a model never grades its own output) and
327/// treats the candidate as data, not instructions.
328#[derive(Debug, Clone, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct JudgeDef {
331    /// The judge model, as `provider/model` (must differ from the candidate's model at runtime).
332    pub model: String,
333    /// Pass iff the judge's score ≥ this threshold, in `[0, 1]`.
334    #[serde(default = "default_judge_threshold")]
335    pub threshold: f64,
336    /// What "good" means for this route — handed to the judge as the grading rubric.
337    #[serde(default)]
338    pub rubric: Option<String>,
339}
340
341/// Configuration for a self-consistency uncertainty gate: resample the original request `k` times
342/// on the same model and score the candidate by agreement with the fresh samples.
343///
344/// Research basis: Wang et al. 2022 (self-consistency) and Farquhar et al., *Nature* 2024
345/// (semantic entropy). Answers a model reliably reproduces are far likelier correct; disagreement
346/// flags hallucination. This produces a continuous confidence score the conformal threshold
347/// machinery can calibrate.
348///
349/// **maker == checker is intentional** — unlike a judge gate, self-consistency is definitionally
350/// self-referential. This is the mechanism, not a bug.
351#[derive(Debug, Clone, Deserialize)]
352#[serde(deny_unknown_fields)]
353pub struct ConsistencyDef {
354    /// The model used for resampling, as `provider/model`. May equal the candidate's model —
355    /// self-consistency is self-referential by design.
356    pub model: String,
357    /// Number of resample calls. Must be in `[2, 8]`; defaults to `3`.
358    #[serde(default = "default_consistency_k")]
359    pub k: u32,
360    /// Pass iff the mean agreement score ≥ this threshold, in `[0, 1]`.
361    pub threshold: f64,
362}
363
364/// Default subprocess-gate timeout: 30s. Long enough for a test suite, short enough to bound the
365/// enforce-path tail.
366fn default_gate_timeout_ms() -> u64 {
367    30_000
368}
369
370/// Default judge pass threshold.
371fn default_judge_threshold() -> f64 {
372    0.7
373}
374
375/// Default self-consistency k (resample count).
376fn default_consistency_k() -> u32 {
377    3
378}
379
380/// One routing rule.
381#[derive(Debug, Clone, Deserialize)]
382#[serde(deny_unknown_fields)]
383pub struct Route {
384    /// The traffic this route claims. An empty match (`{}`) matches everything.
385    #[serde(rename = "match", default)]
386    pub match_: Match,
387    /// Serving mode.
388    pub mode: Mode,
389    /// Model ladder, cheapest first, as `provider/model` strings.
390    #[serde(default)]
391    pub ladder: Vec<String>,
392    /// Inline gates run before serving (enforce mode).
393    #[serde(default)]
394    pub gates: Vec<String>,
395    /// Gates run asynchronously after serving; their verdicts attach to the trace as a
396    /// learning signal and never block the response.
397    #[serde(default)]
398    pub deferred_gates: Vec<String>,
399    /// Per-route routing-mode preset. Overrides `FIRSTPASS_MODE_PROFILE`; overridden by the
400    /// per-request `x-firstpass-mode` header. Absent (default `None`) falls through to the
401    /// global default, then `Balanced` — byte-identical to existing behaviour.
402    #[serde(default)]
403    pub routing_mode: Option<RoutingMode>,
404    /// Percentage rollout (ADR 0009 D1): enforce only a slice of this route's matched traffic,
405    /// held constant per session so a conversation never flips arm mid-thread. Absent (the
406    /// default) enforces all matched traffic — byte-identical to existing behaviour.
407    #[serde(default)]
408    pub rollout: Option<crate::rollout::Rollout>,
409    /// Shadow scoring (ADR 0009 D2): on a sample of *observed* requests, run the ladder off the
410    /// hot path and record what would have been served and what it would have cost. Absent (the
411    /// default) leaves observe exactly as it was. Makes real model calls when enabled, which is
412    /// why `max_usd_per_day` is required rather than defaulted.
413    #[serde(default)]
414    pub shadow: Option<crate::rollout::Shadow>,
415}
416
417/// Predicate over a request's [`Features`]. Every present field is an AND-constraint; absent
418/// fields are wildcards.
419#[derive(Debug, Clone, Default, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub struct Match {
422    /// Require a specific calling agent.
423    #[serde(default)]
424    pub agent: Option<String>,
425    /// Require the subagent to be one of these (or this one, if a bare string).
426    #[serde(default)]
427    pub subagent: Option<StringOrVec>,
428    /// Require a specific task kind.
429    #[serde(default)]
430    pub task_kind: Option<TaskKind>,
431    /// Require the language to be one of these (or this one, if a bare string).
432    #[serde(default)]
433    pub language: Option<StringOrVec>,
434}
435
436impl Match {
437    /// Whether `f` satisfies every present constraint.
438    #[must_use]
439    pub fn matches(&self, f: &Features) -> bool {
440        if let Some(agent) = &self.agent
441            && f.agent.as_deref() != Some(agent.as_str())
442        {
443            return false;
444        }
445        if let Some(subs) = &self.subagent {
446            match &f.subagent {
447                Some(s) if subs.contains(s) => {}
448                _ => return false,
449            }
450        }
451        if let Some(tk) = self.task_kind
452            && f.task_kind != tk
453        {
454            return false;
455        }
456        if let Some(langs) = &self.language {
457            match &f.language {
458                Some(l) if langs.contains(l) => {}
459                _ => return false,
460            }
461        }
462        true
463    }
464}
465
466/// A field that accepts either a single string or a list of strings in TOML/JSON.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(untagged)]
469pub enum StringOrVec {
470    /// A single value.
471    One(String),
472    /// A set of values.
473    Many(Vec<String>),
474}
475
476impl StringOrVec {
477    /// Whether `needle` is contained in this value.
478    #[must_use]
479    pub fn contains(&self, needle: &str) -> bool {
480        match self {
481            StringOrVec::One(s) => s == needle,
482            StringOrVec::Many(v) => v.iter().any(|s| s == needle),
483        }
484    }
485}
486
487/// What to do when a spend cap is hit (SPEC §8.4 — never brick the customer).
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490pub enum OnExhausted {
491    /// Serve the best attempt seen so far (default).
492    #[default]
493    ServeBestAttempt,
494    /// Return a structured error instead of serving.
495    Error,
496}
497
498/// Spend caps. `None` means "no cap".
499#[derive(Debug, Clone, Default, Deserialize)]
500#[serde(deny_unknown_fields)]
501pub struct Budget {
502    /// Max USD per single request (across all rungs + gates).
503    #[serde(default)]
504    pub per_request_usd: Option<f64>,
505    /// Max USD per session.
506    #[serde(default)]
507    pub per_session_usd: Option<f64>,
508    /// Max USD per day.
509    #[serde(default)]
510    pub per_day_usd: Option<f64>,
511    /// Behaviour when a cap is reached.
512    #[serde(default)]
513    pub on_exhausted: OnExhausted,
514}
515
516/// Escalation limits and session-level promotion.
517#[derive(Debug, Clone, Deserialize)]
518#[serde(deny_unknown_fields)]
519pub struct Escalation {
520    /// Hard ceiling on rungs climbed within one request.
521    #[serde(default = "default_max_rungs")]
522    pub max_rungs_per_request: u32,
523    /// Optionally start higher for the rest of a session that keeps failing.
524    #[serde(default)]
525    pub session_promotion: Option<SessionPromotion>,
526    /// Prefetch depth: fire this many rungs ahead concurrently to trade wasted spend for latency.
527    /// `0` (default) is serial — one call at a time. The served result is identical either way.
528    #[serde(default)]
529    pub speculation: u32,
530    /// Calibrated conformal serve threshold: a rung is served iff its aggregate gate score is
531    /// `>=` this value (SPEC §10.1). `None` (default) keeps the original rule — serve iff the
532    /// aggregate gate verdict is `Pass` — byte-identical to today.
533    #[serde(default)]
534    pub serve_threshold: Option<f64>,
535    /// Online/adaptive conformal (Gibbs-Candès ACI): when set, the serve threshold is tracked
536    /// **live** from deferred feedback instead of held fixed, so served-failure stays at target under
537    /// distribution shift. `None` (default) uses the fixed `serve_threshold` above — byte-identical.
538    #[serde(default)]
539    pub adaptive: Option<AdaptiveConfig>,
540    /// Route tool-calling / multimodal requests through enforce (ADR 0005). **Default `true`**:
541    /// agent traffic — the target workload — is gated out of the box. A per-request fidelity
542    /// guard still applies: structured content only routes when every ladder rung's provider
543    /// carries it verbatim (Anthropic-dialect today); otherwise the request falls back to
544    /// transparent observe passthrough rather than risking corruption. Set `false` to restore
545    /// the pre-ADR-0005 behavior (structured requests always pass through un-gated).
546    #[serde(default = "default_enforce_structured")]
547    pub enforce_structured: bool,
548    /// UCB1 start-rung bandit: learn which rung to START the ladder on per request context, to
549    /// cut expected cost by skipping rungs that almost always fail for this context. `None`
550    /// (default) starts every request at rung 0 — byte-identical to today. Prediction may only
551    /// choose where the ladder STARTS; gating, escalation, and serving are untouched.
552    #[serde(default)]
553    pub bandit: Option<BanditConfig>,
554    /// Speculative-deferral band: when set (and the bandit has a warm gate-pass estimate for
555    /// the chosen start rung), speculative prefetch fires **only** when that estimate falls
556    /// inside `[low, high]` — the marginal zone where the next rung is *probably but not
557    /// certainly* needed, which is where parallel spend buys the most latency (speculative
558    /// cascades). Outside the band (confident pass or confident fail-through) requests run
559    /// serial and the speculative tokens are saved. `None` (default) = `speculation` applies
560    /// unconditionally, byte-identical to today. No bandit / cold context ⇒ band inapplicable
561    /// ⇒ configured `speculation` applies.
562    #[serde(default)]
563    pub speculation_band: Option<[f64; 2]>,
564    /// Epsilon-greedy start-rung exploration: randomise a fraction of start-rung choices and
565    /// record propensities so IPS/SNIPS off-policy estimates are valid. `None` (default) →
566    /// deterministic policy — byte-identical to today. See [`ExplorationConfig`].
567    #[serde(default)]
568    pub exploration: Option<ExplorationConfig>,
569    /// Shadow probe (ADR 0008 Phase 1): measure the k-sample gate-pass-count signal on a
570    /// sampled fraction of enforce requests without changing serving or the served cost.
571    /// `None` (default) = off = zero extra provider calls = byte-identical to today.
572    #[serde(default)]
573    pub probe: Option<ProbeConfig>,
574    /// Per-query gate-pass predictor (ADR 0008 Phase 2): a learned `P(gate-pass | rung,
575    /// features)` model, trained online from receipts and recorded on the receipt in shadow.
576    /// `None` (default) = off = byte-identical to today (no predictor, no `predicted_pass`).
577    #[serde(default)]
578    pub predictor: Option<PredictorConfig>,
579    /// Elastic verification (ADR 0008 Phase 3): skip the named expensive gates on a serve when the
580    /// cheap **visible** gate score clears the conformally-calibrated threshold λ. `None` (default)
581    /// runs every gate on every serve — byte-identical to today's uniform verification. See
582    /// [`ElasticConfig`].
583    #[serde(default)]
584    pub elastic: Option<ElasticConfig>,
585}
586
587/// Per-query gate-pass predictor configuration (ADR 0008 Phase 2). The model is an online
588/// logistic regression (`firstpass_core::predictor::PassPredictor`) trained from the trace
589/// store. Its prediction is recorded on the receipt in **shadow** — it does not change routing
590/// in this phase; whether it is good enough to act on is decided offline (`firstpass
591/// predictor-eval`).
592#[derive(Debug, Clone, Copy, Deserialize)]
593#[serde(deny_unknown_fields)]
594pub struct PredictorConfig {
595    /// SGD learning rate, in `(0, 1]`. Validated by [`Config::parse`].
596    #[serde(default = "default_predictor_lr")]
597    pub lr: f64,
598    /// L2 shrinkage on the weights (bias excluded), `>= 0`. Validated by [`Config::parse`].
599    #[serde(default = "default_predictor_l2")]
600    pub l2: f64,
601}
602
603fn default_predictor_lr() -> f64 {
604    0.05
605}
606
607fn default_predictor_l2() -> f64 {
608    1e-4
609}
610
611/// Config for online/adaptive conformal serving ([`crate::conformal::AdaptiveConformal`]).
612#[derive(Debug, Clone, Deserialize)]
613#[serde(deny_unknown_fields)]
614pub struct AdaptiveConfig {
615    /// Target served-failure rate the online loop holds to.
616    pub alpha: f64,
617    /// Step size (default 0.02) — larger tracks shift faster but noisier.
618    #[serde(default = "default_adaptive_gamma")]
619    pub gamma: f64,
620}
621
622fn default_adaptive_gamma() -> f64 {
623    0.02
624}
625
626/// Epsilon-greedy start-rung exploration: a fraction `epsilon` of requests start at a
627/// uniformly-random rung so that propensities are logged and IPS/SNIPS off-policy estimates
628/// are valid (Horvitz-Thompson 1952; SNIPS: Swaminathan & Joachims 2015). The bandit still
629/// observes all gate verdicts — including from exploration draws — so learning is uninterrupted.
630///
631/// Every request under this policy records `policy.propensity` in the trace: the probability the
632/// logging policy had of choosing the start rung it actually chose. Old traces (before this field
633/// was added) serialize byte-identically (propensity is omitted when `None`).
634///
635/// Absent (default) → deterministic policy — no exploration, no propensity recorded,
636/// byte-identical to today.
637#[derive(Debug, Clone, Deserialize)]
638#[serde(deny_unknown_fields)]
639pub struct ExplorationConfig {
640    /// Fraction of requests routed to a uniformly-random start rung instead of the greedy
641    /// choice. Must be finite and in `(0, 0.5]`; validated by [`Config::parse`].
642    /// A small `epsilon` (0.05–0.1) is usually enough: it keeps learning alive and makes
643    /// IPS/SNIPS estimates valid at low cost in exploration waste.
644    pub epsilon: f64,
645}
646
647/// Shadow-probe configuration (ADR 0008 Phase 1): measure the validated k-sample
648/// gate-pass-count signal on a sampled fraction of requests WITHOUT changing serving.
649///
650/// **Cost caveat**: `sample_rate × k` extra model calls (at the `start_rung` model) per
651/// sampled request — measurement only, nothing served changes. Default absent = off = zero
652/// extra provider calls, byte-identical to today. Enable only when you want to collect the
653/// regime signal for offline analysis.
654#[derive(Debug, Clone, Copy, Deserialize)]
655#[serde(deny_unknown_fields)]
656pub struct ProbeConfig {
657    /// Number of parallel probe samples per request. Must be in `[2, 8]`; validated by
658    /// [`Config::parse`]. Each sample is one model call at the `start_rung` model.
659    pub k: u32,
660    /// Fraction of requests that trigger the probe, in `[0, 1]`. `0.0` = never; `1.0` = always.
661    /// Must be finite; validated by [`Config::parse`].
662    pub sample_rate: f64,
663}
664
665/// Elastic-verification serving config (ADR 0008 Phase 3). When present, the gates named in
666/// `expensive_gates` are treated as *skippable*: a rung serves without running them when the
667/// **visible** (cheap, always-run) gate score is `>= lambda` — the elastic three-regime rule,
668/// validated offline (`firstpass-bench --elastic`, artifact `docs/benchmarks/elastic-validation.txt`).
669///
670/// The load-bearing invariant (ADR 0008): the un-verified serves carry the *same* distribution-free
671/// served-failure bound as the verified ones **only when `lambda` was conformally calibrated on
672/// representative data and the workload has not drifted**. `lambda` is therefore operator-supplied
673/// from a calibration run — obtain it from `firstpass calibrate` / the offline `--elastic` harness,
674/// never hand-tuned. `alpha`/`delta`/`calibration_id` are recorded verbatim on every skip receipt so
675/// an auditor can check the bound held against realized deferred outcomes.
676///
677/// Default-off: absent config = today's uniform cascade, byte-identical. Applies to the serial
678/// engine only; if `speculation > 0` is also configured the expensive gates still run (conservative
679/// — running more gates never weakens the bound).
680#[derive(Debug, Clone, Deserialize, PartialEq)]
681#[serde(deny_unknown_fields)]
682pub struct ElasticConfig {
683    /// Gate ids that may be skipped when the visible signal clears `lambda`. Ids **not** listed
684    /// here are "visible" (cheap, e.g. `non-empty`/`json-valid`/`schema`) and always run — they are
685    /// the probe signal. Must be non-empty; validated by [`Config::parse`].
686    pub expensive_gates: Vec<String>,
687    /// Calibrated skip threshold λ: serve without the expensive gates iff the visible gate score is
688    /// `>= lambda`. Must be finite and in `[0, 1]`; validated by [`Config::parse`].
689    pub lambda: f64,
690    /// Target served-failure rate α the λ was calibrated at. Recorded on every skip receipt for
691    /// audit; not enforced per-request (the calibration already baked it into λ).
692    #[serde(default)]
693    pub alpha: Option<f64>,
694    /// Confidence level (1−δ) the λ was calibrated at. Recorded on every skip receipt for audit.
695    #[serde(default)]
696    pub delta: Option<f64>,
697    /// Provenance id of the calibration run that produced λ. Recorded on every skip receipt so the
698    /// skip traces back to the exact conformal calibration that authorized it.
699    #[serde(default)]
700    pub calibration_id: Option<String>,
701}
702
703/// Config for the UCB1 start-rung bandit (`firstpass_proxy::bandit::StartRungBandit`).
704///
705/// Absent (`None`) → start every request at rung 0 (byte-identical to today).
706#[derive(Debug, Clone, Deserialize)]
707#[serde(deny_unknown_fields)]
708pub struct BanditConfig {
709    /// Minimum total gate-verdict observations in a context bucket before the bandit's
710    /// prediction kicks in. Below this, every request starts at rung 0 (cold-start safety).
711    #[serde(default = "default_bandit_min_observations")]
712    pub min_observations: usize,
713    /// UCB1 exploration constant `c` (Auer et al. 2002). Higher values explore more; 1.0 is the
714    /// theoretical default. Must be finite and `>= 0`; validated by [`Config::parse`].
715    #[serde(default = "default_bandit_exploration")]
716    pub exploration: f64,
717    /// Selection algorithm. `"ucb1"` (default) is deterministic and auditable; `"thompson"`
718    /// samples Beta posteriors — stochastic by nature, so every decision logs a non-degenerate
719    /// propensity (clean IPS/SNIPS/DR off-policy estimates without the epsilon overlay) and
720    /// pairs with `discount` for non-stationary traffic.
721    #[serde(default)]
722    pub algorithm: BanditAlgorithm,
723    /// Per-observation multiplicative decay of a context's counts, in `(0, 1]`. `1.0`
724    /// (default) = no forgetting. `0.99` adapts to model churn / workload drift at the cost
725    /// of a slightly noisier estimate. Validated by [`Config::parse`].
726    #[serde(default = "default_bandit_discount")]
727    pub discount: f64,
728}
729
730/// Start-rung bandit selection algorithm.
731#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
732#[serde(rename_all = "lowercase")]
733pub enum BanditAlgorithm {
734    /// Deterministic UCB1 (Auer et al. 2002).
735    #[default]
736    Ucb1,
737    /// Thompson sampling with Beta posteriors (Chapelle & Li 2011).
738    Thompson,
739}
740
741fn default_bandit_discount() -> f64 {
742    1.0
743}
744
745fn default_bandit_min_observations() -> usize {
746    50
747}
748
749fn default_bandit_exploration() -> f64 {
750    1.0
751}
752
753const fn default_max_rungs() -> u32 {
754    3
755}
756
757/// Structured (tools/images) enforce is on by default — the fidelity guard in the proxy keeps
758/// it safe (verbatim-carry rungs only).
759fn default_enforce_structured() -> bool {
760    true
761}
762
763impl Default for Escalation {
764    fn default() -> Self {
765        Self {
766            max_rungs_per_request: default_max_rungs(),
767            session_promotion: None,
768            speculation: 0,
769            serve_threshold: None,
770            adaptive: None,
771            enforce_structured: default_enforce_structured(),
772            speculation_band: None,
773            bandit: None,
774            exploration: None,
775            probe: None,
776            predictor: None,
777            elastic: None,
778        }
779    }
780}
781
782/// "After N failures within a window, promote this session's starting rung."
783#[derive(Debug, Clone, Deserialize)]
784#[serde(deny_unknown_fields)]
785pub struct SessionPromotion {
786    /// Failure count that triggers promotion.
787    pub after_failures: u32,
788    /// Sliding window, e.g. `"30m"`.
789    pub window: String,
790}
791
792impl SessionPromotion {
793    /// Parse [`SessionPromotion::window`] into a [`Duration`].
794    ///
795    /// # Errors
796    /// Returns [`Error::BadDuration`] if the window is not `<int><unit>` with unit in `s`/`m`/`h`/`d`.
797    pub fn window_duration(&self) -> Result<Duration> {
798        parse_window(&self.window)
799    }
800}
801
802/// Parse a compact duration like `30m`, `2h`, `90s`, `1d`.
803fn parse_window(s: &str) -> Result<Duration> {
804    let s = s.trim();
805    let split = s
806        .find(|c: char| c.is_ascii_alphabetic())
807        .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
808    let (num, unit) = s.split_at(split);
809    let n: u64 = num
810        .trim()
811        .parse()
812        .map_err(|_| Error::BadDuration(s.to_owned()))?;
813    let secs = match unit {
814        "s" => n,
815        "m" => n.saturating_mul(60),
816        "h" => n.saturating_mul(3600),
817        "d" => n.saturating_mul(86_400),
818        _ => return Err(Error::BadDuration(s.to_owned())),
819    };
820    Ok(Duration::from_secs(secs))
821}
822
823/// A parsed `provider/model` reference.
824#[derive(Debug, Clone, PartialEq, Eq)]
825pub struct ModelRef {
826    /// Provider segment, e.g. `anthropic`.
827    pub provider: String,
828    /// Model segment, e.g. `claude-haiku-4-5`.
829    pub model: String,
830}
831
832impl ModelRef {
833    /// Parse a `provider/model` string.
834    ///
835    /// # Errors
836    /// Returns [`Error::BadModelRef`] if the input is not exactly one `provider/model` pair.
837    pub fn parse(s: &str) -> Result<Self> {
838        match s.split_once('/') {
839            Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
840                provider: p.to_owned(),
841                model: m.to_owned(),
842            }),
843            _ => Err(Error::BadModelRef(s.to_owned())),
844        }
845    }
846}
847
848const fn default_guardrail_cooldown() -> i64 {
849    3_600
850}
851
852impl Config {
853    /// Parse a TOML configuration document.
854    ///
855    /// # Errors
856    /// Returns [`Error::Config`] on invalid TOML or unknown fields, or [`Error::InvalidConfig`] on
857    /// an invalid gate definition (not exactly one of `cmd`/`judge`, a judge threshold outside
858    /// `[0,1]`, or a duplicate/blank `id`).
859    pub fn parse(toml_str: &str) -> Result<Self> {
860        let config: Self = toml::from_str(toml_str)?;
861        // A nonsensical rollout percent must fail loudly at parse: silently clamping it would
862        // ramp a different amount of traffic than the operator asked for, and they would only
863        // notice via a savings figure that never moved.
864        for (i, route) in config.routes.iter().enumerate() {
865            if let Some(r) = &route.rollout {
866                r.validate()
867                    .map_err(|e| Error::InvalidConfig(format!("route[{i}]: {e}")))?;
868            }
869            if let Some(sh) = &route.shadow {
870                sh.validate()
871                    .map_err(|e| Error::InvalidConfig(format!("route[{i}]: {e}")))?;
872            }
873        }
874        if let Some(g) = &config.guardrail {
875            g.validate().map_err(Error::InvalidConfig)?;
876        }
877        let mut seen = std::collections::HashSet::new();
878        for def in &config.gate_defs {
879            if def.id.trim().is_empty() {
880                return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
881            }
882            // Exactly one kind: `cmd`, `judge`, `consistency`, or `schema` — never more, never none.
883            let kinds_set = [
884                !def.cmd.is_empty(),
885                def.judge.is_some(),
886                def.consistency.is_some(),
887                def.schema.is_some(),
888            ]
889            .iter()
890            .filter(|&&b| b)
891            .count();
892            if kinds_set != 1 {
893                return Err(Error::InvalidConfig(format!(
894                    "gate {:?} must set exactly one of `cmd`, `judge`, `consistency`, or `schema`",
895                    def.id
896                )));
897            }
898            if let Some(judge) = &def.judge
899                && !(0.0..=1.0).contains(&judge.threshold)
900            {
901                return Err(Error::InvalidConfig(format!(
902                    "gate {:?} judge threshold {} is outside [0, 1]",
903                    def.id, judge.threshold
904                )));
905            }
906            if let Some(c) = &def.consistency {
907                if !(2..=8).contains(&c.k) {
908                    return Err(Error::InvalidConfig(format!(
909                        "gate {:?} consistency k {} is outside [2, 8]",
910                        def.id, c.k
911                    )));
912                }
913                if !(0.0..=1.0).contains(&c.threshold) {
914                    return Err(Error::InvalidConfig(format!(
915                        "gate {:?} consistency threshold {} is outside [0, 1]",
916                        def.id, c.threshold
917                    )));
918                }
919            }
920            if !seen.insert(def.id.as_str()) {
921                return Err(Error::InvalidConfig(format!(
922                    "duplicate gate id {:?}",
923                    def.id
924                )));
925            }
926        }
927        for price in &config.price_defs {
928            if price.model.trim().is_empty() {
929                return Err(Error::InvalidConfig(
930                    "price model must not be empty".to_owned(),
931                ));
932            }
933            let ok = |v: f64| v.is_finite() && v >= 0.0;
934            if !ok(price.input_per_mtok) || !ok(price.output_per_mtok) {
935                return Err(Error::InvalidConfig(format!(
936                    "price for {:?} must be finite and >= 0",
937                    price.model
938                )));
939            }
940        }
941        if let Some(b) = &config.escalation.bandit {
942            if !b.exploration.is_finite() || b.exploration < 0.0 {
943                return Err(Error::InvalidConfig(format!(
944                    "bandit.exploration must be finite and >= 0, got {}",
945                    b.exploration
946                )));
947            }
948            if !b.discount.is_finite() || b.discount <= 0.0 || b.discount > 1.0 {
949                return Err(Error::InvalidConfig(format!(
950                    "bandit.discount must be in (0, 1], got {}",
951                    b.discount
952                )));
953            }
954        }
955        if let Some([lo, hi]) = config.escalation.speculation_band {
956            let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
957            if !ok(lo) || !ok(hi) || lo > hi {
958                return Err(Error::InvalidConfig(format!(
959                    "escalation.speculation_band must satisfy 0 <= low <= high <= 1, got [{lo}, {hi}]"
960                )));
961            }
962        }
963        if let Some(exp) = &config.escalation.exploration
964            && (!exp.epsilon.is_finite() || exp.epsilon <= 0.0 || exp.epsilon > 0.5)
965        {
966            return Err(Error::InvalidConfig(format!(
967                "escalation.exploration.epsilon must be finite and in (0, 0.5], got {}",
968                exp.epsilon
969            )));
970        }
971        if let Some(probe) = &config.escalation.probe {
972            if !(2..=8).contains(&probe.k) {
973                return Err(Error::InvalidConfig(format!(
974                    "escalation.probe.k must be in [2, 8], got {}",
975                    probe.k
976                )));
977            }
978            if !probe.sample_rate.is_finite() || !(0.0..=1.0).contains(&probe.sample_rate) {
979                return Err(Error::InvalidConfig(format!(
980                    "escalation.probe.sample_rate must be finite and in [0, 1], got {}",
981                    probe.sample_rate
982                )));
983            }
984        }
985        if let Some(pred) = &config.escalation.predictor {
986            if !pred.lr.is_finite() || !(0.0..=1.0).contains(&pred.lr) || pred.lr == 0.0 {
987                return Err(Error::InvalidConfig(format!(
988                    "escalation.predictor.lr must be finite and in (0, 1], got {}",
989                    pred.lr
990                )));
991            }
992            if !pred.l2.is_finite() || pred.l2 < 0.0 {
993                return Err(Error::InvalidConfig(format!(
994                    "escalation.predictor.l2 must be finite and >= 0, got {}",
995                    pred.l2
996                )));
997            }
998        }
999        if let Some(elastic) = &config.escalation.elastic {
1000            if elastic.expensive_gates.is_empty() {
1001                return Err(Error::InvalidConfig(
1002                    "escalation.elastic.expensive_gates must name at least one gate to skip".into(),
1003                ));
1004            }
1005            if !elastic.lambda.is_finite() || !(0.0..=1.0).contains(&elastic.lambda) {
1006                return Err(Error::InvalidConfig(format!(
1007                    "escalation.elastic.lambda must be finite and in [0, 1], got {}",
1008                    elastic.lambda
1009                )));
1010            }
1011        }
1012        Ok(config)
1013    }
1014
1015    /// The first route whose match claims `f`, or `None` if no route matches.
1016    #[must_use]
1017    pub fn route_for(&self, f: &Features) -> Option<&Route> {
1018        self.routes.iter().find(|r| r.match_.matches(f))
1019    }
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025    use crate::features::Features;
1026
1027    const SPEC_CONFIG: &str = r#"
1028[[route]]
1029match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
1030mode  = "enforce"
1031ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1032gates  = ["schema", "judge-diff"]
1033
1034[[route]]
1035match = { task_kind = "code_edit" }
1036mode  = "enforce"
1037ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
1038gates  = ["patch-applies", "lint-diff", "judge-diff"]
1039deferred_gates = ["compiles", "tests"]
1040
1041[[route]]
1042match = {}
1043mode  = "observe"
1044ladder = ["anthropic/claude-opus-4-8"]
1045
1046[budget]
1047per_request_usd = 0.50
1048per_session_usd = 10.00
1049per_day_usd     = 250.00
1050on_exhausted    = "serve_best_attempt"
1051
1052[escalation]
1053max_rungs_per_request = 3
1054session_promotion = { after_failures = 3, window = "30m" }
1055"#;
1056
1057    #[test]
1058    fn parses_the_spec_example() {
1059        let c = Config::parse(SPEC_CONFIG).unwrap();
1060        assert_eq!(c.routes.len(), 3);
1061        assert_eq!(c.routes[0].mode, Mode::Enforce);
1062        assert_eq!(
1063            c.routes[0].ladder,
1064            ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1065        );
1066        assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
1067        assert_eq!(c.budget.per_request_usd, Some(0.50));
1068        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
1069        assert_eq!(c.escalation.max_rungs_per_request, 3);
1070        let sp = c.escalation.session_promotion.as_ref().unwrap();
1071        assert_eq!(sp.after_failures, 3);
1072        assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
1073    }
1074
1075    #[test]
1076    fn first_matching_route_wins() {
1077        let c = Config::parse(SPEC_CONFIG).unwrap();
1078
1079        // claude-code / test-runner -> route 0
1080        let mut f = Features::new(TaskKind::Explore);
1081        f.agent = Some("claude-code".into());
1082        f.subagent = Some("test-runner".into());
1083        let r = c.route_for(&f).unwrap();
1084        assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
1085
1086        // a code_edit with no agent -> route 1 (route 0's agent constraint fails)
1087        let f2 = Features::new(TaskKind::CodeEdit);
1088        let r2 = c.route_for(&f2).unwrap();
1089        assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
1090
1091        // anything else -> catch-all observe route
1092        let f3 = Features::new(TaskKind::Chat);
1093        let r3 = c.route_for(&f3).unwrap();
1094        assert_eq!(r3.mode, Mode::Observe);
1095    }
1096
1097    #[test]
1098    fn shipped_example_config_parses() {
1099        // The repo ships `firstpass.example.toml` for users to copy. If it drifts
1100        // from the schema, this fails in CI rather than at a user's first run.
1101        let toml = include_str!("../../../firstpass.example.toml");
1102        let c = Config::parse(toml).expect("firstpass.example.toml must parse");
1103        assert_eq!(c.routes.len(), 3);
1104        assert_eq!(c.routes[0].mode, Mode::Enforce);
1105    }
1106
1107    #[test]
1108    fn parses_gate_definitions() {
1109        let toml = r#"
1110[[route]]
1111match = {}
1112mode  = "enforce"
1113ladder = ["anthropic/claude-haiku-4-5"]
1114gates  = ["my-tests"]
1115
1116[[gate]]
1117id  = "my-tests"
1118cmd = ["pytest", "-q"]
1119
1120[[gate]]
1121id         = "judge"
1122cmd        = ["bash", "-c", "./judge.sh"]
1123timeout_ms = 60000
1124"#;
1125        let c = Config::parse(toml).unwrap();
1126        assert_eq!(c.gate_defs.len(), 2);
1127        assert_eq!(c.gate_defs[0].id, "my-tests");
1128        assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
1129        assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
1130        assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
1131    }
1132
1133    #[test]
1134    fn rejects_invalid_gate_definitions() {
1135        let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
1136        assert!(matches!(
1137            Config::parse(empty_cmd),
1138            Err(Error::InvalidConfig(_))
1139        ));
1140
1141        let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
1142        assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
1143    }
1144
1145    #[test]
1146    fn parses_consistency_gate_definition() {
1147        let toml = r#"
1148[[gate]]
1149id          = "uncertainty"
1150consistency = { model = "anthropic/claude-haiku-4-5", k = 5, threshold = 0.6 }
1151"#;
1152        let c = Config::parse(toml).unwrap();
1153        assert_eq!(c.gate_defs.len(), 1);
1154        let cons = c.gate_defs[0].consistency.as_ref().unwrap();
1155        assert_eq!(cons.model, "anthropic/claude-haiku-4-5");
1156        assert_eq!(cons.k, 5);
1157        assert!((cons.threshold - 0.6).abs() < 1e-9);
1158    }
1159
1160    #[test]
1161    fn consistency_k_defaults_to_3() {
1162        let toml = "[[gate]]\nid = \"u\"\nconsistency = { model = \"anthropic/claude-haiku-4-5\", threshold = 0.7 }\n";
1163        let c = Config::parse(toml).unwrap();
1164        assert_eq!(c.gate_defs[0].consistency.as_ref().unwrap().k, 3);
1165    }
1166
1167    #[test]
1168    fn rejects_exactly_one_of_violations_for_consistency() {
1169        // cmd + consistency — two kinds set
1170        let both = "[[gate]]\nid = \"g\"\ncmd = [\"x\"]\nconsistency = { model = \"a/b\", threshold = 0.5 }\n";
1171        assert!(matches!(Config::parse(both), Err(Error::InvalidConfig(_))));
1172
1173        // consistency k out of bounds
1174        let bad_k =
1175            "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 1, threshold = 0.5 }\n";
1176        assert!(matches!(Config::parse(bad_k), Err(Error::InvalidConfig(_))));
1177
1178        let bad_k2 =
1179            "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 9, threshold = 0.5 }\n";
1180        assert!(matches!(
1181            Config::parse(bad_k2),
1182            Err(Error::InvalidConfig(_))
1183        ));
1184
1185        // consistency threshold out of bounds
1186        let bad_thresh =
1187            "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", threshold = 1.1 }\n";
1188        assert!(matches!(
1189            Config::parse(bad_thresh),
1190            Err(Error::InvalidConfig(_))
1191        ));
1192    }
1193
1194    #[test]
1195    fn parses_adaptive_conformal_config() {
1196        let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
1197        let a = c
1198            .escalation
1199            .adaptive
1200            .expect("[escalation.adaptive] should parse");
1201        assert!((a.alpha - 0.1).abs() < 1e-9);
1202        assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
1203        // Absent => None (fixed-threshold serving, default byte-identical behavior).
1204        assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
1205    }
1206
1207    #[test]
1208    fn parses_provider_entries_and_ladders_can_reference_them() {
1209        let c = Config::parse(
1210            r#"
1211[[provider]]
1212id = "groq"
1213dialect = "openai"
1214base_url = "https://api.groq.com/openai"
1215api_key_env = "GROQ_API_KEY"
1216
1217[[provider]]
1218id = "ollama"
1219dialect = "openai"
1220base_url = "http://localhost:11434"
1221
1222[[route]]
1223match = {}
1224mode = "enforce"
1225ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
1226"#,
1227        )
1228        .unwrap();
1229        assert_eq!(c.providers.len(), 2);
1230        let groq = &c.providers[0];
1231        assert_eq!(groq.id, "groq");
1232        assert_eq!(groq.dialect, Dialect::Openai);
1233        assert_eq!(groq.base_url, "https://api.groq.com/openai");
1234        assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
1235        // A keyless local endpoint (Ollama) parses with no api_key_env.
1236        assert_eq!(c.providers[1].id, "ollama");
1237        assert!(c.providers[1].api_key_env.is_none());
1238        // Absent => no extra providers (built-ins only).
1239        assert!(Config::parse("").unwrap().providers.is_empty());
1240    }
1241
1242    #[test]
1243    fn parses_aws_sigv4_provider_and_defaults_auth_to_api_key() {
1244        let c = Config::parse(
1245            r#"
1246[[provider]]
1247id = "bedrock"
1248dialect = "anthropic"
1249auth = "aws_sigv4"
1250region = "us-east-1"
1251"#,
1252        )
1253        .unwrap();
1254        let bedrock = &c.providers[0];
1255        assert_eq!(bedrock.auth, AuthScheme::AwsSigv4);
1256        assert_eq!(bedrock.region.as_deref(), Some("us-east-1"));
1257        assert!(bedrock.project.is_none());
1258
1259        // Omitting `auth` on an existing provider entry defaults to ApiKey (I1: no behavior
1260        // change for today's `[[provider]]` entries).
1261        let c2 = Config::parse(
1262            r#"
1263[[provider]]
1264id = "groq"
1265dialect = "openai"
1266base_url = "https://api.groq.com/openai"
1267"#,
1268        )
1269        .unwrap();
1270        assert_eq!(c2.providers[0].auth, AuthScheme::ApiKey);
1271    }
1272
1273    #[test]
1274    fn all_documented_provider_shapes_parse() {
1275        // The exact `[[provider]]` shapes shown in the README / usage page / firstpass.example.toml.
1276        // This is the guard that the documented provider instructions stay valid — one entry per
1277        // dialect/auth combination we tell users to write.
1278        let c = Config::parse(
1279            r#"
1280[[provider]]
1281id = "groq"
1282dialect = "openai"
1283base_url = "https://api.groq.com/openai"
1284api_key_env = "GROQ_API_KEY"
1285
1286[[provider]]
1287id = "ollama"
1288dialect = "openai"
1289base_url = "http://localhost:11434"
1290
1291[[provider]]
1292id = "gemini"
1293dialect = "gemini"
1294base_url = "https://generativelanguage.googleapis.com"
1295api_key_env = "GEMINI_API_KEY"
1296
1297[[provider]]
1298id = "bedrock"
1299dialect = "anthropic"
1300auth = "aws_sigv4"
1301region = "us-east-1"
1302
1303[[provider]]
1304id = "vertex"
1305dialect = "anthropic"
1306auth = "gcp_oauth"
1307region = "us-east5"
1308project = "my-gcp-project"
1309"#,
1310        )
1311        .expect("every documented provider shape must parse");
1312        assert_eq!(c.providers.len(), 5);
1313
1314        let gemini = c.providers.iter().find(|p| p.id == "gemini").unwrap();
1315        assert_eq!(gemini.dialect, Dialect::Gemini);
1316        assert_eq!(gemini.auth, AuthScheme::ApiKey);
1317
1318        let vertex = c.providers.iter().find(|p| p.id == "vertex").unwrap();
1319        assert_eq!(vertex.auth, AuthScheme::GcpOauth);
1320        assert_eq!(vertex.region.as_deref(), Some("us-east5"));
1321        assert_eq!(vertex.project.as_deref(), Some("my-gcp-project"));
1322    }
1323
1324    #[test]
1325    fn empty_match_is_wildcard() {
1326        let m = Match::default();
1327        assert!(m.matches(&Features::new(TaskKind::Other)));
1328    }
1329
1330    #[test]
1331    fn subagent_list_membership() {
1332        let c = Config::parse(SPEC_CONFIG).unwrap();
1333        let route0 = &c.routes[0];
1334        let mut f = Features::new(TaskKind::Other);
1335        f.agent = Some("claude-code".into());
1336        f.subagent = Some("docs-writer".into()); // not in [test-runner, explore]
1337        assert!(!route0.match_.matches(&f));
1338        f.subagent = Some("explore".into());
1339        assert!(route0.match_.matches(&f));
1340    }
1341
1342    #[test]
1343    fn model_ref_parsing() {
1344        let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
1345        assert_eq!(m.provider, "anthropic");
1346        assert_eq!(m.model, "claude-haiku-4-5");
1347        assert!(ModelRef::parse("no-slash").is_err());
1348        assert!(ModelRef::parse("/model").is_err());
1349        assert!(ModelRef::parse("a/b/c").is_err());
1350    }
1351
1352    #[test]
1353    fn window_parsing_units_and_errors() {
1354        assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
1355        assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
1356        assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
1357        assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
1358        assert!(parse_window("30x").is_err());
1359        assert!(parse_window("abc").is_err());
1360    }
1361
1362    #[test]
1363    fn empty_config_defaults() {
1364        let c = Config::parse("").unwrap();
1365        assert!(c.routes.is_empty());
1366        assert_eq!(c.escalation.max_rungs_per_request, 3);
1367        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
1368    }
1369
1370    // ── ExplorationConfig parse / validation ──────────────────────────────────
1371
1372    #[test]
1373    fn parses_exploration_config() {
1374        let c = Config::parse("[escalation.exploration]\nepsilon = 0.1\n").unwrap();
1375        let exp = c
1376            .escalation
1377            .exploration
1378            .expect("[escalation.exploration] should parse");
1379        assert!((exp.epsilon - 0.1).abs() < 1e-12);
1380        // Absent => None (deterministic policy, byte-identical behavior).
1381        assert!(Config::parse("").unwrap().escalation.exploration.is_none());
1382    }
1383
1384    #[test]
1385    fn exploration_epsilon_boundary_valid() {
1386        // Upper bound 0.5 is allowed.
1387        let c = Config::parse("[escalation.exploration]\nepsilon = 0.5\n").unwrap();
1388        assert!((c.escalation.exploration.unwrap().epsilon - 0.5).abs() < 1e-12);
1389    }
1390
1391    #[test]
1392    fn exploration_epsilon_above_half_rejected() {
1393        let bad = "[escalation.exploration]\nepsilon = 0.51\n";
1394        assert!(
1395            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1396            "epsilon > 0.5 must be rejected"
1397        );
1398    }
1399
1400    #[test]
1401    fn exploration_epsilon_zero_rejected() {
1402        let bad = "[escalation.exploration]\nepsilon = 0.0\n";
1403        assert!(
1404            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1405            "epsilon = 0 must be rejected (must be strictly positive)"
1406        );
1407    }
1408
1409    #[test]
1410    fn exploration_epsilon_negative_rejected() {
1411        let bad = "[escalation.exploration]\nepsilon = -0.1\n";
1412        assert!(
1413            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1414            "epsilon < 0 must be rejected"
1415        );
1416    }
1417
1418    #[test]
1419    fn gate_def_schema_and_on_abstain_parse() {
1420        let toml = r#"
1421[[route]]
1422match = {}
1423mode = "enforce"
1424ladder = ["anthropic/claude-haiku-4-5"]
1425gates = ["extract-shape"]
1426
1427[[gate]]
1428id = "extract-shape"
1429schema = { type = "object", required = ["name"] }
1430on_abstain = "fail_closed"
1431"#;
1432        let config = Config::parse(toml).expect("schema gate def must parse");
1433        let def = &config.gate_defs[0];
1434        assert_eq!(def.id, "extract-shape");
1435        let schema = def.schema.as_ref().expect("schema captured");
1436        assert_eq!(schema["type"], "object");
1437        assert_eq!(def.on_abstain, AbstainPolicy::FailClosed);
1438    }
1439
1440    #[test]
1441    fn gate_def_on_abstain_defaults_fail_open() {
1442        let toml = r#"
1443[[route]]
1444match = {}
1445mode = "enforce"
1446ladder = ["anthropic/claude-haiku-4-5"]
1447
1448[[gate]]
1449id = "tests"
1450cmd = ["true"]
1451"#;
1452        let config = Config::parse(toml).expect("parse");
1453        assert_eq!(config.gate_defs[0].on_abstain, AbstainPolicy::FailOpen);
1454    }
1455
1456    #[test]
1457    fn gate_def_rejects_schema_plus_cmd() {
1458        let toml = r#"
1459[[route]]
1460match = {}
1461mode = "enforce"
1462ladder = ["anthropic/claude-haiku-4-5"]
1463
1464[[gate]]
1465id = "both"
1466cmd = ["true"]
1467schema = { type = "object" }
1468"#;
1469        let err = Config::parse(toml).unwrap_err();
1470        assert!(
1471            err.to_string().contains("exactly one"),
1472            "two kinds must be rejected: {err}"
1473        );
1474    }
1475
1476    #[test]
1477    fn price_overrides_parse_and_validate() {
1478        let toml = r#"
1479[[route]]
1480match = {}
1481mode = "observe"
1482ladder = ["anthropic/claude-haiku-4-5"]
1483
1484[[price]]
1485model = "anthropic/claude-haiku-4-5"
1486input_per_mtok = 0.8
1487output_per_mtok = 4.0
1488"#;
1489        let config = Config::parse(toml).expect("price override must parse");
1490        assert_eq!(config.price_defs[0].model, "anthropic/claude-haiku-4-5");
1491        assert!((config.price_defs[0].input_per_mtok - 0.8).abs() < 1e-12);
1492
1493        let bad = toml.replace("input_per_mtok = 0.8", "input_per_mtok = -1.0");
1494        assert!(Config::parse(&bad).is_err(), "negative price rejected");
1495    }
1496
1497    // ── RoutingMode / ModePreset ──────────────────────────────────────────────
1498
1499    /// The Balanced preset must be a strict no-op: all overrides None/false.
1500    /// This is the invariant that guarantees byte-identical behaviour when no mode is set.
1501    #[test]
1502    fn balanced_preset_is_strict_noop() {
1503        let p = RoutingMode::Balanced.preset();
1504        assert!(
1505            p.speculation.is_none(),
1506            "Balanced must not override speculation"
1507        );
1508        assert!(
1509            p.max_rungs_delta.is_none(),
1510            "Balanced must not override max_rungs"
1511        );
1512        assert!(!p.start_at_top, "Balanced must not set start_at_top");
1513    }
1514
1515    #[test]
1516    fn cost_preset_disables_speculation() {
1517        let p = RoutingMode::Cost.preset();
1518        assert_eq!(p.speculation, Some(0));
1519        assert!(p.max_rungs_delta.is_none());
1520        assert!(!p.start_at_top);
1521    }
1522
1523    #[test]
1524    fn quality_preset_bumps_max_rungs_and_disables_speculation() {
1525        let p = RoutingMode::Quality.preset();
1526        assert_eq!(p.max_rungs_delta, Some(1));
1527        assert_eq!(p.speculation, Some(0));
1528        assert!(!p.start_at_top);
1529    }
1530
1531    #[test]
1532    fn latency_preset_enables_speculation() {
1533        let p = RoutingMode::Latency.preset();
1534        assert_eq!(p.speculation, Some(1));
1535        assert!(p.max_rungs_delta.is_none());
1536        assert!(!p.start_at_top);
1537    }
1538
1539    #[test]
1540    fn max_preset_sets_start_at_top_and_disables_speculation() {
1541        let p = RoutingMode::Max.preset();
1542        assert!(p.start_at_top);
1543        assert_eq!(p.speculation, Some(0));
1544        assert!(p.max_rungs_delta.is_none());
1545    }
1546
1547    #[test]
1548    fn routing_mode_as_str_roundtrips() {
1549        for mode in RoutingMode::ALL {
1550            let s = mode.as_str();
1551            let back: RoutingMode = serde_json::from_str(&format!("\"{s}\"")).unwrap();
1552            assert_eq!(*mode, back, "as_str/serde roundtrip for {s}");
1553        }
1554    }
1555
1556    #[test]
1557    fn routing_mode_defaults_to_balanced() {
1558        assert_eq!(RoutingMode::default(), RoutingMode::Balanced);
1559    }
1560
1561    #[test]
1562    fn route_routing_mode_parses_and_defaults_to_none() {
1563        // Absent → None (byte-identical, no behavioral change).
1564        let no_mode = Config::parse(
1565            "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n",
1566        )
1567        .unwrap();
1568        assert_eq!(no_mode.routes[0].routing_mode, None);
1569
1570        // Explicit cost mode.
1571        let with_mode = Config::parse(
1572            "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\nrouting_mode = \"cost\"\n",
1573        )
1574        .unwrap();
1575        assert_eq!(with_mode.routes[0].routing_mode, Some(RoutingMode::Cost));
1576    }
1577
1578    #[test]
1579    fn all_routing_modes_have_non_empty_description_and_tradeoff() {
1580        for mode in RoutingMode::ALL {
1581            let p = mode.preset();
1582            assert!(
1583                !p.description.is_empty(),
1584                "mode {} has empty description",
1585                mode.as_str()
1586            );
1587            assert!(
1588                !p.tradeoff.is_empty(),
1589                "mode {} has empty tradeoff",
1590                mode.as_str()
1591            );
1592        }
1593    }
1594
1595    #[test]
1596    fn bandit_thompson_and_band_parse_and_validate() {
1597        let toml = r#"
1598[[route]]
1599match = {}
1600mode = "enforce"
1601ladder = ["anthropic/claude-haiku-4-5"]
1602
1603[escalation]
1604speculation = 1
1605speculation_band = [0.3, 0.7]
1606
1607[escalation.bandit]
1608algorithm = "thompson"
1609discount = 0.98
1610"#;
1611        let config = Config::parse(toml).expect("thompson + band must parse");
1612        let b = config.escalation.bandit.as_ref().unwrap();
1613        assert_eq!(b.algorithm, BanditAlgorithm::Thompson);
1614        assert!((b.discount - 0.98).abs() < 1e-12);
1615        assert_eq!(config.escalation.speculation_band, Some([0.3, 0.7]));
1616
1617        // Defaults: ucb1, discount 1.0, no band.
1618        let plain = Config::parse(
1619            "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.bandit]\n",
1620        )
1621        .unwrap();
1622        let b = plain.escalation.bandit.as_ref().unwrap();
1623        assert_eq!(b.algorithm, BanditAlgorithm::Ucb1);
1624        assert!((b.discount - 1.0).abs() < 1e-12);
1625
1626        // Bad discount and bad band are rejected.
1627        assert!(Config::parse(&toml.replace("discount = 0.98", "discount = 0.0")).is_err());
1628        assert!(
1629            Config::parse(&toml.replace(
1630                "speculation_band = [0.3, 0.7]",
1631                "speculation_band = [0.9, 0.2]"
1632            ))
1633            .is_err()
1634        );
1635    }
1636
1637    // ── ProbeConfig parse / validation ───────────────────────────────────────
1638
1639    #[test]
1640    fn probe_absent_defaults_to_none() {
1641        // Default off = byte-identical to today.
1642        let c = Config::parse("").unwrap();
1643        assert!(c.escalation.probe.is_none());
1644    }
1645
1646    #[test]
1647    fn parses_valid_probe_config() {
1648        let c = Config::parse("[escalation.probe]\nk = 5\nsample_rate = 0.1\n").unwrap();
1649        let p = c.escalation.probe.expect("[escalation.probe] should parse");
1650        assert_eq!(p.k, 5);
1651        assert!((p.sample_rate - 0.1).abs() < 1e-12);
1652    }
1653
1654    #[test]
1655    fn probe_sample_rate_boundaries_accepted() {
1656        // 0.0 (never probe) and 1.0 (always probe) are both valid.
1657        let c0 = Config::parse("[escalation.probe]\nk = 2\nsample_rate = 0.0\n").unwrap();
1658        assert!((c0.escalation.probe.unwrap().sample_rate - 0.0).abs() < 1e-12);
1659        let c1 = Config::parse("[escalation.probe]\nk = 8\nsample_rate = 1.0\n").unwrap();
1660        assert!((c1.escalation.probe.unwrap().sample_rate - 1.0).abs() < 1e-12);
1661    }
1662
1663    #[test]
1664    fn probe_rejects_k_below_2() {
1665        let bad = "[escalation.probe]\nk = 1\nsample_rate = 0.5\n";
1666        assert!(
1667            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1668            "k=1 must be rejected"
1669        );
1670    }
1671
1672    #[test]
1673    fn probe_rejects_k_above_8() {
1674        let bad = "[escalation.probe]\nk = 9\nsample_rate = 0.5\n";
1675        assert!(
1676            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1677            "k=9 must be rejected"
1678        );
1679    }
1680
1681    #[test]
1682    fn probe_rejects_sample_rate_above_1() {
1683        let bad = "[escalation.probe]\nk = 5\nsample_rate = 1.5\n";
1684        assert!(
1685            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1686            "sample_rate=1.5 must be rejected"
1687        );
1688    }
1689
1690    #[test]
1691    fn probe_rejects_negative_sample_rate() {
1692        let bad = "[escalation.probe]\nk = 5\nsample_rate = -0.1\n";
1693        assert!(
1694            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1695            "negative sample_rate must be rejected"
1696        );
1697    }
1698
1699    #[test]
1700    fn predictor_config_parses_and_validates() {
1701        let base = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.predictor]\nlr = 0.05\nl2 = 0.001\n";
1702        let cfg = Config::parse(base).expect("valid predictor must parse");
1703        let pred = cfg.escalation.predictor.unwrap();
1704        assert!((pred.lr - 0.05).abs() < 1e-12 && (pred.l2 - 0.001).abs() < 1e-12);
1705        // defaults when omitted
1706        let d = Config::parse("[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.predictor]\n").unwrap();
1707        assert!(d.escalation.predictor.is_some());
1708        // bad lr / l2 rejected
1709        assert!(Config::parse(&base.replace("lr = 0.05", "lr = 0.0")).is_err());
1710        assert!(Config::parse(&base.replace("lr = 0.05", "lr = 1.5")).is_err());
1711        assert!(Config::parse(&base.replace("l2 = 0.001", "l2 = -1.0")).is_err());
1712    }
1713
1714    /// Shadow is off unless asked for, and a nonsensical setting must fail loudly at parse rather
1715    /// than being clamped — a silently-corrected ceiling would spend a different amount than the
1716    /// operator authorised.
1717    #[test]
1718    fn shadow_config_parses_and_validates() {
1719        let base = "[[route]]\nmatch = {}\nmode = \"observe\"\nladder = [\"anthropic/a\"]\n";
1720        let none = Config::parse(base).unwrap();
1721        assert!(
1722            none.routes[0].shadow.is_none(),
1723            "shadow must be off by default"
1724        );
1725
1726        let ok = Config::parse(&format!(
1727            "{base}[route.shadow]\nsample_rate = 0.25\nmax_usd_per_day = 5.0\n"
1728        ))
1729        .unwrap();
1730        let sh = ok.routes[0].shadow.expect("shadow parsed");
1731        assert!((sh.sample_rate - 0.25).abs() < f64::EPSILON);
1732        assert!((sh.max_usd_per_day - 5.0).abs() < f64::EPSILON);
1733
1734        for bad in [
1735            "sample_rate = 1.5\nmax_usd_per_day = 5.0",
1736            "sample_rate = -0.1\nmax_usd_per_day = 5.0",
1737            "sample_rate = 0.1\nmax_usd_per_day = -1.0",
1738        ] {
1739            assert!(
1740                Config::parse(&format!("{base}[route.shadow]\n{bad}\n")).is_err(),
1741                "invalid shadow config accepted: {bad}"
1742            );
1743        }
1744    }
1745
1746    /// Same for rollout: absent by default, invalid percent rejected at parse.
1747    #[test]
1748    fn rollout_config_parses_and_validates() {
1749        let base = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/a\"]\n";
1750        assert!(Config::parse(base).unwrap().routes[0].rollout.is_none());
1751        let ok = Config::parse(&format!(
1752            "{base}[route.rollout]\npercent = 5.0\nkey = \"session\"\n"
1753        ))
1754        .unwrap();
1755        assert!((ok.routes[0].rollout.unwrap().percent - 5.0).abs() < f64::EPSILON);
1756        assert!(Config::parse(&format!("{base}[route.rollout]\npercent = 140.0\n")).is_err());
1757    }
1758}