Skip to main content

trustformers_debug/
anomaly_detector.rs

1//! Anomaly Detection for Model Debugging
2//!
3//! Detects unusual patterns in model execution, tensor values, and gradients
4//! to help identify potential issues during training and inference.
5// reason: debug/profiling scaffolding — structs are constructed and their fields/methods
6// are retained for the data model, serialization completeness, and future consumers that
7// do not yet read every member. Consolidated from many item-level #[allow(dead_code)].
8#![allow(dead_code)]
9
10use anyhow::Result;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, VecDeque};
14
15use crate::DebugConfig;
16
17/// Anomaly detector for model execution
18#[derive(Debug)]
19pub struct AnomalyDetector {
20    config: AnomalyDetectorConfig,
21    detected_anomalies: Vec<Anomaly>,
22    start_time: DateTime<Utc>,
23    recovery_attempts: Vec<RecoveryAttempt>,
24    monitoring_stats: MonitoringStats,
25    performance_history: VecDeque<f64>,
26    gradient_history: HashMap<String, VecDeque<f64>>,
27    loss_history: VecDeque<f64>,
28    weight_baseline: HashMap<String, Vec<f32>>,
29    /// Live handle into the training loop that automatic recovery actions
30    /// are dispatched through. `None` (the default) means recovery actions
31    /// are computed and recorded but never actually performed -- see
32    /// [`Self::execute_recovery_action`].
33    training_control: Option<Box<dyn TrainingControl>>,
34}
35
36/// A live handle into the training loop that [`AnomalyDetector`] can drive
37/// automatic recovery actions through.
38///
39/// Implementations perform the real mutation (clip gradients, reduce the
40/// learning rate, restart the optimizer, ...) against whatever optimizer or
41/// model state the caller owns. Without a `TrainingControl` attached via
42/// [`AnomalyDetector::set_training_control`], [`AnomalyDetector`] never
43/// claims a recovery action succeeded: every action honestly reports "not
44/// performed" instead of the old unconditional `Ok(true)`.
45///
46/// Each method returns `Err` (propagated into the recorded
47/// [`RecoveryAttempt::error_message`], never swallowed) if the underlying
48/// mutation could not be carried out.
49pub trait TrainingControl: std::fmt::Debug + Send + Sync {
50    /// Zero out (or otherwise reset) the current gradients.
51    fn reset_gradients(&mut self) -> Result<()>;
52    /// Multiply the optimizer's learning rate by `factor`.
53    fn reduce_learning_rate(&mut self, factor: f64) -> Result<()>;
54    /// Clip gradients to `max_norm` (e.g. global-norm clipping).
55    fn clip_gradients(&mut self, max_norm: f64) -> Result<()>;
56    /// Reset the optimizer's internal state (momentum buffers, etc.).
57    fn restart_optimizer(&mut self) -> Result<()>;
58    /// Skip the batch currently being processed.
59    fn skip_batch(&mut self) -> Result<()>;
60    /// Reset a specific layer's weights to their initialization.
61    fn reset_weights(&mut self, layer_name: &str) -> Result<()>;
62    /// Apply weight decay at `rate` to the current weights.
63    fn apply_weight_decay(&mut self, rate: f64) -> Result<()>;
64    /// Immediately halt training.
65    fn emergency_stop(&mut self) -> Result<()>;
66}
67// `TrainingControl: Debug` being a supertrait means `dyn TrainingControl`
68// (and so `Box<dyn TrainingControl>` / the `Option` around it on
69// `AnomalyDetector`) automatically implements `Debug` -- no manual impl
70// needed here.
71
72/// Configuration for anomaly detection
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AnomalyDetectorConfig {
75    pub enable_nan_detection: bool,
76    pub enable_inf_detection: bool,
77    pub enable_gradient_explosion: bool,
78    pub enable_gradient_vanishing: bool,
79    pub gradient_threshold: f64,
80    pub enable_memory_leak_detection: bool,
81    pub enable_numerical_instability_detection: bool,
82    pub enable_gradient_conflict_detection: bool,
83    pub enable_performance_monitoring: bool,
84    pub enable_weight_divergence_detection: bool,
85    pub enable_activation_dead_detection: bool,
86    pub enable_loss_anomaly_detection: bool,
87    pub enable_auto_recovery: bool,
88    pub numerical_instability_threshold: f64,
89    pub performance_degradation_threshold: f64,
90    pub weight_divergence_threshold: f64,
91    pub loss_spike_threshold: f64,
92    pub monitoring_window_size: usize,
93    pub recovery_attempts_limit: usize,
94}
95
96impl Default for AnomalyDetectorConfig {
97    fn default() -> Self {
98        Self {
99            enable_nan_detection: true,
100            enable_inf_detection: true,
101            enable_gradient_explosion: true,
102            enable_gradient_vanishing: true,
103            gradient_threshold: 1e6,
104            enable_memory_leak_detection: true,
105            enable_numerical_instability_detection: true,
106            enable_gradient_conflict_detection: true,
107            enable_performance_monitoring: true,
108            enable_weight_divergence_detection: true,
109            enable_activation_dead_detection: true,
110            enable_loss_anomaly_detection: true,
111            enable_auto_recovery: false, // Conservative default
112            numerical_instability_threshold: 1e-12,
113            performance_degradation_threshold: 0.5, // 50% degradation
114            weight_divergence_threshold: 5.0,
115            loss_spike_threshold: 10.0, // 10x loss increase
116            monitoring_window_size: 100,
117            recovery_attempts_limit: 3,
118        }
119    }
120}
121
122/// Types of anomalies that can be detected
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub enum AnomalyType {
125    NaN,
126    Infinity,
127    GradientExplosion,
128    GradientVanishing,
129    MemoryLeak,
130    UnusualActivation,
131    NumericalInstability,
132    GradientConflict,
133    PerformanceDegradation,
134    WeightDivergence,
135    ActivationDead,
136    LossAnomalous,
137}
138
139/// An detected anomaly
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Anomaly {
142    pub anomaly_type: AnomalyType,
143    pub timestamp: DateTime<Utc>,
144    pub location: String,
145    pub description: String,
146    pub severity: AnomalySeverity,
147    pub metadata: HashMap<String, String>,
148}
149
150/// Severity level of an anomaly
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub enum AnomalySeverity {
153    Low,
154    Medium,
155    High,
156    Critical,
157}
158
159/// Auto-recovery action that can be taken
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub enum RecoveryAction {
162    None,
163    ResetGradients,
164    ReduceLearningRate { factor: f64 },
165    ClipGradients { max_norm: f64 },
166    RestartOptimizer,
167    SkipBatch,
168    ResetWeights { layer_name: String },
169    ApplyWeightDecay { rate: f64 },
170    EmergencyStop,
171}
172
173/// Recovery attempt record
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct RecoveryAttempt {
176    pub anomaly_id: String,
177    pub action: RecoveryAction,
178    pub timestamp: DateTime<Utc>,
179    pub success: bool,
180    pub error_message: Option<String>,
181}
182
183/// Real-time monitoring statistics
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct MonitoringStats {
186    pub total_anomalies: usize,
187    pub anomalies_per_type: HashMap<String, usize>,
188    pub recovery_attempts: usize,
189    pub successful_recoveries: usize,
190    pub average_detection_time_ms: f64,
191    pub monitoring_window: Vec<AnomalySnapshot>,
192}
193
194/// Snapshot of anomaly state for monitoring window
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct AnomalySnapshot {
197    pub timestamp: DateTime<Utc>,
198    pub anomaly_count: usize,
199    pub severity_distribution: HashMap<String, usize>,
200    pub performance_metrics: HashMap<String, f64>,
201}
202
203impl AnomalyDetector {
204    /// Create a new anomaly detector
205    pub fn new(_config: &DebugConfig) -> Self {
206        let monitoring_window_size = AnomalyDetectorConfig::default().monitoring_window_size;
207        Self {
208            config: AnomalyDetectorConfig::default(),
209            detected_anomalies: Vec::new(),
210            start_time: Utc::now(),
211            recovery_attempts: Vec::new(),
212            monitoring_stats: MonitoringStats {
213                total_anomalies: 0,
214                anomalies_per_type: HashMap::new(),
215                recovery_attempts: 0,
216                successful_recoveries: 0,
217                average_detection_time_ms: 0.0,
218                monitoring_window: Vec::new(),
219            },
220            performance_history: VecDeque::with_capacity(monitoring_window_size),
221            gradient_history: HashMap::new(),
222            loss_history: VecDeque::with_capacity(monitoring_window_size),
223            weight_baseline: HashMap::new(),
224            training_control: None,
225        }
226    }
227
228    /// Attach a live [`TrainingControl`] handle so that automatic recovery
229    /// actions (see [`Self::attempt_recovery`]) are actually performed
230    /// against real training state, instead of only being computed and
231    /// recorded.
232    pub fn set_training_control(&mut self, control: Box<dyn TrainingControl>) {
233        self.training_control = Some(control);
234    }
235
236    /// Detach the [`TrainingControl`] handle, if any. Future recovery
237    /// actions will honestly report "not performed" until a new one is
238    /// attached.
239    pub fn clear_training_control(&mut self) {
240        self.training_control = None;
241    }
242
243    /// Whether a [`TrainingControl`] handle is currently attached.
244    pub fn has_training_control(&self) -> bool {
245        self.training_control.is_some()
246    }
247
248    /// Start the anomaly detector
249    pub async fn start(&mut self) -> Result<()> {
250        self.start_time = Utc::now();
251        self.detected_anomalies.clear();
252        Ok(())
253    }
254
255    /// Check for NaN values in tensors
256    pub fn check_nan(&mut self, values: &[f32], location: &str) -> Result<()> {
257        if !self.config.enable_nan_detection {
258            return Ok(());
259        }
260
261        if values.iter().any(|v| v.is_nan()) {
262            self.report_anomaly(Anomaly {
263                anomaly_type: AnomalyType::NaN,
264                timestamp: Utc::now(),
265                location: location.to_string(),
266                description: "NaN values detected in tensor".to_string(),
267                severity: AnomalySeverity::High,
268                metadata: HashMap::new(),
269            });
270        }
271
272        Ok(())
273    }
274
275    /// Check for infinite values in tensors
276    pub fn check_inf(&mut self, values: &[f32], location: &str) -> Result<()> {
277        if !self.config.enable_inf_detection {
278            return Ok(());
279        }
280
281        if values.iter().any(|v| v.is_infinite()) {
282            self.report_anomaly(Anomaly {
283                anomaly_type: AnomalyType::Infinity,
284                timestamp: Utc::now(),
285                location: location.to_string(),
286                description: "Infinite values detected in tensor".to_string(),
287                severity: AnomalySeverity::High,
288                metadata: HashMap::new(),
289            });
290        }
291
292        Ok(())
293    }
294
295    /// Check for gradient explosion
296    pub fn check_gradient_explosion(&mut self, gradient_norm: f64, location: &str) -> Result<()> {
297        if !self.config.enable_gradient_explosion {
298            return Ok(());
299        }
300
301        if gradient_norm > self.config.gradient_threshold {
302            self.report_anomaly(Anomaly {
303                anomaly_type: AnomalyType::GradientExplosion,
304                timestamp: Utc::now(),
305                location: location.to_string(),
306                description: format!("Gradient explosion detected: norm = {}", gradient_norm),
307                severity: AnomalySeverity::Critical,
308                metadata: {
309                    let mut meta = HashMap::new();
310                    meta.insert("gradient_norm".to_string(), gradient_norm.to_string());
311                    meta
312                },
313            });
314        }
315
316        Ok(())
317    }
318
319    /// Check for vanishing gradients
320    pub fn check_gradient_vanishing(&mut self, gradient_norm: f64, location: &str) -> Result<()> {
321        if !self.config.enable_gradient_vanishing {
322            return Ok(());
323        }
324
325        let vanishing_threshold = 1e-8;
326        if gradient_norm < vanishing_threshold {
327            self.report_anomaly(Anomaly {
328                anomaly_type: AnomalyType::GradientVanishing,
329                timestamp: Utc::now(),
330                location: location.to_string(),
331                description: format!("Vanishing gradient detected: norm = {}", gradient_norm),
332                severity: AnomalySeverity::High,
333                metadata: {
334                    let mut meta = HashMap::new();
335                    meta.insert("gradient_norm".to_string(), gradient_norm.to_string());
336                    meta.insert("threshold".to_string(), vanishing_threshold.to_string());
337                    meta
338                },
339            });
340        }
341
342        Ok(())
343    }
344
345    /// Check for numerical instability
346    pub fn check_numerical_instability(&mut self, values: &[f32], location: &str) -> Result<()> {
347        let mut metadata = HashMap::new();
348
349        // Check for values close to zero that might cause division problems
350        let near_zero_count = values.iter().filter(|&&v| v.abs() < 1e-10 && v != 0.0).count();
351        if near_zero_count > values.len() / 10 {
352            metadata.insert("near_zero_count".to_string(), near_zero_count.to_string());
353            metadata.insert("total_values".to_string(), values.len().to_string());
354
355            self.report_anomaly(Anomaly {
356                anomaly_type: AnomalyType::UnusualActivation,
357                timestamp: Utc::now(),
358                location: location.to_string(),
359                description: format!(
360                    "Numerical instability: {} values near zero",
361                    near_zero_count
362                ),
363                severity: AnomalySeverity::Medium,
364                metadata: metadata.clone(),
365            });
366        }
367
368        // Check for extreme values that might cause overflow
369        let extreme_count = values.iter().filter(|&&v| v.abs() > 1e6).count();
370        if extreme_count > 0 {
371            metadata.insert("extreme_count".to_string(), extreme_count.to_string());
372
373            self.report_anomaly(Anomaly {
374                anomaly_type: AnomalyType::UnusualActivation,
375                timestamp: Utc::now(),
376                location: location.to_string(),
377                description: format!("Numerical instability: {} extreme values", extreme_count),
378                severity: AnomalySeverity::High,
379                metadata,
380            });
381        }
382
383        Ok(())
384    }
385
386    /// Check for activation saturation
387    pub fn check_activation_saturation(
388        &mut self,
389        activations: &[f32],
390        activation_type: &str,
391        location: &str,
392    ) -> Result<()> {
393        let saturation_threshold = match activation_type.to_lowercase().as_str() {
394            "sigmoid" | "tanh" => 0.01, // Close to 0 or 1 for sigmoid, -1 or 1 for tanh
395            "relu" => 0.0,              // Zero values for ReLU
396            _ => 0.01,
397        };
398
399        let saturated_count = match activation_type.to_lowercase().as_str() {
400            "sigmoid" => activations
401                .iter()
402                .filter(|&&v| v < saturation_threshold || v > 1.0 - saturation_threshold)
403                .count(),
404            "tanh" => activations.iter().filter(|&&v| v.abs() > 1.0 - saturation_threshold).count(),
405            "relu" => activations.iter().filter(|&&v| v == 0.0).count(),
406            _ => activations.iter().filter(|&&v| v.abs() < saturation_threshold).count(),
407        };
408
409        let saturation_ratio = saturated_count as f32 / activations.len() as f32;
410
411        if saturation_ratio > 0.9 {
412            let mut metadata = HashMap::new();
413            metadata.insert("activation_type".to_string(), activation_type.to_string());
414            metadata.insert("saturated_count".to_string(), saturated_count.to_string());
415            metadata.insert("total_count".to_string(), activations.len().to_string());
416            metadata.insert("saturation_ratio".to_string(), saturation_ratio.to_string());
417
418            self.report_anomaly(Anomaly {
419                anomaly_type: AnomalyType::UnusualActivation,
420                timestamp: Utc::now(),
421                location: location.to_string(),
422                description: format!(
423                    "Activation saturation detected: {:.1}% of {} activations saturated",
424                    saturation_ratio * 100.0,
425                    activation_type
426                ),
427                severity: AnomalySeverity::High,
428                metadata,
429            });
430        }
431
432        Ok(())
433    }
434
435    /// Check for memory leaks by tracking memory usage patterns
436    pub fn check_memory_leak(
437        &mut self,
438        current_memory_mb: usize,
439        expected_memory_mb: Option<usize>,
440        location: &str,
441    ) -> Result<()> {
442        if !self.config.enable_memory_leak_detection {
443            return Ok(());
444        }
445
446        let mut should_report = false;
447        let mut description = String::new();
448        let mut metadata = HashMap::new();
449
450        metadata.insert(
451            "current_memory_mb".to_string(),
452            current_memory_mb.to_string(),
453        );
454
455        if let Some(expected) = expected_memory_mb {
456            metadata.insert("expected_memory_mb".to_string(), expected.to_string());
457
458            let growth_ratio = current_memory_mb as f64 / expected as f64;
459            if growth_ratio > 2.0 {
460                should_report = true;
461                description = format!(
462                    "Memory usage {}MB is {:.1}x expected {}MB",
463                    current_memory_mb, growth_ratio, expected
464                );
465                metadata.insert("growth_ratio".to_string(), growth_ratio.to_string());
466            }
467        } else {
468            // Check for absolute high memory usage
469            if current_memory_mb > 8192 {
470                // 8GB threshold
471                should_report = true;
472                description = format!("High memory usage detected: {}MB", current_memory_mb);
473            }
474        }
475
476        if should_report {
477            self.report_anomaly(Anomaly {
478                anomaly_type: AnomalyType::MemoryLeak,
479                timestamp: Utc::now(),
480                location: location.to_string(),
481                description,
482                severity: if current_memory_mb > 16384 {
483                    AnomalySeverity::Critical
484                } else {
485                    AnomalySeverity::High
486                },
487                metadata,
488            });
489        }
490
491        Ok(())
492    }
493
494    /// Check for weight explosion in model parameters
495    pub fn check_weight_explosion(&mut self, weights: &[f32], layer_name: &str) -> Result<()> {
496        let weight_threshold = 10.0;
497        let extreme_weights: Vec<f32> =
498            weights.iter().filter(|&&w| w.abs() > weight_threshold).cloned().collect();
499
500        if !extreme_weights.is_empty() {
501            let mut metadata = HashMap::new();
502            metadata.insert("layer_name".to_string(), layer_name.to_string());
503            metadata.insert(
504                "extreme_weight_count".to_string(),
505                extreme_weights.len().to_string(),
506            );
507            metadata.insert("total_weight_count".to_string(), weights.len().to_string());
508            metadata.insert(
509                "max_weight".to_string(),
510                extreme_weights.iter().map(|w| w.abs()).fold(0.0f32, f32::max).to_string(),
511            );
512
513            self.report_anomaly(Anomaly {
514                anomaly_type: AnomalyType::UnusualActivation,
515                timestamp: Utc::now(),
516                location: layer_name.to_string(),
517                description: format!(
518                    "Weight explosion in {}: {} weights > {}",
519                    layer_name,
520                    extreme_weights.len(),
521                    weight_threshold
522                ),
523                severity: AnomalySeverity::High,
524                metadata,
525            });
526        }
527
528        Ok(())
529    }
530
531    /// Report an anomaly
532    fn report_anomaly(&mut self, anomaly: Anomaly) {
533        tracing::warn!(
534            "🚨 Anomaly detected: {} at {}",
535            anomaly.description,
536            anomaly.location
537        );
538
539        // Update monitoring stats
540        self.monitoring_stats.total_anomalies += 1;
541        let anomaly_type_key = format!("{:?}", anomaly.anomaly_type);
542        *self.monitoring_stats.anomalies_per_type.entry(anomaly_type_key).or_insert(0) += 1;
543
544        self.detected_anomalies.push(anomaly);
545    }
546
547    /// Get all detected anomalies
548    pub fn get_anomalies(&self) -> &[Anomaly] {
549        &self.detected_anomalies
550    }
551
552    /// Clear all detected anomalies
553    pub fn clear_anomalies(&mut self) {
554        self.detected_anomalies.clear();
555    }
556
557    /// Check for gradient conflicts between layers
558    pub fn check_gradient_conflict(
559        &mut self,
560        layer_gradients: &HashMap<String, Vec<f32>>,
561    ) -> Result<()> {
562        if !self.config.enable_gradient_conflict_detection {
563            return Ok(());
564        }
565
566        let layer_names: Vec<_> = layer_gradients.keys().cloned().collect();
567
568        for i in 0..layer_names.len() {
569            for j in i + 1..layer_names.len() {
570                let layer1 = &layer_names[i];
571                let layer2 = &layer_names[j];
572
573                if let (Some(grad1), Some(grad2)) =
574                    (layer_gradients.get(layer1), layer_gradients.get(layer2))
575                {
576                    let conflict_score = self.compute_gradient_conflict(grad1, grad2);
577
578                    if conflict_score > 0.8 {
579                        let mut metadata = HashMap::new();
580                        metadata.insert("layer1".to_string(), layer1.clone());
581                        metadata.insert("layer2".to_string(), layer2.clone());
582                        metadata.insert("conflict_score".to_string(), conflict_score.to_string());
583
584                        self.report_anomaly(Anomaly {
585                            anomaly_type: AnomalyType::GradientConflict,
586                            timestamp: Utc::now(),
587                            location: format!("{}↔{}", layer1, layer2),
588                            description: format!(
589                                "Gradient conflict detected between {} and {} (score: {:.2})",
590                                layer1, layer2, conflict_score
591                            ),
592                            severity: AnomalySeverity::High,
593                            metadata,
594                        });
595                    }
596                }
597            }
598        }
599
600        Ok(())
601    }
602
603    /// Check for weight divergence from baseline
604    pub fn check_weight_divergence(
605        &mut self,
606        layer_name: &str,
607        current_weights: &[f32],
608    ) -> Result<()> {
609        if !self.config.enable_weight_divergence_detection {
610            return Ok(());
611        }
612
613        // Initialize baseline if not exists
614        if !self.weight_baseline.contains_key(layer_name) {
615            self.weight_baseline.insert(layer_name.to_string(), current_weights.to_vec());
616            return Ok(());
617        }
618
619        let Some(baseline) = self.weight_baseline.get(layer_name) else {
620            return Ok(());
621        };
622        if baseline.len() != current_weights.len() {
623            return Ok(()); // Skip if dimensions don't match
624        }
625
626        let divergence = self.compute_weight_divergence(baseline, current_weights);
627
628        if divergence > self.config.weight_divergence_threshold {
629            let mut metadata = HashMap::new();
630            metadata.insert("layer_name".to_string(), layer_name.to_string());
631            metadata.insert("divergence_score".to_string(), divergence.to_string());
632            metadata.insert(
633                "threshold".to_string(),
634                self.config.weight_divergence_threshold.to_string(),
635            );
636
637            self.report_anomaly(Anomaly {
638                anomaly_type: AnomalyType::WeightDivergence,
639                timestamp: Utc::now(),
640                location: layer_name.to_string(),
641                description: format!(
642                    "Weight divergence in {}: {:.2} (threshold: {:.2})",
643                    layer_name, divergence, self.config.weight_divergence_threshold
644                ),
645                severity: if divergence > self.config.weight_divergence_threshold * 2.0 {
646                    AnomalySeverity::Critical
647                } else {
648                    AnomalySeverity::High
649                },
650                metadata,
651            });
652        }
653
654        Ok(())
655    }
656
657    /// Check for performance degradation
658    pub fn check_performance_degradation(
659        &mut self,
660        current_performance: f64,
661        location: &str,
662    ) -> Result<()> {
663        if !self.config.enable_performance_monitoring {
664            return Ok(());
665        }
666
667        // Add to history
668        if self.performance_history.len() >= self.config.monitoring_window_size {
669            self.performance_history.pop_front();
670        }
671        self.performance_history.push_back(current_performance);
672
673        // Check for degradation if we have enough history
674        if self.performance_history.len() >= 10 {
675            let recent_avg = self.performance_history.iter().rev().take(5).sum::<f64>() / 5.0;
676            let baseline_avg = self.performance_history.iter().take(5).sum::<f64>() / 5.0;
677
678            let degradation_ratio = (baseline_avg - recent_avg) / baseline_avg;
679
680            if degradation_ratio > self.config.performance_degradation_threshold {
681                let mut metadata = HashMap::new();
682                metadata.insert("baseline_performance".to_string(), baseline_avg.to_string());
683                metadata.insert("current_performance".to_string(), recent_avg.to_string());
684                metadata.insert(
685                    "degradation_ratio".to_string(),
686                    degradation_ratio.to_string(),
687                );
688
689                self.report_anomaly(Anomaly {
690                    anomaly_type: AnomalyType::PerformanceDegradation,
691                    timestamp: Utc::now(),
692                    location: location.to_string(),
693                    description: format!(
694                        "Performance degradation detected: {:.1}% drop from baseline",
695                        degradation_ratio * 100.0
696                    ),
697                    severity: if degradation_ratio > 0.8 {
698                        AnomalySeverity::Critical
699                    } else {
700                        AnomalySeverity::High
701                    },
702                    metadata,
703                });
704            }
705        }
706
707        Ok(())
708    }
709
710    /// Check for loss anomalies
711    pub fn check_loss_anomaly(&mut self, current_loss: f64, location: &str) -> Result<()> {
712        if !self.config.enable_loss_anomaly_detection {
713            return Ok(());
714        }
715
716        // Add to history
717        if self.loss_history.len() >= self.config.monitoring_window_size {
718            self.loss_history.pop_front();
719        }
720        self.loss_history.push_back(current_loss);
721
722        // Check for loss spikes
723        if self.loss_history.len() >= 3 {
724            let prev_loss = self.loss_history[self.loss_history.len() - 2];
725            let loss_ratio = current_loss / prev_loss;
726
727            if loss_ratio > self.config.loss_spike_threshold {
728                let mut metadata = HashMap::new();
729                metadata.insert("previous_loss".to_string(), prev_loss.to_string());
730                metadata.insert("current_loss".to_string(), current_loss.to_string());
731                metadata.insert("spike_ratio".to_string(), loss_ratio.to_string());
732
733                self.report_anomaly(Anomaly {
734                    anomaly_type: AnomalyType::LossAnomalous,
735                    timestamp: Utc::now(),
736                    location: location.to_string(),
737                    description: format!(
738                        "Loss spike detected: {:.2}x increase (from {:.6} to {:.6})",
739                        loss_ratio, prev_loss, current_loss
740                    ),
741                    severity: if loss_ratio > 100.0 {
742                        AnomalySeverity::Critical
743                    } else {
744                        AnomalySeverity::High
745                    },
746                    metadata,
747                });
748            }
749        }
750
751        Ok(())
752    }
753
754    /// Attempt automatic recovery from an anomaly
755    pub async fn attempt_recovery(&mut self, anomaly: &Anomaly) -> Result<RecoveryAction> {
756        if !self.config.enable_auto_recovery {
757            return Ok(RecoveryAction::None);
758        }
759
760        let action = self.determine_recovery_action(anomaly);
761        let anomaly_id = format!(
762            "{:?}_{}",
763            anomaly.anomaly_type,
764            anomaly.timestamp.timestamp()
765        );
766
767        let (success, error_message) = self.execute_recovery_action(&action).await?;
768
769        self.recovery_attempts.push(RecoveryAttempt {
770            anomaly_id: anomaly_id.clone(),
771            action: action.clone(),
772            timestamp: Utc::now(),
773            success,
774            error_message,
775        });
776
777        self.monitoring_stats.recovery_attempts += 1;
778        if success {
779            self.monitoring_stats.successful_recoveries += 1;
780        }
781
782        Ok(action)
783    }
784
785    /// Get monitoring statistics
786    pub fn get_monitoring_stats(&self) -> &MonitoringStats {
787        &self.monitoring_stats
788    }
789
790    /// Get recovery attempts history
791    pub fn get_recovery_attempts(&self) -> &[RecoveryAttempt] {
792        &self.recovery_attempts
793    }
794
795    /// Update monitoring window with current state
796    pub fn update_monitoring_window(&mut self) -> Result<()> {
797        let mut severity_distribution = HashMap::new();
798        for anomaly in &self.detected_anomalies {
799            let key = format!("{:?}", anomaly.severity);
800            *severity_distribution.entry(key).or_insert(0) += 1;
801        }
802
803        let mut performance_metrics = HashMap::new();
804        if let Some(latest_perf) = self.performance_history.back() {
805            performance_metrics.insert("latest_performance".to_string(), *latest_perf);
806        }
807        if let Some(latest_loss) = self.loss_history.back() {
808            performance_metrics.insert("latest_loss".to_string(), *latest_loss);
809        }
810
811        let snapshot = AnomalySnapshot {
812            timestamp: Utc::now(),
813            anomaly_count: self.detected_anomalies.len(),
814            severity_distribution,
815            performance_metrics,
816        };
817
818        self.monitoring_stats.monitoring_window.push(snapshot);
819
820        // Keep only recent snapshots
821        if self.monitoring_stats.monitoring_window.len() > self.config.monitoring_window_size {
822            self.monitoring_stats.monitoring_window.remove(0);
823        }
824
825        Ok(())
826    }
827
828    // Private helper methods for new functionality
829
830    fn compute_gradient_conflict(&self, grad1: &[f32], grad2: &[f32]) -> f64 {
831        if grad1.len() != grad2.len() {
832            return 0.0;
833        }
834
835        let dot_product: f64 =
836            grad1.iter().zip(grad2.iter()).map(|(a, b)| (*a as f64) * (*b as f64)).sum();
837
838        let norm1: f64 = grad1.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
839        let norm2: f64 = grad2.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
840
841        if norm1 == 0.0 || norm2 == 0.0 {
842            return 0.0;
843        }
844
845        // Cosine similarity - conflicts are indicated by negative correlation
846        let cosine_sim = dot_product / (norm1 * norm2);
847
848        // Convert to conflict score (0 = no conflict, 1 = maximum conflict)
849        (1.0 - cosine_sim) / 2.0
850    }
851
852    fn compute_weight_divergence(&self, baseline: &[f32], current: &[f32]) -> f64 {
853        let mse: f64 = baseline
854            .iter()
855            .zip(current.iter())
856            .map(|(a, b)| (*a as f64 - *b as f64).powi(2))
857            .sum::<f64>()
858            / baseline.len() as f64;
859
860        mse.sqrt()
861    }
862
863    fn determine_recovery_action(&self, anomaly: &Anomaly) -> RecoveryAction {
864        match anomaly.anomaly_type {
865            AnomalyType::GradientExplosion => RecoveryAction::ClipGradients { max_norm: 1.0 },
866            AnomalyType::GradientVanishing => RecoveryAction::ReduceLearningRate { factor: 0.5 },
867            AnomalyType::NaN | AnomalyType::Infinity => RecoveryAction::ResetGradients,
868            AnomalyType::WeightDivergence => RecoveryAction::ApplyWeightDecay { rate: 0.01 },
869            AnomalyType::LossAnomalous => RecoveryAction::SkipBatch,
870            AnomalyType::MemoryLeak => RecoveryAction::RestartOptimizer,
871            AnomalyType::PerformanceDegradation => {
872                RecoveryAction::ReduceLearningRate { factor: 0.8 }
873            },
874            _ => RecoveryAction::None,
875        }
876    }
877
878    /// Dispatch `action` through the attached [`TrainingControl`], if any.
879    ///
880    /// Returns `(true, None)` when the handle performed the action for
881    /// real; `(false, Some(reason))` when it was attempted and the handle
882    /// reported an error, or when no handle is attached at all. This used
883    /// to unconditionally return `Ok(true)` for every variant except
884    /// `EmergencyStop` regardless of whether anything was ever done --
885    /// every branch now goes through a real [`TrainingControl`] call.
886    async fn execute_recovery_action(
887        &mut self,
888        action: &RecoveryAction,
889    ) -> Result<(bool, Option<String>)> {
890        let Some(control) = self.training_control.as_mut() else {
891            let reason = format!(
892                "no TrainingControl attached to this AnomalyDetector (see \
893                 AnomalyDetector::set_training_control); recovery action {action:?} was not \
894                 performed"
895            );
896            tracing::warn!("{reason}");
897            return Ok((false, Some(reason)));
898        };
899
900        let outcome = match action {
901            RecoveryAction::None => Ok(()),
902            RecoveryAction::ResetGradients => control.reset_gradients(),
903            RecoveryAction::ReduceLearningRate { factor } => control.reduce_learning_rate(*factor),
904            RecoveryAction::ClipGradients { max_norm } => control.clip_gradients(*max_norm),
905            RecoveryAction::RestartOptimizer => control.restart_optimizer(),
906            RecoveryAction::SkipBatch => control.skip_batch(),
907            RecoveryAction::ResetWeights { layer_name } => control.reset_weights(layer_name),
908            RecoveryAction::ApplyWeightDecay { rate } => control.apply_weight_decay(*rate),
909            RecoveryAction::EmergencyStop => control.emergency_stop(),
910        };
911
912        match (action, outcome) {
913            (RecoveryAction::EmergencyStop, Ok(())) => {
914                // Matches the pre-existing contract: EmergencyStop always
915                // reports `false` here (it halts training rather than
916                // "recovering" it), but it is now a real call, not a no-op.
917                tracing::warn!("Executed recovery: Emergency stop");
918                Ok((false, None))
919            },
920            (_, Ok(())) => {
921                tracing::info!("Executed recovery action: {action:?}");
922                Ok((true, None))
923            },
924            (_, Err(e)) => {
925                let reason = e.to_string();
926                tracing::error!("Recovery action {action:?} failed: {reason}");
927                Ok((false, Some(reason)))
928            },
929        }
930    }
931
932    /// Quick anomaly check for simplified interface
933    pub async fn quick_check(&self) -> Result<crate::QuickAnomalySummary> {
934        let anomaly_count = self.detected_anomalies.len();
935
936        let severity_level = match anomaly_count {
937            0 => "None",
938            1..=3 => "Low",
939            4..=10 => "Medium",
940            11..=20 => "High",
941            _ => "Critical",
942        }
943        .to_string();
944
945        let mut recommendations = Vec::new();
946        if anomaly_count > 0 {
947            recommendations.push("Review recent training metrics for instabilities".to_string());
948        }
949        if anomaly_count > 5 {
950            recommendations.push(
951                "Consider adjusting learning rate or implementing gradient clipping".to_string(),
952            );
953        }
954        if anomaly_count > 15 {
955            recommendations
956                .push("Training may need to be restarted with better configuration".to_string());
957        }
958        if anomaly_count == 0 {
959            recommendations.push("No anomalies detected, training appears stable".to_string());
960        }
961
962        Ok(crate::QuickAnomalySummary {
963            anomaly_count,
964            severity_level,
965            recommendations,
966        })
967    }
968
969    /// Generate anomaly detection report
970    pub async fn generate_report(&self) -> Result<AnomalyDetectorReport> {
971        let mut anomaly_counts = HashMap::new();
972        for anomaly in &self.detected_anomalies {
973            let count = anomaly_counts.entry(format!("{:?}", anomaly.anomaly_type)).or_insert(0);
974            *count += 1;
975        }
976
977        Ok(AnomalyDetectorReport {
978            session_duration: Utc::now().signed_duration_since(self.start_time),
979            total_anomalies: self.detected_anomalies.len(),
980            anomaly_counts,
981            most_recent_anomalies: self.detected_anomalies.iter().rev().take(10).cloned().collect(),
982            config: self.config.clone(),
983        })
984    }
985}
986
987/// Report generated by the anomaly detector
988#[derive(Debug, Clone, Serialize, Deserialize)]
989pub struct AnomalyDetectorReport {
990    pub session_duration: chrono::Duration,
991    pub total_anomalies: usize,
992    pub anomaly_counts: HashMap<String, usize>,
993    pub most_recent_anomalies: Vec<Anomaly>,
994    pub config: AnomalyDetectorConfig,
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    #[test]
1002    fn test_anomaly_detector_creation() {
1003        let config = DebugConfig::default();
1004        let detector = AnomalyDetector::new(&config);
1005        assert_eq!(detector.get_anomalies().len(), 0);
1006    }
1007
1008    #[test]
1009    fn test_nan_detection() {
1010        let config = DebugConfig::default();
1011        let mut detector = AnomalyDetector::new(&config);
1012
1013        let values = vec![1.0, 2.0, f32::NAN, 4.0];
1014        detector.check_nan(&values, "test_location").expect("operation failed in test");
1015
1016        assert_eq!(detector.get_anomalies().len(), 1);
1017        assert!(matches!(
1018            detector.get_anomalies()[0].anomaly_type,
1019            AnomalyType::NaN
1020        ));
1021    }
1022
1023    #[test]
1024    fn test_inf_detection() {
1025        let config = DebugConfig::default();
1026        let mut detector = AnomalyDetector::new(&config);
1027
1028        let values = vec![1.0, 2.0, f32::INFINITY, 4.0];
1029        detector.check_inf(&values, "test_location").expect("operation failed in test");
1030
1031        assert_eq!(detector.get_anomalies().len(), 1);
1032        assert!(matches!(
1033            detector.get_anomalies()[0].anomaly_type,
1034            AnomalyType::Infinity
1035        ));
1036    }
1037
1038    #[test]
1039    fn test_gradient_explosion_detection() {
1040        let config = DebugConfig::default();
1041        let mut detector = AnomalyDetector::new(&config);
1042
1043        detector
1044            .check_gradient_explosion(1e7, "test_layer")
1045            .expect("operation failed in test");
1046
1047        assert_eq!(detector.get_anomalies().len(), 1);
1048        assert!(matches!(
1049            detector.get_anomalies()[0].anomaly_type,
1050            AnomalyType::GradientExplosion
1051        ));
1052    }
1053
1054    #[test]
1055    fn test_gradient_vanishing_detection() {
1056        let config = DebugConfig::default();
1057        let mut detector = AnomalyDetector::new(&config);
1058
1059        detector
1060            .check_gradient_vanishing(1e-10, "test_layer")
1061            .expect("operation failed in test");
1062
1063        assert_eq!(detector.get_anomalies().len(), 1);
1064        assert!(matches!(
1065            detector.get_anomalies()[0].anomaly_type,
1066            AnomalyType::GradientVanishing
1067        ));
1068    }
1069
1070    #[test]
1071    fn test_numerical_instability_detection() {
1072        let config = DebugConfig::default();
1073        let mut detector = AnomalyDetector::new(&config);
1074
1075        // Test near-zero values
1076        let near_zero_values: Vec<f32> =
1077            (0..100).map(|i| if i < 50 { 1e-12 } else { 1.0 }).collect();
1078        detector
1079            .check_numerical_instability(&near_zero_values, "test_location")
1080            .expect("operation failed in test");
1081        assert_eq!(detector.get_anomalies().len(), 1);
1082
1083        detector.clear_anomalies();
1084
1085        // Test extreme values
1086        let extreme_values = vec![1.0, 2.0, 1e7, 4.0];
1087        detector
1088            .check_numerical_instability(&extreme_values, "test_location")
1089            .expect("operation failed in test");
1090        assert_eq!(detector.get_anomalies().len(), 1);
1091    }
1092
1093    #[test]
1094    fn test_activation_saturation_detection() {
1095        let config = DebugConfig::default();
1096        let mut detector = AnomalyDetector::new(&config);
1097
1098        // Test ReLU saturation (all zeros)
1099        let relu_saturated: Vec<f32> = vec![0.0; 100];
1100        detector
1101            .check_activation_saturation(&relu_saturated, "relu", "test_layer")
1102            .expect("operation failed in test");
1103        assert_eq!(detector.get_anomalies().len(), 1);
1104
1105        detector.clear_anomalies();
1106
1107        // Test sigmoid saturation (all ones)
1108        let sigmoid_saturated: Vec<f32> = vec![0.999; 100];
1109        detector
1110            .check_activation_saturation(&sigmoid_saturated, "sigmoid", "test_layer")
1111            .expect("operation failed in test");
1112        assert_eq!(detector.get_anomalies().len(), 1);
1113    }
1114
1115    #[test]
1116    fn test_memory_leak_detection() {
1117        let config = DebugConfig::default();
1118        let mut detector = AnomalyDetector::new(&config);
1119
1120        // Test memory growth detection (3x growth should trigger)
1121        detector
1122            .check_memory_leak(3072, Some(1024), "test_location")
1123            .expect("operation failed in test");
1124        assert_eq!(detector.get_anomalies().len(), 1);
1125        assert!(matches!(
1126            detector.get_anomalies()[0].anomaly_type,
1127            AnomalyType::MemoryLeak
1128        ));
1129
1130        detector.clear_anomalies();
1131
1132        // Test absolute high memory
1133        detector
1134            .check_memory_leak(10240, None, "test_location")
1135            .expect("operation failed in test");
1136        assert_eq!(detector.get_anomalies().len(), 1);
1137    }
1138
1139    #[test]
1140    fn test_weight_explosion_detection() {
1141        let config = DebugConfig::default();
1142        let mut detector = AnomalyDetector::new(&config);
1143
1144        let weights = vec![1.0, 2.0, 15.0, 4.0, -20.0]; // Two weights exceed threshold of 10.0
1145        detector
1146            .check_weight_explosion(&weights, "test_layer")
1147            .expect("operation failed in test");
1148
1149        assert_eq!(detector.get_anomalies().len(), 1);
1150        assert!(matches!(
1151            detector.get_anomalies()[0].anomaly_type,
1152            AnomalyType::UnusualActivation
1153        ));
1154    }
1155
1156    #[test]
1157    fn test_gradient_conflict_detection() {
1158        let config = DebugConfig::default();
1159        let mut detector = AnomalyDetector::new(&config);
1160
1161        let mut layer_gradients = HashMap::new();
1162        layer_gradients.insert("layer1".to_string(), vec![1.0, 0.0, 0.0]);
1163        layer_gradients.insert("layer2".to_string(), vec![-1.0, 0.0, 0.0]); // Opposing gradients
1164
1165        detector
1166            .check_gradient_conflict(&layer_gradients)
1167            .expect("operation failed in test");
1168
1169        assert_eq!(detector.get_anomalies().len(), 1);
1170        assert!(matches!(
1171            detector.get_anomalies()[0].anomaly_type,
1172            AnomalyType::GradientConflict
1173        ));
1174    }
1175
1176    #[test]
1177    fn test_weight_divergence_detection() {
1178        let config = DebugConfig::default();
1179        let mut detector = AnomalyDetector::new(&config);
1180
1181        let baseline_weights = vec![1.0, 2.0, 3.0, 4.0];
1182        let diverged_weights = vec![10.0, 20.0, 30.0, 40.0]; // Significant divergence
1183
1184        // First call establishes baseline
1185        detector
1186            .check_weight_divergence("test_layer", &baseline_weights)
1187            .expect("operation failed in test");
1188        assert_eq!(detector.get_anomalies().len(), 0);
1189
1190        // Second call detects divergence
1191        detector
1192            .check_weight_divergence("test_layer", &diverged_weights)
1193            .expect("operation failed in test");
1194        assert_eq!(detector.get_anomalies().len(), 1);
1195        assert!(matches!(
1196            detector.get_anomalies()[0].anomaly_type,
1197            AnomalyType::WeightDivergence
1198        ));
1199    }
1200
1201    #[test]
1202    fn test_performance_degradation_detection() {
1203        let config = DebugConfig::default();
1204        let mut detector = AnomalyDetector::new(&config);
1205
1206        // Add baseline performance metrics
1207        for _ in 0..10 {
1208            detector
1209                .check_performance_degradation(100.0, "training")
1210                .expect("operation failed in test"); // Good performance
1211        }
1212        assert_eq!(detector.get_anomalies().len(), 0);
1213
1214        // Add degraded performance metrics - just enough to trigger once
1215        for _ in 0..5 {
1216            detector
1217                .check_performance_degradation(20.0, "training")
1218                .expect("operation failed in test"); // Poor performance
1219        }
1220
1221        // Should have at least one degradation anomaly
1222        assert!(!detector.get_anomalies().is_empty());
1223        assert!(detector
1224            .get_anomalies()
1225            .iter()
1226            .any(|a| matches!(a.anomaly_type, AnomalyType::PerformanceDegradation)));
1227    }
1228
1229    #[test]
1230    fn test_loss_anomaly_detection() {
1231        let config = DebugConfig::default();
1232        let mut detector = AnomalyDetector::new(&config);
1233
1234        // Add normal loss values
1235        detector.check_loss_anomaly(1.0, "training").expect("operation failed in test");
1236        detector.check_loss_anomaly(0.9, "training").expect("operation failed in test");
1237        assert_eq!(detector.get_anomalies().len(), 0);
1238
1239        // Add loss spike
1240        detector
1241            .check_loss_anomaly(100.0, "training")
1242            .expect("operation failed in test"); // 100x spike
1243        assert_eq!(detector.get_anomalies().len(), 1);
1244        assert!(matches!(
1245            detector.get_anomalies()[0].anomaly_type,
1246            AnomalyType::LossAnomalous
1247        ));
1248    }
1249
1250    #[tokio::test]
1251    async fn test_auto_recovery() {
1252        let config = DebugConfig::default();
1253        let mut detector = AnomalyDetector::new(&config);
1254        detector.config.enable_auto_recovery = true;
1255
1256        let anomaly = Anomaly {
1257            anomaly_type: AnomalyType::GradientExplosion,
1258            timestamp: Utc::now(),
1259            location: "test_layer".to_string(),
1260            description: "Test gradient explosion".to_string(),
1261            severity: AnomalySeverity::High,
1262            metadata: HashMap::new(),
1263        };
1264
1265        let action = detector.attempt_recovery(&anomaly).await.expect("temp file creation failed");
1266        assert!(matches!(action, RecoveryAction::ClipGradients { .. }));
1267        assert_eq!(detector.get_recovery_attempts().len(), 1);
1268
1269        // Regression: without a `TrainingControl` attached, the old
1270        // implementation still reported every recovery attempt (other than
1271        // `EmergencyStop`) as a silent, unconditional success. It must now
1272        // be honestly recorded as *not performed*.
1273        let attempt = &detector.get_recovery_attempts()[0];
1274        assert!(
1275            !attempt.success,
1276            "must not report success when no TrainingControl was ever attached"
1277        );
1278        assert!(
1279            attempt.error_message.is_some(),
1280            "must record a real reason, not silently claim success"
1281        );
1282    }
1283
1284    /// A [`TrainingControl`] mock that records every call it receives and
1285    /// lets a test assert the anomaly detector actually drove it -- rather
1286    /// than merely logging and claiming success.
1287    #[derive(Debug, Default)]
1288    struct RecordingTrainingControl {
1289        calls: Vec<String>,
1290        fail_next: bool,
1291    }
1292
1293    impl TrainingControl for RecordingTrainingControl {
1294        fn reset_gradients(&mut self) -> Result<()> {
1295            self.calls.push("reset_gradients".to_string());
1296            Ok(())
1297        }
1298        fn reduce_learning_rate(&mut self, factor: f64) -> Result<()> {
1299            self.calls.push(format!("reduce_learning_rate({factor})"));
1300            Ok(())
1301        }
1302        fn clip_gradients(&mut self, max_norm: f64) -> Result<()> {
1303            self.calls.push(format!("clip_gradients({max_norm})"));
1304            if self.fail_next {
1305                anyhow::bail!("simulated clip_gradients failure");
1306            }
1307            Ok(())
1308        }
1309        fn restart_optimizer(&mut self) -> Result<()> {
1310            self.calls.push("restart_optimizer".to_string());
1311            Ok(())
1312        }
1313        fn skip_batch(&mut self) -> Result<()> {
1314            self.calls.push("skip_batch".to_string());
1315            Ok(())
1316        }
1317        fn reset_weights(&mut self, layer_name: &str) -> Result<()> {
1318            self.calls.push(format!("reset_weights({layer_name})"));
1319            Ok(())
1320        }
1321        fn apply_weight_decay(&mut self, rate: f64) -> Result<()> {
1322            self.calls.push(format!("apply_weight_decay({rate})"));
1323            Ok(())
1324        }
1325        fn emergency_stop(&mut self) -> Result<()> {
1326            self.calls.push("emergency_stop".to_string());
1327            Ok(())
1328        }
1329    }
1330
1331    /// Regression test: with a real `TrainingControl` attached, a recovery
1332    /// action must actually be dispatched to it (with the real parameters
1333    /// from `determine_recovery_action`), and the attempt must be recorded
1334    /// as a real success -- this is the "real work" side of the fix.
1335    #[tokio::test]
1336    async fn test_auto_recovery_with_training_control_actually_dispatches() {
1337        let config = DebugConfig::default();
1338        let mut detector = AnomalyDetector::new(&config);
1339        detector.config.enable_auto_recovery = true;
1340        assert!(!detector.has_training_control());
1341        detector.set_training_control(Box::new(RecordingTrainingControl::default()));
1342        assert!(detector.has_training_control());
1343
1344        let anomaly = Anomaly {
1345            anomaly_type: AnomalyType::GradientExplosion,
1346            timestamp: Utc::now(),
1347            location: "test_layer".to_string(),
1348            description: "Test gradient explosion".to_string(),
1349            severity: AnomalySeverity::High,
1350            metadata: HashMap::new(),
1351        };
1352
1353        let action = detector.attempt_recovery(&anomaly).await.expect("recovery should not error");
1354        assert!(matches!(action, RecoveryAction::ClipGradients { max_norm } if max_norm == 1.0));
1355
1356        let attempt = &detector.get_recovery_attempts()[0];
1357        assert!(
1358            attempt.success,
1359            "a real, successful TrainingControl call must report success"
1360        );
1361        assert!(attempt.error_message.is_none());
1362    }
1363
1364    /// Regression test: when the attached `TrainingControl` itself fails,
1365    /// that must surface as a real, non-generic error message on the
1366    /// recorded attempt -- not the old hardcoded `"Recovery failed"`
1367    /// string, and not a silent `Ok(true)`.
1368    #[tokio::test]
1369    async fn test_auto_recovery_reports_real_error_from_training_control() {
1370        let config = DebugConfig::default();
1371        let mut detector = AnomalyDetector::new(&config);
1372        detector.config.enable_auto_recovery = true;
1373        detector.set_training_control(Box::new(RecordingTrainingControl {
1374            calls: Vec::new(),
1375            fail_next: true,
1376        }));
1377
1378        let anomaly = Anomaly {
1379            anomaly_type: AnomalyType::GradientExplosion,
1380            timestamp: Utc::now(),
1381            location: "test_layer".to_string(),
1382            description: "Test gradient explosion".to_string(),
1383            severity: AnomalySeverity::High,
1384            metadata: HashMap::new(),
1385        };
1386
1387        detector.attempt_recovery(&anomaly).await.expect("recovery should not error");
1388        let attempt = &detector.get_recovery_attempts()[0];
1389        assert!(!attempt.success);
1390        let message = attempt.error_message.as_deref().unwrap_or_default();
1391        assert!(
1392            message.contains("simulated clip_gradients failure"),
1393            "must surface the real underlying error, not a generic placeholder: {message}"
1394        );
1395        assert_ne!(
1396            message, "Recovery failed",
1397            "must not be the old generic placeholder string"
1398        );
1399    }
1400
1401    #[test]
1402    fn test_monitoring_stats() {
1403        let config = DebugConfig::default();
1404        let mut detector = AnomalyDetector::new(&config);
1405
1406        // Create some anomalies to generate stats
1407        detector.check_nan(&[f32::NAN], "test").expect("operation failed in test");
1408        detector.check_inf(&[f32::INFINITY], "test").expect("operation failed in test");
1409
1410        let stats = detector.get_monitoring_stats();
1411        assert_eq!(stats.total_anomalies, 2);
1412        assert!(stats.anomalies_per_type.contains_key("NaN"));
1413        assert!(stats.anomalies_per_type.contains_key("Infinity"));
1414    }
1415
1416    #[test]
1417    fn test_monitoring_window_update() {
1418        let config = DebugConfig::default();
1419        let mut detector = AnomalyDetector::new(&config);
1420
1421        detector.check_nan(&[f32::NAN], "test").expect("operation failed in test");
1422        detector.update_monitoring_window().expect("operation failed in test");
1423
1424        let stats = detector.get_monitoring_stats();
1425        assert_eq!(stats.monitoring_window.len(), 1);
1426        assert_eq!(stats.monitoring_window[0].anomaly_count, 1);
1427    }
1428}