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