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