1use crate::{
2 change_point, 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 pub max_cov: Option<f64>,
52}
53
54pub(crate) fn resolve_audit_params(
60 measurement: &str,
61 cli_min_count: Option<u16>,
62 cli_summarize_by: Option<ReductionFunc>,
63 cli_sigma: Option<f64>,
64 cli_dispersion_method: Option<DispersionMethod>,
65 cli_max_cov: Option<f64>,
66) -> ResolvedAuditParams {
67 let min_count = cli_min_count
68 .or_else(|| config::audit_min_measurements(measurement))
69 .unwrap_or(defaults::DEFAULT_MIN_MEASUREMENTS);
70
71 let summarize_by = cli_summarize_by
72 .or_else(|| config::audit_aggregate_by(measurement).map(ReductionFunc::from))
73 .unwrap_or(ReductionFunc::Min);
74
75 let sigma = cli_sigma
76 .or_else(|| config::audit_sigma(measurement))
77 .unwrap_or(defaults::DEFAULT_SIGMA);
78
79 let dispersion_method = cli_dispersion_method
80 .or_else(|| {
81 Some(DispersionMethod::from(config::audit_dispersion_method(
82 measurement,
83 )))
84 })
85 .unwrap_or(DispersionMethod::StandardDeviation);
86
87 let max_cov = cli_max_cov.or_else(|| config::audit_max_cov(measurement));
88
89 ResolvedAuditParams {
90 min_count,
91 summarize_by,
92 sigma,
93 dispersion_method,
94 max_cov,
95 }
96}
97
98fn discover_matching_measurements(
101 commits: &[Result<Commit>],
102 filters: &[regex::Regex],
103 selectors: &[(String, String)],
104) -> Vec<String> {
105 let mut unique_measurements = HashSet::new();
106
107 for commit in commits.iter().flatten() {
108 for measurement in &commit.measurements {
109 if !crate::filter::matches_any_filter(&measurement.name, filters) {
111 continue;
112 }
113
114 if !measurement.key_values_is_superset_of(selectors) {
116 continue;
117 }
118
119 unique_measurements.insert(measurement.name.clone());
121 }
122 }
123
124 let mut result: Vec<String> = unique_measurements.into_iter().collect();
126 result.sort();
127 result
128}
129
130fn compute_group_values(
138 commits: &[Result<Commit>],
139 measurement_name: &str,
140 selectors: &[(String, String)],
141 separate_by: &[String],
142) -> Result<Vec<Vec<String>>> {
143 if separate_by.is_empty() {
144 return Ok(vec![vec![]]);
145 }
146
147 let mut unique_groups = HashSet::new();
148
149 for commit in commits.iter().flatten() {
150 for measurement in &commit.measurements {
151 if measurement.name != measurement_name {
153 continue;
154 }
155
156 if !measurement.key_values_is_superset_of(selectors) {
158 continue;
159 }
160
161 let values: Vec<String> = separate_by
163 .iter()
164 .filter_map(|key| measurement.key_values.get(key).cloned())
165 .collect();
166
167 if values.len() == separate_by.len() {
169 unique_groups.insert(values);
170 }
171 }
172 }
173
174 if unique_groups.is_empty() {
175 bail!(
176 "Measurement '{}': Invalid separator supplied, no measurements have all required keys: {:?}",
177 measurement_name,
178 separate_by
179 );
180 }
181
182 let mut result: Vec<Vec<String>> = unique_groups.into_iter().collect();
184 result.sort();
185 Ok(result)
186}
187
188fn format_group_label(separate_by: &[String], group_values: &[String]) -> String {
191 separate_by
192 .iter()
193 .zip(group_values.iter())
194 .map(|(key, value)| format!("{}={}", key, value))
195 .collect::<Vec<_>>()
196 .join("/")
197}
198
199fn format_change_point_warnings(
204 change_points: &[change_point::ChangePoint],
205 measurement: &str,
206) -> Vec<String> {
207 if change_points.is_empty() {
208 return vec![];
209 }
210 let short_sha = |cp: &change_point::ChangePoint| -> String {
211 if cp.commit_sha.is_empty() {
212 "unknown".to_string()
213 } else {
214 cp.commit_sha[..cp.commit_sha.len().min(7)].to_string()
215 }
216 };
217 let boilerplate = " Historical z-score comparison may be unreliable due to regime shift.\n Consider bumping epoch or investigating the change.";
218 let warning = if change_points.len() == 1 {
219 let cp = &change_points[0];
220 format!(
221 "⚠️ WARNING: Change point detected in current epoch for '{}' at commit {} ({:+.1}%)\n{}",
222 measurement,
223 short_sha(cp),
224 cp.magnitude_pct,
225 boilerplate
226 )
227 } else {
228 let commit_list = change_points
229 .iter()
230 .map(|cp| format!(" commit {} ({:+.1}%)", short_sha(cp), cp.magnitude_pct))
231 .collect::<Vec<_>>()
232 .join("\n");
233 format!(
234 "⚠️ WARNING: Change points detected in current epoch for '{}':\n{}\n{}",
235 measurement, commit_list, boilerplate
236 )
237 };
238 vec![warning]
239}
240
241fn generate_change_point_warnings(
246 measurement: &str,
247 commits: &[Result<Commit>],
248 selectors: &[(String, String)],
249 summarize_by: &ReductionFunc,
250 no_change_point_warning: bool,
251 cp_config: &change_point::ChangePointConfig,
252) -> Vec<String> {
253 if no_change_point_warning {
254 return vec![];
255 }
256 if !cp_config.enabled {
257 return vec![];
258 }
259 let filter_by =
260 |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
261 let commits_iter = commits.iter().map(|r| match r {
262 Ok(c) => Ok(c.clone()),
263 Err(e) => Err(anyhow::anyhow!("{}", e)),
264 });
265 let (values, shas) =
266 measurement_retrieval::collect_epoch_measurements(commits_iter, filter_by, summarize_by);
267 let raw_cps = change_point::detect_change_points(&values, cp_config);
268 let enriched = change_point::enrich_change_points(&raw_cps, &values, &shas, cp_config);
269 format_change_point_warnings(&enriched, measurement)
270}
271
272#[allow(clippy::too_many_arguments)]
273pub fn audit_multiple(
274 start_commit: &str,
275 max_count: usize,
276 since: Option<&str>,
277 until: Option<&str>,
278 min_count: Option<u16>,
279 selectors: &[(String, String)],
280 summarize_by: Option<ReductionFunc>,
281 sigma: Option<f64>,
282 dispersion_method: Option<DispersionMethod>,
283 max_cov: Option<f64>,
284 combined_patterns: &[String],
285 separate_by: &[String],
286 no_change_point_warning: bool,
287) -> Result<()> {
288 if combined_patterns.is_empty() {
290 return Ok(());
291 }
292
293 let selector_keys: std::collections::HashSet<&str> =
295 selectors.iter().map(|(k, _)| k.as_str()).collect();
296 for key in separate_by {
297 if selector_keys.contains(key.as_str()) {
298 bail!(
299 "separate-by key '{}' already present in selectors; remove it from --selectors or --separate-by",
300 key
301 );
302 }
303 }
304
305 let filters = crate::filter::compile_filters(combined_patterns)?;
308
309 let all_commits: Vec<Result<Commit>> =
312 measurement_retrieval::walk_commits_from(start_commit, max_count, since, until)?.collect();
313
314 let measurements_to_audit = discover_matching_measurements(&all_commits, &filters, selectors);
317
318 if measurements_to_audit.is_empty() {
320 if all_commits.is_empty() {
322 bail!("No commit at HEAD");
323 }
324 let has_any_measurements = all_commits.iter().any(|commit_result| {
326 if let Ok(commit) = commit_result {
327 !commit.measurements.is_empty()
328 } else {
329 false
330 }
331 });
332
333 if !has_any_measurements {
334 bail!("No measurement for HEAD");
336 }
337 bail!("No measurements found matching the provided patterns");
339 }
340
341 let mut failed = false;
342 let mut total_groups = 0;
343 let mut passed_groups = 0;
344
345 for measurement in measurements_to_audit {
347 let params = resolve_audit_params(
348 &measurement,
349 min_count,
350 summarize_by,
351 sigma,
352 dispersion_method,
353 max_cov,
354 );
355
356 if (max_count as u16) < params.min_count {
358 eprintln!(
359 "⚠️ Warning: --max_count ({}) is less than min_measurements ({}) for measurement '{}'.",
360 max_count, params.min_count, measurement
361 );
362 eprintln!(
363 " This limits available historical data and may prevent achieving statistical significance."
364 );
365 }
366
367 let groups = compute_group_values(&all_commits, &measurement, selectors, separate_by)?;
369
370 for group_values in &groups {
372 let mut group_selectors = selectors.to_vec();
374 for (key, value) in separate_by.iter().zip(group_values.iter()) {
375 group_selectors.push((key.clone(), value.clone()));
376 }
377
378 let group_label = if separate_by.is_empty() {
380 String::new()
381 } else {
382 format!(" ({})", format_group_label(separate_by, group_values))
383 };
384
385 let result = audit_with_commits(&measurement, &all_commits, &group_selectors, ¶ms)?;
386
387 let cp_config = config::change_point_config(&measurement);
388 let cp_warnings = generate_change_point_warnings(
389 &measurement,
390 &all_commits,
391 &group_selectors,
392 ¶ms.summarize_by,
393 no_change_point_warning,
394 &cp_config,
395 );
396 if !separate_by.is_empty() {
399 println!("Auditing measurement \"{}\"{}:", measurement, group_label);
401 for warning in &cp_warnings {
403 for line in warning.lines() {
404 println!(" {}", line);
405 }
406 }
407 for line in result.message.lines() {
409 println!(" {}", line);
410 }
411 println!(); } else {
413 for warning in &cp_warnings {
414 println!("{}", warning);
415 }
416 println!("{}", result.message);
417 }
418
419 if !separate_by.is_empty() {
420 total_groups += 1;
421 if result.passed {
422 passed_groups += 1;
423 }
424 }
425 if !result.passed {
426 failed = true;
427 }
428 }
429 }
430
431 if !separate_by.is_empty() {
433 if failed {
434 println!(
435 "Overall: FAILED ({}/{} groups passed)",
436 passed_groups, total_groups
437 );
438 } else {
439 println!(
440 "Overall: PASSED ({}/{} groups passed)",
441 passed_groups, total_groups
442 );
443 }
444 }
445
446 if failed {
447 bail!("One or more measurements failed audit.");
448 }
449
450 Ok(())
451}
452
453fn audit_with_commits(
457 measurement: &str,
458 commits: &[Result<Commit>],
459 selectors: &[(String, String)],
460 params: &ResolvedAuditParams,
461) -> Result<AuditResult> {
462 let commits_iter = commits.iter().map(|r| match r {
463 Ok(c) => Ok(c.clone()),
464 Err(e) => Err(anyhow::anyhow!("{}", e)),
465 });
466
467 let filter_by =
469 |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
470
471 let head_raw: Vec<f64> = commits
474 .first()
475 .and_then(|r| r.as_ref().ok())
476 .map(|c| {
477 c.measurements
478 .iter()
479 .filter(|m| filter_by(m))
480 .map(|m| m.val)
481 .collect()
482 })
483 .unwrap_or_default();
484
485 let mut aggregates = measurement_retrieval::take_while_same_epoch(summarize_measurements(
486 commits_iter,
487 ¶ms.summarize_by,
488 &filter_by,
489 ));
490
491 let head = aggregates
492 .next()
493 .ok_or(anyhow!("No commit at HEAD"))
494 .and_then(|s| {
495 s.and_then(|cs| {
496 cs.measurement
497 .map(|m| m.val)
498 .ok_or(anyhow!("No measurement for HEAD."))
499 })
500 })?;
501
502 let tail: Vec<_> = aggregates
503 .filter_map_ok(|cs| cs.measurement.map(|m| m.val))
504 .try_collect()?;
505
506 audit_with_data(measurement, head, head_raw, tail, params)
507}
508
509fn audit_with_data(
512 measurement: &str,
513 head: f64,
514 head_raw: Vec<f64>,
515 tail: Vec<f64>,
516 params: &ResolvedAuditParams,
517) -> Result<AuditResult> {
518 assert!(params.min_count >= 2, "min_count must be at least 2");
522
523 let unit = config::measurement_unit(measurement);
525 let unit_str = unit.as_deref();
526
527 let head_summary = stats::aggregate_measurements(iter::once(&head));
528 let tail_summary = stats::aggregate_measurements(tail.iter());
529
530 let all_measurements = tail.into_iter().chain(iter::once(head)).collect::<Vec<_>>();
532
533 let mut tail_measurements = all_measurements.clone();
534 tail_measurements.pop(); let tail_median = tail_measurements.median().unwrap_or_default();
536
537 let min_val = all_measurements
539 .iter()
540 .min_by(|a, b| a.partial_cmp(b).unwrap())
541 .unwrap();
542 let max_val = all_measurements
543 .iter()
544 .max_by(|a, b| a.partial_cmp(b).unwrap())
545 .unwrap();
546
547 let tail_median_is_zero = tail_median.abs() < f64::EPSILON;
551
552 let sparkline = if tail_median_is_zero {
553 format!(
555 " [{} – {}] {}",
556 min_val,
557 max_val,
558 spark(all_measurements.as_slice())
559 )
560 } else {
561 let relative_min = min_val / tail_median - 1.0;
564 let relative_max = max_val / tail_median - 1.0;
565
566 format!(
567 " [{:+.2}% – {:+.2}%] {}",
568 (relative_min * 100.0),
569 (relative_max * 100.0),
570 spark(all_measurements.as_slice())
571 )
572 };
573
574 let build_summary = || -> String {
577 let mut summary = String::new();
578
579 let total_measurements = all_measurements.len();
581
582 if total_measurements == 1 {
584 let head_display = StatsWithUnit {
585 stats: &head_summary,
586 unit: unit_str,
587 };
588 summary.push_str(&format!("Head: {}\n", head_display));
589 } else if total_measurements >= 2 {
590 let direction = get_direction_arrow(head_summary.mean, tail_summary.mean);
592 let z_score = head_summary.z_score_with_method(&tail_summary, params.dispersion_method);
593 let z_score_display = format_z_score_display(z_score);
594 let method_name = match params.dispersion_method {
595 DispersionMethod::StandardDeviation => "stddev",
596 DispersionMethod::MedianAbsoluteDeviation => "mad",
597 };
598
599 let head_display = StatsWithUnit {
600 stats: &head_summary,
601 unit: unit_str,
602 };
603 let tail_display = StatsWithUnit {
604 stats: &tail_summary,
605 unit: unit_str,
606 };
607
608 summary.push_str(&format!("Aggregation: {}\n", params.summarize_by));
609 summary.push_str(&format!(
610 "z-score ({method_name}): {direction}{}\n",
611 z_score_display
612 ));
613 summary.push_str(&format!("Head: {}\n", head_display));
614 summary.push_str(&format!("Tail: {}\n", tail_display));
615 summary.push_str(&sparkline);
616 }
617 summary
620 };
621
622 if tail_summary.len < params.min_count.into() {
624 let number_measurements = tail_summary.len;
625 let plural_s = if number_measurements == 1 { "" } else { "s" };
627 info!("Only {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {}. Skipping test.", params.min_count);
628
629 let mut skip_message = format!(
630 "⏭️ '{measurement}'\nOnly {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {}. Skipping test.", params.min_count
631 );
632
633 let summary = build_summary();
635 if !summary.is_empty() {
636 skip_message.push('\n');
637 skip_message.push_str(&summary);
638 }
639
640 return Ok(AuditResult {
641 message: skip_message,
642 passed: true,
643 });
644 }
645
646 let cov_warning = params.max_cov.and_then(|threshold| {
650 let tail_cov = (tail_summary.len >= 2 && tail_summary.mean.abs() > f64::EPSILON)
651 .then(|| tail_summary.stddev / tail_summary.mean * 100.0);
652 let head_raw_summary = stats::aggregate_measurements(head_raw.iter());
653 let head_cov = (head_raw.len() >= 2 && head_raw_summary.mean.abs() > f64::EPSILON)
654 .then(|| head_raw_summary.stddev / head_raw_summary.mean * 100.0);
655
656 let tail_exceeds = tail_cov.is_some_and(|cov| cov > threshold);
657 let head_exceeds = head_cov.is_some_and(|cov| cov > threshold);
658
659 if tail_exceeds || head_exceeds {
660 let mut parts = Vec::new();
661 if let Some(cov) = tail_cov {
662 parts.push(format!("tail={:.1}%", cov));
663 }
664 if let Some(cov) = head_cov {
665 parts.push(format!("head={:.1}%", cov));
666 }
667 Some(format!(
668 "\n⚠️ High CoV: {} (threshold: {threshold:.1}%)",
669 parts.join(", ")
670 ))
671 } else {
672 None
673 }
674 });
675
676 let head_relative_deviation = (head / tail_median - 1.0).abs() * 100.0;
679
680 let head_absolute_deviation = (head - tail_median).abs();
682
683 let min_relative_deviation = config::audit_min_relative_deviation(measurement);
685 let min_absolute_deviation = config::audit_min_absolute_deviation(measurement);
686
687 let passed_due_to_relative_threshold = min_relative_deviation
689 .map(|threshold| head_relative_deviation < threshold)
690 .unwrap_or(false);
691
692 let passed_due_to_absolute_threshold = min_absolute_deviation
693 .map(|threshold| head_absolute_deviation < threshold)
694 .unwrap_or(false);
695
696 let passed_due_to_threshold =
697 passed_due_to_relative_threshold || passed_due_to_absolute_threshold;
698
699 let text_summary = {
700 let mut s = build_summary();
701 if let Some(ref warning) = cov_warning {
702 s.push_str(warning);
703 }
704 s
705 };
706
707 let z_score_exceeds_sigma =
709 head_summary.is_significant(&tail_summary, params.sigma, params.dispersion_method);
710
711 let passed = !z_score_exceeds_sigma || passed_due_to_threshold;
713
714 let threshold_note = if z_score_exceeds_sigma {
717 let mut notes = Vec::new();
718 if passed_due_to_relative_threshold {
719 notes.push(format!(
720 "Note: Passed due to relative deviation ({:.1}%) being below threshold ({:.1}%)",
721 head_relative_deviation,
722 min_relative_deviation.unwrap()
723 ));
724 }
725 if passed_due_to_absolute_threshold {
726 notes.push(format!(
727 "Note: Passed due to absolute deviation ({:.1}) being below threshold ({:.1})",
728 head_absolute_deviation,
729 min_absolute_deviation.unwrap()
730 ));
731 }
732 if notes.is_empty() {
733 String::new()
734 } else {
735 format!("\n{}", notes.join("\n"))
736 }
737 } else {
738 String::new()
739 };
740
741 if !passed {
743 return Ok(AuditResult {
744 message: format!(
745 "❌ '{measurement}'\nHEAD differs significantly from tail measurements.\n{text_summary}{threshold_note}"
746 ),
747 passed: false,
748 });
749 }
750
751 Ok(AuditResult {
752 message: format!("✅ '{measurement}'\n{text_summary}{threshold_note}"),
753 passed: true,
754 })
755}
756
757#[cfg(test)]
758mod test {
759 use crate::test_helpers::with_isolated_test_setup;
760
761 use super::*;
762
763 #[test]
764 fn test_format_z_score_display() {
765 let test_cases = vec![
767 (2.5_f64, " 2.50"),
768 (0.0_f64, " 0.00"),
769 (-1.5_f64, " -1.50"),
770 (999.999_f64, " 1000.00"),
771 (0.001_f64, " 0.00"),
772 (f64::INFINITY, ""),
773 (f64::NEG_INFINITY, ""),
774 (f64::NAN, ""),
775 ];
776
777 for (z_score, expected) in test_cases {
778 let result = format_z_score_display(z_score);
779 assert_eq!(result, expected, "Failed for z_score: {}", z_score);
780 }
781 }
782
783 #[test]
784 fn test_direction_arrows() {
785 let test_cases = vec![
787 (5.0_f64, 3.0_f64, "↑"), (1.0_f64, 3.0_f64, "↓"), (3.0_f64, 3.0_f64, "→"), ];
791
792 for (head_mean, tail_mean, expected) in test_cases {
793 let result = get_direction_arrow(head_mean, tail_mean);
794 assert_eq!(
795 result, expected,
796 "Failed for head_mean: {}, tail_mean: {}",
797 head_mean, tail_mean
798 );
799 }
800 }
801
802 #[test]
803 fn test_audit_with_different_dispersion_methods() {
804 let head_value = 35.0;
808 let tail_values = [30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
809
810 let head_summary = stats::aggregate_measurements(std::iter::once(&head_value));
811 let tail_summary = stats::aggregate_measurements(tail_values.iter());
812
813 let z_score_stddev =
815 head_summary.z_score_with_method(&tail_summary, DispersionMethod::StandardDeviation);
816 let z_score_mad = head_summary
817 .z_score_with_method(&tail_summary, DispersionMethod::MedianAbsoluteDeviation);
818
819 assert!(
822 z_score_stddev < z_score_mad,
823 "stddev z-score ({}) should be smaller than MAD z-score ({}) with outlier data",
824 z_score_stddev,
825 z_score_mad
826 );
827
828 assert!(z_score_stddev > 0.0);
830 assert!(z_score_mad > 0.0);
831 }
832
833 #[test]
834 fn test_dispersion_method_conversion() {
835 let cli_stddev = git_perf_cli_types::DispersionMethod::StandardDeviation;
839 let stats_stddev: DispersionMethod = cli_stddev.into();
840 assert_eq!(stats_stddev, DispersionMethod::StandardDeviation);
841
842 let cli_mad = git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation;
844 let stats_mad: DispersionMethod = cli_mad.into();
845 assert_eq!(stats_mad, DispersionMethod::MedianAbsoluteDeviation);
846 }
847
848 #[test]
849 fn test_audit_multiple_with_no_measurements() {
850 with_isolated_test_setup(|_git_dir, _home_path| {
854 let result = audit_multiple(
855 "HEAD",
856 100,
857 None,
858 None,
859 Some(1),
860 &[],
861 Some(ReductionFunc::Mean),
862 Some(2.0),
863 Some(DispersionMethod::StandardDeviation),
864 None, &[], &[], false,
868 );
869
870 assert!(
872 result.is_ok(),
873 "audit_multiple should succeed with empty pattern list"
874 );
875 });
876 }
877
878 #[test]
881 fn test_min_count_boundary_condition() {
882 let result = audit_with_data(
885 "test_measurement",
886 15.0,
887 vec![],
888 vec![10.0, 11.0, 12.0],
889 &ResolvedAuditParams {
890 min_count: 3,
891 sigma: 2.0,
892 dispersion_method: DispersionMethod::StandardDeviation,
893 summarize_by: ReductionFunc::Min,
894 max_cov: None,
895 },
896 );
897
898 assert!(result.is_ok());
899 let audit_result = result.unwrap();
900 assert!(!audit_result.message.contains("Skipping test"));
902
903 let result = audit_with_data(
905 "test_measurement",
906 15.0,
907 vec![],
908 vec![10.0, 11.0],
909 &ResolvedAuditParams {
910 min_count: 3,
911 sigma: 2.0,
912 dispersion_method: DispersionMethod::StandardDeviation,
913 summarize_by: ReductionFunc::Min,
914 max_cov: None,
915 },
916 );
917
918 assert!(result.is_ok());
919 let audit_result = result.unwrap();
920 assert!(audit_result.message.contains("Skipping test"));
921 assert!(audit_result.passed); }
923
924 #[test]
925 fn test_pluralization_logic() {
926 let result = audit_with_data(
929 "test_measurement",
930 15.0,
931 vec![],
932 vec![],
933 &ResolvedAuditParams {
934 min_count: 5,
935 sigma: 2.0,
936 dispersion_method: DispersionMethod::StandardDeviation,
937 summarize_by: ReductionFunc::Min,
938 max_cov: None,
939 },
940 );
941
942 assert!(result.is_ok());
943 let message = result.unwrap().message;
944 assert!(message.contains("0 historical measurements found")); assert!(!message.contains("0 historical measurement found")); let result = audit_with_data(
949 "test_measurement",
950 15.0,
951 vec![],
952 vec![10.0],
953 &ResolvedAuditParams {
954 min_count: 5,
955 sigma: 2.0,
956 dispersion_method: DispersionMethod::StandardDeviation,
957 summarize_by: ReductionFunc::Min,
958 max_cov: None,
959 },
960 );
961
962 assert!(result.is_ok());
963 let message = result.unwrap().message;
964 assert!(message.contains("1 historical measurement found")); let result = audit_with_data(
968 "test_measurement",
969 15.0,
970 vec![],
971 vec![10.0, 11.0],
972 &ResolvedAuditParams {
973 min_count: 5,
974 sigma: 2.0,
975 dispersion_method: DispersionMethod::StandardDeviation,
976 summarize_by: ReductionFunc::Min,
977 max_cov: None,
978 },
979 );
980
981 assert!(result.is_ok());
982 let message = result.unwrap().message;
983 assert!(message.contains("2 historical measurements found")); }
985
986 #[test]
987 fn test_skip_with_summaries() {
988 let result = audit_with_data(
994 "test_measurement",
995 15.0,
996 vec![],
997 vec![],
998 &ResolvedAuditParams {
999 min_count: 5,
1000 sigma: 2.0,
1001 dispersion_method: DispersionMethod::StandardDeviation,
1002 summarize_by: ReductionFunc::Min,
1003 max_cov: None,
1004 },
1005 );
1006
1007 assert!(result.is_ok());
1008 let message = result.unwrap().message;
1009 assert!(message.contains("Skipping test"));
1010 assert!(message.contains("Head:")); assert!(!message.contains("z-score")); assert!(!message.contains("Tail:")); assert!(!message.contains("[")); let result = audit_with_data(
1017 "test_measurement",
1018 15.0,
1019 vec![],
1020 vec![10.0],
1021 &ResolvedAuditParams {
1022 min_count: 5,
1023 sigma: 2.0,
1024 dispersion_method: DispersionMethod::StandardDeviation,
1025 summarize_by: ReductionFunc::Min,
1026 max_cov: None,
1027 },
1028 );
1029
1030 assert!(result.is_ok());
1031 let message = result.unwrap().message;
1032 assert!(message.contains("Skipping test"));
1033 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();
1039 let head_pos = message.find("Head:").unwrap();
1040 let tail_pos = message.find("Tail:").unwrap();
1041 let spark_pos = message.find("[").unwrap();
1042 assert!(z_pos < head_pos, "z-score should come before Head");
1043 assert!(head_pos < tail_pos, "Head should come before Tail");
1044 assert!(tail_pos < spark_pos, "Tail should come before sparkline");
1045
1046 let result = audit_with_data(
1048 "test_measurement",
1049 15.0,
1050 vec![],
1051 vec![10.0, 11.0],
1052 &ResolvedAuditParams {
1053 min_count: 5,
1054 sigma: 2.0,
1055 dispersion_method: DispersionMethod::StandardDeviation,
1056 summarize_by: ReductionFunc::Min,
1057 max_cov: None,
1058 },
1059 );
1060
1061 assert!(result.is_ok());
1062 let message = result.unwrap().message;
1063 assert!(message.contains("Skipping test"));
1064 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();
1070 let head_pos = message.find("Head:").unwrap();
1071 let tail_pos = message.find("Tail:").unwrap();
1072 let spark_pos = message.find("[").unwrap();
1073 assert!(z_pos < head_pos, "z-score should come before Head");
1074 assert!(head_pos < tail_pos, "Head should come before Tail");
1075 assert!(tail_pos < spark_pos, "Tail should come before sparkline");
1076
1077 let result = audit_with_data(
1079 "test_measurement",
1080 15.0,
1081 vec![],
1082 vec![10.0, 11.0],
1083 &ResolvedAuditParams {
1084 min_count: 5,
1085 sigma: 2.0,
1086 dispersion_method: DispersionMethod::MedianAbsoluteDeviation,
1087 summarize_by: ReductionFunc::Min,
1088 max_cov: None,
1089 },
1090 );
1091
1092 assert!(result.is_ok());
1093 let message = result.unwrap().message;
1094 assert!(message.contains("z-score (mad):")); }
1096
1097 #[test]
1098 fn test_relative_calculations_division_vs_modulo() {
1099 let result = audit_with_data(
1102 "test_measurement",
1103 25.0,
1104 vec![],
1105 vec![10.0, 10.0, 10.0],
1106 &ResolvedAuditParams {
1107 min_count: 2,
1108 sigma: 10.0,
1109 dispersion_method: DispersionMethod::StandardDeviation,
1110 summarize_by: ReductionFunc::Min,
1111 max_cov: None,
1112 },
1113 );
1114
1115 assert!(result.is_ok());
1116 let audit_result = result.unwrap();
1117
1118 assert!(audit_result.message.contains("[+0.00% – +150.00%]"));
1128
1129 assert!(!audit_result.message.contains("[-100.00% – -50.00%]"));
1131 assert!(!audit_result.message.contains("-100.00%"));
1132 assert!(!audit_result.message.contains("-50.00%"));
1133 }
1134
1135 #[test]
1136 fn test_core_pass_fail_logic() {
1137 let result = audit_with_data(
1142 "test_measurement",
1143 100.0,
1144 vec![],
1145 vec![10.0, 10.0, 10.0, 10.0, 10.0],
1146 &ResolvedAuditParams {
1147 min_count: 2,
1148 sigma: 0.5,
1149 dispersion_method: DispersionMethod::StandardDeviation,
1150 summarize_by: ReductionFunc::Min,
1151 max_cov: None,
1152 },
1153 );
1154
1155 assert!(result.is_ok());
1156 let audit_result = result.unwrap();
1157 assert!(!audit_result.passed); assert!(audit_result.message.contains("❌"));
1159
1160 let result = audit_with_data(
1162 "test_measurement",
1163 10.2,
1164 vec![],
1165 vec![10.0, 10.1, 10.0, 10.1, 10.0],
1166 &ResolvedAuditParams {
1167 min_count: 2,
1168 sigma: 100.0,
1169 dispersion_method: DispersionMethod::StandardDeviation,
1170 summarize_by: ReductionFunc::Min,
1171 max_cov: None,
1172 },
1173 );
1174
1175 assert!(result.is_ok());
1176 let audit_result = result.unwrap();
1177 assert!(audit_result.passed); assert!(audit_result.message.contains("✅"));
1179 }
1180
1181 #[test]
1182 fn test_final_result_logic() {
1183 let result = audit_with_data(
1188 "test_measurement",
1189 1000.0,
1190 vec![],
1191 vec![10.0, 10.0, 10.0, 10.0, 10.0],
1192 &ResolvedAuditParams {
1193 min_count: 2,
1194 sigma: 0.1,
1195 dispersion_method: DispersionMethod::StandardDeviation,
1196 summarize_by: ReductionFunc::Min,
1197 max_cov: None,
1198 },
1199 );
1200
1201 assert!(result.is_ok());
1202 let audit_result = result.unwrap();
1203 assert!(!audit_result.passed);
1204 assert!(audit_result.message.contains("❌"));
1205 assert!(audit_result.message.contains("differs significantly"));
1206
1207 let result = audit_with_data(
1209 "test_measurement",
1210 10.01,
1211 vec![],
1212 vec![10.0, 10.1, 10.0, 10.1, 10.0],
1213 &ResolvedAuditParams {
1214 min_count: 2,
1215 sigma: 100.0,
1216 dispersion_method: DispersionMethod::StandardDeviation,
1217 summarize_by: ReductionFunc::Min,
1218 max_cov: None,
1219 },
1220 );
1221
1222 assert!(result.is_ok());
1223 let audit_result = result.unwrap();
1224 assert!(audit_result.passed);
1225 assert!(audit_result.message.contains("✅"));
1226 assert!(!audit_result.message.contains("differs significantly"));
1227 }
1228
1229 #[test]
1230 fn test_dispersion_methods_produce_different_results() {
1231 let head = 35.0;
1233 let tail = vec![30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
1234
1235 let result_stddev = audit_with_data(
1236 "test_measurement",
1237 head,
1238 vec![],
1239 tail.clone(),
1240 &ResolvedAuditParams {
1241 min_count: 2,
1242 sigma: 2.0,
1243 dispersion_method: DispersionMethod::StandardDeviation,
1244 summarize_by: ReductionFunc::Min,
1245 max_cov: None,
1246 },
1247 );
1248
1249 let result_mad = audit_with_data(
1250 "test_measurement",
1251 head,
1252 vec![],
1253 tail,
1254 &ResolvedAuditParams {
1255 min_count: 2,
1256 sigma: 2.0,
1257 dispersion_method: DispersionMethod::MedianAbsoluteDeviation,
1258 summarize_by: ReductionFunc::Min,
1259 max_cov: None,
1260 },
1261 );
1262
1263 assert!(result_stddev.is_ok());
1264 assert!(result_mad.is_ok());
1265
1266 let stddev_result = result_stddev.unwrap();
1267 let mad_result = result_mad.unwrap();
1268
1269 assert!(stddev_result.message.contains("stddev"));
1271 assert!(mad_result.message.contains("mad"));
1272 }
1273
1274 #[test]
1275 fn test_head_and_tail_have_units_and_auto_scaling() {
1276 use crate::test_helpers::setup_test_env_with_config;
1280
1281 let config_content = r#"
1282[measurement."build_time"]
1283unit = "ms"
1284"#;
1285 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1286
1287 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(
1292 "build_time",
1293 head,
1294 vec![],
1295 tail,
1296 &ResolvedAuditParams {
1297 min_count: 2,
1298 sigma: 10.0,
1299 dispersion_method: DispersionMethod::StandardDeviation,
1300 summarize_by: ReductionFunc::Min,
1301 max_cov: None,
1302 },
1303 );
1304
1305 assert!(result.is_ok());
1306 let audit_result = result.unwrap();
1307 let message = &audit_result.message;
1308
1309 assert!(
1311 message.contains("Head:"),
1312 "Message should contain Head section"
1313 );
1314
1315 assert!(
1318 message.contains("12.3s") || message.contains("12.35s"),
1319 "Head mean should be auto-scaled to seconds, got: {}",
1320 message
1321 );
1322
1323 let head_section: Vec<&str> = message
1324 .lines()
1325 .filter(|line| line.contains("Head:"))
1326 .collect();
1327
1328 assert!(
1329 !head_section.is_empty(),
1330 "Should find Head section in message"
1331 );
1332
1333 let head_line = head_section[0];
1334
1335 assert!(
1338 head_line.contains("μ:") && head_line.contains("σ:") && head_line.contains("MAD:"),
1339 "Head line should contain μ, σ, and MAD labels, got: {}",
1340 head_line
1341 );
1342
1343 assert!(
1345 message.contains("Tail:"),
1346 "Message should contain Tail section"
1347 );
1348
1349 let tail_section: Vec<&str> = message
1350 .lines()
1351 .filter(|line| line.contains("Tail:"))
1352 .collect();
1353
1354 assert!(
1355 !tail_section.is_empty(),
1356 "Should find Tail section in message"
1357 );
1358
1359 let tail_line = tail_section[0];
1360
1361 assert!(
1363 tail_line.contains("11s")
1364 || tail_line.contains("11.")
1365 || tail_line.contains("10.")
1366 || tail_line.contains("12."),
1367 "Tail should contain auto-scaled second values, got: {}",
1368 tail_line
1369 );
1370
1371 assert!(
1373 tail_line.contains("μ:")
1374 && tail_line.contains("σ:")
1375 && tail_line.contains("MAD:")
1376 && tail_line.contains("n:"),
1377 "Tail line should contain all stat labels, got: {}",
1378 tail_line
1379 );
1380 }
1381
1382 #[test]
1383 fn test_threshold_note_only_shown_when_audit_would_fail() {
1384 use crate::test_helpers::setup_test_env_with_config;
1387
1388 let config_content = r#"
1389[measurement."build_time"]
1390min_relative_deviation = 10.0
1391"#;
1392 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1393
1394 let result = audit_with_data(
1397 "build_time",
1398 10.1,
1399 vec![],
1400 vec![10.0, 10.1, 10.0, 10.1, 10.0],
1401 &ResolvedAuditParams {
1402 min_count: 2,
1403 sigma: 100.0,
1404 dispersion_method: DispersionMethod::StandardDeviation,
1405 summarize_by: ReductionFunc::Min,
1406 max_cov: None,
1407 },
1408 );
1409
1410 assert!(result.is_ok());
1411 let audit_result = result.unwrap();
1412 assert!(audit_result.passed);
1413 assert!(audit_result.message.contains("✅"));
1414 assert!(
1416 !audit_result
1417 .message
1418 .contains("Note: Passed due to relative deviation"),
1419 "Note should not appear when audit passes without needing threshold bypass"
1420 );
1421
1422 let result = audit_with_data(
1425 "build_time",
1426 1002.0,
1427 vec![],
1428 vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0],
1429 &ResolvedAuditParams {
1430 min_count: 2,
1431 sigma: 0.5,
1432 dispersion_method: DispersionMethod::StandardDeviation,
1433 summarize_by: ReductionFunc::Min,
1434 max_cov: None,
1435 },
1436 );
1437
1438 assert!(result.is_ok());
1439 let audit_result = result.unwrap();
1440 assert!(audit_result.passed);
1441 assert!(audit_result.message.contains("✅"));
1442 assert!(
1444 audit_result
1445 .message
1446 .contains("Note: Passed due to relative deviation"),
1447 "Note should appear when audit passes due to threshold bypass. Got: {}",
1448 audit_result.message
1449 );
1450
1451 let result = audit_with_data(
1454 "build_time",
1455 1200.0,
1456 vec![],
1457 vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0],
1458 &ResolvedAuditParams {
1459 min_count: 2,
1460 sigma: 0.5,
1461 dispersion_method: DispersionMethod::StandardDeviation,
1462 summarize_by: ReductionFunc::Min,
1463 max_cov: None,
1464 },
1465 );
1466
1467 assert!(result.is_ok());
1468 let audit_result = result.unwrap();
1469 assert!(!audit_result.passed);
1470 assert!(audit_result.message.contains("❌"));
1471 assert!(
1473 !audit_result
1474 .message
1475 .contains("Note: Passed due to relative deviation"),
1476 "Note should not appear when audit fails"
1477 );
1478 }
1479
1480 #[test]
1481 fn test_absolute_threshold_note_and_deviation_value() {
1482 use crate::test_helpers::setup_test_env_with_config;
1486
1487 let config_content = r#"
1488[measurement."build_time"]
1489min_absolute_deviation = 50.0
1490"#;
1491 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1492
1493 let result = audit_with_data(
1503 "build_time",
1504 100.0,
1505 vec![],
1506 vec![10.0, 10.0, 10.0, 10.0, 10.0],
1507 &ResolvedAuditParams {
1508 min_count: 2,
1509 sigma: 0.5,
1510 dispersion_method: DispersionMethod::StandardDeviation,
1511 summarize_by: ReductionFunc::Min,
1512 max_cov: None,
1513 },
1514 );
1515
1516 assert!(result.is_ok());
1517 let audit_result = result.unwrap();
1518 assert!(
1520 !audit_result.passed,
1521 "Should fail: absolute deviation 90 > threshold 50. Got: {}",
1522 audit_result.message
1523 );
1524
1525 let result = audit_with_data(
1530 "build_time",
1531 1050.0,
1532 vec![],
1533 vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0],
1534 &ResolvedAuditParams {
1535 min_count: 2,
1536 sigma: 0.5,
1537 dispersion_method: DispersionMethod::StandardDeviation,
1538 summarize_by: ReductionFunc::Min,
1539 max_cov: None,
1540 },
1541 );
1542
1543 assert!(result.is_ok());
1544 let audit_result = result.unwrap();
1545 assert!(
1547 !audit_result.passed,
1548 "Should fail: absolute deviation 50 == threshold 50 (not strictly less than). Got: {}",
1549 audit_result.message
1550 );
1551
1552 let result = audit_with_data(
1555 "build_time",
1556 1049.0,
1557 vec![],
1558 vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0],
1559 &ResolvedAuditParams {
1560 min_count: 2,
1561 sigma: 0.5,
1562 dispersion_method: DispersionMethod::StandardDeviation,
1563 summarize_by: ReductionFunc::Min,
1564 max_cov: None,
1565 },
1566 );
1567
1568 assert!(result.is_ok());
1569 let audit_result = result.unwrap();
1570 assert!(
1571 audit_result.passed,
1572 "Should pass: absolute deviation 49 < threshold 50. Got: {}",
1573 audit_result.message
1574 );
1575 assert!(
1576 audit_result
1577 .message
1578 .contains("Note: Passed due to absolute deviation"),
1579 "Note should appear when audit passes due to absolute threshold. Got: {}",
1580 audit_result.message
1581 );
1582 assert!(
1585 audit_result.message.contains("49.0"),
1586 "Note should show absolute deviation 49.0, not 1.0 (which would indicate / instead of -). Got: {}",
1587 audit_result.message
1588 );
1589 }
1590
1591 #[cfg(test)]
1593 mod integration {
1594 use super::*;
1595 use crate::config::{
1596 audit_aggregate_by, audit_dispersion_method, audit_min_measurements, audit_sigma,
1597 };
1598 use crate::test_helpers::setup_test_env_with_config;
1599
1600 #[test]
1601 fn test_different_dispersion_methods_per_measurement() {
1602 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1603 r#"
1604[measurement]
1605dispersion_method = "stddev"
1606
1607[measurement."build_time"]
1608dispersion_method = "mad"
1609
1610[measurement."memory_usage"]
1611dispersion_method = "stddev"
1612"#,
1613 );
1614
1615 let build_time_method = audit_dispersion_method("build_time");
1617 let memory_usage_method = audit_dispersion_method("memory_usage");
1618 let other_method = audit_dispersion_method("other_metric");
1619
1620 assert_eq!(
1621 DispersionMethod::from(build_time_method),
1622 DispersionMethod::MedianAbsoluteDeviation,
1623 "build_time should use MAD"
1624 );
1625 assert_eq!(
1626 DispersionMethod::from(memory_usage_method),
1627 DispersionMethod::StandardDeviation,
1628 "memory_usage should use stddev"
1629 );
1630 assert_eq!(
1631 DispersionMethod::from(other_method),
1632 DispersionMethod::StandardDeviation,
1633 "other_metric should use default stddev"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_different_min_measurements_per_measurement() {
1639 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1640 r#"
1641[measurement]
1642min_measurements = 5
1643
1644[measurement."build_time"]
1645min_measurements = 10
1646
1647[measurement."memory_usage"]
1648min_measurements = 3
1649"#,
1650 );
1651
1652 assert_eq!(
1653 audit_min_measurements("build_time"),
1654 Some(10),
1655 "build_time should require 10 measurements"
1656 );
1657 assert_eq!(
1658 audit_min_measurements("memory_usage"),
1659 Some(3),
1660 "memory_usage should require 3 measurements"
1661 );
1662 assert_eq!(
1663 audit_min_measurements("other_metric"),
1664 Some(5),
1665 "other_metric should use default 5 measurements"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_different_aggregate_by_per_measurement() {
1671 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1672 r#"
1673[measurement]
1674aggregate_by = "median"
1675
1676[measurement."build_time"]
1677aggregate_by = "max"
1678
1679[measurement."memory_usage"]
1680aggregate_by = "mean"
1681"#,
1682 );
1683
1684 assert_eq!(
1685 audit_aggregate_by("build_time"),
1686 Some(git_perf_cli_types::ReductionFunc::Max),
1687 "build_time should use max"
1688 );
1689 assert_eq!(
1690 audit_aggregate_by("memory_usage"),
1691 Some(git_perf_cli_types::ReductionFunc::Mean),
1692 "memory_usage should use mean"
1693 );
1694 assert_eq!(
1695 audit_aggregate_by("other_metric"),
1696 Some(git_perf_cli_types::ReductionFunc::Median),
1697 "other_metric should use default median"
1698 );
1699 }
1700
1701 #[test]
1702 fn test_different_sigma_per_measurement() {
1703 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1704 r#"
1705[measurement]
1706sigma = 3.0
1707
1708[measurement."build_time"]
1709sigma = 5.5
1710
1711[measurement."memory_usage"]
1712sigma = 2.0
1713"#,
1714 );
1715
1716 assert_eq!(
1717 audit_sigma("build_time"),
1718 Some(5.5),
1719 "build_time should use sigma 5.5"
1720 );
1721 assert_eq!(
1722 audit_sigma("memory_usage"),
1723 Some(2.0),
1724 "memory_usage should use sigma 2.0"
1725 );
1726 assert_eq!(
1727 audit_sigma("other_metric"),
1728 Some(3.0),
1729 "other_metric should use default sigma 3.0"
1730 );
1731 }
1732
1733 #[test]
1734 fn test_cli_overrides_config() {
1735 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1736 r#"
1737[measurement."build_time"]
1738min_measurements = 10
1739aggregate_by = "max"
1740sigma = 5.5
1741dispersion_method = "mad"
1742"#,
1743 );
1744
1745 let params = super::resolve_audit_params(
1747 "build_time",
1748 Some(2), Some(ReductionFunc::Min), Some(3.0), Some(DispersionMethod::StandardDeviation), None, );
1754
1755 assert_eq!(
1756 params.min_count, 2,
1757 "CLI min_measurements should override config"
1758 );
1759 assert_eq!(
1760 params.summarize_by,
1761 ReductionFunc::Min,
1762 "CLI aggregate_by should override config"
1763 );
1764 assert_eq!(params.sigma, 3.0, "CLI sigma should override config");
1765 assert_eq!(
1766 params.dispersion_method,
1767 DispersionMethod::StandardDeviation,
1768 "CLI dispersion should override config"
1769 );
1770 }
1771
1772 #[test]
1773 fn test_config_overrides_defaults() {
1774 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1775 r#"
1776[measurement."build_time"]
1777min_measurements = 10
1778aggregate_by = "max"
1779sigma = 5.5
1780dispersion_method = "mad"
1781"#,
1782 );
1783
1784 let params = super::resolve_audit_params(
1786 "build_time",
1787 None, None,
1789 None,
1790 None,
1791 None, );
1793
1794 assert_eq!(
1795 params.min_count, 10,
1796 "Config min_measurements should override default"
1797 );
1798 assert_eq!(
1799 params.summarize_by,
1800 ReductionFunc::Max,
1801 "Config aggregate_by should override default"
1802 );
1803 assert_eq!(params.sigma, 5.5, "Config sigma should override default");
1804 assert_eq!(
1805 params.dispersion_method,
1806 DispersionMethod::MedianAbsoluteDeviation,
1807 "Config dispersion should override default"
1808 );
1809 }
1810
1811 #[test]
1812 fn test_uses_defaults_when_no_config_or_cli() {
1813 let (_temp_dir, _dir_guard) = setup_test_env_with_config("");
1814
1815 let params = super::resolve_audit_params(
1817 "non_existent_measurement",
1818 None, None,
1820 None,
1821 None,
1822 None, );
1824
1825 assert_eq!(
1826 params.min_count, 2,
1827 "Should use default min_measurements of 2"
1828 );
1829 assert_eq!(
1830 params.summarize_by,
1831 ReductionFunc::Min,
1832 "Should use default aggregate_by of Min"
1833 );
1834 assert_eq!(params.sigma, 4.0, "Should use default sigma of 4.0");
1835 assert_eq!(
1836 params.dispersion_method,
1837 DispersionMethod::StandardDeviation,
1838 "Should use default dispersion of stddev"
1839 );
1840 }
1841 }
1842
1843 #[test]
1844 fn test_discover_matching_measurements() {
1845 use crate::data::{Commit, MeasurementData};
1846 use std::collections::HashMap;
1847
1848 let commits = vec![
1850 Ok(Commit {
1851 commit: "abc123".to_string(),
1852 title: "test: commit 1".to_string(),
1853 author: "Test Author".to_string(),
1854 measurements: vec![
1855 MeasurementData {
1856 epoch: 0,
1857 name: "bench_cpu".to_string(),
1858 timestamp: 1000.0,
1859 val: 100.0,
1860 key_values: {
1861 let mut map = HashMap::new();
1862 map.insert("os".to_string(), "linux".to_string());
1863 map
1864 },
1865 },
1866 MeasurementData {
1867 epoch: 0,
1868 name: "bench_memory".to_string(),
1869 timestamp: 1000.0,
1870 val: 200.0,
1871 key_values: {
1872 let mut map = HashMap::new();
1873 map.insert("os".to_string(), "linux".to_string());
1874 map
1875 },
1876 },
1877 MeasurementData {
1878 epoch: 0,
1879 name: "test_unit".to_string(),
1880 timestamp: 1000.0,
1881 val: 50.0,
1882 key_values: {
1883 let mut map = HashMap::new();
1884 map.insert("os".to_string(), "linux".to_string());
1885 map
1886 },
1887 },
1888 ],
1889 }),
1890 Ok(Commit {
1891 commit: "def456".to_string(),
1892 title: "test: commit 2".to_string(),
1893 author: "Test Author".to_string(),
1894 measurements: vec![
1895 MeasurementData {
1896 epoch: 0,
1897 name: "bench_cpu".to_string(),
1898 timestamp: 1000.0,
1899 val: 105.0,
1900 key_values: {
1901 let mut map = HashMap::new();
1902 map.insert("os".to_string(), "mac".to_string());
1903 map
1904 },
1905 },
1906 MeasurementData {
1907 epoch: 0,
1908 name: "other_metric".to_string(),
1909 timestamp: 1000.0,
1910 val: 75.0,
1911 key_values: {
1912 let mut map = HashMap::new();
1913 map.insert("os".to_string(), "linux".to_string());
1914 map
1915 },
1916 },
1917 ],
1918 }),
1919 ];
1920
1921 let patterns = vec!["bench_.*".to_string()];
1923 let filters = crate::filter::compile_filters(&patterns).unwrap();
1924 let selectors = vec![];
1925 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1926
1927 assert_eq!(discovered.len(), 2);
1928 assert!(discovered.contains(&"bench_cpu".to_string()));
1929 assert!(discovered.contains(&"bench_memory".to_string()));
1930 assert!(!discovered.contains(&"test_unit".to_string()));
1931 assert!(!discovered.contains(&"other_metric".to_string()));
1932
1933 let patterns = vec!["bench_cpu".to_string(), "test_.*".to_string()];
1935 let filters = crate::filter::compile_filters(&patterns).unwrap();
1936 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1937
1938 assert_eq!(discovered.len(), 2);
1939 assert!(discovered.contains(&"bench_cpu".to_string()));
1940 assert!(discovered.contains(&"test_unit".to_string()));
1941 assert!(!discovered.contains(&"bench_memory".to_string()));
1942
1943 let patterns = vec!["bench_.*".to_string()];
1945 let filters = crate::filter::compile_filters(&patterns).unwrap();
1946 let selectors = vec![("os".to_string(), "linux".to_string())];
1947 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1948
1949 assert_eq!(discovered.len(), 2);
1952 assert!(discovered.contains(&"bench_cpu".to_string()));
1953 assert!(discovered.contains(&"bench_memory".to_string()));
1954
1955 let patterns = vec!["nonexistent.*".to_string()];
1957 let filters = crate::filter::compile_filters(&patterns).unwrap();
1958 let selectors = vec![];
1959 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1960
1961 assert_eq!(discovered.len(), 0);
1962
1963 let filters = vec![];
1965 let selectors = vec![];
1966 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1967
1968 assert_eq!(discovered.len(), 4);
1972 assert!(discovered.contains(&"bench_cpu".to_string()));
1973 assert!(discovered.contains(&"bench_memory".to_string()));
1974 assert!(discovered.contains(&"test_unit".to_string()));
1975 assert!(discovered.contains(&"other_metric".to_string()));
1976
1977 let patterns = vec!["bench_.*".to_string()];
1979 let filters = crate::filter::compile_filters(&patterns).unwrap();
1980 let selectors = vec![("os".to_string(), "windows".to_string())];
1981 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1982
1983 assert_eq!(discovered.len(), 0);
1984
1985 let patterns = vec!["^bench_cpu$".to_string()];
1987 let filters = crate::filter::compile_filters(&patterns).unwrap();
1988 let selectors = vec![];
1989 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1990
1991 assert_eq!(discovered.len(), 1);
1992 assert!(discovered.contains(&"bench_cpu".to_string()));
1993
1994 let patterns = vec![".*".to_string()]; let filters = crate::filter::compile_filters(&patterns).unwrap();
1997 let selectors = vec![];
1998 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1999
2000 assert_eq!(discovered[0], "bench_cpu");
2002 assert_eq!(discovered[1], "bench_memory");
2003 assert_eq!(discovered[2], "other_metric");
2004 assert_eq!(discovered[3], "test_unit");
2005 }
2006
2007 #[test]
2008 fn test_audit_multiple_with_combined_patterns() {
2009 use crate::data::{Commit, MeasurementData};
2016 use std::collections::HashMap;
2017
2018 let commits = vec![Ok(Commit {
2020 commit: "abc123".to_string(),
2021 title: "test: commit".to_string(),
2022 author: "Test Author".to_string(),
2023 measurements: vec![
2024 MeasurementData {
2025 epoch: 0,
2026 name: "timer".to_string(),
2027 timestamp: 1000.0,
2028 val: 10.0,
2029 key_values: HashMap::new(),
2030 },
2031 MeasurementData {
2032 epoch: 0,
2033 name: "bench_cpu".to_string(),
2034 timestamp: 1000.0,
2035 val: 100.0,
2036 key_values: HashMap::new(),
2037 },
2038 MeasurementData {
2039 epoch: 0,
2040 name: "memory".to_string(),
2041 timestamp: 1000.0,
2042 val: 500.0,
2043 key_values: HashMap::new(),
2044 },
2045 ],
2046 })];
2047
2048 let measurements = vec!["timer".to_string()];
2051 let filter_patterns = vec!["bench_.*".to_string()];
2052 let combined =
2053 crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
2054
2055 assert_eq!(combined.len(), 2);
2057 assert_eq!(combined[0], "^timer$");
2058 assert_eq!(combined[1], "bench_.*");
2059
2060 let filters = crate::filter::compile_filters(&combined).unwrap();
2062 let selectors = vec![];
2063 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
2064
2065 assert_eq!(discovered.len(), 2);
2067 assert!(discovered.contains(&"timer".to_string()));
2068 assert!(discovered.contains(&"bench_cpu".to_string()));
2069 assert!(!discovered.contains(&"memory".to_string())); let measurements = vec!["timer".to_string(), "memory".to_string()];
2073 let filter_patterns = vec!["bench_.*".to_string(), "test_.*".to_string()];
2074 let combined =
2075 crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
2076
2077 assert_eq!(combined.len(), 4);
2078
2079 let filters = crate::filter::compile_filters(&combined).unwrap();
2080 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
2081
2082 assert_eq!(discovered.len(), 3);
2084 assert!(discovered.contains(&"timer".to_string()));
2085 assert!(discovered.contains(&"memory".to_string()));
2086 assert!(discovered.contains(&"bench_cpu".to_string()));
2087 }
2088
2089 #[test]
2090 fn test_audit_with_empty_tail() {
2091 let result = audit_with_data(
2095 "test_measurement",
2096 10.0,
2097 vec![],
2098 vec![],
2099 &ResolvedAuditParams {
2100 min_count: 2,
2101 sigma: 2.0,
2102 dispersion_method: DispersionMethod::StandardDeviation,
2103 summarize_by: ReductionFunc::Min,
2104 max_cov: None,
2105 },
2106 );
2107
2108 assert!(result.is_ok(), "Should not crash on empty tail");
2110 let audit_result = result.unwrap();
2111
2112 assert!(audit_result.passed);
2114 assert!(audit_result.message.contains("Skipping test"));
2115
2116 assert!(!audit_result.message.to_lowercase().contains("inf"));
2118 assert!(!audit_result.message.to_lowercase().contains("nan"));
2119 }
2120
2121 #[test]
2122 fn test_audit_with_all_zero_tail() {
2123 let result = audit_with_data(
2126 "test_measurement",
2127 5.0,
2128 vec![],
2129 vec![0.0, 0.0, 0.0],
2130 &ResolvedAuditParams {
2131 min_count: 2,
2132 sigma: 2.0,
2133 dispersion_method: DispersionMethod::StandardDeviation,
2134 summarize_by: ReductionFunc::Min,
2135 max_cov: None,
2136 },
2137 );
2138
2139 assert!(result.is_ok(), "Should not crash when tail median is 0.0");
2141 let audit_result = result.unwrap();
2142
2143 assert!(!audit_result.message.to_lowercase().contains("inf"));
2145 assert!(!audit_result.message.to_lowercase().contains("nan"));
2146 }
2147
2148 #[test]
2149 fn test_tiered_baseline_approach() {
2150 let result = audit_with_data(
2156 "test_measurement",
2157 15.0,
2158 vec![],
2159 vec![10.0, 11.0, 12.0],
2160 &ResolvedAuditParams {
2161 min_count: 2,
2162 sigma: 2.0,
2163 dispersion_method: DispersionMethod::StandardDeviation,
2164 summarize_by: ReductionFunc::Min,
2165 max_cov: None,
2166 },
2167 );
2168
2169 assert!(result.is_ok());
2170 let audit_result = result.unwrap();
2171 assert!(audit_result.message.contains('%'));
2173 assert!(!audit_result.message.to_lowercase().contains("inf"));
2174
2175 let result = audit_with_data(
2177 "test_measurement",
2178 5.0,
2179 vec![],
2180 vec![0.0, 0.0, 0.0],
2181 &ResolvedAuditParams {
2182 min_count: 2,
2183 sigma: 2.0,
2184 dispersion_method: DispersionMethod::StandardDeviation,
2185 summarize_by: ReductionFunc::Min,
2186 max_cov: None,
2187 },
2188 );
2189
2190 assert!(result.is_ok());
2191 let audit_result = result.unwrap();
2192 assert!(!audit_result.message.to_lowercase().contains("inf"));
2195 assert!(!audit_result.message.to_lowercase().contains("nan"));
2196 assert!(audit_result.message.contains('–') || audit_result.message.contains('-'));
2198
2199 let result = audit_with_data(
2201 "test_measurement",
2202 0.0,
2203 vec![],
2204 vec![0.0, 0.0, 0.0],
2205 &ResolvedAuditParams {
2206 min_count: 2,
2207 sigma: 2.0,
2208 dispersion_method: DispersionMethod::StandardDeviation,
2209 summarize_by: ReductionFunc::Min,
2210 max_cov: None,
2211 },
2212 );
2213
2214 assert!(result.is_ok());
2215 let audit_result = result.unwrap();
2216 assert!(!audit_result.message.to_lowercase().contains("inf"));
2218 assert!(!audit_result.message.to_lowercase().contains("nan"));
2219 }
2220
2221 #[test]
2222 fn test_min_measurements_two_with_no_tail() {
2223 let result = audit_with_data(
2226 "test_measurement",
2227 15.0,
2228 vec![],
2229 vec![],
2230 &ResolvedAuditParams {
2231 min_count: 2,
2232 sigma: 2.0,
2233 dispersion_method: DispersionMethod::StandardDeviation,
2234 summarize_by: ReductionFunc::Min,
2235 max_cov: None,
2236 },
2237 );
2238
2239 assert!(result.is_ok());
2240 let audit_result = result.unwrap();
2241
2242 assert!(audit_result.passed);
2244 assert!(audit_result.message.contains("Skipping test"));
2245 assert!(audit_result
2246 .message
2247 .contains("0 historical measurements found"));
2248 assert!(audit_result
2249 .message
2250 .contains("Less than requested min_measurements of 2"));
2251
2252 assert!(audit_result.message.contains("Head:"));
2254 assert!(!audit_result.message.contains("z-score"));
2255 assert!(!audit_result.message.contains("Tail:"));
2256 }
2257
2258 #[test]
2259 fn test_min_measurements_two_with_single_tail() {
2260 let result = audit_with_data(
2263 "test_measurement",
2264 15.0,
2265 vec![],
2266 vec![10.0],
2267 &ResolvedAuditParams {
2268 min_count: 2,
2269 sigma: 2.0,
2270 dispersion_method: DispersionMethod::StandardDeviation,
2271 summarize_by: ReductionFunc::Min,
2272 max_cov: None,
2273 },
2274 );
2275
2276 assert!(result.is_ok());
2277 let audit_result = result.unwrap();
2278
2279 assert!(audit_result.passed);
2281 assert!(audit_result.message.contains("Skipping test"));
2282 assert!(audit_result
2283 .message
2284 .contains("1 historical measurement found"));
2285 assert!(audit_result
2286 .message
2287 .contains("Less than requested min_measurements of 2"));
2288
2289 assert!(audit_result.message.contains("Head:"));
2291 assert!(audit_result.message.contains("Tail:"));
2292 assert!(audit_result.message.contains("z-score"));
2293 assert!(audit_result.message.contains("["));
2294 }
2295
2296 #[test]
2297 fn test_aggregation_method_display_min() {
2298 let result = audit_with_data(
2300 "test_measurement",
2301 15.0,
2302 vec![],
2303 vec![10.0, 11.0, 12.0],
2304 &ResolvedAuditParams {
2305 min_count: 2,
2306 sigma: 2.0,
2307 dispersion_method: DispersionMethod::StandardDeviation,
2308 summarize_by: ReductionFunc::Min,
2309 max_cov: None,
2310 },
2311 );
2312
2313 assert!(result.is_ok());
2314 let audit_result = result.unwrap();
2315 assert!(audit_result.message.contains("Aggregation: min"));
2316 }
2317
2318 #[test]
2319 fn test_aggregation_method_display_max() {
2320 let result = audit_with_data(
2322 "test_measurement",
2323 15.0,
2324 vec![],
2325 vec![10.0, 11.0, 12.0],
2326 &ResolvedAuditParams {
2327 min_count: 2,
2328 sigma: 2.0,
2329 dispersion_method: DispersionMethod::StandardDeviation,
2330 summarize_by: ReductionFunc::Max,
2331 max_cov: None,
2332 },
2333 );
2334
2335 assert!(result.is_ok());
2336 let audit_result = result.unwrap();
2337 assert!(audit_result.message.contains("Aggregation: max"));
2338 }
2339
2340 #[test]
2341 fn test_aggregation_method_display_median() {
2342 let result = audit_with_data(
2344 "test_measurement",
2345 15.0,
2346 vec![],
2347 vec![10.0, 11.0, 12.0],
2348 &ResolvedAuditParams {
2349 min_count: 2,
2350 sigma: 2.0,
2351 dispersion_method: DispersionMethod::StandardDeviation,
2352 summarize_by: ReductionFunc::Median,
2353 max_cov: None,
2354 },
2355 );
2356
2357 assert!(result.is_ok());
2358 let audit_result = result.unwrap();
2359 assert!(audit_result.message.contains("Aggregation: median"));
2360 }
2361
2362 #[test]
2363 fn test_aggregation_method_display_mean() {
2364 let result = audit_with_data(
2366 "test_measurement",
2367 15.0,
2368 vec![],
2369 vec![10.0, 11.0, 12.0],
2370 &ResolvedAuditParams {
2371 min_count: 2,
2372 sigma: 2.0,
2373 dispersion_method: DispersionMethod::StandardDeviation,
2374 summarize_by: ReductionFunc::Mean,
2375 max_cov: None,
2376 },
2377 );
2378
2379 assert!(result.is_ok());
2380 let audit_result = result.unwrap();
2381 assert!(audit_result.message.contains("Aggregation: mean"));
2382 }
2383
2384 #[test]
2385 fn test_aggregation_method_not_shown_with_single_measurement() {
2386 let result = audit_with_data(
2388 "test_measurement",
2389 15.0,
2390 vec![],
2391 vec![],
2392 &ResolvedAuditParams {
2393 min_count: 2,
2394 sigma: 2.0,
2395 dispersion_method: DispersionMethod::StandardDeviation,
2396 summarize_by: ReductionFunc::Median,
2397 max_cov: None,
2398 },
2399 );
2400
2401 assert!(result.is_ok());
2402 let audit_result = result.unwrap();
2403 assert!(!audit_result.message.contains("Aggregation:"));
2405 assert!(audit_result.message.contains("Head:"));
2407 }
2408
2409 #[test]
2412 fn test_tail_cov_warning_fires_above_threshold() {
2413 let result = audit_with_data(
2415 "test_measurement",
2416 100.0,
2417 vec![],
2418 vec![50.0, 100.0, 150.0, 100.0, 100.0],
2419 &ResolvedAuditParams {
2420 min_count: 2,
2421 sigma: 10.0,
2422 dispersion_method: DispersionMethod::StandardDeviation,
2423 summarize_by: ReductionFunc::Min,
2424 max_cov: Some(30.0),
2425 },
2426 );
2427 assert!(result.is_ok());
2428 let msg = result.unwrap().message;
2429 assert!(
2430 msg.contains("⚠️ High CoV"),
2431 "Should warn when tail CoV exceeds threshold, got: {msg}"
2432 );
2433 assert!(msg.contains("threshold: 30.0%"), "got: {msg}");
2434 }
2435
2436 #[test]
2437 fn test_tail_cov_warning_absent_below_threshold() {
2438 let result = audit_with_data(
2440 "test_measurement",
2441 100.0,
2442 vec![],
2443 vec![100.0, 100.0, 100.0],
2444 &ResolvedAuditParams {
2445 min_count: 2,
2446 sigma: 10.0,
2447 dispersion_method: DispersionMethod::StandardDeviation,
2448 summarize_by: ReductionFunc::Min,
2449 max_cov: Some(30.0),
2450 },
2451 );
2452 assert!(result.is_ok());
2453 let msg = result.unwrap().message;
2454 assert!(
2455 !msg.contains("⚠️ High CoV"),
2456 "Should not warn when tail CoV is below threshold, got: {msg}"
2457 );
2458 }
2459
2460 #[test]
2461 fn test_head_cov_warning_fires_above_threshold() {
2462 let result = audit_with_data(
2465 "test_measurement",
2466 100.0,
2467 vec![50.0, 150.0],
2468 vec![100.0, 100.0, 100.0, 100.0, 100.0],
2469 &ResolvedAuditParams {
2470 min_count: 2,
2471 sigma: 10.0,
2472 dispersion_method: DispersionMethod::StandardDeviation,
2473 summarize_by: ReductionFunc::Min,
2474 max_cov: Some(50.0),
2475 },
2476 );
2477 assert!(result.is_ok());
2478 let msg = result.unwrap().message;
2479 assert!(
2480 msg.contains("⚠️ High CoV"),
2481 "Should warn when head CoV exceeds threshold, got: {msg}"
2482 );
2483 assert!(
2484 msg.contains("head="),
2485 "Warning should include head CoV value, got: {msg}"
2486 );
2487 }
2488
2489 #[test]
2490 fn test_head_cov_warning_absent_below_threshold() {
2491 let result = audit_with_data(
2493 "test_measurement",
2494 100.0,
2495 vec![100.0, 100.0],
2496 vec![100.0, 100.0, 100.0],
2497 &ResolvedAuditParams {
2498 min_count: 2,
2499 sigma: 10.0,
2500 dispersion_method: DispersionMethod::StandardDeviation,
2501 summarize_by: ReductionFunc::Min,
2502 max_cov: Some(50.0),
2503 },
2504 );
2505 assert!(result.is_ok());
2506 let msg = result.unwrap().message;
2507 assert!(
2508 !msg.contains("⚠️ High CoV"),
2509 "Should not warn when head CoV is below threshold, got: {msg}"
2510 );
2511 }
2512
2513 #[test]
2514 fn test_cov_warning_absent_with_no_threshold() {
2515 let result = audit_with_data(
2517 "test_measurement",
2518 100.0,
2519 vec![10.0, 200.0, 50.0],
2520 vec![10.0, 200.0, 50.0, 300.0, 100.0],
2521 &ResolvedAuditParams {
2522 min_count: 2,
2523 sigma: 10.0,
2524 dispersion_method: DispersionMethod::StandardDeviation,
2525 summarize_by: ReductionFunc::Min,
2526 max_cov: None,
2527 },
2528 );
2529 assert!(result.is_ok());
2530 let msg = result.unwrap().message;
2531 assert!(
2532 !msg.contains("⚠️ High CoV"),
2533 "Should not warn when no threshold is set, got: {msg}"
2534 );
2535 }
2536
2537 #[test]
2538 fn test_cov_warning_absent_for_single_tail_measurement() {
2539 let result = audit_with_data(
2542 "test_measurement",
2543 100.0,
2544 vec![],
2545 vec![100.0],
2546 &ResolvedAuditParams {
2547 min_count: 2,
2548 sigma: 10.0,
2549 dispersion_method: DispersionMethod::StandardDeviation,
2550 summarize_by: ReductionFunc::Min,
2551 max_cov: Some(0.0),
2552 },
2553 );
2554 assert!(result.is_ok());
2555 let msg = result.unwrap().message;
2556 assert!(
2557 !msg.contains("⚠️ High CoV"),
2558 "Should not warn when tail has only 1 measurement, got: {msg}"
2559 );
2560 }
2561
2562 #[test]
2563 fn test_cov_warning_absent_for_single_head_raw_measurement() {
2564 let result = audit_with_data(
2566 "test_measurement",
2567 100.0,
2568 vec![100.0],
2569 vec![100.0, 100.0, 100.0, 100.0],
2570 &ResolvedAuditParams {
2571 min_count: 2,
2572 sigma: 10.0,
2573 dispersion_method: DispersionMethod::StandardDeviation,
2574 summarize_by: ReductionFunc::Min,
2575 max_cov: Some(0.0),
2576 },
2577 );
2578 assert!(result.is_ok());
2579 let msg = result.unwrap().message;
2580 assert!(
2581 !msg.contains("⚠️ High CoV"),
2582 "Should not compute head CoV from a single raw measurement, got: {msg}"
2583 );
2584 }
2585
2586 #[test]
2587 fn test_cov_shows_correct_tail_cov_value() {
2588 let result = audit_with_data(
2591 "test_measurement",
2592 105.0,
2593 vec![],
2594 vec![0.0, 200.0],
2595 &ResolvedAuditParams {
2596 min_count: 2,
2597 sigma: 100.0,
2598 dispersion_method: DispersionMethod::StandardDeviation,
2599 summarize_by: ReductionFunc::Min,
2600 max_cov: Some(100.0),
2601 },
2602 );
2603 assert!(result.is_ok());
2604 let msg = result.unwrap().message;
2605 assert!(
2606 msg.contains("tail=141.4%"),
2607 "Should show correct tail CoV percentage, got: {msg}"
2608 );
2609 }
2610
2611 #[test]
2612 fn test_cov_shows_correct_head_cov_value() {
2613 let result = audit_with_data(
2615 "test_measurement",
2616 100.0,
2617 vec![0.0, 200.0],
2618 vec![100.0, 100.0, 100.0, 100.0],
2619 &ResolvedAuditParams {
2620 min_count: 2,
2621 sigma: 100.0,
2622 dispersion_method: DispersionMethod::StandardDeviation,
2623 summarize_by: ReductionFunc::Min,
2624 max_cov: Some(100.0),
2625 },
2626 );
2627 assert!(result.is_ok());
2628 let msg = result.unwrap().message;
2629 assert!(
2630 msg.contains("head=141.4%"),
2631 "Should show correct head CoV percentage, got: {msg}"
2632 );
2633 }
2634
2635 #[test]
2636 fn test_cov_warning_shown_on_failing_audit() {
2637 let result = audit_with_data(
2639 "test_measurement",
2640 500.0,
2641 vec![],
2642 vec![50.0, 100.0, 150.0, 100.0, 100.0],
2643 &ResolvedAuditParams {
2644 min_count: 2,
2645 sigma: 0.5,
2646 dispersion_method: DispersionMethod::StandardDeviation,
2647 summarize_by: ReductionFunc::Min,
2648 max_cov: Some(30.0),
2649 },
2650 );
2651 assert!(result.is_ok());
2652 let audit_result = result.unwrap();
2653 assert!(!audit_result.passed, "Should fail audit");
2654 assert!(
2655 audit_result.message.contains("⚠️ High CoV"),
2656 "CoV warning should appear even on failure, got: {}",
2657 audit_result.message
2658 );
2659 }
2660
2661 #[test]
2662 fn test_cov_no_warning_when_cov_equals_threshold() {
2663 let result = audit_with_data(
2667 "test_measurement",
2668 100.0,
2669 vec![],
2670 vec![100.0, 100.0, 100.0],
2671 &ResolvedAuditParams {
2672 min_count: 2,
2673 sigma: 10.0,
2674 dispersion_method: DispersionMethod::StandardDeviation,
2675 summarize_by: ReductionFunc::Min,
2676 max_cov: Some(0.0),
2677 },
2678 );
2679 assert!(result.is_ok());
2680 let msg = result.unwrap().message;
2681 assert!(
2682 !msg.contains("⚠️ High CoV"),
2683 "Should not warn when CoV (0%) equals threshold (0%) with strict >, got: {msg}"
2684 );
2685 }
2686
2687 #[test]
2688 fn test_cov_skips_near_zero_mean() {
2689 let eps = f64::EPSILON;
2692 let result = audit_with_data(
2693 "test_measurement",
2694 1.0,
2695 vec![],
2696 vec![0.0, 2.0 * eps],
2697 &ResolvedAuditParams {
2698 min_count: 2,
2699 sigma: 10.0,
2700 dispersion_method: DispersionMethod::StandardDeviation,
2701 summarize_by: ReductionFunc::Mean,
2702 max_cov: Some(100.0),
2703 },
2704 );
2705 assert!(result.is_ok());
2706 assert!(
2707 !result.unwrap().message.contains("⚠️ High CoV"),
2708 "Should not compute CoV when tail mean equals EPSILON"
2709 );
2710 }
2711
2712 #[test]
2713 fn test_cov_warning_fires_when_only_head_exceeds_threshold() {
2714 let result = audit_with_data(
2718 "test_measurement",
2719 100.0,
2720 vec![0.0, 200.0],
2721 vec![100.0, 100.0, 100.0, 100.0],
2722 &ResolvedAuditParams {
2723 min_count: 2,
2724 sigma: 10.0,
2725 dispersion_method: DispersionMethod::StandardDeviation,
2726 summarize_by: ReductionFunc::Min,
2727 max_cov: Some(50.0),
2728 },
2729 );
2730 assert!(result.is_ok());
2731 let msg = result.unwrap().message;
2732 assert!(
2733 msg.contains("⚠️ High CoV"),
2734 "Should warn when head CoV alone exceeds threshold (|| not &&), got: {msg}"
2735 );
2736 }
2737
2738 #[test]
2739 fn test_cov_tail_len_boundary_two() {
2740 let result = audit_with_data(
2744 "test_measurement",
2745 100.0,
2746 vec![],
2747 vec![0.0, 200.0],
2748 &ResolvedAuditParams {
2749 min_count: 2,
2750 sigma: 10.0,
2751 dispersion_method: DispersionMethod::StandardDeviation,
2752 summarize_by: ReductionFunc::Min,
2753 max_cov: Some(50.0),
2754 },
2755 );
2756 assert!(result.is_ok());
2757 let msg = result.unwrap().message;
2758 assert!(
2759 msg.contains("⚠️ High CoV"),
2760 "Should compute tail CoV with exactly 2 tail measurements (>= 2), got: {msg}"
2761 );
2762 }
2763
2764 #[test]
2765 fn test_head_cov_skips_near_zero_mean() {
2766 let eps = f64::EPSILON;
2771 let result = audit_with_data(
2772 "test_measurement",
2773 1.0,
2774 vec![0.0, 2.0 * eps],
2775 vec![100.0, 100.0, 100.0, 100.0],
2776 &ResolvedAuditParams {
2777 min_count: 2,
2778 sigma: 10.0,
2779 dispersion_method: DispersionMethod::StandardDeviation,
2780 summarize_by: ReductionFunc::Mean,
2781 max_cov: Some(100.0),
2782 },
2783 );
2784 assert!(result.is_ok());
2785 assert!(
2786 !result.unwrap().message.contains("⚠️ High CoV"),
2787 "Should not compute head CoV when mean equals EPSILON"
2788 );
2789 }
2790
2791 #[test]
2792 fn test_head_cov_skips_when_mean_near_zero_despite_valid_len() {
2793 let eps = f64::EPSILON;
2799 let result = audit_with_data(
2800 "test_measurement",
2801 1.0,
2802 vec![eps, 0.0],
2803 vec![100.0, 100.0, 100.0, 100.0],
2804 &ResolvedAuditParams {
2805 min_count: 2,
2806 sigma: 10.0,
2807 dispersion_method: DispersionMethod::StandardDeviation,
2808 summarize_by: ReductionFunc::Mean,
2809 max_cov: Some(100.0),
2810 },
2811 );
2812 assert!(result.is_ok());
2813 assert!(
2814 !result.unwrap().message.contains("⚠️ High CoV"),
2815 "Should not compute head CoV when mean is near-zero, even with len >= 2"
2816 );
2817 }
2818
2819 #[test]
2820 fn test_head_cov_no_warning_when_cov_equals_threshold() {
2821 let result = audit_with_data(
2826 "test_measurement",
2827 100.0,
2828 vec![100.0, 100.0],
2829 vec![100.0, 100.0, 100.0],
2830 &ResolvedAuditParams {
2831 min_count: 2,
2832 sigma: 10.0,
2833 dispersion_method: DispersionMethod::StandardDeviation,
2834 summarize_by: ReductionFunc::Min,
2835 max_cov: Some(0.0),
2836 },
2837 );
2838 assert!(result.is_ok());
2839 let msg = result.unwrap().message;
2840 assert!(
2841 !msg.contains("⚠️ High CoV"),
2842 "Should not warn when head CoV (0%) equals threshold (0%) with strict >, got: {msg}"
2843 );
2844 }
2845
2846 fn make_commit_with_measurement(sha: &str, name: &str, val: f64) -> Commit {
2849 Commit {
2850 commit: sha.to_string(),
2851 title: String::new(),
2852 author: String::new(),
2853 measurements: vec![MeasurementData {
2854 epoch: 1,
2855 name: name.to_string(),
2856 timestamp: 0.0,
2857 val,
2858 key_values: std::collections::HashMap::new(),
2859 }],
2860 }
2861 }
2862
2863 fn make_change_point(sha: &str, magnitude_pct: f64) -> change_point::ChangePoint {
2864 change_point::ChangePoint {
2865 index: 5,
2866 commit_sha: sha.to_string(),
2867 magnitude_pct,
2868 confidence: 0.9,
2869 direction: if magnitude_pct > 0.0 {
2870 change_point::ChangeDirection::Increase
2871 } else {
2872 change_point::ChangeDirection::Decrease
2873 },
2874 }
2875 }
2876
2877 #[test]
2878 fn test_format_change_point_warnings_empty() {
2879 let warnings = format_change_point_warnings(&[], "my_bench");
2880 assert!(warnings.is_empty());
2881 }
2882
2883 #[test]
2884 fn test_format_change_point_warnings_increase() {
2885 let cp = make_change_point("abc1234567890", 23.5);
2886 let warnings = format_change_point_warnings(&[cp], "my_bench");
2887 assert_eq!(warnings.len(), 1);
2888 assert!(
2889 warnings[0].contains("my_bench"),
2890 "should include measurement name"
2891 );
2892 assert!(
2893 warnings[0].contains("+23.5%"),
2894 "should show positive magnitude"
2895 );
2896 assert!(
2897 warnings[0].contains("abc1234"),
2898 "should show 7-char short SHA"
2899 );
2900 assert!(
2901 !warnings[0].contains("abc12345"),
2902 "should NOT show 8+ chars"
2903 );
2904 assert!(
2905 warnings[0].contains("regime shift"),
2906 "should mention regime shift"
2907 );
2908 assert!(
2910 warnings[0].contains("at commit abc1234"),
2911 "single change point: should use 'at commit' inline format"
2912 );
2913 }
2914
2915 #[test]
2916 fn test_format_change_point_warnings_decrease() {
2917 let cp = make_change_point("def5678", -15.0);
2918 let warnings = format_change_point_warnings(&[cp], "my_bench");
2919 assert_eq!(warnings.len(), 1);
2920 assert!(
2921 warnings[0].contains("-15.0%"),
2922 "should show negative magnitude"
2923 );
2924 }
2925
2926 #[test]
2927 fn test_format_change_point_warnings_empty_sha() {
2928 let cp = make_change_point("", 10.0);
2929 let warnings = format_change_point_warnings(&[cp], "my_bench");
2930 assert_eq!(warnings.len(), 1);
2931 assert!(
2932 warnings[0].contains("unknown"),
2933 "empty SHA should show 'unknown'"
2934 );
2935 }
2936
2937 #[test]
2938 fn test_format_change_point_warnings_multiple() {
2939 let cps = vec![
2940 make_change_point("aaa1111", 20.0),
2941 make_change_point("bbb2222", -5.0),
2942 ];
2943 let warnings = format_change_point_warnings(&cps, "my_bench");
2944 assert_eq!(warnings.len(), 1);
2946 assert!(
2947 warnings[0].contains("aaa1111"),
2948 "should include first commit"
2949 );
2950 assert!(
2951 warnings[0].contains("bbb2222"),
2952 "should include second commit"
2953 );
2954 assert!(
2955 warnings[0].contains("regime shift"),
2956 "should have boilerplate"
2957 );
2958 assert_eq!(
2960 warnings[0].matches("regime shift").count(),
2961 1,
2962 "boilerplate should not repeat"
2963 );
2964 assert!(
2966 !warnings[0].contains("at commit"),
2967 "multiple change points: should not use 'at commit' inline format"
2968 );
2969 }
2970
2971 #[test]
2972 fn test_generate_change_point_warnings_wrong_name() {
2973 let commits: Vec<Result<Commit>> = (0..10)
2976 .map(|i| {
2977 let val = if i < 5 { 20.0 } else { 10.0 };
2978 Ok(make_commit_with_measurement(
2979 &format!("sha{:040x}", i),
2980 "other", val,
2982 ))
2983 })
2984 .collect();
2985
2986 let cp_config = change_point::ChangePointConfig {
2987 enabled: true,
2988 min_data_points: 5,
2989 min_magnitude_pct: 5.0,
2990 confidence_threshold: 0.5,
2991 penalty: 0.5,
2992 };
2993
2994 let warnings = generate_change_point_warnings(
2995 "bench", &commits,
2997 &[],
2998 &ReductionFunc::Min,
2999 false,
3000 &cp_config,
3001 );
3002 assert!(
3003 warnings.is_empty(),
3004 "Should not warn when no commits match the measurement name"
3005 );
3006 }
3007
3008 #[test]
3009 fn test_generate_change_point_warnings_suppressed() {
3010 let commits: Vec<Result<Commit>> = (0..10)
3012 .map(|i| {
3013 let val = if i < 5 { 10.0 } else { 20.0 };
3014 Ok(make_commit_with_measurement(
3015 &format!("sha{:040}", i),
3016 "bench",
3017 val,
3018 ))
3019 })
3020 .collect();
3021
3022 let cp_config = change_point::ChangePointConfig {
3023 enabled: true,
3024 min_data_points: 5,
3025 min_magnitude_pct: 5.0,
3026 confidence_threshold: 0.5,
3027 penalty: 0.5,
3028 };
3029
3030 let warnings = generate_change_point_warnings(
3031 "bench",
3032 &commits,
3033 &[],
3034 &ReductionFunc::Min,
3035 true, &cp_config,
3037 );
3038 assert!(
3039 warnings.is_empty(),
3040 "Warnings should be suppressed when no_change_point_warning=true"
3041 );
3042 }
3043
3044 #[test]
3045 fn test_generate_change_point_warnings_disabled_config() {
3046 let commits: Vec<Result<Commit>> = (0..10)
3048 .map(|i| {
3049 let val = if i < 5 { 10.0 } else { 20.0 };
3050 Ok(make_commit_with_measurement(
3051 &format!("sha{:040}", i),
3052 "bench",
3053 val,
3054 ))
3055 })
3056 .collect();
3057
3058 let cp_config = change_point::ChangePointConfig {
3059 enabled: false, min_data_points: 5,
3061 min_magnitude_pct: 5.0,
3062 confidence_threshold: 0.5,
3063 penalty: 0.5,
3064 };
3065
3066 let warnings = generate_change_point_warnings(
3067 "bench",
3068 &commits,
3069 &[],
3070 &ReductionFunc::Min,
3071 false,
3072 &cp_config,
3073 );
3074 assert!(
3075 warnings.is_empty(),
3076 "Warnings should be suppressed when cp_config.enabled=false"
3077 );
3078 }
3079
3080 #[test]
3081 fn test_generate_change_point_warnings_stable_data() {
3082 let commits: Vec<Result<Commit>> = (0..10)
3084 .map(|i| {
3085 Ok(make_commit_with_measurement(
3086 &format!("sha{:040}", i),
3087 "bench",
3088 10.0,
3089 ))
3090 })
3091 .collect();
3092
3093 let cp_config = change_point::ChangePointConfig {
3094 enabled: true,
3095 min_data_points: 5,
3096 min_magnitude_pct: 5.0,
3097 confidence_threshold: 0.5,
3098 penalty: 0.5,
3099 };
3100
3101 let warnings = generate_change_point_warnings(
3102 "bench",
3103 &commits,
3104 &[],
3105 &ReductionFunc::Min,
3106 false,
3107 &cp_config,
3108 );
3109 assert!(
3110 warnings.is_empty(),
3111 "Stable data should produce no warnings"
3112 );
3113 }
3114
3115 #[test]
3116 fn test_generate_change_point_warnings_with_regime_shift() {
3117 let commits: Vec<Result<Commit>> = (0..10)
3120 .map(|i| {
3121 let val = if i < 5 { 20.0 } else { 10.0 };
3123 Ok(make_commit_with_measurement(
3124 &format!("sha{:040x}", i),
3125 "bench",
3126 val,
3127 ))
3128 })
3129 .collect();
3130
3131 let cp_config = change_point::ChangePointConfig {
3132 enabled: true,
3133 min_data_points: 5,
3134 min_magnitude_pct: 5.0,
3135 confidence_threshold: 0.5,
3136 penalty: 0.5,
3137 };
3138
3139 let warnings = generate_change_point_warnings(
3140 "bench",
3141 &commits,
3142 &[],
3143 &ReductionFunc::Min,
3144 false,
3145 &cp_config,
3146 );
3147 assert!(
3148 !warnings.is_empty(),
3149 "Regime shift should produce at least one warning"
3150 );
3151 assert!(
3152 warnings[0].contains("bench"),
3153 "Warning should name the measurement"
3154 );
3155 assert!(
3156 warnings[0].contains("WARNING"),
3157 "Warning should contain 'WARNING'"
3158 );
3159 }
3160}