Skip to main content

trustformers_debug/regression/
detector.rs

1//! Automated performance regression detection.
2//!
3//! Compares current profiling measurements against a historical baseline and
4//! produces severity-graded alerts together with actionable recommendations.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11// ============================================================================
12// Data types
13// ============================================================================
14
15/// A single performance measurement (latency, memory, throughput) at a point in time.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PerfMeasurement {
18    /// Component or layer name, e.g. `"attention_forward"`.
19    pub name: String,
20    /// Wall-clock latency in milliseconds.
21    pub latency_ms: f64,
22    /// Peak memory allocated in megabytes.
23    pub memory_mb: f64,
24    /// Throughput expressed as tokens/sec or samples/sec.
25    pub throughput: f64,
26    /// Unix timestamp (seconds) of when this measurement was taken.
27    pub timestamp_secs: u64,
28}
29
30/// A historical baseline used as the reference for regression comparison.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct PerfBaseline {
33    /// All raw measurements that make up this baseline.
34    pub measurements: Vec<PerfMeasurement>,
35    /// Unix timestamp (seconds) when this baseline was captured.
36    pub created_at: u64,
37    /// Human-readable description, e.g. `"v0.1.0 release candidate"`.
38    pub description: String,
39}
40
41impl PerfBaseline {
42    /// Compute per-metric statistics for a named component. Returns `None` when
43    /// fewer than two data points are present (not enough to compute std-dev).
44    pub fn stats_for(&self, name: &str) -> Option<BaselineStats> {
45        let latencies: Vec<f64> = self
46            .measurements
47            .iter()
48            .filter(|m| m.name == name)
49            .map(|m| m.latency_ms)
50            .collect();
51
52        if latencies.len() < 2 {
53            return None;
54        }
55
56        let mean = latencies.iter().sum::<f64>() / latencies.len() as f64;
57        let variance = latencies.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
58            / (latencies.len() - 1) as f64;
59        let std = variance.sqrt();
60
61        let mut sorted = latencies.clone();
62        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
63
64        let p95 = percentile(&sorted, 95.0);
65        let p99 = percentile(&sorted, 99.0);
66
67        Some(BaselineStats {
68            mean_latency_ms: mean,
69            std_latency_ms: std,
70            p95_latency_ms: p95,
71            p99_latency_ms: p99,
72        })
73    }
74}
75
76/// Statistical summary of baseline latency for a single component.
77#[derive(Debug, Clone)]
78pub struct BaselineStats {
79    pub mean_latency_ms: f64,
80    pub std_latency_ms: f64,
81    pub p95_latency_ms: f64,
82    pub p99_latency_ms: f64,
83}
84
85// ============================================================================
86// Regression alerts
87// ============================================================================
88
89/// Which performance metric regressed.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub enum RegressionMetric {
92    Latency,
93    Memory,
94    Throughput,
95}
96
97/// How severe the regression is.
98#[derive(Debug, Clone, PartialEq, PartialOrd, Serialize, Deserialize)]
99pub enum RegressionSeverity {
100    /// 5–10 % regression.
101    Minor,
102    /// 10–25 % regression.
103    Moderate,
104    /// 25–50 % regression.
105    Severe,
106    /// > 50 % regression.
107    Critical,
108}
109
110impl RegressionSeverity {
111    /// Derive severity from an absolute regression percentage value.
112    pub fn from_pct(pct: f64) -> Self {
113        match pct.abs() as u64 {
114            0..=10 => Self::Minor,
115            11..=25 => Self::Moderate,
116            26..=50 => Self::Severe,
117            _ => Self::Critical,
118        }
119    }
120}
121
122/// A single regression alert.
123#[derive(Debug, Clone)]
124pub struct RegressionAlert {
125    /// Component name.
126    pub name: String,
127    /// Which metric regressed.
128    pub metric: RegressionMetric,
129    /// Derived severity class.
130    pub severity: RegressionSeverity,
131    /// Reference value from the baseline.
132    pub baseline_value: f64,
133    /// Current (measured) value.
134    pub current_value: f64,
135    /// Signed percentage change: positive = worse for latency/memory, negative = worse for throughput.
136    pub regression_pct: f64,
137    /// Human-readable summary.
138    pub message: String,
139}
140
141// ============================================================================
142// Configuration
143// ============================================================================
144
145/// Configuration knobs for the regression detector.
146#[derive(Debug, Clone)]
147pub struct RegressionConfig {
148    /// Minimum absolute % change to report (default: 5.0).
149    pub min_regression_pct: f64,
150    /// Z-score threshold for statistical significance (default: 2.0).
151    pub z_score_threshold: f64,
152    /// Monitor latency regressions (default: true).
153    pub monitor_latency: bool,
154    /// Monitor memory regressions (default: true).
155    pub monitor_memory: bool,
156    /// Monitor throughput regressions (default: true).
157    pub monitor_throughput: bool,
158}
159
160impl Default for RegressionConfig {
161    fn default() -> Self {
162        Self {
163            min_regression_pct: 5.0,
164            z_score_threshold: 2.0,
165            monitor_latency: true,
166            monitor_memory: true,
167            monitor_throughput: true,
168        }
169    }
170}
171
172// ============================================================================
173// Detector
174// ============================================================================
175
176/// Compares profiling measurements against a stored baseline and emits alerts.
177pub struct RegressionDetector {
178    config: RegressionConfig,
179}
180
181impl RegressionDetector {
182    pub fn new(config: RegressionConfig) -> Self {
183        Self { config }
184    }
185
186    /// Detect regressions by comparing `current` measurements to the `baseline`.
187    ///
188    /// For each named component that appears in both sets the detector computes:
189    /// * mean current value vs. baseline mean
190    /// * optional z-score significance check (when baseline has ≥ 2 points)
191    ///
192    /// Returns every alert whose absolute regression percentage exceeds
193    /// `config.min_regression_pct`.
194    pub fn detect(
195        &self,
196        current: &[PerfMeasurement],
197        baseline: &PerfBaseline,
198    ) -> Vec<RegressionAlert> {
199        // Group current measurements by name.
200        let mut current_by_name: HashMap<&str, Vec<&PerfMeasurement>> = HashMap::new();
201        for m in current {
202            current_by_name.entry(m.name.as_str()).or_default().push(m);
203        }
204
205        let mut alerts = Vec::new();
206
207        for (name, measurements) in &current_by_name {
208            // Baseline aggregate for this component.
209            let baseline_measurements: Vec<&PerfMeasurement> =
210                baseline.measurements.iter().filter(|m| m.name == *name).collect();
211
212            if baseline_measurements.is_empty() {
213                continue;
214            }
215
216            let current_mean_latency =
217                measurements.iter().map(|m| m.latency_ms).sum::<f64>() / measurements.len() as f64;
218            let current_mean_memory =
219                measurements.iter().map(|m| m.memory_mb).sum::<f64>() / measurements.len() as f64;
220            let current_mean_throughput =
221                measurements.iter().map(|m| m.throughput).sum::<f64>() / measurements.len() as f64;
222
223            let baseline_mean_latency =
224                baseline_measurements.iter().map(|m| m.latency_ms).sum::<f64>()
225                    / baseline_measurements.len() as f64;
226            let baseline_mean_memory =
227                baseline_measurements.iter().map(|m| m.memory_mb).sum::<f64>()
228                    / baseline_measurements.len() as f64;
229            let baseline_mean_throughput =
230                baseline_measurements.iter().map(|m| m.throughput).sum::<f64>()
231                    / baseline_measurements.len() as f64;
232
233            let stats = baseline.stats_for(name);
234
235            // --- latency (higher is worse) ---
236            if self.config.monitor_latency && baseline_mean_latency > 0.0 {
237                let pct =
238                    (current_mean_latency - baseline_mean_latency) / baseline_mean_latency * 100.0;
239                if pct > self.config.min_regression_pct
240                    && self.is_significant(
241                        current_mean_latency,
242                        baseline_mean_latency,
243                        stats.as_ref().map(|s| s.std_latency_ms),
244                    )
245                {
246                    let severity = RegressionSeverity::from_pct(pct);
247                    alerts.push(RegressionAlert {
248                        name: name.to_string(),
249                        metric: RegressionMetric::Latency,
250                        severity: severity.clone(),
251                        baseline_value: baseline_mean_latency,
252                        current_value: current_mean_latency,
253                        regression_pct: pct,
254                        message: format!(
255                            "[{name}] Latency regressed by {pct:.1}% ({baseline_mean_latency:.2}ms → {current_mean_latency:.2}ms) — severity: {severity:?}",
256                        ),
257                    });
258                }
259            }
260
261            // --- memory (higher is worse) ---
262            if self.config.monitor_memory && baseline_mean_memory > 0.0 {
263                let pct =
264                    (current_mean_memory - baseline_mean_memory) / baseline_mean_memory * 100.0;
265                if pct > self.config.min_regression_pct {
266                    let severity = RegressionSeverity::from_pct(pct);
267                    alerts.push(RegressionAlert {
268                        name: name.to_string(),
269                        metric: RegressionMetric::Memory,
270                        severity: severity.clone(),
271                        baseline_value: baseline_mean_memory,
272                        current_value: current_mean_memory,
273                        regression_pct: pct,
274                        message: format!(
275                            "[{name}] Memory regressed by {pct:.1}% ({baseline_mean_memory:.1}MB → {current_mean_memory:.1}MB) — severity: {severity:?}",
276                        ),
277                    });
278                }
279            }
280
281            // --- throughput (lower is worse) ---
282            if self.config.monitor_throughput && baseline_mean_throughput > 0.0 {
283                let pct = (baseline_mean_throughput - current_mean_throughput)
284                    / baseline_mean_throughput
285                    * 100.0;
286                if pct > self.config.min_regression_pct {
287                    let severity = RegressionSeverity::from_pct(pct);
288                    alerts.push(RegressionAlert {
289                        name: name.to_string(),
290                        metric: RegressionMetric::Throughput,
291                        severity: severity.clone(),
292                        baseline_value: baseline_mean_throughput,
293                        current_value: current_mean_throughput,
294                        regression_pct: pct,
295                        message: format!(
296                            "[{name}] Throughput dropped by {pct:.1}% ({baseline_mean_throughput:.1} → {current_mean_throughput:.1} tok/s) — severity: {severity:?}",
297                        ),
298                    });
299                }
300            }
301        }
302
303        // Sort by severity (worst first) so consumers see critical issues first.
304        alerts.sort_by(|a, b| {
305            b.severity.partial_cmp(&a.severity).unwrap_or(std::cmp::Ordering::Equal)
306        });
307        alerts
308    }
309
310    /// Render alerts as a human-readable text report.
311    pub fn report(&self, alerts: &[RegressionAlert]) -> String {
312        if alerts.is_empty() {
313            return "No regressions detected — all metrics within acceptable range.\n".to_string();
314        }
315
316        let mut out = String::new();
317        out.push_str("=== Performance Regression Report ===\n\n");
318        out.push_str(&format!("Total alerts: {}\n\n", alerts.len()));
319
320        for (i, alert) in alerts.iter().enumerate() {
321            out.push_str(&format!("{}. {}\n", i + 1, alert.message));
322        }
323
324        out.push('\n');
325        out.push_str(&format!(
326            "Critical: {}  Severe: {}  Moderate: {}  Minor: {}\n",
327            alerts.iter().filter(|a| a.severity == RegressionSeverity::Critical).count(),
328            alerts.iter().filter(|a| a.severity == RegressionSeverity::Severe).count(),
329            alerts.iter().filter(|a| a.severity == RegressionSeverity::Moderate).count(),
330            alerts.iter().filter(|a| a.severity == RegressionSeverity::Minor).count(),
331        ));
332        out
333    }
334
335    /// Persist a `PerfBaseline` as JSON to `path`.
336    pub fn save_baseline(&self, baseline: &PerfBaseline, path: &Path) -> Result<()> {
337        let json =
338            serde_json::to_string_pretty(baseline).context("failed to serialize baseline")?;
339        if let Some(parent) = path.parent() {
340            std::fs::create_dir_all(parent).with_context(|| {
341                format!("failed to create baseline directory: {}", parent.display())
342            })?;
343        }
344        std::fs::write(path, json)
345            .with_context(|| format!("failed to write baseline: {}", path.display()))?;
346        tracing::info!(path = %path.display(), "saved performance baseline");
347        Ok(())
348    }
349
350    /// Load a `PerfBaseline` from a JSON file.
351    pub fn load_baseline(&self, path: &Path) -> Result<PerfBaseline> {
352        let content = std::fs::read_to_string(path)
353            .with_context(|| format!("failed to read baseline: {}", path.display()))?;
354        let baseline: PerfBaseline =
355            serde_json::from_str(&content).context("failed to deserialize baseline")?;
356        Ok(baseline)
357    }
358
359    /// Build a new `PerfBaseline` from a set of measurements.
360    pub fn create_baseline(measurements: Vec<PerfMeasurement>, description: &str) -> PerfBaseline {
361        let created_at = std::time::SystemTime::now()
362            .duration_since(std::time::UNIX_EPOCH)
363            .map(|d| d.as_secs())
364            .unwrap_or(0);
365
366        PerfBaseline {
367            measurements,
368            created_at,
369            description: description.to_string(),
370        }
371    }
372
373    /// Generate actionable recommendations for a list of alerts.
374    pub fn recommendations(&self, alerts: &[RegressionAlert]) -> Vec<String> {
375        let mut recs: Vec<String> = Vec::new();
376
377        let has_latency = alerts.iter().any(|a| a.metric == RegressionMetric::Latency);
378        let has_memory = alerts.iter().any(|a| a.metric == RegressionMetric::Memory);
379        let has_throughput = alerts.iter().any(|a| a.metric == RegressionMetric::Throughput);
380        let has_critical = alerts.iter().any(|a| a.severity == RegressionSeverity::Critical);
381        let has_severe = alerts.iter().any(|a| a.severity == RegressionSeverity::Severe);
382
383        if has_latency {
384            recs.push("Profile forward/backward passes to identify new hot-spots (use flame_graph_profiler).".to_string());
385            recs.push(
386                "Check for inadvertent Python or C FFI call sites introduced in recent commits."
387                    .to_string(),
388            );
389            recs.push("Consider operator fusion or kernel-level optimisations for attention / FFN layers.".to_string());
390        }
391        if has_memory {
392            recs.push(
393                "Audit tensor lifetime to ensure activations are freed promptly after use."
394                    .to_string(),
395            );
396            recs.push(
397                "Enable gradient checkpointing or reduce batch size to stay within memory budget."
398                    .to_string(),
399            );
400            recs.push("Use memory_profiler to locate the largest allocations.".to_string());
401        }
402        if has_throughput {
403            recs.push(
404                "Check data-loading pipeline: a slow DataLoader can mask compute regressions."
405                    .to_string(),
406            );
407            recs.push(
408                "Investigate whether new ops are preventing Tensor-Core utilisation.".to_string(),
409            );
410        }
411        if has_critical || has_severe {
412            recs.push("URGENT: consider reverting the most recent change and bisecting to isolate the regression.".to_string());
413        }
414
415        if recs.is_empty() {
416            recs.push(
417                "No actionable recommendations — regressions are within acceptable bounds."
418                    .to_string(),
419            );
420        }
421
422        recs
423    }
424
425    // ---------- helpers ----------
426
427    /// True when the observed deviation is statistically significant (z-score > threshold).
428    /// Falls back to `true` when no std-dev is available (always report the regression).
429    fn is_significant(&self, current: f64, baseline_mean: f64, baseline_std: Option<f64>) -> bool {
430        match baseline_std {
431            Some(std) if std > 0.0 => {
432                let z = (current - baseline_mean).abs() / std;
433                z > self.config.z_score_threshold
434            },
435            _ => true,
436        }
437    }
438}
439
440// ============================================================================
441// Internal helpers
442// ============================================================================
443
444fn percentile(sorted: &[f64], p: f64) -> f64 {
445    if sorted.is_empty() {
446        return 0.0;
447    }
448    let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize;
449    sorted[idx.min(sorted.len() - 1)]
450}
451
452// ============================================================================
453// Tests
454// ============================================================================
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::env::temp_dir;
460
461    fn make_measurement(
462        name: &str,
463        latency_ms: f64,
464        memory_mb: f64,
465        throughput: f64,
466    ) -> PerfMeasurement {
467        PerfMeasurement {
468            name: name.to_string(),
469            latency_ms,
470            memory_mb,
471            throughput,
472            timestamp_secs: 0,
473        }
474    }
475
476    fn make_baseline(measurements: Vec<PerfMeasurement>) -> PerfBaseline {
477        PerfBaseline {
478            measurements,
479            created_at: 0,
480            description: "test baseline".to_string(),
481        }
482    }
483
484    #[test]
485    fn test_no_regression_when_within_threshold() {
486        let detector = RegressionDetector::new(RegressionConfig::default());
487        let baseline = make_baseline(vec![make_measurement("attn", 10.0, 100.0, 1000.0)]);
488        let current = vec![make_measurement("attn", 10.2, 101.0, 998.0)]; // < 5%
489        let alerts = detector.detect(&current, &baseline);
490        assert!(alerts.is_empty(), "should not alert on < 5% changes");
491    }
492
493    #[test]
494    fn test_latency_regression_detected() {
495        let config = RegressionConfig {
496            monitor_memory: false,
497            monitor_throughput: false,
498            ..Default::default()
499        };
500        let detector = RegressionDetector::new(config);
501        let baseline = make_baseline(vec![
502            make_measurement("attn", 10.0, 0.0, 0.0),
503            make_measurement("attn", 10.0, 0.0, 0.0),
504        ]);
505        let current = vec![make_measurement("attn", 15.0, 0.0, 0.0)]; // 50% regression
506        let alerts = detector.detect(&current, &baseline);
507        assert!(!alerts.is_empty());
508        assert_eq!(alerts[0].metric, RegressionMetric::Latency);
509        assert_eq!(alerts[0].severity, RegressionSeverity::Severe);
510    }
511
512    #[test]
513    fn test_memory_regression_detected() {
514        let config = RegressionConfig {
515            monitor_latency: false,
516            monitor_throughput: false,
517            ..Default::default()
518        };
519        let detector = RegressionDetector::new(config);
520        let baseline = make_baseline(vec![make_measurement("ffn", 0.0, 100.0, 0.0)]);
521        let current = vec![make_measurement("ffn", 0.0, 150.0, 0.0)]; // 50%
522        let alerts = detector.detect(&current, &baseline);
523        assert!(!alerts.is_empty());
524        assert_eq!(alerts[0].metric, RegressionMetric::Memory);
525    }
526
527    #[test]
528    fn test_throughput_regression_detected() {
529        let config = RegressionConfig {
530            monitor_latency: false,
531            monitor_memory: false,
532            ..Default::default()
533        };
534        let detector = RegressionDetector::new(config);
535        let baseline = make_baseline(vec![make_measurement("decode", 0.0, 0.0, 1000.0)]);
536        let current = vec![make_measurement("decode", 0.0, 0.0, 600.0)]; // 40%
537        let alerts = detector.detect(&current, &baseline);
538        assert!(!alerts.is_empty());
539        assert_eq!(alerts[0].metric, RegressionMetric::Throughput);
540    }
541
542    #[test]
543    fn test_severity_from_pct() {
544        assert_eq!(RegressionSeverity::from_pct(7.0), RegressionSeverity::Minor);
545        assert_eq!(
546            RegressionSeverity::from_pct(15.0),
547            RegressionSeverity::Moderate
548        );
549        assert_eq!(
550            RegressionSeverity::from_pct(35.0),
551            RegressionSeverity::Severe
552        );
553        assert_eq!(
554            RegressionSeverity::from_pct(75.0),
555            RegressionSeverity::Critical
556        );
557    }
558
559    #[test]
560    fn test_report_no_alerts() {
561        let detector = RegressionDetector::new(RegressionConfig::default());
562        let report = detector.report(&[]);
563        assert!(report.contains("No regressions detected"));
564    }
565
566    #[test]
567    fn test_save_and_load_baseline() -> Result<()> {
568        let detector = RegressionDetector::new(RegressionConfig::default());
569        let baseline = RegressionDetector::create_baseline(
570            vec![make_measurement("attn", 10.0, 100.0, 500.0)],
571            "test baseline v1",
572        );
573        let path = temp_dir().join(format!("baseline_{}.json", uuid::Uuid::new_v4()));
574        detector.save_baseline(&baseline, &path)?;
575        let loaded = detector.load_baseline(&path)?;
576        assert_eq!(loaded.description, "test baseline v1");
577        assert_eq!(loaded.measurements.len(), 1);
578        Ok(())
579    }
580
581    #[test]
582    fn test_recommendations_populated_for_latency() {
583        let config = RegressionConfig {
584            monitor_memory: false,
585            monitor_throughput: false,
586            ..Default::default()
587        };
588        let detector = RegressionDetector::new(config);
589        let baseline = make_baseline(vec![
590            make_measurement("a", 10.0, 0.0, 0.0),
591            make_measurement("a", 10.0, 0.0, 0.0),
592        ]);
593        let current = vec![make_measurement("a", 20.0, 0.0, 0.0)];
594        let alerts = detector.detect(&current, &baseline);
595        let recs = detector.recommendations(&alerts);
596        assert!(!recs.is_empty());
597    }
598
599    #[test]
600    fn test_baseline_stats_for() {
601        let baseline = make_baseline(vec![
602            make_measurement("layer_a", 10.0, 0.0, 0.0),
603            make_measurement("layer_a", 12.0, 0.0, 0.0),
604            make_measurement("layer_a", 11.0, 0.0, 0.0),
605        ]);
606        let stats = baseline.stats_for("layer_a");
607        assert!(stats.is_some());
608        let s = stats.expect("should have stats");
609        assert!((s.mean_latency_ms - 11.0).abs() < 0.1);
610        assert!(s.std_latency_ms > 0.0);
611    }
612
613    // ── additional tests ──────────────────────────────────────────────────
614
615    #[test]
616    fn test_regression_config_default() {
617        let cfg = RegressionConfig::default();
618        assert!(cfg.monitor_latency);
619        assert!(cfg.monitor_memory);
620        assert!(cfg.monitor_throughput);
621        assert!(cfg.min_regression_pct > 0.0);
622        assert!(cfg.z_score_threshold > 0.0);
623    }
624
625    #[test]
626    fn test_create_baseline_sets_description() {
627        let baseline = RegressionDetector::create_baseline(
628            vec![make_measurement("l0", 5.0, 50.0, 200.0)],
629            "v1.0 baseline",
630        );
631        assert_eq!(baseline.description, "v1.0 baseline");
632        assert_eq!(baseline.measurements.len(), 1);
633    }
634
635    #[test]
636    fn test_create_baseline_empty_measurements() {
637        let baseline = RegressionDetector::create_baseline(vec![], "empty");
638        assert!(baseline.measurements.is_empty());
639    }
640
641    #[test]
642    fn test_detect_no_baseline_data_no_alerts() {
643        let detector = RegressionDetector::new(RegressionConfig::default());
644        let baseline = make_baseline(vec![make_measurement("other_layer", 10.0, 100.0, 500.0)]);
645        let current = vec![make_measurement("attn", 20.0, 200.0, 100.0)];
646        // "attn" not in baseline → no alerts
647        let alerts = detector.detect(&current, &baseline);
648        assert!(alerts.is_empty());
649    }
650
651    #[test]
652    fn test_detect_alerts_sorted_by_severity() {
653        let baseline = make_baseline(vec![
654            make_measurement("a", 10.0, 0.0, 0.0),
655            make_measurement("a", 10.0, 0.0, 0.0),
656            make_measurement("b", 10.0, 0.0, 0.0),
657            make_measurement("b", 10.0, 0.0, 0.0),
658        ]);
659        let current = vec![
660            make_measurement("a", 16.0, 0.0, 0.0), // ~60% → Critical
661            make_measurement("b", 11.5, 0.0, 0.0), // ~15% → Moderate
662        ];
663        let cfg = RegressionConfig {
664            monitor_memory: false,
665            monitor_throughput: false,
666            ..Default::default()
667        };
668        let det2 = RegressionDetector::new(cfg);
669        let alerts = det2.detect(&current, &baseline);
670        if alerts.len() >= 2 {
671            assert!(alerts[0].severity >= alerts[1].severity);
672        }
673    }
674
675    #[test]
676    fn test_regression_metric_variants() {
677        let metrics = [
678            RegressionMetric::Latency,
679            RegressionMetric::Memory,
680            RegressionMetric::Throughput,
681        ];
682        for m in &metrics {
683            assert!(!format!("{:?}", m).is_empty());
684        }
685    }
686
687    #[test]
688    fn test_regression_severity_variants() {
689        let severities = [
690            RegressionSeverity::Minor,
691            RegressionSeverity::Moderate,
692            RegressionSeverity::Severe,
693            RegressionSeverity::Critical,
694        ];
695        for s in &severities {
696            assert!(!format!("{:?}", s).is_empty());
697        }
698    }
699
700    #[test]
701    fn test_regression_alert_fields() {
702        let alert = RegressionAlert {
703            name: "attention".to_string(),
704            metric: RegressionMetric::Latency,
705            severity: RegressionSeverity::Severe,
706            baseline_value: 10.0,
707            current_value: 15.0,
708            regression_pct: 50.0,
709            message: "50% regression".to_string(),
710        };
711        assert_eq!(alert.name, "attention");
712        assert!((alert.regression_pct - 50.0).abs() < 1e-6);
713    }
714
715    #[test]
716    fn test_report_with_alerts_includes_count() {
717        let detector = RegressionDetector::new(RegressionConfig::default());
718        let alerts = vec![RegressionAlert {
719            name: "a".to_string(),
720            metric: RegressionMetric::Latency,
721            severity: RegressionSeverity::Minor,
722            baseline_value: 10.0,
723            current_value: 10.5,
724            regression_pct: 5.0,
725            message: "Minor regression".to_string(),
726        }];
727        let report = detector.report(&alerts);
728        assert!(report.contains("Total alerts: 1"));
729    }
730
731    #[test]
732    fn test_perf_measurement_fields() {
733        let m = PerfMeasurement {
734            name: "ffn".to_string(),
735            latency_ms: 12.5,
736            memory_mb: 256.0,
737            throughput: 1024.0,
738            timestamp_secs: 1000,
739        };
740        assert_eq!(m.name, "ffn");
741        assert!((m.latency_ms - 12.5).abs() < 1e-6);
742    }
743
744    #[test]
745    fn test_baseline_stats_for_single_measurement_returns_none() {
746        // Need at least 2 measurements for stats
747        let baseline = make_baseline(vec![make_measurement("solo", 10.0, 0.0, 0.0)]);
748        assert!(baseline.stats_for("solo").is_none());
749    }
750
751    #[test]
752    fn test_baseline_stats_p95_p99() {
753        let measurements: Vec<PerfMeasurement> =
754            (1..=10).map(|i| make_measurement("l", i as f64 * 10.0, 0.0, 0.0)).collect();
755        let baseline = make_baseline(measurements);
756        let stats = baseline.stats_for("l").expect("should have stats");
757        assert!(stats.p95_latency_ms >= stats.mean_latency_ms);
758        assert!(stats.p99_latency_ms >= stats.p95_latency_ms);
759    }
760}