Skip to main content

trueno_explain/
diff.rs

1//! Analysis diff and regression detection
2//!
3//! Compares two analysis reports to detect performance regressions.
4//! Supports CI integration with exit codes for automated gating.
5
6use crate::analyzer::AnalysisReport;
7use serde::{Deserialize, Serialize};
8
9/// Regression severity levels
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum Severity {
12    /// Informational change (no action needed)
13    Info,
14    /// Minor regression (review recommended)
15    Warning,
16    /// Major regression (CI should fail)
17    Critical,
18}
19
20/// A detected change between baseline and current
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Change {
23    /// What changed
24    pub metric: String,
25    /// Baseline value
26    pub baseline: f32,
27    /// Current value
28    pub current: f32,
29    /// Percentage change (positive = regression)
30    pub percent_change: f32,
31    /// Severity of the change
32    pub severity: Severity,
33}
34
35/// Result of comparing two analyses
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DiffReport {
38    /// Name of the analysis
39    pub name: String,
40    /// List of detected changes
41    pub changes: Vec<Change>,
42    /// Whether any regressions were detected
43    pub has_regression: bool,
44    /// Summary message
45    pub summary: String,
46}
47
48/// Thresholds for regression detection
49#[derive(Debug, Clone)]
50pub struct DiffThresholds {
51    /// Register increase warning threshold (percentage)
52    pub register_increase_warning: f32,
53    /// Register increase critical threshold (percentage)
54    pub register_increase_critical: f32,
55    /// Instruction count increase warning threshold (percentage)
56    pub instruction_increase_warning: f32,
57    /// Instruction count increase critical threshold (percentage)
58    pub instruction_increase_critical: f32,
59    /// Occupancy decrease warning threshold (percentage points)
60    pub occupancy_decrease_warning: f32,
61    /// Occupancy decrease critical threshold (percentage points)
62    pub occupancy_decrease_critical: f32,
63    /// Warning count increase that triggers concern
64    pub warning_count_increase: u32,
65}
66
67impl Default for DiffThresholds {
68    fn default() -> Self {
69        Self {
70            register_increase_warning: 10.0,     // 10% more registers = warning
71            register_increase_critical: 25.0,    // 25% more = critical
72            instruction_increase_warning: 15.0,  // 15% more instructions = warning
73            instruction_increase_critical: 50.0, // 50% more = critical
74            occupancy_decrease_warning: 10.0,    // 10pp occupancy drop = warning
75            occupancy_decrease_critical: 25.0,   // 25pp = critical
76            warning_count_increase: 2,           // 2+ new warnings = concern
77        }
78    }
79}
80
81/// Compare two analysis reports
82#[must_use]
83pub fn compare_reports(
84    baseline: &AnalysisReport,
85    current: &AnalysisReport,
86    thresholds: &DiffThresholds,
87) -> DiffReport {
88    let mut changes = Vec::new();
89    let mut has_regression = false;
90
91    // Compare register usage
92    let baseline_regs = baseline.registers.total() as f32;
93    let current_regs = current.registers.total() as f32;
94    if baseline_regs > 0.0 {
95        let percent = (current_regs - baseline_regs) / baseline_regs * 100.0;
96        if percent.abs() > 0.1 {
97            let severity = if percent > thresholds.register_increase_critical {
98                has_regression = true;
99                Severity::Critical
100            } else if percent > thresholds.register_increase_warning {
101                Severity::Warning
102            } else {
103                Severity::Info
104            };
105            changes.push(Change {
106                metric: "register_count".to_string(),
107                baseline: baseline_regs,
108                current: current_regs,
109                percent_change: percent,
110                severity,
111            });
112        }
113    }
114
115    // Compare instruction count
116    let baseline_inst = baseline.instruction_count as f32;
117    let current_inst = current.instruction_count as f32;
118    if baseline_inst > 0.0 {
119        let percent = (current_inst - baseline_inst) / baseline_inst * 100.0;
120        if percent.abs() > 0.1 {
121            let severity = if percent > thresholds.instruction_increase_critical {
122                has_regression = true;
123                Severity::Critical
124            } else if percent > thresholds.instruction_increase_warning {
125                Severity::Warning
126            } else {
127                Severity::Info
128            };
129            changes.push(Change {
130                metric: "instruction_count".to_string(),
131                baseline: baseline_inst,
132                current: current_inst,
133                percent_change: percent,
134                severity,
135            });
136        }
137    }
138
139    // Compare estimated occupancy
140    let baseline_occ = baseline.estimated_occupancy * 100.0;
141    let current_occ = current.estimated_occupancy * 100.0;
142    let occ_diff = baseline_occ - current_occ; // Positive = regression (drop)
143    if occ_diff.abs() > 0.1 {
144        let severity = if occ_diff >= thresholds.occupancy_decrease_critical {
145            has_regression = true;
146            Severity::Critical
147        } else if occ_diff >= thresholds.occupancy_decrease_warning {
148            Severity::Warning
149        } else {
150            Severity::Info
151        };
152        changes.push(Change {
153            metric: "estimated_occupancy".to_string(),
154            baseline: baseline_occ,
155            current: current_occ,
156            percent_change: -occ_diff, // Negative change = regression
157            severity,
158        });
159    }
160
161    // Compare warning counts
162    let baseline_warns = baseline.warnings.len() as u32;
163    let current_warns = current.warnings.len() as u32;
164    if current_warns > baseline_warns {
165        let increase = current_warns - baseline_warns;
166        let severity = if increase >= thresholds.warning_count_increase {
167            Severity::Warning
168        } else {
169            Severity::Info
170        };
171        changes.push(Change {
172            metric: "muda_warnings".to_string(),
173            baseline: baseline_warns as f32,
174            current: current_warns as f32,
175            percent_change: if baseline_warns > 0 {
176                (increase as f32 / baseline_warns as f32) * 100.0
177            } else {
178                100.0
179            },
180            severity,
181        });
182    }
183
184    // Generate summary
185    let critical_count = changes
186        .iter()
187        .filter(|c| c.severity == Severity::Critical)
188        .count();
189    let warning_count = changes
190        .iter()
191        .filter(|c| c.severity == Severity::Warning)
192        .count();
193
194    let summary = if critical_count > 0 {
195        format!(
196            "{} critical regression(s), {} warning(s)",
197            critical_count, warning_count
198        )
199    } else if warning_count > 0 {
200        format!("{} warning(s), no critical regressions", warning_count)
201    } else if changes.is_empty() {
202        "No significant changes detected".to_string()
203    } else {
204        format!("{} minor change(s)", changes.len())
205    };
206
207    DiffReport {
208        name: current.name.clone(),
209        changes,
210        has_regression,
211        summary,
212    }
213}
214
215/// Format diff report as text
216#[must_use]
217pub fn format_diff_text(report: &DiffReport) -> String {
218    let mut output = String::new();
219
220    output.push_str(&format!("╔══ Diff Report: {} ══╗\n", report.name));
221    output.push_str(&format!("Summary: {}\n\n", report.summary));
222
223    if report.changes.is_empty() {
224        output.push_str("  No changes detected.\n");
225    } else {
226        for change in &report.changes {
227            let icon = match change.severity {
228                Severity::Critical => "❌",
229                Severity::Warning => "⚠️",
230                Severity::Info => "ℹ️",
231            };
232            let direction = if change.percent_change > 0.0 {
233                "↑"
234            } else {
235                "↓"
236            };
237            output.push_str(&format!(
238                "{} {}: {} → {} ({}{:.1}%)\n",
239                icon,
240                change.metric,
241                change.baseline,
242                change.current,
243                direction,
244                change.percent_change.abs()
245            ));
246        }
247    }
248
249    if report.has_regression {
250        output.push_str("\n🚨 REGRESSION DETECTED - CI should fail\n");
251    }
252
253    output
254}
255
256/// Format diff report as JSON
257#[must_use]
258pub fn format_diff_json(report: &DiffReport) -> String {
259    serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string())
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::analyzer::{MemoryPattern, MudaType, MudaWarning, RegisterUsage, RooflineMetric};
266
267    fn make_warning() -> MudaWarning {
268        MudaWarning {
269            muda_type: MudaType::Transport,
270            description: "Test warning".to_string(),
271            impact: "Minor".to_string(),
272            line: None,
273            suggestion: None,
274        }
275    }
276
277    fn make_report(name: &str, regs: u32, inst: u32, occ: f32, warns: usize) -> AnalysisReport {
278        AnalysisReport {
279            name: name.to_string(),
280            target: "test".to_string(),
281            registers: RegisterUsage {
282                f32_regs: regs,
283                f64_regs: 0,
284                pred_regs: 0,
285                ..Default::default()
286            },
287            memory: MemoryPattern::default(),
288            roofline: RooflineMetric::default(),
289            warnings: (0..warns).map(|_| make_warning()).collect(),
290            instruction_count: inst,
291            estimated_occupancy: occ,
292        }
293    }
294
295    #[test]
296    fn test_no_changes() {
297        let baseline = make_report("test", 32, 100, 0.75, 1);
298        let current = make_report("test", 32, 100, 0.75, 1);
299        let thresholds = DiffThresholds::default();
300
301        let report = compare_reports(&baseline, &current, &thresholds);
302
303        assert!(!report.has_regression);
304        assert!(report.changes.is_empty());
305    }
306
307    #[test]
308    fn test_register_increase_warning() {
309        let baseline = make_report("test", 32, 100, 0.75, 1);
310        let current = make_report("test", 36, 100, 0.75, 1); // 12.5% increase
311        let thresholds = DiffThresholds::default();
312
313        let report = compare_reports(&baseline, &current, &thresholds);
314
315        assert!(!report.has_regression);
316        assert!(report
317            .changes
318            .iter()
319            .any(|c| c.metric == "register_count" && c.severity == Severity::Warning));
320    }
321
322    #[test]
323    fn test_register_increase_critical() {
324        let baseline = make_report("test", 32, 100, 0.75, 1);
325        let current = make_report("test", 48, 100, 0.75, 1); // 50% increase
326        let thresholds = DiffThresholds::default();
327
328        let report = compare_reports(&baseline, &current, &thresholds);
329
330        assert!(report.has_regression);
331        assert!(report
332            .changes
333            .iter()
334            .any(|c| c.metric == "register_count" && c.severity == Severity::Critical));
335    }
336
337    #[test]
338    fn test_occupancy_decrease() {
339        let baseline = make_report("test", 32, 100, 0.75, 1);
340        let current = make_report("test", 32, 100, 0.50, 1); // 25pp decrease
341        let thresholds = DiffThresholds::default();
342
343        let report = compare_reports(&baseline, &current, &thresholds);
344
345        assert!(report.has_regression);
346    }
347
348    #[test]
349    fn test_warning_count_increase() {
350        let baseline = make_report("test", 32, 100, 0.75, 1);
351        let current = make_report("test", 32, 100, 0.75, 4); // 3 new warnings
352        let thresholds = DiffThresholds::default();
353
354        let report = compare_reports(&baseline, &current, &thresholds);
355
356        assert!(report.changes.iter().any(|c| c.metric == "muda_warnings"));
357    }
358
359    #[test]
360    fn test_format_text() {
361        let baseline = make_report("test", 32, 100, 0.75, 1);
362        let current = make_report("test", 40, 100, 0.75, 1);
363        let thresholds = DiffThresholds::default();
364
365        let report = compare_reports(&baseline, &current, &thresholds);
366        let text = format_diff_text(&report);
367
368        assert!(text.contains("Diff Report"));
369        assert!(text.contains("register_count"));
370    }
371
372    #[test]
373    fn test_format_json() {
374        let baseline = make_report("test", 32, 100, 0.75, 1);
375        let current = make_report("test", 32, 100, 0.75, 1);
376        let thresholds = DiffThresholds::default();
377
378        let report = compare_reports(&baseline, &current, &thresholds);
379        let json = format_diff_json(&report);
380
381        assert!(json.contains("\"name\": \"test\""));
382    }
383
384    /// F086: Diff detects register regression
385    #[test]
386    fn f086_diff_detects_register_regression() {
387        let baseline = make_report("gemm", 32, 500, 0.75, 0);
388        let current = make_report("gemm", 64, 500, 0.75, 0); // 100% increase
389        let thresholds = DiffThresholds::default();
390
391        let report = compare_reports(&baseline, &current, &thresholds);
392
393        assert!(report.has_regression, "Should detect register regression");
394        assert!(
395            report
396                .changes
397                .iter()
398                .any(|c| c.metric == "register_count" && c.severity == Severity::Critical),
399            "Register increase should be critical"
400        );
401    }
402
403    /// F089: Diff returns exit code on regression
404    #[test]
405    fn f089_diff_exit_code_on_regression() {
406        let baseline = make_report("gemm", 32, 500, 0.75, 0);
407        let current = make_report("gemm", 64, 800, 0.50, 5); // Multiple regressions
408        let thresholds = DiffThresholds::default();
409
410        let report = compare_reports(&baseline, &current, &thresholds);
411
412        // In real CLI, has_regression determines exit code
413        assert!(report.has_regression, "Should have regression for CI fail");
414    }
415}