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    /// Extra model providers a ladder can route to, beyond the built-in `anthropic` / `openai`.
42    /// Any OpenAI-compatible endpoint (Groq, Together, Fireworks, DeepSeek, Mistral, xAI,
43    /// OpenRouter, Ollama, vLLM, Azure, …) is one `[[provider]]` entry — no rebuild. Declared as
44    /// `[[provider]]` sections; a ladder rung is then `<id>/<model>`.
45    #[serde(rename = "provider", default)]
46    pub providers: Vec<ProviderDef>,
47}
48
49/// The wire API a provider speaks. `anthropic` = Messages API; `openai` = Chat Completions API
50/// (the de-facto standard that nearly every hosted and open-source model host implements).
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum Dialect {
54    /// Anthropic Messages API (`POST /v1/messages`).
55    Anthropic,
56    /// OpenAI Chat Completions API (`POST /v1/chat/completions`).
57    Openai,
58}
59
60/// A model provider a ladder can route to. Declared as `[[provider]]` in TOML; referenced from a
61/// ladder as `<id>/<model>` (e.g. `groq/llama-3.3-70b-versatile`).
62#[derive(Debug, Clone, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct ProviderDef {
65    /// Ladder prefix for this provider, e.g. `"groq"`. Overrides a built-in of the same id.
66    pub id: String,
67    /// Which wire API it speaks.
68    pub dialect: Dialect,
69    /// Base URL, e.g. `"https://api.groq.com/openai"` or `"http://localhost:11434"` for Ollama.
70    pub base_url: String,
71    /// Env var the API key is read from at call time, e.g. `"GROQ_API_KEY"`. Omit for a keyless
72    /// endpoint (local Ollama / vLLM). Per-request BYOK headers still apply to the built-in
73    /// `anthropic` / `openai` providers.
74    #[serde(default)]
75    pub api_key_env: Option<String>,
76}
77
78/// A user-defined gate (SPEC §8.1). Exactly one kind per definition:
79/// - **subprocess** (`cmd`): any executable that reads the candidate as JSON on **stdin** (never
80///   argv — injection-resistant) and emits `{"verdict":"pass|fail|abstain", ...}` on stdout.
81/// - **judge** (`judge`): a native LLM-judge gate that grades the candidate against a rubric.
82#[derive(Debug, Clone, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct GateDef {
85    /// The id a route references this gate by (must be unique and not shadow a built-in gate id).
86    pub id: String,
87    /// Subprocess command: program first, then its args — e.g. `["pytest", "-q"]`. Set this **or**
88    /// `judge`, not both.
89    #[serde(default)]
90    pub cmd: Vec<String>,
91    /// Hard timeout in milliseconds for a subprocess gate; it abstains (`timeout`) if the process
92    /// runs longer.
93    #[serde(default = "default_gate_timeout_ms")]
94    pub timeout_ms: u64,
95    /// LLM-judge configuration. Set this **or** `cmd`, not both.
96    #[serde(default)]
97    pub judge: Option<JudgeDef>,
98}
99
100/// Configuration for a native LLM-judge gate (SPEC §8.3): a separate model grades the candidate
101/// against a rubric. The runner enforces maker ≠ checker (a model never grades its own output) and
102/// treats the candidate as data, not instructions.
103#[derive(Debug, Clone, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct JudgeDef {
106    /// The judge model, as `provider/model` (must differ from the candidate's model at runtime).
107    pub model: String,
108    /// Pass iff the judge's score ≥ this threshold, in `[0, 1]`.
109    #[serde(default = "default_judge_threshold")]
110    pub threshold: f64,
111    /// What "good" means for this route — handed to the judge as the grading rubric.
112    #[serde(default)]
113    pub rubric: Option<String>,
114}
115
116/// Default subprocess-gate timeout: 30s. Long enough for a test suite, short enough to bound the
117/// enforce-path tail.
118fn default_gate_timeout_ms() -> u64 {
119    30_000
120}
121
122/// Default judge pass threshold.
123fn default_judge_threshold() -> f64 {
124    0.7
125}
126
127/// One routing rule.
128#[derive(Debug, Clone, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct Route {
131    /// The traffic this route claims. An empty match (`{}`) matches everything.
132    #[serde(rename = "match", default)]
133    pub match_: Match,
134    /// Serving mode.
135    pub mode: Mode,
136    /// Model ladder, cheapest first, as `provider/model` strings.
137    #[serde(default)]
138    pub ladder: Vec<String>,
139    /// Inline gates run before serving (enforce mode).
140    #[serde(default)]
141    pub gates: Vec<String>,
142    /// Gates run asynchronously after serving; their verdicts attach to the trace as a
143    /// learning signal and never block the response.
144    #[serde(default)]
145    pub deferred_gates: Vec<String>,
146}
147
148/// Predicate over a request's [`Features`]. Every present field is an AND-constraint; absent
149/// fields are wildcards.
150#[derive(Debug, Clone, Default, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct Match {
153    /// Require a specific calling agent.
154    #[serde(default)]
155    pub agent: Option<String>,
156    /// Require the subagent to be one of these (or this one, if a bare string).
157    #[serde(default)]
158    pub subagent: Option<StringOrVec>,
159    /// Require a specific task kind.
160    #[serde(default)]
161    pub task_kind: Option<TaskKind>,
162    /// Require the language to be one of these (or this one, if a bare string).
163    #[serde(default)]
164    pub language: Option<StringOrVec>,
165}
166
167impl Match {
168    /// Whether `f` satisfies every present constraint.
169    #[must_use]
170    pub fn matches(&self, f: &Features) -> bool {
171        if let Some(agent) = &self.agent
172            && f.agent.as_deref() != Some(agent.as_str())
173        {
174            return false;
175        }
176        if let Some(subs) = &self.subagent {
177            match &f.subagent {
178                Some(s) if subs.contains(s) => {}
179                _ => return false,
180            }
181        }
182        if let Some(tk) = self.task_kind
183            && f.task_kind != tk
184        {
185            return false;
186        }
187        if let Some(langs) = &self.language {
188            match &f.language {
189                Some(l) if langs.contains(l) => {}
190                _ => return false,
191            }
192        }
193        true
194    }
195}
196
197/// A field that accepts either a single string or a list of strings in TOML/JSON.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(untagged)]
200pub enum StringOrVec {
201    /// A single value.
202    One(String),
203    /// A set of values.
204    Many(Vec<String>),
205}
206
207impl StringOrVec {
208    /// Whether `needle` is contained in this value.
209    #[must_use]
210    pub fn contains(&self, needle: &str) -> bool {
211        match self {
212            StringOrVec::One(s) => s == needle,
213            StringOrVec::Many(v) => v.iter().any(|s| s == needle),
214        }
215    }
216}
217
218/// What to do when a spend cap is hit (SPEC §8.4 — never brick the customer).
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum OnExhausted {
222    /// Serve the best attempt seen so far (default).
223    #[default]
224    ServeBestAttempt,
225    /// Return a structured error instead of serving.
226    Error,
227}
228
229/// Spend caps. `None` means "no cap".
230#[derive(Debug, Clone, Default, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct Budget {
233    /// Max USD per single request (across all rungs + gates).
234    #[serde(default)]
235    pub per_request_usd: Option<f64>,
236    /// Max USD per session.
237    #[serde(default)]
238    pub per_session_usd: Option<f64>,
239    /// Max USD per day.
240    #[serde(default)]
241    pub per_day_usd: Option<f64>,
242    /// Behaviour when a cap is reached.
243    #[serde(default)]
244    pub on_exhausted: OnExhausted,
245}
246
247/// Escalation limits and session-level promotion.
248#[derive(Debug, Clone, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Escalation {
251    /// Hard ceiling on rungs climbed within one request.
252    #[serde(default = "default_max_rungs")]
253    pub max_rungs_per_request: u32,
254    /// Optionally start higher for the rest of a session that keeps failing.
255    #[serde(default)]
256    pub session_promotion: Option<SessionPromotion>,
257    /// Prefetch depth: fire this many rungs ahead concurrently to trade wasted spend for latency.
258    /// `0` (default) is serial — one call at a time. The served result is identical either way.
259    #[serde(default)]
260    pub speculation: u32,
261    /// Calibrated conformal serve threshold: a rung is served iff its aggregate gate score is
262    /// `>=` this value (SPEC §10.1). `None` (default) keeps the original rule — serve iff the
263    /// aggregate gate verdict is `Pass` — byte-identical to today.
264    #[serde(default)]
265    pub serve_threshold: Option<f64>,
266    /// Online/adaptive conformal (Gibbs-Candès ACI): when set, the serve threshold is tracked
267    /// **live** from deferred feedback instead of held fixed, so served-failure stays at target under
268    /// distribution shift. `None` (default) uses the fixed `serve_threshold` above — byte-identical.
269    #[serde(default)]
270    pub adaptive: Option<AdaptiveConfig>,
271    /// Opt-in: route tool-calling / multimodal requests through enforce instead of falling back to
272    /// observe (ADR 0005). `false` (default) is byte-identical to today — such requests pass through
273    /// un-gated. Turn on **only after** verifying enforce faithfully round-trips your tool workload;
274    /// content fidelity is preserved either way, but escalating a live tool turn is operator-gated.
275    #[serde(default)]
276    pub enforce_structured: bool,
277}
278
279/// Config for online/adaptive conformal serving ([`crate::conformal::AdaptiveConformal`]).
280#[derive(Debug, Clone, Deserialize)]
281#[serde(deny_unknown_fields)]
282pub struct AdaptiveConfig {
283    /// Target served-failure rate the online loop holds to.
284    pub alpha: f64,
285    /// Step size (default 0.02) — larger tracks shift faster but noisier.
286    #[serde(default = "default_adaptive_gamma")]
287    pub gamma: f64,
288}
289
290fn default_adaptive_gamma() -> f64 {
291    0.02
292}
293
294const fn default_max_rungs() -> u32 {
295    3
296}
297
298impl Default for Escalation {
299    fn default() -> Self {
300        Self {
301            max_rungs_per_request: default_max_rungs(),
302            session_promotion: None,
303            speculation: 0,
304            serve_threshold: None,
305            adaptive: None,
306            enforce_structured: false,
307        }
308    }
309}
310
311/// "After N failures within a window, promote this session's starting rung."
312#[derive(Debug, Clone, Deserialize)]
313#[serde(deny_unknown_fields)]
314pub struct SessionPromotion {
315    /// Failure count that triggers promotion.
316    pub after_failures: u32,
317    /// Sliding window, e.g. `"30m"`.
318    pub window: String,
319}
320
321impl SessionPromotion {
322    /// Parse [`SessionPromotion::window`] into a [`Duration`].
323    ///
324    /// # Errors
325    /// Returns [`Error::BadDuration`] if the window is not `<int><unit>` with unit in `s`/`m`/`h`/`d`.
326    pub fn window_duration(&self) -> Result<Duration> {
327        parse_window(&self.window)
328    }
329}
330
331/// Parse a compact duration like `30m`, `2h`, `90s`, `1d`.
332fn parse_window(s: &str) -> Result<Duration> {
333    let s = s.trim();
334    let split = s
335        .find(|c: char| c.is_ascii_alphabetic())
336        .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
337    let (num, unit) = s.split_at(split);
338    let n: u64 = num
339        .trim()
340        .parse()
341        .map_err(|_| Error::BadDuration(s.to_owned()))?;
342    let secs = match unit {
343        "s" => n,
344        "m" => n.saturating_mul(60),
345        "h" => n.saturating_mul(3600),
346        "d" => n.saturating_mul(86_400),
347        _ => return Err(Error::BadDuration(s.to_owned())),
348    };
349    Ok(Duration::from_secs(secs))
350}
351
352/// A parsed `provider/model` reference.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct ModelRef {
355    /// Provider segment, e.g. `anthropic`.
356    pub provider: String,
357    /// Model segment, e.g. `claude-haiku-4-5`.
358    pub model: String,
359}
360
361impl ModelRef {
362    /// Parse a `provider/model` string.
363    ///
364    /// # Errors
365    /// Returns [`Error::BadModelRef`] if the input is not exactly one `provider/model` pair.
366    pub fn parse(s: &str) -> Result<Self> {
367        match s.split_once('/') {
368            Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
369                provider: p.to_owned(),
370                model: m.to_owned(),
371            }),
372            _ => Err(Error::BadModelRef(s.to_owned())),
373        }
374    }
375}
376
377impl Config {
378    /// Parse a TOML configuration document.
379    ///
380    /// # Errors
381    /// Returns [`Error::Config`] on invalid TOML or unknown fields, or [`Error::InvalidConfig`] on
382    /// an invalid gate definition (not exactly one of `cmd`/`judge`, a judge threshold outside
383    /// `[0,1]`, or a duplicate/blank `id`).
384    pub fn parse(toml_str: &str) -> Result<Self> {
385        let config: Self = toml::from_str(toml_str)?;
386        let mut seen = std::collections::HashSet::new();
387        for def in &config.gate_defs {
388            if def.id.trim().is_empty() {
389                return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
390            }
391            // Exactly one kind: a subprocess `cmd` or a `judge`, never both, never neither.
392            if def.cmd.is_empty() == def.judge.is_none() {
393                return Err(Error::InvalidConfig(format!(
394                    "gate {:?} must set exactly one of `cmd` or `judge`",
395                    def.id
396                )));
397            }
398            if let Some(judge) = &def.judge
399                && !(0.0..=1.0).contains(&judge.threshold)
400            {
401                return Err(Error::InvalidConfig(format!(
402                    "gate {:?} judge threshold {} is outside [0, 1]",
403                    def.id, judge.threshold
404                )));
405            }
406            if !seen.insert(def.id.as_str()) {
407                return Err(Error::InvalidConfig(format!(
408                    "duplicate gate id {:?}",
409                    def.id
410                )));
411            }
412        }
413        Ok(config)
414    }
415
416    /// The first route whose match claims `f`, or `None` if no route matches.
417    #[must_use]
418    pub fn route_for(&self, f: &Features) -> Option<&Route> {
419        self.routes.iter().find(|r| r.match_.matches(f))
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::features::Features;
427
428    const SPEC_CONFIG: &str = r#"
429[[route]]
430match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
431mode  = "enforce"
432ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
433gates  = ["schema", "judge-diff"]
434
435[[route]]
436match = { task_kind = "code_edit" }
437mode  = "enforce"
438ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
439gates  = ["patch-applies", "lint-diff", "judge-diff"]
440deferred_gates = ["compiles", "tests"]
441
442[[route]]
443match = {}
444mode  = "observe"
445ladder = ["anthropic/claude-opus-4-8"]
446
447[budget]
448per_request_usd = 0.50
449per_session_usd = 10.00
450per_day_usd     = 250.00
451on_exhausted    = "serve_best_attempt"
452
453[escalation]
454max_rungs_per_request = 3
455session_promotion = { after_failures = 3, window = "30m" }
456"#;
457
458    #[test]
459    fn parses_the_spec_example() {
460        let c = Config::parse(SPEC_CONFIG).unwrap();
461        assert_eq!(c.routes.len(), 3);
462        assert_eq!(c.routes[0].mode, Mode::Enforce);
463        assert_eq!(
464            c.routes[0].ladder,
465            ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
466        );
467        assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
468        assert_eq!(c.budget.per_request_usd, Some(0.50));
469        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
470        assert_eq!(c.escalation.max_rungs_per_request, 3);
471        let sp = c.escalation.session_promotion.as_ref().unwrap();
472        assert_eq!(sp.after_failures, 3);
473        assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
474    }
475
476    #[test]
477    fn first_matching_route_wins() {
478        let c = Config::parse(SPEC_CONFIG).unwrap();
479
480        // claude-code / test-runner -> route 0
481        let mut f = Features::new(TaskKind::Explore);
482        f.agent = Some("claude-code".into());
483        f.subagent = Some("test-runner".into());
484        let r = c.route_for(&f).unwrap();
485        assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
486
487        // a code_edit with no agent -> route 1 (route 0's agent constraint fails)
488        let f2 = Features::new(TaskKind::CodeEdit);
489        let r2 = c.route_for(&f2).unwrap();
490        assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
491
492        // anything else -> catch-all observe route
493        let f3 = Features::new(TaskKind::Chat);
494        let r3 = c.route_for(&f3).unwrap();
495        assert_eq!(r3.mode, Mode::Observe);
496    }
497
498    #[test]
499    fn shipped_example_config_parses() {
500        // The repo ships `firstpass.example.toml` for users to copy. If it drifts
501        // from the schema, this fails in CI rather than at a user's first run.
502        let toml = include_str!("../../../firstpass.example.toml");
503        let c = Config::parse(toml).expect("firstpass.example.toml must parse");
504        assert_eq!(c.routes.len(), 3);
505        assert_eq!(c.routes[0].mode, Mode::Enforce);
506    }
507
508    #[test]
509    fn parses_gate_definitions() {
510        let toml = r#"
511[[route]]
512match = {}
513mode  = "enforce"
514ladder = ["anthropic/claude-haiku-4-5"]
515gates  = ["my-tests"]
516
517[[gate]]
518id  = "my-tests"
519cmd = ["pytest", "-q"]
520
521[[gate]]
522id         = "judge"
523cmd        = ["bash", "-c", "./judge.sh"]
524timeout_ms = 60000
525"#;
526        let c = Config::parse(toml).unwrap();
527        assert_eq!(c.gate_defs.len(), 2);
528        assert_eq!(c.gate_defs[0].id, "my-tests");
529        assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
530        assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
531        assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
532    }
533
534    #[test]
535    fn rejects_invalid_gate_definitions() {
536        let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
537        assert!(matches!(
538            Config::parse(empty_cmd),
539            Err(Error::InvalidConfig(_))
540        ));
541
542        let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
543        assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
544    }
545
546    #[test]
547    fn parses_adaptive_conformal_config() {
548        let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
549        let a = c
550            .escalation
551            .adaptive
552            .expect("[escalation.adaptive] should parse");
553        assert!((a.alpha - 0.1).abs() < 1e-9);
554        assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
555        // Absent => None (fixed-threshold serving, default byte-identical behavior).
556        assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
557    }
558
559    #[test]
560    fn parses_provider_entries_and_ladders_can_reference_them() {
561        let c = Config::parse(
562            r#"
563[[provider]]
564id = "groq"
565dialect = "openai"
566base_url = "https://api.groq.com/openai"
567api_key_env = "GROQ_API_KEY"
568
569[[provider]]
570id = "ollama"
571dialect = "openai"
572base_url = "http://localhost:11434"
573
574[[route]]
575match = {}
576mode = "enforce"
577ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
578"#,
579        )
580        .unwrap();
581        assert_eq!(c.providers.len(), 2);
582        let groq = &c.providers[0];
583        assert_eq!(groq.id, "groq");
584        assert_eq!(groq.dialect, Dialect::Openai);
585        assert_eq!(groq.base_url, "https://api.groq.com/openai");
586        assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
587        // A keyless local endpoint (Ollama) parses with no api_key_env.
588        assert_eq!(c.providers[1].id, "ollama");
589        assert!(c.providers[1].api_key_env.is_none());
590        // Absent => no extra providers (built-ins only).
591        assert!(Config::parse("").unwrap().providers.is_empty());
592    }
593
594    #[test]
595    fn empty_match_is_wildcard() {
596        let m = Match::default();
597        assert!(m.matches(&Features::new(TaskKind::Other)));
598    }
599
600    #[test]
601    fn subagent_list_membership() {
602        let c = Config::parse(SPEC_CONFIG).unwrap();
603        let route0 = &c.routes[0];
604        let mut f = Features::new(TaskKind::Other);
605        f.agent = Some("claude-code".into());
606        f.subagent = Some("docs-writer".into()); // not in [test-runner, explore]
607        assert!(!route0.match_.matches(&f));
608        f.subagent = Some("explore".into());
609        assert!(route0.match_.matches(&f));
610    }
611
612    #[test]
613    fn model_ref_parsing() {
614        let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
615        assert_eq!(m.provider, "anthropic");
616        assert_eq!(m.model, "claude-haiku-4-5");
617        assert!(ModelRef::parse("no-slash").is_err());
618        assert!(ModelRef::parse("/model").is_err());
619        assert!(ModelRef::parse("a/b/c").is_err());
620    }
621
622    #[test]
623    fn window_parsing_units_and_errors() {
624        assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
625        assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
626        assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
627        assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
628        assert!(parse_window("30x").is_err());
629        assert!(parse_window("abc").is_err());
630    }
631
632    #[test]
633    fn empty_config_defaults() {
634        let c = Config::parse("").unwrap();
635        assert!(c.routes.is_empty());
636        assert_eq!(c.escalation.max_rungs_per_request, 3);
637        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
638    }
639}