1use crate::error::{Error, Result};
9use crate::features::{Features, TaskKind};
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Mode {
17 Enforce,
19 Observe,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum RoutingMode {
37 Observe,
40 Cost,
43 #[default]
46 Balanced,
47 Quality,
50 Latency,
53 Max,
56}
57
58impl RoutingMode {
59 #[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 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 #[must_use]
87 pub fn preset(self) -> ModePreset {
88 match self {
89 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 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 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 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 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 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#[derive(Debug, Clone, Copy)]
146pub struct ModePreset {
147 pub speculation: Option<u32>,
149 pub max_rungs_delta: Option<i32>,
152 pub start_at_top: bool,
154 pub description: &'static str,
156 pub tradeoff: &'static str,
158}
159
160#[derive(Debug, Clone, Default, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct Config {
164 #[serde(rename = "route", default)]
166 pub routes: Vec<Route>,
167 #[serde(default)]
169 pub budget: Budget,
170 #[serde(default)]
172 pub escalation: Escalation,
173 #[serde(rename = "gate", default)]
176 pub gate_defs: Vec<GateDef>,
177 #[serde(rename = "price", default)]
181 pub price_defs: Vec<PriceDef>,
182 #[serde(rename = "provider", default)]
187 pub providers: Vec<ProviderDef>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
194#[serde(rename_all = "lowercase")]
195pub enum Dialect {
196 Anthropic,
198 Openai,
200 Gemini,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum AuthScheme {
211 #[default]
214 ApiKey,
215 AwsSigv4,
218 GcpOauth,
221}
222
223#[derive(Debug, Clone, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct ProviderDef {
228 pub id: String,
230 pub dialect: Dialect,
232 #[serde(default)]
235 pub base_url: String,
236 #[serde(default)]
240 pub api_key_env: Option<String>,
241 #[serde(default)]
244 pub auth: AuthScheme,
245 #[serde(default)]
248 pub region: Option<String>,
249 #[serde(default)]
251 pub project: Option<String>,
252}
253
254#[derive(Debug, Clone, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct PriceDef {
258 pub model: String,
260 pub input_per_mtok: f64,
262 pub output_per_mtok: f64,
264}
265
266#[derive(Debug, Clone, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct GateDef {
277 pub id: String,
279 #[serde(default)]
282 pub cmd: Vec<String>,
283 #[serde(default = "default_gate_timeout_ms")]
286 pub timeout_ms: u64,
287 #[serde(default)]
289 pub judge: Option<JudgeDef>,
290 #[serde(default)]
292 pub consistency: Option<ConsistencyDef>,
293 #[serde(default)]
296 pub schema: Option<serde_json::Value>,
297 #[serde(default)]
302 pub on_abstain: AbstainPolicy,
303}
304
305#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum AbstainPolicy {
309 #[default]
311 FailOpen,
312 FailClosed,
314}
315
316#[derive(Debug, Clone, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct JudgeDef {
322 pub model: String,
324 #[serde(default = "default_judge_threshold")]
326 pub threshold: f64,
327 #[serde(default)]
329 pub rubric: Option<String>,
330}
331
332#[derive(Debug, Clone, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct ConsistencyDef {
345 pub model: String,
348 #[serde(default = "default_consistency_k")]
350 pub k: u32,
351 pub threshold: f64,
353}
354
355fn default_gate_timeout_ms() -> u64 {
358 30_000
359}
360
361fn default_judge_threshold() -> f64 {
363 0.7
364}
365
366fn default_consistency_k() -> u32 {
368 3
369}
370
371#[derive(Debug, Clone, Deserialize)]
373#[serde(deny_unknown_fields)]
374pub struct Route {
375 #[serde(rename = "match", default)]
377 pub match_: Match,
378 pub mode: Mode,
380 #[serde(default)]
382 pub ladder: Vec<String>,
383 #[serde(default)]
385 pub gates: Vec<String>,
386 #[serde(default)]
389 pub deferred_gates: Vec<String>,
390 #[serde(default)]
394 pub routing_mode: Option<RoutingMode>,
395}
396
397#[derive(Debug, Clone, Default, Deserialize)]
400#[serde(deny_unknown_fields)]
401pub struct Match {
402 #[serde(default)]
404 pub agent: Option<String>,
405 #[serde(default)]
407 pub subagent: Option<StringOrVec>,
408 #[serde(default)]
410 pub task_kind: Option<TaskKind>,
411 #[serde(default)]
413 pub language: Option<StringOrVec>,
414}
415
416impl Match {
417 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(untagged)]
449pub enum StringOrVec {
450 One(String),
452 Many(Vec<String>),
454}
455
456impl StringOrVec {
457 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
469#[serde(rename_all = "snake_case")]
470pub enum OnExhausted {
471 #[default]
473 ServeBestAttempt,
474 Error,
476}
477
478#[derive(Debug, Clone, Default, Deserialize)]
480#[serde(deny_unknown_fields)]
481pub struct Budget {
482 #[serde(default)]
484 pub per_request_usd: Option<f64>,
485 #[serde(default)]
487 pub per_session_usd: Option<f64>,
488 #[serde(default)]
490 pub per_day_usd: Option<f64>,
491 #[serde(default)]
493 pub on_exhausted: OnExhausted,
494}
495
496#[derive(Debug, Clone, Deserialize)]
498#[serde(deny_unknown_fields)]
499pub struct Escalation {
500 #[serde(default = "default_max_rungs")]
502 pub max_rungs_per_request: u32,
503 #[serde(default)]
505 pub session_promotion: Option<SessionPromotion>,
506 #[serde(default)]
509 pub speculation: u32,
510 #[serde(default)]
514 pub serve_threshold: Option<f64>,
515 #[serde(default)]
519 pub adaptive: Option<AdaptiveConfig>,
520 #[serde(default = "default_enforce_structured")]
527 pub enforce_structured: bool,
528 #[serde(default)]
533 pub bandit: Option<BanditConfig>,
534 #[serde(default)]
543 pub speculation_band: Option<[f64; 2]>,
544 #[serde(default)]
548 pub exploration: Option<ExplorationConfig>,
549 #[serde(default)]
553 pub probe: Option<ProbeConfig>,
554 #[serde(default)]
558 pub predictor: Option<PredictorConfig>,
559 #[serde(default)]
564 pub elastic: Option<ElasticConfig>,
565}
566
567#[derive(Debug, Clone, Copy, Deserialize)]
573#[serde(deny_unknown_fields)]
574pub struct PredictorConfig {
575 #[serde(default = "default_predictor_lr")]
577 pub lr: f64,
578 #[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#[derive(Debug, Clone, Deserialize)]
593#[serde(deny_unknown_fields)]
594pub struct AdaptiveConfig {
595 pub alpha: f64,
597 #[serde(default = "default_adaptive_gamma")]
599 pub gamma: f64,
600}
601
602fn default_adaptive_gamma() -> f64 {
603 0.02
604}
605
606#[derive(Debug, Clone, Deserialize)]
618#[serde(deny_unknown_fields)]
619pub struct ExplorationConfig {
620 pub epsilon: f64,
625}
626
627#[derive(Debug, Clone, Copy, Deserialize)]
635#[serde(deny_unknown_fields)]
636pub struct ProbeConfig {
637 pub k: u32,
640 pub sample_rate: f64,
643}
644
645#[derive(Debug, Clone, Deserialize, PartialEq)]
661#[serde(deny_unknown_fields)]
662pub struct ElasticConfig {
663 pub expensive_gates: Vec<String>,
667 pub lambda: f64,
670 #[serde(default)]
673 pub alpha: Option<f64>,
674 #[serde(default)]
676 pub delta: Option<f64>,
677 #[serde(default)]
680 pub calibration_id: Option<String>,
681}
682
683#[derive(Debug, Clone, Deserialize)]
687#[serde(deny_unknown_fields)]
688pub struct BanditConfig {
689 #[serde(default = "default_bandit_min_observations")]
692 pub min_observations: usize,
693 #[serde(default = "default_bandit_exploration")]
696 pub exploration: f64,
697 #[serde(default)]
702 pub algorithm: BanditAlgorithm,
703 #[serde(default = "default_bandit_discount")]
707 pub discount: f64,
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
712#[serde(rename_all = "lowercase")]
713pub enum BanditAlgorithm {
714 #[default]
716 Ucb1,
717 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
737fn 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#[derive(Debug, Clone, Deserialize)]
764#[serde(deny_unknown_fields)]
765pub struct SessionPromotion {
766 pub after_failures: u32,
768 pub window: String,
770}
771
772impl SessionPromotion {
773 pub fn window_duration(&self) -> Result<Duration> {
778 parse_window(&self.window)
779 }
780}
781
782fn 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#[derive(Debug, Clone, PartialEq, Eq)]
805pub struct ModelRef {
806 pub provider: String,
808 pub model: String,
810}
811
812impl ModelRef {
813 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 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 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 #[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 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 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 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 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 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 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 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 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 assert_eq!(c.providers[1].id, "ollama");
1197 assert!(c.providers[1].api_key_env.is_none());
1198 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 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 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()); 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 #[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 assert!(Config::parse("").unwrap().escalation.exploration.is_none());
1342 }
1343
1344 #[test]
1345 fn exploration_epsilon_boundary_valid() {
1346 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 #[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 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 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 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 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 #[test]
1600 fn probe_absent_defaults_to_none() {
1601 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 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 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 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}