Skip to main content

git_perf/
study.rs

1use std::collections::HashMap;
2
3use anyhow::{bail, Result};
4use readable::num::Float;
5
6use crate::{
7    data::MeasurementData,
8    measurement_retrieval,
9    stats::{aggregate_measurements, NumericReductionFunc, ReductionFunc},
10};
11
12pub struct Recommendations {
13    pub dispersion_method: &'static str,
14    pub aggregate_by: &'static str,
15    pub sigma: f64,
16    pub min_measurements: u16,
17    pub min_relative_deviation: f64,
18    pub max_cov: f64,
19}
20
21struct GroupedStudy {
22    /// Per-group aggregated values (one per independent runner instance)
23    group_aggregates: Vec<f64>,
24    /// Total raw measurement count across all groups
25    total_raw: usize,
26    /// Whether grouping by key succeeded (vs. raw fallback)
27    grouped_by_key: bool,
28}
29
30fn group_measurements(measurements: &[&MeasurementData], key: &str) -> GroupedStudy {
31    let total_raw = measurements.len();
32    let mut groups: HashMap<String, Vec<f64>> = HashMap::new();
33    for m in measurements {
34        let group_val = m.key_values.get(key).cloned().unwrap_or_default();
35        groups.entry(group_val).or_default().push(m.val);
36    }
37
38    // Only use grouped CoV if the key is actually present and produces ≥2 groups
39    // (a single group keyed by "" means no runner tagged their measurements).
40    let non_empty_key_groups = groups.keys().filter(|k| !k.is_empty()).count();
41    if non_empty_key_groups >= 2 {
42        let group_aggregates = groups
43            .iter()
44            .filter(|(k, _)| !k.is_empty())
45            .map(|(_, vals)| {
46                vals.iter()
47                    .cloned()
48                    .aggregate_by(ReductionFunc::Min)
49                    .unwrap_or(f64::NAN)
50            })
51            .collect();
52        GroupedStudy {
53            group_aggregates,
54            total_raw,
55            grouped_by_key: true,
56        }
57    } else {
58        // Fallback: treat each raw measurement as an independent sample
59        let group_aggregates = measurements.iter().map(|m| m.val).collect();
60        GroupedStudy {
61            group_aggregates,
62            total_raw,
63            grouped_by_key: false,
64        }
65    }
66}
67
68/// Returns true if MAD is preferred over stddev as the dispersion method.
69/// Requires a low MAD/σ ratio (outliers present) AND at least 5 data points.
70fn is_mad_preferred(mad_sigma_ratio: f64, n: usize) -> bool {
71    mad_sigma_ratio < 0.7 && n >= 5
72}
73
74/// Recommended sigma for given CoV. Returns 3.5 if CoV > 5%, otherwise 4.0.
75fn recommend_sigma(cov: f64) -> f64 {
76    if cov > 5.0 {
77        3.5_f64
78    } else {
79        4.0_f64
80    }
81}
82
83/// Returns true if CoV strictly exceeds the threshold (NaN-safe: NaN > x = false).
84fn exceeds_cov_threshold(cov: f64, threshold: f64) -> bool {
85    cov > threshold
86}
87
88/// Compute recommendations from between-group aggregate values.
89/// Returns None if there are fewer than 3 data points.
90#[must_use]
91pub fn compute_recommendations(aggregates: &[f64]) -> Option<Recommendations> {
92    if aggregates.len() < 3 {
93        return None;
94    }
95    let stats = aggregate_measurements(aggregates.iter());
96    if stats.mean.abs() <= f64::EPSILON || stats.mean.is_nan() {
97        return None;
98    }
99
100    let cov = stats.stddev / stats.mean * 100.0;
101    // NaN from compute_mad_sigma_ratio (near-zero stddev) maps to "stddev" via is_mad_preferred
102    let mad_sigma_ratio = compute_mad_sigma_ratio(stats.mad, stats.stddev);
103
104    // Low MAD/σ means outliers inflate stddev relative to MAD, making MAD the
105    // more robust dispersion method for detecting genuine regressions.
106    let dispersion_method = if is_mad_preferred(mad_sigma_ratio, aggregates.len()) {
107        "mad"
108    } else {
109        "stddev"
110    };
111    let aggregate_by = if cov > 10.0 { "median" } else { "min" };
112    let sigma = recommend_sigma(cov);
113    let min_measurements: u16 = if cov > 10.0 { 5 } else { 3 };
114    // Round up to nearest 0.5 for readability
115    let min_relative_deviation = (cov * 1.5 * 2.0).ceil() / 2.0;
116    let max_cov = (cov * 2.0 * 2.0).ceil() / 2.0;
117
118    Some(Recommendations {
119        dispersion_method,
120        aggregate_by,
121        sigma,
122        min_measurements,
123        min_relative_deviation,
124        max_cov,
125    })
126}
127
128/// Compute between-group CoV as a percentage. Returns NaN when mean is near zero.
129fn compute_cov_pct(stddev: f64, mean: f64) -> f64 {
130    if mean.abs() > f64::EPSILON && !mean.is_nan() {
131        stddev / mean * 100.0
132    } else {
133        f64::NAN
134    }
135}
136
137/// Compute MAD as a percentage of the mean. Returns NaN when mean is near zero.
138fn compute_mad_pct(mad: f64, mean: f64) -> f64 {
139    if mean.abs() > f64::EPSILON && !mean.is_nan() {
140        mad / mean.abs() * 100.0
141    } else {
142        f64::NAN
143    }
144}
145
146/// Compute MAD/σ ratio. Returns NaN when stddev is near zero.
147fn compute_mad_sigma_ratio(mad: f64, stddev: f64) -> f64 {
148    if stddev > f64::EPSILON {
149        mad / stddev
150    } else {
151        f64::NAN
152    }
153}
154
155pub(crate) fn format_output(
156    name: &str,
157    aggregates: &[f64],
158    grouped_by_key: bool,
159    total_raw: usize,
160    group_by: &str,
161    max_cov_threshold: Option<f64>,
162) -> String {
163    let stats = aggregate_measurements(aggregates.iter());
164    let n = aggregates.len();
165    let cov = compute_cov_pct(stats.stddev, stats.mean);
166    let mad_pct = compute_mad_pct(stats.mad, stats.mean);
167    let mad_sigma_ratio = compute_mad_sigma_ratio(stats.mad, stats.stddev);
168
169    let cov_label = if grouped_by_key {
170        "Between-group CoV"
171    } else {
172        "Overall CoV (no group key found)"
173    };
174
175    let grouping_note = if grouped_by_key {
176        format!(
177            "{n} groups × {} reps (grouped by: {group_by})",
178            total_raw / n
179        )
180    } else {
181        format!(
182            "{total_raw} raw measurements (no '{group_by}' key found — \
183             tag runners with --key-value {group_by}=<instance> for between-runner CoV)"
184        )
185    };
186
187    let cov_str = if cov.is_nan() {
188        "N/A".to_string()
189    } else {
190        format!("{:.1}%", cov)
191    };
192    let mad_pct_str = if mad_pct.is_nan() {
193        "N/A".to_string()
194    } else {
195        format!("{:.1}%", mad_pct)
196    };
197    let mad_sigma_str = if mad_sigma_ratio.is_nan() {
198        "N/A".to_string()
199    } else {
200        format!("{:.2}", mad_sigma_ratio)
201    };
202
203    let mut out = format!(
204        "📊 '{}' — {}\n  μ: {} | σ: {} | MAD: {}\n  {}: {} | MAD%: {} | MAD/σ: {}\n",
205        name,
206        grouping_note,
207        Float::from(stats.mean),
208        Float::from(stats.stddev),
209        Float::from(stats.mad),
210        cov_label,
211        cov_str,
212        mad_pct_str,
213        mad_sigma_str,
214    );
215
216    // CoV verdict
217    if !cov.is_nan() {
218        let verdict = if let Some(threshold) = max_cov_threshold {
219            if exceeds_cov_threshold(cov, threshold) {
220                format!(
221                    "\n  ⚠️  CoV {:.1}% exceeds threshold {:.1}% — \
222                     benchmark may produce unreliable CI results.\n\
223                     Consider: increasing workload size, adding warmup, \
224                     or reducing setup variance.",
225                    cov, threshold
226                )
227            } else {
228                format!(
229                    "\n  ✅ CoV {:.1}% is within threshold {:.1}%.",
230                    cov, threshold
231                )
232            }
233        } else if cov > 20.0 {
234            "\n  ⚠️  CoV > 20%: benchmark is too noisy for reliable regression detection.\n\
235             Consider increasing workload size or reducing setup variance."
236                .to_string()
237        } else if cov > 10.0 {
238            "\n  ⚠️  CoV 10–20%: moderate noise. Monitor with max_cov in config.".to_string()
239        } else {
240            "\n  ✅ CoV < 10%: benchmark is stable.".to_string()
241        };
242        out.push_str(&verdict);
243    }
244
245    // Recommended config
246    if let Some(recs) = compute_recommendations(aggregates) {
247        out.push_str(&format!(
248            "\n\n  Recommended .gitperfconfig:\n\
249             \n  [measurement.\"{name}\"]\n\
250             \n  dispersion_method = \"{method}\"",
251            method = recs.dispersion_method,
252        ));
253        if is_mad_preferred(mad_sigma_ratio, n) && !mad_sigma_ratio.is_nan() {
254            out.push_str(&format!(
255                "  # MAD/σ = {:.2} — outliers between runners detected",
256                mad_sigma_ratio
257            ));
258        }
259        out.push_str(&format!("\n  sigma = {}", recs.sigma));
260        if recs.sigma < 4.0 {
261            out.push_str("  # tightened threshold for CoV > 5%");
262        }
263        out.push_str(&format!("\n  aggregate_by = \"{}\"", recs.aggregate_by));
264        if cov > 10.0 {
265            out.push_str("  # CoV > 10% → median more stable than min");
266        }
267        out.push_str(&format!("\n  min_measurements = {}", recs.min_measurements));
268        if cov > 10.0 {
269            out.push_str("  # CoV > 10% → need more history");
270        }
271        out.push_str(&format!(
272            "\n  min_relative_deviation = {}  # 1.5× between-group CoV — noise floor",
273            recs.min_relative_deviation
274        ));
275        out.push_str(&format!(
276            "\n  max_cov = {}  # warn if noise grows to 2× current level\n",
277            recs.max_cov
278        ));
279    } else {
280        out.push_str(
281            "\n\n  Not enough data points for recommendations (need ≥ 3 groups).\n\
282             Run the benchmark on more independent runner instances.",
283        );
284    }
285
286    out
287}
288
289pub fn run_study(
290    commit: &str,
291    max_count: usize,
292    name: &str,
293    max_cov_threshold: Option<f64>,
294    group_by: &str,
295) -> Result<()> {
296    let commits: Vec<_> =
297        measurement_retrieval::walk_commits_from(commit, max_count, None, None)?.collect();
298
299    let head_measurements: Vec<&MeasurementData> = commits
300        .first()
301        .and_then(|r| r.as_ref().ok())
302        .map(|c| c.measurements.iter().filter(|m| m.name == name).collect())
303        .unwrap_or_default();
304
305    if head_measurements.is_empty() {
306        bail!(
307            "No measurements found for '{}' at HEAD.\n\
308             Have you run 'git-perf measure' or 'git-perf push && git-perf pull'?",
309            name
310        );
311    }
312
313    let GroupedStudy {
314        group_aggregates,
315        total_raw,
316        grouped_by_key,
317    } = group_measurements(&head_measurements, group_by);
318
319    if group_aggregates.len() < 3 {
320        bail!(
321            "Need at least 3 independent data points for a reliable study (found {}).\n\
322             Run the benchmark on more independent runner instances and tag each with:\n\
323               --key-value {}=<instance_number>",
324            group_aggregates.len(),
325            group_by
326        );
327    }
328
329    let output = format_output(
330        name,
331        &group_aggregates,
332        grouped_by_key,
333        total_raw,
334        group_by,
335        max_cov_threshold,
336    );
337    print!("{}", output);
338
339    if let Some(threshold) = max_cov_threshold {
340        let stats = aggregate_measurements(group_aggregates.iter());
341        let cov = compute_cov_pct(stats.stddev, stats.mean);
342        // NaN is treated as not exceeding: exceeds_cov_threshold returns false for NaN
343        if exceeds_cov_threshold(cov, threshold) {
344            bail!("CoV {:.1}% exceeds threshold {:.1}%", cov, threshold);
345        }
346    }
347
348    Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn make_uniform(vals: &[f64]) -> Vec<f64> {
356        vals.to_vec()
357    }
358
359    #[test]
360    fn test_recommend_low_cov() {
361        // Very tight data → low CoV → stddev, min, sigma=4.0, min_measurements=3
362        let data = make_uniform(&[100.0, 101.0, 100.5, 99.5, 100.2, 100.8]);
363        let recs = compute_recommendations(&data).expect("should have recommendations");
364        assert_eq!(recs.dispersion_method, "stddev");
365        assert_eq!(recs.aggregate_by, "min");
366        assert!((recs.sigma - 4.0).abs() < f64::EPSILON);
367        assert_eq!(recs.min_measurements, 3);
368        // min_relative_deviation should be small (CoV < 5%)
369        assert!(recs.min_relative_deviation < 10.0);
370    }
371
372    #[test]
373    fn test_recommend_high_cov() {
374        // Wide spread → high CoV > 10% → mad, median, sigma=3.5, min_measurements=5
375        let data: Vec<f64> = vec![100.0, 115.0, 90.0, 120.0, 85.0, 110.0, 95.0, 125.0];
376        let recs = compute_recommendations(&data).expect("should have recommendations");
377        // High CoV should trigger median and more min_measurements
378        assert_eq!(recs.aggregate_by, "median");
379        assert_eq!(recs.min_measurements, 5);
380        assert!((recs.sigma - 3.5).abs() < f64::EPSILON);
381    }
382
383    #[test]
384    fn test_recommend_insufficient_data() {
385        assert!(compute_recommendations(&[100.0]).is_none());
386        assert!(compute_recommendations(&[100.0, 101.0]).is_none());
387        assert!(compute_recommendations(&[]).is_none());
388    }
389
390    #[test]
391    fn test_recommend_rounding() {
392        // data: [100.0, 108.0, 100.5, 107.5, 99.5, 108.5]
393        // mean=104, stddev=sqrt(97/5)≈4.405, cov≈4.235%
394        // min_relative_deviation = ceil(4.235 * 1.5 * 2.0) / 2.0 = ceil(12.705) / 2 = 6.5
395        // max_cov              = ceil(4.235 * 2.0 * 2.0) / 2.0 = ceil(16.94)  / 2 = 8.5
396        let recs = compute_recommendations(&[100.0, 108.0, 100.5, 107.5, 99.5, 108.5]).unwrap();
397        // Check exact values so mutations to the rounding formula are caught
398        assert!(
399            (recs.min_relative_deviation - 6.5).abs() < 0.01,
400            "expected min_relative_deviation=6.5, got {}",
401            recs.min_relative_deviation
402        );
403        assert!(
404            (recs.max_cov - 8.5).abs() < 0.01,
405            "expected max_cov=8.5, got {}",
406            recs.max_cov
407        );
408        // Result should also be a multiple of 0.5
409        let scaled = recs.min_relative_deviation * 2.0;
410        assert!(
411            (scaled - scaled.round()).abs() < f64::EPSILON,
412            "min_relative_deviation should be a multiple of 0.5"
413        );
414    }
415
416    #[test]
417    fn test_recommend_dispersion_method_boundary() {
418        // n=4 with one extreme outlier: MAD/σ ≈ 0, but n < 5 → "stddev"
419        let data_4 = vec![100.0, 100.0, 100.0, 200.0];
420        let recs_4 = compute_recommendations(&data_4).unwrap();
421        assert_eq!(
422            recs_4.dispersion_method, "stddev",
423            "n=4 should use stddev regardless of MAD/σ"
424        );
425
426        // n=5 same pattern: MAD/σ ≈ 0 and n >= 5 → "mad"
427        let data_5 = vec![100.0, 100.0, 100.0, 100.0, 200.0];
428        let recs_5 = compute_recommendations(&data_5).unwrap();
429        assert_eq!(
430            recs_5.dispersion_method, "mad",
431            "n=5 with low MAD/σ should use mad"
432        );
433    }
434
435    #[test]
436    fn test_compute_helpers() {
437        // compute_cov_pct: normal input
438        let cov = compute_cov_pct(10.0, 100.0);
439        assert!((cov - 10.0).abs() < 1e-9, "cov should be 10%, got {cov}");
440
441        // compute_cov_pct: near-zero mean → NaN
442        assert!(compute_cov_pct(1.0, 0.0).is_nan());
443
444        // compute_cov_pct: exact-EPSILON mean → NaN (EPSILON > EPSILON is false)
445        assert!(compute_cov_pct(1.0, f64::EPSILON).is_nan());
446
447        // compute_mad_pct: normal input
448        let mp = compute_mad_pct(5.0, 100.0);
449        assert!((mp - 5.0).abs() < 1e-9, "mad_pct should be 5%, got {mp}");
450
451        // compute_mad_pct: near-zero mean → NaN
452        assert!(compute_mad_pct(1.0, 0.0).is_nan());
453
454        // compute_mad_pct: exact-EPSILON mean → NaN (EPSILON > EPSILON is false)
455        assert!(compute_mad_pct(1.0, f64::EPSILON).is_nan());
456
457        // compute_mad_sigma_ratio: normal input
458        let r = compute_mad_sigma_ratio(3.0, 6.0);
459        assert!((r - 0.5).abs() < 1e-9, "ratio should be 0.5, got {r}");
460
461        // compute_mad_sigma_ratio: near-zero stddev → NaN
462        assert!(compute_mad_sigma_ratio(1.0, 0.0).is_nan());
463
464        // compute_mad_sigma_ratio: exact-EPSILON stddev → NaN (EPSILON > EPSILON is false)
465        assert!(compute_mad_sigma_ratio(1.0, f64::EPSILON).is_nan());
466    }
467
468    #[test]
469    fn test_recommendations_near_zero_mean_guard() {
470        // mean = EPSILON → should return None (|| with && mutation would continue instead)
471        // data sums to 3*EPSILON so mean = EPSILON (well below practical significance)
472        let eps_data = vec![f64::EPSILON, f64::EPSILON, f64::EPSILON];
473        assert!(
474            compute_recommendations(&eps_data).is_none(),
475            "near-zero mean should give None"
476        );
477    }
478
479    #[test]
480    fn test_recommendations_at_exact_cov_boundaries() {
481        // [95, 95, 100, 105, 105]: Welford's algorithm for symmetric data gives stddev that
482        // via compute_recommendations cov = stddev/mean*100 lands at or below 5.0, meaning
483        // cov > 5.0 is FALSE → sigma = 4.0. This kills the > 5.0 → >= 5.0 mutant.
484        let data_near_5pct = vec![95.0, 95.0, 100.0, 105.0, 105.0];
485        let recs = compute_recommendations(&data_near_5pct).unwrap();
486        assert!(
487            (recs.sigma - 4.0).abs() < f64::EPSILON,
488            "cov ≤ 5.0: sigma should be 4.0, got {}",
489            recs.sigma
490        );
491        assert_eq!(
492            recs.aggregate_by, "min",
493            "cov ≤ 5.0: should use min aggregation"
494        );
495        assert_eq!(recs.min_measurements, 3, "cov ≤ 5.0: min_measurements = 3");
496    }
497
498    #[test]
499    fn test_format_output_config_block_comments() {
500        // Low CoV (≈ 0.5%): no CoV-threshold config comments
501        let low_cov = vec![100.0, 100.5, 99.5, 100.2, 100.8, 99.8];
502        let out_low = format_output("bench", &low_cov, true, 6, "group", None);
503        assert!(
504            !out_low.contains("tightened threshold"),
505            "low CoV should NOT have sigma tightened comment:\n{out_low}"
506        );
507        // Use specific unique strings to distinguish the two CoV > 10% comment locations
508        assert!(
509            !out_low.contains("median more stable than min"),
510            "low CoV should NOT have aggregate_by comment:\n{out_low}"
511        );
512        assert!(
513            !out_low.contains("need more history"),
514            "low CoV should NOT have min_measurements comment:\n{out_low}"
515        );
516
517        // High CoV (≈ 12%): sigma and BOTH CoV>10% comments should appear
518        let high_cov = vec![100.0, 112.0, 88.0, 115.0, 85.0, 110.0];
519        let out_high = format_output("bench", &high_cov, true, 60, "group", None);
520        assert!(
521            out_high.contains("tightened threshold"),
522            "high CoV should have sigma tightened comment:\n{out_high}"
523        );
524        assert!(
525            out_high.contains("median more stable than min"),
526            "high CoV should have aggregate_by comment:\n{out_high}"
527        );
528        assert!(
529            out_high.contains("need more history"),
530            "high CoV should have min_measurements comment:\n{out_high}"
531        );
532    }
533
534    #[test]
535    fn test_format_output_at_cov_boundaries() {
536        // [95, 95, 100, 105, 105]: Welford's algorithm gives stddev that via
537        // compute_cov_pct lands at or below 5.0 in format_output, meaning
538        // cov > 5.0 is FALSE → no sigma "tightened threshold" comment.
539        // This kills the > 5.0 → >= 5.0 mutant in format_output.
540        let data_near_5pct = vec![95.0, 95.0, 100.0, 105.0, 105.0];
541        let out_5 = format_output("bench", &data_near_5pct, true, 5, "group", None);
542        assert!(
543            !out_5.contains("tightened threshold"),
544            "cov ≤ 5.0: no sigma tightened comment:\n{out_5}"
545        );
546        assert!(
547            out_5.contains("stable"),
548            "cov ≤ 5.0: should show stable verdict:\n{out_5}"
549        );
550    }
551
552    #[test]
553    fn test_format_output_verdict_low_cov() {
554        // CoV < 10% → "stable" verdict
555        let aggregates = vec![100.0, 100.5, 99.5, 100.2, 100.8, 99.8];
556        let out = format_output("bench", &aggregates, true, 6, "group", None);
557        assert!(
558            out.contains("stable"),
559            "low CoV should give stable verdict:\n{out}"
560        );
561        assert!(out.contains("Between-group CoV"), "grouped output:\n{out}");
562    }
563
564    #[test]
565    fn test_format_output_verdict_moderate_cov() {
566        // CoV 10–20%: values with ~13% spread
567        let aggregates = vec![100.0, 112.0, 88.0, 115.0, 85.0, 110.0];
568        let out = format_output("bench", &aggregates, true, 60, "group", None);
569        // Moderate verdict contains "10–20%" and does not contain the stable/noisy verdicts
570        assert!(
571            out.contains("10\u{2013}20%"),
572            "moderate CoV should show 10–20% range:\n{out}"
573        );
574        assert!(
575            !out.contains("benchmark is stable"),
576            "moderate should NOT say 'benchmark is stable':\n{out}"
577        );
578        assert!(
579            !out.contains("too noisy"),
580            "moderate should NOT say too noisy:\n{out}"
581        );
582    }
583
584    #[test]
585    fn test_format_output_verdict_high_cov() {
586        // CoV > 20%: wide spread
587        let aggregates = vec![100.0, 130.0, 70.0, 145.0, 60.0, 125.0, 75.0];
588        let out = format_output("bench", &aggregates, true, 700, "group", None);
589        assert!(
590            out.contains("too noisy"),
591            "high CoV should give too noisy verdict:\n{out}"
592        );
593        assert!(
594            !out.contains("benchmark is stable"),
595            "high CoV should NOT say 'benchmark is stable':\n{out}"
596        );
597    }
598
599    #[test]
600    fn test_format_output_threshold_exceeded() {
601        // CoV > threshold → "exceeds threshold"
602        let aggregates = vec![100.0, 115.0, 90.0, 120.0, 85.0, 110.0];
603        let out = format_output("bench", &aggregates, true, 60, "group", Some(5.0));
604        assert!(
605            out.contains("exceeds threshold"),
606            "high CoV with low threshold:\n{out}"
607        );
608    }
609
610    #[test]
611    fn test_format_output_threshold_passed() {
612        // CoV < threshold → "within threshold"
613        let aggregates = vec![100.0, 100.5, 99.5, 100.2, 100.8, 99.8];
614        let out = format_output("bench", &aggregates, true, 6, "group", Some(50.0));
615        assert!(
616            out.contains("within threshold"),
617            "low CoV with high threshold:\n{out}"
618        );
619    }
620
621    #[test]
622    fn test_format_output_mad_outlier_comment() {
623        // n=5, outlier → low MAD/σ → "outliers" comment in config block
624        let aggregates = vec![100.0, 100.0, 100.0, 100.0, 200.0];
625        let out = format_output("bench", &aggregates, true, 5, "group", None);
626        assert!(
627            out.contains("outliers"),
628            "low MAD/σ with n>=5 should show outliers comment:\n{out}"
629        );
630    }
631
632    #[test]
633    fn test_format_output_no_mad_comment_n_lt_5() {
634        // n=4, same pattern → no "outliers" comment because n < 5
635        let aggregates = vec![100.0, 100.0, 100.0, 200.0];
636        let out = format_output("bench", &aggregates, true, 4, "group", None);
637        assert!(
638            !out.contains("outliers"),
639            "n=4 should NOT show outliers comment:\n{out}"
640        );
641    }
642
643    #[test]
644    fn test_format_output_cov_numeric_in_output() {
645        // For tight data, CoV should appear as a small % (< 2%), not thousands%
646        // This catches mutations to the CoV division formula
647        let aggregates = vec![100.0, 100.5, 99.5, 100.2, 100.8, 99.8];
648        let out = format_output("bench", &aggregates, false, 6, "group", None);
649        assert!(out.contains("Overall CoV"), "fallback label:\n{out}");
650        // "stable" verdict only appears when CoV < 10%; mutating / to * gives CoV ≈ 5000%
651        assert!(
652            out.contains("stable"),
653            "small CoV should give stable:\n{out}"
654        );
655        // MAD% and MAD/σ should be present (not N/A) for valid data
656        assert!(
657            !out.contains("MAD%: N/A"),
658            "MAD% should not be N/A for valid data:\n{out}"
659        );
660        assert!(
661            !out.contains("MAD/σ: N/A"),
662            "MAD/σ should not be N/A for valid data:\n{out}"
663        );
664    }
665
666    #[test]
667    fn test_format_output_fallback_grouping_note() {
668        let aggregates = vec![100.0, 105.0, 95.0];
669        let out = format_output("bench", &aggregates, false, 3, "group", None);
670        assert!(out.contains("raw measurements"), "fallback note:\n{out}");
671        assert!(out.contains("no 'group' key"), "missing key note:\n{out}");
672    }
673
674    #[test]
675    fn test_group_by_key() {
676        let mut m1 = MeasurementData {
677            epoch: 0,
678            name: "t".to_string(),
679            timestamp: 0.0,
680            val: 100.0,
681            key_values: std::collections::HashMap::new(),
682        };
683        m1.key_values.insert("group".to_string(), "1".to_string());
684
685        let mut m2 = m1.clone();
686        m2.val = 200.0;
687        m2.key_values.insert("group".to_string(), "2".to_string());
688
689        let mut m3 = m1.clone();
690        m3.val = 300.0;
691        m3.key_values.insert("group".to_string(), "3".to_string());
692
693        // Add a second rep to group 1 (lower value — should be min)
694        let mut m1b = m1.clone();
695        m1b.val = 90.0;
696
697        let measurements = vec![&m1, &m1b, &m2, &m3];
698        let grouped = group_measurements(&measurements, "group");
699
700        assert!(grouped.grouped_by_key);
701        assert_eq!(grouped.group_aggregates.len(), 3);
702        assert_eq!(grouped.total_raw, 4);
703
704        // Group 1 should have min(100, 90) = 90
705        assert!(grouped.group_aggregates.contains(&90.0));
706        assert!(grouped.group_aggregates.contains(&200.0));
707        assert!(grouped.group_aggregates.contains(&300.0));
708    }
709
710    #[test]
711    fn test_group_by_key_fallback() {
712        // No group key → fallback to raw values
713        let m1 = MeasurementData {
714            epoch: 0,
715            name: "t".to_string(),
716            timestamp: 0.0,
717            val: 100.0,
718            key_values: std::collections::HashMap::new(),
719        };
720        let m2 = MeasurementData {
721            val: 110.0,
722            ..m1.clone()
723        };
724        let m3 = MeasurementData {
725            val: 95.0,
726            ..m1.clone()
727        };
728
729        let measurements = vec![&m1, &m2, &m3];
730        let grouped = group_measurements(&measurements, "group");
731
732        assert!(!grouped.grouped_by_key);
733        assert_eq!(grouped.group_aggregates.len(), 3);
734        assert_eq!(grouped.total_raw, 3);
735    }
736
737    #[test]
738    fn test_is_mad_preferred_boundary() {
739        // At the exact literal boundary 0.7: strict < is false, <= would be true — kills < vs <= mutant
740        assert!(!is_mad_preferred(0.7, 5));
741        // Below threshold: < and <= both true
742        assert!(is_mad_preferred(0.6, 5));
743        // Above threshold: < and <= both false
744        assert!(!is_mad_preferred(0.8, 5));
745        // n=4 boundary: never preferred regardless of ratio
746        assert!(!is_mad_preferred(0.5, 4));
747        // n=5 with low ratio: preferred
748        assert!(is_mad_preferred(0.5, 5));
749    }
750
751    #[test]
752    fn test_threshold_helpers() {
753        // recommend_sigma: literal 5.0 distinguishes > 5.0 from >= 5.0
754        assert_eq!(recommend_sigma(5.0), 4.0, "5.0 is NOT > 5.0");
755        assert_eq!(recommend_sigma(5.1), 3.5, "5.1 IS > 5.0");
756        // exceeds_cov_threshold: literal equality distinguishes > from >=
757        assert!(!exceeds_cov_threshold(10.0, 10.0), "10.0 is NOT > 10.0");
758        assert!(exceeds_cov_threshold(10.1, 10.0), "10.1 IS > 10.0");
759    }
760
761    #[test]
762    fn test_recommendations_near_10pct_boundary() {
763        // [90, 100, 110]: Welford gives exact M2=200 → stddev=10.0, mean=100.0
764        // cov = 10.0/100.0*100.0 = 10.0 (IEEE 754 rounds down, excess < half ULP)
765        // cov > 10.0 = false → kills > 10.0 → >= 10.0 mutations in compute_recommendations
766        let data = vec![90.0, 100.0, 110.0];
767        let recs = compute_recommendations(&data).unwrap();
768        assert_eq!(recs.aggregate_by, "min", "cov=10.0 is NOT > 10.0 → min");
769        assert_eq!(
770            recs.min_measurements, 3,
771            "cov=10.0 is NOT > 10.0 → min_measurements=3"
772        );
773    }
774
775    #[test]
776    fn test_format_output_near_10pct_boundary() {
777        // [90, 100, 110]: cov=10.0 exactly (NOT > 10.0)
778        // Kills > 10.0 → >= 10.0 mutations in the three format_output threshold checks
779        let data = vec![90.0, 100.0, 110.0];
780        let out = format_output("bench", &data, true, 3, "group", None);
781        assert!(
782            out.contains("stable"),
783            "cov=10.0 is NOT > 10.0 → stable:\n{out}"
784        );
785        assert!(
786            !out.contains("median more stable than min"),
787            "cov=10.0 → no aggregate_by comment:\n{out}"
788        );
789        assert!(
790            !out.contains("need more history"),
791            "cov=10.0 → no min_measurements comment:\n{out}"
792        );
793    }
794
795    #[test]
796    fn test_format_output_near_20pct_boundary() {
797        // [80, 100, 120]: Welford gives exact M2=800 → stddev=20.0, mean=100.0
798        // cov = 20.0/100.0*100.0 = 20.0 (IEEE 754 rounds down, exact)
799        // cov > 20.0 = false → kills > 20.0 → >= 20.0 mutation in format_output verdict
800        let data = vec![80.0, 100.0, 120.0];
801        let out = format_output("bench", &data, true, 3, "group", None);
802        assert!(
803            !out.contains("too noisy"),
804            "cov=20.0 is NOT > 20.0 → not too noisy:\n{out}"
805        );
806        assert!(
807            out.contains("10\u{2013}20%") || out.contains("moderate"),
808            "cov=20.0 >= 10.0 → moderate verdict:\n{out}"
809        );
810    }
811}