1use crate::{RiskClass, auto_routing::SchedulerGate, common::ValidationError};
7use serde::{Deserialize, Serialize};
8
9#[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 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#[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
75pub type EligibilityInput = EligibilityContext;
77
78impl EligibilityContext {
79 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#[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 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}