1use crate::{
2 data::MeasurementData,
3 measurement_retrieval::{self, summarize_measurements},
4 stats,
5};
6use anyhow::{anyhow, bail, Result};
7use git_perf_cli_types::ReductionFunc;
8use itertools::Itertools;
9use log::error;
10use sparklines::spark;
11use std::cmp::Ordering;
12use std::iter;
13
14#[derive(Debug, PartialEq)]
15struct AuditResult {
16 message: String,
17 passed: bool,
18}
19
20pub fn audit_multiple(
21 measurements: &[String],
22 max_count: usize,
23 min_count: u16,
24 selectors: &[(String, String)],
25 summarize_by: ReductionFunc,
26 sigma: f64,
27) -> Result<()> {
28 let mut failed = false;
29
30 for measurement in measurements {
31 let result = audit(
32 measurement,
33 max_count,
34 min_count,
35 selectors,
36 summarize_by,
37 sigma,
38 )?;
39
40 println!("{}", result.message);
41
42 if !result.passed {
43 failed = true;
44 }
45 }
46
47 if failed {
48 bail!("One or more measurements failed audit.");
49 }
50
51 Ok(())
52}
53
54fn audit(
55 measurement: &str,
56 max_count: usize,
57 min_count: u16,
58 selectors: &[(String, String)],
59 summarize_by: ReductionFunc,
60 sigma: f64,
61) -> Result<AuditResult> {
62 let all = measurement_retrieval::walk_commits(max_count)?;
63
64 let filter_by = |m: &MeasurementData| {
65 m.name == measurement
66 && selectors
67 .iter()
68 .all(|s| m.key_values.get(&s.0).map(|v| *v == s.1).unwrap_or(false))
69 };
70
71 let mut aggregates = measurement_retrieval::take_while_same_epoch(summarize_measurements(
72 all,
73 &summarize_by,
74 &filter_by,
75 ));
76
77 let head = aggregates
78 .next()
79 .ok_or(anyhow!("No commit at HEAD"))
80 .and_then(|s| {
81 s.and_then(|cs| {
82 cs.measurement
83 .map(|m| m.val)
84 .ok_or(anyhow!("No measurement for HEAD."))
85 })
86 })?;
87
88 let tail: Vec<_> = aggregates
89 .filter_map_ok(|cs| cs.measurement.map(|m| m.val))
90 .take(max_count)
91 .try_collect()?;
92
93 let head_summary = stats::aggregate_measurements(iter::once(&head));
94 let tail_summary = stats::aggregate_measurements(tail.iter());
95
96 if tail_summary.len < min_count.into() {
97 let number_measurements = tail_summary.len;
98 let plural_s = if number_measurements > 1 { "s" } else { "" };
99 error!("Only {number_measurements} measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test.");
100 return Ok(AuditResult {
101 message: format!("Only {number_measurements} measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test."),
102 passed: true,
103 });
104 }
105
106 let direction = match head_summary.mean.partial_cmp(&tail_summary.mean).unwrap() {
107 Ordering::Greater => "↑",
108 Ordering::Less => "↓",
109 Ordering::Equal => "→",
110 };
111
112 let all_measurements = tail.into_iter().chain(iter::once(head)).collect::<Vec<_>>();
113 let average = all_measurements.iter().sum::<f64>() / all_measurements.len() as f64;
114 let relative_min = all_measurements
115 .iter()
116 .min_by(|a, b| a.partial_cmp(b).unwrap())
117 .unwrap()
118 / average
119 - 1.0;
120 let relative_max = all_measurements
121 .iter()
122 .max_by(|a, b| a.partial_cmp(b).unwrap())
123 .unwrap()
124 / average
125 - 1.0;
126
127 let text_summary = format!(
128 "z-score: {direction} {:.2}\nHead: {}\nTail: {}\n [{:+.1}% – {:+.1}%] {}",
129 head_summary.z_score(&tail_summary),
130 &head_summary,
131 &tail_summary,
132 (relative_min * 100.0),
133 (relative_max * 100.0),
134 spark(all_measurements.as_slice()),
135 );
136
137 if head_summary.z_score(&tail_summary) > sigma {
138 return Ok(AuditResult {
139 message: format!(
140 "❌ '{measurement}'\nHEAD differs significantly from tail measurements.\n{text_summary}"
141 ),
142 passed: false,
143 });
144 }
145
146 Ok(AuditResult {
147 message: format!("✅ '{measurement}'\n{text_summary}"),
148 passed: true,
149 })
150}