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}
42
43/// A user-defined gate (SPEC §8.1). Exactly one kind per definition:
44/// - **subprocess** (`cmd`): any executable that reads the candidate as JSON on **stdin** (never
45///   argv — injection-resistant) and emits `{"verdict":"pass|fail|abstain", ...}` on stdout.
46/// - **judge** (`judge`): a native LLM-judge gate that grades the candidate against a rubric.
47#[derive(Debug, Clone, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct GateDef {
50    /// The id a route references this gate by (must be unique and not shadow a built-in gate id).
51    pub id: String,
52    /// Subprocess command: program first, then its args — e.g. `["pytest", "-q"]`. Set this **or**
53    /// `judge`, not both.
54    #[serde(default)]
55    pub cmd: Vec<String>,
56    /// Hard timeout in milliseconds for a subprocess gate; it abstains (`timeout`) if the process
57    /// runs longer.
58    #[serde(default = "default_gate_timeout_ms")]
59    pub timeout_ms: u64,
60    /// LLM-judge configuration. Set this **or** `cmd`, not both.
61    #[serde(default)]
62    pub judge: Option<JudgeDef>,
63}
64
65/// Configuration for a native LLM-judge gate (SPEC §8.3): a separate model grades the candidate
66/// against a rubric. The runner enforces maker ≠ checker (a model never grades its own output) and
67/// treats the candidate as data, not instructions.
68#[derive(Debug, Clone, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct JudgeDef {
71    /// The judge model, as `provider/model` (must differ from the candidate's model at runtime).
72    pub model: String,
73    /// Pass iff the judge's score ≥ this threshold, in `[0, 1]`.
74    #[serde(default = "default_judge_threshold")]
75    pub threshold: f64,
76    /// What "good" means for this route — handed to the judge as the grading rubric.
77    #[serde(default)]
78    pub rubric: Option<String>,
79}
80
81/// Default subprocess-gate timeout: 30s. Long enough for a test suite, short enough to bound the
82/// enforce-path tail.
83fn default_gate_timeout_ms() -> u64 {
84    30_000
85}
86
87/// Default judge pass threshold.
88fn default_judge_threshold() -> f64 {
89    0.7
90}
91
92/// One routing rule.
93#[derive(Debug, Clone, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct Route {
96    /// The traffic this route claims. An empty match (`{}`) matches everything.
97    #[serde(rename = "match", default)]
98    pub match_: Match,
99    /// Serving mode.
100    pub mode: Mode,
101    /// Model ladder, cheapest first, as `provider/model` strings.
102    #[serde(default)]
103    pub ladder: Vec<String>,
104    /// Inline gates run before serving (enforce mode).
105    #[serde(default)]
106    pub gates: Vec<String>,
107    /// Gates run asynchronously after serving; their verdicts attach to the trace as a
108    /// learning signal and never block the response.
109    #[serde(default)]
110    pub deferred_gates: Vec<String>,
111}
112
113/// Predicate over a request's [`Features`]. Every present field is an AND-constraint; absent
114/// fields are wildcards.
115#[derive(Debug, Clone, Default, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct Match {
118    /// Require a specific calling agent.
119    #[serde(default)]
120    pub agent: Option<String>,
121    /// Require the subagent to be one of these (or this one, if a bare string).
122    #[serde(default)]
123    pub subagent: Option<StringOrVec>,
124    /// Require a specific task kind.
125    #[serde(default)]
126    pub task_kind: Option<TaskKind>,
127    /// Require the language to be one of these (or this one, if a bare string).
128    #[serde(default)]
129    pub language: Option<StringOrVec>,
130}
131
132impl Match {
133    /// Whether `f` satisfies every present constraint.
134    #[must_use]
135    pub fn matches(&self, f: &Features) -> bool {
136        if let Some(agent) = &self.agent
137            && f.agent.as_deref() != Some(agent.as_str())
138        {
139            return false;
140        }
141        if let Some(subs) = &self.subagent {
142            match &f.subagent {
143                Some(s) if subs.contains(s) => {}
144                _ => return false,
145            }
146        }
147        if let Some(tk) = self.task_kind
148            && f.task_kind != tk
149        {
150            return false;
151        }
152        if let Some(langs) = &self.language {
153            match &f.language {
154                Some(l) if langs.contains(l) => {}
155                _ => return false,
156            }
157        }
158        true
159    }
160}
161
162/// A field that accepts either a single string or a list of strings in TOML/JSON.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum StringOrVec {
166    /// A single value.
167    One(String),
168    /// A set of values.
169    Many(Vec<String>),
170}
171
172impl StringOrVec {
173    /// Whether `needle` is contained in this value.
174    #[must_use]
175    pub fn contains(&self, needle: &str) -> bool {
176        match self {
177            StringOrVec::One(s) => s == needle,
178            StringOrVec::Many(v) => v.iter().any(|s| s == needle),
179        }
180    }
181}
182
183/// What to do when a spend cap is hit (SPEC §8.4 — never brick the customer).
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum OnExhausted {
187    /// Serve the best attempt seen so far (default).
188    #[default]
189    ServeBestAttempt,
190    /// Return a structured error instead of serving.
191    Error,
192}
193
194/// Spend caps. `None` means "no cap".
195#[derive(Debug, Clone, Default, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct Budget {
198    /// Max USD per single request (across all rungs + gates).
199    #[serde(default)]
200    pub per_request_usd: Option<f64>,
201    /// Max USD per session.
202    #[serde(default)]
203    pub per_session_usd: Option<f64>,
204    /// Max USD per day.
205    #[serde(default)]
206    pub per_day_usd: Option<f64>,
207    /// Behaviour when a cap is reached.
208    #[serde(default)]
209    pub on_exhausted: OnExhausted,
210}
211
212/// Escalation limits and session-level promotion.
213#[derive(Debug, Clone, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct Escalation {
216    /// Hard ceiling on rungs climbed within one request.
217    #[serde(default = "default_max_rungs")]
218    pub max_rungs_per_request: u32,
219    /// Optionally start higher for the rest of a session that keeps failing.
220    #[serde(default)]
221    pub session_promotion: Option<SessionPromotion>,
222    /// Prefetch depth: fire this many rungs ahead concurrently to trade wasted spend for latency.
223    /// `0` (default) is serial — one call at a time. The served result is identical either way.
224    #[serde(default)]
225    pub speculation: u32,
226    /// Calibrated conformal serve threshold: a rung is served iff its aggregate gate score is
227    /// `>=` this value (SPEC §10.1). `None` (default) keeps the original rule — serve iff the
228    /// aggregate gate verdict is `Pass` — byte-identical to today.
229    #[serde(default)]
230    pub serve_threshold: Option<f64>,
231}
232
233const fn default_max_rungs() -> u32 {
234    3
235}
236
237impl Default for Escalation {
238    fn default() -> Self {
239        Self {
240            max_rungs_per_request: default_max_rungs(),
241            session_promotion: None,
242            speculation: 0,
243            serve_threshold: None,
244        }
245    }
246}
247
248/// "After N failures within a window, promote this session's starting rung."
249#[derive(Debug, Clone, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct SessionPromotion {
252    /// Failure count that triggers promotion.
253    pub after_failures: u32,
254    /// Sliding window, e.g. `"30m"`.
255    pub window: String,
256}
257
258impl SessionPromotion {
259    /// Parse [`SessionPromotion::window`] into a [`Duration`].
260    ///
261    /// # Errors
262    /// Returns [`Error::BadDuration`] if the window is not `<int><unit>` with unit in `s`/`m`/`h`/`d`.
263    pub fn window_duration(&self) -> Result<Duration> {
264        parse_window(&self.window)
265    }
266}
267
268/// Parse a compact duration like `30m`, `2h`, `90s`, `1d`.
269fn parse_window(s: &str) -> Result<Duration> {
270    let s = s.trim();
271    let split = s
272        .find(|c: char| c.is_ascii_alphabetic())
273        .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
274    let (num, unit) = s.split_at(split);
275    let n: u64 = num
276        .trim()
277        .parse()
278        .map_err(|_| Error::BadDuration(s.to_owned()))?;
279    let secs = match unit {
280        "s" => n,
281        "m" => n.saturating_mul(60),
282        "h" => n.saturating_mul(3600),
283        "d" => n.saturating_mul(86_400),
284        _ => return Err(Error::BadDuration(s.to_owned())),
285    };
286    Ok(Duration::from_secs(secs))
287}
288
289/// A parsed `provider/model` reference.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct ModelRef {
292    /// Provider segment, e.g. `anthropic`.
293    pub provider: String,
294    /// Model segment, e.g. `claude-haiku-4-5`.
295    pub model: String,
296}
297
298impl ModelRef {
299    /// Parse a `provider/model` string.
300    ///
301    /// # Errors
302    /// Returns [`Error::BadModelRef`] if the input is not exactly one `provider/model` pair.
303    pub fn parse(s: &str) -> Result<Self> {
304        match s.split_once('/') {
305            Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
306                provider: p.to_owned(),
307                model: m.to_owned(),
308            }),
309            _ => Err(Error::BadModelRef(s.to_owned())),
310        }
311    }
312}
313
314impl Config {
315    /// Parse a TOML configuration document.
316    ///
317    /// # Errors
318    /// Returns [`Error::Config`] on invalid TOML or unknown fields, or [`Error::InvalidConfig`] on
319    /// an invalid gate definition (not exactly one of `cmd`/`judge`, a judge threshold outside
320    /// `[0,1]`, or a duplicate/blank `id`).
321    pub fn parse(toml_str: &str) -> Result<Self> {
322        let config: Self = toml::from_str(toml_str)?;
323        let mut seen = std::collections::HashSet::new();
324        for def in &config.gate_defs {
325            if def.id.trim().is_empty() {
326                return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
327            }
328            // Exactly one kind: a subprocess `cmd` or a `judge`, never both, never neither.
329            if def.cmd.is_empty() == def.judge.is_none() {
330                return Err(Error::InvalidConfig(format!(
331                    "gate {:?} must set exactly one of `cmd` or `judge`",
332                    def.id
333                )));
334            }
335            if let Some(judge) = &def.judge
336                && !(0.0..=1.0).contains(&judge.threshold)
337            {
338                return Err(Error::InvalidConfig(format!(
339                    "gate {:?} judge threshold {} is outside [0, 1]",
340                    def.id, judge.threshold
341                )));
342            }
343            if !seen.insert(def.id.as_str()) {
344                return Err(Error::InvalidConfig(format!(
345                    "duplicate gate id {:?}",
346                    def.id
347                )));
348            }
349        }
350        Ok(config)
351    }
352
353    /// The first route whose match claims `f`, or `None` if no route matches.
354    #[must_use]
355    pub fn route_for(&self, f: &Features) -> Option<&Route> {
356        self.routes.iter().find(|r| r.match_.matches(f))
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::features::Features;
364
365    const SPEC_CONFIG: &str = r#"
366[[route]]
367match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
368mode  = "enforce"
369ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
370gates  = ["schema", "judge-diff"]
371
372[[route]]
373match = { task_kind = "code_edit" }
374mode  = "enforce"
375ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
376gates  = ["patch-applies", "lint-diff", "judge-diff"]
377deferred_gates = ["compiles", "tests"]
378
379[[route]]
380match = {}
381mode  = "observe"
382ladder = ["anthropic/claude-opus-4-8"]
383
384[budget]
385per_request_usd = 0.50
386per_session_usd = 10.00
387per_day_usd     = 250.00
388on_exhausted    = "serve_best_attempt"
389
390[escalation]
391max_rungs_per_request = 3
392session_promotion = { after_failures = 3, window = "30m" }
393"#;
394
395    #[test]
396    fn parses_the_spec_example() {
397        let c = Config::parse(SPEC_CONFIG).unwrap();
398        assert_eq!(c.routes.len(), 3);
399        assert_eq!(c.routes[0].mode, Mode::Enforce);
400        assert_eq!(
401            c.routes[0].ladder,
402            ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
403        );
404        assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
405        assert_eq!(c.budget.per_request_usd, Some(0.50));
406        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
407        assert_eq!(c.escalation.max_rungs_per_request, 3);
408        let sp = c.escalation.session_promotion.as_ref().unwrap();
409        assert_eq!(sp.after_failures, 3);
410        assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
411    }
412
413    #[test]
414    fn first_matching_route_wins() {
415        let c = Config::parse(SPEC_CONFIG).unwrap();
416
417        // claude-code / test-runner -> route 0
418        let mut f = Features::new(TaskKind::Explore);
419        f.agent = Some("claude-code".into());
420        f.subagent = Some("test-runner".into());
421        let r = c.route_for(&f).unwrap();
422        assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
423
424        // a code_edit with no agent -> route 1 (route 0's agent constraint fails)
425        let f2 = Features::new(TaskKind::CodeEdit);
426        let r2 = c.route_for(&f2).unwrap();
427        assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
428
429        // anything else -> catch-all observe route
430        let f3 = Features::new(TaskKind::Chat);
431        let r3 = c.route_for(&f3).unwrap();
432        assert_eq!(r3.mode, Mode::Observe);
433    }
434
435    #[test]
436    fn shipped_example_config_parses() {
437        // The repo ships `firstpass.example.toml` for users to copy. If it drifts
438        // from the schema, this fails in CI rather than at a user's first run.
439        let toml = include_str!("../../../firstpass.example.toml");
440        let c = Config::parse(toml).expect("firstpass.example.toml must parse");
441        assert_eq!(c.routes.len(), 3);
442        assert_eq!(c.routes[0].mode, Mode::Enforce);
443    }
444
445    #[test]
446    fn parses_gate_definitions() {
447        let toml = r#"
448[[route]]
449match = {}
450mode  = "enforce"
451ladder = ["anthropic/claude-haiku-4-5"]
452gates  = ["my-tests"]
453
454[[gate]]
455id  = "my-tests"
456cmd = ["pytest", "-q"]
457
458[[gate]]
459id         = "judge"
460cmd        = ["bash", "-c", "./judge.sh"]
461timeout_ms = 60000
462"#;
463        let c = Config::parse(toml).unwrap();
464        assert_eq!(c.gate_defs.len(), 2);
465        assert_eq!(c.gate_defs[0].id, "my-tests");
466        assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
467        assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
468        assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
469    }
470
471    #[test]
472    fn rejects_invalid_gate_definitions() {
473        let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
474        assert!(matches!(
475            Config::parse(empty_cmd),
476            Err(Error::InvalidConfig(_))
477        ));
478
479        let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
480        assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
481    }
482
483    #[test]
484    fn empty_match_is_wildcard() {
485        let m = Match::default();
486        assert!(m.matches(&Features::new(TaskKind::Other)));
487    }
488
489    #[test]
490    fn subagent_list_membership() {
491        let c = Config::parse(SPEC_CONFIG).unwrap();
492        let route0 = &c.routes[0];
493        let mut f = Features::new(TaskKind::Other);
494        f.agent = Some("claude-code".into());
495        f.subagent = Some("docs-writer".into()); // not in [test-runner, explore]
496        assert!(!route0.match_.matches(&f));
497        f.subagent = Some("explore".into());
498        assert!(route0.match_.matches(&f));
499    }
500
501    #[test]
502    fn model_ref_parsing() {
503        let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
504        assert_eq!(m.provider, "anthropic");
505        assert_eq!(m.model, "claude-haiku-4-5");
506        assert!(ModelRef::parse("no-slash").is_err());
507        assert!(ModelRef::parse("/model").is_err());
508        assert!(ModelRef::parse("a/b/c").is_err());
509    }
510
511    #[test]
512    fn window_parsing_units_and_errors() {
513        assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
514        assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
515        assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
516        assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
517        assert!(parse_window("30x").is_err());
518        assert!(parse_window("abc").is_err());
519    }
520
521    #[test]
522    fn empty_config_defaults() {
523        let c = Config::parse("").unwrap();
524        assert!(c.routes.is_empty());
525        assert_eq!(c.escalation.max_rungs_per_request, 3);
526        assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
527    }
528}