git_perf/
audit.rs

1use crate::{
2    config,
3    data::MeasurementData,
4    measurement_retrieval::{self, summarize_measurements},
5    stats::{self, DispersionMethod, ReductionFunc, VecAggregation},
6};
7use anyhow::{anyhow, bail, Result};
8use itertools::Itertools;
9use log::error;
10use sparklines::spark;
11use std::cmp::Ordering;
12use std::iter;
13
14/// Formats a z-score for display in audit output.
15/// Only finite z-scores are displayed with numeric values.
16/// Infinite and NaN values return an empty string.
17fn format_z_score_display(z_score: f64) -> String {
18    if z_score.is_finite() {
19        format!(" {:.2}", z_score)
20    } else {
21        String::new()
22    }
23}
24
25/// Determines the direction arrow based on comparison of head and tail means.
26/// Returns ↑ for greater, ↓ for less, → for equal.
27fn get_direction_arrow(head_mean: f64, tail_mean: f64) -> &'static str {
28    match head_mean.partial_cmp(&tail_mean).unwrap() {
29        Ordering::Greater => "↑",
30        Ordering::Less => "↓",
31        Ordering::Equal => "→",
32    }
33}
34
35#[derive(Debug, PartialEq)]
36struct AuditResult {
37    message: String,
38    passed: bool,
39}
40
41pub fn audit_multiple(
42    measurements: &[String],
43    max_count: usize,
44    min_count: u16,
45    selectors: &[(String, String)],
46    summarize_by: ReductionFunc,
47    sigma: f64,
48    dispersion_method: DispersionMethod,
49) -> Result<()> {
50    let mut failed = false;
51
52    for measurement in measurements {
53        let result = audit(
54            measurement,
55            max_count,
56            min_count,
57            selectors,
58            summarize_by,
59            sigma,
60            dispersion_method,
61        )?;
62
63        println!("{}", result.message);
64
65        if !result.passed {
66            failed = true;
67        }
68    }
69
70    if failed {
71        bail!("One or more measurements failed audit.");
72    }
73
74    Ok(())
75}
76
77fn audit(
78    measurement: &str,
79    max_count: usize,
80    min_count: u16,
81    selectors: &[(String, String)],
82    summarize_by: ReductionFunc,
83    sigma: f64,
84    dispersion_method: DispersionMethod,
85) -> Result<AuditResult> {
86    let all = measurement_retrieval::walk_commits(max_count)?;
87
88    // Filter using subset relation: selectors ⊆ measurement.key_values
89    let filter_by =
90        |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
91
92    let mut aggregates = measurement_retrieval::take_while_same_epoch(summarize_measurements(
93        all,
94        &summarize_by,
95        &filter_by,
96    ));
97
98    let head = aggregates
99        .next()
100        .ok_or(anyhow!("No commit at HEAD"))
101        .and_then(|s| {
102            s.and_then(|cs| {
103                cs.measurement
104                    .map(|m| m.val)
105                    .ok_or(anyhow!("No measurement for HEAD."))
106            })
107        })?;
108
109    let tail: Vec<_> = aggregates
110        .filter_map_ok(|cs| cs.measurement.map(|m| m.val))
111        .take(max_count)
112        .try_collect()?;
113
114    audit_with_data(measurement, head, tail, min_count, sigma, dispersion_method)
115}
116
117/// Core audit logic that can be tested with mock data
118/// This function contains all the mutation-tested logic paths
119fn audit_with_data(
120    measurement: &str,
121    head: f64,
122    tail: Vec<f64>,
123    min_count: u16,
124    sigma: f64,
125    dispersion_method: DispersionMethod,
126) -> Result<AuditResult> {
127    let head_summary = stats::aggregate_measurements(iter::once(&head));
128    let tail_summary = stats::aggregate_measurements(tail.iter());
129
130    // MUTATION POINT: < vs == (Line 120)
131    if tail_summary.len < min_count.into() {
132        let number_measurements = tail_summary.len;
133        // MUTATION POINT: > vs < (Line 122)
134        let plural_s = if number_measurements > 1 { "s" } else { "" };
135        error!("Only {number_measurements} measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test.");
136        return Ok(AuditResult {
137            message: format!("⏭️ '{measurement}'\nOnly {number_measurements} measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test."),
138            passed: true,
139        });
140    }
141
142    let direction = get_direction_arrow(head_summary.mean, tail_summary.mean);
143
144    let mut tail_measurements = tail.clone();
145    let tail_median = tail_measurements.median().unwrap_or(0.0);
146
147    let all_measurements = tail.into_iter().chain(iter::once(head)).collect::<Vec<_>>();
148
149    // MUTATION POINT: / vs % (Line 140)
150    let relative_min = all_measurements
151        .iter()
152        .min_by(|a, b| a.partial_cmp(b).unwrap())
153        .unwrap()
154        / tail_median
155        - 1.0;
156    let relative_max = all_measurements
157        .iter()
158        .max_by(|a, b| a.partial_cmp(b).unwrap())
159        .unwrap()
160        / tail_median
161        - 1.0;
162
163    // MUTATION POINT: / vs % (Line 150)
164    let head_relative_deviation = (head / tail_median - 1.0).abs() * 100.0;
165
166    // Check if we have a minimum relative deviation threshold configured
167    let min_relative_deviation = config::audit_min_relative_deviation(measurement);
168    let threshold_applied = min_relative_deviation.is_some();
169
170    // MUTATION POINT: < vs == (Line 156)
171    let passed_due_to_threshold = min_relative_deviation
172        .map(|threshold| head_relative_deviation < threshold)
173        .unwrap_or(false);
174
175    let z_score = head_summary.z_score_with_method(&tail_summary, dispersion_method);
176    let z_score_display = format_z_score_display(z_score);
177
178    let method_name = match dispersion_method {
179        DispersionMethod::StandardDeviation => "stddev",
180        DispersionMethod::MedianAbsoluteDeviation => "mad",
181    };
182
183    let text_summary = format!(
184        "z-score ({method_name}): {direction}{}\nHead: {}\nTail: {}\n [{:+.1}% – {:+.1}%] {}",
185        z_score_display,
186        &head_summary,
187        &tail_summary,
188        (relative_min * 100.0),
189        (relative_max * 100.0),
190        spark(all_measurements.as_slice()),
191    );
192
193    // MUTATION POINT: > vs >= (Line 178)
194    let z_score_exceeds_sigma =
195        head_summary.is_significant(&tail_summary, sigma, dispersion_method);
196
197    // MUTATION POINT: ! removal (Line 181)
198    let passed = !z_score_exceeds_sigma || passed_due_to_threshold;
199
200    // Add threshold information to output if applicable
201    let threshold_note = if threshold_applied && passed_due_to_threshold {
202        format!(
203            "\nNote: Passed due to relative deviation ({:.1}%) being below threshold ({:.1}%)",
204            head_relative_deviation,
205            min_relative_deviation.unwrap()
206        )
207    } else {
208        String::new()
209    };
210
211    // MUTATION POINT: ! removal (Line 194)
212    if !passed {
213        return Ok(AuditResult {
214            message: format!(
215                "❌ '{measurement}'\nHEAD differs significantly from tail measurements.\n{text_summary}{threshold_note}"
216            ),
217            passed: false,
218        });
219    }
220
221    Ok(AuditResult {
222        message: format!("✅ '{measurement}'\n{text_summary}{threshold_note}"),
223        passed: true,
224    })
225}
226
227#[cfg(test)]
228mod test {
229    use super::*;
230
231    #[test]
232    fn test_format_z_score_display() {
233        // Test cases for z-score display formatting
234        let test_cases = vec![
235            (2.5_f64, " 2.50"),
236            (0.0_f64, " 0.00"),
237            (-1.5_f64, " -1.50"),
238            (999.999_f64, " 1000.00"),
239            (0.001_f64, " 0.00"),
240            (f64::INFINITY, ""),
241            (f64::NEG_INFINITY, ""),
242            (f64::NAN, ""),
243        ];
244
245        for (z_score, expected) in test_cases {
246            let result = format_z_score_display(z_score);
247            assert_eq!(result, expected, "Failed for z_score: {}", z_score);
248        }
249    }
250
251    #[test]
252    fn test_direction_arrows() {
253        // Test cases for direction arrow logic
254        let test_cases = vec![
255            (5.0_f64, 3.0_f64, "↑"), // head > tail
256            (1.0_f64, 3.0_f64, "↓"), // head < tail
257            (3.0_f64, 3.0_f64, "→"), // head == tail
258        ];
259
260        for (head_mean, tail_mean, expected) in test_cases {
261            let result = get_direction_arrow(head_mean, tail_mean);
262            assert_eq!(
263                result, expected,
264                "Failed for head_mean: {}, tail_mean: {}",
265                head_mean, tail_mean
266            );
267        }
268    }
269
270    #[test]
271    fn test_audit_with_different_dispersion_methods() {
272        // Test that audit produces different results with different dispersion methods
273
274        // Create mock data that would produce different z-scores with stddev vs MAD
275        let head_value = 35.0;
276        let tail_values = [30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
277
278        let head_summary = stats::aggregate_measurements(std::iter::once(&head_value));
279        let tail_summary = stats::aggregate_measurements(tail_values.iter());
280
281        // Calculate z-scores with both methods
282        let z_score_stddev =
283            head_summary.z_score_with_method(&tail_summary, DispersionMethod::StandardDeviation);
284        let z_score_mad = head_summary
285            .z_score_with_method(&tail_summary, DispersionMethod::MedianAbsoluteDeviation);
286
287        // With the outlier (100.0), stddev should be much larger than MAD
288        // So z-score with stddev should be smaller than z-score with MAD
289        assert!(
290            z_score_stddev < z_score_mad,
291            "stddev z-score ({}) should be smaller than MAD z-score ({}) with outlier data",
292            z_score_stddev,
293            z_score_mad
294        );
295
296        // Both should be positive since head > tail mean
297        assert!(z_score_stddev > 0.0);
298        assert!(z_score_mad > 0.0);
299    }
300
301    #[test]
302    fn test_dispersion_method_conversion() {
303        // Test that the conversion from CLI types to stats types works correctly
304
305        // Test stddev conversion
306        let cli_stddev = git_perf_cli_types::DispersionMethod::StandardDeviation;
307        let stats_stddev: DispersionMethod = cli_stddev.into();
308        assert_eq!(stats_stddev, DispersionMethod::StandardDeviation);
309
310        // Test MAD conversion
311        let cli_mad = git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation;
312        let stats_mad: DispersionMethod = cli_mad.into();
313        assert_eq!(stats_mad, DispersionMethod::MedianAbsoluteDeviation);
314    }
315
316    #[test]
317    fn test_audit_multiple_with_no_measurements() {
318        // This test exercises the actual production audit_multiple function
319        // Tests the case where no measurements are provided (empty list)
320        let result = audit_multiple(
321            &[], // Empty measurements list
322            100,
323            1,
324            &[],
325            ReductionFunc::Mean,
326            2.0,
327            DispersionMethod::StandardDeviation,
328        );
329
330        // Should succeed when no measurements need to be audited
331        assert!(
332            result.is_ok(),
333            "audit_multiple should succeed with empty measurement list"
334        );
335    }
336
337    // MUTATION TESTING COVERAGE TESTS - Exercise actual production code paths
338
339    #[test]
340    fn test_min_count_boundary_condition() {
341        // COVERS MUTATION: tail_summary.len < min_count.into() vs ==
342        // Test with exactly min_count measurements (should NOT skip)
343        let result = audit_with_data(
344            "test_measurement",
345            15.0,
346            vec![10.0, 11.0, 12.0], // Exactly 3 measurements
347            3,                      // min_count = 3
348            2.0,
349            DispersionMethod::StandardDeviation,
350        );
351
352        assert!(result.is_ok());
353        let audit_result = result.unwrap();
354        // Should NOT be skipped (would be skipped if < was changed to ==)
355        assert!(!audit_result.message.contains("Skipping test"));
356
357        // Test with fewer than min_count (should skip)
358        let result = audit_with_data(
359            "test_measurement",
360            15.0,
361            vec![10.0, 11.0], // Only 2 measurements
362            3,                // min_count = 3
363            2.0,
364            DispersionMethod::StandardDeviation,
365        );
366
367        assert!(result.is_ok());
368        let audit_result = result.unwrap();
369        assert!(audit_result.message.contains("Skipping test"));
370        assert!(audit_result.passed); // Skipped tests are marked as passed
371    }
372
373    #[test]
374    fn test_pluralization_logic() {
375        // COVERS MUTATION: number_measurements > 1 vs <
376        // Test with 0 measurements (no 's')
377        let result = audit_with_data(
378            "test_measurement",
379            15.0,
380            vec![], // 0 measurements
381            5,      // min_count > 0 to trigger skip
382            2.0,
383            DispersionMethod::StandardDeviation,
384        );
385
386        assert!(result.is_ok());
387        let message = result.unwrap().message;
388        assert!(message.contains("0 measurement found")); // No 's'
389        assert!(!message.contains("0 measurements found")); // Should not have 's'
390
391        // Test with 1 measurement (no 's')
392        let result = audit_with_data(
393            "test_measurement",
394            15.0,
395            vec![10.0], // 1 measurement
396            5,          // min_count > 1 to trigger skip
397            2.0,
398            DispersionMethod::StandardDeviation,
399        );
400
401        assert!(result.is_ok());
402        let message = result.unwrap().message;
403        assert!(message.contains("1 measurement found")); // No 's'
404
405        // Test with 2+ measurements (should have 's')
406        let result = audit_with_data(
407            "test_measurement",
408            15.0,
409            vec![10.0, 11.0], // 2 measurements
410            5,                // min_count > 2 to trigger skip
411            2.0,
412            DispersionMethod::StandardDeviation,
413        );
414
415        assert!(result.is_ok());
416        let message = result.unwrap().message;
417        assert!(message.contains("2 measurements found")); // Has 's'
418    }
419
420    #[test]
421    fn test_relative_calculations_division_vs_modulo() {
422        // COVERS MUTATIONS: / vs % in relative_min, relative_max, head_relative_deviation
423        // Use values where division and modulo produce very different results
424        let result = audit_with_data(
425            "test_measurement",
426            25.0,                   // head
427            vec![10.0, 10.0, 10.0], // tail, median = 10.0
428            1,
429            10.0, // High sigma to avoid z-score failures
430            DispersionMethod::StandardDeviation,
431        );
432
433        assert!(result.is_ok());
434        let audit_result = result.unwrap();
435
436        // With division:
437        // - relative_min = (10.0 / 10.0 - 1.0) * 100 = 0.0%
438        // - relative_max = (25.0 / 10.0 - 1.0) * 100 = 150.0%
439        // With modulo:
440        // - relative_min = (10.0 % 10.0 - 1.0) * 100 = -100.0% (since 10.0 % 10.0 = 0.0)
441        // - relative_max = (25.0 % 10.0 - 1.0) * 100 = -50.0% (since 25.0 % 10.0 = 5.0)
442
443        // Check that the calculation uses division, not modulo
444        // The range should show [+0.0% – +150.0%], not [-100.0% – -50.0%]
445        assert!(audit_result.message.contains("[+0.0% – +150.0%]"));
446
447        // Ensure the modulo results are NOT present
448        assert!(!audit_result.message.contains("[-100.0% – -50.0%]"));
449        assert!(!audit_result.message.contains("-100.0%"));
450        assert!(!audit_result.message.contains("-50.0%"));
451    }
452
453    #[test]
454    fn test_core_pass_fail_logic() {
455        // COVERS MUTATION: !z_score_exceeds_sigma || passed_due_to_threshold
456        // vs z_score_exceeds_sigma || passed_due_to_threshold
457
458        // Case 1: z_score exceeds sigma, no threshold bypass (should fail)
459        let result = audit_with_data(
460            "test_measurement",                 // No config threshold for this name
461            100.0,                              // Very high head value
462            vec![10.0, 10.0, 10.0, 10.0, 10.0], // Low tail values
463            1,
464            0.5, // Low sigma threshold
465            DispersionMethod::StandardDeviation,
466        );
467
468        assert!(result.is_ok());
469        let audit_result = result.unwrap();
470        assert!(!audit_result.passed); // Should fail
471        assert!(audit_result.message.contains("❌"));
472
473        // Case 2: z_score within sigma (should pass)
474        let result = audit_with_data(
475            "test_measurement",
476            10.2,                               // Close to tail values
477            vec![10.0, 10.1, 10.0, 10.1, 10.0], // Some variance to avoid zero stddev
478            1,
479            100.0, // Very high sigma threshold
480            DispersionMethod::StandardDeviation,
481        );
482
483        assert!(result.is_ok());
484        let audit_result = result.unwrap();
485        assert!(audit_result.passed); // Should pass
486        assert!(audit_result.message.contains("✅"));
487    }
488
489    #[test]
490    fn test_final_result_logic() {
491        // COVERS MUTATION: if !passed vs if passed
492        // This tests the final branch that determines success vs failure message
493
494        // Test failing case (should get failure message)
495        let result = audit_with_data(
496            "test_measurement",
497            1000.0, // Extreme outlier
498            vec![10.0, 10.0, 10.0, 10.0, 10.0],
499            1,
500            0.1, // Very strict sigma
501            DispersionMethod::StandardDeviation,
502        );
503
504        assert!(result.is_ok());
505        let audit_result = result.unwrap();
506        assert!(!audit_result.passed);
507        assert!(audit_result.message.contains("❌"));
508        assert!(audit_result.message.contains("differs significantly"));
509
510        // Test passing case (should get success message)
511        let result = audit_with_data(
512            "test_measurement",
513            10.01,                              // Very close to tail
514            vec![10.0, 10.1, 10.0, 10.1, 10.0], // Varied values to avoid zero variance
515            1,
516            100.0, // Very lenient sigma
517            DispersionMethod::StandardDeviation,
518        );
519
520        assert!(result.is_ok());
521        let audit_result = result.unwrap();
522        assert!(audit_result.passed);
523        assert!(audit_result.message.contains("✅"));
524        assert!(!audit_result.message.contains("differs significantly"));
525    }
526
527    #[test]
528    fn test_dispersion_methods_produce_different_results() {
529        // Test that different dispersion methods work in the production code
530        let head = 35.0;
531        let tail = vec![30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
532
533        let result_stddev = audit_with_data(
534            "test_measurement",
535            head,
536            tail.clone(),
537            1,
538            2.0,
539            DispersionMethod::StandardDeviation,
540        );
541
542        let result_mad = audit_with_data(
543            "test_measurement",
544            head,
545            tail,
546            1,
547            2.0,
548            DispersionMethod::MedianAbsoluteDeviation,
549        );
550
551        assert!(result_stddev.is_ok());
552        assert!(result_mad.is_ok());
553
554        let stddev_result = result_stddev.unwrap();
555        let mad_result = result_mad.unwrap();
556
557        // Both should contain method indicators
558        assert!(stddev_result.message.contains("stddev"));
559        assert!(mad_result.message.contains("mad"));
560    }
561}