1use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::time::Duration;
12use uuid::Uuid;
13
14use super::config::ModelSelectionConfig;
15use super::error::{DecisionError, DecisionResult};
16use super::traits::OptimizationStrategy;
17use super::types::{
18 ComparisonOperator, ConfigChange, ConfigType, Decision, DecisionCriteria, DecisionInput,
19 DecisionOutcome, DecisionType, ExpectedImpact, RollbackCondition, RollbackPlan, SafetyCheck,
20};
21
22#[derive(Debug)]
32pub struct ModelSelectionStrategy {
33 config: ModelSelectionConfig,
35
36 model_performance: HashMap<String, ModelPerformance>,
38
39 current_model: Option<String>,
41
42 last_evaluation: Option<DateTime<Utc>>,
44
45 decision_history: Vec<DecisionRecord>,
47
48 stats: StrategyStats,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54struct ModelPerformance {
55 model: String,
57
58 request_count: u64,
60
61 avg_latency_ms: f64,
63
64 p95_latency_ms: f64,
66
67 p99_latency_ms: f64,
69
70 avg_cost_per_request: f64,
72
73 total_cost: f64,
75
76 success_rate: f64,
78
79 avg_quality_score: f64,
81
82 last_updated: DateTime<Utc>,
84
85 sample_size: usize,
87}
88
89#[derive(Debug, Clone)]
91struct ObjectivePoint {
92 model: String,
93 cost: f64, latency: f64, quality: f64, }
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100struct DecisionRecord {
101 decision_id: String,
102 from_model: String,
103 to_model: String,
104 timestamp: DateTime<Utc>,
105 expected_impact: ExpectedImpact,
106 actual_impact: Option<super::types::ActualImpact>,
107 success: bool,
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112struct StrategyStats {
113 total_evaluations: u64,
114 total_decisions: u64,
115 successful_switches: u64,
116 failed_switches: u64,
117 total_cost_savings: f64,
118 total_latency_improvement: f64,
119 total_quality_improvement: f64,
120}
121
122impl ModelSelectionStrategy {
123 pub fn new(config: ModelSelectionConfig) -> Self {
125 Self {
126 config,
127 model_performance: HashMap::new(),
128 current_model: None,
129 last_evaluation: None,
130 decision_history: Vec::new(),
131 stats: StrategyStats::default(),
132 }
133 }
134
135 fn calculate_pareto_frontier(&self, points: &[ObjectivePoint]) -> Vec<ObjectivePoint> {
141 let mut frontier = Vec::new();
142
143 for candidate in points {
144 let mut is_dominated = false;
145
146 for other in points {
148 if self.dominates(other, candidate) {
149 is_dominated = true;
150 break;
151 }
152 }
153
154 if !is_dominated {
155 frontier.push(candidate.clone());
156 }
157 }
158
159 frontier
160 }
161
162 fn dominates(&self, a: &ObjectivePoint, b: &ObjectivePoint) -> bool {
168 if a.model == b.model {
170 return false;
171 }
172
173 let cost_better = a.cost <= b.cost;
174 let latency_better = a.latency <= b.latency;
175 let quality_better = a.quality >= b.quality;
176
177 let cost_strictly_better = a.cost < b.cost;
178 let latency_strictly_better = a.latency < b.latency;
179 let quality_strictly_better = a.quality > b.quality;
180
181 cost_better
184 && latency_better
185 && quality_better
186 && (cost_strictly_better || latency_strictly_better || quality_strictly_better)
187 }
188
189 fn score_model(&self, point: &ObjectivePoint) -> f64 {
191 let cost_norm = (100.0 - point.cost.min(100.0)) / 100.0; let latency_norm = (5000.0 - point.latency.min(5000.0)) / 5000.0; let quality_norm = point.quality / 5.0; self.config.cost_weight * cost_norm
198 + self.config.performance_weight * latency_norm
199 + self.config.quality_weight * quality_norm
200 }
201
202 fn select_best_model(&self, frontier: &[ObjectivePoint]) -> Option<ObjectivePoint> {
204 if frontier.is_empty() {
205 return None;
206 }
207
208 frontier
210 .iter()
211 .max_by(|a, b| {
212 self.score_model(a)
213 .partial_cmp(&self.score_model(b))
214 .unwrap_or(std::cmp::Ordering::Equal)
215 })
216 .cloned()
217 }
218
219 fn extract_model_performance(&self, input: &DecisionInput) -> Vec<ObjectivePoint> {
221 let mut points = Vec::new();
222
223 for report in &input.analysis_reports {
225 if false {
228 let model_metrics = serde_json::Value::Null;
229 if let Ok(metrics) =
230 serde_json::from_value::<HashMap<String, ModelMetrics>>(model_metrics.clone())
231 {
232 for (model, perf) in metrics {
233 points.push(ObjectivePoint {
234 model,
235 cost: perf.avg_cost_per_request,
236 latency: perf.avg_latency_ms,
237 quality: perf.avg_quality_score,
238 });
239 }
240 }
241 }
242 }
243
244 if points.is_empty() {
246 for (model, perf) in &self.model_performance {
247 points.push(ObjectivePoint {
248 model: model.clone(),
249 cost: perf.avg_cost_per_request,
250 latency: perf.avg_latency_ms,
251 quality: perf.avg_quality_score,
252 });
253 }
254 }
255
256 if let Some(current_model) = &self.current_model {
258 points.push(ObjectivePoint {
259 model: current_model.clone(),
260 cost: input.current_metrics.daily_cost_usd / input.current_metrics.request_count as f64,
261 latency: input.current_metrics.avg_latency_ms,
262 quality: input.current_metrics.avg_rating.unwrap_or(3.5),
263 });
264 }
265
266 points
267 }
268
269 fn safety_checks(
271 &self,
272 current: &ObjectivePoint,
273 target: &ObjectivePoint,
274 criteria: &DecisionCriteria,
275 ) -> Vec<SafetyCheck> {
276 let mut checks = Vec::new();
277 let now = Utc::now();
278
279 let cost_increase = target.cost - current.cost;
281 let cost_increase_pct = (cost_increase / current.cost) * 100.0;
282 let cost_passed = cost_increase_pct <= self.config.max_cost_increase_pct;
283
284 checks.push(SafetyCheck {
285 name: "cost_check".to_string(),
286 passed: cost_passed,
287 details: format!(
288 "Cost change: {:.2}% ({:.4} -> {:.4} USD/req). Max allowed: {:.2}%",
289 cost_increase_pct,
290 current.cost,
291 target.cost,
292 self.config.max_cost_increase_pct
293 ),
294 timestamp: now,
295 });
296
297 if let Some(max_latency_increase) = criteria.max_latency_increase_ms {
299 let latency_increase = target.latency - current.latency;
300 let latency_passed = latency_increase <= max_latency_increase;
301
302 checks.push(SafetyCheck {
303 name: "latency_check".to_string(),
304 passed: latency_passed,
305 details: format!(
306 "Latency change: {:.2}ms ({:.2} -> {:.2}ms). Max allowed: {:.2}ms",
307 latency_increase, current.latency, target.latency, max_latency_increase
308 ),
309 timestamp: now,
310 });
311 }
312
313 if let Some(min_quality) = criteria.min_quality_score {
315 let quality_passed = target.quality >= min_quality;
316
317 checks.push(SafetyCheck {
318 name: "quality_check".to_string(),
319 passed: quality_passed,
320 details: format!(
321 "Quality: {:.2} (current: {:.2}). Minimum: {:.2}",
322 target.quality, current.quality, min_quality
323 ),
324 timestamp: now,
325 });
326 }
327
328 let eval_period_passed = if let Some(last_eval) = self.last_evaluation {
330 let elapsed = Utc::now() - last_eval;
331 elapsed.to_std().unwrap_or(Duration::from_secs(0)) >= self.config.min_evaluation_period
332 } else {
333 true
334 };
335
336 checks.push(SafetyCheck {
337 name: "evaluation_period_check".to_string(),
338 passed: eval_period_passed,
339 details: format!(
340 "Minimum evaluation period: {:?}. Last evaluation: {:?}",
341 self.config.min_evaluation_period, self.last_evaluation
342 ),
343 timestamp: now,
344 });
345
346 checks
347 }
348
349 fn generate_rollback_plan(&self, from_model: &str, to_model: &str) -> RollbackPlan {
351 RollbackPlan {
352 description: format!("Rollback from {} to {} if metrics degrade", to_model, from_model),
353 revert_changes: vec![ConfigChange {
354 config_type: ConfigType::Model,
355 path: "model.name".to_string(),
356 old_value: Some(serde_json::json!(to_model)),
357 new_value: serde_json::json!(from_model),
358 description: format!("Revert model selection from {} to {}", to_model, from_model),
359 }],
360 trigger_conditions: vec![
361 RollbackCondition {
362 metric: "error_rate_pct".to_string(),
363 operator: ComparisonOperator::GreaterThan,
364 threshold: 10.0,
365 description: "Error rate exceeds 10%".to_string(),
366 },
367 RollbackCondition {
368 metric: "p95_latency_ms".to_string(),
369 operator: ComparisonOperator::GreaterThan,
370 threshold: 2000.0,
371 description: "P95 latency exceeds 2000ms".to_string(),
372 },
373 RollbackCondition {
374 metric: "avg_rating".to_string(),
375 operator: ComparisonOperator::LessThan,
376 threshold: 3.0,
377 description: "Average rating drops below 3.0".to_string(),
378 },
379 ],
380 max_duration: Duration::from_secs(3600), }
382 }
383
384 fn calculate_confidence(&self, perf: &ModelPerformance) -> f64 {
386 let sample_confidence = (perf.sample_size as f64 / 1000.0).min(1.0);
388
389 let success_confidence = perf.success_rate;
391
392 (sample_confidence * 0.5 + success_confidence * 0.5).min(1.0)
394 }
395
396 fn update_model_performance(&mut self, input: &DecisionInput) {
398 if let Some(current_model) = &self.current_model {
400 let perf = self
401 .model_performance
402 .entry(current_model.clone())
403 .or_insert_with(|| ModelPerformance {
404 model: current_model.clone(),
405 request_count: 0,
406 avg_latency_ms: 0.0,
407 p95_latency_ms: 0.0,
408 p99_latency_ms: 0.0,
409 avg_cost_per_request: 0.0,
410 total_cost: 0.0,
411 success_rate: 0.0,
412 avg_quality_score: 0.0,
413 last_updated: Utc::now(),
414 sample_size: 0,
415 });
416
417 perf.request_count = input.current_metrics.request_count;
419 perf.avg_latency_ms = input.current_metrics.avg_latency_ms;
420 perf.p95_latency_ms = input.current_metrics.p95_latency_ms;
421 perf.p99_latency_ms = input.current_metrics.p99_latency_ms;
422 perf.avg_cost_per_request = if input.current_metrics.request_count > 0 {
423 input.current_metrics.daily_cost_usd / input.current_metrics.request_count as f64
424 } else {
425 0.0
426 };
427 perf.total_cost = input.current_metrics.total_cost_usd;
428 perf.success_rate = input.current_metrics.success_rate_pct / 100.0;
429 perf.avg_quality_score = input.current_metrics.avg_rating.unwrap_or(3.5);
430 perf.last_updated = Utc::now();
431 perf.sample_size = input.current_metrics.request_count as usize;
432 }
433 }
434}
435
436#[derive(Debug, Clone, Deserialize)]
438struct ModelMetrics {
439 avg_cost_per_request: f64,
440 avg_latency_ms: f64,
441 avg_quality_score: f64,
442}
443
444#[async_trait]
445impl OptimizationStrategy for ModelSelectionStrategy {
446 fn name(&self) -> &str {
447 "model_selection"
448 }
449
450 fn priority(&self) -> u32 {
451 self.config.priority
452 }
453
454 fn is_applicable(&self, input: &DecisionInput) -> bool {
455 if !self.config.enabled {
461 return false;
462 }
463
464 let has_metrics = input.current_metrics.request_count > 100;
466
467 let eval_period_ok = if let Some(last_eval) = self.last_evaluation {
469 let elapsed = Utc::now() - last_eval;
470 elapsed.to_std().unwrap_or(Duration::from_secs(0)) >= self.config.min_evaluation_period
471 } else {
472 true
473 };
474
475 has_metrics && eval_period_ok
476 }
477
478 async fn evaluate(
479 &self,
480 input: &DecisionInput,
481 criteria: &DecisionCriteria,
482 ) -> DecisionResult<Vec<Decision>> {
483 let mut decisions = Vec::new();
484
485 let points = self.extract_model_performance(input);
487
488 if points.is_empty() {
489 return Ok(decisions);
490 }
491
492 let frontier = self.calculate_pareto_frontier(&points);
494
495 if frontier.is_empty() {
496 return Ok(decisions);
497 }
498
499 let best_model = self
501 .select_best_model(&frontier)
502 .ok_or_else(|| DecisionError::InsufficientData("No suitable model found".to_string()))?;
503
504 let current_model = if let Some(current) = &self.current_model {
506 points
507 .iter()
508 .find(|p| p.model == *current)
509 .cloned()
510 .unwrap_or_else(|| ObjectivePoint {
511 model: current.clone(),
512 cost: input.current_metrics.daily_cost_usd
513 / input.current_metrics.request_count as f64,
514 latency: input.current_metrics.avg_latency_ms,
515 quality: input.current_metrics.avg_rating.unwrap_or(3.5),
516 })
517 } else {
518 points[0].clone()
520 };
521
522 if best_model.model == current_model.model {
524 return Ok(decisions);
526 }
527
528 let safety_checks = self.safety_checks(¤t_model, &best_model, criteria);
530
531 let all_passed = safety_checks.iter().all(|check| check.passed);
533
534 if !all_passed {
535 return Ok(decisions);
537 }
538
539 let cost_change = best_model.cost - current_model.cost;
541 let latency_change = best_model.latency - current_model.latency;
542 let quality_change = best_model.quality - current_model.quality;
543
544 let expected_impact = ExpectedImpact {
545 cost_change_usd: Some(cost_change * input.current_metrics.request_count as f64),
546 latency_change_ms: Some(latency_change),
547 quality_change: Some(quality_change),
548 throughput_change_rps: None,
549 success_rate_change_pct: None,
550 time_to_impact: Duration::from_secs(300), confidence: self.config.min_confidence,
552 };
553
554 let rollback_plan = self.generate_rollback_plan(¤t_model.model, &best_model.model);
556
557 let justification = format!(
559 "Pareto optimization recommends switching from {} to {}. \
560 Expected impact: cost {}{:.2}%, latency {}{:.2}ms, quality {}{:.2}. \
561 Model {} is on the Pareto frontier and scores {:.3} (weighted by cost={:.1}, perf={:.1}, quality={:.1}). \
562 This model is not dominated by any other model in cost-latency-quality space.",
563 current_model.model,
564 best_model.model,
565 if cost_change > 0.0 { "+" } else { "" },
566 (cost_change / current_model.cost) * 100.0,
567 if latency_change > 0.0 { "+" } else { "" },
568 latency_change,
569 if quality_change > 0.0 { "+" } else { "" },
570 quality_change,
571 best_model.model,
572 self.score_model(&best_model),
573 self.config.cost_weight,
574 self.config.performance_weight,
575 self.config.quality_weight
576 );
577
578 let decision = Decision {
580 id: Uuid::new_v4().to_string(),
581 timestamp: Utc::now(),
582 strategy: self.name().to_string(),
583 decision_type: DecisionType::ModelSwitch,
584 confidence: self.config.min_confidence,
585 expected_impact,
586 config_changes: vec![ConfigChange {
587 config_type: ConfigType::Model,
588 path: "model.name".to_string(),
589 old_value: Some(serde_json::json!(current_model.model)),
590 new_value: serde_json::json!(best_model.model),
591 description: format!(
592 "Switch model from {} to {} based on Pareto optimization",
593 current_model.model, best_model.model
594 ),
595 }],
596 justification,
597 related_insights: input
598 .insights
599 .iter()
600 .filter(|i| i.analyzer == "cost" || i.analyzer == "performance" || i.analyzer == "quality")
601 .map(|i| i.id.clone())
602 .collect(),
603 related_recommendations: input
604 .recommendations
605 .iter()
606 .filter(|r| r.tags.contains(&"optimization".to_string()))
607 .map(|r| r.id.clone())
608 .collect(),
609 priority: self.priority(),
610 requires_approval: true,
611 safety_checks,
612 rollback_plan: Some(rollback_plan),
613 metadata: {
614 let mut meta = HashMap::new();
615 meta.insert("from_model".to_string(), serde_json::json!(current_model.model));
616 meta.insert("to_model".to_string(), serde_json::json!(best_model.model));
617 meta.insert("pareto_frontier_size".to_string(), serde_json::json!(frontier.len()));
618 meta.insert(
619 "score".to_string(),
620 serde_json::json!(self.score_model(&best_model)),
621 );
622 meta
623 },
624 };
625
626 decisions.push(decision);
627
628 Ok(decisions)
629 }
630
631 async fn validate(&self, decision: &Decision, criteria: &DecisionCriteria) -> DecisionResult<()> {
632 if decision.decision_type != DecisionType::ModelSwitch {
634 return Err(DecisionError::ValidationFailed(
635 "Invalid decision type for model selection strategy".to_string(),
636 ));
637 }
638
639 if decision.confidence < criteria.min_confidence {
641 return Err(DecisionError::ValidationFailed(format!(
642 "Confidence {:.2} is below minimum {:.2}",
643 decision.confidence, criteria.min_confidence
644 )));
645 }
646
647 for check_name in &criteria.required_safety_checks {
649 let check = decision
650 .safety_checks
651 .iter()
652 .find(|c| c.name == *check_name)
653 .ok_or_else(|| {
654 DecisionError::ValidationFailed(format!("Missing required safety check: {}", check_name))
655 })?;
656
657 if !check.passed {
658 return Err(DecisionError::ValidationFailed(format!(
659 "Safety check '{}' failed: {}",
660 check_name, check.details
661 )));
662 }
663 }
664
665 if decision.config_changes.is_empty() {
667 return Err(DecisionError::ValidationFailed(
668 "No configuration changes specified".to_string(),
669 ));
670 }
671
672 let has_model_change = decision
674 .config_changes
675 .iter()
676 .any(|c| c.config_type == ConfigType::Model && c.path == "model.name");
677
678 if !has_model_change {
679 return Err(DecisionError::ValidationFailed(
680 "No model configuration change found".to_string(),
681 ));
682 }
683
684 if let Some(max_cost_increase) = criteria.max_cost_increase_usd {
686 if let Some(cost_change) = decision.expected_impact.cost_change_usd {
687 if cost_change > max_cost_increase {
688 return Err(DecisionError::ConstraintViolation(format!(
689 "Cost increase {:.2} USD exceeds maximum {:.2} USD",
690 cost_change, max_cost_increase
691 )));
692 }
693 }
694 }
695
696 if let Some(max_latency_increase) = criteria.max_latency_increase_ms {
698 if let Some(latency_change) = decision.expected_impact.latency_change_ms {
699 if latency_change > max_latency_increase {
700 return Err(DecisionError::ConstraintViolation(format!(
701 "Latency increase {:.2}ms exceeds maximum {:.2}ms",
702 latency_change, max_latency_increase
703 )));
704 }
705 }
706 }
707
708 Ok(())
709 }
710
711 async fn learn(&mut self, decision: &Decision, outcome: &DecisionOutcome) -> DecisionResult<()> {
712 self.stats.total_decisions += 1;
714
715 if outcome.success {
716 self.stats.successful_switches += 1;
717
718 if let Some(actual_impact) = &outcome.actual_impact {
720 if let Some(cost_change) = actual_impact.cost_change_usd.into() {
721 self.stats.total_cost_savings -= cost_change;
722 }
723 self.stats.total_latency_improvement -= actual_impact.latency_change_ms;
724 self.stats.total_quality_improvement += actual_impact.quality_change;
725 }
726
727 if let Some(to_model) = decision.metadata.get("to_model") {
729 if let Some(model_name) = to_model.as_str() {
730 self.current_model = Some(model_name.to_string());
731 }
732 }
733
734 if let (Some(from_model), Some(to_model)) = (
736 decision.metadata.get("from_model").and_then(|v| v.as_str()),
737 decision.metadata.get("to_model").and_then(|v| v.as_str()),
738 ) {
739 self.decision_history.push(DecisionRecord {
740 decision_id: decision.id.clone(),
741 from_model: from_model.to_string(),
742 to_model: to_model.to_string(),
743 timestamp: decision.timestamp,
744 expected_impact: decision.expected_impact.clone(),
745 actual_impact: outcome.actual_impact.clone(),
746 success: true,
747 });
748 }
749 } else {
750 self.stats.failed_switches += 1;
751
752 if let (Some(from_model), Some(to_model)) = (
754 decision.metadata.get("from_model").and_then(|v| v.as_str()),
755 decision.metadata.get("to_model").and_then(|v| v.as_str()),
756 ) {
757 self.decision_history.push(DecisionRecord {
758 decision_id: decision.id.clone(),
759 from_model: from_model.to_string(),
760 to_model: to_model.to_string(),
761 timestamp: decision.timestamp,
762 expected_impact: decision.expected_impact.clone(),
763 actual_impact: outcome.actual_impact.clone(),
764 success: false,
765 });
766 }
767 }
768
769 self.last_evaluation = Some(Utc::now());
771
772 if self.decision_history.len() > 1000 {
774 self.decision_history.drain(0..100);
775 }
776
777 Ok(())
778 }
779
780 fn get_stats(&self) -> serde_json::Value {
781 serde_json::json!({
782 "total_evaluations": self.stats.total_evaluations,
783 "total_decisions": self.stats.total_decisions,
784 "successful_switches": self.stats.successful_switches,
785 "failed_switches": self.stats.failed_switches,
786 "success_rate": if self.stats.total_decisions > 0 {
787 self.stats.successful_switches as f64 / self.stats.total_decisions as f64
788 } else {
789 0.0
790 },
791 "total_cost_savings": self.stats.total_cost_savings,
792 "total_latency_improvement": self.stats.total_latency_improvement,
793 "total_quality_improvement": self.stats.total_quality_improvement,
794 "current_model": self.current_model,
795 "tracked_models": self.model_performance.len(),
796 "last_evaluation": self.last_evaluation,
797 "decision_history_size": self.decision_history.len(),
798 })
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 fn create_test_config() -> ModelSelectionConfig {
807 ModelSelectionConfig {
808 enabled: true,
809 priority: 100,
810 min_confidence: 0.7,
811 cost_weight: 0.4,
812 performance_weight: 0.3,
813 quality_weight: 0.3,
814 available_models: vec![
815 "gpt-4".to_string(),
816 "gpt-3.5-turbo".to_string(),
817 "claude-3-opus".to_string(),
818 ],
819 min_evaluation_period: Duration::from_secs(60),
820 max_cost_increase_pct: 50.0,
821 }
822 }
823
824 fn create_test_input(current_model: &str, request_count: u64) -> DecisionInput {
825 DecisionInput {
826 timestamp: Utc::now(),
827 analysis_reports: vec![],
828 insights: vec![],
829 recommendations: vec![],
830 current_metrics: super::super::types::SystemMetrics {
831 avg_latency_ms: 500.0,
832 p95_latency_ms: 800.0,
833 p99_latency_ms: 1200.0,
834 success_rate_pct: 99.0,
835 error_rate_pct: 1.0,
836 throughput_rps: 100.0,
837 total_cost_usd: 100.0,
838 daily_cost_usd: 100.0,
839 avg_rating: Some(4.0),
840 cache_hit_rate_pct: Some(50.0),
841 request_count,
842 timestamp: Utc::now(),
843 },
844 context: {
845 let mut ctx = HashMap::new();
846 ctx.insert("current_model".to_string(), serde_json::json!(current_model));
847 ctx
848 },
849 }
850 }
851
852 fn create_test_criteria() -> DecisionCriteria {
853 DecisionCriteria {
854 min_confidence: 0.7,
855 max_cost_increase_usd: Some(100.0),
856 max_latency_increase_ms: Some(500.0),
857 min_quality_score: Some(3.5),
858 required_safety_checks: vec![
859 "cost_check".to_string(),
860 "latency_check".to_string(),
861 "quality_check".to_string(),
862 ],
863 allow_breaking_changes: false,
864 max_concurrent_decisions: 3,
865 enabled_strategies: vec!["model_selection".to_string()],
866 strategy_priorities: {
867 let mut priorities = HashMap::new();
868 priorities.insert("model_selection".to_string(), 100);
869 priorities
870 },
871 }
872 }
873
874 #[test]
875 fn test_strategy_creation() {
876 let config = create_test_config();
877 let strategy = ModelSelectionStrategy::new(config);
878
879 assert_eq!(strategy.name(), "model_selection");
880 assert_eq!(strategy.priority(), 100);
881 assert!(strategy.model_performance.is_empty());
882 assert!(strategy.current_model.is_none());
883 }
884
885 #[test]
886 fn test_dominance_check() {
887 let strategy = ModelSelectionStrategy::new(create_test_config());
888
889 let point_a = ObjectivePoint {
890 model: "model_a".to_string(),
891 cost: 10.0,
892 latency: 100.0,
893 quality: 4.5,
894 };
895
896 let point_b = ObjectivePoint {
897 model: "model_b".to_string(),
898 cost: 15.0,
899 latency: 120.0,
900 quality: 4.0,
901 };
902
903 assert!(strategy.dominates(&point_a, &point_b));
905 assert!(!strategy.dominates(&point_b, &point_a));
906 }
907
908 #[test]
909 fn test_dominance_partial() {
910 let strategy = ModelSelectionStrategy::new(create_test_config());
911
912 let point_a = ObjectivePoint {
913 model: "model_a".to_string(),
914 cost: 10.0,
915 latency: 100.0,
916 quality: 4.0,
917 };
918
919 let point_b = ObjectivePoint {
920 model: "model_b".to_string(),
921 cost: 15.0,
922 latency: 80.0,
923 quality: 4.5,
924 };
925
926 assert!(!strategy.dominates(&point_a, &point_b));
928 assert!(!strategy.dominates(&point_b, &point_a));
929 }
930
931 #[test]
932 fn test_pareto_frontier_single_point() {
933 let strategy = ModelSelectionStrategy::new(create_test_config());
934
935 let points = vec![ObjectivePoint {
936 model: "model_a".to_string(),
937 cost: 10.0,
938 latency: 100.0,
939 quality: 4.5,
940 }];
941
942 let frontier = strategy.calculate_pareto_frontier(&points);
943 assert_eq!(frontier.len(), 1);
944 assert_eq!(frontier[0].model, "model_a");
945 }
946
947 #[test]
948 fn test_pareto_frontier_multiple_points() {
949 let strategy = ModelSelectionStrategy::new(create_test_config());
950
951 let points = vec![
952 ObjectivePoint {
953 model: "cheap_slow".to_string(),
954 cost: 5.0,
955 latency: 500.0,
956 quality: 3.5,
957 },
958 ObjectivePoint {
959 model: "expensive_fast".to_string(),
960 cost: 20.0,
961 latency: 100.0,
962 quality: 4.5,
963 },
964 ObjectivePoint {
965 model: "balanced".to_string(),
966 cost: 10.0,
967 latency: 200.0,
968 quality: 4.0,
969 },
970 ObjectivePoint {
971 model: "dominated".to_string(),
972 cost: 15.0,
973 latency: 300.0,
974 quality: 3.8,
975 },
976 ];
977
978 let frontier = strategy.calculate_pareto_frontier(&points);
979
980 assert_eq!(frontier.len(), 3);
982
983 let frontier_models: Vec<String> = frontier.iter().map(|p| p.model.clone()).collect();
984 assert!(frontier_models.contains(&"cheap_slow".to_string()));
985 assert!(frontier_models.contains(&"expensive_fast".to_string()));
986 assert!(frontier_models.contains(&"balanced".to_string()));
987 assert!(!frontier_models.contains(&"dominated".to_string()));
988 }
989
990 #[test]
991 fn test_model_scoring() {
992 let mut config = create_test_config();
993 config.cost_weight = 0.5;
994 config.performance_weight = 0.3;
995 config.quality_weight = 0.2;
996 let strategy = ModelSelectionStrategy::new(config);
997
998 let point = ObjectivePoint {
999 model: "test_model".to_string(),
1000 cost: 10.0,
1001 latency: 200.0,
1002 quality: 4.0,
1003 };
1004
1005 let score = strategy.score_model(&point);
1006 assert!(score > 0.0 && score <= 1.0);
1007 }
1008
1009 #[test]
1010 fn test_best_model_selection() {
1011 let strategy = ModelSelectionStrategy::new(create_test_config());
1012
1013 let frontier = vec![
1014 ObjectivePoint {
1015 model: "model_a".to_string(),
1016 cost: 5.0,
1017 latency: 500.0,
1018 quality: 3.5,
1019 },
1020 ObjectivePoint {
1021 model: "model_b".to_string(),
1022 cost: 20.0,
1023 latency: 100.0,
1024 quality: 4.5,
1025 },
1026 ObjectivePoint {
1027 model: "model_c".to_string(),
1028 cost: 10.0,
1029 latency: 200.0,
1030 quality: 4.0,
1031 },
1032 ];
1033
1034 let best = strategy.select_best_model(&frontier);
1035 assert!(best.is_some());
1036
1037 let best = best.unwrap();
1039 assert_eq!(best.model, "model_c");
1040 }
1041
1042 #[test]
1043 fn test_safety_checks_all_pass() {
1044 let strategy = ModelSelectionStrategy::new(create_test_config());
1045
1046 let current = ObjectivePoint {
1047 model: "current".to_string(),
1048 cost: 10.0,
1049 latency: 200.0,
1050 quality: 4.0,
1051 };
1052
1053 let target = ObjectivePoint {
1054 model: "target".to_string(),
1055 cost: 12.0, latency: 180.0,
1057 quality: 4.2,
1058 };
1059
1060 let criteria = create_test_criteria();
1061 let checks = strategy.safety_checks(¤t, &target, &criteria);
1062
1063 assert!(checks.iter().all(|c| c.passed));
1065 }
1066
1067 #[test]
1068 fn test_safety_checks_cost_fail() {
1069 let strategy = ModelSelectionStrategy::new(create_test_config());
1070
1071 let current = ObjectivePoint {
1072 model: "current".to_string(),
1073 cost: 10.0,
1074 latency: 200.0,
1075 quality: 4.0,
1076 };
1077
1078 let target = ObjectivePoint {
1079 model: "target".to_string(),
1080 cost: 20.0, latency: 180.0,
1082 quality: 4.2,
1083 };
1084
1085 let criteria = create_test_criteria();
1086 let checks = strategy.safety_checks(¤t, &target, &criteria);
1087
1088 let cost_check = checks.iter().find(|c| c.name == "cost_check").unwrap();
1090 assert!(!cost_check.passed);
1091 }
1092
1093 #[test]
1094 fn test_is_applicable_insufficient_metrics() {
1095 let strategy = ModelSelectionStrategy::new(create_test_config());
1096 let input = create_test_input("gpt-4", 50); assert!(!strategy.is_applicable(&input));
1099 }
1100
1101 #[test]
1102 fn test_is_applicable_sufficient_metrics() {
1103 let strategy = ModelSelectionStrategy::new(create_test_config());
1104 let input = create_test_input("gpt-4", 1000); assert!(strategy.is_applicable(&input));
1107 }
1108
1109 #[test]
1110 fn test_is_applicable_disabled_strategy() {
1111 let mut config = create_test_config();
1112 config.enabled = false;
1113 let strategy = ModelSelectionStrategy::new(config);
1114 let input = create_test_input("gpt-4", 1000);
1115
1116 assert!(!strategy.is_applicable(&input));
1117 }
1118
1119 #[tokio::test]
1120 async fn test_validate_valid_decision() {
1121 let strategy = ModelSelectionStrategy::new(create_test_config());
1122 let criteria = create_test_criteria();
1123
1124 let decision = Decision {
1125 id: Uuid::new_v4().to_string(),
1126 timestamp: Utc::now(),
1127 strategy: "model_selection".to_string(),
1128 decision_type: DecisionType::ModelSwitch,
1129 confidence: 0.8,
1130 expected_impact: ExpectedImpact {
1131 cost_change_usd: Some(10.0),
1132 latency_change_ms: Some(-50.0),
1133 quality_change: Some(0.2),
1134 throughput_change_rps: None,
1135 success_rate_change_pct: None,
1136 time_to_impact: Duration::from_secs(300),
1137 confidence: 0.8,
1138 },
1139 config_changes: vec![ConfigChange {
1140 config_type: ConfigType::Model,
1141 path: "model.name".to_string(),
1142 old_value: Some(serde_json::json!("gpt-4")),
1143 new_value: serde_json::json!("claude-3-opus"),
1144 description: "Switch model".to_string(),
1145 }],
1146 justification: "Test justification".to_string(),
1147 related_insights: vec![],
1148 related_recommendations: vec![],
1149 priority: 100,
1150 requires_approval: true,
1151 safety_checks: vec![
1152 SafetyCheck {
1153 name: "cost_check".to_string(),
1154 passed: true,
1155 details: "Cost OK".to_string(),
1156 timestamp: Utc::now(),
1157 },
1158 SafetyCheck {
1159 name: "latency_check".to_string(),
1160 passed: true,
1161 details: "Latency OK".to_string(),
1162 timestamp: Utc::now(),
1163 },
1164 SafetyCheck {
1165 name: "quality_check".to_string(),
1166 passed: true,
1167 details: "Quality OK".to_string(),
1168 timestamp: Utc::now(),
1169 },
1170 ],
1171 rollback_plan: None,
1172 metadata: HashMap::new(),
1173 };
1174
1175 let result = strategy.validate(&decision, &criteria).await;
1176 assert!(result.is_ok());
1177 }
1178
1179 #[tokio::test]
1180 async fn test_validate_wrong_decision_type() {
1181 let strategy = ModelSelectionStrategy::new(create_test_config());
1182 let criteria = create_test_criteria();
1183
1184 let mut decision = Decision {
1185 id: Uuid::new_v4().to_string(),
1186 timestamp: Utc::now(),
1187 strategy: "model_selection".to_string(),
1188 decision_type: DecisionType::EnableCaching, confidence: 0.8,
1190 expected_impact: ExpectedImpact {
1191 cost_change_usd: Some(10.0),
1192 latency_change_ms: Some(-50.0),
1193 quality_change: Some(0.2),
1194 throughput_change_rps: None,
1195 success_rate_change_pct: None,
1196 time_to_impact: Duration::from_secs(300),
1197 confidence: 0.8,
1198 },
1199 config_changes: vec![],
1200 justification: "Test".to_string(),
1201 related_insights: vec![],
1202 related_recommendations: vec![],
1203 priority: 100,
1204 requires_approval: true,
1205 safety_checks: vec![],
1206 rollback_plan: None,
1207 metadata: HashMap::new(),
1208 };
1209
1210 let result = strategy.validate(&decision, &criteria).await;
1211 assert!(result.is_err());
1212 }
1213
1214 #[tokio::test]
1215 async fn test_validate_low_confidence() {
1216 let strategy = ModelSelectionStrategy::new(create_test_config());
1217 let criteria = create_test_criteria();
1218
1219 let decision = Decision {
1220 id: Uuid::new_v4().to_string(),
1221 timestamp: Utc::now(),
1222 strategy: "model_selection".to_string(),
1223 decision_type: DecisionType::ModelSwitch,
1224 confidence: 0.5, expected_impact: ExpectedImpact {
1226 cost_change_usd: Some(10.0),
1227 latency_change_ms: Some(-50.0),
1228 quality_change: Some(0.2),
1229 throughput_change_rps: None,
1230 success_rate_change_pct: None,
1231 time_to_impact: Duration::from_secs(300),
1232 confidence: 0.5,
1233 },
1234 config_changes: vec![ConfigChange {
1235 config_type: ConfigType::Model,
1236 path: "model.name".to_string(),
1237 old_value: Some(serde_json::json!("gpt-4")),
1238 new_value: serde_json::json!("claude-3-opus"),
1239 description: "Switch model".to_string(),
1240 }],
1241 justification: "Test".to_string(),
1242 related_insights: vec![],
1243 related_recommendations: vec![],
1244 priority: 100,
1245 requires_approval: true,
1246 safety_checks: vec![
1247 SafetyCheck {
1248 name: "cost_check".to_string(),
1249 passed: true,
1250 details: "OK".to_string(),
1251 timestamp: Utc::now(),
1252 },
1253 SafetyCheck {
1254 name: "latency_check".to_string(),
1255 passed: true,
1256 details: "OK".to_string(),
1257 timestamp: Utc::now(),
1258 },
1259 SafetyCheck {
1260 name: "quality_check".to_string(),
1261 passed: true,
1262 details: "OK".to_string(),
1263 timestamp: Utc::now(),
1264 },
1265 ],
1266 rollback_plan: None,
1267 metadata: HashMap::new(),
1268 };
1269
1270 let result = strategy.validate(&decision, &criteria).await;
1271 assert!(result.is_err());
1272 }
1273
1274 #[tokio::test]
1275 async fn test_learn_successful_outcome() {
1276 let mut strategy = ModelSelectionStrategy::new(create_test_config());
1277
1278 let decision = Decision {
1279 id: Uuid::new_v4().to_string(),
1280 timestamp: Utc::now(),
1281 strategy: "model_selection".to_string(),
1282 decision_type: DecisionType::ModelSwitch,
1283 confidence: 0.8,
1284 expected_impact: ExpectedImpact {
1285 cost_change_usd: Some(-10.0),
1286 latency_change_ms: Some(-50.0),
1287 quality_change: Some(0.2),
1288 throughput_change_rps: None,
1289 success_rate_change_pct: None,
1290 time_to_impact: Duration::from_secs(300),
1291 confidence: 0.8,
1292 },
1293 config_changes: vec![],
1294 justification: "Test".to_string(),
1295 related_insights: vec![],
1296 related_recommendations: vec![],
1297 priority: 100,
1298 requires_approval: true,
1299 safety_checks: vec![],
1300 rollback_plan: None,
1301 metadata: {
1302 let mut meta = HashMap::new();
1303 meta.insert("from_model".to_string(), serde_json::json!("gpt-4"));
1304 meta.insert("to_model".to_string(), serde_json::json!("claude-3-opus"));
1305 meta
1306 },
1307 };
1308
1309 let outcome = DecisionOutcome {
1310 decision_id: decision.id.clone(),
1311 execution_time: Utc::now(),
1312 success: true,
1313 error: None,
1314 actual_impact: Some(super::super::types::ActualImpact {
1315 cost_change_usd: -12.0,
1316 latency_change_ms: -45.0,
1317 quality_change: 0.25,
1318 throughput_change_rps: 0.0,
1319 success_rate_change_pct: 0.0,
1320 time_to_impact: Duration::from_secs(300),
1321 variance_from_expected: 0.1,
1322 }),
1323 rolled_back: false,
1324 rollback_reason: None,
1325 metrics_before: create_test_input("gpt-4", 1000).current_metrics,
1326 metrics_after: Some(create_test_input("claude-3-opus", 1000).current_metrics),
1327 duration: Duration::from_secs(3600),
1328 };
1329
1330 let result = strategy.learn(&decision, &outcome).await;
1331 assert!(result.is_ok());
1332 assert_eq!(strategy.stats.successful_switches, 1);
1333 assert_eq!(strategy.stats.total_decisions, 1);
1334 assert_eq!(strategy.current_model, Some("claude-3-opus".to_string()));
1335 }
1336
1337 #[tokio::test]
1338 async fn test_learn_failed_outcome() {
1339 let mut strategy = ModelSelectionStrategy::new(create_test_config());
1340
1341 let decision = Decision {
1342 id: Uuid::new_v4().to_string(),
1343 timestamp: Utc::now(),
1344 strategy: "model_selection".to_string(),
1345 decision_type: DecisionType::ModelSwitch,
1346 confidence: 0.8,
1347 expected_impact: ExpectedImpact {
1348 cost_change_usd: Some(-10.0),
1349 latency_change_ms: Some(-50.0),
1350 quality_change: Some(0.2),
1351 throughput_change_rps: None,
1352 success_rate_change_pct: None,
1353 time_to_impact: Duration::from_secs(300),
1354 confidence: 0.8,
1355 },
1356 config_changes: vec![],
1357 justification: "Test".to_string(),
1358 related_insights: vec![],
1359 related_recommendations: vec![],
1360 priority: 100,
1361 requires_approval: true,
1362 safety_checks: vec![],
1363 rollback_plan: None,
1364 metadata: {
1365 let mut meta = HashMap::new();
1366 meta.insert("from_model".to_string(), serde_json::json!("gpt-4"));
1367 meta.insert("to_model".to_string(), serde_json::json!("claude-3-opus"));
1368 meta
1369 },
1370 };
1371
1372 let outcome = DecisionOutcome {
1373 decision_id: decision.id.clone(),
1374 execution_time: Utc::now(),
1375 success: false,
1376 error: Some("Model switch failed".to_string()),
1377 actual_impact: None,
1378 rolled_back: true,
1379 rollback_reason: Some("Error rate exceeded threshold".to_string()),
1380 metrics_before: create_test_input("gpt-4", 1000).current_metrics,
1381 metrics_after: None,
1382 duration: Duration::from_secs(600),
1383 };
1384
1385 let result = strategy.learn(&decision, &outcome).await;
1386 assert!(result.is_ok());
1387 assert_eq!(strategy.stats.failed_switches, 1);
1388 assert_eq!(strategy.stats.total_decisions, 1);
1389 }
1390
1391 #[test]
1392 fn test_get_stats() {
1393 let mut strategy = ModelSelectionStrategy::new(create_test_config());
1394 strategy.stats.total_decisions = 10;
1395 strategy.stats.successful_switches = 8;
1396 strategy.stats.failed_switches = 2;
1397 strategy.stats.total_cost_savings = 150.0;
1398
1399 let stats = strategy.get_stats();
1400
1401 assert_eq!(stats["total_decisions"], 10);
1402 assert_eq!(stats["successful_switches"], 8);
1403 assert_eq!(stats["failed_switches"], 2);
1404 assert_eq!(stats["success_rate"], 0.8);
1405 assert_eq!(stats["total_cost_savings"], 150.0);
1406 }
1407
1408 #[test]
1409 fn test_rollback_plan_generation() {
1410 let strategy = ModelSelectionStrategy::new(create_test_config());
1411 let plan = strategy.generate_rollback_plan("gpt-4", "claude-3-opus");
1412
1413 assert!(plan.description.contains("gpt-4"));
1414 assert!(plan.description.contains("claude-3-opus"));
1415 assert_eq!(plan.revert_changes.len(), 1);
1416 assert_eq!(plan.trigger_conditions.len(), 3);
1417
1418 let error_condition = plan
1420 .trigger_conditions
1421 .iter()
1422 .find(|c| c.metric == "error_rate_pct")
1423 .unwrap();
1424 assert_eq!(error_condition.operator, ComparisonOperator::GreaterThan);
1425 assert_eq!(error_condition.threshold, 10.0);
1426 }
1427}