Skip to main content

llm_optimizer_processor/decision/
batching.rs

1//! Request Batching Strategy
2//!
3//! This strategy analyzes request patterns and groups similar requests to improve
4//! efficiency and reduce costs. It balances latency vs cost savings by:
5//!
6//! - Detecting similar requests that can be batched together
7//! - Calculating optimal batch sizes and timeouts
8//! - Analyzing latency-cost tradeoffs
9//! - Managing priority queues for different request types
10//! - Learning from batching effectiveness over time
11//!
12//! # Example
13//!
14//! ```rust,no_run
15//! use processor::decision::batching::BatchingStrategy;
16//! use processor::decision::config::BatchingConfig;
17//!
18//! let config = BatchingConfig::default();
19//! let mut strategy = BatchingStrategy::new(config);
20//! ```
21
22use async_trait::async_trait;
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25use std::collections::{HashMap, VecDeque};
26use std::time::Duration;
27use tracing::{debug, info, warn};
28use uuid::Uuid;
29
30use super::config::BatchingConfig;
31use super::error::{DecisionError, DecisionResult};
32use super::traits::OptimizationStrategy;
33use super::types::*;
34use crate::analyzer::{Insight, InsightCategory, Recommendation, Severity};
35
36/// Request Batching Strategy
37///
38/// Groups similar requests to improve efficiency and reduce costs while
39/// balancing latency requirements.
40pub struct BatchingStrategy {
41    config: BatchingConfig,
42    stats: BatchingStats,
43    request_patterns: HashMap<String, RequestPattern>,
44    batch_history: VecDeque<BatchExecution>,
45    similarity_cache: HashMap<String, Vec<String>>,
46    learning_data: LearningData,
47}
48
49impl BatchingStrategy {
50    /// Create a new batching strategy with the given configuration
51    pub fn new(config: BatchingConfig) -> Self {
52        Self {
53            config,
54            stats: BatchingStats::default(),
55            request_patterns: HashMap::new(),
56            batch_history: VecDeque::new(),
57            similarity_cache: HashMap::new(),
58            learning_data: LearningData::default(),
59        }
60    }
61
62    /// Create with default configuration
63    pub fn with_defaults() -> Self {
64        Self::new(BatchingConfig::default())
65    }
66
67    /// Detect patterns in request data
68    fn detect_request_patterns(&mut self, input: &DecisionInput) -> Vec<RequestPattern> {
69        let mut patterns = Vec::new();
70
71        // Analyze insights for batching opportunities
72        for insight in &input.insights {
73            if let Some(pattern) = self.extract_pattern_from_insight(insight) {
74                patterns.push(pattern);
75            }
76        }
77
78        // Analyze recommendations for batching suggestions
79        for recommendation in &input.recommendations {
80            if let Some(pattern) = self.extract_pattern_from_recommendation(recommendation) {
81                patterns.push(pattern);
82            }
83        }
84
85        // Analyze current metrics for request frequency
86        if input.current_metrics.throughput_rps > 0.0 {
87            let pattern = self.infer_pattern_from_metrics(&input.current_metrics);
88            patterns.push(pattern);
89        }
90
91        patterns
92    }
93
94    /// Extract batching pattern from insight
95    fn extract_pattern_from_insight(&self, insight: &Insight) -> Option<RequestPattern> {
96        // Look for insights about similar requests or patterns
97        if insight.title.to_lowercase().contains("similar")
98            || insight.title.to_lowercase().contains("pattern")
99            || insight.title.to_lowercase().contains("batch")
100        {
101            Some(RequestPattern {
102                pattern_id: format!("pattern-{}", Uuid::new_v4()),
103                request_type: extract_request_type(&insight.title),
104                frequency_per_minute: 10.0, // Default estimate
105                avg_processing_time_ms: 100.0,
106                similarity_score: insight.confidence.value(),
107                last_seen: Utc::now(),
108            })
109        } else {
110            None
111        }
112    }
113
114    /// Extract batching pattern from recommendation
115    fn extract_pattern_from_recommendation(&self, rec: &Recommendation) -> Option<RequestPattern> {
116        // Look for recommendations about batching
117        if rec.action.action_type.to_lowercase().contains("batch")
118            || rec.title.to_lowercase().contains("group")
119            || rec.title.to_lowercase().contains("combine")
120        {
121            Some(RequestPattern {
122                pattern_id: format!("pattern-{}", Uuid::new_v4()),
123                request_type: extract_request_type(&rec.title),
124                frequency_per_minute: 15.0, // Default estimate
125                avg_processing_time_ms: 120.0,
126                similarity_score: rec.confidence.value(),
127                last_seen: Utc::now(),
128            })
129        } else {
130            None
131        }
132    }
133
134    /// Infer pattern from system metrics
135    fn infer_pattern_from_metrics(&self, metrics: &SystemMetrics) -> RequestPattern {
136        RequestPattern {
137            pattern_id: format!("pattern-metrics-{}", Uuid::new_v4()),
138            request_type: "general".to_string(),
139            frequency_per_minute: metrics.throughput_rps * 60.0,
140            avg_processing_time_ms: metrics.avg_latency_ms,
141            similarity_score: 0.7, // Default moderate similarity
142            last_seen: Utc::now(),
143        }
144    }
145
146    /// Calculate optimal batch size for a pattern
147    fn calculate_optimal_batch_size(
148        &self,
149        pattern: &RequestPattern,
150        criteria: &DecisionCriteria,
151    ) -> OptimalBatchSize {
152        // Start with configured maximum
153        let max_batch_size = self.config.max_batch_size;
154
155        // Calculate based on frequency
156        let frequency_based_size = (pattern.frequency_per_minute / 60.0
157            * self.config.batch_timeout.as_millis() as f64
158            / 1000.0)
159            .ceil() as usize;
160
161        // Constrain by maximum allowed latency increase
162        let max_latency_ms = criteria
163            .max_latency_increase_ms
164            .unwrap_or(self.config.max_latency_increase_ms);
165
166        let latency_constrained_size = if pattern.avg_processing_time_ms > 0.0 {
167            (max_latency_ms / pattern.avg_processing_time_ms).floor() as usize
168        } else {
169            max_batch_size
170        };
171
172        // Take minimum of all constraints
173        let optimal_size = frequency_based_size
174            .min(latency_constrained_size)
175            .min(max_batch_size)
176            .max(self.config.min_requests_for_batching);
177
178        // Calculate cost savings estimate
179        let cost_savings_pct = self.estimate_cost_savings(optimal_size);
180
181        // Calculate latency increase estimate
182        let latency_increase_ms = self.estimate_latency_increase(optimal_size, pattern);
183
184        OptimalBatchSize {
185            batch_size: optimal_size,
186            expected_cost_savings_pct: cost_savings_pct,
187            expected_latency_increase_ms: latency_increase_ms,
188            confidence: self.calculate_batch_confidence(pattern, optimal_size),
189        }
190    }
191
192    /// Estimate cost savings for a batch size
193    fn estimate_cost_savings(&self, batch_size: usize) -> f64 {
194        // Cost savings increase with batch size but with diminishing returns
195        // Formula: savings = base_rate * log(batch_size + 1) / log(max_size + 1)
196        let base_savings = 30.0; // 30% max savings
197        let normalized = (batch_size as f64 + 1.0).ln()
198            / (self.config.max_batch_size as f64 + 1.0).ln();
199
200        base_savings * normalized
201    }
202
203    /// Estimate latency increase for a batch size and pattern
204    fn estimate_latency_increase(&self, batch_size: usize, pattern: &RequestPattern) -> f64 {
205        // Latency increases linearly with wait time
206        let max_wait_time_ms = self.config.batch_timeout.as_millis() as f64;
207
208        // Average wait time is half the timeout for a full batch
209        let fill_ratio = (pattern.frequency_per_minute / 60.0)
210            * (max_wait_time_ms / 1000.0)
211            / batch_size as f64;
212
213        if fill_ratio >= 1.0 {
214            // Batch fills quickly, minimal wait
215            max_wait_time_ms * 0.3
216        } else {
217            // Batch takes longer to fill
218            max_wait_time_ms * (1.0 - fill_ratio * 0.7)
219        }
220    }
221
222    /// Calculate confidence in batch configuration
223    fn calculate_batch_confidence(&self, pattern: &RequestPattern, batch_size: usize) -> f64 {
224        let mut confidence = pattern.similarity_score;
225
226        // Higher confidence if we have historical data
227        if let Some(history) = self.learning_data.pattern_performance.get(&pattern.pattern_id) {
228            confidence = confidence * 0.5 + history.success_rate * 0.5;
229        }
230
231        // Lower confidence for very large batches without proven history
232        if batch_size > 5 && !self.learning_data.pattern_performance.contains_key(&pattern.pattern_id) {
233            confidence *= 0.8;
234        }
235
236        // Higher confidence for frequently seen patterns
237        if pattern.frequency_per_minute > 20.0 {
238            confidence = (confidence * 1.1).min(1.0);
239        }
240
241        confidence
242    }
243
244    /// Analyze latency-cost tradeoffs
245    fn analyze_tradeoffs(
246        &self,
247        optimal: &OptimalBatchSize,
248        criteria: &DecisionCriteria,
249    ) -> TradeoffAnalysis {
250        let cost_benefit = optimal.expected_cost_savings_pct / 100.0;
251        let latency_penalty = optimal.expected_latency_increase_ms
252            / criteria
253                .max_latency_increase_ms
254                .unwrap_or(self.config.max_latency_increase_ms);
255
256        // Calculate tradeoff score (higher is better)
257        // Weighted by priority: cost savings vs latency impact
258        let cost_weight = 0.6;
259        let latency_weight = 0.4;
260
261        let tradeoff_score = (cost_benefit * cost_weight) - (latency_penalty * latency_weight);
262
263        let is_acceptable = tradeoff_score > 0.2 // Minimum threshold
264            && optimal.expected_latency_increase_ms <= criteria
265                .max_latency_increase_ms
266                .unwrap_or(self.config.max_latency_increase_ms)
267            && optimal.expected_cost_savings_pct >= self.config.min_cost_savings_pct;
268
269        TradeoffAnalysis {
270            tradeoff_score,
271            cost_benefit,
272            latency_penalty,
273            is_acceptable,
274            reasoning: self.generate_tradeoff_reasoning(
275                optimal,
276                tradeoff_score,
277                is_acceptable,
278            ),
279        }
280    }
281
282    /// Generate human-readable reasoning for tradeoff
283    fn generate_tradeoff_reasoning(
284        &self,
285        optimal: &OptimalBatchSize,
286        score: f64,
287        acceptable: bool,
288    ) -> String {
289        if acceptable {
290            format!(
291                "Batching is beneficial: {:.1}% cost savings with only {:.0}ms latency increase. \
292                Tradeoff score: {:.2}",
293                optimal.expected_cost_savings_pct, optimal.expected_latency_increase_ms, score
294            )
295        } else if optimal.expected_latency_increase_ms > self.config.max_latency_increase_ms {
296            format!(
297                "Latency increase ({:.0}ms) exceeds maximum allowed ({:.0}ms)",
298                optimal.expected_latency_increase_ms, self.config.max_latency_increase_ms
299            )
300        } else if optimal.expected_cost_savings_pct < self.config.min_cost_savings_pct {
301            format!(
302                "Cost savings ({:.1}%) below minimum threshold ({:.1}%)",
303                optimal.expected_cost_savings_pct, self.config.min_cost_savings_pct
304            )
305        } else {
306            format!(
307                "Tradeoff score ({:.2}) below acceptable threshold (0.2)",
308                score
309            )
310        }
311    }
312
313    /// Perform safety checks for batching decision
314    fn perform_safety_checks(
315        &self,
316        optimal: &OptimalBatchSize,
317        criteria: &DecisionCriteria,
318    ) -> Vec<SafetyCheck> {
319        let mut checks = Vec::new();
320
321        // Check 1: Latency increase within limits
322        let latency_ok = optimal.expected_latency_increase_ms
323            <= criteria
324                .max_latency_increase_ms
325                .unwrap_or(self.config.max_latency_increase_ms);
326
327        checks.push(SafetyCheck {
328            name: "latency_check".to_string(),
329            passed: latency_ok,
330            details: format!(
331                "Expected latency increase: {:.0}ms (max: {:.0}ms)",
332                optimal.expected_latency_increase_ms,
333                criteria
334                    .max_latency_increase_ms
335                    .unwrap_or(self.config.max_latency_increase_ms)
336            ),
337            timestamp: Utc::now(),
338        });
339
340        // Check 2: Cost savings meet minimum threshold
341        let cost_ok = optimal.expected_cost_savings_pct >= self.config.min_cost_savings_pct;
342
343        checks.push(SafetyCheck {
344            name: "cost_check".to_string(),
345            passed: cost_ok,
346            details: format!(
347                "Expected cost savings: {:.1}% (min: {:.1}%)",
348                optimal.expected_cost_savings_pct, self.config.min_cost_savings_pct
349            ),
350            timestamp: Utc::now(),
351        });
352
353        // Check 3: Confidence meets minimum threshold
354        let confidence_ok = optimal.confidence >= criteria.min_confidence;
355
356        checks.push(SafetyCheck {
357            name: "confidence_check".to_string(),
358            passed: confidence_ok,
359            details: format!(
360                "Configuration confidence: {:.2} (min: {:.2})",
361                optimal.confidence, criteria.min_confidence
362            ),
363            timestamp: Utc::now(),
364        });
365
366        // Check 4: Quality should not be significantly impacted
367        // Batching typically doesn't affect quality, but check history
368        let quality_ok = if let Some(avg_history) = self.get_average_quality_impact() {
369            avg_history > -0.1 // Allow up to 0.1 point quality drop
370        } else {
371            true // No history, assume OK
372        };
373
374        checks.push(SafetyCheck {
375            name: "quality_check".to_string(),
376            passed: quality_ok,
377            details: if let Some(impact) = self.get_average_quality_impact() {
378                format!("Historical quality impact: {:.2}", impact)
379            } else {
380                "No historical quality data".to_string()
381            },
382            timestamp: Utc::now(),
383        });
384
385        checks
386    }
387
388    /// Get average quality impact from historical data
389    fn get_average_quality_impact(&self) -> Option<f64> {
390        if self.batch_history.is_empty() {
391            return None;
392        }
393
394        let mut total_impact = 0.0;
395        let mut count = 0;
396
397        for execution in &self.batch_history {
398            if let Some(quality_impact) = execution.quality_impact {
399                total_impact += quality_impact;
400                count += 1;
401            }
402        }
403
404        if count > 0 {
405            Some(total_impact / count as f64)
406        } else {
407            None
408        }
409    }
410
411    /// Generate batching configuration decision
412    fn generate_decision(
413        &self,
414        pattern: &RequestPattern,
415        optimal: &OptimalBatchSize,
416        tradeoff: &TradeoffAnalysis,
417        safety_checks: Vec<SafetyCheck>,
418    ) -> Decision {
419        let decision_id = Uuid::new_v4().to_string();
420
421        let config_changes = vec![
422            ConfigChange {
423                config_type: ConfigType::Batching,
424                path: "batching.enabled".to_string(),
425                old_value: Some(serde_json::json!(false)),
426                new_value: serde_json::json!(true),
427                description: "Enable request batching".to_string(),
428            },
429            ConfigChange {
430                config_type: ConfigType::Batching,
431                path: "batching.batch_size".to_string(),
432                old_value: None,
433                new_value: serde_json::json!(optimal.batch_size),
434                description: format!("Set batch size to {}", optimal.batch_size),
435            },
436            ConfigChange {
437                config_type: ConfigType::Batching,
438                path: "batching.timeout_ms".to_string(),
439                old_value: None,
440                new_value: serde_json::json!(self.config.batch_timeout.as_millis()),
441                description: format!(
442                    "Set batch timeout to {}ms",
443                    self.config.batch_timeout.as_millis()
444                ),
445            },
446            ConfigChange {
447                config_type: ConfigType::Batching,
448                path: "batching.request_type".to_string(),
449                old_value: None,
450                new_value: serde_json::json!(pattern.request_type),
451                description: format!("Apply batching to '{}' requests", pattern.request_type),
452            },
453        ];
454
455        let rollback_plan = RollbackPlan {
456            description: "Disable batching if metrics degrade".to_string(),
457            revert_changes: vec![ConfigChange {
458                config_type: ConfigType::Batching,
459                path: "batching.enabled".to_string(),
460                old_value: Some(serde_json::json!(true)),
461                new_value: serde_json::json!(false),
462                description: "Disable batching".to_string(),
463            }],
464            trigger_conditions: vec![
465                RollbackCondition {
466                    metric: "avg_latency_ms".to_string(),
467                    operator: ComparisonOperator::GreaterThan,
468                    threshold: optimal.expected_latency_increase_ms * 1.5,
469                    description: "Latency increased more than expected".to_string(),
470                },
471                RollbackCondition {
472                    metric: "error_rate_pct".to_string(),
473                    operator: ComparisonOperator::GreaterThan,
474                    threshold: 2.0,
475                    description: "Error rate increased significantly".to_string(),
476                },
477            ],
478            max_duration: Duration::from_secs(1800), // 30 minutes
479        };
480
481        Decision {
482            id: decision_id,
483            timestamp: Utc::now(),
484            strategy: "batching".to_string(),
485            decision_type: DecisionType::EnableBatching,
486            confidence: optimal.confidence,
487            expected_impact: ExpectedImpact {
488                cost_change_usd: Some(
489                    -(optimal.expected_cost_savings_pct / 100.0) * 10.0, // Negative = savings
490                ),
491                latency_change_ms: Some(optimal.expected_latency_increase_ms),
492                quality_change: None, // Batching typically doesn't affect quality
493                throughput_change_rps: None, // May improve slightly but not primary benefit
494                success_rate_change_pct: None,
495                time_to_impact: Duration::from_secs(300), // 5 minutes
496                confidence: optimal.confidence,
497            },
498            config_changes,
499            justification: format!(
500                "Enable batching for '{}' requests with batch size {} and {}ms timeout. {}",
501                pattern.request_type, optimal.batch_size,
502                self.config.batch_timeout.as_millis(),
503                tradeoff.reasoning
504            ),
505            related_insights: vec![],
506            related_recommendations: vec![],
507            priority: self.calculate_decision_priority(optimal, tradeoff),
508            requires_approval: !tradeoff.is_acceptable || optimal.expected_latency_increase_ms > 100.0,
509            safety_checks,
510            rollback_plan: Some(rollback_plan),
511            metadata: {
512                let mut meta = HashMap::new();
513                meta.insert(
514                    "pattern_id".to_string(),
515                    serde_json::json!(pattern.pattern_id),
516                );
517                meta.insert(
518                    "request_type".to_string(),
519                    serde_json::json!(pattern.request_type),
520                );
521                meta.insert(
522                    "batch_size".to_string(),
523                    serde_json::json!(optimal.batch_size),
524                );
525                meta.insert(
526                    "tradeoff_score".to_string(),
527                    serde_json::json!(tradeoff.tradeoff_score),
528                );
529                meta
530            },
531        }
532    }
533
534    /// Calculate decision priority based on impact
535    fn calculate_decision_priority(&self, optimal: &OptimalBatchSize, tradeoff: &TradeoffAnalysis) -> u32 {
536        let mut priority = self.config.priority;
537
538        // Boost priority for high cost savings
539        if optimal.expected_cost_savings_pct > 25.0 {
540            priority += 10;
541        } else if optimal.expected_cost_savings_pct > 15.0 {
542            priority += 5;
543        }
544
545        // Reduce priority for high latency impact
546        if optimal.expected_latency_increase_ms > 150.0 {
547            priority = priority.saturating_sub(10);
548        } else if optimal.expected_latency_increase_ms > 100.0 {
549            priority = priority.saturating_sub(5);
550        }
551
552        // Boost priority for high confidence
553        if optimal.confidence > 0.85 {
554            priority += 5;
555        }
556
557        // Boost priority for good tradeoff score
558        if tradeoff.tradeoff_score > 0.5 {
559            priority += 5;
560        }
561
562        priority
563    }
564
565    /// Update learning data from decision outcome
566    fn update_learning(&mut self, decision: &Decision, outcome: &DecisionOutcome) {
567        self.stats.total_batching_decisions += 1;
568
569        if outcome.success {
570            self.stats.successful_batching_decisions += 1;
571
572            // Extract pattern info from metadata
573            if let Some(pattern_id) = decision.metadata.get("pattern_id") {
574                if let Some(pattern_id_str) = pattern_id.as_str() {
575                    let performance = self
576                        .learning_data
577                        .pattern_performance
578                        .entry(pattern_id_str.to_string())
579                        .or_insert_with(PatternPerformance::default);
580
581                    performance.total_executions += 1;
582                    performance.successful_executions += 1;
583                    performance.success_rate =
584                        performance.successful_executions as f64 / performance.total_executions as f64;
585
586                    // Update with actual impact
587                    if let Some(actual_impact) = &outcome.actual_impact {
588                        performance.total_cost_savings += actual_impact.cost_change_usd.abs();
589                        performance.total_latency_increase += actual_impact.latency_change_ms;
590
591                        // Update averages
592                        performance.avg_cost_savings = performance.total_cost_savings
593                            / performance.successful_executions as f64;
594                        performance.avg_latency_increase = performance.total_latency_increase
595                            / performance.successful_executions as f64;
596
597                        // Track variance from expected
598                        let variance = (actual_impact.cost_change_usd.abs()
599                            - decision
600                                .expected_impact
601                                .cost_change_usd
602                                .unwrap_or(0.0)
603                                .abs())
604                        .abs();
605                        performance.prediction_accuracy =
606                            1.0 - (variance / decision.expected_impact.cost_change_usd.unwrap_or(1.0).abs()).min(1.0);
607                    }
608                }
609            }
610        } else {
611            self.stats.failed_batching_decisions += 1;
612
613            // Update failure tracking for pattern
614            if let Some(pattern_id) = decision.metadata.get("pattern_id") {
615                if let Some(pattern_id_str) = pattern_id.as_str() {
616                    let performance = self
617                        .learning_data
618                        .pattern_performance
619                        .entry(pattern_id_str.to_string())
620                        .or_insert_with(PatternPerformance::default);
621
622                    performance.total_executions += 1;
623                    performance.success_rate =
624                        performance.successful_executions as f64 / performance.total_executions as f64;
625                }
626            }
627        }
628
629        // Record execution
630        if let Some(actual_impact) = &outcome.actual_impact {
631            self.batch_history.push_back(BatchExecution {
632                timestamp: outcome.execution_time,
633                batch_size: decision
634                    .metadata
635                    .get("batch_size")
636                    .and_then(|v| v.as_u64())
637                    .unwrap_or(1) as usize,
638                cost_savings: actual_impact.cost_change_usd.abs(),
639                latency_increase: actual_impact.latency_change_ms,
640                quality_impact: Some(actual_impact.quality_change),
641                success: outcome.success,
642            });
643
644            // Keep history bounded
645            if self.batch_history.len() > 100 {
646                self.batch_history.pop_front();
647            }
648        }
649
650        info!(
651            "Updated batching learning data: {} total decisions, {:.1}% success rate",
652            self.stats.total_batching_decisions,
653            (self.stats.successful_batching_decisions as f64 / self.stats.total_batching_decisions as f64) * 100.0
654        );
655    }
656}
657
658#[async_trait]
659impl OptimizationStrategy for BatchingStrategy {
660    fn name(&self) -> &str {
661        "batching"
662    }
663
664    fn priority(&self) -> u32 {
665        self.config.priority
666    }
667
668    fn is_applicable(&self, input: &DecisionInput) -> bool {
669        // Batching is applicable if:
670        // 1. We have sufficient request volume
671        let has_volume = input.current_metrics.throughput_rps > 1.0;
672
673        // 2. We have insights or recommendations suggesting batching
674        let has_batching_insights = input.insights.iter().any(|i| {
675            i.title.to_lowercase().contains("batch")
676                || i.title.to_lowercase().contains("similar")
677                || i.title.to_lowercase().contains("group")
678        });
679
680        let has_batching_recommendations = input.recommendations.iter().any(|r| {
681            r.action.action_type.to_lowercase().contains("batch")
682                || r.title.to_lowercase().contains("batch")
683        });
684
685        // 3. Current metrics suggest opportunity for optimization
686        let has_cost_pressure = input.current_metrics.total_cost_usd > 10.0
687            || input.current_metrics.daily_cost_usd > 1.0;
688
689        has_volume && (has_batching_insights || has_batching_recommendations || has_cost_pressure)
690    }
691
692    async fn evaluate(
693        &self,
694        input: &DecisionInput,
695        criteria: &DecisionCriteria,
696    ) -> DecisionResult<Vec<Decision>> {
697        debug!("Evaluating batching strategy");
698
699        // Detect request patterns
700        let patterns = {
701            let mut strategy = self.clone();
702            strategy.detect_request_patterns(input)
703        };
704
705        if patterns.is_empty() {
706            debug!("No batching patterns detected");
707            return Ok(vec![]);
708        }
709
710        let mut decisions = Vec::new();
711
712        for pattern in patterns {
713            // Calculate optimal batch size
714            let optimal = self.calculate_optimal_batch_size(&pattern, criteria);
715
716            // Analyze tradeoffs
717            let tradeoff = self.analyze_tradeoffs(&optimal, criteria);
718
719            // Skip if tradeoff is not acceptable
720            if !tradeoff.is_acceptable {
721                debug!(
722                    "Skipping pattern {} due to unacceptable tradeoff: {}",
723                    pattern.pattern_id, tradeoff.reasoning
724                );
725                continue;
726            }
727
728            // Perform safety checks
729            let safety_checks = self.perform_safety_checks(&optimal, criteria);
730
731            // Verify all required safety checks passed
732            let all_checks_passed = safety_checks.iter().all(|check| check.passed);
733
734            if !all_checks_passed {
735                debug!(
736                    "Skipping pattern {} due to failed safety checks",
737                    pattern.pattern_id
738                );
739                continue;
740            }
741
742            // Generate decision
743            let decision = self.generate_decision(&pattern, &optimal, &tradeoff, safety_checks);
744
745            info!(
746                "Generated batching decision for pattern '{}': batch_size={}, cost_savings={:.1}%, latency_increase={:.0}ms",
747                pattern.request_type,
748                optimal.batch_size,
749                optimal.expected_cost_savings_pct,
750                optimal.expected_latency_increase_ms
751            );
752
753            decisions.push(decision);
754        }
755
756        Ok(decisions)
757    }
758
759    async fn validate(
760        &self,
761        decision: &Decision,
762        criteria: &DecisionCriteria,
763    ) -> DecisionResult<()> {
764        // Verify this is a batching decision
765        if decision.decision_type != DecisionType::EnableBatching {
766            return Err(DecisionError::ValidationFailed(
767                "Not a batching decision".to_string(),
768            ));
769        }
770
771        // Verify confidence meets threshold
772        if decision.confidence < criteria.min_confidence {
773            return Err(DecisionError::ValidationFailed(format!(
774                "Confidence {:.2} below minimum {:.2}",
775                decision.confidence, criteria.min_confidence
776            )));
777        }
778
779        // Verify latency increase is acceptable
780        if let Some(latency_change) = decision.expected_impact.latency_change_ms {
781            let max_latency = criteria
782                .max_latency_increase_ms
783                .unwrap_or(self.config.max_latency_increase_ms);
784
785            if latency_change > max_latency {
786                return Err(DecisionError::ConstraintViolation(format!(
787                    "Latency increase {:.0}ms exceeds maximum {:.0}ms",
788                    latency_change, max_latency
789                )));
790            }
791        }
792
793        // Verify all required safety checks passed
794        for required_check in &criteria.required_safety_checks {
795            let check_passed = decision
796                .safety_checks
797                .iter()
798                .any(|check| check.name == *required_check && check.passed);
799
800            if !check_passed {
801                return Err(DecisionError::ValidationFailed(format!(
802                    "Required safety check '{}' did not pass",
803                    required_check
804                )));
805            }
806        }
807
808        Ok(())
809    }
810
811    async fn learn(&mut self, decision: &Decision, outcome: &DecisionOutcome) -> DecisionResult<()> {
812        debug!(
813            "Learning from batching decision outcome: success={}",
814            outcome.success
815        );
816
817        self.update_learning(decision, outcome);
818
819        Ok(())
820    }
821
822    fn get_stats(&self) -> serde_json::Value {
823        serde_json::json!({
824            "total_decisions": self.stats.total_batching_decisions,
825            "successful_decisions": self.stats.successful_batching_decisions,
826            "failed_decisions": self.stats.failed_batching_decisions,
827            "success_rate": if self.stats.total_batching_decisions > 0 {
828                (self.stats.successful_batching_decisions as f64 / self.stats.total_batching_decisions as f64) * 100.0
829            } else {
830                0.0
831            },
832            "patterns_tracked": self.request_patterns.len(),
833            "batch_history_size": self.batch_history.len(),
834            "pattern_performance": self.learning_data.pattern_performance.iter().map(|(k, v)| {
835                (k.clone(), serde_json::json!({
836                    "executions": v.total_executions,
837                    "success_rate": v.success_rate,
838                    "avg_cost_savings": v.avg_cost_savings,
839                    "avg_latency_increase": v.avg_latency_increase,
840                    "prediction_accuracy": v.prediction_accuracy,
841                }))
842            }).collect::<HashMap<_, _>>(),
843        })
844    }
845}
846
847// Make BatchingStrategy cloneable for internal use
848impl Clone for BatchingStrategy {
849    fn clone(&self) -> Self {
850        Self {
851            config: self.config.clone(),
852            stats: self.stats.clone(),
853            request_patterns: self.request_patterns.clone(),
854            batch_history: self.batch_history.clone(),
855            similarity_cache: self.similarity_cache.clone(),
856            learning_data: self.learning_data.clone(),
857        }
858    }
859}
860
861/// Request pattern for batching
862#[derive(Debug, Clone, Serialize, Deserialize)]
863struct RequestPattern {
864    pattern_id: String,
865    request_type: String,
866    frequency_per_minute: f64,
867    avg_processing_time_ms: f64,
868    similarity_score: f64,
869    last_seen: DateTime<Utc>,
870}
871
872/// Optimal batch size calculation result
873#[derive(Debug, Clone, Serialize, Deserialize)]
874struct OptimalBatchSize {
875    batch_size: usize,
876    expected_cost_savings_pct: f64,
877    expected_latency_increase_ms: f64,
878    confidence: f64,
879}
880
881/// Tradeoff analysis result
882#[derive(Debug, Clone, Serialize, Deserialize)]
883struct TradeoffAnalysis {
884    tradeoff_score: f64,
885    cost_benefit: f64,
886    latency_penalty: f64,
887    is_acceptable: bool,
888    reasoning: String,
889}
890
891/// Statistics for batching strategy
892#[derive(Debug, Clone, Default, Serialize, Deserialize)]
893struct BatchingStats {
894    total_batching_decisions: u64,
895    successful_batching_decisions: u64,
896    failed_batching_decisions: u64,
897}
898
899/// Historical batch execution
900#[derive(Debug, Clone, Serialize, Deserialize)]
901struct BatchExecution {
902    timestamp: DateTime<Utc>,
903    batch_size: usize,
904    cost_savings: f64,
905    latency_increase: f64,
906    quality_impact: Option<f64>,
907    success: bool,
908}
909
910/// Learning data for pattern optimization
911#[derive(Debug, Clone, Default, Serialize, Deserialize)]
912struct LearningData {
913    pattern_performance: HashMap<String, PatternPerformance>,
914}
915
916/// Performance tracking for a specific pattern
917#[derive(Debug, Clone, Default, Serialize, Deserialize)]
918struct PatternPerformance {
919    total_executions: u64,
920    successful_executions: u64,
921    success_rate: f64,
922    total_cost_savings: f64,
923    total_latency_increase: f64,
924    avg_cost_savings: f64,
925    avg_latency_increase: f64,
926    prediction_accuracy: f64,
927}
928
929/// Extract request type from text
930fn extract_request_type(text: &str) -> String {
931    let text_lower = text.to_lowercase();
932
933    if text_lower.contains("query") || text_lower.contains("search") {
934        "query".to_string()
935    } else if text_lower.contains("completion") || text_lower.contains("generate") {
936        "completion".to_string()
937    } else if text_lower.contains("summary") || text_lower.contains("summarize") {
938        "summary".to_string()
939    } else if text_lower.contains("translation") || text_lower.contains("translate") {
940        "translation".to_string()
941    } else if text_lower.contains("classification") || text_lower.contains("classify") {
942        "classification".to_string()
943    } else {
944        "general".to_string()
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951    use crate::analyzer::{Confidence, Insight, InsightCategory, Recommendation, Severity};
952    use crate::decision::types::*;
953
954    fn create_test_input() -> DecisionInput {
955        DecisionInput {
956            timestamp: Utc::now(),
957            analysis_reports: vec![],
958            insights: vec![create_test_insight()],
959            recommendations: vec![create_test_recommendation()],
960            current_metrics: create_test_metrics(),
961            context: HashMap::new(),
962        }
963    }
964
965    fn create_test_insight() -> Insight {
966        Insight {
967            id: "insight-1".to_string(),
968            analyzer: "pattern".to_string(),
969            timestamp: Utc::now(),
970            severity: Severity::Info,
971            confidence: Confidence::new(0.85),
972            title: "Similar query requests detected".to_string(),
973            description: "Multiple similar queries could be batched".to_string(),
974            category: InsightCategory::Performance,
975            impact: None,
976            evidence: vec![],
977            metadata: HashMap::new(),
978        }
979    }
980
981    fn create_test_recommendation() -> Recommendation {
982        Recommendation {
983            id: "rec-1".to_string(),
984            analyzer: "cost".to_string(),
985            timestamp: Utc::now(),
986            priority: crate::analyzer::Priority::High,
987            confidence: Confidence::new(0.8),
988            title: "Enable request batching".to_string(),
989            description: "Batch similar requests to reduce costs".to_string(),
990            action: crate::analyzer::Action {
991                action_type: "enable_batching".to_string(),
992                parameters: HashMap::new(),
993                estimated_effort: None,
994            },
995            expected_impact: None,
996            risk_level: crate::analyzer::RiskLevel::Low,
997            metadata: HashMap::new(),
998        }
999    }
1000
1001    fn create_test_metrics() -> SystemMetrics {
1002        SystemMetrics {
1003            avg_latency_ms: 150.0,
1004            p95_latency_ms: 300.0,
1005            p99_latency_ms: 500.0,
1006            success_rate_pct: 99.0,
1007            error_rate_pct: 1.0,
1008            throughput_rps: 10.0,
1009            total_cost_usd: 50.0,
1010            daily_cost_usd: 5.0,
1011            avg_rating: Some(4.5),
1012            cache_hit_rate_pct: Some(30.0),
1013            request_count: 1000,
1014            timestamp: Utc::now(),
1015        }
1016    }
1017
1018    #[test]
1019    fn test_strategy_creation() {
1020        let config = BatchingConfig::default();
1021        let strategy = BatchingStrategy::new(config);
1022
1023        assert_eq!(strategy.name(), "batching");
1024        assert_eq!(strategy.priority(), 70);
1025    }
1026
1027    #[test]
1028    fn test_is_applicable_with_volume() {
1029        let strategy = BatchingStrategy::with_defaults();
1030        let input = create_test_input();
1031
1032        assert!(strategy.is_applicable(&input));
1033    }
1034
1035    #[test]
1036    fn test_is_not_applicable_without_volume() {
1037        let strategy = BatchingStrategy::with_defaults();
1038        let mut input = create_test_input();
1039        input.current_metrics.throughput_rps = 0.1; // Very low
1040        input.insights.clear(); // No insights
1041        input.recommendations.clear(); // No recommendations
1042        input.current_metrics.total_cost_usd = 0.5; // Low cost
1043
1044        assert!(!strategy.is_applicable(&input));
1045    }
1046
1047    #[test]
1048    fn test_detect_request_patterns() {
1049        let mut strategy = BatchingStrategy::with_defaults();
1050        let input = create_test_input();
1051
1052        let patterns = strategy.detect_request_patterns(&input);
1053
1054        assert!(!patterns.is_empty());
1055        assert!(patterns.iter().any(|p| p.request_type == "query"));
1056    }
1057
1058    #[test]
1059    fn test_calculate_optimal_batch_size() {
1060        let strategy = BatchingStrategy::with_defaults();
1061        let pattern = RequestPattern {
1062            pattern_id: "test-pattern".to_string(),
1063            request_type: "query".to_string(),
1064            frequency_per_minute: 60.0,
1065            avg_processing_time_ms: 100.0,
1066            similarity_score: 0.85,
1067            last_seen: Utc::now(),
1068        };
1069        let criteria = DecisionCriteria::default();
1070
1071        let optimal = strategy.calculate_optimal_batch_size(&pattern, &criteria);
1072
1073        assert!(optimal.batch_size >= 1);
1074        assert!(optimal.batch_size <= strategy.config.max_batch_size);
1075        assert!(optimal.expected_cost_savings_pct > 0.0);
1076        assert!(optimal.confidence > 0.0 && optimal.confidence <= 1.0);
1077    }
1078
1079    #[test]
1080    fn test_estimate_cost_savings() {
1081        let strategy = BatchingStrategy::with_defaults();
1082
1083        let savings_2 = strategy.estimate_cost_savings(2);
1084        let savings_5 = strategy.estimate_cost_savings(5);
1085        let savings_10 = strategy.estimate_cost_savings(10);
1086
1087        // Savings should increase with batch size
1088        assert!(savings_2 < savings_5);
1089        assert!(savings_5 < savings_10);
1090
1091        // But with diminishing returns
1092        let increase_2_to_5 = savings_5 - savings_2;
1093        let increase_5_to_10 = savings_10 - savings_5;
1094        assert!(increase_5_to_10 < increase_2_to_5);
1095    }
1096
1097    #[test]
1098    fn test_estimate_latency_increase() {
1099        let strategy = BatchingStrategy::with_defaults();
1100        let pattern = RequestPattern {
1101            pattern_id: "test".to_string(),
1102            request_type: "query".to_string(),
1103            frequency_per_minute: 60.0,
1104            avg_processing_time_ms: 100.0,
1105            similarity_score: 0.85,
1106            last_seen: Utc::now(),
1107        };
1108
1109        let latency = strategy.estimate_latency_increase(5, &pattern);
1110
1111        assert!(latency >= 0.0);
1112        assert!(latency <= strategy.config.batch_timeout.as_millis() as f64);
1113    }
1114
1115    #[test]
1116    fn test_analyze_tradeoffs_acceptable() {
1117        let strategy = BatchingStrategy::with_defaults();
1118        let optimal = OptimalBatchSize {
1119            batch_size: 5,
1120            expected_cost_savings_pct: 25.0,
1121            expected_latency_increase_ms: 50.0,
1122            confidence: 0.85,
1123        };
1124        let criteria = DecisionCriteria::default();
1125
1126        let tradeoff = strategy.analyze_tradeoffs(&optimal, &criteria);
1127
1128        assert!(tradeoff.is_acceptable);
1129        assert!(tradeoff.tradeoff_score > 0.0);
1130    }
1131
1132    #[test]
1133    fn test_analyze_tradeoffs_unacceptable_latency() {
1134        let strategy = BatchingStrategy::with_defaults();
1135        let optimal = OptimalBatchSize {
1136            batch_size: 10,
1137            expected_cost_savings_pct: 20.0,
1138            expected_latency_increase_ms: 600.0, // Too high
1139            confidence: 0.85,
1140        };
1141        let criteria = DecisionCriteria::default();
1142
1143        let tradeoff = strategy.analyze_tradeoffs(&optimal, &criteria);
1144
1145        assert!(!tradeoff.is_acceptable);
1146    }
1147
1148    #[test]
1149    fn test_perform_safety_checks() {
1150        let strategy = BatchingStrategy::with_defaults();
1151        let optimal = OptimalBatchSize {
1152            batch_size: 5,
1153            expected_cost_savings_pct: 20.0,
1154            expected_latency_increase_ms: 100.0,
1155            confidence: 0.85,
1156        };
1157        let criteria = DecisionCriteria::default();
1158
1159        let checks = strategy.perform_safety_checks(&optimal, &criteria);
1160
1161        assert!(!checks.is_empty());
1162        assert!(checks.iter().any(|c| c.name == "latency_check"));
1163        assert!(checks.iter().any(|c| c.name == "cost_check"));
1164        assert!(checks.iter().any(|c| c.name == "confidence_check"));
1165    }
1166
1167    #[tokio::test]
1168    async fn test_evaluate_generates_decisions() {
1169        let strategy = BatchingStrategy::with_defaults();
1170        let input = create_test_input();
1171        let criteria = DecisionCriteria::default();
1172
1173        let decisions = strategy.evaluate(&input, &criteria).await.unwrap();
1174
1175        // Should generate at least one decision given our test input
1176        assert!(!decisions.is_empty());
1177
1178        let decision = &decisions[0];
1179        assert_eq!(decision.decision_type, DecisionType::EnableBatching);
1180        assert_eq!(decision.strategy, "batching");
1181        assert!(!decision.config_changes.is_empty());
1182    }
1183
1184    #[tokio::test]
1185    async fn test_validate_valid_decision() {
1186        let strategy = BatchingStrategy::with_defaults();
1187        let criteria = DecisionCriteria::default();
1188
1189        let decision = Decision {
1190            id: Uuid::new_v4().to_string(),
1191            timestamp: Utc::now(),
1192            strategy: "batching".to_string(),
1193            decision_type: DecisionType::EnableBatching,
1194            confidence: 0.85,
1195            expected_impact: ExpectedImpact {
1196                cost_change_usd: Some(-5.0),
1197                latency_change_ms: Some(100.0),
1198                quality_change: None,
1199                throughput_change_rps: None,
1200                success_rate_change_pct: None,
1201                time_to_impact: Duration::from_secs(300),
1202                confidence: 0.85,
1203            },
1204            config_changes: vec![],
1205            justification: "Test".to_string(),
1206            related_insights: vec![],
1207            related_recommendations: vec![],
1208            priority: 70,
1209            requires_approval: false,
1210            safety_checks: vec![
1211                SafetyCheck {
1212                    name: "cost_check".to_string(),
1213                    passed: true,
1214                    details: "OK".to_string(),
1215                    timestamp: Utc::now(),
1216                },
1217                SafetyCheck {
1218                    name: "latency_check".to_string(),
1219                    passed: true,
1220                    details: "OK".to_string(),
1221                    timestamp: Utc::now(),
1222                },
1223                SafetyCheck {
1224                    name: "quality_check".to_string(),
1225                    passed: true,
1226                    details: "OK".to_string(),
1227                    timestamp: Utc::now(),
1228                },
1229            ],
1230            rollback_plan: None,
1231            metadata: HashMap::new(),
1232        };
1233
1234        assert!(strategy.validate(&decision, &criteria).await.is_ok());
1235    }
1236
1237    #[tokio::test]
1238    async fn test_validate_wrong_decision_type() {
1239        let strategy = BatchingStrategy::with_defaults();
1240        let criteria = DecisionCriteria::default();
1241
1242        let decision = Decision {
1243            id: Uuid::new_v4().to_string(),
1244            timestamp: Utc::now(),
1245            strategy: "batching".to_string(),
1246            decision_type: DecisionType::ModelSwitch, // Wrong type
1247            confidence: 0.85,
1248            expected_impact: ExpectedImpact {
1249                cost_change_usd: Some(-5.0),
1250                latency_change_ms: Some(100.0),
1251                quality_change: None,
1252                throughput_change_rps: None,
1253                success_rate_change_pct: None,
1254                time_to_impact: Duration::from_secs(300),
1255                confidence: 0.85,
1256            },
1257            config_changes: vec![],
1258            justification: "Test".to_string(),
1259            related_insights: vec![],
1260            related_recommendations: vec![],
1261            priority: 70,
1262            requires_approval: false,
1263            safety_checks: vec![],
1264            rollback_plan: None,
1265            metadata: HashMap::new(),
1266        };
1267
1268        assert!(strategy.validate(&decision, &criteria).await.is_err());
1269    }
1270
1271    #[tokio::test]
1272    async fn test_learn_from_outcome() {
1273        let mut strategy = BatchingStrategy::with_defaults();
1274
1275        let decision = Decision {
1276            id: "test-decision".to_string(),
1277            timestamp: Utc::now(),
1278            strategy: "batching".to_string(),
1279            decision_type: DecisionType::EnableBatching,
1280            confidence: 0.85,
1281            expected_impact: ExpectedImpact {
1282                cost_change_usd: Some(-5.0),
1283                latency_change_ms: Some(100.0),
1284                quality_change: None,
1285                throughput_change_rps: None,
1286                success_rate_change_pct: None,
1287                time_to_impact: Duration::from_secs(300),
1288                confidence: 0.85,
1289            },
1290            config_changes: vec![],
1291            justification: "Test".to_string(),
1292            related_insights: vec![],
1293            related_recommendations: vec![],
1294            priority: 70,
1295            requires_approval: false,
1296            safety_checks: vec![],
1297            rollback_plan: None,
1298            metadata: {
1299                let mut meta = HashMap::new();
1300                meta.insert("pattern_id".to_string(), serde_json::json!("test-pattern"));
1301                meta.insert("batch_size".to_string(), serde_json::json!(5));
1302                meta
1303            },
1304        };
1305
1306        let outcome = DecisionOutcome {
1307            decision_id: "test-decision".to_string(),
1308            execution_time: Utc::now(),
1309            success: true,
1310            error: None,
1311            actual_impact: Some(ActualImpact {
1312                cost_change_usd: -6.0,
1313                latency_change_ms: 95.0,
1314                quality_change: 0.0,
1315                throughput_change_rps: 0.0,
1316                success_rate_change_pct: 0.0,
1317                time_to_impact: Duration::from_secs(300),
1318                variance_from_expected: 0.2,
1319            }),
1320            rolled_back: false,
1321            rollback_reason: None,
1322            metrics_before: create_test_metrics(),
1323            metrics_after: Some(create_test_metrics()),
1324            duration: Duration::from_secs(600),
1325        };
1326
1327        strategy.learn(&decision, &outcome).await.unwrap();
1328
1329        assert_eq!(strategy.stats.total_batching_decisions, 1);
1330        assert_eq!(strategy.stats.successful_batching_decisions, 1);
1331        assert!(!strategy.batch_history.is_empty());
1332    }
1333
1334    #[test]
1335    fn test_extract_request_type() {
1336        assert_eq!(extract_request_type("query requests"), "query");
1337        assert_eq!(extract_request_type("completion tasks"), "completion");
1338        assert_eq!(extract_request_type("summary generation"), "summary");
1339        assert_eq!(extract_request_type("translation requests"), "translation");
1340        assert_eq!(extract_request_type("classification jobs"), "classification");
1341        assert_eq!(extract_request_type("other requests"), "general");
1342    }
1343
1344    #[test]
1345    fn test_get_stats() {
1346        let strategy = BatchingStrategy::with_defaults();
1347        let stats = strategy.get_stats();
1348
1349        assert!(stats.is_object());
1350        assert!(stats.get("total_decisions").is_some());
1351        assert!(stats.get("successful_decisions").is_some());
1352        assert!(stats.get("failed_decisions").is_some());
1353        assert!(stats.get("success_rate").is_some());
1354    }
1355
1356    #[test]
1357    fn test_calculate_decision_priority() {
1358        let strategy = BatchingStrategy::with_defaults();
1359
1360        let optimal_high_savings = OptimalBatchSize {
1361            batch_size: 5,
1362            expected_cost_savings_pct: 30.0,
1363            expected_latency_increase_ms: 50.0,
1364            confidence: 0.9,
1365        };
1366
1367        let tradeoff = TradeoffAnalysis {
1368            tradeoff_score: 0.6,
1369            cost_benefit: 0.3,
1370            latency_penalty: 0.1,
1371            is_acceptable: true,
1372            reasoning: "Good tradeoff".to_string(),
1373        };
1374
1375        let priority = strategy.calculate_decision_priority(&optimal_high_savings, &tradeoff);
1376
1377        // Should be higher than base priority due to high savings and confidence
1378        assert!(priority > strategy.config.priority);
1379    }
1380}