Skip to main content

converge_provider_api/
selection.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Canonical backend selection vocabulary for provider consumers and adapters.
5
6use serde::{Deserialize, Serialize};
7
8use crate::backend::BackendKind;
9use crate::capability::Capability;
10use crate::chat::LlmError;
11use crate::error::BackendError;
12
13/// Requirements for generic backend selection.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct BackendRequirements {
16    pub kind: BackendKind,
17    pub required_capabilities: Vec<Capability>,
18    pub max_cost_class: CostClass,
19    pub max_latency_ms: u32,
20    pub data_sovereignty: DataSovereignty,
21    pub compliance: ComplianceLevel,
22    pub requires_replay: bool,
23    pub requires_offline: bool,
24}
25
26impl BackendRequirements {
27    #[must_use]
28    pub fn new(kind: BackendKind) -> Self {
29        Self {
30            kind,
31            required_capabilities: Vec::new(),
32            max_cost_class: CostClass::VeryHigh,
33            max_latency_ms: 0,
34            data_sovereignty: DataSovereignty::Any,
35            compliance: ComplianceLevel::None,
36            requires_replay: false,
37            requires_offline: false,
38        }
39    }
40
41    #[must_use]
42    pub fn with_capability(mut self, capability: Capability) -> Self {
43        self.required_capabilities.push(capability);
44        self
45    }
46
47    #[must_use]
48    pub fn with_max_cost(mut self, cost: CostClass) -> Self {
49        self.max_cost_class = cost;
50        self
51    }
52
53    #[must_use]
54    pub fn with_max_latency_ms(mut self, ms: u32) -> Self {
55        self.max_latency_ms = ms;
56        self
57    }
58
59    #[must_use]
60    pub fn with_data_sovereignty(mut self, sovereignty: DataSovereignty) -> Self {
61        self.data_sovereignty = sovereignty;
62        self
63    }
64
65    #[must_use]
66    pub fn with_compliance(mut self, compliance: ComplianceLevel) -> Self {
67        self.compliance = compliance;
68        self
69    }
70
71    #[must_use]
72    pub fn with_replay(mut self) -> Self {
73        self.requires_replay = true;
74        self
75    }
76
77    #[must_use]
78    pub fn with_offline(mut self) -> Self {
79        self.requires_offline = true;
80        self
81    }
82
83    #[must_use]
84    pub fn fast_llm() -> Self {
85        Self::new(BackendKind::Llm)
86            .with_capability(Capability::TextGeneration)
87            .with_max_cost(CostClass::Low)
88            .with_max_latency_ms(2_000)
89    }
90
91    #[must_use]
92    pub fn reasoning_llm() -> Self {
93        Self::new(BackendKind::Llm)
94            .with_capability(Capability::TextGeneration)
95            .with_capability(Capability::Reasoning)
96            .with_max_cost(CostClass::High)
97            .with_max_latency_ms(30_000)
98    }
99
100    #[must_use]
101    pub fn access_policy() -> Self {
102        Self::new(BackendKind::Policy)
103            .with_capability(Capability::AccessControl)
104            .with_max_latency_ms(100)
105    }
106
107    #[must_use]
108    pub fn constraint_solver() -> Self {
109        Self::new(BackendKind::Optimization).with_capability(Capability::ConstraintSolving)
110    }
111
112    #[must_use]
113    pub fn embedding_pipeline() -> Self {
114        Self::new(BackendKind::Analytics).with_capability(Capability::Embedding)
115    }
116
117    #[must_use]
118    pub fn vector_search() -> Self {
119        Self::new(BackendKind::Search).with_capability(Capability::VectorSearch)
120    }
121}
122
123/// Trait for selecting a backend that satisfies generic requirements.
124pub trait BackendSelector: Send + Sync {
125    fn select(&self, requirements: &BackendRequirements) -> Result<String, BackendError>;
126}
127
128/// Structured request for provider selection inside a convergence loop.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct ProviderRequest {
131    /// Stable request identifier used for idempotency.
132    pub id: String,
133    /// Capabilities that must each be covered by at least one backend.
134    /// Duplicates request multiple independent backends for the same capability.
135    pub required_capabilities: Vec<Capability>,
136    /// Rich backend requirements for a single role-scoped backend selection.
137    ///
138    /// When present, provider selection should choose one backend satisfying
139    /// the requirement envelope rather than only covering independent
140    /// capability slots.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub backend_requirements: Option<BackendRequirements>,
143}
144
145/// Structured result of provider selection.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ProviderAssignment {
148    /// The request this assignment answers.
149    pub request_id: String,
150    /// Matched capability-to-backend assignments.
151    pub assignments: Vec<CapabilityAssignment>,
152    /// Capabilities that no registered backend could satisfy.
153    pub unmatched: Vec<Capability>,
154    /// `assignments.len() / required_capabilities.len()` — 1.0 is full coverage.
155    pub coverage_ratio: f64,
156}
157
158/// A single capability-to-backend assignment.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct CapabilityAssignment {
161    pub capability: Capability,
162    pub backend_name: String,
163}
164
165/// Data jurisdiction requirements.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
167pub enum Jurisdiction {
168    #[default]
169    Unrestricted,
170    Trusted,
171    SameRegion,
172    SameCountry,
173}
174
175impl Jurisdiction {
176    #[must_use]
177    pub fn satisfied_by(
178        self,
179        provider_country: &str,
180        provider_region: &str,
181        user_country: &str,
182        user_region: &str,
183    ) -> bool {
184        match self {
185            Self::Unrestricted => true,
186            Self::Trusted => is_trusted_jurisdiction(provider_region),
187            Self::SameRegion => provider_region == user_region,
188            Self::SameCountry => provider_country == user_country,
189        }
190    }
191}
192
193fn is_trusted_jurisdiction(region: &str) -> bool {
194    matches!(
195        region.to_uppercase().as_str(),
196        "EU" | "EEA" | "CH" | "UK" | "JP" | "CA" | "NZ" | "IL" | "KR" | "AR" | "UY"
197    )
198}
199
200/// Latency class requirements.
201#[derive(
202    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
203)]
204pub enum LatencyClass {
205    Realtime,
206    #[default]
207    Interactive,
208    Background,
209    Batch,
210}
211
212impl LatencyClass {
213    #[must_use]
214    pub fn max_latency_ms(self) -> u32 {
215        match self {
216            Self::Realtime => 100,
217            Self::Interactive => 2_000,
218            Self::Background => 30_000,
219            Self::Batch => 300_000,
220        }
221    }
222
223    #[must_use]
224    pub fn satisfied_by(self, provider_latency_ms: u32) -> bool {
225        provider_latency_ms <= self.max_latency_ms()
226    }
227}
228
229/// Cost tier preference.
230#[derive(
231    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
232)]
233pub enum CostTier {
234    Minimal,
235    #[default]
236    Standard,
237    Premium,
238}
239
240/// Task complexity hint.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
242pub enum TaskComplexity {
243    Extraction,
244    #[default]
245    Classification,
246    Reasoning,
247    Generation,
248}
249
250impl TaskComplexity {
251    #[must_use]
252    pub fn min_quality_hint(self) -> f64 {
253        match self {
254            Self::Extraction => 0.5,
255            Self::Classification => 0.6,
256            Self::Reasoning => 0.8,
257            Self::Generation => 0.7,
258        }
259    }
260
261    #[must_use]
262    pub fn requires_reasoning(self) -> bool {
263        matches!(self, Self::Reasoning)
264    }
265}
266
267/// Required model capabilities.
268#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
269#[allow(clippy::struct_excessive_bools)]
270pub struct RequiredCapabilities {
271    pub tool_use: bool,
272    pub vision: bool,
273    pub min_context_tokens: Option<usize>,
274    pub structured_output: bool,
275    pub code: bool,
276    pub multilingual: bool,
277    pub web_search: bool,
278}
279
280impl RequiredCapabilities {
281    #[must_use]
282    pub fn none() -> Self {
283        Self::default()
284    }
285
286    #[must_use]
287    pub fn with_tool_use(mut self) -> Self {
288        self.tool_use = true;
289        self
290    }
291
292    #[must_use]
293    pub fn with_vision(mut self) -> Self {
294        self.vision = true;
295        self
296    }
297
298    #[must_use]
299    pub fn with_min_context(mut self, tokens: usize) -> Self {
300        self.min_context_tokens = Some(tokens);
301        self
302    }
303
304    #[must_use]
305    pub fn with_structured_output(mut self) -> Self {
306        self.structured_output = true;
307        self
308    }
309
310    #[must_use]
311    pub fn with_code(mut self) -> Self {
312        self.code = true;
313        self
314    }
315
316    #[must_use]
317    pub fn with_multilingual(mut self) -> Self {
318        self.multilingual = true;
319        self
320    }
321
322    #[must_use]
323    pub fn with_web_search(mut self) -> Self {
324        self.web_search = true;
325        self
326    }
327}
328
329/// Cost classification — how expensive is this backend to use?
330#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
331pub enum CostClass {
332    Free,
333    VeryLow,
334    Low,
335    Medium,
336    High,
337    VeryHigh,
338}
339
340impl CostClass {
341    #[must_use]
342    pub fn allowed_classes(self) -> Vec<CostClass> {
343        let all = [
344            CostClass::Free,
345            CostClass::VeryLow,
346            CostClass::Low,
347            CostClass::Medium,
348            CostClass::High,
349            CostClass::VeryHigh,
350        ];
351        all.iter().copied().filter(|&c| c <= self).collect()
352    }
353
354    #[must_use]
355    pub fn from_tier(tier: CostTier) -> Self {
356        match tier {
357            CostTier::Minimal => Self::Low,
358            CostTier::Standard => Self::Medium,
359            CostTier::Premium => Self::VeryHigh,
360        }
361    }
362}
363
364/// Data sovereignty requirements — where can data legally reside?
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
366pub enum DataSovereignty {
367    Any,
368    EU,
369    US,
370    Switzerland,
371    China,
372    OnPremises,
373}
374
375impl DataSovereignty {
376    #[must_use]
377    pub fn from_jurisdiction(jurisdiction: Jurisdiction, user_region: &str) -> Self {
378        match jurisdiction {
379            Jurisdiction::Unrestricted | Jurisdiction::Trusted => Self::Any,
380            Jurisdiction::SameRegion => match user_region.to_uppercase().as_str() {
381                "EU" | "EEA" => Self::EU,
382                "CH" => Self::Switzerland,
383                "CN" => Self::China,
384                "US" => Self::US,
385                _ => Self::Any,
386            },
387            Jurisdiction::SameCountry => Self::OnPremises,
388        }
389    }
390}
391
392/// Compliance level requirements.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
394pub enum ComplianceLevel {
395    None,
396    GDPR,
397    HIPAA,
398    SOC2,
399    HighExplainability,
400}
401
402/// Selection criteria using orthogonal dimensions.
403#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
404pub struct SelectionCriteria {
405    pub jurisdiction: Jurisdiction,
406    pub latency: LatencyClass,
407    pub cost: CostTier,
408    pub complexity: TaskComplexity,
409    pub capabilities: RequiredCapabilities,
410    pub compliance: Option<ComplianceLevel>,
411    pub user_country: Option<String>,
412    pub user_region: Option<String>,
413}
414
415impl SelectionCriteria {
416    #[must_use]
417    pub fn high_volume() -> Self {
418        Self {
419            latency: LatencyClass::Interactive,
420            cost: CostTier::Minimal,
421            complexity: TaskComplexity::Extraction,
422            ..Default::default()
423        }
424    }
425
426    #[must_use]
427    pub fn interactive() -> Self {
428        Self {
429            latency: LatencyClass::Interactive,
430            cost: CostTier::Minimal,
431            complexity: TaskComplexity::Classification,
432            ..Default::default()
433        }
434    }
435
436    #[must_use]
437    pub fn analysis() -> Self {
438        Self {
439            latency: LatencyClass::Background,
440            cost: CostTier::Premium,
441            complexity: TaskComplexity::Reasoning,
442            ..Default::default()
443        }
444    }
445
446    #[must_use]
447    pub fn batch() -> Self {
448        Self {
449            latency: LatencyClass::Batch,
450            cost: CostTier::Minimal,
451            complexity: TaskComplexity::Extraction,
452            ..Default::default()
453        }
454    }
455
456    #[must_use]
457    pub fn with_jurisdiction(mut self, jurisdiction: Jurisdiction) -> Self {
458        self.jurisdiction = jurisdiction;
459        self
460    }
461
462    #[must_use]
463    pub fn with_latency(mut self, latency: LatencyClass) -> Self {
464        self.latency = latency;
465        self
466    }
467
468    #[must_use]
469    pub fn with_cost(mut self, cost: CostTier) -> Self {
470        self.cost = cost;
471        self
472    }
473
474    #[must_use]
475    pub fn with_complexity(mut self, complexity: TaskComplexity) -> Self {
476        self.complexity = complexity;
477        self
478    }
479
480    #[must_use]
481    pub fn with_capabilities(mut self, capabilities: RequiredCapabilities) -> Self {
482        self.capabilities = capabilities;
483        self
484    }
485
486    #[must_use]
487    pub fn with_compliance(mut self, compliance: ComplianceLevel) -> Self {
488        self.compliance = Some(compliance);
489        self
490    }
491
492    #[must_use]
493    pub fn with_user_location(
494        mut self,
495        country: impl Into<String>,
496        region: impl Into<String>,
497    ) -> Self {
498        self.user_country = Some(country.into());
499        self.user_region = Some(region.into());
500        self
501    }
502
503    #[must_use]
504    pub fn to_agent_requirements(&self) -> AgentRequirements {
505        let user_region = self.user_region.as_deref().unwrap_or("US");
506        AgentRequirements {
507            max_cost_class: CostClass::from_tier(self.cost),
508            max_latency_ms: self.latency.max_latency_ms(),
509            requires_reasoning: self.complexity.requires_reasoning(),
510            requires_web_search: self.capabilities.web_search,
511            requires_tool_use: self.capabilities.tool_use,
512            requires_vision: self.capabilities.vision,
513            min_context_tokens: self.capabilities.min_context_tokens,
514            requires_structured_output: self.capabilities.structured_output,
515            requires_code: self.capabilities.code,
516            min_quality: self.complexity.min_quality_hint(),
517            data_sovereignty: DataSovereignty::from_jurisdiction(self.jurisdiction, user_region),
518            compliance: self.compliance.unwrap_or(ComplianceLevel::None),
519            requires_multilingual: self.capabilities.multilingual,
520        }
521    }
522}
523
524/// Requirements for an agent's LLM usage.
525#[derive(Debug, Clone, PartialEq)]
526pub struct AgentRequirements {
527    pub max_cost_class: CostClass,
528    pub max_latency_ms: u32,
529    pub requires_reasoning: bool,
530    pub requires_web_search: bool,
531    pub requires_tool_use: bool,
532    pub requires_vision: bool,
533    pub min_context_tokens: Option<usize>,
534    pub requires_structured_output: bool,
535    pub requires_code: bool,
536    pub min_quality: f64,
537    pub data_sovereignty: DataSovereignty,
538    pub compliance: ComplianceLevel,
539    pub requires_multilingual: bool,
540}
541
542impl AgentRequirements {
543    #[must_use]
544    pub fn fast_cheap() -> Self {
545        Self {
546            max_cost_class: CostClass::VeryLow,
547            max_latency_ms: 2_000,
548            requires_reasoning: false,
549            requires_web_search: false,
550            requires_tool_use: false,
551            requires_vision: false,
552            min_context_tokens: None,
553            requires_structured_output: false,
554            requires_code: false,
555            min_quality: 0.6,
556            data_sovereignty: DataSovereignty::Any,
557            compliance: ComplianceLevel::None,
558            requires_multilingual: false,
559        }
560    }
561
562    #[must_use]
563    pub fn deep_research() -> Self {
564        Self {
565            max_cost_class: CostClass::High,
566            max_latency_ms: 30_000,
567            requires_reasoning: true,
568            requires_web_search: true,
569            requires_tool_use: false,
570            requires_vision: false,
571            min_context_tokens: None,
572            requires_structured_output: false,
573            requires_code: false,
574            min_quality: 0.9,
575            data_sovereignty: DataSovereignty::Any,
576            compliance: ComplianceLevel::None,
577            requires_multilingual: false,
578        }
579    }
580
581    #[must_use]
582    pub fn balanced() -> Self {
583        Self {
584            max_cost_class: CostClass::Medium,
585            max_latency_ms: 5_000,
586            requires_reasoning: false,
587            requires_web_search: false,
588            requires_tool_use: false,
589            requires_vision: false,
590            min_context_tokens: None,
591            requires_structured_output: false,
592            requires_code: false,
593            min_quality: 0.7,
594            data_sovereignty: DataSovereignty::Any,
595            compliance: ComplianceLevel::None,
596            requires_multilingual: false,
597        }
598    }
599
600    #[must_use]
601    pub fn new(max_cost_class: CostClass, max_latency_ms: u32, requires_reasoning: bool) -> Self {
602        Self {
603            max_cost_class,
604            max_latency_ms,
605            requires_reasoning,
606            requires_web_search: false,
607            requires_tool_use: false,
608            requires_vision: false,
609            min_context_tokens: None,
610            requires_structured_output: false,
611            requires_code: false,
612            min_quality: 0.7,
613            data_sovereignty: DataSovereignty::Any,
614            compliance: ComplianceLevel::None,
615            requires_multilingual: false,
616        }
617    }
618
619    #[must_use]
620    pub fn powerful() -> Self {
621        Self {
622            max_cost_class: CostClass::High,
623            max_latency_ms: 10_000,
624            requires_reasoning: true,
625            requires_web_search: false,
626            requires_tool_use: false,
627            requires_vision: false,
628            min_context_tokens: None,
629            requires_structured_output: false,
630            requires_code: false,
631            min_quality: 0.9,
632            data_sovereignty: DataSovereignty::Any,
633            compliance: ComplianceLevel::None,
634            requires_multilingual: false,
635        }
636    }
637
638    #[must_use]
639    pub fn with_quality(self, quality: f64) -> Self {
640        self.with_min_quality(quality)
641    }
642
643    #[must_use]
644    pub fn with_web_search(mut self, requires: bool) -> Self {
645        self.requires_web_search = requires;
646        self
647    }
648
649    #[must_use]
650    pub fn with_tool_use(mut self, requires: bool) -> Self {
651        self.requires_tool_use = requires;
652        self
653    }
654
655    #[must_use]
656    pub fn with_vision(mut self, requires: bool) -> Self {
657        self.requires_vision = requires;
658        self
659    }
660
661    #[must_use]
662    pub fn with_min_context(mut self, tokens: usize) -> Self {
663        self.min_context_tokens = Some(tokens);
664        self
665    }
666
667    #[must_use]
668    pub fn with_structured_output(mut self, requires: bool) -> Self {
669        self.requires_structured_output = requires;
670        self
671    }
672
673    #[must_use]
674    pub fn with_code(mut self, requires: bool) -> Self {
675        self.requires_code = requires;
676        self
677    }
678
679    #[must_use]
680    pub fn with_min_quality(mut self, quality: f64) -> Self {
681        self.min_quality = quality.clamp(0.0, 1.0);
682        self
683    }
684
685    #[must_use]
686    pub fn with_data_sovereignty(mut self, sovereignty: DataSovereignty) -> Self {
687        self.data_sovereignty = sovereignty;
688        self
689    }
690
691    #[must_use]
692    pub fn with_compliance(mut self, compliance: ComplianceLevel) -> Self {
693        self.compliance = compliance;
694        self
695    }
696
697    #[must_use]
698    pub fn with_multilingual(mut self, requires: bool) -> Self {
699        self.requires_multilingual = requires;
700        self
701    }
702
703    #[must_use]
704    pub fn from_criteria(criteria: &SelectionCriteria) -> Self {
705        criteria.to_agent_requirements()
706    }
707}
708
709/// Trait for model selection based on LLM requirements.
710pub trait ModelSelectorTrait: Send + Sync {
711    fn select(&self, requirements: &AgentRequirements) -> Result<(String, String), LlmError>;
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn cost_class_ordering() {
720        assert!(CostClass::Free < CostClass::VeryLow);
721        assert!(CostClass::VeryLow < CostClass::Low);
722        assert!(CostClass::Low < CostClass::Medium);
723        assert!(CostClass::Medium < CostClass::High);
724        assert!(CostClass::High < CostClass::VeryHigh);
725    }
726
727    #[test]
728    fn requirements_builder() {
729        let reqs = BackendRequirements::new(BackendKind::Llm)
730            .with_capability(Capability::TextGeneration)
731            .with_capability(Capability::Reasoning)
732            .with_max_cost(CostClass::Medium)
733            .with_max_latency_ms(5_000);
734
735        assert_eq!(reqs.kind, BackendKind::Llm);
736        assert_eq!(reqs.required_capabilities.len(), 2);
737        assert_eq!(reqs.max_cost_class, CostClass::Medium);
738        assert_eq!(reqs.max_latency_ms, 5_000);
739    }
740
741    #[test]
742    fn selection_criteria_presets() {
743        let high_vol = SelectionCriteria::high_volume();
744        assert_eq!(high_vol.cost, CostTier::Minimal);
745        assert_eq!(high_vol.complexity, TaskComplexity::Extraction);
746
747        let analysis = SelectionCriteria::analysis();
748        assert_eq!(analysis.cost, CostTier::Premium);
749        assert_eq!(analysis.complexity, TaskComplexity::Reasoning);
750    }
751
752    #[test]
753    fn selection_criteria_to_agent_requirements() {
754        let criteria = SelectionCriteria::default()
755            .with_latency(LatencyClass::Background)
756            .with_cost(CostTier::Premium)
757            .with_complexity(TaskComplexity::Reasoning)
758            .with_capabilities(
759                RequiredCapabilities::none()
760                    .with_tool_use()
761                    .with_vision()
762                    .with_min_context(128_000)
763                    .with_structured_output()
764                    .with_code(),
765            );
766        let requirements = criteria.to_agent_requirements();
767        assert_eq!(requirements.max_latency_ms, 30_000);
768        assert!(requirements.requires_reasoning);
769        assert!(requirements.min_quality >= 0.8);
770        assert!(requirements.requires_tool_use);
771        assert!(requirements.requires_vision);
772        assert_eq!(requirements.min_context_tokens, Some(128_000));
773        assert!(requirements.requires_structured_output);
774        assert!(requirements.requires_code);
775    }
776
777    #[test]
778    fn preset_constructors() {
779        let fast = BackendRequirements::fast_llm();
780        assert_eq!(fast.kind, BackendKind::Llm);
781        assert_eq!(fast.max_cost_class, CostClass::Low);
782
783        let policy = BackendRequirements::access_policy();
784        assert_eq!(policy.kind, BackendKind::Policy);
785        assert!(
786            policy
787                .required_capabilities
788                .contains(&Capability::AccessControl)
789        );
790
791        let solver = BackendRequirements::constraint_solver();
792        assert_eq!(solver.kind, BackendKind::Optimization);
793    }
794
795    #[test]
796    fn preset_reasoning_llm() {
797        let r = BackendRequirements::reasoning_llm();
798        assert_eq!(r.kind, BackendKind::Llm);
799        assert_eq!(r.max_cost_class, CostClass::High);
800        assert_eq!(r.max_latency_ms, 30_000);
801        assert!(
802            r.required_capabilities
803                .contains(&Capability::TextGeneration)
804        );
805        assert!(r.required_capabilities.contains(&Capability::Reasoning));
806    }
807
808    #[test]
809    fn preset_embedding_pipeline() {
810        let r = BackendRequirements::embedding_pipeline();
811        assert_eq!(r.kind, BackendKind::Analytics);
812        assert!(r.required_capabilities.contains(&Capability::Embedding));
813    }
814
815    #[test]
816    fn preset_vector_search() {
817        let r = BackendRequirements::vector_search();
818        assert_eq!(r.kind, BackendKind::Search);
819        assert!(r.required_capabilities.contains(&Capability::VectorSearch));
820    }
821
822    #[test]
823    fn jurisdiction_unrestricted_always_satisfied() {
824        assert!(Jurisdiction::Unrestricted.satisfied_by("US", "US", "SE", "EU"));
825    }
826
827    #[test]
828    fn jurisdiction_trusted_eu() {
829        assert!(Jurisdiction::Trusted.satisfied_by("SE", "EU", "SE", "EU"));
830    }
831
832    #[test]
833    fn jurisdiction_trusted_us_not_trusted() {
834        assert!(!Jurisdiction::Trusted.satisfied_by("US", "US", "SE", "EU"));
835    }
836
837    #[test]
838    fn jurisdiction_trusted_various() {
839        for region in &["CH", "UK", "JP", "CA", "NZ", "IL", "KR", "AR", "UY", "EEA"] {
840            assert!(
841                Jurisdiction::Trusted.satisfied_by("X", region, "Y", "Z"),
842                "expected {region} to be trusted"
843            );
844        }
845    }
846
847    #[test]
848    fn jurisdiction_same_region() {
849        assert!(Jurisdiction::SameRegion.satisfied_by("SE", "EU", "DE", "EU"));
850        assert!(!Jurisdiction::SameRegion.satisfied_by("SE", "EU", "US", "US"));
851    }
852
853    #[test]
854    fn jurisdiction_same_country() {
855        assert!(Jurisdiction::SameCountry.satisfied_by("SE", "EU", "SE", "EU"));
856        assert!(!Jurisdiction::SameCountry.satisfied_by("SE", "EU", "DE", "EU"));
857    }
858
859    #[test]
860    fn latency_class_max_values() {
861        assert_eq!(LatencyClass::Realtime.max_latency_ms(), 100);
862        assert_eq!(LatencyClass::Interactive.max_latency_ms(), 2_000);
863        assert_eq!(LatencyClass::Background.max_latency_ms(), 30_000);
864        assert_eq!(LatencyClass::Batch.max_latency_ms(), 300_000);
865    }
866
867    #[test]
868    fn latency_class_satisfied_by() {
869        assert!(LatencyClass::Realtime.satisfied_by(50));
870        assert!(LatencyClass::Realtime.satisfied_by(100));
871        assert!(!LatencyClass::Realtime.satisfied_by(101));
872
873        assert!(LatencyClass::Interactive.satisfied_by(2_000));
874        assert!(!LatencyClass::Interactive.satisfied_by(2_001));
875    }
876
877    #[test]
878    fn latency_class_ordering() {
879        assert!(LatencyClass::Realtime < LatencyClass::Interactive);
880        assert!(LatencyClass::Interactive < LatencyClass::Background);
881        assert!(LatencyClass::Background < LatencyClass::Batch);
882    }
883
884    #[test]
885    fn data_sovereignty_from_jurisdiction_unrestricted() {
886        assert_eq!(
887            DataSovereignty::from_jurisdiction(Jurisdiction::Unrestricted, "EU"),
888            DataSovereignty::Any
889        );
890    }
891
892    #[test]
893    fn data_sovereignty_from_jurisdiction_trusted() {
894        assert_eq!(
895            DataSovereignty::from_jurisdiction(Jurisdiction::Trusted, "EU"),
896            DataSovereignty::Any
897        );
898    }
899
900    #[test]
901    fn data_sovereignty_from_jurisdiction_same_region_eu() {
902        assert_eq!(
903            DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "EU"),
904            DataSovereignty::EU
905        );
906        assert_eq!(
907            DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "eea"),
908            DataSovereignty::EU
909        );
910    }
911
912    #[test]
913    fn data_sovereignty_from_jurisdiction_same_region_other() {
914        assert_eq!(
915            DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "CH"),
916            DataSovereignty::Switzerland
917        );
918        assert_eq!(
919            DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "CN"),
920            DataSovereignty::China
921        );
922        assert_eq!(
923            DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "US"),
924            DataSovereignty::US
925        );
926        assert_eq!(
927            DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "XX"),
928            DataSovereignty::Any
929        );
930    }
931
932    #[test]
933    fn data_sovereignty_from_jurisdiction_same_country() {
934        assert_eq!(
935            DataSovereignty::from_jurisdiction(Jurisdiction::SameCountry, "SE"),
936            DataSovereignty::OnPremises
937        );
938    }
939
940    #[test]
941    fn cost_class_allowed_contains_self() {
942        for class in [
943            CostClass::Free,
944            CostClass::VeryLow,
945            CostClass::Low,
946            CostClass::Medium,
947            CostClass::High,
948            CostClass::VeryHigh,
949        ] {
950            let allowed = class.allowed_classes();
951            assert!(
952                allowed.contains(&class),
953                "{class:?} should contain itself in allowed_classes"
954            );
955        }
956    }
957
958    #[test]
959    fn cost_class_allowed_counts() {
960        assert_eq!(CostClass::Free.allowed_classes().len(), 1);
961        assert_eq!(CostClass::VeryLow.allowed_classes().len(), 2);
962        assert_eq!(CostClass::Low.allowed_classes().len(), 3);
963        assert_eq!(CostClass::Medium.allowed_classes().len(), 4);
964        assert_eq!(CostClass::High.allowed_classes().len(), 5);
965        assert_eq!(CostClass::VeryHigh.allowed_classes().len(), 6);
966    }
967
968    #[test]
969    fn cost_class_from_tier() {
970        assert_eq!(CostClass::from_tier(CostTier::Minimal), CostClass::Low);
971        assert_eq!(CostClass::from_tier(CostTier::Standard), CostClass::Medium);
972        assert_eq!(CostClass::from_tier(CostTier::Premium), CostClass::VeryHigh);
973    }
974
975    #[test]
976    fn task_complexity_min_quality() {
977        assert!((TaskComplexity::Extraction.min_quality_hint() - 0.5).abs() < f64::EPSILON);
978        assert!((TaskComplexity::Classification.min_quality_hint() - 0.6).abs() < f64::EPSILON);
979        assert!((TaskComplexity::Reasoning.min_quality_hint() - 0.8).abs() < f64::EPSILON);
980        assert!((TaskComplexity::Generation.min_quality_hint() - 0.7).abs() < f64::EPSILON);
981    }
982
983    #[test]
984    fn task_complexity_requires_reasoning() {
985        assert!(!TaskComplexity::Extraction.requires_reasoning());
986        assert!(!TaskComplexity::Classification.requires_reasoning());
987        assert!(TaskComplexity::Reasoning.requires_reasoning());
988        assert!(!TaskComplexity::Generation.requires_reasoning());
989    }
990
991    #[test]
992    fn agent_requirements_fast_cheap() {
993        let r = AgentRequirements::fast_cheap();
994        assert_eq!(r.max_cost_class, CostClass::VeryLow);
995        assert_eq!(r.max_latency_ms, 2_000);
996        assert!(!r.requires_reasoning);
997    }
998
999    #[test]
1000    fn agent_requirements_deep_research() {
1001        let r = AgentRequirements::deep_research();
1002        assert!(r.requires_reasoning);
1003        assert!(r.requires_web_search);
1004        assert!(r.min_quality >= 0.9);
1005    }
1006
1007    #[test]
1008    fn agent_requirements_with_min_quality_clamped() {
1009        let r = AgentRequirements::fast_cheap().with_min_quality(2.0);
1010        assert!((r.min_quality - 1.0).abs() < f64::EPSILON);
1011
1012        let r = AgentRequirements::fast_cheap().with_min_quality(-1.0);
1013        assert!(r.min_quality.abs() < f64::EPSILON);
1014    }
1015
1016    #[test]
1017    fn provider_request_and_assignment_roundtrip() {
1018        let request = ProviderRequest {
1019            id: "req-1".to_string(),
1020            required_capabilities: vec![Capability::Reasoning, Capability::Scheduling],
1021            backend_requirements: None,
1022        };
1023        let assignment = ProviderAssignment {
1024            request_id: request.id.clone(),
1025            assignments: vec![CapabilityAssignment {
1026                capability: Capability::Reasoning,
1027                backend_name: "solver-a".to_string(),
1028            }],
1029            unmatched: vec![Capability::Scheduling],
1030            coverage_ratio: 0.5,
1031        };
1032
1033        let request_back: ProviderRequest =
1034            serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
1035        let assignment_back: ProviderAssignment =
1036            serde_json::from_str(&serde_json::to_string(&assignment).unwrap()).unwrap();
1037
1038        assert!(request_back.backend_requirements.is_none());
1039        assert_eq!(
1040            request_back.required_capabilities,
1041            request.required_capabilities
1042        );
1043        assert_eq!(assignment_back.assignments, assignment.assignments);
1044        assert_eq!(assignment_back.unmatched, assignment.unmatched);
1045    }
1046
1047    #[test]
1048    fn provider_request_defaults_missing_backend_requirements() {
1049        let legacy_json = r#"{"id":"legacy","required_capabilities":["Reasoning"]}"#;
1050
1051        let request: ProviderRequest = serde_json::from_str(legacy_json).unwrap();
1052
1053        assert_eq!(request.id, "legacy");
1054        assert_eq!(request.required_capabilities, vec![Capability::Reasoning]);
1055        assert!(request.backend_requirements.is_none());
1056    }
1057}