Skip to main content

lean_ctx_protocol/
auto_routing.rs

1//! Public `model=auto` wire contracts.
2//!
3//! These types describe the boundary between the OSS Runtime and a routing
4//! control plane. The production decision intelligence that produces a
5//! decision remains in `lean-ctx-enterprise` (Class D); this module contains
6//! only transportable data and structural validation.
7
8use crate::common::ValidationError;
9use serde::{Deserialize, Serialize};
10
11/// Requested routing behavior at the public client or gateway boundary.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum RoutingMode {
15    /// Use the explicitly requested model without auto-routing.
16    Explicit,
17    /// Permit an external control plane to provide an auto decision.
18    Auto,
19    /// Produce or transport a decision for observation without changing use.
20    Shadow,
21}
22
23/// Runtime configuration for the public `model=auto` surface.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct AutoRoutingConfig {
27    /// Whether auto-routing is accepted at the public boundary.
28    pub enabled: bool,
29    /// Model used when an auto decision is unavailable or rejected.
30    pub fallback_model: String,
31    /// Provider used with [`Self::fallback_model`].
32    pub fallback_provider: String,
33    /// Task classes for which the boundary may accept auto-routing.
34    #[serde(default)]
35    pub eligible_task_classes: Vec<String>,
36}
37
38impl Default for AutoRoutingConfig {
39    fn default() -> Self {
40        Self {
41            enabled: false,
42            fallback_model: "baseline".to_owned(),
43            fallback_provider: "baseline".to_owned(),
44            eligible_task_classes: Vec::new(),
45        }
46    }
47}
48
49impl AutoRoutingConfig {
50    /// Validate structural configuration invariants.
51    pub fn validate(&self) -> Result<(), ValidationError> {
52        require_non_empty(&self.fallback_model, "fallback_model")?;
53        require_non_empty(&self.fallback_provider, "fallback_provider")?;
54        for task_class in &self.eligible_task_classes {
55            require_non_empty(task_class, "eligible_task_classes entry")?;
56        }
57        Ok(())
58    }
59
60    /// Return whether a task class is present in the static allowlist.
61    pub fn allows_task_class(&self, task_class: &str) -> bool {
62        self.enabled
63            && self
64                .eligible_task_classes
65                .iter()
66                .any(|candidate| candidate == task_class)
67    }
68}
69
70/// Evidence thresholds that a shadow scheduler must satisfy before auto-routing.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct SchedulerGate {
74    pub min_evaluated_tasks: u64,
75    pub max_outcome_degradation_pct: f64,
76    pub min_cost_improvement_pct: f64,
77    pub require_holdout_evidence: bool,
78}
79
80/// Observed shadow-scheduler performance used to evaluate [`SchedulerGate`].
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct AutoRoutingEvidence {
84    pub evaluated_tasks: u64,
85    pub outcome_degradation_pct: f64,
86    pub cost_improvement_pct: f64,
87    pub has_holdout_evidence: bool,
88}
89
90/// Request envelope sent to an auto-routing boundary.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct AutoRoutingRequest {
94    pub task_id: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub requested_model: Option<String>,
97    pub routing_mode: RoutingMode,
98}
99
100impl AutoRoutingRequest {
101    /// Validate request identity and explicit-mode requirements.
102    pub fn validate(&self) -> Result<(), ValidationError> {
103        require_non_empty(&self.task_id, "task_id")?;
104        if let Some(model) = &self.requested_model {
105            require_non_empty(model, "requested_model")?;
106        }
107        if self.routing_mode == RoutingMode::Explicit && self.requested_model.is_none() {
108            return Err(ValidationError::new(
109                "explicit routing requires requested_model",
110            ));
111        }
112        Ok(())
113    }
114}
115
116/// Candidate selected by a control plane, with a portable fallback reference.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct RoutingDecision {
120    pub selected_model: String,
121    pub selected_provider: String,
122    pub rationale_code: String,
123    pub fallback_plan: String,
124    /// Calibrated confidence supplied by the decision producer, in [0, 1].
125    pub confidence: f64,
126}
127
128impl RoutingDecision {
129    /// Validate fields without interpreting or recomputing the decision.
130    pub fn validate(&self) -> Result<(), ValidationError> {
131        require_non_empty(&self.selected_model, "selected_model")?;
132        require_non_empty(&self.selected_provider, "selected_provider")?;
133        require_non_empty(&self.rationale_code, "rationale_code")?;
134        require_non_empty(&self.fallback_plan, "fallback_plan")?;
135        validate_unit_interval(self.confidence, "confidence")
136    }
137}
138
139/// Receipt fields that preserve the requested and selected model lineage.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct RoutingReceipt {
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub requested_model: Option<String>,
145    pub baseline_model: String,
146    pub selected_model: String,
147    pub decision_ref: String,
148    pub method_version: String,
149}
150
151impl RoutingReceipt {
152    /// Validate receipt lineage fields.
153    pub fn validate(&self) -> Result<(), ValidationError> {
154        if let Some(model) = &self.requested_model {
155            require_non_empty(model, "requested_model")?;
156        }
157        require_non_empty(&self.baseline_model, "baseline_model")?;
158        require_non_empty(&self.selected_model, "selected_model")?;
159        require_non_empty(&self.decision_ref, "decision_ref")?;
160        require_non_empty(&self.method_version, "method_version")
161    }
162}
163
164fn require_non_empty(value: &str, field: &str) -> Result<(), ValidationError> {
165    if value.trim().is_empty() {
166        Err(ValidationError::new(format!("{field} must not be empty")))
167    } else {
168        Ok(())
169    }
170}
171
172fn validate_unit_interval(value: f64, field: &str) -> Result<(), ValidationError> {
173    if value.is_finite() && (0.0..=1.0).contains(&value) {
174        Ok(())
175    } else {
176        Err(ValidationError::new(format!(
177            "{field} must be finite and between 0 and 1"
178        )))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn wire_types_round_trip() {
188        let config = AutoRoutingConfig {
189            enabled: true,
190            fallback_model: "baseline-model".to_owned(),
191            fallback_provider: "baseline-provider".to_owned(),
192            eligible_task_classes: vec!["coding".to_owned()],
193        };
194        let request = AutoRoutingRequest {
195            task_id: "task-1".to_owned(),
196            requested_model: Some("requested-model".to_owned()),
197            routing_mode: RoutingMode::Auto,
198        };
199        let decision = RoutingDecision {
200            selected_model: "selected-model".to_owned(),
201            selected_provider: "selected-provider".to_owned(),
202            rationale_code: "reference".to_owned(),
203            fallback_plan: "baseline-plan".to_owned(),
204            confidence: 0.8,
205        };
206        let receipt = RoutingReceipt {
207            requested_model: request.requested_model.clone(),
208            baseline_model: config.fallback_model.clone(),
209            selected_model: decision.selected_model.clone(),
210            decision_ref: "decision-1".to_owned(),
211            method_version: "v1".to_owned(),
212        };
213
214        let config_json = serde_json::to_string(&config).expect("config serializes");
215        let request_json = serde_json::to_string(&request).expect("request serializes");
216        let decision_json = serde_json::to_string(&decision).expect("decision serializes");
217        let receipt_json = serde_json::to_string(&receipt).expect("receipt serializes");
218        assert_eq!(
219            serde_json::from_str::<AutoRoutingConfig>(&config_json).unwrap(),
220            config
221        );
222        assert_eq!(
223            serde_json::from_str::<AutoRoutingRequest>(&request_json).unwrap(),
224            request
225        );
226        assert_eq!(
227            serde_json::from_str::<RoutingDecision>(&decision_json).unwrap(),
228            decision
229        );
230        assert_eq!(
231            serde_json::from_str::<RoutingReceipt>(&receipt_json).unwrap(),
232            receipt
233        );
234        config.validate().expect("valid config");
235        request.validate().expect("valid request");
236        decision.validate().expect("valid decision");
237        receipt.validate().expect("valid receipt");
238    }
239
240    #[test]
241    fn structural_validation_rejects_invalid_values() {
242        let request = AutoRoutingRequest {
243            task_id: "task-1".to_owned(),
244            requested_model: None,
245            routing_mode: RoutingMode::Explicit,
246        };
247        assert!(request.validate().is_err());
248
249        let decision = RoutingDecision {
250            selected_model: "model".to_owned(),
251            selected_provider: "provider".to_owned(),
252            rationale_code: "reason".to_owned(),
253            fallback_plan: "baseline".to_owned(),
254            confidence: 1.1,
255        };
256        assert!(decision.validate().is_err());
257    }
258}