Skip to main content

atupa_core/
diff.rs

1//! Protocol diff structures: [`ProtocolDiffReport`] and [`DiffRow`].
2
3use serde::{Deserialize, Serialize};
4
5// ─── ProtocolDiffReport ───────────────────────────────────────────────────────
6
7/// A field-by-field comparison report between two protocol executions.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ProtocolDiffReport {
10    /// Human-readable name of the protocol being compared (e.g. `"Lido stETH"`).
11    pub protocol: String,
12    /// Ordered list of metric comparisons.
13    pub rows: Vec<DiffRow>,
14}
15
16impl ProtocolDiffReport {
17    /// Returns `true` if any row in this report represents a regression.
18    pub fn has_regressions(&self) -> bool {
19        self.rows.iter().any(DiffRow::is_regression)
20    }
21
22    /// Returns an iterator over only the rows that are regressions.
23    pub fn regressions(&self) -> impl Iterator<Item = &DiffRow> {
24        self.rows.iter().filter(|r| r.is_regression())
25    }
26
27    /// Returns an iterator over only the rows that are improvements.
28    pub fn improvements(&self) -> impl Iterator<Item = &DiffRow> {
29        self.rows.iter().filter(|r| r.is_improvement())
30    }
31}
32
33// ─── DiffRow ──────────────────────────────────────────────────────────────────
34
35/// A single comparable metric between a base and target execution.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DiffRow {
38    /// Human-readable metric name (e.g. `"Total Gas"`).
39    pub metric: String,
40    /// Metric value for the base transaction.
41    pub base: f64,
42    /// Metric value for the target transaction.
43    pub target: f64,
44    /// Absolute difference: `target - base`.
45    pub delta: f64,
46    /// Percentage change relative to the base: `delta / base * 100`.
47    ///
48    /// Returns `0.0` when `base` is `0` to avoid division by zero.
49    pub pct: f64,
50    /// When `true`, an *increase* in this metric is a regression (e.g. gas cost, read count).
51    /// When `false`, a *decrease* in this metric is a regression.
52    pub higher_is_worse: bool,
53}
54
55impl DiffRow {
56    /// Construct a new [`DiffRow`], automatically computing `delta` and `pct`.
57    ///
58    /// ```
59    /// use atupa_core::DiffRow;
60    ///
61    /// let row = DiffRow::new("Total Gas", 1_000.0, 1_200.0, true);
62    /// assert_eq!(row.delta, 200.0);
63    /// assert_eq!(row.pct, 20.0);
64    /// assert!(row.is_regression());
65    /// ```
66    pub fn new(metric: &str, base: f64, target: f64, higher_is_worse: bool) -> Self {
67        let delta = target - base;
68        let pct = if base != 0.0 { delta / base * 100.0 } else { 0.0 };
69        Self {
70            metric: metric.to_string(),
71            base,
72            target,
73            delta,
74            pct,
75            higher_is_worse,
76        }
77    }
78
79    /// Returns `true` if this metric has regressed (moved in the undesired direction).
80    ///
81    /// - `higher_is_worse = true` → regression when `delta > 0` (cost increased).
82    /// - `higher_is_worse = false` → regression when `delta < 0` (a desirable metric decreased).
83    pub fn is_regression(&self) -> bool {
84        (self.higher_is_worse && self.delta > 0.0)
85            || (!self.higher_is_worse && self.delta < 0.0)
86    }
87
88    /// Returns `true` if this metric has improved relative to the baseline.
89    pub fn is_improvement(&self) -> bool {
90        (self.higher_is_worse && self.delta < 0.0)
91            || (!self.higher_is_worse && self.delta > 0.0)
92    }
93
94    /// Returns `true` if the metric value is unchanged between base and target.
95    pub fn is_neutral(&self) -> bool {
96        self.delta == 0.0
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    // ── DiffRow ───────────────────────────────────────────────────────────────
105
106    #[test]
107    fn positive_delta_with_higher_is_worse_is_regression() {
108        let row = DiffRow::new("Total Gas", 100.0, 150.0, true);
109        assert_eq!(row.delta, 50.0);
110        assert_eq!(row.pct, 50.0);
111        assert!(row.is_regression());
112        assert!(!row.is_improvement());
113        assert!(!row.is_neutral());
114    }
115
116    #[test]
117    fn negative_delta_with_higher_is_worse_is_improvement() {
118        let row = DiffRow::new("Total Gas", 100.0, 80.0, true);
119        assert_eq!(row.delta, -20.0);
120        assert_eq!(row.pct, -20.0);
121        assert!(row.is_improvement());
122        assert!(!row.is_regression());
123    }
124
125    #[test]
126    fn zero_base_pct_is_zero_not_nan() {
127        let row = DiffRow::new("New Metric", 0.0, 42.0, true);
128        assert_eq!(row.delta, 42.0);
129        assert_eq!(row.pct, 0.0, "pct must be 0 when base is 0 to avoid NaN/inf");
130    }
131
132    #[test]
133    fn unchanged_metric_is_neutral() {
134        let row = DiffRow::new("Steps", 50.0, 50.0, true);
135        assert!(row.is_neutral());
136        assert!(!row.is_regression());
137        assert!(!row.is_improvement());
138    }
139
140    #[test]
141    fn lower_is_better_regression_when_delta_negative() {
142        // E.g. "coverage %" where higher is better
143        let row = DiffRow::new("Coverage %", 80.0, 70.0, false);
144        assert!(row.is_regression());
145        assert!(!row.is_improvement());
146    }
147
148    // ── ProtocolDiffReport ────────────────────────────────────────────────────
149
150    #[test]
151    fn report_detects_regressions() {
152        let rows = vec![
153            DiffRow::new("Gas", 100.0, 120.0, true),  // regression
154            DiffRow::new("Steps", 50.0, 50.0, true),  // neutral
155            DiffRow::new("Reads", 10.0, 8.0, true),   // improvement
156        ];
157        let report = ProtocolDiffReport { protocol: "Test".to_string(), rows };
158        assert!(report.has_regressions());
159        assert_eq!(report.regressions().count(), 1);
160        assert_eq!(report.improvements().count(), 1);
161    }
162
163    #[test]
164    fn report_with_no_regressions() {
165        let rows = vec![
166            DiffRow::new("Gas", 100.0, 90.0, true),   // improvement
167            DiffRow::new("Steps", 50.0, 50.0, true),  // neutral
168        ];
169        let report = ProtocolDiffReport { protocol: "Test".to_string(), rows };
170        assert!(!report.has_regressions());
171        assert_eq!(report.regressions().count(), 0);
172        assert_eq!(report.improvements().count(), 1);
173    }
174}