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/// Classify severity based on a value exceeding warning/critical thresholds.
82/// Returns the severity and whether this constitutes a regression (critical).
83fn classify_severity(value: f32, warning_threshold: f32, critical_threshold: f32) -> Severity {
84    if value > critical_threshold {
85        Severity::Critical
86    } else if value > warning_threshold {
87        Severity::Warning
88    } else {
89        Severity::Info
90    }
91}
92
93/// Compare a percent-change metric (registers, instructions) between baseline and current.
94/// Pushes a `Change` if the difference is significant. Returns true if a critical regression.
95fn compare_percent_metric(
96    metric: &str,
97    baseline_val: f32,
98    current_val: f32,
99    warning_threshold: f32,
100    critical_threshold: f32,
101    changes: &mut Vec<Change>,
102) -> bool {
103    if baseline_val <= 0.0 {
104        return false;
105    }
106    let percent = (current_val - baseline_val) / baseline_val * 100.0;
107    if percent.abs() <= 0.1 {
108        return false;
109    }
110    let severity = classify_severity(percent, warning_threshold, critical_threshold);
111    let is_critical = severity == Severity::Critical;
112    changes.push(Change {
113        metric: metric.to_string(),
114        baseline: baseline_val,
115        current: current_val,
116        percent_change: percent,
117        severity,
118    });
119    is_critical
120}
121
122/// Compare occupancy (absolute percentage-point drop). Returns true if critical regression.
123fn compare_occupancy(
124    baseline: &AnalysisReport,
125    current: &AnalysisReport,
126    thresholds: &DiffThresholds,
127    changes: &mut Vec<Change>,
128) -> bool {
129    let baseline_occ = baseline.estimated_occupancy * 100.0;
130    let current_occ = current.estimated_occupancy * 100.0;
131    let occ_diff = baseline_occ - current_occ; // Positive = regression (drop)
132    if occ_diff.abs() <= 0.1 {
133        return false;
134    }
135    // Occupancy uses >= (absolute pp thresholds) unlike percent metrics which use >
136    let severity = if occ_diff >= thresholds.occupancy_decrease_critical {
137        Severity::Critical
138    } else if occ_diff >= thresholds.occupancy_decrease_warning {
139        Severity::Warning
140    } else {
141        Severity::Info
142    };
143    let is_critical = severity == Severity::Critical;
144    changes.push(Change {
145        metric: "estimated_occupancy".to_string(),
146        baseline: baseline_occ,
147        current: current_occ,
148        percent_change: -occ_diff, // Negative change = regression
149        severity,
150    });
151    is_critical
152}
153
154/// Compare warning counts between baseline and current.
155fn compare_warning_counts(
156    baseline: &AnalysisReport,
157    current: &AnalysisReport,
158    thresholds: &DiffThresholds,
159    changes: &mut Vec<Change>,
160) {
161    let baseline_warns = baseline.warnings.len() as u32;
162    let current_warns = current.warnings.len() as u32;
163    if current_warns <= baseline_warns {
164        return;
165    }
166    let increase = current_warns - baseline_warns;
167    let severity = if increase >= thresholds.warning_count_increase {
168        Severity::Warning
169    } else {
170        Severity::Info
171    };
172    let percent_change = if baseline_warns > 0 {
173        (increase as f32 / baseline_warns as f32) * 100.0
174    } else {
175        100.0
176    };
177    changes.push(Change {
178        metric: "muda_warnings".to_string(),
179        baseline: baseline_warns as f32,
180        current: current_warns as f32,
181        percent_change,
182        severity,
183    });
184}
185
186/// Generate a human-readable summary from the list of changes.
187fn build_summary(changes: &[Change]) -> String {
188    let critical_count = changes
189        .iter()
190        .filter(|c| c.severity == Severity::Critical)
191        .count();
192    let warning_count = changes
193        .iter()
194        .filter(|c| c.severity == Severity::Warning)
195        .count();
196
197    if critical_count > 0 {
198        format!(
199            "{} critical regression(s), {} warning(s)",
200            critical_count, warning_count
201        )
202    } else if warning_count > 0 {
203        format!("{} warning(s), no critical regressions", warning_count)
204    } else if changes.is_empty() {
205        "No significant changes detected".to_string()
206    } else {
207        format!("{} minor change(s)", changes.len())
208    }
209}
210
211/// Compare two analysis reports
212#[must_use]
213pub fn compare_reports(
214    baseline: &AnalysisReport,
215    current: &AnalysisReport,
216    thresholds: &DiffThresholds,
217) -> DiffReport {
218    let mut changes = Vec::new();
219
220    // Compare register usage
221    let reg_critical = compare_percent_metric(
222        "register_count",
223        baseline.registers.total() as f32,
224        current.registers.total() as f32,
225        thresholds.register_increase_warning,
226        thresholds.register_increase_critical,
227        &mut changes,
228    );
229
230    // Compare instruction count
231    let inst_critical = compare_percent_metric(
232        "instruction_count",
233        baseline.instruction_count as f32,
234        current.instruction_count as f32,
235        thresholds.instruction_increase_warning,
236        thresholds.instruction_increase_critical,
237        &mut changes,
238    );
239
240    // Compare estimated occupancy
241    let occ_critical = compare_occupancy(baseline, current, thresholds, &mut changes);
242
243    // Compare warning counts
244    compare_warning_counts(baseline, current, thresholds, &mut changes);
245
246    let has_regression = reg_critical || inst_critical || occ_critical;
247    let summary = build_summary(&changes);
248
249    DiffReport {
250        name: current.name.clone(),
251        changes,
252        has_regression,
253        summary,
254    }
255}
256
257/// Format diff report as text
258#[must_use]
259pub fn format_diff_text(report: &DiffReport) -> String {
260    let mut output = String::new();
261
262    output.push_str(&format!("╔══ Diff Report: {} ══╗\n", report.name));
263    output.push_str(&format!("Summary: {}\n\n", report.summary));
264
265    if report.changes.is_empty() {
266        output.push_str("  No changes detected.\n");
267    } else {
268        for change in &report.changes {
269            let icon = match change.severity {
270                Severity::Critical => "❌",
271                Severity::Warning => "⚠️",
272                Severity::Info => "ℹ️",
273            };
274            let direction = if change.percent_change > 0.0 {
275                "↑"
276            } else {
277                "↓"
278            };
279            output.push_str(&format!(
280                "{} {}: {} → {} ({}{:.1}%)\n",
281                icon,
282                change.metric,
283                change.baseline,
284                change.current,
285                direction,
286                change.percent_change.abs()
287            ));
288        }
289    }
290
291    if report.has_regression {
292        output.push_str("\n🚨 REGRESSION DETECTED - CI should fail\n");
293    }
294
295    output
296}
297
298/// Format diff report as JSON
299#[must_use]
300pub fn format_diff_json(report: &DiffReport) -> String {
301    serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string())
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::analyzer::{MemoryPattern, MudaType, MudaWarning, RegisterUsage, RooflineMetric};
308
309    fn make_warning() -> MudaWarning {
310        MudaWarning {
311            muda_type: MudaType::Transport,
312            description: "Test warning".to_string(),
313            impact: "Minor".to_string(),
314            line: None,
315            suggestion: None,
316        }
317    }
318
319    fn make_report(name: &str, regs: u32, inst: u32, occ: f32, warns: usize) -> AnalysisReport {
320        AnalysisReport {
321            name: name.to_string(),
322            target: "test".to_string(),
323            registers: RegisterUsage {
324                f32_regs: regs,
325                f64_regs: 0,
326                pred_regs: 0,
327                ..Default::default()
328            },
329            memory: MemoryPattern::default(),
330            roofline: RooflineMetric::default(),
331            warnings: (0..warns).map(|_| make_warning()).collect(),
332            instruction_count: inst,
333            estimated_occupancy: occ,
334        }
335    }
336
337    #[test]
338    fn test_no_changes() {
339        let baseline = make_report("test", 32, 100, 0.75, 1);
340        let current = make_report("test", 32, 100, 0.75, 1);
341        let thresholds = DiffThresholds::default();
342
343        let report = compare_reports(&baseline, &current, &thresholds);
344
345        assert!(!report.has_regression);
346        assert!(report.changes.is_empty());
347    }
348
349    #[test]
350    fn test_register_increase_warning() {
351        let baseline = make_report("test", 32, 100, 0.75, 1);
352        let current = make_report("test", 36, 100, 0.75, 1); // 12.5% increase
353        let thresholds = DiffThresholds::default();
354
355        let report = compare_reports(&baseline, &current, &thresholds);
356
357        assert!(!report.has_regression);
358        assert!(report
359            .changes
360            .iter()
361            .any(|c| c.metric == "register_count" && c.severity == Severity::Warning));
362    }
363
364    #[test]
365    fn test_register_increase_critical() {
366        let baseline = make_report("test", 32, 100, 0.75, 1);
367        let current = make_report("test", 48, 100, 0.75, 1); // 50% increase
368        let thresholds = DiffThresholds::default();
369
370        let report = compare_reports(&baseline, &current, &thresholds);
371
372        assert!(report.has_regression);
373        assert!(report
374            .changes
375            .iter()
376            .any(|c| c.metric == "register_count" && c.severity == Severity::Critical));
377    }
378
379    #[test]
380    fn test_occupancy_decrease() {
381        let baseline = make_report("test", 32, 100, 0.75, 1);
382        let current = make_report("test", 32, 100, 0.50, 1); // 25pp decrease
383        let thresholds = DiffThresholds::default();
384
385        let report = compare_reports(&baseline, &current, &thresholds);
386
387        assert!(report.has_regression);
388    }
389
390    #[test]
391    fn test_warning_count_increase() {
392        let baseline = make_report("test", 32, 100, 0.75, 1);
393        let current = make_report("test", 32, 100, 0.75, 4); // 3 new warnings
394        let thresholds = DiffThresholds::default();
395
396        let report = compare_reports(&baseline, &current, &thresholds);
397
398        assert!(report.changes.iter().any(|c| c.metric == "muda_warnings"));
399    }
400
401    #[test]
402    fn test_format_text() {
403        let baseline = make_report("test", 32, 100, 0.75, 1);
404        let current = make_report("test", 40, 100, 0.75, 1);
405        let thresholds = DiffThresholds::default();
406
407        let report = compare_reports(&baseline, &current, &thresholds);
408        let text = format_diff_text(&report);
409
410        assert!(text.contains("Diff Report"));
411        assert!(text.contains("register_count"));
412    }
413
414    #[test]
415    fn test_format_json() {
416        let baseline = make_report("test", 32, 100, 0.75, 1);
417        let current = make_report("test", 32, 100, 0.75, 1);
418        let thresholds = DiffThresholds::default();
419
420        let report = compare_reports(&baseline, &current, &thresholds);
421        let json = format_diff_json(&report);
422
423        assert!(json.contains("\"name\": \"test\""));
424    }
425
426    /// F086: Diff detects register regression
427    #[test]
428    fn f086_diff_detects_register_regression() {
429        let baseline = make_report("gemm", 32, 500, 0.75, 0);
430        let current = make_report("gemm", 64, 500, 0.75, 0); // 100% increase
431        let thresholds = DiffThresholds::default();
432
433        let report = compare_reports(&baseline, &current, &thresholds);
434
435        assert!(report.has_regression, "Should detect register regression");
436        assert!(
437            report
438                .changes
439                .iter()
440                .any(|c| c.metric == "register_count" && c.severity == Severity::Critical),
441            "Register increase should be critical"
442        );
443    }
444
445    /// F089: Diff returns exit code on regression
446    #[test]
447    fn f089_diff_exit_code_on_regression() {
448        let baseline = make_report("gemm", 32, 500, 0.75, 0);
449        let current = make_report("gemm", 64, 800, 0.50, 5); // Multiple regressions
450        let thresholds = DiffThresholds::default();
451
452        let report = compare_reports(&baseline, &current, &thresholds);
453
454        // In real CLI, has_regression determines exit code
455        assert!(report.has_regression, "Should have regression for CI fail");
456    }
457}