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/// Top-level configuration document.
25#[derive(Debug, Clone, Default, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct Config {
28    /// Ordered routes; first match wins.
29    #[serde(rename = "route", default)]
30    pub routes: Vec<Route>,
31    /// Spend caps.
32    #[serde(default)]
33    pub budget: Budget,
34    /// Escalation limits and promotion rules.
35    #[serde(default)]
36    pub escalation: Escalation,
37    /// User-defined subprocess gates (SPEC §8.1), referenced by `id` from a route's `gates` /
38    /// `deferred_gates`. Declared as `[[gate]]` sections in TOML.
39    #[serde(rename = "gate", default)]
40    pub gate_defs: Vec<GateDef>,
41    /// Per-deployment price overrides, replacing the built-in defaults for the named models.
42    /// List prices drift and enterprise contracts differ — savings math is only honest when the
43    /// operator can pin THEIR prices. Declared as `[[price]]` sections in TOML.
44    #[serde(rename = "price", default)]
45    pub price_defs: Vec<PriceDef>,
46    /// Extra model providers a ladder can route to, beyond the built-in `anthropic` / `openai`.
47    /// Any OpenAI-compatible endpoint (Groq, Together, Fireworks, DeepSeek, Mistral, xAI,
48    /// OpenRouter, Ollama, vLLM, Azure, …) is one `[[provider]]` entry — no rebuild. Declared as
49    /// `[[provider]]` sections; a ladder rung is then `<id>/<model>`.
50    #[serde(rename = "provider", default)]
51    pub providers: Vec<ProviderDef>,
52}
53
54/// The wire API a provider speaks. `anthropic` = Messages API; `openai` = Chat Completions API
55/// (the de-facto standard that nearly every hosted and open-source model host implements);
56/// `gemini` = Google's Generative Language API (`generateContent`).
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum Dialect {
60    /// Anthropic Messages API (`POST /v1/messages`).
61    Anthropic,
62    /// OpenAI Chat Completions API (`POST /v1/chat/completions`).
63    Openai,
64    /// Google Gemini Generative Language API (`POST /v1beta/models/<model>:generateContent`),
65    /// authenticated with an API key in the `x-goog-api-key` header.
66    Gemini,
67}
68
69/// How a provider call is credentialed — orthogonal to [`Dialect`] (ADR 0006): dialect shapes the
70/// request/response body, auth scheme shapes how the request is signed/credentialed. Default
71/// (`api_key_env` / BYOK header) is unchanged for every existing `[[provider]]` entry.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum AuthScheme {
75    /// API key in a header (`x-api-key`, `Authorization: Bearer`, or `x-goog-api-key`) — today's
76    /// behavior for `anthropic` / `openai` / `gemini`.
77    #[default]
78    ApiKey,
79    /// AWS SigV4 request signing (Bedrock) — credentials from `AWS_ACCESS_KEY_ID` /
80    /// `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, region-scoped.
81    AwsSigv4,
82    /// GCP OAuth2 bearer token (Vertex AI) — minted and cached by `gcp_auth` from
83    /// `GOOGLE_APPLICATION_CREDENTIALS` or the ambient environment.
84    GcpOauth,
85}
86
87/// A model provider a ladder can route to. Declared as `[[provider]]` in TOML; referenced from a
88/// ladder as `<id>/<model>` (e.g. `groq/llama-3.3-70b-versatile`).
89#[derive(Debug, Clone, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct ProviderDef {
92    /// Ladder prefix for this provider, e.g. `"groq"`. Overrides a built-in of the same id.
93    pub id: String,
94    /// Which wire API it speaks.
95    pub dialect: Dialect,
96    /// Base URL, e.g. `"https://api.groq.com/openai"` or `"http://localhost:11434"` for Ollama.
97    /// Unused for `aws_sigv4/gcp_oauth` auth (those construct the URL from `region`/`project`).
98    #[serde(default)]
99    pub base_url: String,
100    /// Env var the API key is read from at call time, e.g. `"GROQ_API_KEY"`. Omit for a keyless
101    /// endpoint (local Ollama / vLLM). Per-request BYOK headers still apply to the built-in
102    /// `anthropic` / `openai` providers.
103    #[serde(default)]
104    pub api_key_env: Option<String>,
105    /// How this provider's requests are credentialed. `api_key` (default) is byte-identical to
106    /// today; `aws_sigv4` / `gcp_oauth` are the Bedrock/Vertex auth schemes (ADR 0006).
107    #[serde(default)]
108    pub auth: AuthScheme,
109    /// Cloud region, e.g. `"us-east-1"` — required for `aws_sigv4` (Bedrock) and `gcp_oauth`
110    /// (Vertex).
111    #[serde(default)]
112    pub region: Option<String>,
113    /// GCP project id — required for `gcp_oauth` (Vertex).
114    #[serde(default)]
115    pub project: Option<String>,
116}
117
118/// A per-deployment price override for one model (`provider/model`), USD per 1M tokens.
119#[derive(Debug, Clone, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct PriceDef {
122    /// The ladder id this price applies to, e.g. `"anthropic/claude-haiku-4-5"`.
123    pub model: String,
124    /// USD per 1M input (prompt) tokens.
125    pub input_per_mtok: f64,
126    /// USD per 1M output (completion) tokens.
127    pub output_per_mtok: f64,
128}
129
130/// A user-defined gate (SPEC §8.1). Exactly one kind per definition:
131/// - **subprocess** (`cmd`): any executable that reads the candidate as JSON on **stdin** (never
132///   argv — injection-resistant) and emits `{"verdict":"pass|fail|abstain", ...}` on stdout.
133/// - **judge** (`judge`): a native LLM-judge gate that grades the candidate against a rubric.
134/// - **consistency** (`consistency`): a self-consistency uncertainty gate that scores the
135///   candidate by agreement with k fresh samples of the same model (Wang et al. 2022).
136/// - **schema** (`schema`): validates the candidate (parsed as JSON) against a JSON-Schema
137///   subset (top-level `type` / `required` / per-property `type`).
138#[derive(Debug, Clone, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct GateDef {
141    /// The id a route references this gate by (must be unique and not shadow a built-in gate id).
142    pub id: String,
143    /// Subprocess command: program first, then its args — e.g. `["pytest", "-q"]`. Set this **or**
144    /// `judge` / `consistency` / `schema`, not both.
145    #[serde(default)]
146    pub cmd: Vec<String>,
147    /// Hard timeout in milliseconds for a subprocess gate; it abstains (`timeout`) if the process
148    /// runs longer.
149    #[serde(default = "default_gate_timeout_ms")]
150    pub timeout_ms: u64,
151    /// LLM-judge configuration. Set this **or** `cmd` / `consistency` / `schema`, not both.
152    #[serde(default)]
153    pub judge: Option<JudgeDef>,
154    /// Self-consistency configuration. Set this **or** `cmd` / `judge` / `schema`, not both.
155    #[serde(default)]
156    pub consistency: Option<ConsistencyDef>,
157    /// JSON-Schema (subset) the candidate must satisfy. Set this **or** `cmd` / `judge` /
158    /// `consistency`, not both.
159    #[serde(default)]
160    pub schema: Option<serde_json::Value>,
161    /// What an **abstain** from this gate means for serving (§7.2). `fail_open` (default): an
162    /// abstaining gate never blocks serving — availability over strictness, today's behavior.
163    /// `fail_closed`: an abstain blocks serving exactly like a `Fail` — strictness over
164    /// availability, for gates whose silence must never be mistaken for approval.
165    #[serde(default)]
166    pub on_abstain: AbstainPolicy,
167}
168
169/// Per-gate abstain policy (§7.2): what happens to serving when the gate can't produce a verdict.
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum AbstainPolicy {
173    /// An abstain never blocks serving (the historical behavior).
174    #[default]
175    FailOpen,
176    /// An abstain blocks serving exactly like a `Fail`.
177    FailClosed,
178}
179
180/// Configuration for a native LLM-judge gate (SPEC §8.3): a separate model grades the candidate
181/// against a rubric. The runner enforces maker ≠ checker (a model never grades its own output) and
182/// treats the candidate as data, not instructions.
183#[derive(Debug, Clone, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct JudgeDef {
186    /// The judge model, as `provider/model` (must differ from the candidate's model at runtime).
187    pub model: String,
188    /// Pass iff the judge's score ≥ this threshold, in `[0, 1]`.
189    #[serde(default = "default_judge_threshold")]
190    pub threshold: f64,
191    /// What "good" means for this route — handed to the judge as the grading rubric.
192    #[serde(default)]
193    pub rubric: Option<String>,
194}
195
196/// Configuration for a self-consistency uncertainty gate: resample the original request `k` times
197/// on the same model and score the candidate by agreement with the fresh samples.
198///
199/// Research basis: Wang et al. 2022 (self-consistency) and Farquhar et al., *Nature* 2024
200/// (semantic entropy). Answers a model reliably reproduces are far likelier correct; disagreement
201/// flags hallucination. This produces a continuous confidence score the conformal threshold
202/// machinery can calibrate.
203///
204/// **maker == checker is intentional** — unlike a judge gate, self-consistency is definitionally
205/// self-referential. This is the mechanism, not a bug.
206#[derive(Debug, Clone, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct ConsistencyDef {
209    /// The model used for resampling, as `provider/model`. May equal the candidate's model —
210    /// self-consistency is self-referential by design.
211    pub model: String,
212    /// Number of resample calls. Must be in `[2, 8]`; defaults to `3`.
213    #[serde(default = "default_consistency_k")]
214    pub k: u32,
215    /// Pass iff the mean agreement score ≥ this threshold, in `[0, 1]`.
216    pub threshold: f64,
217}
218
219/// Default subprocess-gate timeout: 30s. Long enough for a test suite, short enough to bound the
220/// enforce-path tail.
221fn default_gate_timeout_ms() -> u64 {
222    30_000
223}
224
225/// Default judge pass threshold.
226fn default_judge_threshold() -> f64 {
227    0.7
228}
229
230/// Default self-consistency k (resample count).
231fn default_consistency_k() -> u32 {
232    3
233}
234
235/// One routing rule.
236#[derive(Debug, Clone, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct Route {
239    /// The traffic this route claims. An empty match (`{}`) matches everything.
240    #[serde(rename = "match", default)]
241    pub match_: Match,
242    /// Serving mode.
243    pub mode: Mode,
244    /// Model ladder, cheapest first, as `provider/model` strings.
245    #[serde(default)]
246    pub ladder: Vec<String>,
247    /// Inline gates run before serving (enforce mode).
248    #[serde(default)]
249    pub gates: Vec<String>,
250    /// Gates run asynchronously after serving; their verdicts attach to the trace as a
251    /// learning signal and never block the response.
252    #[serde(default)]
253    pub deferred_gates: Vec<String>,
254}
255
256/// Predicate over a request's [`Features`]. Every present field is an AND-constraint; absent
257/// fields are wildcards.
258#[derive(Debug, Clone, Default, Deserialize)]
259#[serde(deny_unknown_fields)]
260pub struct Match {
261    /// Require a specific calling agent.
262    #[serde(default)]
263    pub agent: Option<String>,
264    /// Require the subagent to be one of these (or this one, if a bare string).
265    #[serde(default)]
266    pub subagent: Option<StringOrVec>,
267    /// Require a specific task kind.
268    #[serde(default)]
269    pub task_kind: Option<TaskKind>,
270    /// Require the language to be one of these (or this one, if a bare string).
271    #[serde(default)]
272    pub language: Option<StringOrVec>,
273}
274
275impl Match {
276    /// Whether `f` satisfies every present constraint.
277    #[must_use]
278    pub fn matches(&self, f: &Features) -> bool {
279        if let Some(agent) = &self.agent
280            && f.agent.as_deref() != Some(agent.as_str())
281        {
282            return false;
283        }
284        if let Some(subs) = &self.subagent {
285            match &f.subagent {
286                Some(s) if subs.contains(s) => {}
287                _ => return false,
288            }
289        }
290        if let Some(tk) = self.task_kind
291            && f.task_kind != tk
292        {
293            return false;
294        }
295        if let Some(langs) = &self.language {
296            match &f.language {
297                Some(l) if langs.contains(l) => {}
298                _ => return false,
299            }
300        }
301        true
302    }
303}
304
305/// A field that accepts either a single string or a list of strings in TOML/JSON.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(untagged)]
308pub enum StringOrVec {
309    /// A single value.
310    One(String),
311    /// A set of values.
312    Many(Vec<String>),
313}
314
315impl StringOrVec {
316    /// Whether `needle` is contained in this value.
317    #[must_use]
318    pub fn contains(&self, needle: &str) -> bool {
319        match self {
320            StringOrVec::One(s) => s == needle,
321            StringOrVec::Many(v) => v.iter().any(|s| s == needle),
322        }
323    }
324}
325
326/// What to do when a spend cap is hit (SPEC §8.4 — never brick the customer).
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
328#[serde(rename_all = "snake_case")]
329pub enum OnExhausted {
330    /// Serve the best attempt seen so far (default).
331    #[default]
332    ServeBestAttempt,
333    /// Return a structured error instead of serving.
334    Error,
335}
336
337/// Spend caps. `None` means "no cap".
338#[derive(Debug, Clone, Default, Deserialize)]
339#[serde(deny_unknown_fields)]
340pub struct Budget {
341    /// Max USD per single request (across all rungs + gates).
342    #[serde(default)]
343    pub per_request_usd: Option<f64>,
344    /// Max USD per session.
345    #[serde(default)]
346    pub per_session_usd: Option<f64>,
347    /// Max USD per day.
348    #[serde(default)]
349    pub per_day_usd: Option<f64>,
350    /// Behaviour when a cap is reached.
351    #[serde(default)]
352    pub on_exhausted: OnExhausted,
353}
354
355/// Escalation limits and session-level promotion.
356#[derive(Debug, Clone, Deserialize)]
357#[serde(deny_unknown_fields)]
358pub struct Escalation {
359    /// Hard ceiling on rungs climbed within one request.
360    #[serde(default = "default_max_rungs")]
361    pub max_rungs_per_request: u32,
362    /// Optionally start higher for the rest of a session that keeps failing.
363    #[serde(default)]
364    pub session_promotion: Option<SessionPromotion>,
365    /// Prefetch depth: fire this many rungs ahead concurrently to trade wasted spend for latency.
366    /// `0` (default) is serial — one call at a time. The served result is identical either way.
367    #[serde(default)]
368    pub speculation: u32,
369    /// Calibrated conformal serve threshold: a rung is served iff its aggregate gate score is
370    /// `>=` this value (SPEC §10.1). `None` (default) keeps the original rule — serve iff the
371    /// aggregate gate verdict is `Pass` — byte-identical to today.
372    #[serde(default)]
373    pub serve_threshold: Option<f64>,
374    /// Online/adaptive conformal (Gibbs-Candès ACI): when set, the serve threshold is tracked
375    /// **live** from deferred feedback instead of held fixed, so served-failure stays at target under
376    /// distribution shift. `None` (default) uses the fixed `serve_threshold` above — byte-identical.
377    #[serde(default)]
378    pub adaptive: Option<AdaptiveConfig>,
379    /// Route tool-calling / multimodal requests through enforce (ADR 0005). **Default `true`**:
380    /// agent traffic — the target workload — is gated out of the box. A per-request fidelity
381    /// guard still applies: structured content only routes when every ladder rung's provider
382    /// carries it verbatim (Anthropic-dialect today); otherwise the request falls back to
383    /// transparent observe passthrough rather than risking corruption. Set `false` to restore
384    /// the pre-ADR-0005 behavior (structured requests always pass through un-gated).
385    #[serde(default = "default_enforce_structured")]
386    pub enforce_structured: bool,
387    /// UCB1 start-rung bandit: learn which rung to START the ladder on per request context, to
388    /// cut expected cost by skipping rungs that almost always fail for this context. `None`
389    /// (default) starts every request at rung 0 — byte-identical to today. Prediction may only
390    /// choose where the ladder STARTS; gating, escalation, and serving are untouched.
391    #[serde(default)]
392    pub bandit: Option<BanditConfig>,
393    /// Speculative-deferral band: when set (and the bandit has a warm gate-pass estimate for
394    /// the chosen start rung), speculative prefetch fires **only** when that estimate falls
395    /// inside `[low, high]` — the marginal zone where the next rung is *probably but not
396    /// certainly* needed, which is where parallel spend buys the most latency (speculative
397    /// cascades). Outside the band (confident pass or confident fail-through) requests run
398    /// serial and the speculative tokens are saved. `None` (default) = `speculation` applies
399    /// unconditionally, byte-identical to today. No bandit / cold context ⇒ band inapplicable
400    /// ⇒ configured `speculation` applies.
401    #[serde(default)]
402    pub speculation_band: Option<[f64; 2]>,
403    /// Epsilon-greedy start-rung exploration: randomise a fraction of start-rung choices and
404    /// record propensities so IPS/SNIPS off-policy estimates are valid. `None` (default) →
405    /// deterministic policy — byte-identical to today. See [`ExplorationConfig`].
406    #[serde(default)]
407    pub exploration: Option<ExplorationConfig>,
408}
409
410/// Config for online/adaptive conformal serving ([`crate::conformal::AdaptiveConformal`]).
411#[derive(Debug, Clone, Deserialize)]
412#[serde(deny_unknown_fields)]
413pub struct AdaptiveConfig {
414    /// Target served-failure rate the online loop holds to.
415    pub alpha: f64,
416    /// Step size (default 0.02) — larger tracks shift faster but noisier.
417    #[serde(default = "default_adaptive_gamma")]
418    pub gamma: f64,
419}
420
421fn default_adaptive_gamma() -> f64 {
422    0.02
423}
424
425/// Epsilon-greedy start-rung exploration: a fraction `epsilon` of requests start at a
426/// uniformly-random rung so that propensities are logged and IPS/SNIPS off-policy estimates
427/// are valid (Horvitz-Thompson 1952; SNIPS: Swaminathan & Joachims 2015). The bandit still
428/// observes all gate verdicts — including from exploration draws — so learning is uninterrupted.
429///
430/// Every request under this policy records `policy.propensity` in the trace: the probability the
431/// logging policy had of choosing the start rung it actually chose. Old traces (before this field
432/// was added) serialize byte-identically (propensity is omitted when `None`).
433///
434/// Absent (default) → deterministic policy — no exploration, no propensity recorded,
435/// byte-identical to today.
436#[derive(Debug, Clone, Deserialize)]
437#[serde(deny_unknown_fields)]
438pub struct ExplorationConfig {
439    /// Fraction of requests routed to a uniformly-random start rung instead of the greedy
440    /// choice. Must be finite and in `(0, 0.5]`; validated by [`Config::parse`].
441    /// A small `epsilon` (0.05–0.1) is usually enough: it keeps learning alive and makes
442    /// IPS/SNIPS estimates valid at low cost in exploration waste.
443    pub epsilon: f64,
444}
445
446/// Config for the UCB1 start-rung bandit (`firstpass_proxy::bandit::StartRungBandit`).
447///
448/// Absent (`None`) → start every request at rung 0 (byte-identical to today).
449#[derive(Debug, Clone, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct BanditConfig {
452    /// Minimum total gate-verdict observations in a context bucket before the bandit's
453    /// prediction kicks in. Below this, every request starts at rung 0 (cold-start safety).
454    #[serde(default = "default_bandit_min_observations")]
455    pub min_observations: usize,
456    /// UCB1 exploration constant `c` (Auer et al. 2002). Higher values explore more; 1.0 is the
457    /// theoretical default. Must be finite and `>= 0`; validated by [`Config::parse`].
458    #[serde(default = "default_bandit_exploration")]
459    pub exploration: f64,
460    /// Selection algorithm. `"ucb1"` (default) is deterministic and auditable; `"thompson"`
461    /// samples Beta posteriors — stochastic by nature, so every decision logs a non-degenerate
462    /// propensity (clean IPS/SNIPS/DR off-policy estimates without the epsilon overlay) and
463    /// pairs with `discount` for non-stationary traffic.
464    #[serde(default)]
465    pub algorithm: BanditAlgorithm,
466    /// Per-observation multiplicative decay of a context's counts, in `(0, 1]`. `1.0`
467    /// (default) = no forgetting. `0.99` adapts to model churn / workload drift at the cost
468    /// of a slightly noisier estimate. Validated by [`Config::parse`].
469    #[serde(default = "default_bandit_discount")]
470    pub discount: f64,
471}
472
473/// Start-rung bandit selection algorithm.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
475#[serde(rename_all = "lowercase")]
476pub enum BanditAlgorithm {
477    /// Deterministic UCB1 (Auer et al. 2002).
478    #[default]
479    Ucb1,
480    /// Thompson sampling with Beta posteriors (Chapelle & Li 2011).
481    Thompson,
482}
483
484fn default_bandit_discount() -> f64 {
485    1.0
486}
487
488fn default_bandit_min_observations() -> usize {
489    50
490}
491
492fn default_bandit_exploration() -> f64 {
493    1.0
494}
495
496const fn default_max_rungs() -> u32 {
497    3
498}
499
500/// Structured (tools/images) enforce is on by default — the fidelity guard in the proxy keeps
501/// it safe (verbatim-carry rungs only).
502fn default_enforce_structured() -> bool {
503    true
504}
505
506impl Default for Escalation {
507    fn default() -> Self {
508        Self {
509            max_rungs_per_request: default_max_rungs(),
510            session_promotion: None,
511            speculation: 0,
512            serve_threshold: None,
513            adaptive: None,
514            enforce_structured: default_enforce_structured(),
515            speculation_band: None,
516            bandit: None,
517            exploration: None,
518        }
519    }
520}
521
522/// "After N failures within a window, promote this session's starting rung."
523#[derive(Debug, Clone, Deserialize)]
524#[serde(deny_unknown_fields)]
525pub struct SessionPromotion {
526    /// Failure count that triggers promotion.
527    pub after_failures: u32,
528    /// Sliding window, e.g. `"30m"`.
529    pub window: String,
530}
531
532impl SessionPromotion {
533    /// Parse [`SessionPromotion::window`] into a [`Duration`].
534    ///
535    /// # Errors
536    /// Returns [`Error::BadDuration`] if the window is not `<int><unit>` with unit in `s`/`m`/`h`/`d`.
537    pub fn window_duration(&self) -> Result<Duration> {
538        parse_window(&self.window)
539    }
540}
541
542/// Parse a compact duration like `30m`, `2h`, `90s`, `1d`.
543fn parse_window(s: &str) -> Result<Duration> {
544    let s = s.trim();
545    let split = s
546        .find(|c: char| c.is_ascii_alphabetic())
547        .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
548    let (num, unit) = s.split_at(split);
549    let n: u64 = num
550        .trim()
551        .parse()
552        .map_err(|_| Error::BadDuration(s.to_owned()))?;
553    let secs = match unit {
554        "s" => n,
555        "m" => n.saturating_mul(60),
556        "h" => n.saturating_mul(3600),
557        "d" => n.saturating_mul(86_400),
558        _ => return Err(Error::BadDuration(s.to_owned())),
559    };
560    Ok(Duration::from_secs(secs))
561}
562
563/// A parsed `provider/model` reference.
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct ModelRef {
566    /// Provider segment, e.g. `anthropic`.
567    pub provider: String,
568    /// Model segment, e.g. `claude-haiku-4-5`.
569    pub model: String,
570}
571
572impl ModelRef {
573    /// Parse a `provider/model` string.
574    ///
575    /// # Errors
576    /// Returns [`Error::BadModelRef`] if the input is not exactly one `provider/model` pair.
577    pub fn parse(s: &str) -> Result<Self> {
578        match s.split_once('/') {
579            Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
580                provider: p.to_owned(),
581                model: m.to_owned(),
582            }),
583            _ => Err(Error::BadModelRef(s.to_owned())),
584        }
585    }
586}
587
588impl Config {
589    /// Parse a TOML configuration document.
590    ///
591    /// # Errors
592    /// Returns [`Error::Config`] on invalid TOML or unknown fields, or [`Error::InvalidConfig`] on
593    /// an invalid gate definition (not exactly one of `cmd`/`judge`, a judge threshold outside
594    /// `[0,1]`, or a duplicate/blank `id`).
595    pub fn parse(toml_str: &str) -> Result<Self> {
596        let config: Self = toml::from_str(toml_str)?;
597        let mut seen = std::collections::HashSet::new();
598        for def in &config.gate_defs {
599            if def.id.trim().is_empty() {
600                return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
601            }
602            // Exactly one kind: `cmd`, `judge`, `consistency`, or `schema` — never more, never none.
603            let kinds_set = [
604                !def.cmd.is_empty(),
605                def.judge.is_some(),
606                def.consistency.is_some(),
607                def.schema.is_some(),
608            ]
609            .iter()
610            .filter(|&&b| b)
611            .count();
612            if kinds_set != 1 {
613                return Err(Error::InvalidConfig(format!(
614                    "gate {:?} must set exactly one of `cmd`, `judge`, `consistency`, or `schema`",
615                    def.id
616                )));
617            }
618            if let Some(judge) = &def.judge
619                && !(0.0..=1.0).contains(&judge.threshold)
620            {
621                return Err(Error::InvalidConfig(format!(
622                    "gate {:?} judge threshold {} is outside [0, 1]",
623                    def.id, judge.threshold
624                )));
625            }
626            if let Some(c) = &def.consistency {
627                if !(2..=8).contains(&c.k) {
628                    return Err(Error::InvalidConfig(format!(
629                        "gate {:?} consistency k {} is outside [2, 8]",
630                        def.id, c.k
631                    )));
632                }
633                if !(0.0..=1.0).contains(&c.threshold) {
634                    return Err(Error::InvalidConfig(format!(
635                        "gate {:?} consistency threshold {} is outside [0, 1]",
636                        def.id, c.threshold
637                    )));
638                }
639            }
640            if !seen.insert(def.id.as_str()) {
641                return Err(Error::InvalidConfig(format!(
642                    "duplicate gate id {:?}",
643                    def.id
644                )));
645            }
646        }
647        for price in &config.price_defs {
648            if price.model.trim().is_empty() {
649                return Err(Error::InvalidConfig(
650                    "price model must not be empty".to_owned(),
651                ));
652            }
653            let ok = |v: f64| v.is_finite() && v >= 0.0;
654            if !ok(price.input_per_mtok) || !ok(price.output_per_mtok) {
655                return Err(Error::InvalidConfig(format!(
656                    "price for {:?} must be finite and >= 0",
657                    price.model
658                )));
659            }
660        }
661        if let Some(b) = &config.escalation.bandit {
662            if !b.exploration.is_finite() || b.exploration < 0.0 {
663                return Err(Error::InvalidConfig(format!(
664                    "bandit.exploration must be finite and >= 0, got {}",
665                    b.exploration
666                )));
667            }
668            if !b.discount.is_finite() || b.discount <= 0.0 || b.discount > 1.0 {
669                return Err(Error::InvalidConfig(format!(
670                    "bandit.discount must be in (0, 1], got {}",
671                    b.discount
672                )));
673            }
674        }
675        if let Some([lo, hi]) = config.escalation.speculation_band {
676            let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
677            if !ok(lo) || !ok(hi) || lo > hi {
678                return Err(Error::InvalidConfig(format!(
679                    "escalation.speculation_band must satisfy 0 <= low <= high <= 1, got [{lo}, {hi}]"
680                )));
681            }
682        }
683        if let Some(exp) = &config.escalation.exploration
684            && (!exp.epsilon.is_finite() || exp.epsilon <= 0.0 || exp.epsilon > 0.5)
685        {
686            return Err(Error::InvalidConfig(format!(
687                "escalation.exploration.epsilon must be finite and in (0, 0.5], got {}",
688                exp.epsilon
689            )));
690        }
691        Ok(config)
692    }
693
694    /// The first route whose match claims `f`, or `None` if no route matches.
695    #[must_use]
696    pub fn route_for(&self, f: &Features) -> Option<&Route> {
697        self.routes.iter().find(|r| r.match_.matches(f))
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use crate::features::Features;
705
706    const SPEC_CONFIG: &str = r#"
707[[route]]
708match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
709mode  = "enforce"
710ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
711gates  = ["schema", "judge-diff"]
712
713[[route]]
714match = { task_kind = "code_edit" }
715mode  = "enforce"
716ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
717gates  = ["patch-applies", "lint-diff", "judge-diff"]
718deferred_gates = ["compiles", "tests"]
719
720[[route]]
721match = {}
722mode  = "observe"
723ladder = ["anthropic/claude-opus-4-8"]
724
725[budget]
726per_request_usd = 0.50
727per_session_usd = 10.00
728per_day_usd     = 250.00
729on_exhausted    = "serve_best_attempt"
730
731[escalation]
732max_rungs_per_request = 3
733session_promotion = { after_failures = 3, window = "30m" }
734"#;
735
736    #[test]
737    fn parses_the_spec_example() {
738        let c = Config::parse(SPEC_CONFIG).unwrap();
739        assert_eq!(c.routes.len(), 3);
740        assert_eq!(c.routes[0].mode, Mode::Enforce);
741        assert_eq!(
742            c.routes[0].ladder,
743            ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
744        );
745        assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
746        assert_eq!(c.budget.per_request_usd, Some(0.50));
747        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
748        assert_eq!(c.escalation.max_rungs_per_request, 3);
749        let sp = c.escalation.session_promotion.as_ref().unwrap();
750        assert_eq!(sp.after_failures, 3);
751        assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
752    }
753
754    #[test]
755    fn first_matching_route_wins() {
756        let c = Config::parse(SPEC_CONFIG).unwrap();
757
758        // claude-code / test-runner -> route 0
759        let mut f = Features::new(TaskKind::Explore);
760        f.agent = Some("claude-code".into());
761        f.subagent = Some("test-runner".into());
762        let r = c.route_for(&f).unwrap();
763        assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
764
765        // a code_edit with no agent -> route 1 (route 0's agent constraint fails)
766        let f2 = Features::new(TaskKind::CodeEdit);
767        let r2 = c.route_for(&f2).unwrap();
768        assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
769
770        // anything else -> catch-all observe route
771        let f3 = Features::new(TaskKind::Chat);
772        let r3 = c.route_for(&f3).unwrap();
773        assert_eq!(r3.mode, Mode::Observe);
774    }
775
776    #[test]
777    fn shipped_example_config_parses() {
778        // The repo ships `firstpass.example.toml` for users to copy. If it drifts
779        // from the schema, this fails in CI rather than at a user's first run.
780        let toml = include_str!("../../../firstpass.example.toml");
781        let c = Config::parse(toml).expect("firstpass.example.toml must parse");
782        assert_eq!(c.routes.len(), 3);
783        assert_eq!(c.routes[0].mode, Mode::Enforce);
784    }
785
786    #[test]
787    fn parses_gate_definitions() {
788        let toml = r#"
789[[route]]
790match = {}
791mode  = "enforce"
792ladder = ["anthropic/claude-haiku-4-5"]
793gates  = ["my-tests"]
794
795[[gate]]
796id  = "my-tests"
797cmd = ["pytest", "-q"]
798
799[[gate]]
800id         = "judge"
801cmd        = ["bash", "-c", "./judge.sh"]
802timeout_ms = 60000
803"#;
804        let c = Config::parse(toml).unwrap();
805        assert_eq!(c.gate_defs.len(), 2);
806        assert_eq!(c.gate_defs[0].id, "my-tests");
807        assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
808        assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
809        assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
810    }
811
812    #[test]
813    fn rejects_invalid_gate_definitions() {
814        let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
815        assert!(matches!(
816            Config::parse(empty_cmd),
817            Err(Error::InvalidConfig(_))
818        ));
819
820        let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
821        assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
822    }
823
824    #[test]
825    fn parses_consistency_gate_definition() {
826        let toml = r#"
827[[gate]]
828id          = "uncertainty"
829consistency = { model = "anthropic/claude-haiku-4-5", k = 5, threshold = 0.6 }
830"#;
831        let c = Config::parse(toml).unwrap();
832        assert_eq!(c.gate_defs.len(), 1);
833        let cons = c.gate_defs[0].consistency.as_ref().unwrap();
834        assert_eq!(cons.model, "anthropic/claude-haiku-4-5");
835        assert_eq!(cons.k, 5);
836        assert!((cons.threshold - 0.6).abs() < 1e-9);
837    }
838
839    #[test]
840    fn consistency_k_defaults_to_3() {
841        let toml = "[[gate]]\nid = \"u\"\nconsistency = { model = \"anthropic/claude-haiku-4-5\", threshold = 0.7 }\n";
842        let c = Config::parse(toml).unwrap();
843        assert_eq!(c.gate_defs[0].consistency.as_ref().unwrap().k, 3);
844    }
845
846    #[test]
847    fn rejects_exactly_one_of_violations_for_consistency() {
848        // cmd + consistency — two kinds set
849        let both = "[[gate]]\nid = \"g\"\ncmd = [\"x\"]\nconsistency = { model = \"a/b\", threshold = 0.5 }\n";
850        assert!(matches!(Config::parse(both), Err(Error::InvalidConfig(_))));
851
852        // consistency k out of bounds
853        let bad_k =
854            "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 1, threshold = 0.5 }\n";
855        assert!(matches!(Config::parse(bad_k), Err(Error::InvalidConfig(_))));
856
857        let bad_k2 =
858            "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 9, threshold = 0.5 }\n";
859        assert!(matches!(
860            Config::parse(bad_k2),
861            Err(Error::InvalidConfig(_))
862        ));
863
864        // consistency threshold out of bounds
865        let bad_thresh =
866            "[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", threshold = 1.1 }\n";
867        assert!(matches!(
868            Config::parse(bad_thresh),
869            Err(Error::InvalidConfig(_))
870        ));
871    }
872
873    #[test]
874    fn parses_adaptive_conformal_config() {
875        let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
876        let a = c
877            .escalation
878            .adaptive
879            .expect("[escalation.adaptive] should parse");
880        assert!((a.alpha - 0.1).abs() < 1e-9);
881        assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
882        // Absent => None (fixed-threshold serving, default byte-identical behavior).
883        assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
884    }
885
886    #[test]
887    fn parses_provider_entries_and_ladders_can_reference_them() {
888        let c = Config::parse(
889            r#"
890[[provider]]
891id = "groq"
892dialect = "openai"
893base_url = "https://api.groq.com/openai"
894api_key_env = "GROQ_API_KEY"
895
896[[provider]]
897id = "ollama"
898dialect = "openai"
899base_url = "http://localhost:11434"
900
901[[route]]
902match = {}
903mode = "enforce"
904ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
905"#,
906        )
907        .unwrap();
908        assert_eq!(c.providers.len(), 2);
909        let groq = &c.providers[0];
910        assert_eq!(groq.id, "groq");
911        assert_eq!(groq.dialect, Dialect::Openai);
912        assert_eq!(groq.base_url, "https://api.groq.com/openai");
913        assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
914        // A keyless local endpoint (Ollama) parses with no api_key_env.
915        assert_eq!(c.providers[1].id, "ollama");
916        assert!(c.providers[1].api_key_env.is_none());
917        // Absent => no extra providers (built-ins only).
918        assert!(Config::parse("").unwrap().providers.is_empty());
919    }
920
921    #[test]
922    fn parses_aws_sigv4_provider_and_defaults_auth_to_api_key() {
923        let c = Config::parse(
924            r#"
925[[provider]]
926id = "bedrock"
927dialect = "anthropic"
928auth = "aws_sigv4"
929region = "us-east-1"
930"#,
931        )
932        .unwrap();
933        let bedrock = &c.providers[0];
934        assert_eq!(bedrock.auth, AuthScheme::AwsSigv4);
935        assert_eq!(bedrock.region.as_deref(), Some("us-east-1"));
936        assert!(bedrock.project.is_none());
937
938        // Omitting `auth` on an existing provider entry defaults to ApiKey (I1: no behavior
939        // change for today's `[[provider]]` entries).
940        let c2 = Config::parse(
941            r#"
942[[provider]]
943id = "groq"
944dialect = "openai"
945base_url = "https://api.groq.com/openai"
946"#,
947        )
948        .unwrap();
949        assert_eq!(c2.providers[0].auth, AuthScheme::ApiKey);
950    }
951
952    #[test]
953    fn all_documented_provider_shapes_parse() {
954        // The exact `[[provider]]` shapes shown in the README / usage page / firstpass.example.toml.
955        // This is the guard that the documented provider instructions stay valid — one entry per
956        // dialect/auth combination we tell users to write.
957        let c = Config::parse(
958            r#"
959[[provider]]
960id = "groq"
961dialect = "openai"
962base_url = "https://api.groq.com/openai"
963api_key_env = "GROQ_API_KEY"
964
965[[provider]]
966id = "ollama"
967dialect = "openai"
968base_url = "http://localhost:11434"
969
970[[provider]]
971id = "gemini"
972dialect = "gemini"
973base_url = "https://generativelanguage.googleapis.com"
974api_key_env = "GEMINI_API_KEY"
975
976[[provider]]
977id = "bedrock"
978dialect = "anthropic"
979auth = "aws_sigv4"
980region = "us-east-1"
981
982[[provider]]
983id = "vertex"
984dialect = "anthropic"
985auth = "gcp_oauth"
986region = "us-east5"
987project = "my-gcp-project"
988"#,
989        )
990        .expect("every documented provider shape must parse");
991        assert_eq!(c.providers.len(), 5);
992
993        let gemini = c.providers.iter().find(|p| p.id == "gemini").unwrap();
994        assert_eq!(gemini.dialect, Dialect::Gemini);
995        assert_eq!(gemini.auth, AuthScheme::ApiKey);
996
997        let vertex = c.providers.iter().find(|p| p.id == "vertex").unwrap();
998        assert_eq!(vertex.auth, AuthScheme::GcpOauth);
999        assert_eq!(vertex.region.as_deref(), Some("us-east5"));
1000        assert_eq!(vertex.project.as_deref(), Some("my-gcp-project"));
1001    }
1002
1003    #[test]
1004    fn empty_match_is_wildcard() {
1005        let m = Match::default();
1006        assert!(m.matches(&Features::new(TaskKind::Other)));
1007    }
1008
1009    #[test]
1010    fn subagent_list_membership() {
1011        let c = Config::parse(SPEC_CONFIG).unwrap();
1012        let route0 = &c.routes[0];
1013        let mut f = Features::new(TaskKind::Other);
1014        f.agent = Some("claude-code".into());
1015        f.subagent = Some("docs-writer".into()); // not in [test-runner, explore]
1016        assert!(!route0.match_.matches(&f));
1017        f.subagent = Some("explore".into());
1018        assert!(route0.match_.matches(&f));
1019    }
1020
1021    #[test]
1022    fn model_ref_parsing() {
1023        let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
1024        assert_eq!(m.provider, "anthropic");
1025        assert_eq!(m.model, "claude-haiku-4-5");
1026        assert!(ModelRef::parse("no-slash").is_err());
1027        assert!(ModelRef::parse("/model").is_err());
1028        assert!(ModelRef::parse("a/b/c").is_err());
1029    }
1030
1031    #[test]
1032    fn window_parsing_units_and_errors() {
1033        assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
1034        assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
1035        assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
1036        assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
1037        assert!(parse_window("30x").is_err());
1038        assert!(parse_window("abc").is_err());
1039    }
1040
1041    #[test]
1042    fn empty_config_defaults() {
1043        let c = Config::parse("").unwrap();
1044        assert!(c.routes.is_empty());
1045        assert_eq!(c.escalation.max_rungs_per_request, 3);
1046        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
1047    }
1048
1049    // ── ExplorationConfig parse / validation ──────────────────────────────────
1050
1051    #[test]
1052    fn parses_exploration_config() {
1053        let c = Config::parse("[escalation.exploration]\nepsilon = 0.1\n").unwrap();
1054        let exp = c
1055            .escalation
1056            .exploration
1057            .expect("[escalation.exploration] should parse");
1058        assert!((exp.epsilon - 0.1).abs() < 1e-12);
1059        // Absent => None (deterministic policy, byte-identical behavior).
1060        assert!(Config::parse("").unwrap().escalation.exploration.is_none());
1061    }
1062
1063    #[test]
1064    fn exploration_epsilon_boundary_valid() {
1065        // Upper bound 0.5 is allowed.
1066        let c = Config::parse("[escalation.exploration]\nepsilon = 0.5\n").unwrap();
1067        assert!((c.escalation.exploration.unwrap().epsilon - 0.5).abs() < 1e-12);
1068    }
1069
1070    #[test]
1071    fn exploration_epsilon_above_half_rejected() {
1072        let bad = "[escalation.exploration]\nepsilon = 0.51\n";
1073        assert!(
1074            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1075            "epsilon > 0.5 must be rejected"
1076        );
1077    }
1078
1079    #[test]
1080    fn exploration_epsilon_zero_rejected() {
1081        let bad = "[escalation.exploration]\nepsilon = 0.0\n";
1082        assert!(
1083            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1084            "epsilon = 0 must be rejected (must be strictly positive)"
1085        );
1086    }
1087
1088    #[test]
1089    fn exploration_epsilon_negative_rejected() {
1090        let bad = "[escalation.exploration]\nepsilon = -0.1\n";
1091        assert!(
1092            matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
1093            "epsilon < 0 must be rejected"
1094        );
1095    }
1096
1097    #[test]
1098    fn gate_def_schema_and_on_abstain_parse() {
1099        let toml = r#"
1100[[route]]
1101match = {}
1102mode = "enforce"
1103ladder = ["anthropic/claude-haiku-4-5"]
1104gates = ["extract-shape"]
1105
1106[[gate]]
1107id = "extract-shape"
1108schema = { type = "object", required = ["name"] }
1109on_abstain = "fail_closed"
1110"#;
1111        let config = Config::parse(toml).expect("schema gate def must parse");
1112        let def = &config.gate_defs[0];
1113        assert_eq!(def.id, "extract-shape");
1114        let schema = def.schema.as_ref().expect("schema captured");
1115        assert_eq!(schema["type"], "object");
1116        assert_eq!(def.on_abstain, AbstainPolicy::FailClosed);
1117    }
1118
1119    #[test]
1120    fn gate_def_on_abstain_defaults_fail_open() {
1121        let toml = r#"
1122[[route]]
1123match = {}
1124mode = "enforce"
1125ladder = ["anthropic/claude-haiku-4-5"]
1126
1127[[gate]]
1128id = "tests"
1129cmd = ["true"]
1130"#;
1131        let config = Config::parse(toml).expect("parse");
1132        assert_eq!(config.gate_defs[0].on_abstain, AbstainPolicy::FailOpen);
1133    }
1134
1135    #[test]
1136    fn gate_def_rejects_schema_plus_cmd() {
1137        let toml = r#"
1138[[route]]
1139match = {}
1140mode = "enforce"
1141ladder = ["anthropic/claude-haiku-4-5"]
1142
1143[[gate]]
1144id = "both"
1145cmd = ["true"]
1146schema = { type = "object" }
1147"#;
1148        let err = Config::parse(toml).unwrap_err();
1149        assert!(
1150            err.to_string().contains("exactly one"),
1151            "two kinds must be rejected: {err}"
1152        );
1153    }
1154
1155    #[test]
1156    fn price_overrides_parse_and_validate() {
1157        let toml = r#"
1158[[route]]
1159match = {}
1160mode = "observe"
1161ladder = ["anthropic/claude-haiku-4-5"]
1162
1163[[price]]
1164model = "anthropic/claude-haiku-4-5"
1165input_per_mtok = 0.8
1166output_per_mtok = 4.0
1167"#;
1168        let config = Config::parse(toml).expect("price override must parse");
1169        assert_eq!(config.price_defs[0].model, "anthropic/claude-haiku-4-5");
1170        assert!((config.price_defs[0].input_per_mtok - 0.8).abs() < 1e-12);
1171
1172        let bad = toml.replace("input_per_mtok = 0.8", "input_per_mtok = -1.0");
1173        assert!(Config::parse(&bad).is_err(), "negative price rejected");
1174    }
1175
1176    #[test]
1177    fn bandit_thompson_and_band_parse_and_validate() {
1178        let toml = r#"
1179[[route]]
1180match = {}
1181mode = "enforce"
1182ladder = ["anthropic/claude-haiku-4-5"]
1183
1184[escalation]
1185speculation = 1
1186speculation_band = [0.3, 0.7]
1187
1188[escalation.bandit]
1189algorithm = "thompson"
1190discount = 0.98
1191"#;
1192        let config = Config::parse(toml).expect("thompson + band must parse");
1193        let b = config.escalation.bandit.as_ref().unwrap();
1194        assert_eq!(b.algorithm, BanditAlgorithm::Thompson);
1195        assert!((b.discount - 0.98).abs() < 1e-12);
1196        assert_eq!(config.escalation.speculation_band, Some([0.3, 0.7]));
1197
1198        // Defaults: ucb1, discount 1.0, no band.
1199        let plain = Config::parse(
1200            "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.bandit]\n",
1201        )
1202        .unwrap();
1203        let b = plain.escalation.bandit.as_ref().unwrap();
1204        assert_eq!(b.algorithm, BanditAlgorithm::Ucb1);
1205        assert!((b.discount - 1.0).abs() < 1e-12);
1206
1207        // Bad discount and bad band are rejected.
1208        assert!(Config::parse(&toml.replace("discount = 0.98", "discount = 0.0")).is_err());
1209        assert!(
1210            Config::parse(&toml.replace(
1211                "speculation_band = [0.3, 0.7]",
1212                "speculation_band = [0.9, 0.2]"
1213            ))
1214            .is_err()
1215        );
1216    }
1217}