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 group_aggregates: Vec<f64>,
24 total_raw: usize,
26 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 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 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
68fn is_mad_preferred(mad_sigma_ratio: f64, n: usize) -> bool {
71 mad_sigma_ratio < 0.7 && n >= 5
72}
73
74fn recommend_sigma(cov: f64) -> f64 {
76 if cov > 5.0 {
77 3.5_f64
78 } else {
79 4.0_f64
80 }
81}
82
83fn exceeds_cov_threshold(cov: f64, threshold: f64) -> bool {
85 cov > threshold
86}
87
88#[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 let mad_sigma_ratio = compute_mad_sigma_ratio(stats.mad, stats.stddev);
103
104 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 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
128fn 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
137fn 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
146fn 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 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 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 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 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 assert!(recs.min_relative_deviation < 10.0);
370 }
371
372 #[test]
373 fn test_recommend_high_cov() {
374 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 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 let recs = compute_recommendations(&[100.0, 108.0, 100.5, 107.5, 99.5, 108.5]).unwrap();
397 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 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 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 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 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 assert!(compute_cov_pct(1.0, 0.0).is_nan());
443
444 assert!(compute_cov_pct(1.0, f64::EPSILON).is_nan());
446
447 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 assert!(compute_mad_pct(1.0, 0.0).is_nan());
453
454 assert!(compute_mad_pct(1.0, f64::EPSILON).is_nan());
456
457 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 assert!(compute_mad_sigma_ratio(1.0, 0.0).is_nan());
463
464 assert!(compute_mad_sigma_ratio(1.0, f64::EPSILON).is_nan());
466 }
467
468 #[test]
469 fn test_recommendations_near_zero_mean_guard() {
470 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
652 out.contains("stable"),
653 "small CoV should give stable:\n{out}"
654 );
655 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 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 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 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 assert!(!is_mad_preferred(0.7, 5));
741 assert!(is_mad_preferred(0.6, 5));
743 assert!(!is_mad_preferred(0.8, 5));
745 assert!(!is_mad_preferred(0.5, 4));
747 assert!(is_mad_preferred(0.5, 5));
749 }
750
751 #[test]
752 fn test_threshold_helpers() {
753 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 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 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 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 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}