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