Skip to main content

apr_cli/federation/
policy.rs

1//! Routing policies for federation
2//!
3//! Policies determine HOW nodes are selected for inference requests.
4//! Multiple policies can be composed (scored, weighted, chained).
5
6use super::traits::*;
7use std::time::Duration;
8
9// ============================================================================
10// Selection Criteria
11// ============================================================================
12
13/// Criteria for selecting nodes
14#[derive(Debug, Clone)]
15pub struct SelectionCriteria {
16    /// Required capability
17    pub capability: Capability,
18    /// Minimum health state
19    pub min_health: HealthState,
20    /// Maximum latency
21    pub max_latency: Option<Duration>,
22    /// Required privacy level
23    pub min_privacy: PrivacyLevel,
24    /// Preferred regions (in order)
25    pub preferred_regions: Vec<RegionId>,
26    /// Excluded nodes
27    pub excluded_nodes: Vec<NodeId>,
28}
29
30impl Default for SelectionCriteria {
31    fn default() -> Self {
32        Self {
33            capability: Capability::Generate,
34            min_health: HealthState::Degraded,
35            max_latency: None,
36            min_privacy: PrivacyLevel::Public,
37            preferred_regions: vec![],
38            excluded_nodes: vec![],
39        }
40    }
41}
42
43// ============================================================================
44// Concrete Routing Policies
45// ============================================================================
46
47/// Latency-based routing policy
48///
49/// Scores nodes inversely proportional to their latency.
50/// Lower latency = higher score.
51pub struct LatencyPolicy {
52    /// Weight for this policy in composite scoring
53    pub weight: f64,
54    /// Maximum acceptable latency (nodes above this get score 0)
55    pub max_latency: Duration,
56}
57
58impl Default for LatencyPolicy {
59    fn default() -> Self {
60        Self {
61            weight: 1.0,
62            max_latency: Duration::from_secs(5),
63        }
64    }
65}
66
67impl RoutingPolicyTrait for LatencyPolicy {
68    fn score(&self, candidate: &RouteCandidate, _request: &InferenceRequest) -> f64 {
69        let latency_ms = candidate.target.estimated_latency.as_millis() as f64;
70        let max_ms = self.max_latency.as_millis() as f64;
71
72        if latency_ms >= max_ms {
73            return 0.0;
74        }
75
76        // Score: 1.0 at 0ms, 0.0 at max_latency
77        let score = 1.0 - (latency_ms / max_ms);
78        score * self.weight
79    }
80
81    fn is_eligible(&self, candidate: &RouteCandidate, _request: &InferenceRequest) -> bool {
82        candidate.target.estimated_latency <= self.max_latency
83    }
84
85    fn name(&self) -> &'static str {
86        "latency"
87    }
88}
89
90/// Locality-based routing policy
91///
92/// Prefers nodes in the same region as the request origin.
93/// Useful for data sovereignty and latency.
94pub struct LocalityPolicy {
95    /// Weight for this policy
96    pub weight: f64,
97    /// Score boost for same-region
98    pub same_region_boost: f64,
99    /// Score penalty for cross-region
100    pub cross_region_penalty: f64,
101}
102
103impl Default for LocalityPolicy {
104    fn default() -> Self {
105        Self {
106            weight: 1.0,
107            same_region_boost: 0.3,
108            cross_region_penalty: 0.1,
109        }
110    }
111}
112
113impl RoutingPolicyTrait for LocalityPolicy {
114    fn score(&self, candidate: &RouteCandidate, _request: &InferenceRequest) -> f64 {
115        // Check if request has tenant locality preference
116        let base_score = 0.5;
117
118        // For now, use locality score from candidate
119        let score = base_score + candidate.scores.locality_score * self.same_region_boost;
120        score * self.weight
121    }
122
123    fn is_eligible(&self, _candidate: &RouteCandidate, _request: &InferenceRequest) -> bool {
124        true // Locality is a preference, not a hard requirement
125    }
126
127    fn name(&self) -> &'static str {
128        "locality"
129    }
130}
131
132/// Privacy-based routing policy
133///
134/// Enforces data sovereignty by filtering nodes based on privacy level.
135#[derive(Default)]
136pub struct PrivacyPolicy {
137    /// Region privacy levels
138    pub region_privacy: std::collections::HashMap<RegionId, PrivacyLevel>,
139}
140
141impl PrivacyPolicy {
142    /// Add a region with its privacy level
143    #[must_use]
144    pub fn with_region(mut self, region: RegionId, level: PrivacyLevel) -> Self {
145        self.region_privacy.insert(region, level);
146        self
147    }
148}
149
150impl RoutingPolicyTrait for PrivacyPolicy {
151    fn score(&self, _candidate: &RouteCandidate, _request: &InferenceRequest) -> f64 {
152        1.0 // Privacy is binary: eligible or not
153    }
154
155    fn is_eligible(&self, candidate: &RouteCandidate, request: &InferenceRequest) -> bool {
156        let region_level = self
157            .region_privacy
158            .get(&candidate.target.region_id)
159            .copied()
160            // Default to Internal - unknown regions can handle internal traffic
161            .unwrap_or(PrivacyLevel::Internal);
162
163        // Region must meet or exceed request's privacy requirement
164        region_level >= request.qos.privacy
165    }
166
167    fn name(&self) -> &'static str {
168        "privacy"
169    }
170}
171
172/// Cost-based routing policy
173///
174/// Balances cost vs performance based on user tolerance.
175pub struct CostPolicy {
176    /// Weight for this policy
177    pub weight: f64,
178    /// Cost per region (0.0 = cheapest, 1.0 = most expensive)
179    pub region_costs: std::collections::HashMap<RegionId, f64>,
180}
181
182impl Default for CostPolicy {
183    fn default() -> Self {
184        Self {
185            weight: 1.0,
186            region_costs: std::collections::HashMap::new(),
187        }
188    }
189}
190
191impl CostPolicy {
192    #[must_use]
193    pub fn with_region_cost(mut self, region: RegionId, cost: f64) -> Self {
194        self.region_costs.insert(region, cost.clamp(0.0, 1.0));
195        self
196    }
197}
198
199impl RoutingPolicyTrait for CostPolicy {
200    fn score(&self, candidate: &RouteCandidate, request: &InferenceRequest) -> f64 {
201        let region_cost = self
202            .region_costs
203            .get(&candidate.target.region_id)
204            .copied()
205            .unwrap_or(0.5);
206
207        let cost_tolerance = request.qos.cost_tolerance as f64 / 100.0;
208
209        // High tolerance = prefer fast (expensive)
210        // Low tolerance = prefer cheap
211        let score = if cost_tolerance > 0.5 {
212            // User tolerates cost, score performance
213            candidate.scores.throughput_score
214        } else {
215            // User wants cheap, invert cost
216            1.0 - region_cost
217        };
218
219        score * self.weight
220    }
221
222    fn is_eligible(&self, _candidate: &RouteCandidate, _request: &InferenceRequest) -> bool {
223        true
224    }
225
226    fn name(&self) -> &'static str {
227        "cost"
228    }
229}
230
231/// Health-based routing policy
232///
233/// Strongly penalizes unhealthy or degraded nodes.
234pub struct HealthPolicy {
235    /// Weight for this policy
236    pub weight: f64,
237    /// Score for healthy nodes
238    pub healthy_score: f64,
239    /// Score for degraded nodes
240    pub degraded_score: f64,
241}
242
243impl Default for HealthPolicy {
244    fn default() -> Self {
245        Self {
246            weight: 2.0, // Health is important!
247            healthy_score: 1.0,
248            degraded_score: 0.3,
249        }
250    }
251}
252
253impl RoutingPolicyTrait for HealthPolicy {
254    fn score(&self, candidate: &RouteCandidate, _request: &InferenceRequest) -> f64 {
255        candidate.scores.health_score * self.weight
256    }
257
258    fn is_eligible(&self, candidate: &RouteCandidate, _request: &InferenceRequest) -> bool {
259        // Must have some health score
260        candidate.scores.health_score > 0.0
261    }
262
263    fn name(&self) -> &'static str {
264        "health"
265    }
266}
267
268// ============================================================================
269// Composite Policy
270// ============================================================================
271
272/// Combines multiple policies with weighted scoring
273pub struct CompositePolicy {
274    policies: Vec<Box<dyn RoutingPolicyTrait>>,
275}
276
277impl CompositePolicy {
278    pub fn new() -> Self {
279        Self { policies: vec![] }
280    }
281
282    #[must_use]
283    pub fn with_policy(mut self, policy: impl RoutingPolicyTrait + 'static) -> Self {
284        self.policies.push(Box::new(policy));
285        self
286    }
287
288    /// Create default enterprise policy
289    pub fn enterprise_default() -> Self {
290        Self::new()
291            .with_policy(HealthPolicy::default())
292            .with_policy(LatencyPolicy::default())
293            .with_policy(PrivacyPolicy::default())
294            .with_policy(LocalityPolicy::default())
295            .with_policy(CostPolicy::default())
296    }
297}
298
299impl Default for CompositePolicy {
300    fn default() -> Self {
301        Self::enterprise_default()
302    }
303}
304
305impl RoutingPolicyTrait for CompositePolicy {
306    fn score(&self, candidate: &RouteCandidate, request: &InferenceRequest) -> f64 {
307        if self.policies.is_empty() {
308            return 1.0;
309        }
310
311        let total: f64 = self
312            .policies
313            .iter()
314            .map(|p| p.score(candidate, request))
315            .sum();
316
317        total / self.policies.len() as f64
318    }
319
320    fn is_eligible(&self, candidate: &RouteCandidate, request: &InferenceRequest) -> bool {
321        // Must pass ALL policies
322        self.policies
323            .iter()
324            .all(|p| p.is_eligible(candidate, request))
325    }
326
327    fn name(&self) -> &'static str {
328        "composite"
329    }
330}
331
332// ============================================================================
333// Wrapper type for export
334// ============================================================================
335
336/// Routing policy configuration
337pub struct RoutingPolicy {
338    #[allow(dead_code)]
339    inner: Box<dyn RoutingPolicyTrait>,
340}
341
342impl RoutingPolicy {
343    pub fn latency() -> Self {
344        Self {
345            inner: Box::new(LatencyPolicy::default()),
346        }
347    }
348
349    pub fn locality() -> Self {
350        Self {
351            inner: Box::new(LocalityPolicy::default()),
352        }
353    }
354
355    pub fn privacy() -> Self {
356        Self {
357            inner: Box::new(PrivacyPolicy::default()),
358        }
359    }
360
361    pub fn cost() -> Self {
362        Self {
363            inner: Box::new(CostPolicy::default()),
364        }
365    }
366
367    pub fn health() -> Self {
368        Self {
369            inner: Box::new(HealthPolicy::default()),
370        }
371    }
372
373    pub fn enterprise() -> Self {
374        Self {
375            inner: Box::new(CompositePolicy::enterprise_default()),
376        }
377    }
378}
379
380// ============================================================================
381// Tests
382// ============================================================================
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    fn mock_candidate(latency_ms: u64, health_score: f64) -> RouteCandidate {
389        RouteCandidate {
390            target: RouteTarget {
391                node_id: NodeId("node1".to_string()),
392                region_id: RegionId("us-west".to_string()),
393                endpoint: "http://node1:8080".to_string(),
394                estimated_latency: Duration::from_millis(latency_ms),
395                score: 0.0,
396            },
397            scores: RouteScores {
398                latency_score: 1.0 - (latency_ms as f64 / 5000.0),
399                throughput_score: 0.8,
400                cost_score: 0.5,
401                locality_score: 0.7,
402                health_score,
403                total: 0.0,
404            },
405            eligible: true,
406            rejection_reason: None,
407        }
408    }
409
410    fn mock_request() -> InferenceRequest {
411        InferenceRequest {
412            capability: Capability::Generate,
413            input: vec![],
414            qos: QoSRequirements::default(),
415            request_id: "req-1".to_string(),
416            tenant_id: None,
417        }
418    }
419
420    #[test]
421    fn test_latency_policy_scoring() {
422        let policy = LatencyPolicy::default();
423        let request = mock_request();
424
425        // Fast node should score higher
426        let fast = mock_candidate(100, 1.0);
427        let slow = mock_candidate(4000, 1.0);
428
429        let fast_score = policy.score(&fast, &request);
430        let slow_score = policy.score(&slow, &request);
431
432        assert!(fast_score > slow_score);
433        assert!(fast_score > 0.9); // 100ms out of 5000ms max
434    }
435
436    #[test]
437    fn test_latency_policy_eligibility() {
438        let policy = LatencyPolicy {
439            max_latency: Duration::from_secs(2),
440            ..Default::default()
441        };
442        let request = mock_request();
443
444        let fast = mock_candidate(1000, 1.0);
445        let slow = mock_candidate(3000, 1.0);
446
447        assert!(policy.is_eligible(&fast, &request));
448        assert!(!policy.is_eligible(&slow, &request));
449    }
450
451    #[test]
452    fn test_health_policy_scoring() {
453        let policy = HealthPolicy::default();
454        let request = mock_request();
455
456        let healthy = mock_candidate(100, 1.0);
457        let degraded = mock_candidate(100, 0.3);
458
459        let healthy_score = policy.score(&healthy, &request);
460        let degraded_score = policy.score(&degraded, &request);
461
462        assert!(healthy_score > degraded_score);
463    }
464
465    #[test]
466    fn test_composite_policy() {
467        let policy = CompositePolicy::enterprise_default();
468        let request = mock_request();
469
470        let good = mock_candidate(100, 1.0);
471        let bad = mock_candidate(4000, 0.2);
472
473        let good_score = policy.score(&good, &request);
474        let bad_score = policy.score(&bad, &request);
475
476        assert!(good_score > bad_score);
477    }
478
479    #[test]
480    fn test_privacy_policy_eligibility() {
481        let policy = PrivacyPolicy::default()
482            .with_region(RegionId("eu-west".to_string()), PrivacyLevel::Confidential)
483            .with_region(RegionId("us-east".to_string()), PrivacyLevel::Public);
484
485        let mut request = mock_request();
486        request.qos.privacy = PrivacyLevel::Confidential;
487
488        let eu_candidate = RouteCandidate {
489            target: RouteTarget {
490                node_id: NodeId("node-eu".to_string()),
491                region_id: RegionId("eu-west".to_string()),
492                endpoint: "http://eu:8080".to_string(),
493                estimated_latency: Duration::from_millis(100),
494                score: 0.0,
495            },
496            scores: RouteScores::default(),
497            eligible: true,
498            rejection_reason: None,
499        };
500
501        let us_candidate = RouteCandidate {
502            target: RouteTarget {
503                node_id: NodeId("node-us".to_string()),
504                region_id: RegionId("us-east".to_string()),
505                endpoint: "http://us:8080".to_string(),
506                estimated_latency: Duration::from_millis(50),
507                score: 0.0,
508            },
509            scores: RouteScores::default(),
510            eligible: true,
511            rejection_reason: None,
512        };
513
514        // EU meets confidential requirement
515        assert!(policy.is_eligible(&eu_candidate, &request));
516        // US is public, doesn't meet confidential
517        assert!(!policy.is_eligible(&us_candidate, &request));
518    }
519
520    // =========================================================================
521    // SelectionCriteria tests
522    // =========================================================================
523
524    #[test]
525    fn test_selection_criteria_default() {
526        let criteria = SelectionCriteria::default();
527        assert!(matches!(criteria.capability, Capability::Generate));
528        assert_eq!(criteria.min_health, HealthState::Degraded);
529        assert!(criteria.max_latency.is_none());
530        assert_eq!(criteria.min_privacy, PrivacyLevel::Public);
531        assert!(criteria.preferred_regions.is_empty());
532        assert!(criteria.excluded_nodes.is_empty());
533    }
534
535    #[test]
536    fn test_selection_criteria_custom() {
537        let criteria = SelectionCriteria {
538            capability: Capability::Transcribe,
539            min_health: HealthState::Healthy,
540            max_latency: Some(Duration::from_secs(2)),
541            min_privacy: PrivacyLevel::Confidential,
542            preferred_regions: vec![RegionId("eu-west".to_string())],
543            excluded_nodes: vec![NodeId("bad-node".to_string())],
544        };
545        assert!(matches!(criteria.capability, Capability::Transcribe));
546        assert_eq!(criteria.max_latency, Some(Duration::from_secs(2)));
547        assert_eq!(criteria.preferred_regions.len(), 1);
548        assert_eq!(criteria.excluded_nodes.len(), 1);
549    }
550
551    // =========================================================================
552    // LatencyPolicy edge cases
553    // =========================================================================
554
555    #[test]
556    fn test_latency_policy_at_max() {
557        let policy = LatencyPolicy::default(); // max 5000ms
558        let request = mock_request();
559
560        // Exactly at max latency -> score 0
561        let at_max = mock_candidate(5000, 1.0);
562        let score = policy.score(&at_max, &request);
563        assert_eq!(score, 0.0);
564    }
565
566    #[test]
567    fn test_latency_policy_beyond_max() {
568        let policy = LatencyPolicy::default();
569        let request = mock_request();
570
571        let beyond = mock_candidate(6000, 1.0);
572        let score = policy.score(&beyond, &request);
573        assert_eq!(score, 0.0);
574    }
575
576    #[test]
577    fn test_latency_policy_zero_latency() {
578        let policy = LatencyPolicy::default();
579        let request = mock_request();
580
581        let zero = mock_candidate(0, 1.0);
582        let score = policy.score(&zero, &request);
583        assert!((score - 1.0).abs() < f64::EPSILON);
584    }
585
586    #[test]
587    fn test_latency_policy_name() {
588        let policy = LatencyPolicy::default();
589        assert_eq!(policy.name(), "latency");
590    }
591
592    #[test]
593    fn test_latency_policy_custom_weight() {
594        let policy = LatencyPolicy {
595            weight: 2.0,
596            max_latency: Duration::from_secs(5),
597        };
598        let request = mock_request();
599        let candidate = mock_candidate(0, 1.0);
600
601        let score = policy.score(&candidate, &request);
602        assert!((score - 2.0).abs() < f64::EPSILON);
603    }
604
605    #[test]
606    fn test_latency_policy_eligibility_at_boundary() {
607        let policy = LatencyPolicy {
608            max_latency: Duration::from_millis(1000),
609            ..Default::default()
610        };
611        let request = mock_request();
612
613        // Exactly at boundary
614        let at_boundary = mock_candidate(1000, 1.0);
615        assert!(policy.is_eligible(&at_boundary, &request));
616
617        // Just over
618        let over = mock_candidate(1001, 1.0);
619        assert!(!policy.is_eligible(&over, &request));
620    }
621
622    // =========================================================================
623    // LocalityPolicy tests
624    // =========================================================================
625
626    #[test]
627    fn test_locality_policy_default() {
628        let policy = LocalityPolicy::default();
629        assert_eq!(policy.weight, 1.0);
630        assert_eq!(policy.same_region_boost, 0.3);
631        assert_eq!(policy.cross_region_penalty, 0.1);
632    }
633
634    #[test]
635    fn test_locality_policy_scoring() {
636        let policy = LocalityPolicy::default();
637        let request = mock_request();
638
639        let high_locality = RouteCandidate {
640            target: RouteTarget {
641                node_id: NodeId("n1".to_string()),
642                region_id: RegionId("us-west".to_string()),
643                endpoint: String::new(),
644                estimated_latency: Duration::from_millis(50),
645                score: 0.0,
646            },
647            scores: RouteScores {
648                latency_score: 0.9,
649                throughput_score: 0.8,
650                cost_score: 0.5,
651                locality_score: 1.0,
652                health_score: 1.0,
653                total: 0.0,
654            },
655            eligible: true,
656            rejection_reason: None,
657        };
658
659        let low_locality = RouteCandidate {
660            target: RouteTarget {
661                node_id: NodeId("n2".to_string()),
662                region_id: RegionId("ap-south".to_string()),
663                endpoint: String::new(),
664                estimated_latency: Duration::from_millis(200),
665                score: 0.0,
666            },
667            scores: RouteScores {
668                latency_score: 0.5,
669                throughput_score: 0.3,
670                cost_score: 0.5,
671                locality_score: 0.0,
672                health_score: 1.0,
673                total: 0.0,
674            },
675            eligible: true,
676            rejection_reason: None,
677        };
678
679        let high_score = policy.score(&high_locality, &request);
680        let low_score = policy.score(&low_locality, &request);
681        assert!(high_score > low_score);
682    }
683
684    #[test]
685    fn test_locality_policy_always_eligible() {
686        let policy = LocalityPolicy::default();
687        let request = mock_request();
688        let candidate = mock_candidate(100, 1.0);
689        assert!(policy.is_eligible(&candidate, &request));
690    }
691
692    #[test]
693    fn test_locality_policy_name() {
694        let policy = LocalityPolicy::default();
695        assert_eq!(policy.name(), "locality");
696    }
697
698    // =========================================================================
699    // PrivacyPolicy tests
700    // =========================================================================
701
702    #[test]
703    fn test_privacy_policy_name() {
704        let policy = PrivacyPolicy::default();
705        assert_eq!(policy.name(), "privacy");
706    }
707
708    #[test]
709    fn test_privacy_policy_score_always_one() {
710        let policy = PrivacyPolicy::default();
711        let request = mock_request();
712        let candidate = mock_candidate(100, 1.0);
713        let score = policy.score(&candidate, &request);
714        assert!((score - 1.0).abs() < f64::EPSILON);
715    }
716
717    #[test]
718    fn test_privacy_policy_unknown_region_defaults_internal() {
719        let policy = PrivacyPolicy::default(); // No regions configured
720
721        let mut request = mock_request();
722        request.qos.privacy = PrivacyLevel::Internal;
723
724        let candidate = mock_candidate(100, 1.0);
725        // Unknown region defaults to Internal, which satisfies Internal requirement
726        assert!(policy.is_eligible(&candidate, &request));
727    }
728
729    #[test]
730    fn test_privacy_policy_unknown_region_fails_confidential() {
731        let policy = PrivacyPolicy::default();
732
733        let mut request = mock_request();
734        request.qos.privacy = PrivacyLevel::Confidential;
735
736        let candidate = mock_candidate(100, 1.0);
737        // Unknown region defaults to Internal, which does NOT satisfy Confidential
738        assert!(!policy.is_eligible(&candidate, &request));
739    }
740
741    #[test]
742    fn test_privacy_policy_restricted_region() {
743        let policy = PrivacyPolicy::default()
744            .with_region(RegionId("us-west".to_string()), PrivacyLevel::Restricted);
745
746        let mut request = mock_request();
747        request.qos.privacy = PrivacyLevel::Restricted;
748
749        let candidate = mock_candidate(100, 1.0);
750        // us-west is Restricted, satisfies Restricted requirement
751        assert!(policy.is_eligible(&candidate, &request));
752    }
753
754    #[test]
755    fn test_privacy_policy_public_request_any_region() {
756        let policy = PrivacyPolicy::default()
757            .with_region(RegionId("us-west".to_string()), PrivacyLevel::Public);
758
759        let mut request = mock_request();
760        request.qos.privacy = PrivacyLevel::Public;
761
762        let candidate = mock_candidate(100, 1.0);
763        assert!(policy.is_eligible(&candidate, &request));
764    }
765
766    // =========================================================================
767    // CostPolicy tests
768    // =========================================================================
769
770    #[test]
771    fn test_cost_policy_default() {
772        let policy = CostPolicy::default();
773        assert_eq!(policy.weight, 1.0);
774        assert!(policy.region_costs.is_empty());
775    }
776
777    #[test]
778    fn test_cost_policy_with_region_cost() {
779        let policy = CostPolicy::default()
780            .with_region_cost(RegionId("us-west".to_string()), 0.3)
781            .with_region_cost(RegionId("eu-west".to_string()), 0.7);
782
783        assert_eq!(policy.region_costs.len(), 2);
784    }
785
786    #[test]
787    fn test_cost_policy_clamps_cost() {
788        let policy = CostPolicy::default()
789            .with_region_cost(RegionId("r1".to_string()), 1.5)
790            .with_region_cost(RegionId("r2".to_string()), -0.5);
791
792        assert_eq!(
793            *policy
794                .region_costs
795                .get(&RegionId("r1".to_string()))
796                .expect("r1"),
797            1.0
798        );
799        assert_eq!(
800            *policy
801                .region_costs
802                .get(&RegionId("r2".to_string()))
803                .expect("r2"),
804            0.0
805        );
806    }
807
808    #[test]
809    fn test_cost_policy_high_tolerance_prefers_throughput() {
810        let policy = CostPolicy::default().with_region_cost(RegionId("us-west".to_string()), 0.9);
811
812        let mut request = mock_request();
813        request.qos.cost_tolerance = 80; // High tolerance -> prefer performance
814
815        let candidate = mock_candidate(100, 1.0); // throughput_score = 0.8
816        let score = policy.score(&candidate, &request);
817
818        // With high cost tolerance, score should be based on throughput_score
819        assert!((score - 0.8).abs() < f64::EPSILON);
820    }
821
822    #[test]
823    fn test_cost_policy_low_tolerance_prefers_cheap() {
824        let policy = CostPolicy::default().with_region_cost(RegionId("us-west".to_string()), 0.8);
825
826        let mut request = mock_request();
827        request.qos.cost_tolerance = 20; // Low tolerance -> prefer cheap
828
829        let candidate = mock_candidate(100, 1.0);
830        let score = policy.score(&candidate, &request);
831
832        // 1.0 - 0.8 = 0.2 (invert cost for cheap preference)
833        assert!((score - 0.2).abs() < f64::EPSILON);
834    }
835
836    #[test]
837    fn test_cost_policy_unknown_region_defaults() {
838        let policy = CostPolicy::default(); // No region costs configured
839
840        let mut request = mock_request();
841        request.qos.cost_tolerance = 20; // Low tolerance
842
843        let candidate = mock_candidate(100, 1.0);
844        let score = policy.score(&candidate, &request);
845
846        // Unknown region defaults to 0.5 cost; 1.0 - 0.5 = 0.5
847        assert!((score - 0.5).abs() < f64::EPSILON);
848    }
849
850    #[test]
851    fn test_cost_policy_always_eligible() {
852        let policy = CostPolicy::default();
853        let request = mock_request();
854        let candidate = mock_candidate(100, 1.0);
855        assert!(policy.is_eligible(&candidate, &request));
856    }
857
858    #[test]
859    fn test_cost_policy_name() {
860        let policy = CostPolicy::default();
861        assert_eq!(policy.name(), "cost");
862    }
863
864    #[test]
865    fn test_cost_policy_tolerance_boundary_50() {
866        // Exactly at 50 -> low tolerance branch (<=0.5)
867        let policy = CostPolicy::default().with_region_cost(RegionId("us-west".to_string()), 0.3);
868
869        let mut request = mock_request();
870        request.qos.cost_tolerance = 50;
871
872        let candidate = mock_candidate(100, 1.0);
873        let score = policy.score(&candidate, &request);
874
875        // cost_tolerance=50 -> 0.5 which is NOT > 0.5, so cheap branch: 1.0 - 0.3 = 0.7
876        assert!((score - 0.7).abs() < f64::EPSILON);
877    }
878
879    // =========================================================================
880    // HealthPolicy tests
881    // =========================================================================
882
883    #[test]
884    fn test_health_policy_default() {
885        let policy = HealthPolicy::default();
886        assert_eq!(policy.weight, 2.0);
887        assert_eq!(policy.healthy_score, 1.0);
888        assert_eq!(policy.degraded_score, 0.3);
889    }
890
891    #[test]
892    fn test_health_policy_name() {
893        let policy = HealthPolicy::default();
894        assert_eq!(policy.name(), "health");
895    }
896
897    #[test]
898    fn test_health_policy_eligibility_zero_health() {
899        let policy = HealthPolicy::default();
900        let request = mock_request();
901
902        let dead = mock_candidate(100, 0.0);
903        assert!(!policy.is_eligible(&dead, &request));
904    }
905
906    #[test]
907    fn test_health_policy_eligibility_positive_health() {
908        let policy = HealthPolicy::default();
909        let request = mock_request();
910
911        let alive = mock_candidate(100, 0.01);
912        assert!(policy.is_eligible(&alive, &request));
913    }
914
915    #[test]
916    fn test_health_policy_score_scales_with_weight() {
917        let policy = HealthPolicy {
918            weight: 3.0,
919            ..Default::default()
920        };
921        let request = mock_request();
922
923        let healthy = mock_candidate(100, 1.0);
924        let score = policy.score(&healthy, &request);
925        assert!((score - 3.0).abs() < f64::EPSILON);
926    }
927
928    // =========================================================================
929    // CompositePolicy tests
930    // =========================================================================
931
932    #[test]
933    fn test_composite_policy_new_empty() {
934        let policy = CompositePolicy::new();
935        let request = mock_request();
936        let candidate = mock_candidate(100, 1.0);
937
938        // Empty policy returns 1.0
939        let score = policy.score(&candidate, &request);
940        assert!((score - 1.0).abs() < f64::EPSILON);
941    }
942
943    #[test]
944    fn test_composite_policy_default_is_enterprise() {
945        let policy = CompositePolicy::default();
946        assert_eq!(policy.name(), "composite");
947    }
948
949    #[test]
950    fn test_composite_policy_name() {
951        let policy = CompositePolicy::new();
952        assert_eq!(policy.name(), "composite");
953    }
954
955    #[test]
956    fn test_composite_policy_eligibility_all_pass() {
957        let policy = CompositePolicy::new()
958            .with_policy(HealthPolicy::default())
959            .with_policy(LatencyPolicy::default());
960        let request = mock_request();
961
962        let good = mock_candidate(100, 1.0);
963        assert!(policy.is_eligible(&good, &request));
964    }
965
966    #[test]
967    fn test_composite_policy_eligibility_one_fails() {
968        // HealthPolicy requires health_score > 0
969        let policy = CompositePolicy::new()
970            .with_policy(HealthPolicy::default())
971            .with_policy(LatencyPolicy::default());
972        let request = mock_request();
973
974        let dead = mock_candidate(100, 0.0);
975        assert!(!policy.is_eligible(&dead, &request));
976    }
977
978    #[test]
979    fn test_composite_policy_eligibility_empty_passes() {
980        let policy = CompositePolicy::new();
981        let request = mock_request();
982        let candidate = mock_candidate(100, 1.0);
983        assert!(policy.is_eligible(&candidate, &request));
984    }
985
986    #[test]
987    fn test_composite_policy_score_averages() {
988        // Two policies that score differently
989        let policy = CompositePolicy::new()
990            .with_policy(HealthPolicy {
991                weight: 1.0,
992                ..Default::default()
993            })
994            .with_policy(LatencyPolicy::default());
995        let request = mock_request();
996        let candidate = mock_candidate(0, 1.0);
997
998        // HealthPolicy: 1.0 * 1.0 = 1.0, LatencyPolicy: 1.0 * 1.0 = 1.0
999        // Average = 1.0
1000        let score = policy.score(&candidate, &request);
1001        assert!((score - 1.0).abs() < f64::EPSILON);
1002    }
1003
1004    // =========================================================================
1005    // RoutingPolicy wrapper tests
1006    // =========================================================================
1007
1008    #[test]
1009    fn test_routing_policy_latency() {
1010        let _policy = RoutingPolicy::latency();
1011    }
1012
1013    #[test]
1014    fn test_routing_policy_locality() {
1015        let _policy = RoutingPolicy::locality();
1016    }
1017
1018    #[test]
1019    fn test_routing_policy_privacy() {
1020        let _policy = RoutingPolicy::privacy();
1021    }
1022
1023    #[test]
1024    fn test_routing_policy_cost() {
1025        let _policy = RoutingPolicy::cost();
1026    }
1027
1028    #[test]
1029    fn test_routing_policy_health() {
1030        let _policy = RoutingPolicy::health();
1031    }
1032
1033    #[test]
1034    fn test_routing_policy_enterprise() {
1035        let _policy = RoutingPolicy::enterprise();
1036    }
1037
1038    // =========================================================================
1039    // RouteScores default test
1040    // =========================================================================
1041
1042    #[test]
1043    fn test_route_scores_default() {
1044        let scores = RouteScores::default();
1045        assert_eq!(scores.latency_score, 0.5);
1046        assert_eq!(scores.throughput_score, 0.5);
1047        assert_eq!(scores.cost_score, 0.5);
1048        assert_eq!(scores.locality_score, 0.5);
1049        assert_eq!(scores.health_score, 1.0);
1050        assert_eq!(scores.total, 0.5);
1051    }
1052
1053    // =========================================================================
1054    // Helper mock with custom region for privacy testing
1055    // =========================================================================
1056
1057    fn mock_candidate_in_region(
1058        region: &str,
1059        latency_ms: u64,
1060        health_score: f64,
1061    ) -> RouteCandidate {
1062        RouteCandidate {
1063            target: RouteTarget {
1064                node_id: NodeId("node1".to_string()),
1065                region_id: RegionId(region.to_string()),
1066                endpoint: "http://node1:8080".to_string(),
1067                estimated_latency: Duration::from_millis(latency_ms),
1068                score: 0.0,
1069            },
1070            scores: RouteScores {
1071                latency_score: 1.0 - (latency_ms as f64 / 5000.0),
1072                throughput_score: 0.8,
1073                cost_score: 0.5,
1074                locality_score: 0.7,
1075                health_score,
1076                total: 0.0,
1077            },
1078            eligible: true,
1079            rejection_reason: None,
1080        }
1081    }
1082
1083    #[test]
1084    fn test_privacy_policy_with_specific_region_candidate() {
1085        let policy = PrivacyPolicy::default()
1086            .with_region(RegionId("eu-west".to_string()), PrivacyLevel::Restricted)
1087            .with_region(RegionId("us-west".to_string()), PrivacyLevel::Internal);
1088
1089        let mut request = mock_request();
1090        request.qos.privacy = PrivacyLevel::Restricted;
1091
1092        let eu = mock_candidate_in_region("eu-west", 100, 1.0);
1093        let us = mock_candidate_in_region("us-west", 50, 1.0);
1094
1095        assert!(policy.is_eligible(&eu, &request));
1096        assert!(!policy.is_eligible(&us, &request));
1097    }
1098}
1099
1100impl Default for RouteScores {
1101    fn default() -> Self {
1102        Self {
1103            latency_score: 0.5,
1104            throughput_score: 0.5,
1105            cost_score: 0.5,
1106            locality_score: 0.5,
1107            health_score: 1.0,
1108            total: 0.5,
1109        }
1110    }
1111}