1use crate::anomaly_detector::{Anomaly, AnomalySeverity};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, VecDeque};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GradientAnomalyDetector {
15 pub enabled: bool,
16 pub sensitivity: f64,
17 pub detection_window: usize,
18 pub anomaly_history: VecDeque<GradientAnomaly>,
19 pub baseline_statistics: HashMap<String, BaselineGradientStats>,
20}
21
22impl Default for GradientAnomalyDetector {
23 fn default() -> Self {
24 Self {
25 enabled: true,
26 sensitivity: 0.8,
27 detection_window: 50,
28 anomaly_history: VecDeque::with_capacity(1000),
29 baseline_statistics: HashMap::new(),
30 }
31 }
32}
33
34impl GradientAnomalyDetector {
35 pub fn new(sensitivity: f64, window_size: usize) -> Self {
36 Self {
37 enabled: true,
38 sensitivity,
39 detection_window: window_size,
40 anomaly_history: VecDeque::with_capacity(1000),
41 baseline_statistics: HashMap::new(),
42 }
43 }
44
45 pub fn establish_baseline(&mut self, layer_name: &str, gradient_history: &[f64]) {
46 if gradient_history.len() < 10 {
47 return;
48 }
49
50 let mean = gradient_history.iter().sum::<f64>() / gradient_history.len() as f64;
51 let variance = gradient_history.iter().map(|&x| (x - mean).powi(2)).sum::<f64>()
52 / gradient_history.len() as f64;
53 let std = variance.sqrt();
54
55 let mut sorted_values = gradient_history.to_vec();
56 sorted_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
57
58 let median_idx = sorted_values.len() / 2;
59 let median = if sorted_values.len().is_multiple_of(2) {
60 (sorted_values[median_idx - 1] + sorted_values[median_idx]) / 2.0
61 } else {
62 sorted_values[median_idx]
63 };
64
65 let percentile_5_idx = (sorted_values.len() as f64 * 0.05) as usize;
66 let percentile_95_idx = (sorted_values.len() as f64 * 0.95) as usize;
67
68 let baseline = BaselineGradientStats {
69 mean,
70 std,
71 median,
72 percentile_95: sorted_values[percentile_95_idx.min(sorted_values.len() - 1)],
73 percentile_5: sorted_values[percentile_5_idx],
74 samples: gradient_history.len(),
75 };
76
77 self.baseline_statistics.insert(layer_name.to_string(), baseline);
78 }
79
80 pub fn detect_anomalies(
81 &mut self,
82 layer_name: &str,
83 gradient_norm: f64,
84 step: usize,
85 ) -> Vec<GradientAnomaly> {
86 if !self.enabled {
87 return Vec::new();
88 }
89
90 let baseline = match self.baseline_statistics.get(layer_name) {
91 Some(baseline) => baseline,
92 None => return Vec::new(), };
94
95 let mut anomalies = Vec::new();
96
97 if let Some(anomaly) =
99 self.detect_statistical_anomaly(layer_name, gradient_norm, step, baseline)
100 {
101 anomalies.push(anomaly);
102 }
103
104 if let Some(anomaly) = self.detect_pattern_anomaly(layer_name, gradient_norm, step) {
106 anomalies.push(anomaly);
107 }
108
109 for anomaly in &anomalies {
111 if self.anomaly_history.len() >= 1000 {
112 self.anomaly_history.pop_front();
113 }
114 self.anomaly_history.push_back(anomaly.clone());
115 }
116
117 anomalies
118 }
119
120 fn detect_statistical_anomaly(
121 &self,
122 layer_name: &str,
123 gradient_norm: f64,
124 step: usize,
125 baseline: &BaselineGradientStats,
126 ) -> Option<GradientAnomaly> {
127 let z_score = (gradient_norm - baseline.mean) / baseline.std;
128 let threshold = 2.0 + (1.0 - self.sensitivity) * 2.0; if z_score.abs() > threshold {
131 let anomaly_type = if z_score > 0.0 {
132 if z_score > threshold * 1.5 {
133 AnomalyType::SuddenSpike
134 } else {
135 AnomalyType::SuddenSpike
136 }
137 } else {
138 AnomalyType::SuddenDrop
139 };
140
141 let severity = (z_score.abs() / threshold).min(1.0);
142
143 Some(GradientAnomaly {
144 layer_name: layer_name.to_string(),
145 anomaly_type,
146 severity,
147 timestamp: Utc::now(),
148 context: AnomalyContext {
149 step,
150 gradient_norm,
151 expected_range: (baseline.percentile_5, baseline.percentile_95),
152 deviation_magnitude: z_score.abs(),
153 },
154 })
155 } else {
156 None
157 }
158 }
159
160 fn expected_range_for(
178 &self,
179 layer_name: &str,
180 recent_anomalies: &[&GradientAnomaly],
181 ) -> (f64, f64) {
182 if let Some(baseline) = self.baseline_statistics.get(layer_name) {
183 return (baseline.percentile_5, baseline.percentile_95);
184 }
185 let norms: Vec<f64> = recent_anomalies.iter().map(|a| a.context.gradient_norm).collect();
186 match (
187 norms.iter().cloned().fold(f64::INFINITY, f64::min),
188 norms.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
189 ) {
190 (lo, hi) if lo.is_finite() && hi.is_finite() => (lo, hi),
191 _ => (0.0, 0.0),
192 }
193 }
194
195 fn detect_pattern_anomaly(
196 &self,
197 layer_name: &str,
198 gradient_norm: f64,
199 step: usize,
200 ) -> Option<GradientAnomaly> {
201 let recent_anomalies: Vec<&GradientAnomaly> = self
203 .anomaly_history
204 .iter()
205 .filter(|a| a.layer_name == layer_name)
206 .rev()
207 .take(10)
208 .collect();
209
210 if recent_anomalies.len() >= 3 {
211 let oscillation_count = recent_anomalies
213 .windows(2)
214 .filter(|pair| {
215 matches!(
216 (&pair[0].anomaly_type, &pair[1].anomaly_type),
217 (AnomalyType::SuddenSpike, AnomalyType::SuddenDrop)
218 | (AnomalyType::SuddenDrop, AnomalyType::SuddenSpike)
219 )
220 })
221 .count();
222
223 if oscillation_count >= 2 {
224 return Some(GradientAnomaly {
225 layer_name: layer_name.to_string(),
226 anomaly_type: AnomalyType::Oscillation,
227 severity: 0.7,
228 timestamp: Utc::now(),
229 context: AnomalyContext {
230 step,
231 gradient_norm,
232 expected_range: self.expected_range_for(layer_name, &recent_anomalies),
233 deviation_magnitude: oscillation_count as f64,
234 },
235 });
236 }
237 }
238
239 if recent_anomalies.len() >= 5 {
241 let all_similar = recent_anomalies.windows(2).all(|pair| {
242 (pair[0].context.gradient_norm - pair[1].context.gradient_norm).abs() < 1e-6
243 });
244
245 if all_similar {
246 return Some(GradientAnomaly {
247 layer_name: layer_name.to_string(),
248 anomaly_type: AnomalyType::Stagnation,
249 severity: 0.8,
250 timestamp: Utc::now(),
251 context: AnomalyContext {
252 step,
253 gradient_norm,
254 expected_range: self.expected_range_for(layer_name, &recent_anomalies),
255 deviation_magnitude: 0.0,
256 },
257 });
258 }
259 }
260
261 None
262 }
263
264 pub fn get_anomaly_summary(&self, layer_name: Option<&str>) -> AnomalySummary {
265 let filtered_anomalies: Vec<&GradientAnomaly> = match layer_name {
266 Some(name) => self.anomaly_history.iter().filter(|a| a.layer_name == name).collect(),
267 None => self.anomaly_history.iter().collect(),
268 };
269
270 let total_anomalies = filtered_anomalies.len();
271 let mut anomaly_type_counts = HashMap::new();
272 let mut severity_sum = 0.0;
273
274 for anomaly in &filtered_anomalies {
275 *anomaly_type_counts.entry(anomaly.anomaly_type.clone()).or_insert(0) += 1;
276 severity_sum += anomaly.severity;
277 }
278
279 let average_severity =
280 if total_anomalies > 0 { severity_sum / total_anomalies as f64 } else { 0.0 };
281
282 let anomalies: Vec<Anomaly> = filtered_anomalies
284 .iter()
285 .map(|gradient_anomaly| {
286 let severity = if gradient_anomaly.severity >= 0.8 {
287 AnomalySeverity::Critical
288 } else if gradient_anomaly.severity >= 0.6 {
289 AnomalySeverity::High
290 } else if gradient_anomaly.severity >= 0.3 {
291 AnomalySeverity::Medium
292 } else {
293 AnomalySeverity::Low
294 };
295
296 let general_anomaly_type = match gradient_anomaly.anomaly_type {
298 AnomalyType::SuddenSpike => {
299 crate::anomaly_detector::AnomalyType::GradientExplosion
300 },
301 AnomalyType::SuddenDrop => {
302 crate::anomaly_detector::AnomalyType::GradientVanishing
303 },
304 AnomalyType::Oscillation => {
305 crate::anomaly_detector::AnomalyType::NumericalInstability
306 },
307 AnomalyType::Stagnation => {
308 crate::anomaly_detector::AnomalyType::GradientVanishing
309 },
310 AnomalyType::Chaos => {
311 crate::anomaly_detector::AnomalyType::NumericalInstability
312 },
313 };
314
315 let description = format!(
316 "Gradient anomaly of type {:?} detected with severity {:.2}",
317 gradient_anomaly.anomaly_type, gradient_anomaly.severity
318 );
319
320 let mut metadata = HashMap::new();
321 metadata.insert(
322 "step".to_string(),
323 gradient_anomaly.context.step.to_string(),
324 );
325 metadata.insert(
326 "gradient_norm".to_string(),
327 gradient_anomaly.context.gradient_norm.to_string(),
328 );
329 metadata.insert(
330 "expected_range_min".to_string(),
331 gradient_anomaly.context.expected_range.0.to_string(),
332 );
333 metadata.insert(
334 "expected_range_max".to_string(),
335 gradient_anomaly.context.expected_range.1.to_string(),
336 );
337 metadata.insert(
338 "deviation_magnitude".to_string(),
339 gradient_anomaly.context.deviation_magnitude.to_string(),
340 );
341 metadata.insert(
342 "original_anomaly_type".to_string(),
343 format!("{:?}", gradient_anomaly.anomaly_type),
344 );
345
346 Anomaly {
347 anomaly_type: general_anomaly_type,
348 timestamp: gradient_anomaly.timestamp,
349 location: gradient_anomaly.layer_name.clone(),
350 description,
351 severity,
352 metadata,
353 }
354 })
355 .collect();
356
357 AnomalySummary {
358 layer_name: layer_name.map(|s| s.to_string()),
359 total_anomalies,
360 anomaly_type_counts,
361 average_severity,
362 recent_trend: self.analyze_recent_trend(&filtered_anomalies),
363 recommendations: self.generate_anomaly_recommendations(&filtered_anomalies),
364 anomalies,
365 }
366 }
367
368 fn analyze_recent_trend(&self, anomalies: &[&GradientAnomaly]) -> AnomalyTrend {
369 if anomalies.len() < 5 {
370 return AnomalyTrend::Stable;
371 }
372
373 let recent_anomalies: Vec<&GradientAnomaly> =
374 anomalies.iter().rev().take(10).cloned().collect();
375 let older_anomalies: Vec<&GradientAnomaly> =
376 anomalies.iter().rev().skip(10).take(10).cloned().collect();
377
378 if older_anomalies.is_empty() {
379 return AnomalyTrend::Stable;
380 }
381
382 let recent_avg_severity: f64 = recent_anomalies.iter().map(|a| a.severity).sum::<f64>()
383 / recent_anomalies.len() as f64;
384 let older_avg_severity: f64 =
385 older_anomalies.iter().map(|a| a.severity).sum::<f64>() / older_anomalies.len() as f64;
386
387 let trend_threshold = 0.1;
388 if recent_avg_severity > older_avg_severity + trend_threshold {
389 AnomalyTrend::Increasing
390 } else if recent_avg_severity < older_avg_severity - trend_threshold {
391 AnomalyTrend::Decreasing
392 } else {
393 AnomalyTrend::Stable
394 }
395 }
396
397 fn generate_anomaly_recommendations(&self, anomalies: &[&GradientAnomaly]) -> Vec<String> {
398 let mut recommendations = Vec::new();
399
400 let spike_count = anomalies
401 .iter()
402 .filter(|a| matches!(a.anomaly_type, AnomalyType::SuddenSpike))
403 .count();
404 let drop_count = anomalies
405 .iter()
406 .filter(|a| matches!(a.anomaly_type, AnomalyType::SuddenDrop))
407 .count();
408 let oscillation_count = anomalies
409 .iter()
410 .filter(|a| matches!(a.anomaly_type, AnomalyType::Oscillation))
411 .count();
412 let stagnation_count = anomalies
413 .iter()
414 .filter(|a| matches!(a.anomaly_type, AnomalyType::Stagnation))
415 .count();
416
417 if spike_count > 3 {
418 recommendations
419 .push("Consider reducing learning rate to prevent gradient explosion".to_string());
420 recommendations.push("Add gradient clipping to stabilize training".to_string());
421 }
422
423 if drop_count > 3 {
424 recommendations.push("Check for vanishing gradient issues".to_string());
425 recommendations
426 .push("Consider using residual connections or better initialization".to_string());
427 }
428
429 if oscillation_count > 2 {
430 recommendations.push("Reduce learning rate to dampen oscillations".to_string());
431 recommendations
432 .push("Consider using momentum or adaptive learning rate methods".to_string());
433 }
434
435 if stagnation_count > 2 {
436 recommendations.push(
437 "Learning may have plateaued - consider learning rate scheduling".to_string(),
438 );
439 recommendations
440 .push("Check for potential convergence or training data issues".to_string());
441 }
442
443 if recommendations.is_empty() {
444 recommendations.push("Gradient behavior appears normal".to_string());
445 }
446
447 recommendations
448 }
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct GradientAnomaly {
454 pub layer_name: String,
455 pub anomaly_type: AnomalyType,
456 pub severity: f64,
457 pub timestamp: DateTime<Utc>,
458 pub context: AnomalyContext,
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
463pub enum AnomalyType {
464 SuddenSpike,
465 SuddenDrop,
466 Oscillation,
467 Stagnation,
468 Chaos,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct AnomalyContext {
474 pub step: usize,
475 pub gradient_norm: f64,
476 pub expected_range: (f64, f64),
477 pub deviation_magnitude: f64,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct BaselineGradientStats {
483 pub mean: f64,
484 pub std: f64,
485 pub median: f64,
486 pub percentile_95: f64,
487 pub percentile_5: f64,
488 pub samples: usize,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct AnomalySummary {
494 pub layer_name: Option<String>,
495 pub total_anomalies: usize,
496 pub anomaly_type_counts: HashMap<AnomalyType, usize>,
497 pub average_severity: f64,
498 pub recent_trend: AnomalyTrend,
499 pub recommendations: Vec<String>,
500 pub anomalies: Vec<Anomaly>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub enum AnomalyTrend {
506 Increasing,
507 Stable,
508 Decreasing,
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn make_anomaly(layer: &str, anomaly_type: AnomalyType, norm: f64) -> GradientAnomaly {
516 GradientAnomaly {
517 layer_name: layer.to_string(),
518 anomaly_type,
519 severity: 0.5,
520 timestamp: Utc::now(),
521 context: AnomalyContext {
522 step: 0,
523 gradient_norm: norm,
524 expected_range: (0.0, 0.0),
525 deviation_magnitude: 0.0,
526 },
527 }
528 }
529
530 #[test]
531 fn test_oscillation_uses_real_baseline_expected_range_not_placeholder() {
532 let mut detector = GradientAnomalyDetector::new(0.8, 50);
533 let history: Vec<f64> = (0..20).map(|i| 100.0 + (i as f64 % 3.0)).collect();
536 detector.establish_baseline("layer0", &history);
537 let baseline = detector.baseline_statistics.get("layer0").cloned().expect("established");
538
539 detector
543 .anomaly_history
544 .push_back(make_anomaly("layer0", AnomalyType::SuddenSpike, 500.0));
545 detector
546 .anomaly_history
547 .push_back(make_anomaly("layer0", AnomalyType::SuddenDrop, 1.0));
548 detector
549 .anomaly_history
550 .push_back(make_anomaly("layer0", AnomalyType::SuddenSpike, 500.0));
551
552 let anomaly = detector
553 .detect_pattern_anomaly("layer0", 500.0, 99)
554 .expect("a 3-entry Spike/Drop/Spike history must trigger an oscillation anomaly");
555 assert!(matches!(anomaly.anomaly_type, AnomalyType::Oscillation));
556 assert_eq!(
557 anomaly.context.expected_range,
558 (baseline.percentile_5, baseline.percentile_95),
559 "expected_range must be the REAL baseline band, not the old hardcoded (0.0, 1.0)"
560 );
561 assert_ne!(anomaly.context.expected_range, (0.0, 1.0));
562 }
563
564 #[test]
565 fn test_stagnation_uses_real_baseline_expected_range_not_placeholder() {
566 let mut detector = GradientAnomalyDetector::new(0.8, 50);
567 let history: Vec<f64> = (0..20).map(|i| 100.0 + (i as f64 % 3.0)).collect();
568 detector.establish_baseline("layer0", &history);
569 let baseline = detector.baseline_statistics.get("layer0").cloned().expect("established");
570
571 for _ in 0..5 {
575 detector.anomaly_history.push_back(make_anomaly(
576 "layer0",
577 AnomalyType::SuddenDrop,
578 42.0,
579 ));
580 }
581
582 let anomaly = detector
583 .detect_pattern_anomaly("layer0", 42.0, 99)
584 .expect("5 near-identical recent norms must trigger a stagnation anomaly");
585 assert!(matches!(anomaly.anomaly_type, AnomalyType::Stagnation));
586 assert_eq!(
587 anomaly.context.expected_range,
588 (baseline.percentile_5, baseline.percentile_95),
589 "expected_range must be the REAL baseline band, not the old hardcoded (0.0, 1.0)"
590 );
591 assert_ne!(anomaly.context.expected_range, (0.0, 1.0));
592 }
593
594 #[test]
595 fn test_expected_range_for_falls_back_to_observed_bounds_without_baseline() {
596 let detector = GradientAnomalyDetector::new(0.8, 50);
602 let a1 = make_anomaly("orphan", AnomalyType::SuddenSpike, 5.0);
603 let a2 = make_anomaly("orphan", AnomalyType::SuddenDrop, 1.0);
604 let range = detector.expected_range_for("orphan", &[&a1, &a2]);
605 assert_eq!(range, (1.0, 5.0));
606 }
607
608 #[test]
609 fn test_detect_anomalies_end_to_end_publishes_real_expected_range() {
610 let mut detector = GradientAnomalyDetector::new(0.8, 50);
614 let history: Vec<f64> = (0..20).map(|i| 10.0 + (i as f64 % 2.0)).collect();
615 detector.establish_baseline("layer0", &history);
616 let baseline = detector.baseline_statistics.get("layer0").cloned().expect("established");
617
618 let mut last_oscillation = None;
619 for (step, norm) in [500.0, 0.001, 500.0, 0.001].into_iter().enumerate() {
620 for anomaly in detector.detect_anomalies("layer0", norm, step) {
621 if matches!(anomaly.anomaly_type, AnomalyType::Oscillation) {
622 last_oscillation = Some(anomaly);
623 }
624 }
625 }
626
627 let oscillation = last_oscillation.expect(
628 "alternating far-above/far-below-baseline norms must \
629 eventually trigger a real oscillation anomaly through the public API",
630 );
631 assert_eq!(
632 oscillation.context.expected_range,
633 (baseline.percentile_5, baseline.percentile_95)
634 );
635 assert_ne!(oscillation.context.expected_range, (0.0, 1.0));
636 }
637}