Skip to main content

confium_coordinator/
perf_baseline.rs

1//! Performance baseline manager — save/load/compare Criterion baselines.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6
7/// A performance measurement.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct BenchmarkResult {
10    pub name: String,
11    pub mean_ns: f64,
12    pub median_ns: f64,
13    pub stddev_ns: f64,
14    pub samples: usize,
15}
16
17/// A baseline — a set of benchmark results at a point in time.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Baseline {
20    pub name: String,
21    pub created_at: chrono::DateTime<chrono::Utc>,
22    pub results: Vec<BenchmarkResult>,
23}
24
25/// Comparison result between a measurement and a baseline.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Comparison {
28    pub name: String,
29    pub baseline_ns: f64,
30    pub current_ns: f64,
31    pub change_pct: f64,
32    pub regression: bool,
33}
34
35/// Regression threshold (percentage above baseline that counts as regression).
36const REGRESSION_THRESHOLD_PCT: f64 = 10.0;
37
38/// Compare current results against a baseline.
39pub fn compare(current: &[BenchmarkResult], baseline: &Baseline) -> Vec<Comparison> {
40    let baseline_map: HashMap<&str, &BenchmarkResult> = baseline
41        .results
42        .iter()
43        .map(|r| (r.name.as_str(), r))
44        .collect();
45
46    current
47        .iter()
48        .filter_map(|c| {
49            let base = baseline_map.get(c.name.as_str())?;
50            let change_pct = ((c.mean_ns - base.mean_ns) / base.mean_ns) * 100.0;
51            Some(Comparison {
52                name: c.name.clone(),
53                baseline_ns: base.mean_ns,
54                current_ns: c.mean_ns,
55                change_pct,
56                regression: change_pct > REGRESSION_THRESHOLD_PCT,
57            })
58        })
59        .collect()
60}
61
62/// Save a baseline to a JSON file.
63pub fn save_baseline(baseline: &Baseline, path: &Path) -> std::io::Result<()> {
64    let json = serde_json::to_string_pretty(baseline)
65        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
66    std::fs::write(path, json)
67}
68
69/// Load a baseline from a JSON file.
70pub fn load_baseline(path: &Path) -> std::io::Result<Baseline> {
71    let json = std::fs::read_to_string(path)?;
72    serde_json::from_str(&json).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
73}
74
75/// Create a baseline from benchmark results.
76pub fn create_baseline(name: &str, results: Vec<BenchmarkResult>) -> Baseline {
77    Baseline {
78        name: name.into(),
79        created_at: chrono::Utc::now(),
80        results,
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    fn make_result(name: &str, mean_ns: f64) -> BenchmarkResult {
89        BenchmarkResult {
90            name: name.into(),
91            mean_ns,
92            median_ns: mean_ns,
93            stddev_ns: 10.0,
94            samples: 100,
95        }
96    }
97
98    #[test]
99    fn create_baseline_works() {
100        let baseline = create_baseline("v1", vec![make_result("bench_a", 1000.0)]);
101        assert_eq!(baseline.name, "v1");
102        assert_eq!(baseline.results.len(), 1);
103    }
104
105    #[test]
106    fn compare_no_change() {
107        let baseline = create_baseline("v1", vec![make_result("bench_a", 1000.0)]);
108        let current = vec![make_result("bench_a", 1000.0)];
109        let comparisons = compare(&current, &baseline);
110        assert_eq!(comparisons.len(), 1);
111        assert!(!comparisons[0].regression);
112    }
113
114    #[test]
115    fn compare_detects_regression() {
116        let baseline = create_baseline("v1", vec![make_result("bench_a", 1000.0)]);
117        let current = vec![make_result("bench_a", 1200.0)];
118        let comparisons = compare(&current, &baseline);
119        assert_eq!(comparisons[0].change_pct, 20.0);
120        assert!(comparisons[0].regression);
121    }
122
123    #[test]
124    fn compare_improvement_not_regression() {
125        let baseline = create_baseline("v1", vec![make_result("bench_a", 1000.0)]);
126        let current = vec![make_result("bench_a", 800.0)];
127        let comparisons = compare(&current, &baseline);
128        assert!(!comparisons[0].regression);
129    }
130
131    #[test]
132    fn compare_handles_missing_benchmarks() {
133        let baseline = create_baseline("v1", vec![make_result("a", 1.0)]);
134        let current = vec![make_result("b", 2.0)];
135        let comparisons = compare(&current, &baseline);
136        assert!(comparisons.is_empty());
137    }
138
139    #[test]
140    fn save_load_round_trips() {
141        let tmp = tempfile::tempdir().unwrap();
142        let path = tmp.path().join("baseline.json");
143        let baseline = create_baseline("test", vec![make_result("x", 42.0)]);
144        save_baseline(&baseline, &path).unwrap();
145        let loaded = load_baseline(&path).unwrap();
146        assert_eq!(loaded.name, "test");
147        assert_eq!(loaded.results[0].mean_ns, 42.0);
148    }
149
150    #[test]
151    fn regression_threshold_10pct() {
152        let baseline = create_baseline("v1", vec![make_result("a", 100.0)]);
153        let current = vec![make_result("a", 109.0)]; // 9% slower
154        let comparisons = compare(&current, &baseline);
155        assert!(!comparisons[0].regression); // under 10%
156    }
157
158    #[test]
159    fn multiple_benchmarks_compared() {
160        let baseline =
161            create_baseline("v1", vec![make_result("a", 100.0), make_result("b", 200.0)]);
162        let current = vec![
163            make_result("a", 150.0), // 50% regression
164            make_result("b", 190.0), // 5% improvement
165        ];
166        let comparisons = compare(&current, &baseline);
167        assert_eq!(comparisons.len(), 2);
168        assert!(comparisons[0].regression);
169        assert!(!comparisons[1].regression);
170    }
171}