1use crate::{
2 config,
3 data::{Commit, MeasurementData},
4 defaults,
5 measurement_retrieval::{self, summarize_measurements},
6 stats::{self, DispersionMethod, ReductionFunc, StatsWithUnit, VecAggregation},
7};
8use anyhow::{anyhow, bail, Result};
9use itertools::Itertools;
10use log::info;
11use sparklines::spark;
12use std::cmp::Ordering;
13use std::collections::HashSet;
14use std::iter;
15
16fn format_z_score_display(z_score: f64) -> String {
20 if z_score.is_finite() {
21 format!(" {:.2}", z_score)
22 } else {
23 String::new()
24 }
25}
26
27fn get_direction_arrow(head_mean: f64, tail_mean: f64) -> &'static str {
31 match head_mean.partial_cmp(&tail_mean) {
32 Some(Ordering::Greater) => "↑",
33 Some(Ordering::Less) => "↓",
34 Some(Ordering::Equal) | None => "→",
35 }
36}
37
38#[derive(Debug, PartialEq)]
39struct AuditResult {
40 message: String,
41 passed: bool,
42}
43
44#[derive(Debug, PartialEq)]
46pub(crate) struct ResolvedAuditParams {
47 pub min_count: u16,
48 pub summarize_by: ReductionFunc,
49 pub sigma: f64,
50 pub dispersion_method: DispersionMethod,
51}
52
53pub(crate) fn resolve_audit_params(
59 measurement: &str,
60 cli_min_count: Option<u16>,
61 cli_summarize_by: Option<ReductionFunc>,
62 cli_sigma: Option<f64>,
63 cli_dispersion_method: Option<DispersionMethod>,
64) -> ResolvedAuditParams {
65 let min_count = cli_min_count
66 .or_else(|| config::audit_min_measurements(measurement))
67 .unwrap_or(defaults::DEFAULT_MIN_MEASUREMENTS);
68
69 let summarize_by = cli_summarize_by
70 .or_else(|| config::audit_aggregate_by(measurement).map(ReductionFunc::from))
71 .unwrap_or(ReductionFunc::Min);
72
73 let sigma = cli_sigma
74 .or_else(|| config::audit_sigma(measurement))
75 .unwrap_or(defaults::DEFAULT_SIGMA);
76
77 let dispersion_method = cli_dispersion_method
78 .or_else(|| {
79 Some(DispersionMethod::from(config::audit_dispersion_method(
80 measurement,
81 )))
82 })
83 .unwrap_or(DispersionMethod::StandardDeviation);
84
85 ResolvedAuditParams {
86 min_count,
87 summarize_by,
88 sigma,
89 dispersion_method,
90 }
91}
92
93fn discover_matching_measurements(
96 commits: &[Result<Commit>],
97 filters: &[regex::Regex],
98 selectors: &[(String, String)],
99) -> Vec<String> {
100 let mut unique_measurements = HashSet::new();
101
102 for commit in commits.iter().flatten() {
103 for measurement in &commit.measurements {
104 if !crate::filter::matches_any_filter(&measurement.name, filters) {
106 continue;
107 }
108
109 if !measurement.key_values_is_superset_of(selectors) {
111 continue;
112 }
113
114 unique_measurements.insert(measurement.name.clone());
116 }
117 }
118
119 let mut result: Vec<String> = unique_measurements.into_iter().collect();
121 result.sort();
122 result
123}
124
125fn compute_group_values(
133 commits: &[Result<Commit>],
134 measurement_name: &str,
135 selectors: &[(String, String)],
136 separate_by: &[String],
137) -> Result<Vec<Vec<String>>> {
138 if separate_by.is_empty() {
139 return Ok(vec![vec![]]);
140 }
141
142 let mut unique_groups = HashSet::new();
143
144 for commit in commits.iter().flatten() {
145 for measurement in &commit.measurements {
146 if measurement.name != measurement_name {
148 continue;
149 }
150
151 if !measurement.key_values_is_superset_of(selectors) {
153 continue;
154 }
155
156 let values: Vec<String> = separate_by
158 .iter()
159 .filter_map(|key| measurement.key_values.get(key).cloned())
160 .collect();
161
162 if values.len() == separate_by.len() {
164 unique_groups.insert(values);
165 }
166 }
167 }
168
169 if unique_groups.is_empty() {
170 bail!(
171 "Measurement '{}': Invalid separator supplied, no measurements have all required keys: {:?}",
172 measurement_name,
173 separate_by
174 );
175 }
176
177 let mut result: Vec<Vec<String>> = unique_groups.into_iter().collect();
179 result.sort();
180 Ok(result)
181}
182
183fn format_group_label(separate_by: &[String], group_values: &[String]) -> String {
186 separate_by
187 .iter()
188 .zip(group_values.iter())
189 .map(|(key, value)| format!("{}={}", key, value))
190 .collect::<Vec<_>>()
191 .join("/")
192}
193
194#[allow(clippy::too_many_arguments)]
195pub fn audit_multiple(
196 start_commit: &str,
197 max_count: usize,
198 since: Option<&str>,
199 until: Option<&str>,
200 min_count: Option<u16>,
201 selectors: &[(String, String)],
202 summarize_by: Option<ReductionFunc>,
203 sigma: Option<f64>,
204 dispersion_method: Option<DispersionMethod>,
205 combined_patterns: &[String],
206 separate_by: &[String],
207 _no_change_point_warning: bool, ) -> Result<()> {
209 if combined_patterns.is_empty() {
211 return Ok(());
212 }
213
214 let selector_keys: std::collections::HashSet<&str> =
216 selectors.iter().map(|(k, _)| k.as_str()).collect();
217 for key in separate_by {
218 if selector_keys.contains(key.as_str()) {
219 bail!(
220 "separate-by key '{}' already present in selectors; remove it from --selectors or --separate-by",
221 key
222 );
223 }
224 }
225
226 let filters = crate::filter::compile_filters(combined_patterns)?;
229
230 let all_commits: Vec<Result<Commit>> =
233 measurement_retrieval::walk_commits_from(start_commit, max_count, since, until)?.collect();
234
235 let measurements_to_audit = discover_matching_measurements(&all_commits, &filters, selectors);
238
239 if measurements_to_audit.is_empty() {
241 if all_commits.is_empty() {
243 bail!("No commit at HEAD");
244 }
245 let has_any_measurements = all_commits.iter().any(|commit_result| {
247 if let Ok(commit) = commit_result {
248 !commit.measurements.is_empty()
249 } else {
250 false
251 }
252 });
253
254 if !has_any_measurements {
255 bail!("No measurement for HEAD");
257 }
258 bail!("No measurements found matching the provided patterns");
260 }
261
262 let mut failed = false;
263 let mut total_groups = 0;
264 let mut passed_groups = 0;
265
266 for measurement in measurements_to_audit {
268 let params = resolve_audit_params(
269 &measurement,
270 min_count,
271 summarize_by,
272 sigma,
273 dispersion_method,
274 );
275
276 if (max_count as u16) < params.min_count {
278 eprintln!(
279 "⚠️ Warning: --max_count ({}) is less than min_measurements ({}) for measurement '{}'.",
280 max_count, params.min_count, measurement
281 );
282 eprintln!(
283 " This limits available historical data and may prevent achieving statistical significance."
284 );
285 }
286
287 let groups = compute_group_values(&all_commits, &measurement, selectors, separate_by)?;
289
290 for group_values in &groups {
292 let mut group_selectors = selectors.to_vec();
294 for (key, value) in separate_by.iter().zip(group_values.iter()) {
295 group_selectors.push((key.clone(), value.clone()));
296 }
297
298 let group_label = if separate_by.is_empty() {
300 String::new()
301 } else {
302 format!(" ({})", format_group_label(separate_by, group_values))
303 };
304
305 let result = audit_with_commits(
306 &measurement,
307 &all_commits,
308 params.min_count,
309 &group_selectors,
310 params.summarize_by,
311 params.sigma,
312 params.dispersion_method,
313 )?;
314
315 if !separate_by.is_empty() {
325 println!("Auditing measurement \"{}\"{}:", measurement, group_label);
327 for line in result.message.lines() {
329 println!(" {}", line);
330 }
331 println!(); } else {
333 println!("{}", result.message);
334 }
335
336 if !separate_by.is_empty() {
337 total_groups += 1;
338 if result.passed {
339 passed_groups += 1;
340 }
341 }
342 if !result.passed {
343 failed = true;
344 }
345 }
346 }
347
348 if !separate_by.is_empty() {
350 if failed {
351 println!(
352 "Overall: FAILED ({}/{} groups passed)",
353 passed_groups, total_groups
354 );
355 } else {
356 println!(
357 "Overall: PASSED ({}/{} groups passed)",
358 passed_groups, total_groups
359 );
360 }
361 }
362
363 if failed {
364 bail!("One or more measurements failed audit.");
365 }
366
367 Ok(())
368}
369
370fn audit_with_commits(
374 measurement: &str,
375 commits: &[Result<Commit>],
376 min_count: u16,
377 selectors: &[(String, String)],
378 summarize_by: ReductionFunc,
379 sigma: f64,
380 dispersion_method: DispersionMethod,
381) -> Result<AuditResult> {
382 let commits_iter = commits.iter().map(|r| match r {
385 Ok(commit) => Ok(Commit {
386 commit: commit.commit.clone(),
387 title: commit.title.clone(),
388 author: commit.author.clone(),
389 measurements: commit.measurements.clone(),
390 }),
391 Err(e) => Err(anyhow::anyhow!("{}", e)),
392 });
393
394 let filter_by =
396 |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
397
398 let mut aggregates = measurement_retrieval::take_while_same_epoch(summarize_measurements(
399 commits_iter,
400 &summarize_by,
401 &filter_by,
402 ));
403
404 let head = aggregates
405 .next()
406 .ok_or(anyhow!("No commit at HEAD"))
407 .and_then(|s| {
408 s.and_then(|cs| {
409 cs.measurement
410 .map(|m| m.val)
411 .ok_or(anyhow!("No measurement for HEAD."))
412 })
413 })?;
414
415 let tail: Vec<_> = aggregates
416 .filter_map_ok(|cs| cs.measurement.map(|m| m.val))
417 .try_collect()?;
418
419 audit_with_data(
420 measurement,
421 head,
422 tail,
423 min_count,
424 sigma,
425 dispersion_method,
426 summarize_by,
427 )
428}
429
430fn audit_with_data(
433 measurement: &str,
434 head: f64,
435 tail: Vec<f64>,
436 min_count: u16,
437 sigma: f64,
438 dispersion_method: DispersionMethod,
439 summarize_by: ReductionFunc,
440) -> Result<AuditResult> {
441 assert!(min_count >= 2, "min_count must be at least 2");
445
446 let unit = config::measurement_unit(measurement);
448 let unit_str = unit.as_deref();
449
450 let head_summary = stats::aggregate_measurements(iter::once(&head));
451 let tail_summary = stats::aggregate_measurements(tail.iter());
452
453 let all_measurements = tail.into_iter().chain(iter::once(head)).collect::<Vec<_>>();
455
456 let mut tail_measurements = all_measurements.clone();
457 tail_measurements.pop(); let tail_median = tail_measurements.median().unwrap_or_default();
459
460 let min_val = all_measurements
462 .iter()
463 .min_by(|a, b| a.partial_cmp(b).unwrap())
464 .unwrap();
465 let max_val = all_measurements
466 .iter()
467 .max_by(|a, b| a.partial_cmp(b).unwrap())
468 .unwrap();
469
470 let tail_median_is_zero = tail_median.abs() < f64::EPSILON;
474
475 let sparkline = if tail_median_is_zero {
476 format!(
478 " [{} – {}] {}",
479 min_val,
480 max_val,
481 spark(all_measurements.as_slice())
482 )
483 } else {
484 let relative_min = min_val / tail_median - 1.0;
487 let relative_max = max_val / tail_median - 1.0;
488
489 format!(
490 " [{:+.2}% – {:+.2}%] {}",
491 (relative_min * 100.0),
492 (relative_max * 100.0),
493 spark(all_measurements.as_slice())
494 )
495 };
496
497 let build_summary = || -> String {
500 let mut summary = String::new();
501
502 let total_measurements = all_measurements.len();
504
505 if total_measurements == 1 {
507 let head_display = StatsWithUnit {
508 stats: &head_summary,
509 unit: unit_str,
510 };
511 summary.push_str(&format!("Head: {}\n", head_display));
512 } else if total_measurements >= 2 {
513 let direction = get_direction_arrow(head_summary.mean, tail_summary.mean);
515 let z_score = head_summary.z_score_with_method(&tail_summary, dispersion_method);
516 let z_score_display = format_z_score_display(z_score);
517 let method_name = match dispersion_method {
518 DispersionMethod::StandardDeviation => "stddev",
519 DispersionMethod::MedianAbsoluteDeviation => "mad",
520 };
521
522 let head_display = StatsWithUnit {
523 stats: &head_summary,
524 unit: unit_str,
525 };
526 let tail_display = StatsWithUnit {
527 stats: &tail_summary,
528 unit: unit_str,
529 };
530
531 summary.push_str(&format!("Aggregation: {summarize_by}\n"));
532 summary.push_str(&format!(
533 "z-score ({method_name}): {direction}{}\n",
534 z_score_display
535 ));
536 summary.push_str(&format!("Head: {}\n", head_display));
537 summary.push_str(&format!("Tail: {}\n", tail_display));
538 summary.push_str(&sparkline);
539 }
540 summary
543 };
544
545 if tail_summary.len < min_count.into() {
547 let number_measurements = tail_summary.len;
548 let plural_s = if number_measurements == 1 { "" } else { "s" };
550 info!("Only {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test.");
551
552 let mut skip_message = format!(
553 "⏭️ '{measurement}'\nOnly {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test."
554 );
555
556 let summary = build_summary();
558 if !summary.is_empty() {
559 skip_message.push('\n');
560 skip_message.push_str(&summary);
561 }
562
563 return Ok(AuditResult {
564 message: skip_message,
565 passed: true,
566 });
567 }
568
569 let head_relative_deviation = (head / tail_median - 1.0).abs() * 100.0;
572
573 let head_absolute_deviation = (head - tail_median).abs();
575
576 let min_relative_deviation = config::audit_min_relative_deviation(measurement);
578 let min_absolute_deviation = config::audit_min_absolute_deviation(measurement);
579
580 let passed_due_to_relative_threshold = min_relative_deviation
582 .map(|threshold| head_relative_deviation < threshold)
583 .unwrap_or(false);
584
585 let passed_due_to_absolute_threshold = min_absolute_deviation
586 .map(|threshold| head_absolute_deviation < threshold)
587 .unwrap_or(false);
588
589 let passed_due_to_threshold =
590 passed_due_to_relative_threshold || passed_due_to_absolute_threshold;
591
592 let text_summary = build_summary();
593
594 let z_score_exceeds_sigma =
596 head_summary.is_significant(&tail_summary, sigma, dispersion_method);
597
598 let passed = !z_score_exceeds_sigma || passed_due_to_threshold;
600
601 let threshold_note = if z_score_exceeds_sigma {
604 let mut notes = Vec::new();
605 if passed_due_to_relative_threshold {
606 notes.push(format!(
607 "Note: Passed due to relative deviation ({:.1}%) being below threshold ({:.1}%)",
608 head_relative_deviation,
609 min_relative_deviation.unwrap()
610 ));
611 }
612 if passed_due_to_absolute_threshold {
613 notes.push(format!(
614 "Note: Passed due to absolute deviation ({:.1}) being below threshold ({:.1})",
615 head_absolute_deviation,
616 min_absolute_deviation.unwrap()
617 ));
618 }
619 if notes.is_empty() {
620 String::new()
621 } else {
622 format!("\n{}", notes.join("\n"))
623 }
624 } else {
625 String::new()
626 };
627
628 if !passed {
630 return Ok(AuditResult {
631 message: format!(
632 "❌ '{measurement}'\nHEAD differs significantly from tail measurements.\n{text_summary}{threshold_note}"
633 ),
634 passed: false,
635 });
636 }
637
638 Ok(AuditResult {
639 message: format!("✅ '{measurement}'\n{text_summary}{threshold_note}"),
640 passed: true,
641 })
642}
643
644#[cfg(test)]
645mod test {
646 use crate::test_helpers::with_isolated_test_setup;
647
648 use super::*;
649
650 #[test]
651 fn test_format_z_score_display() {
652 let test_cases = vec![
654 (2.5_f64, " 2.50"),
655 (0.0_f64, " 0.00"),
656 (-1.5_f64, " -1.50"),
657 (999.999_f64, " 1000.00"),
658 (0.001_f64, " 0.00"),
659 (f64::INFINITY, ""),
660 (f64::NEG_INFINITY, ""),
661 (f64::NAN, ""),
662 ];
663
664 for (z_score, expected) in test_cases {
665 let result = format_z_score_display(z_score);
666 assert_eq!(result, expected, "Failed for z_score: {}", z_score);
667 }
668 }
669
670 #[test]
671 fn test_direction_arrows() {
672 let test_cases = vec![
674 (5.0_f64, 3.0_f64, "↑"), (1.0_f64, 3.0_f64, "↓"), (3.0_f64, 3.0_f64, "→"), ];
678
679 for (head_mean, tail_mean, expected) in test_cases {
680 let result = get_direction_arrow(head_mean, tail_mean);
681 assert_eq!(
682 result, expected,
683 "Failed for head_mean: {}, tail_mean: {}",
684 head_mean, tail_mean
685 );
686 }
687 }
688
689 #[test]
690 fn test_audit_with_different_dispersion_methods() {
691 let head_value = 35.0;
695 let tail_values = [30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
696
697 let head_summary = stats::aggregate_measurements(std::iter::once(&head_value));
698 let tail_summary = stats::aggregate_measurements(tail_values.iter());
699
700 let z_score_stddev =
702 head_summary.z_score_with_method(&tail_summary, DispersionMethod::StandardDeviation);
703 let z_score_mad = head_summary
704 .z_score_with_method(&tail_summary, DispersionMethod::MedianAbsoluteDeviation);
705
706 assert!(
709 z_score_stddev < z_score_mad,
710 "stddev z-score ({}) should be smaller than MAD z-score ({}) with outlier data",
711 z_score_stddev,
712 z_score_mad
713 );
714
715 assert!(z_score_stddev > 0.0);
717 assert!(z_score_mad > 0.0);
718 }
719
720 #[test]
721 fn test_dispersion_method_conversion() {
722 let cli_stddev = git_perf_cli_types::DispersionMethod::StandardDeviation;
726 let stats_stddev: DispersionMethod = cli_stddev.into();
727 assert_eq!(stats_stddev, DispersionMethod::StandardDeviation);
728
729 let cli_mad = git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation;
731 let stats_mad: DispersionMethod = cli_mad.into();
732 assert_eq!(stats_mad, DispersionMethod::MedianAbsoluteDeviation);
733 }
734
735 #[test]
736 fn test_audit_multiple_with_no_measurements() {
737 with_isolated_test_setup(|_git_dir, _home_path| {
741 let result = audit_multiple(
742 "HEAD",
743 100,
744 None,
745 None,
746 Some(1),
747 &[],
748 Some(ReductionFunc::Mean),
749 Some(2.0),
750 Some(DispersionMethod::StandardDeviation),
751 &[], &[], false,
754 );
755
756 assert!(
758 result.is_ok(),
759 "audit_multiple should succeed with empty pattern list"
760 );
761 });
762 }
763
764 #[test]
767 fn test_min_count_boundary_condition() {
768 let result = audit_with_data(
771 "test_measurement",
772 15.0,
773 vec![10.0, 11.0, 12.0], 3, 2.0,
776 DispersionMethod::StandardDeviation,
777 ReductionFunc::Min,
778 );
779
780 assert!(result.is_ok());
781 let audit_result = result.unwrap();
782 assert!(!audit_result.message.contains("Skipping test"));
784
785 let result = audit_with_data(
787 "test_measurement",
788 15.0,
789 vec![10.0, 11.0], 3, 2.0,
792 DispersionMethod::StandardDeviation,
793 ReductionFunc::Min,
794 );
795
796 assert!(result.is_ok());
797 let audit_result = result.unwrap();
798 assert!(audit_result.message.contains("Skipping test"));
799 assert!(audit_result.passed); }
801
802 #[test]
803 fn test_pluralization_logic() {
804 let result = audit_with_data(
807 "test_measurement",
808 15.0,
809 vec![], 5, 2.0,
812 DispersionMethod::StandardDeviation,
813 ReductionFunc::Min,
814 );
815
816 assert!(result.is_ok());
817 let message = result.unwrap().message;
818 assert!(message.contains("0 historical measurements found")); assert!(!message.contains("0 historical measurement found")); let result = audit_with_data(
823 "test_measurement",
824 15.0,
825 vec![10.0], 5, 2.0,
828 DispersionMethod::StandardDeviation,
829 ReductionFunc::Min,
830 );
831
832 assert!(result.is_ok());
833 let message = result.unwrap().message;
834 assert!(message.contains("1 historical measurement found")); let result = audit_with_data(
838 "test_measurement",
839 15.0,
840 vec![10.0, 11.0], 5, 2.0,
843 DispersionMethod::StandardDeviation,
844 ReductionFunc::Min,
845 );
846
847 assert!(result.is_ok());
848 let message = result.unwrap().message;
849 assert!(message.contains("2 historical measurements found")); }
851
852 #[test]
853 fn test_skip_with_summaries() {
854 let result = audit_with_data(
860 "test_measurement",
861 15.0,
862 vec![], 5, 2.0,
865 DispersionMethod::StandardDeviation,
866 ReductionFunc::Min,
867 );
868
869 assert!(result.is_ok());
870 let message = result.unwrap().message;
871 assert!(message.contains("Skipping test"));
872 assert!(message.contains("Head:")); assert!(!message.contains("z-score")); assert!(!message.contains("Tail:")); assert!(!message.contains("[")); let result = audit_with_data(
879 "test_measurement",
880 15.0,
881 vec![10.0], 5, 2.0,
884 DispersionMethod::StandardDeviation,
885 ReductionFunc::Min,
886 );
887
888 assert!(result.is_ok());
889 let message = result.unwrap().message;
890 assert!(message.contains("Skipping test"));
891 assert!(message.contains("z-score (stddev):")); assert!(message.contains("Head:")); assert!(message.contains("Tail:")); assert!(message.contains("[")); let z_pos = message.find("z-score").unwrap();
897 let head_pos = message.find("Head:").unwrap();
898 let tail_pos = message.find("Tail:").unwrap();
899 let spark_pos = message.find("[").unwrap();
900 assert!(z_pos < head_pos, "z-score should come before Head");
901 assert!(head_pos < tail_pos, "Head should come before Tail");
902 assert!(tail_pos < spark_pos, "Tail should come before sparkline");
903
904 let result = audit_with_data(
906 "test_measurement",
907 15.0,
908 vec![10.0, 11.0], 5, 2.0,
911 DispersionMethod::StandardDeviation,
912 ReductionFunc::Min,
913 );
914
915 assert!(result.is_ok());
916 let message = result.unwrap().message;
917 assert!(message.contains("Skipping test"));
918 assert!(message.contains("z-score (stddev):")); assert!(message.contains("Head:")); assert!(message.contains("Tail:")); assert!(message.contains("[")); let z_pos = message.find("z-score").unwrap();
924 let head_pos = message.find("Head:").unwrap();
925 let tail_pos = message.find("Tail:").unwrap();
926 let spark_pos = message.find("[").unwrap();
927 assert!(z_pos < head_pos, "z-score should come before Head");
928 assert!(head_pos < tail_pos, "Head should come before Tail");
929 assert!(tail_pos < spark_pos, "Tail should come before sparkline");
930
931 let result = audit_with_data(
933 "test_measurement",
934 15.0,
935 vec![10.0, 11.0], 5, 2.0,
938 DispersionMethod::MedianAbsoluteDeviation,
939 ReductionFunc::Min,
940 );
941
942 assert!(result.is_ok());
943 let message = result.unwrap().message;
944 assert!(message.contains("z-score (mad):")); }
946
947 #[test]
948 fn test_relative_calculations_division_vs_modulo() {
949 let result = audit_with_data(
952 "test_measurement",
953 25.0, vec![10.0, 10.0, 10.0], 2,
956 10.0, DispersionMethod::StandardDeviation,
958 ReductionFunc::Min,
959 );
960
961 assert!(result.is_ok());
962 let audit_result = result.unwrap();
963
964 assert!(audit_result.message.contains("[+0.00% – +150.00%]"));
974
975 assert!(!audit_result.message.contains("[-100.00% – -50.00%]"));
977 assert!(!audit_result.message.contains("-100.00%"));
978 assert!(!audit_result.message.contains("-50.00%"));
979 }
980
981 #[test]
982 fn test_core_pass_fail_logic() {
983 let result = audit_with_data(
988 "test_measurement", 100.0, vec![10.0, 10.0, 10.0, 10.0, 10.0], 2,
992 0.5, DispersionMethod::StandardDeviation,
994 ReductionFunc::Min,
995 );
996
997 assert!(result.is_ok());
998 let audit_result = result.unwrap();
999 assert!(!audit_result.passed); assert!(audit_result.message.contains("❌"));
1001
1002 let result = audit_with_data(
1004 "test_measurement",
1005 10.2, vec![10.0, 10.1, 10.0, 10.1, 10.0], 2,
1008 100.0, DispersionMethod::StandardDeviation,
1010 ReductionFunc::Min,
1011 );
1012
1013 assert!(result.is_ok());
1014 let audit_result = result.unwrap();
1015 assert!(audit_result.passed); assert!(audit_result.message.contains("✅"));
1017 }
1018
1019 #[test]
1020 fn test_final_result_logic() {
1021 let result = audit_with_data(
1026 "test_measurement",
1027 1000.0, vec![10.0, 10.0, 10.0, 10.0, 10.0],
1029 2,
1030 0.1, DispersionMethod::StandardDeviation,
1032 ReductionFunc::Min,
1033 );
1034
1035 assert!(result.is_ok());
1036 let audit_result = result.unwrap();
1037 assert!(!audit_result.passed);
1038 assert!(audit_result.message.contains("❌"));
1039 assert!(audit_result.message.contains("differs significantly"));
1040
1041 let result = audit_with_data(
1043 "test_measurement",
1044 10.01, vec![10.0, 10.1, 10.0, 10.1, 10.0], 2,
1047 100.0, DispersionMethod::StandardDeviation,
1049 ReductionFunc::Min,
1050 );
1051
1052 assert!(result.is_ok());
1053 let audit_result = result.unwrap();
1054 assert!(audit_result.passed);
1055 assert!(audit_result.message.contains("✅"));
1056 assert!(!audit_result.message.contains("differs significantly"));
1057 }
1058
1059 #[test]
1060 fn test_dispersion_methods_produce_different_results() {
1061 let head = 35.0;
1063 let tail = vec![30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
1064
1065 let result_stddev = audit_with_data(
1066 "test_measurement",
1067 head,
1068 tail.clone(),
1069 2,
1070 2.0,
1071 DispersionMethod::StandardDeviation,
1072 ReductionFunc::Min,
1073 );
1074
1075 let result_mad = audit_with_data(
1076 "test_measurement",
1077 head,
1078 tail,
1079 2,
1080 2.0,
1081 DispersionMethod::MedianAbsoluteDeviation,
1082 ReductionFunc::Min,
1083 );
1084
1085 assert!(result_stddev.is_ok());
1086 assert!(result_mad.is_ok());
1087
1088 let stddev_result = result_stddev.unwrap();
1089 let mad_result = result_mad.unwrap();
1090
1091 assert!(stddev_result.message.contains("stddev"));
1093 assert!(mad_result.message.contains("mad"));
1094 }
1095
1096 #[test]
1097 fn test_head_and_tail_have_units_and_auto_scaling() {
1098 use crate::test_helpers::setup_test_env_with_config;
1102
1103 let config_content = r#"
1104[measurement."build_time"]
1105unit = "ms"
1106"#;
1107 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1108
1109 let head = 12_345.67; let tail = vec![10_000.0, 10_500.0, 11_000.0, 11_500.0, 12_000.0]; let result = audit_with_data(
1114 "build_time",
1115 head,
1116 tail,
1117 2,
1118 10.0, DispersionMethod::StandardDeviation,
1120 ReductionFunc::Min,
1121 );
1122
1123 assert!(result.is_ok());
1124 let audit_result = result.unwrap();
1125 let message = &audit_result.message;
1126
1127 assert!(
1129 message.contains("Head:"),
1130 "Message should contain Head section"
1131 );
1132
1133 assert!(
1136 message.contains("12.3s") || message.contains("12.35s"),
1137 "Head mean should be auto-scaled to seconds, got: {}",
1138 message
1139 );
1140
1141 let head_section: Vec<&str> = message
1142 .lines()
1143 .filter(|line| line.contains("Head:"))
1144 .collect();
1145
1146 assert!(
1147 !head_section.is_empty(),
1148 "Should find Head section in message"
1149 );
1150
1151 let head_line = head_section[0];
1152
1153 assert!(
1156 head_line.contains("μ:") && head_line.contains("σ:") && head_line.contains("MAD:"),
1157 "Head line should contain μ, σ, and MAD labels, got: {}",
1158 head_line
1159 );
1160
1161 assert!(
1163 message.contains("Tail:"),
1164 "Message should contain Tail section"
1165 );
1166
1167 let tail_section: Vec<&str> = message
1168 .lines()
1169 .filter(|line| line.contains("Tail:"))
1170 .collect();
1171
1172 assert!(
1173 !tail_section.is_empty(),
1174 "Should find Tail section in message"
1175 );
1176
1177 let tail_line = tail_section[0];
1178
1179 assert!(
1181 tail_line.contains("11s")
1182 || tail_line.contains("11.")
1183 || tail_line.contains("10.")
1184 || tail_line.contains("12."),
1185 "Tail should contain auto-scaled second values, got: {}",
1186 tail_line
1187 );
1188
1189 assert!(
1191 tail_line.contains("μ:")
1192 && tail_line.contains("σ:")
1193 && tail_line.contains("MAD:")
1194 && tail_line.contains("n:"),
1195 "Tail line should contain all stat labels, got: {}",
1196 tail_line
1197 );
1198 }
1199
1200 #[test]
1201 fn test_threshold_note_only_shown_when_audit_would_fail() {
1202 use crate::test_helpers::setup_test_env_with_config;
1205
1206 let config_content = r#"
1207[measurement."build_time"]
1208min_relative_deviation = 10.0
1209"#;
1210 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1211
1212 let result = audit_with_data(
1215 "build_time",
1216 10.1, vec![10.0, 10.1, 10.0, 10.1, 10.0], 2,
1219 100.0, DispersionMethod::StandardDeviation,
1221 ReductionFunc::Min,
1222 );
1223
1224 assert!(result.is_ok());
1225 let audit_result = result.unwrap();
1226 assert!(audit_result.passed);
1227 assert!(audit_result.message.contains("✅"));
1228 assert!(
1230 !audit_result
1231 .message
1232 .contains("Note: Passed due to relative deviation"),
1233 "Note should not appear when audit passes without needing threshold bypass"
1234 );
1235
1236 let result = audit_with_data(
1239 "build_time",
1240 1002.0, vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0], 2,
1243 0.5, DispersionMethod::StandardDeviation,
1245 ReductionFunc::Min,
1246 );
1247
1248 assert!(result.is_ok());
1249 let audit_result = result.unwrap();
1250 assert!(audit_result.passed);
1251 assert!(audit_result.message.contains("✅"));
1252 assert!(
1254 audit_result
1255 .message
1256 .contains("Note: Passed due to relative deviation"),
1257 "Note should appear when audit passes due to threshold bypass. Got: {}",
1258 audit_result.message
1259 );
1260
1261 let result = audit_with_data(
1264 "build_time",
1265 1200.0, vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0], 2,
1268 0.5, DispersionMethod::StandardDeviation,
1270 ReductionFunc::Min,
1271 );
1272
1273 assert!(result.is_ok());
1274 let audit_result = result.unwrap();
1275 assert!(!audit_result.passed);
1276 assert!(audit_result.message.contains("❌"));
1277 assert!(
1279 !audit_result
1280 .message
1281 .contains("Note: Passed due to relative deviation"),
1282 "Note should not appear when audit fails"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_absolute_threshold_note_and_deviation_value() {
1288 use crate::test_helpers::setup_test_env_with_config;
1292
1293 let config_content = r#"
1294[measurement."build_time"]
1295min_absolute_deviation = 50.0
1296"#;
1297 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1298
1299 let result = audit_with_data(
1309 "build_time",
1310 100.0, vec![10.0, 10.0, 10.0, 10.0, 10.0], 2,
1313 0.5, DispersionMethod::StandardDeviation,
1315 ReductionFunc::Min,
1316 );
1317
1318 assert!(result.is_ok());
1319 let audit_result = result.unwrap();
1320 assert!(
1322 !audit_result.passed,
1323 "Should fail: absolute deviation 90 > threshold 50. Got: {}",
1324 audit_result.message
1325 );
1326
1327 let result = audit_with_data(
1332 "build_time",
1333 1050.0, vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0], 2,
1336 0.5, DispersionMethod::StandardDeviation,
1338 ReductionFunc::Min,
1339 );
1340
1341 assert!(result.is_ok());
1342 let audit_result = result.unwrap();
1343 assert!(
1345 !audit_result.passed,
1346 "Should fail: absolute deviation 50 == threshold 50 (not strictly less than). Got: {}",
1347 audit_result.message
1348 );
1349
1350 let result = audit_with_data(
1353 "build_time",
1354 1049.0, vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0], 2,
1357 0.5, DispersionMethod::StandardDeviation,
1359 ReductionFunc::Min,
1360 );
1361
1362 assert!(result.is_ok());
1363 let audit_result = result.unwrap();
1364 assert!(
1365 audit_result.passed,
1366 "Should pass: absolute deviation 49 < threshold 50. Got: {}",
1367 audit_result.message
1368 );
1369 assert!(
1370 audit_result
1371 .message
1372 .contains("Note: Passed due to absolute deviation"),
1373 "Note should appear when audit passes due to absolute threshold. Got: {}",
1374 audit_result.message
1375 );
1376 assert!(
1379 audit_result.message.contains("49.0"),
1380 "Note should show absolute deviation 49.0, not 1.0 (which would indicate / instead of -). Got: {}",
1381 audit_result.message
1382 );
1383 }
1384
1385 #[cfg(test)]
1387 mod integration {
1388 use super::*;
1389 use crate::config::{
1390 audit_aggregate_by, audit_dispersion_method, audit_min_measurements, audit_sigma,
1391 };
1392 use crate::test_helpers::setup_test_env_with_config;
1393
1394 #[test]
1395 fn test_different_dispersion_methods_per_measurement() {
1396 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1397 r#"
1398[measurement]
1399dispersion_method = "stddev"
1400
1401[measurement."build_time"]
1402dispersion_method = "mad"
1403
1404[measurement."memory_usage"]
1405dispersion_method = "stddev"
1406"#,
1407 );
1408
1409 let build_time_method = audit_dispersion_method("build_time");
1411 let memory_usage_method = audit_dispersion_method("memory_usage");
1412 let other_method = audit_dispersion_method("other_metric");
1413
1414 assert_eq!(
1415 DispersionMethod::from(build_time_method),
1416 DispersionMethod::MedianAbsoluteDeviation,
1417 "build_time should use MAD"
1418 );
1419 assert_eq!(
1420 DispersionMethod::from(memory_usage_method),
1421 DispersionMethod::StandardDeviation,
1422 "memory_usage should use stddev"
1423 );
1424 assert_eq!(
1425 DispersionMethod::from(other_method),
1426 DispersionMethod::StandardDeviation,
1427 "other_metric should use default stddev"
1428 );
1429 }
1430
1431 #[test]
1432 fn test_different_min_measurements_per_measurement() {
1433 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1434 r#"
1435[measurement]
1436min_measurements = 5
1437
1438[measurement."build_time"]
1439min_measurements = 10
1440
1441[measurement."memory_usage"]
1442min_measurements = 3
1443"#,
1444 );
1445
1446 assert_eq!(
1447 audit_min_measurements("build_time"),
1448 Some(10),
1449 "build_time should require 10 measurements"
1450 );
1451 assert_eq!(
1452 audit_min_measurements("memory_usage"),
1453 Some(3),
1454 "memory_usage should require 3 measurements"
1455 );
1456 assert_eq!(
1457 audit_min_measurements("other_metric"),
1458 Some(5),
1459 "other_metric should use default 5 measurements"
1460 );
1461 }
1462
1463 #[test]
1464 fn test_different_aggregate_by_per_measurement() {
1465 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1466 r#"
1467[measurement]
1468aggregate_by = "median"
1469
1470[measurement."build_time"]
1471aggregate_by = "max"
1472
1473[measurement."memory_usage"]
1474aggregate_by = "mean"
1475"#,
1476 );
1477
1478 assert_eq!(
1479 audit_aggregate_by("build_time"),
1480 Some(git_perf_cli_types::ReductionFunc::Max),
1481 "build_time should use max"
1482 );
1483 assert_eq!(
1484 audit_aggregate_by("memory_usage"),
1485 Some(git_perf_cli_types::ReductionFunc::Mean),
1486 "memory_usage should use mean"
1487 );
1488 assert_eq!(
1489 audit_aggregate_by("other_metric"),
1490 Some(git_perf_cli_types::ReductionFunc::Median),
1491 "other_metric should use default median"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_different_sigma_per_measurement() {
1497 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1498 r#"
1499[measurement]
1500sigma = 3.0
1501
1502[measurement."build_time"]
1503sigma = 5.5
1504
1505[measurement."memory_usage"]
1506sigma = 2.0
1507"#,
1508 );
1509
1510 assert_eq!(
1511 audit_sigma("build_time"),
1512 Some(5.5),
1513 "build_time should use sigma 5.5"
1514 );
1515 assert_eq!(
1516 audit_sigma("memory_usage"),
1517 Some(2.0),
1518 "memory_usage should use sigma 2.0"
1519 );
1520 assert_eq!(
1521 audit_sigma("other_metric"),
1522 Some(3.0),
1523 "other_metric should use default sigma 3.0"
1524 );
1525 }
1526
1527 #[test]
1528 fn test_cli_overrides_config() {
1529 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1530 r#"
1531[measurement."build_time"]
1532min_measurements = 10
1533aggregate_by = "max"
1534sigma = 5.5
1535dispersion_method = "mad"
1536"#,
1537 );
1538
1539 let params = super::resolve_audit_params(
1541 "build_time",
1542 Some(2), Some(ReductionFunc::Min), Some(3.0), Some(DispersionMethod::StandardDeviation), );
1547
1548 assert_eq!(
1549 params.min_count, 2,
1550 "CLI min_measurements should override config"
1551 );
1552 assert_eq!(
1553 params.summarize_by,
1554 ReductionFunc::Min,
1555 "CLI aggregate_by should override config"
1556 );
1557 assert_eq!(params.sigma, 3.0, "CLI sigma should override config");
1558 assert_eq!(
1559 params.dispersion_method,
1560 DispersionMethod::StandardDeviation,
1561 "CLI dispersion should override config"
1562 );
1563 }
1564
1565 #[test]
1566 fn test_config_overrides_defaults() {
1567 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1568 r#"
1569[measurement."build_time"]
1570min_measurements = 10
1571aggregate_by = "max"
1572sigma = 5.5
1573dispersion_method = "mad"
1574"#,
1575 );
1576
1577 let params = super::resolve_audit_params(
1579 "build_time",
1580 None, None,
1582 None,
1583 None,
1584 );
1585
1586 assert_eq!(
1587 params.min_count, 10,
1588 "Config min_measurements should override default"
1589 );
1590 assert_eq!(
1591 params.summarize_by,
1592 ReductionFunc::Max,
1593 "Config aggregate_by should override default"
1594 );
1595 assert_eq!(params.sigma, 5.5, "Config sigma should override default");
1596 assert_eq!(
1597 params.dispersion_method,
1598 DispersionMethod::MedianAbsoluteDeviation,
1599 "Config dispersion should override default"
1600 );
1601 }
1602
1603 #[test]
1604 fn test_uses_defaults_when_no_config_or_cli() {
1605 let (_temp_dir, _dir_guard) = setup_test_env_with_config("");
1606
1607 let params = super::resolve_audit_params(
1609 "non_existent_measurement",
1610 None, None,
1612 None,
1613 None,
1614 );
1615
1616 assert_eq!(
1617 params.min_count, 2,
1618 "Should use default min_measurements of 2"
1619 );
1620 assert_eq!(
1621 params.summarize_by,
1622 ReductionFunc::Min,
1623 "Should use default aggregate_by of Min"
1624 );
1625 assert_eq!(params.sigma, 4.0, "Should use default sigma of 4.0");
1626 assert_eq!(
1627 params.dispersion_method,
1628 DispersionMethod::StandardDeviation,
1629 "Should use default dispersion of stddev"
1630 );
1631 }
1632 }
1633
1634 #[test]
1635 fn test_discover_matching_measurements() {
1636 use crate::data::{Commit, MeasurementData};
1637 use std::collections::HashMap;
1638
1639 let commits = vec![
1641 Ok(Commit {
1642 commit: "abc123".to_string(),
1643 title: "test: commit 1".to_string(),
1644 author: "Test Author".to_string(),
1645 measurements: vec![
1646 MeasurementData {
1647 epoch: 0,
1648 name: "bench_cpu".to_string(),
1649 timestamp: 1000.0,
1650 val: 100.0,
1651 key_values: {
1652 let mut map = HashMap::new();
1653 map.insert("os".to_string(), "linux".to_string());
1654 map
1655 },
1656 },
1657 MeasurementData {
1658 epoch: 0,
1659 name: "bench_memory".to_string(),
1660 timestamp: 1000.0,
1661 val: 200.0,
1662 key_values: {
1663 let mut map = HashMap::new();
1664 map.insert("os".to_string(), "linux".to_string());
1665 map
1666 },
1667 },
1668 MeasurementData {
1669 epoch: 0,
1670 name: "test_unit".to_string(),
1671 timestamp: 1000.0,
1672 val: 50.0,
1673 key_values: {
1674 let mut map = HashMap::new();
1675 map.insert("os".to_string(), "linux".to_string());
1676 map
1677 },
1678 },
1679 ],
1680 }),
1681 Ok(Commit {
1682 commit: "def456".to_string(),
1683 title: "test: commit 2".to_string(),
1684 author: "Test Author".to_string(),
1685 measurements: vec![
1686 MeasurementData {
1687 epoch: 0,
1688 name: "bench_cpu".to_string(),
1689 timestamp: 1000.0,
1690 val: 105.0,
1691 key_values: {
1692 let mut map = HashMap::new();
1693 map.insert("os".to_string(), "mac".to_string());
1694 map
1695 },
1696 },
1697 MeasurementData {
1698 epoch: 0,
1699 name: "other_metric".to_string(),
1700 timestamp: 1000.0,
1701 val: 75.0,
1702 key_values: {
1703 let mut map = HashMap::new();
1704 map.insert("os".to_string(), "linux".to_string());
1705 map
1706 },
1707 },
1708 ],
1709 }),
1710 ];
1711
1712 let patterns = vec!["bench_.*".to_string()];
1714 let filters = crate::filter::compile_filters(&patterns).unwrap();
1715 let selectors = vec![];
1716 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1717
1718 assert_eq!(discovered.len(), 2);
1719 assert!(discovered.contains(&"bench_cpu".to_string()));
1720 assert!(discovered.contains(&"bench_memory".to_string()));
1721 assert!(!discovered.contains(&"test_unit".to_string()));
1722 assert!(!discovered.contains(&"other_metric".to_string()));
1723
1724 let patterns = vec!["bench_cpu".to_string(), "test_.*".to_string()];
1726 let filters = crate::filter::compile_filters(&patterns).unwrap();
1727 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1728
1729 assert_eq!(discovered.len(), 2);
1730 assert!(discovered.contains(&"bench_cpu".to_string()));
1731 assert!(discovered.contains(&"test_unit".to_string()));
1732 assert!(!discovered.contains(&"bench_memory".to_string()));
1733
1734 let patterns = vec!["bench_.*".to_string()];
1736 let filters = crate::filter::compile_filters(&patterns).unwrap();
1737 let selectors = vec![("os".to_string(), "linux".to_string())];
1738 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1739
1740 assert_eq!(discovered.len(), 2);
1743 assert!(discovered.contains(&"bench_cpu".to_string()));
1744 assert!(discovered.contains(&"bench_memory".to_string()));
1745
1746 let patterns = vec!["nonexistent.*".to_string()];
1748 let filters = crate::filter::compile_filters(&patterns).unwrap();
1749 let selectors = vec![];
1750 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1751
1752 assert_eq!(discovered.len(), 0);
1753
1754 let filters = vec![];
1756 let selectors = vec![];
1757 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1758
1759 assert_eq!(discovered.len(), 4);
1763 assert!(discovered.contains(&"bench_cpu".to_string()));
1764 assert!(discovered.contains(&"bench_memory".to_string()));
1765 assert!(discovered.contains(&"test_unit".to_string()));
1766 assert!(discovered.contains(&"other_metric".to_string()));
1767
1768 let patterns = vec!["bench_.*".to_string()];
1770 let filters = crate::filter::compile_filters(&patterns).unwrap();
1771 let selectors = vec![("os".to_string(), "windows".to_string())];
1772 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1773
1774 assert_eq!(discovered.len(), 0);
1775
1776 let patterns = vec!["^bench_cpu$".to_string()];
1778 let filters = crate::filter::compile_filters(&patterns).unwrap();
1779 let selectors = vec![];
1780 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1781
1782 assert_eq!(discovered.len(), 1);
1783 assert!(discovered.contains(&"bench_cpu".to_string()));
1784
1785 let patterns = vec![".*".to_string()]; let filters = crate::filter::compile_filters(&patterns).unwrap();
1788 let selectors = vec![];
1789 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1790
1791 assert_eq!(discovered[0], "bench_cpu");
1793 assert_eq!(discovered[1], "bench_memory");
1794 assert_eq!(discovered[2], "other_metric");
1795 assert_eq!(discovered[3], "test_unit");
1796 }
1797
1798 #[test]
1799 fn test_audit_multiple_with_combined_patterns() {
1800 use crate::data::{Commit, MeasurementData};
1807 use std::collections::HashMap;
1808
1809 let commits = vec![Ok(Commit {
1811 commit: "abc123".to_string(),
1812 title: "test: commit".to_string(),
1813 author: "Test Author".to_string(),
1814 measurements: vec![
1815 MeasurementData {
1816 epoch: 0,
1817 name: "timer".to_string(),
1818 timestamp: 1000.0,
1819 val: 10.0,
1820 key_values: HashMap::new(),
1821 },
1822 MeasurementData {
1823 epoch: 0,
1824 name: "bench_cpu".to_string(),
1825 timestamp: 1000.0,
1826 val: 100.0,
1827 key_values: HashMap::new(),
1828 },
1829 MeasurementData {
1830 epoch: 0,
1831 name: "memory".to_string(),
1832 timestamp: 1000.0,
1833 val: 500.0,
1834 key_values: HashMap::new(),
1835 },
1836 ],
1837 })];
1838
1839 let measurements = vec!["timer".to_string()];
1842 let filter_patterns = vec!["bench_.*".to_string()];
1843 let combined =
1844 crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
1845
1846 assert_eq!(combined.len(), 2);
1848 assert_eq!(combined[0], "^timer$");
1849 assert_eq!(combined[1], "bench_.*");
1850
1851 let filters = crate::filter::compile_filters(&combined).unwrap();
1853 let selectors = vec![];
1854 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1855
1856 assert_eq!(discovered.len(), 2);
1858 assert!(discovered.contains(&"timer".to_string()));
1859 assert!(discovered.contains(&"bench_cpu".to_string()));
1860 assert!(!discovered.contains(&"memory".to_string())); let measurements = vec!["timer".to_string(), "memory".to_string()];
1864 let filter_patterns = vec!["bench_.*".to_string(), "test_.*".to_string()];
1865 let combined =
1866 crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
1867
1868 assert_eq!(combined.len(), 4);
1869
1870 let filters = crate::filter::compile_filters(&combined).unwrap();
1871 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1872
1873 assert_eq!(discovered.len(), 3);
1875 assert!(discovered.contains(&"timer".to_string()));
1876 assert!(discovered.contains(&"memory".to_string()));
1877 assert!(discovered.contains(&"bench_cpu".to_string()));
1878 }
1879
1880 #[test]
1881 fn test_audit_with_empty_tail() {
1882 let result = audit_with_data(
1886 "test_measurement",
1887 10.0, vec![], 2, 2.0, DispersionMethod::StandardDeviation,
1892 ReductionFunc::Min,
1893 );
1894
1895 assert!(result.is_ok(), "Should not crash on empty tail");
1897 let audit_result = result.unwrap();
1898
1899 assert!(audit_result.passed);
1901 assert!(audit_result.message.contains("Skipping test"));
1902
1903 assert!(!audit_result.message.to_lowercase().contains("inf"));
1905 assert!(!audit_result.message.to_lowercase().contains("nan"));
1906 }
1907
1908 #[test]
1909 fn test_audit_with_all_zero_tail() {
1910 let result = audit_with_data(
1913 "test_measurement",
1914 5.0, vec![0.0, 0.0, 0.0], 2, 2.0, DispersionMethod::StandardDeviation,
1919 ReductionFunc::Min,
1920 );
1921
1922 assert!(result.is_ok(), "Should not crash when tail median is 0.0");
1924 let audit_result = result.unwrap();
1925
1926 assert!(!audit_result.message.to_lowercase().contains("inf"));
1928 assert!(!audit_result.message.to_lowercase().contains("nan"));
1929 }
1930
1931 #[test]
1932 fn test_tiered_baseline_approach() {
1933 let result = audit_with_data(
1939 "test_measurement",
1940 15.0, vec![10.0, 11.0, 12.0], 2,
1943 2.0,
1944 DispersionMethod::StandardDeviation,
1945 ReductionFunc::Min,
1946 );
1947
1948 assert!(result.is_ok());
1949 let audit_result = result.unwrap();
1950 assert!(audit_result.message.contains('%'));
1952 assert!(!audit_result.message.to_lowercase().contains("inf"));
1953
1954 let result = audit_with_data(
1956 "test_measurement",
1957 5.0, vec![0.0, 0.0, 0.0], 2,
1960 2.0,
1961 DispersionMethod::StandardDeviation,
1962 ReductionFunc::Min,
1963 );
1964
1965 assert!(result.is_ok());
1966 let audit_result = result.unwrap();
1967 assert!(!audit_result.message.to_lowercase().contains("inf"));
1970 assert!(!audit_result.message.to_lowercase().contains("nan"));
1971 assert!(audit_result.message.contains('–') || audit_result.message.contains('-'));
1973
1974 let result = audit_with_data(
1976 "test_measurement",
1977 0.0, vec![0.0, 0.0, 0.0], 2,
1980 2.0,
1981 DispersionMethod::StandardDeviation,
1982 ReductionFunc::Min,
1983 );
1984
1985 assert!(result.is_ok());
1986 let audit_result = result.unwrap();
1987 assert!(!audit_result.message.to_lowercase().contains("inf"));
1989 assert!(!audit_result.message.to_lowercase().contains("nan"));
1990 }
1991
1992 #[test]
1993 fn test_min_measurements_two_with_no_tail() {
1994 let result = audit_with_data(
1997 "test_measurement",
1998 15.0, vec![], 2, 2.0,
2002 DispersionMethod::StandardDeviation,
2003 ReductionFunc::Min,
2004 );
2005
2006 assert!(result.is_ok());
2007 let audit_result = result.unwrap();
2008
2009 assert!(audit_result.passed);
2011 assert!(audit_result.message.contains("Skipping test"));
2012 assert!(audit_result
2013 .message
2014 .contains("0 historical measurements found"));
2015 assert!(audit_result
2016 .message
2017 .contains("Less than requested min_measurements of 2"));
2018
2019 assert!(audit_result.message.contains("Head:"));
2021 assert!(!audit_result.message.contains("z-score"));
2022 assert!(!audit_result.message.contains("Tail:"));
2023 }
2024
2025 #[test]
2026 fn test_min_measurements_two_with_single_tail() {
2027 let result = audit_with_data(
2030 "test_measurement",
2031 15.0, vec![10.0], 2, 2.0,
2035 DispersionMethod::StandardDeviation,
2036 ReductionFunc::Min,
2037 );
2038
2039 assert!(result.is_ok());
2040 let audit_result = result.unwrap();
2041
2042 assert!(audit_result.passed);
2044 assert!(audit_result.message.contains("Skipping test"));
2045 assert!(audit_result
2046 .message
2047 .contains("1 historical measurement found"));
2048 assert!(audit_result
2049 .message
2050 .contains("Less than requested min_measurements of 2"));
2051
2052 assert!(audit_result.message.contains("Head:"));
2054 assert!(audit_result.message.contains("Tail:"));
2055 assert!(audit_result.message.contains("z-score"));
2056 assert!(audit_result.message.contains("["));
2057 }
2058
2059 #[test]
2060 fn test_aggregation_method_display_min() {
2061 let result = audit_with_data(
2063 "test_measurement",
2064 15.0,
2065 vec![10.0, 11.0, 12.0],
2066 2,
2067 2.0,
2068 DispersionMethod::StandardDeviation,
2069 ReductionFunc::Min,
2070 );
2071
2072 assert!(result.is_ok());
2073 let audit_result = result.unwrap();
2074 assert!(audit_result.message.contains("Aggregation: min"));
2075 }
2076
2077 #[test]
2078 fn test_aggregation_method_display_max() {
2079 let result = audit_with_data(
2081 "test_measurement",
2082 15.0,
2083 vec![10.0, 11.0, 12.0],
2084 2,
2085 2.0,
2086 DispersionMethod::StandardDeviation,
2087 ReductionFunc::Max,
2088 );
2089
2090 assert!(result.is_ok());
2091 let audit_result = result.unwrap();
2092 assert!(audit_result.message.contains("Aggregation: max"));
2093 }
2094
2095 #[test]
2096 fn test_aggregation_method_display_median() {
2097 let result = audit_with_data(
2099 "test_measurement",
2100 15.0,
2101 vec![10.0, 11.0, 12.0],
2102 2,
2103 2.0,
2104 DispersionMethod::StandardDeviation,
2105 ReductionFunc::Median,
2106 );
2107
2108 assert!(result.is_ok());
2109 let audit_result = result.unwrap();
2110 assert!(audit_result.message.contains("Aggregation: median"));
2111 }
2112
2113 #[test]
2114 fn test_aggregation_method_display_mean() {
2115 let result = audit_with_data(
2117 "test_measurement",
2118 15.0,
2119 vec![10.0, 11.0, 12.0],
2120 2,
2121 2.0,
2122 DispersionMethod::StandardDeviation,
2123 ReductionFunc::Mean,
2124 );
2125
2126 assert!(result.is_ok());
2127 let audit_result = result.unwrap();
2128 assert!(audit_result.message.contains("Aggregation: mean"));
2129 }
2130
2131 #[test]
2132 fn test_aggregation_method_not_shown_with_single_measurement() {
2133 let result = audit_with_data(
2135 "test_measurement",
2136 15.0,
2137 vec![], 2,
2139 2.0,
2140 DispersionMethod::StandardDeviation,
2141 ReductionFunc::Median,
2142 );
2143
2144 assert!(result.is_ok());
2145 let audit_result = result.unwrap();
2146 assert!(!audit_result.message.contains("Aggregation:"));
2148 assert!(audit_result.message.contains("Head:"));
2150 }
2151}