Skip to main content

git_perf/
audit.rs

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
16/// Formats a z-score for display in audit output.
17/// Only finite z-scores are displayed with numeric values.
18/// Infinite and NaN values return an empty string.
19fn 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
27/// Determines the direction arrow based on comparison of head and tail means.
28/// Returns ↑ for greater, ↓ for less, → for equal.
29/// Returns → for NaN values to avoid panicking.
30fn 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/// Resolved audit parameters for a specific measurement.
45#[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
54/// Resolves audit parameters for a specific measurement with proper precedence:
55/// CLI option -> measurement-specific config -> global config -> built-in default
56///
57/// Note: When CLI provides min_count, the caller (audit_multiple) uses the same
58/// value for all measurements. When CLI is None, this function reads per-measurement config.
59pub(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
98/// Discovers all unique measurement names from commits that match the filters and selectors.
99/// This is used to efficiently find which measurements to audit when filters are provided.
100fn 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            // Check if measurement name matches any filter
110            if !crate::filter::matches_any_filter(&measurement.name, filters) {
111                continue;
112            }
113
114            // Check if measurement matches selectors
115            if !measurement.key_values_is_superset_of(selectors) {
116                continue;
117            }
118
119            // This measurement matches - add to set
120            unique_measurements.insert(measurement.name.clone());
121        }
122    }
123
124    // Convert to sorted vector for deterministic ordering
125    let mut result: Vec<String> = unique_measurements.into_iter().collect();
126    result.sort();
127    result
128}
129
130/// Compute group value combinations for splitting measurements by metadata keys.
131///
132/// Returns a vector of group values where each inner vector contains the values
133/// for the split keys. If no splits are specified, returns a single empty group.
134///
135/// # Errors
136/// Returns error if separate_by is non-empty but no measurements have all required keys
137fn 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            // Only consider measurements that match the name
152            if measurement.name != measurement_name {
153                continue;
154            }
155
156            // Check if measurement matches selectors
157            if !measurement.key_values_is_superset_of(selectors) {
158                continue;
159            }
160
161            // Extract values for separate_by keys
162            let values: Vec<String> = separate_by
163                .iter()
164                .filter_map(|key| measurement.key_values.get(key).cloned())
165                .collect();
166
167            // Only include if all keys are present
168            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    // Convert to sorted vector for deterministic ordering
183    let mut result: Vec<Vec<String>> = unique_groups.into_iter().collect();
184    result.sort();
185    Ok(result)
186}
187
188/// Formats a group label from separate_by keys and values.
189/// Example: ["os", "arch"] with ["ubuntu", "x64"] -> "os=ubuntu/arch=x64"
190fn 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
199/// Formats change point warnings for a set of detected change points.
200///
201/// Returns a single consolidated warning string, or an empty vec if none.
202/// Multiple change points are listed together under one warning to avoid repeating the boilerplate.
203fn 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
241/// Generates change point warnings for audit output.
242///
243/// Returns warning strings to print before the audit result, or empty vec if
244/// warnings are suppressed or change point detection is disabled.
245fn 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    // Early return if patterns are empty - nothing to audit
289    if combined_patterns.is_empty() {
290        return Ok(());
291    }
292
293    // Validate that separate_by keys don't overlap with selectors (would produce contradictory filters)
294    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    // Compile combined regex patterns (measurements as exact matches + filter patterns)
306    // early to fail fast on invalid patterns
307    let filters = crate::filter::compile_filters(combined_patterns)?;
308
309    // Phase 1: Walk commits ONCE (optimization: scan commits only once)
310    // Collect into Vec so we can reuse the data for multiple measurements
311    let all_commits: Vec<Result<Commit>> =
312        measurement_retrieval::walk_commits_from(start_commit, max_count, since, until)?.collect();
313
314    // Phase 2: Discover all measurements that match the combined patterns from the commit data
315    // The combined_patterns already include both measurements (as exact regex) and filters (OR behavior)
316    let measurements_to_audit = discover_matching_measurements(&all_commits, &filters, selectors);
317
318    // If no measurements were discovered, provide appropriate error message
319    if measurements_to_audit.is_empty() {
320        // Check if we have any commits at all
321        if all_commits.is_empty() {
322            bail!("No commit at HEAD");
323        }
324        // Check if any commits have any measurements at all
325        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            // No measurements exist in any commits - specific error for this case
335            bail!("No measurement for HEAD");
336        }
337        // Measurements exist but don't match the patterns
338        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    // Phase 3: For each measurement, audit using the pre-loaded commit data
346    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        // Warn if max_count limits historical data below min_measurements requirement
357        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        // Compute groups for this measurement
368        let groups = compute_group_values(&all_commits, &measurement, selectors, separate_by)?;
369
370        // Audit each group independently
371        for group_values in &groups {
372            // Build combined selectors (original selectors + group selectors)
373            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            // Format group label for display
379            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, &params)?;
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                &params.summarize_by,
393                no_change_point_warning,
394                &cp_config,
395            );
396            // Print the result with group label, with warnings interleaved on stdout
397            // before the result so ordering is deterministic regardless of stderr/stdout capture.
398            if !separate_by.is_empty() {
399                // Print header for the group
400                println!("Auditing measurement \"{}\"{}:", measurement, group_label);
401                // Print warnings indented inside the group block
402                for warning in &cp_warnings {
403                    for line in warning.lines() {
404                        println!("  {}", line);
405                    }
406                }
407                // Indent the result message
408                for line in result.message.lines() {
409                    println!("  {}", line);
410                }
411                println!(); // Add blank line between groups
412            } 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    // Print summary if grouping is active
432    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
453/// Audits a measurement using pre-loaded commit data.
454/// This is more efficient than the old `audit` function when auditing multiple measurements,
455/// as it reuses the same commit data instead of walking commits multiple times.
456fn 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    // Filter to only this specific measurement with matching selectors
468    let filter_by =
469        |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
470
471    // Extract raw HEAD measurements before aggregation for head CoV computation.
472    // Each measurement data point at HEAD is a separate sample (e.g., repeated benchmark runs).
473    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        &params.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
509/// Core audit logic that can be tested with mock data
510/// This function contains all the mutation-tested logic paths
511fn 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    // Note: CLI enforces min_count >= 2 via clap::value_parser!(u16).range(2..)
519    // Tests may use lower values for edge case testing, but production code
520    // should never call this with min_count < 2
521    assert!(params.min_count >= 2, "min_count must be at least 2");
522
523    // Get unit for this measurement from config
524    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    // Generate sparkline and calculate range for all measurements - used in both skip and normal paths
531    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(); // Remove head to get just tail for median calculation
535    let tail_median = tail_measurements.median().unwrap_or_default();
536
537    // Calculate min and max once for use in both branches
538    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    // Tiered approach for sparkline display:
548    // 1. If tail median is non-zero: use median as baseline, show percentages (default behavior)
549    // 2. If tail median is zero: show absolute differences instead
550    let tail_median_is_zero = tail_median.abs() < f64::EPSILON;
551
552    let sparkline = if tail_median_is_zero {
553        // Median is zero - show absolute range
554        format!(
555            " [{} – {}] {}",
556            min_val,
557            max_val,
558            spark(all_measurements.as_slice())
559        )
560    } else {
561        // MUTATION POINT: / vs % (Line 140)
562        // Median is non-zero - use it as baseline for percentage ranges
563        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    // Helper function to build the measurement summary text
575    // This is used for both skipped and normal audit results to avoid duplication
576    let build_summary = || -> String {
577        let mut summary = String::new();
578
579        // Use the length of all_measurements vector for total count
580        let total_measurements = all_measurements.len();
581
582        // If only 1 total measurement (head only, no tail), show only head summary
583        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            // 2+ measurements: show aggregation method, z-score, head, tail, and sparkline
591            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        // If 0 total measurements, return empty summary
618
619        summary
620    };
621
622    // MUTATION POINT: < vs == (Line 120)
623    if tail_summary.len < params.min_count.into() {
624        let number_measurements = tail_summary.len;
625        // MUTATION POINT: > vs < (Line 122)
626        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        // Add summary using the same logic as passing/failing cases
634        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    // Tail CoV uses per-commit aggregated values (cross-run baseline stability). Head CoV uses the
647    // raw measurements at HEAD (within-run repeatability). Require ≥2 samples and a non-zero mean
648    // for each.
649    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    // MUTATION POINT: / vs % (Line 150)
677    // Calculate relative deviation - naturally handles infinity when tail_median is zero
678    let head_relative_deviation = (head / tail_median - 1.0).abs() * 100.0;
679
680    // Calculate absolute deviation
681    let head_absolute_deviation = (head - tail_median).abs();
682
683    // Check if we have a minimum relative deviation threshold configured
684    let min_relative_deviation = config::audit_min_relative_deviation(measurement);
685    let min_absolute_deviation = config::audit_min_absolute_deviation(measurement);
686
687    // MUTATION POINT: < vs == (Line 156)
688    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    // MUTATION POINT: > vs >= (Line 178)
708    let z_score_exceeds_sigma =
709        head_summary.is_significant(&tail_summary, params.sigma, params.dispersion_method);
710
711    // MUTATION POINT: ! removal (Line 181)
712    let passed = !z_score_exceeds_sigma || passed_due_to_threshold;
713
714    // Add threshold information to output if applicable
715    // Only show note when the audit would have failed without the threshold
716    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    // MUTATION POINT: ! removal (Line 194)
742    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        // Test cases for z-score display formatting
766        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        // Test cases for direction arrow logic
786        let test_cases = vec![
787            (5.0_f64, 3.0_f64, "↑"), // head > tail
788            (1.0_f64, 3.0_f64, "↓"), // head < tail
789            (3.0_f64, 3.0_f64, "→"), // head == tail
790        ];
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        // Test that audit produces different results with different dispersion methods
805
806        // Create mock data that would produce different z-scores with stddev vs MAD
807        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        // Calculate z-scores with both methods
814        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        // With the outlier (100.0), stddev should be much larger than MAD
820        // So z-score with stddev should be smaller than z-score with MAD
821        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        // Both should be positive since head > tail mean
829        assert!(z_score_stddev > 0.0);
830        assert!(z_score_mad > 0.0);
831    }
832
833    #[test]
834    fn test_dispersion_method_conversion() {
835        // Test that the conversion from CLI types to stats types works correctly
836
837        // Test stddev conversion
838        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        // Test MAD conversion
843        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        // This test exercises the actual production audit_multiple function
851        // Tests the case where no patterns are provided (empty list)
852        // With no patterns, it should succeed (nothing to audit)
853        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, // max_cov
865                &[],  // Empty combined_patterns
866                &[],  // Empty separate_by
867                false,
868            );
869
870            // Should succeed when no measurements need to be audited
871            assert!(
872                result.is_ok(),
873                "audit_multiple should succeed with empty pattern list"
874            );
875        });
876    }
877
878    // MUTATION TESTING COVERAGE TESTS - Exercise actual production code paths
879
880    #[test]
881    fn test_min_count_boundary_condition() {
882        // COVERS MUTATION: tail_summary.len < min_count.into() vs ==
883        // Test with exactly min_count measurements (should NOT skip)
884        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        // Should NOT be skipped (would be skipped if < was changed to ==)
901        assert!(!audit_result.message.contains("Skipping test"));
902
903        // Test with fewer than min_count (should skip)
904        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); // Skipped tests are marked as passed
922    }
923
924    #[test]
925    fn test_pluralization_logic() {
926        // COVERS MUTATION: number_measurements > 1 vs ==
927        // Test with 0 measurements (should have 's' - grammatically correct)
928        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")); // Has 's'
945        assert!(!message.contains("0 historical measurement found")); // Should not be singular
946
947        // Test with 1 measurement (no 's')
948        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")); // No 's'
965
966        // Test with 2+ measurements (should have 's')
967        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")); // Has 's'
984    }
985
986    #[test]
987    fn test_skip_with_summaries() {
988        // Test that when audit is skipped, summaries are shown based on TOTAL measurement count
989        // Total measurements = 1 head + N tail
990        // and the format matches passing/failing cases
991
992        // Test with 0 tail measurements (1 total): should show Head only
993        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:")); // Head summary shown
1011        assert!(!message.contains("z-score")); // No z-score (only 1 total measurement)
1012        assert!(!message.contains("Tail:")); // No tail
1013        assert!(!message.contains("[")); // No sparkline
1014
1015        // Test with 1 tail measurement (2 total): should show everything
1016        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):")); // Z-score with method shown
1034        assert!(message.contains("Head:")); // Head summary shown
1035        assert!(message.contains("Tail:")); // Tail summary shown
1036        assert!(message.contains("[")); // Sparkline shown
1037                                        // Verify order: z-score, Head, Tail, sparkline
1038        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        // Test with 2 tail measurements (3 total): should show everything
1047        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):")); // Z-score with method shown
1065        assert!(message.contains("Head:")); // Head summary shown
1066        assert!(message.contains("Tail:")); // Tail summary shown
1067        assert!(message.contains("[")); // Sparkline shown
1068                                        // Verify order: z-score, Head, Tail, sparkline
1069        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        // Test with MAD dispersion method to ensure method name is correct
1078        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):")); // MAD method shown
1095    }
1096
1097    #[test]
1098    fn test_relative_calculations_division_vs_modulo() {
1099        // COVERS MUTATIONS: / vs % in relative_min, relative_max, head_relative_deviation
1100        // Use values where division and modulo produce very different results
1101        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        // With division:
1119        // - relative_min = (10.0 / 10.0 - 1.0) * 100 = 0.0%
1120        // - relative_max = (25.0 / 10.0 - 1.0) * 100 = 150.0%
1121        // With modulo:
1122        // - relative_min = (10.0 % 10.0 - 1.0) * 100 = -100.0% (since 10.0 % 10.0 = 0.0)
1123        // - relative_max = (25.0 % 10.0 - 1.0) * 100 = -50.0% (since 25.0 % 10.0 = 5.0)
1124
1125        // Check that the calculation uses division, not modulo
1126        // The range should show [+0.00% – +150.00%], not [-100.00% – -50.00%]
1127        assert!(audit_result.message.contains("[+0.00% – +150.00%]"));
1128
1129        // Ensure the modulo results are NOT present
1130        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        // COVERS MUTATION: !z_score_exceeds_sigma || passed_due_to_threshold
1138        // vs z_score_exceeds_sigma || passed_due_to_threshold
1139
1140        // Case 1: z_score exceeds sigma, no threshold bypass (should fail)
1141        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); // Should fail
1158        assert!(audit_result.message.contains("❌"));
1159
1160        // Case 2: z_score within sigma (should pass)
1161        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); // Should pass
1178        assert!(audit_result.message.contains("✅"));
1179    }
1180
1181    #[test]
1182    fn test_final_result_logic() {
1183        // COVERS MUTATION: if !passed vs if passed
1184        // This tests the final branch that determines success vs failure message
1185
1186        // Test failing case (should get failure message)
1187        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        // Test passing case (should get success message)
1208        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        // Test that different dispersion methods work in the production code
1232        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        // Both should contain method indicators
1270        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        // Test that both head and tail measurements display units with auto-scaling
1277
1278        // First, set up a test environment with a configured unit
1279        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        // Test with large millisecond values that should auto-scale to seconds
1288        let head = 12_345.67; // Will auto-scale to ~12.35s
1289        let tail = vec![10_000.0, 10_500.0, 11_000.0, 11_500.0, 12_000.0]; // Will auto-scale to 10s, 10.5s, 11s, etc.
1290
1291        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        // Verify Head section exists
1310        assert!(
1311            message.contains("Head:"),
1312            "Message should contain Head section"
1313        );
1314
1315        // With auto-scaling, 12345.67ms should become ~12.35s or 12.3s
1316        // Check that the value is auto-scaled (contains 's' for seconds)
1317        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        // With auto-scaling, all values (mean, stddev, MAD) get their units auto-scaled
1336        // They should all have units now (not just mean)
1337        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        // Verify Tail section has units
1344        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        // Tail mean should be auto-scaled to seconds (10000-12000ms → 10-12s)
1362        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        // Verify the basic format structure is present
1372        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        // Test that the threshold note is only shown when the audit would have
1385        // failed without the threshold (i.e., when z_score_exceeds_sigma is true)
1386        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        // Case 1: Low z-score AND low relative deviation (threshold is configured but not needed)
1395        // Should pass without showing the note
1396        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        // The note should NOT be shown because the audit would have passed anyway
1415        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        // Case 2: High z-score but low relative deviation (threshold saves the audit)
1423        // Should pass and show the note
1424        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        // The note SHOULD be shown because the audit would have failed without the threshold
1443        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        // Case 3: High z-score AND high relative deviation (threshold doesn't help)
1452        // Should fail
1453        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        // No note shown because the audit still failed
1472        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        // Tests that:
1483        // 1. The note shows the correct absolute deviation value (catches - vs / mutation)
1484        // 2. The boundary: deviation exactly AT threshold fails (catches < vs <= mutation)
1485        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        // Case 1: High z-score but low absolute deviation (threshold saves the audit)
1494        // head=1010, tail values very tightly clustered around 1000
1495        // absolute deviation = |1010 - 1000| = 10 < 50 => should pass
1496        // if - were replaced with /, deviation would be |1010/1000| = 1.01, still < 50 (passes anyway)
1497        // So we need values where subtraction and division give meaningfully different results
1498        // head=1005, tail=1000: subtract=5, divide=1.005; but threshold=50, both < 50
1499        // Let's use head=100, tail_median=10: subtract=90, divide=10; threshold=50
1500        // With threshold=50: subtract(90) >= 50 fails, divide(10) < 50 passes
1501        // This catches the - vs / mutation
1502        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        // absolute deviation = |100 - 10| = 90, which is > 50 threshold => should FAIL
1519        assert!(
1520            !audit_result.passed,
1521            "Should fail: absolute deviation 90 > threshold 50. Got: {}",
1522            audit_result.message
1523        );
1524
1525        // Case 2: absolute deviation exactly equals threshold => should FAIL (< not <=)
1526        // head=1050, tail_median=1000, absolute_deviation=50, threshold=50
1527        // With < : 50 < 50 is false => fails (correct)
1528        // With <= : 50 <= 50 is true => passes (wrong)
1529        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        // absolute deviation = |1050 - 1000| = 50, which equals threshold 50 => should FAIL
1546        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        // Case 3: absolute deviation strictly below threshold => should PASS with note
1553        // head=1049, tail_median=1000, absolute_deviation=49, threshold=50
1554        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        // Verify the note contains the correct deviation value (catches - vs / mutation)
1583        // If / were used: |1049/1000| = 1.049, note would say "1.0" not "49.0"
1584        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    // Integration tests that verify per-measurement config determination
1592    #[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            // Verify each measurement gets its own config
1616            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            // Test that CLI values override config
1746            let params = super::resolve_audit_params(
1747                "build_time",
1748                Some(2),                                   // CLI min
1749                Some(ReductionFunc::Min),                  // CLI aggregate
1750                Some(3.0),                                 // CLI sigma
1751                Some(DispersionMethod::StandardDeviation), // CLI dispersion
1752                None,                                      // CLI max_cov
1753            );
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            // Test that config values are used when no CLI values provided
1785            let params = super::resolve_audit_params(
1786                "build_time",
1787                None, // No CLI values
1788                None,
1789                None,
1790                None,
1791                None, // max_cov
1792            );
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            // Test that defaults are used when no CLI or config
1816            let params = super::resolve_audit_params(
1817                "non_existent_measurement",
1818                None, // No CLI values
1819                None,
1820                None,
1821                None,
1822                None, // max_cov
1823            );
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        // Create mock commits with various measurements
1849        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        // Test 1: Single filter pattern matching "bench_*"
1922        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        // Test 2: Multiple filter patterns (OR behavior)
1934        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        // Test 3: Filter with selectors
1944        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        // bench_cpu and bench_memory both have os=linux (in first commit)
1950        // bench_cpu also has os=mac (in second commit) but selector filters it to only linux
1951        assert_eq!(discovered.len(), 2);
1952        assert!(discovered.contains(&"bench_cpu".to_string()));
1953        assert!(discovered.contains(&"bench_memory".to_string()));
1954
1955        // Test 4: No matches
1956        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        // Test 5: Empty filters (should match all)
1964        let filters = vec![];
1965        let selectors = vec![];
1966        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1967
1968        // Empty filters should match nothing based on the logic
1969        // Actually, looking at matches_any_filter, empty filters return true
1970        // So this should discover all measurements
1971        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        // Test 6: Selector filters out everything
1978        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        // Test 7: Exact match with anchored regex (simulating -m argument)
1986        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        // Test 8: Sorted output (verify deterministic ordering)
1995        let patterns = vec![".*".to_string()]; // Match all
1996        let filters = crate::filter::compile_filters(&patterns).unwrap();
1997        let selectors = vec![];
1998        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1999
2000        // Should be sorted alphabetically
2001        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        // This test verifies that combining explicit measurements (-m) and filter patterns (--filter)
2010        // works correctly with OR behavior. Both should be audited.
2011        // Note: This is an integration test that uses actual audit_multiple function,
2012        // but we can't easily test it without a real git repo, so we test the pattern combination
2013        // and discovery logic instead.
2014
2015        use crate::data::{Commit, MeasurementData};
2016        use std::collections::HashMap;
2017
2018        // Create mock commits
2019        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        // Simulate combining -m timer with --filter "bench_.*"
2049        // This is what combine_measurements_and_filters does in cli.rs
2050        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        // combined should have: ["^timer$", "bench_.*"]
2056        assert_eq!(combined.len(), 2);
2057        assert_eq!(combined[0], "^timer$");
2058        assert_eq!(combined[1], "bench_.*");
2059
2060        // Now compile and discover
2061        let filters = crate::filter::compile_filters(&combined).unwrap();
2062        let selectors = vec![];
2063        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
2064
2065        // Should discover both timer (exact match) and bench_cpu (pattern match)
2066        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())); // Not in -m or filter
2070
2071        // Test with multiple explicit measurements and multiple filters
2072        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        // Should discover timer, memory, and bench_cpu (no test_* in commits)
2083        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        // Test for division by zero bug when tail is empty
2092        // This test reproduces the bug where tail_median is 0.0 when tail is empty,
2093        // causing division by zero in sparkline calculation
2094        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        // Should succeed and skip (not crash with division by zero)
2109        assert!(result.is_ok(), "Should not crash on empty tail");
2110        let audit_result = result.unwrap();
2111
2112        // Should be skipped due to insufficient measurements
2113        assert!(audit_result.passed);
2114        assert!(audit_result.message.contains("Skipping test"));
2115
2116        // The message should not contain inf or NaN
2117        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        // Test for division by zero when all tail measurements are 0.0
2124        // This tests the edge case where median is 0.0 even with measurements
2125        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        // Should succeed (not crash with division by zero)
2140        assert!(result.is_ok(), "Should not crash when tail median is 0.0");
2141        let audit_result = result.unwrap();
2142
2143        // The message should not contain inf or NaN
2144        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        // Test the tiered approach:
2151        // 1. Non-zero median → use median, show percentages
2152        // 2. Zero median → show absolute values
2153
2154        // Case 1: Median is non-zero - use percentages (default behavior)
2155        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        // Should use median as baseline and show percentage
2172        assert!(audit_result.message.contains('%'));
2173        assert!(!audit_result.message.to_lowercase().contains("inf"));
2174
2175        // Case 2: Median is zero with non-zero head - use absolute values
2176        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        // Should show absolute values instead of percentages
2193        // The message should contain the sparkline but not percentage symbols
2194        assert!(!audit_result.message.to_lowercase().contains("inf"));
2195        assert!(!audit_result.message.to_lowercase().contains("nan"));
2196        // Check that sparkline exists (contains the dash character)
2197        assert!(audit_result.message.contains('–') || audit_result.message.contains('-'));
2198
2199        // Case 3: Everything is zero - show absolute values [0 - 0]
2200        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        // Should show absolute range [0 - 0]
2217        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        // Test the minimum allowed min_measurements value (2) with no tail measurements.
2224        // This should skip the audit since we have 0 < 2 tail measurements.
2225        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        // Should pass (skipped) since we have 0 < 2 tail measurements
2243        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        // Should show Head summary only (total_measurements = 1)
2253        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        // Test the minimum allowed min_measurements value (2) with a single tail measurement.
2261        // This should skip since we have 1 < 2 tail measurements.
2262        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        // Should pass (skipped) since we have 1 < 2 tail measurements
2280        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        // Should show both Head and Tail summaries with z-score (total_measurements = 2)
2290        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        // Test that the aggregation method is displayed correctly with ReductionFunc::Min
2299        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        // Test that the aggregation method is displayed correctly with ReductionFunc::Max
2321        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        // Test that the aggregation method is displayed correctly with ReductionFunc::Median
2343        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        // Test that the aggregation method is displayed correctly with ReductionFunc::Mean
2365        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        // Test that aggregation method is NOT shown when there's only 1 measurement
2387        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        // Should NOT show aggregation method (only 1 measurement total)
2404        assert!(!audit_result.message.contains("Aggregation:"));
2405        // But should show Head summary
2406        assert!(audit_result.message.contains("Head:"));
2407    }
2408
2409    // --- CoV warning tests ---
2410
2411    #[test]
2412    fn test_tail_cov_warning_fires_above_threshold() {
2413        // tail = [50, 100, 150, 100, 100]: mean=100, sample stddev≈35.4 → CoV≈35.4% > 30%
2414        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        // tail = [100, 100, 100]: mean=100, stddev=0 → CoV=0% < 30%
2439        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        // head_raw = [50, 150]: mean=100, sample stddev≈70.7 → CoV≈70.7% > 50%
2463        // tail is stable so tail CoV is low; only head CoV triggers the warning
2464        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        // head_raw = [100, 100]: mean=100, stddev=0 → CoV=0% < 50%
2492        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        // High-variance data but no threshold → no warning
2516        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        // Only 1 tail measurement → tail_summary.len = 1, so tail CoV skipped
2540        // Also head_raw empty → head CoV also skipped → no warning
2541        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        // head_raw has only 1 value → head CoV skipped; tail stable → tail CoV not triggered
2565        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        // tail = [0, 200]: mean=100, sample stddev=141.4… → CoV≈141.4%
2589        // Verifies stddev / mean (not stddev * mean or stddev + mean) and * 100.0
2590        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        // head_raw = [0, 200]: mean=100, sample stddev=141.4… → CoV≈141.4%
2614        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        // CoV warning is informational and appears even when z-score causes a fail
2638        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        // tail = [100, 100, 100]: CoV = 0%, threshold = 0%
2664        // Strict > means 0 > 0 is false → no warning.
2665        // With >= mutation: 0 >= 0 is true → warning fires → mutation caught.
2666        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        // tail mean ≈ EPSILON: the guard "mean.abs() > EPSILON" prevents CoV computation.
2690        // With >= mutation: EPSILON >= EPSILON → CoV computed → warning fires → caught.
2691        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        // tail CoV is below threshold, head CoV is above → warning fires (|| not &&)
2715        // head_raw = [0, 200]: CoV≈141.4%; tail = [100, 100]: CoV=0%
2716        // threshold = 50%: only head exceeds. With && instead of ||, no warning → mutation caught.
2717        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        // Verify tail CoV is computed when tail has exactly 2 measurements (len >= 2 boundary).
2741        // With > instead of >= mutation: len=2 is NOT > 2 → CoV skipped → no warning → caught.
2742        // tail = [0, 200]: mean=100, CoV≈141.4% > 50%
2743        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        // head_raw mean = EPSILON: the guard "mean.abs() > EPSILON" must prevent CoV computation.
2767        // head_raw = [0.0, 2*EPSILON]: mean = EPSILON, sample stddev = EPSILON*sqrt(2)
2768        // If guard used >= instead of >: EPSILON >= EPSILON → CoV = sqrt(2)*100% ≈ 141.4% > 100%
2769        // → warning fires → mutation caught.
2770        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        // Catches && → || mutation on head guard (line 489:45).
2794        // head_raw has len=2 (passes len >= 2) but mean = EPSILON/2 ≤ EPSILON (fails mean guard).
2795        // Original (&&): true && false = false → no CoV.
2796        // Mutated (||): true || false = true → CoV = stddev/(EPSILON/2)*100 ≈ 141.4% > threshold
2797        // → warning fires → mutation caught.
2798        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        // head_raw = [100, 100]: stddev=0, CoV=0%, threshold=0%.
2822        // Strict > means 0 > 0 is false → no warning.
2823        // With >= mutation: 0 >= 0 → warning fires → mutation caught.
2824        // Tail is stable (CoV=0%) so only the head threshold comparison is exercised.
2825        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    // --- Change point warning tests ---
2847
2848    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        // Single change point uses "at commit" inline format
2909        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        // Multiple change points consolidated into a single warning
2945        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        // Boilerplate should appear only once, not per change point
2959        assert_eq!(
2960            warnings[0].matches("regime shift").count(),
2961            1,
2962            "boilerplate should not repeat"
2963        );
2964        // Multiple change points should NOT use the single "at commit" inline format
2965        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        // Commits have a clear regime shift but under a different measurement name.
2974        // The filter must exclude them, so no warnings should appear.
2975        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", // wrong name
2981                    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", // asks for "bench", commits only have "other"
2996            &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        // When no_change_point_warning=true, no warnings regardless of data
3011        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, // no_change_point_warning = true
3036            &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        // When cp_config.enabled=false, no warnings regardless of data
3047        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, // disabled
3060            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        // Stable data → no change points → no warnings
3083        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        // Clear regime shift: 5 × 10.0 then 5 × 20.0 (100% increase)
3118        // Commits are ordered newest-first, so index 0 = most recent
3119        let commits: Vec<Result<Commit>> = (0..10)
3120            .map(|i| {
3121                // Newer commits (0..5) have value 20.0, older (5..10) have value 10.0
3122                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}