Skip to main content

lean_ctx_protocol/
eligibility.rs

1//! Static eligibility-policy contracts for controlled routing.
2//!
3//! The policy is an inspectable schema. Production calibration, candidate
4//! ranking, and policy authoring remain in `lean-ctx-enterprise` (Class D).
5
6use crate::{RiskClass, auto_routing::SchedulerGate, common::ValidationError};
7use serde::{Deserialize, Serialize};
8
9/// Static conditions that a task must satisfy before a control plane may use
10/// an auto-routing decision.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct EligibilityPolicy {
14    pub min_evaluated_tasks: u64,
15    pub min_confidence: f64,
16    pub max_risk_class: RiskClass,
17    #[serde(default)]
18    pub allowed_regions: Vec<String>,
19    #[serde(default)]
20    pub allowed_providers: Vec<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub required_task_class: Option<String>,
23    pub quality_floor: f64,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub scheduler_gate: Option<SchedulerGate>,
26}
27
28impl Default for EligibilityPolicy {
29    fn default() -> Self {
30        Self {
31            min_evaluated_tasks: 200,
32            min_confidence: 0.8,
33            max_risk_class: RiskClass::Medium,
34            allowed_regions: Vec::new(),
35            allowed_providers: Vec::new(),
36            required_task_class: None,
37            quality_floor: 0.99,
38            scheduler_gate: None,
39        }
40    }
41}
42
43impl EligibilityPolicy {
44    /// Validate policy bounds and list entries.
45    pub fn validate(&self) -> Result<(), ValidationError> {
46        if self.min_evaluated_tasks == 0 {
47            return Err(ValidationError::new(
48                "min_evaluated_tasks must be greater than zero",
49            ));
50        }
51        validate_unit_interval(self.min_confidence, "min_confidence")?;
52        validate_unit_interval(self.quality_floor, "quality_floor")?;
53        validate_allowlist(&self.allowed_regions, "allowed_regions")?;
54        validate_allowlist(&self.allowed_providers, "allowed_providers")?;
55        if let Some(task_class) = &self.required_task_class {
56            require_non_empty(task_class, "required_task_class")?;
57        }
58        Ok(())
59    }
60}
61
62/// Static observations supplied to the deterministic reference evaluator.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct EligibilityContext {
66    pub evaluated_tasks: u64,
67    pub confidence: f64,
68    pub risk_class: RiskClass,
69    pub region: String,
70    pub provider: String,
71    pub task_class: String,
72    pub quality: f64,
73}
74
75/// Alias emphasizing that the context is an evaluator input, not a decision.
76pub type EligibilityInput = EligibilityContext;
77
78impl EligibilityContext {
79    /// Validate static observation bounds.
80    pub fn validate(&self) -> Result<(), ValidationError> {
81        validate_unit_interval(self.confidence, "confidence")?;
82        validate_unit_interval(self.quality, "quality")?;
83        require_non_empty(&self.region, "region")?;
84        require_non_empty(&self.provider, "provider")?;
85        require_non_empty(&self.task_class, "task_class")
86    }
87}
88
89/// Result of applying only the policy's static conditions.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct EligibilityResult {
93    pub eligible: bool,
94    pub reasons: Vec<String>,
95    pub required_sample_size: u64,
96}
97
98impl EligibilityResult {
99    /// Construct an eligible result with the policy's required sample size.
100    pub fn eligible(required_sample_size: u64) -> Self {
101        Self {
102            eligible: true,
103            reasons: Vec::new(),
104            required_sample_size,
105        }
106    }
107}
108
109fn validate_allowlist(values: &[String], field: &str) -> Result<(), ValidationError> {
110    for value in values {
111        require_non_empty(value, &format!("{field} entry"))?;
112    }
113    Ok(())
114}
115
116fn require_non_empty(value: &str, field: &str) -> Result<(), ValidationError> {
117    if value.trim().is_empty() {
118        Err(ValidationError::new(format!("{field} must not be empty")))
119    } else {
120        Ok(())
121    }
122}
123
124fn validate_unit_interval(value: f64, field: &str) -> Result<(), ValidationError> {
125    if value.is_finite() && (0.0..=1.0).contains(&value) {
126        Ok(())
127    } else {
128        Err(ValidationError::new(format!(
129            "{field} must be finite and between 0 and 1"
130        )))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn policy_and_result_round_trip() {
140        let policy = EligibilityPolicy {
141            min_evaluated_tasks: 200,
142            min_confidence: 0.8,
143            max_risk_class: RiskClass::Medium,
144            allowed_regions: vec!["eu".to_owned()],
145            allowed_providers: vec!["provider-a".to_owned()],
146            required_task_class: Some("coding".to_owned()),
147            quality_floor: 0.99,
148            scheduler_gate: None,
149        };
150        let context = EligibilityContext {
151            evaluated_tasks: 250,
152            confidence: 0.9,
153            risk_class: RiskClass::Low,
154            region: "eu".to_owned(),
155            provider: "provider-a".to_owned(),
156            task_class: "coding".to_owned(),
157            quality: 1.0,
158        };
159        let result = EligibilityResult::eligible(policy.min_evaluated_tasks);
160
161        let policy_json = serde_json::to_string(&policy).expect("policy serializes");
162        let context_json = serde_json::to_string(&context).expect("context serializes");
163        let result_json = serde_json::to_string(&result).expect("result serializes");
164        assert_eq!(
165            serde_json::from_str::<EligibilityPolicy>(&policy_json).unwrap(),
166            policy
167        );
168        assert_eq!(
169            serde_json::from_str::<EligibilityContext>(&context_json).unwrap(),
170            context
171        );
172        assert_eq!(
173            serde_json::from_str::<EligibilityResult>(&result_json).unwrap(),
174            result
175        );
176    }
177
178    #[test]
179    fn policy_without_scheduler_gate_remains_compatible() {
180        let json = r#"{
181            "min_evaluated_tasks": 200,
182            "min_confidence": 0.8,
183            "max_risk_class": "medium",
184            "quality_floor": 0.99
185        }"#;
186
187        let policy: EligibilityPolicy = serde_json::from_str(json).expect("legacy policy parses");
188
189        assert!(policy.scheduler_gate.is_none());
190    }
191}