1use crate::{
2 config,
3 data::MeasurementData,
4 measurement_retrieval::{self, summarize_measurements},
5 stats::{self, DispersionMethod, ReductionFunc, VecAggregation},
6};
7use anyhow::{anyhow, bail, Result};
8use itertools::Itertools;
9use log::error;
10use sparklines::spark;
11use std::cmp::Ordering;
12use std::iter;
13
14fn format_z_score_display(z_score: f64) -> String {
18 if z_score.is_finite() {
19 format!(" {:.2}", z_score)
20 } else {
21 String::new()
22 }
23}
24
25fn get_direction_arrow(head_mean: f64, tail_mean: f64) -> &'static str {
28 match head_mean.partial_cmp(&tail_mean).unwrap() {
29 Ordering::Greater => "↑",
30 Ordering::Less => "↓",
31 Ordering::Equal => "→",
32 }
33}
34
35#[derive(Debug, PartialEq)]
36struct AuditResult {
37 message: String,
38 passed: bool,
39}
40
41pub fn audit_multiple(
42 measurements: &[String],
43 max_count: usize,
44 min_count: u16,
45 selectors: &[(String, String)],
46 summarize_by: ReductionFunc,
47 sigma: f64,
48 dispersion_method: DispersionMethod,
49) -> Result<()> {
50 let mut failed = false;
51
52 for measurement in measurements {
53 let result = audit(
54 measurement,
55 max_count,
56 min_count,
57 selectors,
58 summarize_by,
59 sigma,
60 dispersion_method,
61 )?;
62
63 println!("{}", result.message);
64
65 if !result.passed {
66 failed = true;
67 }
68 }
69
70 if failed {
71 bail!("One or more measurements failed audit.");
72 }
73
74 Ok(())
75}
76
77fn audit(
78 measurement: &str,
79 max_count: usize,
80 min_count: u16,
81 selectors: &[(String, String)],
82 summarize_by: ReductionFunc,
83 sigma: f64,
84 dispersion_method: DispersionMethod,
85) -> Result<AuditResult> {
86 let all = measurement_retrieval::walk_commits(max_count)?;
87
88 let filter_by =
90 |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
91
92 let mut aggregates = measurement_retrieval::take_while_same_epoch(summarize_measurements(
93 all,
94 &summarize_by,
95 &filter_by,
96 ));
97
98 let head = aggregates
99 .next()
100 .ok_or(anyhow!("No commit at HEAD"))
101 .and_then(|s| {
102 s.and_then(|cs| {
103 cs.measurement
104 .map(|m| m.val)
105 .ok_or(anyhow!("No measurement for HEAD."))
106 })
107 })?;
108
109 let tail: Vec<_> = aggregates
110 .filter_map_ok(|cs| cs.measurement.map(|m| m.val))
111 .take(max_count)
112 .try_collect()?;
113
114 audit_with_data(measurement, head, tail, min_count, sigma, dispersion_method)
115}
116
117fn audit_with_data(
120 measurement: &str,
121 head: f64,
122 tail: Vec<f64>,
123 min_count: u16,
124 sigma: f64,
125 dispersion_method: DispersionMethod,
126) -> Result<AuditResult> {
127 let head_summary = stats::aggregate_measurements(iter::once(&head));
128 let tail_summary = stats::aggregate_measurements(tail.iter());
129
130 if tail_summary.len < min_count.into() {
132 let number_measurements = tail_summary.len;
133 let plural_s = if number_measurements > 1 { "s" } else { "" };
135 error!("Only {number_measurements} measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test.");
136 return Ok(AuditResult {
137 message: format!("⏭️ '{measurement}'\nOnly {number_measurements} measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test."),
138 passed: true,
139 });
140 }
141
142 let direction = get_direction_arrow(head_summary.mean, tail_summary.mean);
143
144 let mut tail_measurements = tail.clone();
145 let tail_median = tail_measurements.median().unwrap_or(0.0);
146
147 let all_measurements = tail.into_iter().chain(iter::once(head)).collect::<Vec<_>>();
148
149 let relative_min = all_measurements
151 .iter()
152 .min_by(|a, b| a.partial_cmp(b).unwrap())
153 .unwrap()
154 / tail_median
155 - 1.0;
156 let relative_max = all_measurements
157 .iter()
158 .max_by(|a, b| a.partial_cmp(b).unwrap())
159 .unwrap()
160 / tail_median
161 - 1.0;
162
163 let head_relative_deviation = (head / tail_median - 1.0).abs() * 100.0;
165
166 let min_relative_deviation = config::audit_min_relative_deviation(measurement);
168 let threshold_applied = min_relative_deviation.is_some();
169
170 let passed_due_to_threshold = min_relative_deviation
172 .map(|threshold| head_relative_deviation < threshold)
173 .unwrap_or(false);
174
175 let z_score = head_summary.z_score_with_method(&tail_summary, dispersion_method);
176 let z_score_display = format_z_score_display(z_score);
177
178 let method_name = match dispersion_method {
179 DispersionMethod::StandardDeviation => "stddev",
180 DispersionMethod::MedianAbsoluteDeviation => "mad",
181 };
182
183 let text_summary = format!(
184 "z-score ({method_name}): {direction}{}\nHead: {}\nTail: {}\n [{:+.1}% – {:+.1}%] {}",
185 z_score_display,
186 &head_summary,
187 &tail_summary,
188 (relative_min * 100.0),
189 (relative_max * 100.0),
190 spark(all_measurements.as_slice()),
191 );
192
193 let z_score_exceeds_sigma =
195 head_summary.is_significant(&tail_summary, sigma, dispersion_method);
196
197 let passed = !z_score_exceeds_sigma || passed_due_to_threshold;
199
200 let threshold_note = if threshold_applied && passed_due_to_threshold {
202 format!(
203 "\nNote: Passed due to relative deviation ({:.1}%) being below threshold ({:.1}%)",
204 head_relative_deviation,
205 min_relative_deviation.unwrap()
206 )
207 } else {
208 String::new()
209 };
210
211 if !passed {
213 return Ok(AuditResult {
214 message: format!(
215 "❌ '{measurement}'\nHEAD differs significantly from tail measurements.\n{text_summary}{threshold_note}"
216 ),
217 passed: false,
218 });
219 }
220
221 Ok(AuditResult {
222 message: format!("✅ '{measurement}'\n{text_summary}{threshold_note}"),
223 passed: true,
224 })
225}
226
227#[cfg(test)]
228mod test {
229 use super::*;
230
231 #[test]
232 fn test_format_z_score_display() {
233 let test_cases = vec![
235 (2.5_f64, " 2.50"),
236 (0.0_f64, " 0.00"),
237 (-1.5_f64, " -1.50"),
238 (999.999_f64, " 1000.00"),
239 (0.001_f64, " 0.00"),
240 (f64::INFINITY, ""),
241 (f64::NEG_INFINITY, ""),
242 (f64::NAN, ""),
243 ];
244
245 for (z_score, expected) in test_cases {
246 let result = format_z_score_display(z_score);
247 assert_eq!(result, expected, "Failed for z_score: {}", z_score);
248 }
249 }
250
251 #[test]
252 fn test_direction_arrows() {
253 let test_cases = vec![
255 (5.0_f64, 3.0_f64, "↑"), (1.0_f64, 3.0_f64, "↓"), (3.0_f64, 3.0_f64, "→"), ];
259
260 for (head_mean, tail_mean, expected) in test_cases {
261 let result = get_direction_arrow(head_mean, tail_mean);
262 assert_eq!(
263 result, expected,
264 "Failed for head_mean: {}, tail_mean: {}",
265 head_mean, tail_mean
266 );
267 }
268 }
269
270 #[test]
271 fn test_audit_with_different_dispersion_methods() {
272 let head_value = 35.0;
276 let tail_values = [30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
277
278 let head_summary = stats::aggregate_measurements(std::iter::once(&head_value));
279 let tail_summary = stats::aggregate_measurements(tail_values.iter());
280
281 let z_score_stddev =
283 head_summary.z_score_with_method(&tail_summary, DispersionMethod::StandardDeviation);
284 let z_score_mad = head_summary
285 .z_score_with_method(&tail_summary, DispersionMethod::MedianAbsoluteDeviation);
286
287 assert!(
290 z_score_stddev < z_score_mad,
291 "stddev z-score ({}) should be smaller than MAD z-score ({}) with outlier data",
292 z_score_stddev,
293 z_score_mad
294 );
295
296 assert!(z_score_stddev > 0.0);
298 assert!(z_score_mad > 0.0);
299 }
300
301 #[test]
302 fn test_dispersion_method_conversion() {
303 let cli_stddev = git_perf_cli_types::DispersionMethod::StandardDeviation;
307 let stats_stddev: DispersionMethod = cli_stddev.into();
308 assert_eq!(stats_stddev, DispersionMethod::StandardDeviation);
309
310 let cli_mad = git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation;
312 let stats_mad: DispersionMethod = cli_mad.into();
313 assert_eq!(stats_mad, DispersionMethod::MedianAbsoluteDeviation);
314 }
315
316 #[test]
317 fn test_audit_multiple_with_no_measurements() {
318 let result = audit_multiple(
321 &[], 100,
323 1,
324 &[],
325 ReductionFunc::Mean,
326 2.0,
327 DispersionMethod::StandardDeviation,
328 );
329
330 assert!(
332 result.is_ok(),
333 "audit_multiple should succeed with empty measurement list"
334 );
335 }
336
337 #[test]
340 fn test_min_count_boundary_condition() {
341 let result = audit_with_data(
344 "test_measurement",
345 15.0,
346 vec![10.0, 11.0, 12.0], 3, 2.0,
349 DispersionMethod::StandardDeviation,
350 );
351
352 assert!(result.is_ok());
353 let audit_result = result.unwrap();
354 assert!(!audit_result.message.contains("Skipping test"));
356
357 let result = audit_with_data(
359 "test_measurement",
360 15.0,
361 vec![10.0, 11.0], 3, 2.0,
364 DispersionMethod::StandardDeviation,
365 );
366
367 assert!(result.is_ok());
368 let audit_result = result.unwrap();
369 assert!(audit_result.message.contains("Skipping test"));
370 assert!(audit_result.passed); }
372
373 #[test]
374 fn test_pluralization_logic() {
375 let result = audit_with_data(
378 "test_measurement",
379 15.0,
380 vec![], 5, 2.0,
383 DispersionMethod::StandardDeviation,
384 );
385
386 assert!(result.is_ok());
387 let message = result.unwrap().message;
388 assert!(message.contains("0 measurement found")); assert!(!message.contains("0 measurements found")); let result = audit_with_data(
393 "test_measurement",
394 15.0,
395 vec![10.0], 5, 2.0,
398 DispersionMethod::StandardDeviation,
399 );
400
401 assert!(result.is_ok());
402 let message = result.unwrap().message;
403 assert!(message.contains("1 measurement found")); let result = audit_with_data(
407 "test_measurement",
408 15.0,
409 vec![10.0, 11.0], 5, 2.0,
412 DispersionMethod::StandardDeviation,
413 );
414
415 assert!(result.is_ok());
416 let message = result.unwrap().message;
417 assert!(message.contains("2 measurements found")); }
419
420 #[test]
421 fn test_relative_calculations_division_vs_modulo() {
422 let result = audit_with_data(
425 "test_measurement",
426 25.0, vec![10.0, 10.0, 10.0], 1,
429 10.0, DispersionMethod::StandardDeviation,
431 );
432
433 assert!(result.is_ok());
434 let audit_result = result.unwrap();
435
436 assert!(audit_result.message.contains("[+0.0% – +150.0%]"));
446
447 assert!(!audit_result.message.contains("[-100.0% – -50.0%]"));
449 assert!(!audit_result.message.contains("-100.0%"));
450 assert!(!audit_result.message.contains("-50.0%"));
451 }
452
453 #[test]
454 fn test_core_pass_fail_logic() {
455 let result = audit_with_data(
460 "test_measurement", 100.0, vec![10.0, 10.0, 10.0, 10.0, 10.0], 1,
464 0.5, DispersionMethod::StandardDeviation,
466 );
467
468 assert!(result.is_ok());
469 let audit_result = result.unwrap();
470 assert!(!audit_result.passed); assert!(audit_result.message.contains("❌"));
472
473 let result = audit_with_data(
475 "test_measurement",
476 10.2, vec![10.0, 10.1, 10.0, 10.1, 10.0], 1,
479 100.0, DispersionMethod::StandardDeviation,
481 );
482
483 assert!(result.is_ok());
484 let audit_result = result.unwrap();
485 assert!(audit_result.passed); assert!(audit_result.message.contains("✅"));
487 }
488
489 #[test]
490 fn test_final_result_logic() {
491 let result = audit_with_data(
496 "test_measurement",
497 1000.0, vec![10.0, 10.0, 10.0, 10.0, 10.0],
499 1,
500 0.1, DispersionMethod::StandardDeviation,
502 );
503
504 assert!(result.is_ok());
505 let audit_result = result.unwrap();
506 assert!(!audit_result.passed);
507 assert!(audit_result.message.contains("❌"));
508 assert!(audit_result.message.contains("differs significantly"));
509
510 let result = audit_with_data(
512 "test_measurement",
513 10.01, vec![10.0, 10.1, 10.0, 10.1, 10.0], 1,
516 100.0, DispersionMethod::StandardDeviation,
518 );
519
520 assert!(result.is_ok());
521 let audit_result = result.unwrap();
522 assert!(audit_result.passed);
523 assert!(audit_result.message.contains("✅"));
524 assert!(!audit_result.message.contains("differs significantly"));
525 }
526
527 #[test]
528 fn test_dispersion_methods_produce_different_results() {
529 let head = 35.0;
531 let tail = vec![30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
532
533 let result_stddev = audit_with_data(
534 "test_measurement",
535 head,
536 tail.clone(),
537 1,
538 2.0,
539 DispersionMethod::StandardDeviation,
540 );
541
542 let result_mad = audit_with_data(
543 "test_measurement",
544 head,
545 tail,
546 1,
547 2.0,
548 DispersionMethod::MedianAbsoluteDeviation,
549 );
550
551 assert!(result_stddev.is_ok());
552 assert!(result_mad.is_ok());
553
554 let stddev_result = result_stddev.unwrap();
555 let mad_result = result_mad.unwrap();
556
557 assert!(stddev_result.message.contains("stddev"));
559 assert!(mad_result.message.contains("mad"));
560 }
561}