1use crate::config::ComparisonConfig;
2use crate::{BenchResult, CpuSnapshot, Percentiles};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9fn get_primary_mac_address() -> Result<String, std::io::Error> {
14 let interface = default_net::get_default_interface().map_err(|e| {
15 std::io::Error::new(
16 std::io::ErrorKind::NotFound,
17 format!("Failed to get default network interface: {}", e),
18 )
19 })?;
20
21 let mac_addr = interface.mac_addr.ok_or_else(|| {
22 std::io::Error::new(
23 std::io::ErrorKind::NotFound,
24 "Default interface has no MAC address",
25 )
26 })?;
27
28 let mac_string = format!("{}", mac_addr).replace(':', "-").to_lowercase();
31
32 hash_mac_address(&mac_string)
34}
35
36fn hash_mac_address(mac: &str) -> Result<String, std::io::Error> {
40 let mut hasher = Sha256::new();
41 hasher.update(mac.as_bytes());
42 let result = hasher.finalize();
43
44 Ok(format!("{:x}", result)[..16].to_string())
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct BaselineData {
51 pub benchmark_name: String,
52 pub module: String,
53 pub timestamp: String,
54 pub samples: Vec<u128>,
56 pub statistics: crate::Statistics,
58 pub iterations: usize,
59 #[serde(alias = "hostname")]
60 pub machine_id: String,
61
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub cpu_samples: Vec<CpuSnapshot>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub percentiles: Option<Percentiles>,
69
70 #[serde(default, skip_serializing_if = "is_false")]
72 pub was_regression: bool,
73}
74
75fn is_false(b: &bool) -> bool {
76 !*b
77}
78
79impl BaselineData {
80 pub fn from_bench_result(
81 result: &BenchResult,
82 machine_id: String,
83 was_regression: bool,
84 ) -> Self {
85 let samples: Vec<u128> = result.all_timings.iter().map(|d| d.as_nanos()).collect();
87
88 let statistics = crate::calculate_statistics(&samples);
90
91 Self {
92 benchmark_name: result.name.clone(),
93 module: result.module.clone(),
94 timestamp: chrono::Utc::now().to_rfc3339(),
95 samples,
96 statistics,
97 iterations: result.iterations,
98 machine_id,
99 cpu_samples: result.cpu_samples.clone(),
100 percentiles: Some(result.percentiles.clone()),
101 was_regression,
102 }
103 }
104
105 pub fn to_bench_result(&self) -> BenchResult {
106 let percentiles = if let Some(ref p) = self.percentiles {
108 p.clone()
109 } else {
110 Percentiles {
112 mean: Duration::from_nanos(self.statistics.mean as u64),
113 p50: Duration::from_nanos(self.statistics.median as u64),
114 p90: Duration::from_nanos(self.statistics.p90 as u64),
115 p99: Duration::from_nanos(self.statistics.p99 as u64),
116 }
117 };
118
119 let all_timings: Vec<Duration> = self
121 .samples
122 .iter()
123 .map(|&ns| Duration::from_nanos(ns as u64))
124 .collect();
125
126 BenchResult {
127 name: self.benchmark_name.clone(),
128 module: self.module.clone(),
129 percentiles,
130 iterations: self.iterations,
131 samples: self.samples.len(),
132 all_timings,
133 cpu_samples: self.cpu_samples.clone(),
134 warmup_ms: None,
135 warmup_iterations: None,
136 }
137 }
138}
139
140#[derive(Debug)]
142pub struct BaselineManager {
143 root_dir: PathBuf,
144 machine_id: String,
145}
146
147impl BaselineManager {
148 pub fn new() -> Result<Self, std::io::Error> {
152 let machine_id = get_primary_mac_address()?;
153
154 Ok(Self {
155 root_dir: PathBuf::from(".benches"),
156 machine_id,
157 })
158 }
159
160 pub fn with_root_dir<P: AsRef<Path>>(root_dir: P) -> Result<Self, std::io::Error> {
162 let machine_id = get_primary_mac_address()?;
163
164 Ok(Self {
165 root_dir: root_dir.as_ref().to_path_buf(),
166 machine_id,
167 })
168 }
169
170 fn machine_dir(&self) -> PathBuf {
172 self.root_dir.join(&self.machine_id)
173 }
174
175 fn benchmark_dir(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
177 let dir_name = format!("{}_{}", crate_name, benchmark_name);
178 self.machine_dir().join(dir_name)
179 }
180
181 fn legacy_baseline_path(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
183 let filename = format!("{}_{}.json", crate_name, benchmark_name);
184 self.machine_dir().join(filename)
185 }
186
187 fn get_run_path(&self, crate_name: &str, benchmark_name: &str) -> PathBuf {
189 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S");
190 let filename = format!("{}.json", timestamp);
191 self.benchmark_dir(crate_name, benchmark_name)
192 .join(filename)
193 }
194
195 fn ensure_dir_exists(
197 &self,
198 crate_name: &str,
199 benchmark_name: &str,
200 ) -> Result<(), std::io::Error> {
201 fs::create_dir_all(self.benchmark_dir(crate_name, benchmark_name))
202 }
203
204 pub fn save_baseline(
206 &self,
207 crate_name: &str,
208 result: &BenchResult,
209 was_regression: bool,
210 ) -> Result<(), std::io::Error> {
211 self.ensure_dir_exists(crate_name, &result.name)?;
212
213 let baseline =
214 BaselineData::from_bench_result(result, self.machine_id.clone(), was_regression);
215 let json = serde_json::to_string_pretty(&baseline)?;
216
217 let path = self.get_run_path(crate_name, &result.name);
218 fs::write(path, json)?;
219
220 Ok(())
221 }
222
223 pub fn load_baseline(
225 &self,
226 crate_name: &str,
227 benchmark_name: &str,
228 ) -> Result<Option<BaselineData>, std::io::Error> {
229 let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
230
231 if bench_dir.exists() && bench_dir.is_dir() {
233 let mut runs: Vec<_> = fs::read_dir(&bench_dir)?
235 .filter_map(|e| e.ok())
236 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
237 .collect();
238
239 if runs.is_empty() {
240 return Ok(None);
241 }
242
243 runs.sort_by_key(|e| e.file_name());
245 let latest = runs.last().unwrap();
246
247 let contents = fs::read_to_string(latest.path())?;
248 let baseline: BaselineData = serde_json::from_str(&contents)?;
249 return Ok(Some(baseline));
250 }
251
252 let legacy_path = self.legacy_baseline_path(crate_name, benchmark_name);
254 if legacy_path.exists() {
255 let contents = fs::read_to_string(legacy_path)?;
256 let baseline: BaselineData = serde_json::from_str(&contents)?;
257 return Ok(Some(baseline));
258 }
259
260 Ok(None)
261 }
262
263 pub fn has_baseline(&self, crate_name: &str, benchmark_name: &str) -> bool {
265 let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
266 if bench_dir.exists() && bench_dir.is_dir() {
267 return true;
268 }
269 self.legacy_baseline_path(crate_name, benchmark_name)
270 .exists()
271 }
272
273 pub fn list_runs(
275 &self,
276 crate_name: &str,
277 benchmark_name: &str,
278 ) -> Result<Vec<String>, std::io::Error> {
279 let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
280
281 if !bench_dir.exists() || !bench_dir.is_dir() {
282 return Ok(vec![]);
283 }
284
285 let mut runs: Vec<String> = fs::read_dir(&bench_dir)?
286 .filter_map(|e| e.ok())
287 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
288 .filter_map(|e| {
289 e.file_name()
290 .to_string_lossy()
291 .strip_suffix(".json")
292 .map(|s| s.to_string())
293 })
294 .collect();
295
296 runs.sort();
297 Ok(runs)
298 }
299
300 pub fn load_run(
302 &self,
303 crate_name: &str,
304 benchmark_name: &str,
305 timestamp: &str,
306 ) -> Result<Option<BaselineData>, std::io::Error> {
307 let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
308 let filename = format!("{}.json", timestamp);
309 let path = bench_dir.join(filename);
310
311 if !path.exists() {
312 return Ok(None);
313 }
314
315 let contents = fs::read_to_string(path)?;
316 let baseline: BaselineData = serde_json::from_str(&contents)?;
317 Ok(Some(baseline))
318 }
319
320 pub fn list_baselines(&self, crate_name: &str) -> Result<Vec<String>, std::io::Error> {
322 let machine_dir = self.machine_dir();
323
324 if !machine_dir.exists() {
325 return Ok(vec![]);
326 }
327
328 let prefix = format!("{}_", crate_name);
329 let mut baselines = Vec::new();
330
331 for entry in fs::read_dir(machine_dir)? {
332 let entry = entry?;
333 let name = entry.file_name().to_string_lossy().to_string();
334
335 if name.starts_with(&prefix) && entry.path().is_dir() {
337 let benchmark_name = name.strip_prefix(&prefix).unwrap_or(&name).to_string();
339 baselines.push(benchmark_name);
340 }
341 else if name.starts_with(&prefix) && name.ends_with(".json") {
343 let benchmark_name = name
344 .strip_prefix(&prefix)
345 .and_then(|s| s.strip_suffix(".json"))
346 .unwrap_or(&name)
347 .to_string();
348 baselines.push(benchmark_name);
349 }
350 }
351
352 Ok(baselines)
353 }
354
355 pub fn load_recent_baselines(
361 &self,
362 crate_name: &str,
363 benchmark_name: &str,
364 count: usize,
365 ) -> Result<Vec<BaselineData>, std::io::Error> {
366 let bench_dir = self.benchmark_dir(crate_name, benchmark_name);
367
368 if !bench_dir.exists() || !bench_dir.is_dir() {
369 return Ok(vec![]);
370 }
371
372 let mut runs: Vec<_> = fs::read_dir(&bench_dir)?
374 .filter_map(|e| e.ok())
375 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
376 .collect();
377
378 if runs.is_empty() {
379 return Ok(vec![]);
380 }
381
382 runs.sort_by_key(|e| e.file_name());
384
385 let mut baselines = Vec::new();
387 for entry in runs.iter().rev() {
388 if baselines.len() >= count {
390 break;
391 }
392
393 let contents = fs::read_to_string(entry.path())?;
394 if let Ok(baseline) = serde_json::from_str::<BaselineData>(&contents) {
395 if !baseline.was_regression {
397 baselines.push(baseline);
398 }
399 }
400 }
401
402 baselines.reverse();
404
405 Ok(baselines)
406 }
407}
408
409impl Default for BaselineManager {
410 fn default() -> Self {
411 Self::new().expect("Failed to get primary MAC address")
412 }
413}
414
415#[derive(Debug, Clone)]
417pub struct ComparisonResult {
418 pub benchmark_name: String,
419 pub comparison: Option<crate::Comparison>,
420 pub is_regression: bool,
421}
422
423pub fn detect_regression_with_cpd(
432 current: &crate::BenchResult,
433 historical: &[BaselineData],
434 threshold: f64,
435 confidence_level: f64,
436 cp_threshold: f64,
437 hazard_rate: f64,
438) -> ComparisonResult {
439 if historical.is_empty() {
440 return ComparisonResult {
441 benchmark_name: current.name.clone(),
442 comparison: None,
443 is_regression: false,
444 };
445 }
446
447 let historical_means: Vec<f64> = historical
449 .iter()
450 .map(|b| b.statistics.mean as f64)
451 .collect();
452
453 let current_mean = current.percentiles.mean.as_nanos() as f64;
454
455 let hist_mean = crate::statistics::mean(&historical_means);
457 let hist_stddev = crate::statistics::standard_deviation(&historical_means);
458
459 let z_score_value = crate::statistics::z_score(current_mean, hist_mean, hist_stddev);
461
462 let z_critical = if (confidence_level - 0.90).abs() < 0.01 {
464 1.282 } else if (confidence_level - 0.95).abs() < 0.01 {
466 1.645 } else if (confidence_level - 0.99).abs() < 0.01 {
468 2.326 } else {
470 1.96 };
472
473 let upper_bound = hist_mean + (z_critical * hist_stddev);
474 let lower_bound = hist_mean - (z_critical * hist_stddev);
475
476 let statistically_significant = current_mean > upper_bound;
478
479 let change_probability = crate::changepoint::bayesian_change_point_probability(
481 current_mean,
482 &historical_means,
483 hazard_rate,
484 );
485
486 let percentage_change = ((current_mean - hist_mean) / hist_mean) * 100.0;
488 let practically_significant = percentage_change > threshold;
489
490 let is_regression = if z_score_value.abs() > 5.0 {
504 statistically_significant && practically_significant
506 } else if z_score_value.abs() > 2.0 {
507 statistically_significant && practically_significant && change_probability > cp_threshold
509 } else {
510 false
512 };
513
514 ComparisonResult {
515 benchmark_name: current.name.clone(),
516 comparison: Some(crate::Comparison {
517 current_mean: current.percentiles.mean,
518 baseline_mean: Duration::from_nanos(hist_mean as u64),
519 percentage_change,
520 baseline_count: historical.len(),
521 z_score: Some(z_score_value),
522 confidence_interval: Some((lower_bound, upper_bound)),
523 change_probability: Some(change_probability),
524 }),
525 is_regression,
526 }
527}
528
529pub fn process_with_baselines(
537 results: &[crate::BenchResult],
538 config: &ComparisonConfig,
539) -> Result<Vec<ComparisonResult>, std::io::Error> {
540 let baseline_manager = BaselineManager::new()?;
541 let mut comparisons = Vec::new();
542
543 for result in results {
544 let crate_name = result.module.split("::").next().unwrap_or("unknown");
546
547 let historical =
549 baseline_manager.load_recent_baselines(crate_name, &result.name, config.window_size)?;
550
551 let comparison_result = if !historical.is_empty() {
552 detect_regression_with_cpd(
554 result,
555 &historical,
556 config.threshold,
557 config.confidence_level,
558 config.cp_threshold,
559 config.hazard_rate,
560 )
561 } else {
562 ComparisonResult {
564 benchmark_name: result.name.clone(),
565 comparison: None,
566 is_regression: false,
567 }
568 };
569
570 let is_regression = comparison_result.is_regression;
571 comparisons.push(comparison_result);
572
573 baseline_manager.save_baseline(crate_name, result, is_regression)?;
575 }
576
577 Ok(comparisons)
578}
579
580pub fn check_regressions_and_exit(comparisons: &[ComparisonResult], config: &ComparisonConfig) {
582 if !config.ci_mode {
583 return;
584 }
585
586 let has_regression = comparisons.iter().any(|c| c.is_regression);
587
588 if has_regression {
589 use colored::Colorize;
590 eprintln!();
591 eprintln!(
592 "{}",
593 format!(
594 "FAILED: Performance regression detected (threshold: {}%)",
595 config.threshold
596 )
597 .red()
598 .bold()
599 );
600 std::process::exit(1);
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use std::time::Duration;
608 use tempfile::TempDir;
609
610 fn create_test_result(name: &str) -> BenchResult {
611 BenchResult {
612 name: name.to_string(),
613 module: "test_module".to_string(),
614 iterations: 100,
615 samples: 10,
616 percentiles: Percentiles {
617 p50: Duration::from_millis(5),
618 p90: Duration::from_millis(10),
619 p99: Duration::from_millis(15),
620 mean: Duration::from_millis(8),
621 },
622 all_timings: vec![Duration::from_millis(5); 10],
623 cpu_samples: vec![],
624 ..Default::default()
625 }
626 }
627
628 #[test]
629 fn test_baseline_data_conversion() {
630 let result = create_test_result("test_bench");
631 let machine_id = "0123456789abcdef".to_string(); let baseline = BaselineData::from_bench_result(&result, machine_id.clone(), false);
634
635 assert_eq!(baseline.benchmark_name, "test_bench");
636 assert_eq!(baseline.module, "test_module");
637 assert_eq!(baseline.machine_id, machine_id);
638 assert_eq!(baseline.iterations, 100);
639 assert_eq!(baseline.statistics.sample_count, 10);
640 assert_eq!(baseline.samples.len(), 10);
641
642 let converted = baseline.to_bench_result();
643 assert_eq!(converted.name, result.name);
644 assert_eq!(converted.module, result.module);
645 assert_eq!(converted.percentiles.p90, result.percentiles.p90);
646 }
647
648 #[test]
649 fn test_save_and_load_baseline() {
650 let temp_dir = TempDir::new().unwrap();
651 let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
652
653 let result = create_test_result("test_bench");
654
655 manager.save_baseline("my_crate", &result, false).unwrap();
657
658 let loaded = manager.load_baseline("my_crate", "test_bench").unwrap();
660 assert!(loaded.is_some());
661
662 let baseline = loaded.unwrap();
663 assert_eq!(baseline.benchmark_name, "test_bench");
664 assert_eq!(baseline.module, "test_module");
665 assert!(baseline.percentiles.is_some());
666 assert_eq!(baseline.percentiles.unwrap().p90, Duration::from_millis(10));
667 }
668
669 #[test]
670 fn test_load_nonexistent_baseline() {
671 let temp_dir = TempDir::new().unwrap();
672 let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
673
674 let loaded = manager.load_baseline("my_crate", "nonexistent").unwrap();
675 assert!(loaded.is_none());
676 }
677
678 #[test]
679 fn test_has_baseline() {
680 let temp_dir = TempDir::new().unwrap();
681 let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
682
683 let result = create_test_result("test_bench");
684
685 assert!(!manager.has_baseline("my_crate", "test_bench"));
686
687 manager.save_baseline("my_crate", &result, false).unwrap();
688
689 assert!(manager.has_baseline("my_crate", "test_bench"));
690 }
691
692 #[test]
693 fn test_list_baselines() {
694 let temp_dir = TempDir::new().unwrap();
695 let manager = BaselineManager::with_root_dir(temp_dir.path()).unwrap();
696
697 let result1 = create_test_result("bench1");
698 let result2 = create_test_result("bench2");
699
700 manager.save_baseline("my_crate", &result1, false).unwrap();
701 manager.save_baseline("my_crate", &result2, false).unwrap();
702
703 let mut baselines = manager.list_baselines("my_crate").unwrap();
704 baselines.sort();
705
706 assert_eq!(baselines, vec!["bench1", "bench2"]);
707 }
708
709 #[test]
710 fn test_get_primary_mac_address() {
711 let result = get_primary_mac_address();
713
714 assert!(result.is_ok(), "Failed to get machine ID: {:?}", result);
716
717 let machine_id = result.unwrap();
718
719 assert_eq!(
721 machine_id.len(),
722 16,
723 "Machine ID should be 16 characters: {}",
724 machine_id
725 );
726
727 assert_eq!(
729 machine_id,
730 machine_id.to_lowercase(),
731 "Machine ID should be lowercase"
732 );
733 assert!(
734 machine_id.chars().all(|c| c.is_ascii_hexdigit()),
735 "Machine ID should contain only hex digits"
736 );
737 }
738
739 #[test]
740 fn test_mac_address_format() {
741 let manager_result = BaselineManager::new();
743 assert!(
744 manager_result.is_ok(),
745 "Failed to create BaselineManager: {:?}",
746 manager_result
747 );
748
749 let manager = manager_result.unwrap();
750
751 assert_eq!(
753 manager.machine_id.len(),
754 16,
755 "Machine ID should be 16 characters"
756 );
757 assert_eq!(manager.machine_id, manager.machine_id.to_lowercase());
758 assert!(manager.machine_id.chars().all(|c| c.is_ascii_hexdigit()));
759 }
760}