use crate::error::{Error, Result};
use crate::features::{Features, TaskKind};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Enforce,
Observe,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(rename = "route", default)]
pub routes: Vec<Route>,
#[serde(default)]
pub budget: Budget,
#[serde(default)]
pub escalation: Escalation,
#[serde(rename = "gate", default)]
pub gate_defs: Vec<GateDef>,
#[serde(rename = "price", default)]
pub price_defs: Vec<PriceDef>,
#[serde(rename = "provider", default)]
pub providers: Vec<ProviderDef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Dialect {
Anthropic,
Openai,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthScheme {
#[default]
ApiKey,
AwsSigv4,
GcpOauth,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderDef {
pub id: String,
pub dialect: Dialect,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default)]
pub auth: AuthScheme,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub project: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PriceDef {
pub model: String,
pub input_per_mtok: f64,
pub output_per_mtok: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GateDef {
pub id: String,
#[serde(default)]
pub cmd: Vec<String>,
#[serde(default = "default_gate_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub judge: Option<JudgeDef>,
#[serde(default)]
pub consistency: Option<ConsistencyDef>,
#[serde(default)]
pub schema: Option<serde_json::Value>,
#[serde(default)]
pub on_abstain: AbstainPolicy,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AbstainPolicy {
#[default]
FailOpen,
FailClosed,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JudgeDef {
pub model: String,
#[serde(default = "default_judge_threshold")]
pub threshold: f64,
#[serde(default)]
pub rubric: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConsistencyDef {
pub model: String,
#[serde(default = "default_consistency_k")]
pub k: u32,
pub threshold: f64,
}
fn default_gate_timeout_ms() -> u64 {
30_000
}
fn default_judge_threshold() -> f64 {
0.7
}
fn default_consistency_k() -> u32 {
3
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Route {
#[serde(rename = "match", default)]
pub match_: Match,
pub mode: Mode,
#[serde(default)]
pub ladder: Vec<String>,
#[serde(default)]
pub gates: Vec<String>,
#[serde(default)]
pub deferred_gates: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Match {
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub subagent: Option<StringOrVec>,
#[serde(default)]
pub task_kind: Option<TaskKind>,
#[serde(default)]
pub language: Option<StringOrVec>,
}
impl Match {
#[must_use]
pub fn matches(&self, f: &Features) -> bool {
if let Some(agent) = &self.agent
&& f.agent.as_deref() != Some(agent.as_str())
{
return false;
}
if let Some(subs) = &self.subagent {
match &f.subagent {
Some(s) if subs.contains(s) => {}
_ => return false,
}
}
if let Some(tk) = self.task_kind
&& f.task_kind != tk
{
return false;
}
if let Some(langs) = &self.language {
match &f.language {
Some(l) if langs.contains(l) => {}
_ => return false,
}
}
true
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrVec {
One(String),
Many(Vec<String>),
}
impl StringOrVec {
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
match self {
StringOrVec::One(s) => s == needle,
StringOrVec::Many(v) => v.iter().any(|s| s == needle),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnExhausted {
#[default]
ServeBestAttempt,
Error,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Budget {
#[serde(default)]
pub per_request_usd: Option<f64>,
#[serde(default)]
pub per_session_usd: Option<f64>,
#[serde(default)]
pub per_day_usd: Option<f64>,
#[serde(default)]
pub on_exhausted: OnExhausted,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Escalation {
#[serde(default = "default_max_rungs")]
pub max_rungs_per_request: u32,
#[serde(default)]
pub session_promotion: Option<SessionPromotion>,
#[serde(default)]
pub speculation: u32,
#[serde(default)]
pub serve_threshold: Option<f64>,
#[serde(default)]
pub adaptive: Option<AdaptiveConfig>,
#[serde(default = "default_enforce_structured")]
pub enforce_structured: bool,
#[serde(default)]
pub bandit: Option<BanditConfig>,
#[serde(default)]
pub speculation_band: Option<[f64; 2]>,
#[serde(default)]
pub exploration: Option<ExplorationConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdaptiveConfig {
pub alpha: f64,
#[serde(default = "default_adaptive_gamma")]
pub gamma: f64,
}
fn default_adaptive_gamma() -> f64 {
0.02
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExplorationConfig {
pub epsilon: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BanditConfig {
#[serde(default = "default_bandit_min_observations")]
pub min_observations: usize,
#[serde(default = "default_bandit_exploration")]
pub exploration: f64,
#[serde(default)]
pub algorithm: BanditAlgorithm,
#[serde(default = "default_bandit_discount")]
pub discount: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BanditAlgorithm {
#[default]
Ucb1,
Thompson,
}
fn default_bandit_discount() -> f64 {
1.0
}
fn default_bandit_min_observations() -> usize {
50
}
fn default_bandit_exploration() -> f64 {
1.0
}
const fn default_max_rungs() -> u32 {
3
}
fn default_enforce_structured() -> bool {
true
}
impl Default for Escalation {
fn default() -> Self {
Self {
max_rungs_per_request: default_max_rungs(),
session_promotion: None,
speculation: 0,
serve_threshold: None,
adaptive: None,
enforce_structured: default_enforce_structured(),
speculation_band: None,
bandit: None,
exploration: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionPromotion {
pub after_failures: u32,
pub window: String,
}
impl SessionPromotion {
pub fn window_duration(&self) -> Result<Duration> {
parse_window(&self.window)
}
}
fn parse_window(s: &str) -> Result<Duration> {
let s = s.trim();
let split = s
.find(|c: char| c.is_ascii_alphabetic())
.ok_or_else(|| Error::BadDuration(s.to_owned()))?;
let (num, unit) = s.split_at(split);
let n: u64 = num
.trim()
.parse()
.map_err(|_| Error::BadDuration(s.to_owned()))?;
let secs = match unit {
"s" => n,
"m" => n.saturating_mul(60),
"h" => n.saturating_mul(3600),
"d" => n.saturating_mul(86_400),
_ => return Err(Error::BadDuration(s.to_owned())),
};
Ok(Duration::from_secs(secs))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRef {
pub provider: String,
pub model: String,
}
impl ModelRef {
pub fn parse(s: &str) -> Result<Self> {
match s.split_once('/') {
Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
provider: p.to_owned(),
model: m.to_owned(),
}),
_ => Err(Error::BadModelRef(s.to_owned())),
}
}
}
impl Config {
pub fn parse(toml_str: &str) -> Result<Self> {
let config: Self = toml::from_str(toml_str)?;
let mut seen = std::collections::HashSet::new();
for def in &config.gate_defs {
if def.id.trim().is_empty() {
return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
}
let kinds_set = [
!def.cmd.is_empty(),
def.judge.is_some(),
def.consistency.is_some(),
def.schema.is_some(),
]
.iter()
.filter(|&&b| b)
.count();
if kinds_set != 1 {
return Err(Error::InvalidConfig(format!(
"gate {:?} must set exactly one of `cmd`, `judge`, `consistency`, or `schema`",
def.id
)));
}
if let Some(judge) = &def.judge
&& !(0.0..=1.0).contains(&judge.threshold)
{
return Err(Error::InvalidConfig(format!(
"gate {:?} judge threshold {} is outside [0, 1]",
def.id, judge.threshold
)));
}
if let Some(c) = &def.consistency {
if !(2..=8).contains(&c.k) {
return Err(Error::InvalidConfig(format!(
"gate {:?} consistency k {} is outside [2, 8]",
def.id, c.k
)));
}
if !(0.0..=1.0).contains(&c.threshold) {
return Err(Error::InvalidConfig(format!(
"gate {:?} consistency threshold {} is outside [0, 1]",
def.id, c.threshold
)));
}
}
if !seen.insert(def.id.as_str()) {
return Err(Error::InvalidConfig(format!(
"duplicate gate id {:?}",
def.id
)));
}
}
for price in &config.price_defs {
if price.model.trim().is_empty() {
return Err(Error::InvalidConfig(
"price model must not be empty".to_owned(),
));
}
let ok = |v: f64| v.is_finite() && v >= 0.0;
if !ok(price.input_per_mtok) || !ok(price.output_per_mtok) {
return Err(Error::InvalidConfig(format!(
"price for {:?} must be finite and >= 0",
price.model
)));
}
}
if let Some(b) = &config.escalation.bandit {
if !b.exploration.is_finite() || b.exploration < 0.0 {
return Err(Error::InvalidConfig(format!(
"bandit.exploration must be finite and >= 0, got {}",
b.exploration
)));
}
if !b.discount.is_finite() || b.discount <= 0.0 || b.discount > 1.0 {
return Err(Error::InvalidConfig(format!(
"bandit.discount must be in (0, 1], got {}",
b.discount
)));
}
}
if let Some([lo, hi]) = config.escalation.speculation_band {
let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
if !ok(lo) || !ok(hi) || lo > hi {
return Err(Error::InvalidConfig(format!(
"escalation.speculation_band must satisfy 0 <= low <= high <= 1, got [{lo}, {hi}]"
)));
}
}
if let Some(exp) = &config.escalation.exploration
&& (!exp.epsilon.is_finite() || exp.epsilon <= 0.0 || exp.epsilon > 0.5)
{
return Err(Error::InvalidConfig(format!(
"escalation.exploration.epsilon must be finite and in (0, 0.5], got {}",
exp.epsilon
)));
}
Ok(config)
}
#[must_use]
pub fn route_for(&self, f: &Features) -> Option<&Route> {
self.routes.iter().find(|r| r.match_.matches(f))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::features::Features;
const SPEC_CONFIG: &str = r#"
[[route]]
match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
mode = "enforce"
ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
gates = ["schema", "judge-diff"]
[[route]]
match = { task_kind = "code_edit" }
mode = "enforce"
ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
gates = ["patch-applies", "lint-diff", "judge-diff"]
deferred_gates = ["compiles", "tests"]
[[route]]
match = {}
mode = "observe"
ladder = ["anthropic/claude-opus-4-8"]
[budget]
per_request_usd = 0.50
per_session_usd = 10.00
per_day_usd = 250.00
on_exhausted = "serve_best_attempt"
[escalation]
max_rungs_per_request = 3
session_promotion = { after_failures = 3, window = "30m" }
"#;
#[test]
fn parses_the_spec_example() {
let c = Config::parse(SPEC_CONFIG).unwrap();
assert_eq!(c.routes.len(), 3);
assert_eq!(c.routes[0].mode, Mode::Enforce);
assert_eq!(
c.routes[0].ladder,
["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
);
assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
assert_eq!(c.budget.per_request_usd, Some(0.50));
assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
assert_eq!(c.escalation.max_rungs_per_request, 3);
let sp = c.escalation.session_promotion.as_ref().unwrap();
assert_eq!(sp.after_failures, 3);
assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
}
#[test]
fn first_matching_route_wins() {
let c = Config::parse(SPEC_CONFIG).unwrap();
let mut f = Features::new(TaskKind::Explore);
f.agent = Some("claude-code".into());
f.subagent = Some("test-runner".into());
let r = c.route_for(&f).unwrap();
assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
let f2 = Features::new(TaskKind::CodeEdit);
let r2 = c.route_for(&f2).unwrap();
assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
let f3 = Features::new(TaskKind::Chat);
let r3 = c.route_for(&f3).unwrap();
assert_eq!(r3.mode, Mode::Observe);
}
#[test]
fn shipped_example_config_parses() {
let toml = include_str!("../../../firstpass.example.toml");
let c = Config::parse(toml).expect("firstpass.example.toml must parse");
assert_eq!(c.routes.len(), 3);
assert_eq!(c.routes[0].mode, Mode::Enforce);
}
#[test]
fn parses_gate_definitions() {
let toml = r#"
[[route]]
match = {}
mode = "enforce"
ladder = ["anthropic/claude-haiku-4-5"]
gates = ["my-tests"]
[[gate]]
id = "my-tests"
cmd = ["pytest", "-q"]
[[gate]]
id = "judge"
cmd = ["bash", "-c", "./judge.sh"]
timeout_ms = 60000
"#;
let c = Config::parse(toml).unwrap();
assert_eq!(c.gate_defs.len(), 2);
assert_eq!(c.gate_defs[0].id, "my-tests");
assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
}
#[test]
fn rejects_invalid_gate_definitions() {
let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
assert!(matches!(
Config::parse(empty_cmd),
Err(Error::InvalidConfig(_))
));
let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
}
#[test]
fn parses_consistency_gate_definition() {
let toml = r#"
[[gate]]
id = "uncertainty"
consistency = { model = "anthropic/claude-haiku-4-5", k = 5, threshold = 0.6 }
"#;
let c = Config::parse(toml).unwrap();
assert_eq!(c.gate_defs.len(), 1);
let cons = c.gate_defs[0].consistency.as_ref().unwrap();
assert_eq!(cons.model, "anthropic/claude-haiku-4-5");
assert_eq!(cons.k, 5);
assert!((cons.threshold - 0.6).abs() < 1e-9);
}
#[test]
fn consistency_k_defaults_to_3() {
let toml = "[[gate]]\nid = \"u\"\nconsistency = { model = \"anthropic/claude-haiku-4-5\", threshold = 0.7 }\n";
let c = Config::parse(toml).unwrap();
assert_eq!(c.gate_defs[0].consistency.as_ref().unwrap().k, 3);
}
#[test]
fn rejects_exactly_one_of_violations_for_consistency() {
let both = "[[gate]]\nid = \"g\"\ncmd = [\"x\"]\nconsistency = { model = \"a/b\", threshold = 0.5 }\n";
assert!(matches!(Config::parse(both), Err(Error::InvalidConfig(_))));
let bad_k =
"[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 1, threshold = 0.5 }\n";
assert!(matches!(Config::parse(bad_k), Err(Error::InvalidConfig(_))));
let bad_k2 =
"[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", k = 9, threshold = 0.5 }\n";
assert!(matches!(
Config::parse(bad_k2),
Err(Error::InvalidConfig(_))
));
let bad_thresh =
"[[gate]]\nid = \"g\"\nconsistency = { model = \"a/b\", threshold = 1.1 }\n";
assert!(matches!(
Config::parse(bad_thresh),
Err(Error::InvalidConfig(_))
));
}
#[test]
fn parses_adaptive_conformal_config() {
let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
let a = c
.escalation
.adaptive
.expect("[escalation.adaptive] should parse");
assert!((a.alpha - 0.1).abs() < 1e-9);
assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
}
#[test]
fn parses_provider_entries_and_ladders_can_reference_them() {
let c = Config::parse(
r#"
[[provider]]
id = "groq"
dialect = "openai"
base_url = "https://api.groq.com/openai"
api_key_env = "GROQ_API_KEY"
[[provider]]
id = "ollama"
dialect = "openai"
base_url = "http://localhost:11434"
[[route]]
match = {}
mode = "enforce"
ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
"#,
)
.unwrap();
assert_eq!(c.providers.len(), 2);
let groq = &c.providers[0];
assert_eq!(groq.id, "groq");
assert_eq!(groq.dialect, Dialect::Openai);
assert_eq!(groq.base_url, "https://api.groq.com/openai");
assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
assert_eq!(c.providers[1].id, "ollama");
assert!(c.providers[1].api_key_env.is_none());
assert!(Config::parse("").unwrap().providers.is_empty());
}
#[test]
fn parses_aws_sigv4_provider_and_defaults_auth_to_api_key() {
let c = Config::parse(
r#"
[[provider]]
id = "bedrock"
dialect = "anthropic"
auth = "aws_sigv4"
region = "us-east-1"
"#,
)
.unwrap();
let bedrock = &c.providers[0];
assert_eq!(bedrock.auth, AuthScheme::AwsSigv4);
assert_eq!(bedrock.region.as_deref(), Some("us-east-1"));
assert!(bedrock.project.is_none());
let c2 = Config::parse(
r#"
[[provider]]
id = "groq"
dialect = "openai"
base_url = "https://api.groq.com/openai"
"#,
)
.unwrap();
assert_eq!(c2.providers[0].auth, AuthScheme::ApiKey);
}
#[test]
fn all_documented_provider_shapes_parse() {
let c = Config::parse(
r#"
[[provider]]
id = "groq"
dialect = "openai"
base_url = "https://api.groq.com/openai"
api_key_env = "GROQ_API_KEY"
[[provider]]
id = "ollama"
dialect = "openai"
base_url = "http://localhost:11434"
[[provider]]
id = "gemini"
dialect = "gemini"
base_url = "https://generativelanguage.googleapis.com"
api_key_env = "GEMINI_API_KEY"
[[provider]]
id = "bedrock"
dialect = "anthropic"
auth = "aws_sigv4"
region = "us-east-1"
[[provider]]
id = "vertex"
dialect = "anthropic"
auth = "gcp_oauth"
region = "us-east5"
project = "my-gcp-project"
"#,
)
.expect("every documented provider shape must parse");
assert_eq!(c.providers.len(), 5);
let gemini = c.providers.iter().find(|p| p.id == "gemini").unwrap();
assert_eq!(gemini.dialect, Dialect::Gemini);
assert_eq!(gemini.auth, AuthScheme::ApiKey);
let vertex = c.providers.iter().find(|p| p.id == "vertex").unwrap();
assert_eq!(vertex.auth, AuthScheme::GcpOauth);
assert_eq!(vertex.region.as_deref(), Some("us-east5"));
assert_eq!(vertex.project.as_deref(), Some("my-gcp-project"));
}
#[test]
fn empty_match_is_wildcard() {
let m = Match::default();
assert!(m.matches(&Features::new(TaskKind::Other)));
}
#[test]
fn subagent_list_membership() {
let c = Config::parse(SPEC_CONFIG).unwrap();
let route0 = &c.routes[0];
let mut f = Features::new(TaskKind::Other);
f.agent = Some("claude-code".into());
f.subagent = Some("docs-writer".into()); assert!(!route0.match_.matches(&f));
f.subagent = Some("explore".into());
assert!(route0.match_.matches(&f));
}
#[test]
fn model_ref_parsing() {
let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
assert_eq!(m.provider, "anthropic");
assert_eq!(m.model, "claude-haiku-4-5");
assert!(ModelRef::parse("no-slash").is_err());
assert!(ModelRef::parse("/model").is_err());
assert!(ModelRef::parse("a/b/c").is_err());
}
#[test]
fn window_parsing_units_and_errors() {
assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
assert!(parse_window("30x").is_err());
assert!(parse_window("abc").is_err());
}
#[test]
fn empty_config_defaults() {
let c = Config::parse("").unwrap();
assert!(c.routes.is_empty());
assert_eq!(c.escalation.max_rungs_per_request, 3);
assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
}
#[test]
fn parses_exploration_config() {
let c = Config::parse("[escalation.exploration]\nepsilon = 0.1\n").unwrap();
let exp = c
.escalation
.exploration
.expect("[escalation.exploration] should parse");
assert!((exp.epsilon - 0.1).abs() < 1e-12);
assert!(Config::parse("").unwrap().escalation.exploration.is_none());
}
#[test]
fn exploration_epsilon_boundary_valid() {
let c = Config::parse("[escalation.exploration]\nepsilon = 0.5\n").unwrap();
assert!((c.escalation.exploration.unwrap().epsilon - 0.5).abs() < 1e-12);
}
#[test]
fn exploration_epsilon_above_half_rejected() {
let bad = "[escalation.exploration]\nepsilon = 0.51\n";
assert!(
matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
"epsilon > 0.5 must be rejected"
);
}
#[test]
fn exploration_epsilon_zero_rejected() {
let bad = "[escalation.exploration]\nepsilon = 0.0\n";
assert!(
matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
"epsilon = 0 must be rejected (must be strictly positive)"
);
}
#[test]
fn exploration_epsilon_negative_rejected() {
let bad = "[escalation.exploration]\nepsilon = -0.1\n";
assert!(
matches!(Config::parse(bad), Err(Error::InvalidConfig(_))),
"epsilon < 0 must be rejected"
);
}
#[test]
fn gate_def_schema_and_on_abstain_parse() {
let toml = r#"
[[route]]
match = {}
mode = "enforce"
ladder = ["anthropic/claude-haiku-4-5"]
gates = ["extract-shape"]
[[gate]]
id = "extract-shape"
schema = { type = "object", required = ["name"] }
on_abstain = "fail_closed"
"#;
let config = Config::parse(toml).expect("schema gate def must parse");
let def = &config.gate_defs[0];
assert_eq!(def.id, "extract-shape");
let schema = def.schema.as_ref().expect("schema captured");
assert_eq!(schema["type"], "object");
assert_eq!(def.on_abstain, AbstainPolicy::FailClosed);
}
#[test]
fn gate_def_on_abstain_defaults_fail_open() {
let toml = r#"
[[route]]
match = {}
mode = "enforce"
ladder = ["anthropic/claude-haiku-4-5"]
[[gate]]
id = "tests"
cmd = ["true"]
"#;
let config = Config::parse(toml).expect("parse");
assert_eq!(config.gate_defs[0].on_abstain, AbstainPolicy::FailOpen);
}
#[test]
fn gate_def_rejects_schema_plus_cmd() {
let toml = r#"
[[route]]
match = {}
mode = "enforce"
ladder = ["anthropic/claude-haiku-4-5"]
[[gate]]
id = "both"
cmd = ["true"]
schema = { type = "object" }
"#;
let err = Config::parse(toml).unwrap_err();
assert!(
err.to_string().contains("exactly one"),
"two kinds must be rejected: {err}"
);
}
#[test]
fn price_overrides_parse_and_validate() {
let toml = r#"
[[route]]
match = {}
mode = "observe"
ladder = ["anthropic/claude-haiku-4-5"]
[[price]]
model = "anthropic/claude-haiku-4-5"
input_per_mtok = 0.8
output_per_mtok = 4.0
"#;
let config = Config::parse(toml).expect("price override must parse");
assert_eq!(config.price_defs[0].model, "anthropic/claude-haiku-4-5");
assert!((config.price_defs[0].input_per_mtok - 0.8).abs() < 1e-12);
let bad = toml.replace("input_per_mtok = 0.8", "input_per_mtok = -1.0");
assert!(Config::parse(&bad).is_err(), "negative price rejected");
}
#[test]
fn bandit_thompson_and_band_parse_and_validate() {
let toml = r#"
[[route]]
match = {}
mode = "enforce"
ladder = ["anthropic/claude-haiku-4-5"]
[escalation]
speculation = 1
speculation_band = [0.3, 0.7]
[escalation.bandit]
algorithm = "thompson"
discount = 0.98
"#;
let config = Config::parse(toml).expect("thompson + band must parse");
let b = config.escalation.bandit.as_ref().unwrap();
assert_eq!(b.algorithm, BanditAlgorithm::Thompson);
assert!((b.discount - 0.98).abs() < 1e-12);
assert_eq!(config.escalation.speculation_band, Some([0.3, 0.7]));
let plain = Config::parse(
"[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n[escalation.bandit]\n",
)
.unwrap();
let b = plain.escalation.bandit.as_ref().unwrap();
assert_eq!(b.algorithm, BanditAlgorithm::Ucb1);
assert!((b.discount - 1.0).abs() < 1e-12);
assert!(Config::parse(&toml.replace("discount = 0.98", "discount = 0.0")).is_err());
assert!(
Config::parse(&toml.replace(
"speculation_band = [0.3, 0.7]",
"speculation_band = [0.9, 0.2]"
))
.is_err()
);
}
}