Skip to main content

cbtop/optimize/
regression.rs

1//! Regression detection for CI/CD integration (OPT-003).
2
3use crate::error::CbtopError;
4use serde::{Deserialize, Serialize};
5
6use super::suite::{BaselineEntry, BaselineReport};
7
8/// Entry describing a performance regression
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct RegressionEntry {
11    /// Workload name
12    pub workload: String,
13    /// Problem size
14    pub size: usize,
15    /// Baseline GFLOP/s
16    pub baseline_gflops: f64,
17    /// Current GFLOP/s
18    pub current_gflops: f64,
19    /// Change percentage (negative = regression)
20    pub change_percent: f64,
21}
22
23/// Results of regression detection
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RegressionReport {
26    /// Whether all checks passed (no regressions)
27    pub passed: bool,
28    /// Detected regressions
29    pub regressions: Vec<RegressionEntry>,
30    /// Detected improvements
31    pub improvements: Vec<RegressionEntry>,
32    /// Summary message
33    pub summary: String,
34}
35
36/// Automated regression detection for CI/CD integration
37pub struct RegressionDetector {
38    /// Baseline to compare against
39    baseline: BaselineReport,
40    /// Threshold for regression detection (%)
41    threshold_percent: f64,
42}
43
44impl RegressionDetector {
45    /// Create new regression detector
46    pub fn new(baseline: BaselineReport, threshold_percent: f64) -> Self {
47        Self {
48            baseline,
49            threshold_percent,
50        }
51    }
52
53    /// Load baseline from file and create detector
54    pub fn from_file(path: &std::path::Path, threshold_percent: f64) -> Result<Self, CbtopError> {
55        let baseline = BaselineReport::load(path)?;
56        Ok(Self::new(baseline, threshold_percent))
57    }
58
59    /// Check current results against baseline
60    pub fn check(&self, current: &BaselineReport) -> RegressionReport {
61        let mut regressions = Vec::new();
62        let mut improvements = Vec::new();
63
64        for current_entry in &current.entries {
65            if let Some(baseline_entry) = self.find_baseline(current_entry) {
66                if baseline_entry.gflops > 0.0 {
67                    let change = (current_entry.gflops - baseline_entry.gflops)
68                        / baseline_entry.gflops
69                        * 100.0;
70
71                    if change < -self.threshold_percent {
72                        regressions.push(RegressionEntry {
73                            workload: current_entry.workload.clone(),
74                            size: current_entry.size,
75                            baseline_gflops: baseline_entry.gflops,
76                            current_gflops: current_entry.gflops,
77                            change_percent: change,
78                        });
79                    } else if change > self.threshold_percent {
80                        improvements.push(RegressionEntry {
81                            workload: current_entry.workload.clone(),
82                            size: current_entry.size,
83                            baseline_gflops: baseline_entry.gflops,
84                            current_gflops: current_entry.gflops,
85                            change_percent: change,
86                        });
87                    }
88                }
89            }
90        }
91
92        let passed = regressions.is_empty();
93        let summary = if passed {
94            if improvements.is_empty() {
95                "All benchmarks within threshold".to_string()
96            } else {
97                format!("{} improvements detected", improvements.len())
98            }
99        } else {
100            format!(
101                "FAILED: {} regressions detected (threshold: {}%)",
102                regressions.len(),
103                self.threshold_percent
104            )
105        };
106
107        RegressionReport {
108            passed,
109            regressions,
110            improvements,
111            summary,
112        }
113    }
114
115    fn find_baseline(&self, current: &BaselineEntry) -> Option<&BaselineEntry> {
116        self.baseline.entries.iter().find(|b| {
117            b.workload == current.workload && b.size == current.size && b.backend == current.backend
118        })
119    }
120}
121
122impl RegressionReport {
123    /// Get exit code for CI (0 = pass, 1 = regression)
124    pub fn exit_code(&self) -> i32 {
125        if self.passed {
126            0
127        } else {
128            1
129        }
130    }
131
132    /// Format as human-readable report
133    pub fn format_report(&self) -> String {
134        let mut report = String::new();
135
136        report.push_str("# Regression Check Report\n\n");
137        report.push_str(&format!("**Status**: {}\n\n", self.summary));
138
139        if !self.regressions.is_empty() {
140            report.push_str("## Regressions\n\n");
141            for r in &self.regressions {
142                report.push_str(&format!(
143                    "- **{}** @ {}: {:.1} -> {:.1} GFLOP/s ({:.1}%)\n",
144                    r.workload, r.size, r.baseline_gflops, r.current_gflops, r.change_percent
145                ));
146            }
147            report.push('\n');
148        }
149
150        if !self.improvements.is_empty() {
151            report.push_str("## Improvements\n\n");
152            for i in &self.improvements {
153                report.push_str(&format!(
154                    "- **{}** @ {}: {:.1} -> {:.1} GFLOP/s (+{:.1}%)\n",
155                    i.workload, i.size, i.baseline_gflops, i.current_gflops, i.change_percent
156                ));
157            }
158        }
159
160        report
161    }
162}