lean_ctx_proxy/
eligibility.rs1use lean_ctx_protocol::RiskClass;
10pub use lean_ctx_protocol::eligibility::{
11 EligibilityContext, EligibilityInput, EligibilityPolicy, EligibilityResult,
12};
13
14pub fn evaluate(policy: &EligibilityPolicy, context: &EligibilityContext) -> EligibilityResult {
16 let mut reasons = Vec::new();
17
18 if policy.validate().is_err() {
19 reasons.push("invalid_policy".to_owned());
20 }
21 if context.validate().is_err() {
22 reasons.push("invalid_context".to_owned());
23 }
24
25 if context.evaluated_tasks < policy.min_evaluated_tasks {
26 reasons.push("insufficient_evaluated_tasks".to_owned());
27 }
28 if context.confidence < policy.min_confidence {
29 reasons.push("confidence_below_floor".to_owned());
30 }
31 if risk_rank(context.risk_class) > risk_rank(policy.max_risk_class) {
32 reasons.push("risk_class_exceeds_maximum".to_owned());
33 }
34 if !policy.allowed_regions.is_empty()
35 && !policy
36 .allowed_regions
37 .iter()
38 .any(|region| region == &context.region)
39 {
40 reasons.push("region_not_allowed".to_owned());
41 }
42 if !policy.allowed_providers.is_empty()
43 && !policy
44 .allowed_providers
45 .iter()
46 .any(|provider| provider == &context.provider)
47 {
48 reasons.push("provider_not_allowed".to_owned());
49 }
50 if policy
51 .required_task_class
52 .as_ref()
53 .is_some_and(|required| required != &context.task_class)
54 {
55 reasons.push("task_class_mismatch".to_owned());
56 }
57 if context.quality < policy.quality_floor {
58 reasons.push("quality_below_floor".to_owned());
59 }
60
61 EligibilityResult {
62 eligible: reasons.is_empty(),
63 reasons,
64 required_sample_size: policy.min_evaluated_tasks,
65 }
66}
67
68pub trait EligibilityPolicyEvaluator {
71 fn evaluate(&self, context: &EligibilityContext) -> EligibilityResult;
72}
73
74impl EligibilityPolicyEvaluator for EligibilityPolicy {
75 fn evaluate(&self, context: &EligibilityContext) -> EligibilityResult {
76 evaluate(self, context)
77 }
78}
79
80fn risk_rank(risk_class: RiskClass) -> u8 {
81 match risk_class {
82 RiskClass::Low => 0,
83 RiskClass::Medium => 1,
84 RiskClass::High => 2,
85 RiskClass::Critical => 3,
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 fn policy() -> EligibilityPolicy {
94 EligibilityPolicy {
95 min_evaluated_tasks: 200,
96 min_confidence: 0.8,
97 max_risk_class: RiskClass::Medium,
98 allowed_regions: vec!["eu".to_owned()],
99 allowed_providers: vec!["provider-a".to_owned()],
100 required_task_class: Some("coding".to_owned()),
101 quality_floor: 0.99,
102 scheduler_gate: None,
103 }
104 }
105
106 fn context() -> EligibilityContext {
107 EligibilityContext {
108 evaluated_tasks: 250,
109 confidence: 0.9,
110 risk_class: RiskClass::Low,
111 region: "eu".to_owned(),
112 provider: "provider-a".to_owned(),
113 task_class: "coding".to_owned(),
114 quality: 1.0,
115 }
116 }
117
118 #[test]
119 fn evaluator_accepts_static_policy_match() {
120 let result = evaluate(&policy(), &context());
121 assert!(result.eligible);
122 assert!(result.reasons.is_empty(), "{:?}", result.reasons);
123 assert_eq!(result.required_sample_size, 200);
124 assert!(EligibilityPolicyEvaluator::evaluate(&policy(), &context()).eligible);
125 }
126
127 #[test]
128 fn evaluator_reports_all_static_failures_in_order() {
129 let mut input = context();
130 input.evaluated_tasks = 1;
131 input.confidence = 0.1;
132 input.risk_class = RiskClass::Critical;
133 input.region = "us".to_owned();
134 input.provider = "provider-b".to_owned();
135 input.task_class = "review".to_owned();
136 input.quality = 0.5;
137
138 let result = evaluate(&policy(), &input);
139 assert!(!result.eligible);
140 assert_eq!(
141 result.reasons,
142 vec![
143 "insufficient_evaluated_tasks",
144 "confidence_below_floor",
145 "risk_class_exceeds_maximum",
146 "region_not_allowed",
147 "provider_not_allowed",
148 "task_class_mismatch",
149 "quality_below_floor",
150 ]
151 );
152 }
153}