1use 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
36pub 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 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 pub fn with_defaults() -> Self {
64 Self::new(BatchingConfig::default())
65 }
66
67 fn detect_request_patterns(&mut self, input: &DecisionInput) -> Vec<RequestPattern> {
69 let mut patterns = Vec::new();
70
71 for insight in &input.insights {
73 if let Some(pattern) = self.extract_pattern_from_insight(insight) {
74 patterns.push(pattern);
75 }
76 }
77
78 for recommendation in &input.recommendations {
80 if let Some(pattern) = self.extract_pattern_from_recommendation(recommendation) {
81 patterns.push(pattern);
82 }
83 }
84
85 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 fn extract_pattern_from_insight(&self, insight: &Insight) -> Option<RequestPattern> {
96 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, 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 fn extract_pattern_from_recommendation(&self, rec: &Recommendation) -> Option<RequestPattern> {
116 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, 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 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, last_seen: Utc::now(),
143 }
144 }
145
146 fn calculate_optimal_batch_size(
148 &self,
149 pattern: &RequestPattern,
150 criteria: &DecisionCriteria,
151 ) -> OptimalBatchSize {
152 let max_batch_size = self.config.max_batch_size;
154
155 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 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 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 let cost_savings_pct = self.estimate_cost_savings(optimal_size);
180
181 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 fn estimate_cost_savings(&self, batch_size: usize) -> f64 {
194 let base_savings = 30.0; 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 fn estimate_latency_increase(&self, batch_size: usize, pattern: &RequestPattern) -> f64 {
205 let max_wait_time_ms = self.config.batch_timeout.as_millis() as f64;
207
208 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 max_wait_time_ms * 0.3
216 } else {
217 max_wait_time_ms * (1.0 - fill_ratio * 0.7)
219 }
220 }
221
222 fn calculate_batch_confidence(&self, pattern: &RequestPattern, batch_size: usize) -> f64 {
224 let mut confidence = pattern.similarity_score;
225
226 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 if batch_size > 5 && !self.learning_data.pattern_performance.contains_key(&pattern.pattern_id) {
233 confidence *= 0.8;
234 }
235
236 if pattern.frequency_per_minute > 20.0 {
238 confidence = (confidence * 1.1).min(1.0);
239 }
240
241 confidence
242 }
243
244 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 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 && 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 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 fn perform_safety_checks(
315 &self,
316 optimal: &OptimalBatchSize,
317 criteria: &DecisionCriteria,
318 ) -> Vec<SafetyCheck> {
319 let mut checks = Vec::new();
320
321 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 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 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 let quality_ok = if let Some(avg_history) = self.get_average_quality_impact() {
369 avg_history > -0.1 } else {
371 true };
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 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 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), };
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, ),
491 latency_change_ms: Some(optimal.expected_latency_increase_ms),
492 quality_change: None, throughput_change_rps: None, success_rate_change_pct: None,
495 time_to_impact: Duration::from_secs(300), 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 fn calculate_decision_priority(&self, optimal: &OptimalBatchSize, tradeoff: &TradeoffAnalysis) -> u32 {
536 let mut priority = self.config.priority;
537
538 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 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 if optimal.confidence > 0.85 {
554 priority += 5;
555 }
556
557 if tradeoff.tradeoff_score > 0.5 {
559 priority += 5;
560 }
561
562 priority
563 }
564
565 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 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 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 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 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 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 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 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 let has_volume = input.current_metrics.throughput_rps > 1.0;
672
673 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 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 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 let optimal = self.calculate_optimal_batch_size(&pattern, criteria);
715
716 let tradeoff = self.analyze_tradeoffs(&optimal, criteria);
718
719 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 let safety_checks = self.perform_safety_checks(&optimal, criteria);
730
731 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 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 if decision.decision_type != DecisionType::EnableBatching {
766 return Err(DecisionError::ValidationFailed(
767 "Not a batching decision".to_string(),
768 ));
769 }
770
771 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 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 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
847impl 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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
912struct LearningData {
913 pattern_performance: HashMap<String, PatternPerformance>,
914}
915
916#[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
929fn 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; input.insights.clear(); input.recommendations.clear(); input.current_metrics.total_cost_usd = 0.5; 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 assert!(savings_2 < savings_5);
1089 assert!(savings_5 < savings_10);
1090
1091 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, 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 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, 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 assert!(priority > strategy.config.priority);
1379 }
1380}