Skip to main content

llm_optimizer_processor/decision/
model_selection.rs

1//! Model Selection Strategy
2//!
3//! This strategy uses multi-objective optimization to select the optimal LLM model
4//! based on cost, performance (latency), and quality. It implements Pareto frontier
5//! analysis to find the best trade-offs between these competing objectives.
6
7use 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/// Model Selection Strategy
23///
24/// This strategy evaluates different LLM models based on:
25/// - Cost: Token cost and total daily cost
26/// - Performance: Latency (P50, P95, P99)
27/// - Quality: User ratings and success rates
28///
29/// It uses Pareto optimization to find models that are optimal for at least
30/// one objective while not being dominated by other models in all objectives.
31#[derive(Debug)]
32pub struct ModelSelectionStrategy {
33    /// Configuration
34    config: ModelSelectionConfig,
35
36    /// Performance history for each model
37    model_performance: HashMap<String, ModelPerformance>,
38
39    /// Current model being used
40    current_model: Option<String>,
41
42    /// Last evaluation time
43    last_evaluation: Option<DateTime<Utc>>,
44
45    /// Decision history for learning
46    decision_history: Vec<DecisionRecord>,
47
48    /// Statistics
49    stats: StrategyStats,
50}
51
52/// Performance metrics for a specific model
53#[derive(Debug, Clone, Serialize, Deserialize)]
54struct ModelPerformance {
55    /// Model name
56    model: String,
57
58    /// Number of requests
59    request_count: u64,
60
61    /// Average latency (ms)
62    avg_latency_ms: f64,
63
64    /// P95 latency (ms)
65    p95_latency_ms: f64,
66
67    /// P99 latency (ms)
68    p99_latency_ms: f64,
69
70    /// Average cost per request (USD)
71    avg_cost_per_request: f64,
72
73    /// Total cost (USD)
74    total_cost: f64,
75
76    /// Success rate (0-1)
77    success_rate: f64,
78
79    /// Average quality score (0-5)
80    avg_quality_score: f64,
81
82    /// Last updated
83    last_updated: DateTime<Utc>,
84
85    /// Sample size for statistics
86    sample_size: usize,
87}
88
89/// A point in the objective space (cost, latency, quality)
90#[derive(Debug, Clone)]
91struct ObjectivePoint {
92    model: String,
93    cost: f64,      // Lower is better
94    latency: f64,   // Lower is better
95    quality: f64,   // Higher is better (we'll negate for dominance)
96}
97
98/// Record of a decision for learning
99#[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/// Strategy statistics
111#[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    /// Create a new model selection strategy
124    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    /// Calculate Pareto frontier from a set of objective points
136    ///
137    /// A point is on the Pareto frontier if it is not dominated by any other point.
138    /// Point A dominates point B if A is better or equal in all objectives and
139    /// strictly better in at least one objective.
140    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            // Check if this candidate is dominated by any other point
147            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    /// Check if point A dominates point B
163    ///
164    /// A dominates B if:
165    /// - A.cost <= B.cost AND A.latency <= B.latency AND A.quality >= B.quality
166    /// - At least one of the above is strictly better
167    fn dominates(&self, a: &ObjectivePoint, b: &ObjectivePoint) -> bool {
168        // Skip self-comparison
169        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        // A dominates B if it's better or equal in all dimensions
182        // and strictly better in at least one
183        cost_better
184            && latency_better
185            && quality_better
186            && (cost_strictly_better || latency_strictly_better || quality_strictly_better)
187    }
188
189    /// Score a model based on weighted objectives
190    fn score_model(&self, point: &ObjectivePoint) -> f64 {
191        // Normalize metrics (assuming reasonable ranges)
192        let cost_norm = (100.0 - point.cost.min(100.0)) / 100.0; // 0-100 USD range
193        let latency_norm = (5000.0 - point.latency.min(5000.0)) / 5000.0; // 0-5000ms range
194        let quality_norm = point.quality / 5.0; // 0-5 range
195
196        // Weighted sum
197        self.config.cost_weight * cost_norm
198            + self.config.performance_weight * latency_norm
199            + self.config.quality_weight * quality_norm
200    }
201
202    /// Select the best model from the Pareto frontier
203    fn select_best_model(&self, frontier: &[ObjectivePoint]) -> Option<ObjectivePoint> {
204        if frontier.is_empty() {
205            return None;
206        }
207
208        // Score each point and select the highest
209        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    /// Extract model performance from decision input
220    fn extract_model_performance(&self, input: &DecisionInput) -> Vec<ObjectivePoint> {
221        let mut points = Vec::new();
222
223        // Get performance metrics from insights and recommendations
224        for report in &input.analysis_reports {
225            // Try to extract model metrics from insights since metadata field doesn't exist
226            // This is a placeholder - actual implementation would extract from insights
227            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 no metrics from reports, use stored performance data
245        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        // Add current system metrics as the baseline
257        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    /// Perform safety checks for a model switch decision
270    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        // Cost check
280        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        // Latency check
298        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        // Quality check
314        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        // Minimum evaluation period check
329        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    /// Generate a rollback plan for a model switch
350    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), // 1 hour
381        }
382    }
383
384    /// Calculate confidence based on sample size and variance
385    fn calculate_confidence(&self, perf: &ModelPerformance) -> f64 {
386        // Base confidence on sample size
387        let sample_confidence = (perf.sample_size as f64 / 1000.0).min(1.0);
388
389        // Adjust for success rate
390        let success_confidence = perf.success_rate;
391
392        // Combined confidence
393        (sample_confidence * 0.5 + success_confidence * 0.5).min(1.0)
394    }
395
396    /// Update model performance from decision input
397    fn update_model_performance(&mut self, input: &DecisionInput) {
398        // Extract current model performance
399        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            // Update with current metrics
418            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/// Helper struct for deserializing model metrics
437#[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        // Strategy is applicable if:
456        // 1. We have sufficient metrics
457        // 2. Multiple models are available
458        // 3. Enough time has passed since last evaluation
459
460        if !self.config.enabled {
461            return false;
462        }
463
464        // Check if we have metrics
465        let has_metrics = input.current_metrics.request_count > 100;
466
467        // Check evaluation period
468        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        // Extract model performance data
486        let points = self.extract_model_performance(input);
487
488        if points.is_empty() {
489            return Ok(decisions);
490        }
491
492        // Calculate Pareto frontier
493        let frontier = self.calculate_pareto_frontier(&points);
494
495        if frontier.is_empty() {
496            return Ok(decisions);
497        }
498
499        // Select the best model from the frontier
500        let best_model = self
501            .select_best_model(&frontier)
502            .ok_or_else(|| DecisionError::InsufficientData("No suitable model found".to_string()))?;
503
504        // Get current model
505        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            // No current model set, use the first point as baseline
519            points[0].clone()
520        };
521
522        // Check if switching is beneficial
523        if best_model.model == current_model.model {
524            // Already using the best model
525            return Ok(decisions);
526        }
527
528        // Perform safety checks
529        let safety_checks = self.safety_checks(&current_model, &best_model, criteria);
530
531        // Check if all required safety checks passed
532        let all_passed = safety_checks.iter().all(|check| check.passed);
533
534        if !all_passed {
535            // Safety checks failed, don't make a decision
536            return Ok(decisions);
537        }
538
539        // Calculate expected impact
540        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), // 5 minutes
551            confidence: self.config.min_confidence,
552        };
553
554        // Generate rollback plan
555        let rollback_plan = self.generate_rollback_plan(&current_model.model, &best_model.model);
556
557        // Build justification
558        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        // Create the decision
579        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        // Validate decision type
633        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        // Validate confidence
640        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        // Validate all required safety checks passed
648        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        // Validate config changes
666        if decision.config_changes.is_empty() {
667            return Err(DecisionError::ValidationFailed(
668                "No configuration changes specified".to_string(),
669            ));
670        }
671
672        // Validate that we have model change
673        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        // Validate cost increase
685        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        // Validate latency increase
697        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        // Update statistics
713        self.stats.total_decisions += 1;
714
715        if outcome.success {
716            self.stats.successful_switches += 1;
717
718            // Update model performance
719            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            // Update current model
728            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            // Record decision
735            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            // Record failed decision
753            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        // Update last evaluation time
770        self.last_evaluation = Some(Utc::now());
771
772        // Trim decision history if needed
773        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        // A dominates B (lower cost, lower latency, higher quality)
904        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        // Neither dominates (A is cheaper, B is faster and better quality)
927        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        // Should have 3 points on frontier (all except dominated)
981        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        // With default weights (0.4, 0.3, 0.3), model_c should score highest
1038        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, // 20% increase (within 50% limit)
1056            latency: 180.0,
1057            quality: 4.2,
1058        };
1059
1060        let criteria = create_test_criteria();
1061        let checks = strategy.safety_checks(&current, &target, &criteria);
1062
1063        // All checks should pass
1064        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, // 100% increase (exceeds 50% limit)
1081            latency: 180.0,
1082            quality: 4.2,
1083        };
1084
1085        let criteria = create_test_criteria();
1086        let checks = strategy.safety_checks(&current, &target, &criteria);
1087
1088        // Cost check should fail
1089        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); // Only 50 requests
1097
1098        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); // 1000 requests
1105
1106        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, // Wrong type
1189            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, // Below minimum
1225            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        // Check trigger conditions
1419        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}