Skip to main content

lean_ctx_proxy/
eligibility.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Static eligibility evaluation for the public proxy boundary.
3//!
4//! This evaluator checks only the supplied policy and observations. It does
5//! not calibrate confidence, learn weights, rank candidates, or persist task
6//! outcomes; production decision intelligence is Class D in
7//! `lean-ctx-enterprise`.
8
9use lean_ctx_protocol::RiskClass;
10pub use lean_ctx_protocol::eligibility::{
11    EligibilityContext, EligibilityInput, EligibilityPolicy, EligibilityResult,
12};
13
14/// Evaluate static eligibility conditions in stable policy-field order.
15pub 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
68/// Extension trait providing method syntax without adding intelligence to the
69/// protocol schema type.
70pub 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}