Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/realizar/src/stats.rs
Line
Count
Source
1
//! Statistical analysis for A/B testing with log-normal latency support
2
//!
3
//! Per Box et al. (2005), latency distributions are often log-normal.
4
//! This module provides log-transform and non-parametric tests.
5
//!
6
//! ## Features
7
//!
8
//! - **Welch's t-test**: Standard parametric comparison
9
//! - **Log-transformed t-test**: For log-normal latency data
10
//! - **Mann-Whitney U test**: Non-parametric test per Box et al. (2005)
11
//! - **Automatic test selection**: Based on sample size and skewness
12
//!
13
//! ## Citations
14
//!
15
//! - Box, G. E. P., Hunter, J. S., & Hunter, W. G. (2005).
16
//!   *Statistics for Experimenters*. Wiley-Interscience.
17
//! - Welch, B. L. (1947). "The Generalization of 'Student's' Problem."
18
//!   *Biometrika*, 34(1-2), 28-35.
19
20
#![allow(clippy::cast_precision_loss)] // Statistical functions need usize->f64
21
22
/// Configuration for experiment analysis
23
#[derive(Debug, Clone)]
24
pub struct AnalysisConfig {
25
    /// Significance level (default 0.05)
26
    pub alpha: f64,
27
    /// Whether to auto-detect skewness
28
    pub auto_detect_skew: bool,
29
}
30
31
impl Default for AnalysisConfig {
32
2
    fn default() -> Self {
33
2
        Self {
34
2
            alpha: 0.05,
35
2
            auto_detect_skew: true,
36
2
        }
37
2
    }
38
}
39
40
/// Result of statistical analysis
41
#[derive(Debug, Clone)]
42
pub struct AnalysisResult {
43
    /// Control group mean (or geometric mean if log-transformed)
44
    pub control_mean: f64,
45
    /// Treatment group mean
46
    pub treatment_mean: f64,
47
    /// Effect size (relative change)
48
    pub effect_size: f64,
49
    /// P-value for the test
50
    pub p_value: f64,
51
    /// Whether result is statistically significant
52
    pub significant: bool,
53
    /// Test method used
54
    pub method: TestMethod,
55
}
56
57
/// Statistical test method used
58
#[derive(Debug, Clone, PartialEq, Eq)]
59
pub enum TestMethod {
60
    /// Standard t-test (normal data)
61
    TTest,
62
    /// Log-transformed t-test (log-normal data)
63
    LogTransformTTest,
64
    /// Mann-Whitney U test (non-parametric)
65
    MannWhitneyU,
66
}
67
68
/// Analyze experiment results with automatic distribution detection
69
#[must_use]
70
2
pub fn analyze(control: &[f64], treatment: &[f64], config: &AnalysisConfig) -> AnalysisResult {
71
2
    let skewness = compute_skewness(control);
72
73
    // Auto-detect: use log-transform for highly skewed data (latency)
74
2
    let use_log = config.auto_detect_skew && skewness.abs() > 1.0;
75
76
2
    if use_log {
77
1
        analyze_log_transform(control, treatment, config.alpha)
78
    } else {
79
1
        analyze_t_test(control, treatment, config.alpha)
80
    }
81
2
}
82
83
/// Log-transformed t-test for log-normal latency data
84
#[must_use]
85
3
pub fn analyze_log_transform(control: &[f64], treatment: &[f64], alpha: f64) -> AnalysisResult {
86
    // Transform to log space
87
30
    let 
log_control3
:
Vec<f64>3
=
control3
.
iter3
().
map3
(|x| x.ln()).
collect3
();
88
30
    let 
log_treatment3
:
Vec<f64>3
=
treatment3
.
iter3
().
map3
(|x| x.ln()).
collect3
();
89
90
3
    let result = analyze_t_test(&log_control, &log_treatment, alpha);
91
92
    // Convert means back (geometric mean)
93
3
    AnalysisResult {
94
3
        control_mean: result.control_mean.exp(),
95
3
        treatment_mean: result.treatment_mean.exp(),
96
3
        effect_size: result.treatment_mean.exp() / result.control_mean.exp() - 1.0,
97
3
        p_value: result.p_value,
98
3
        significant: result.significant,
99
3
        method: TestMethod::LogTransformTTest,
100
3
    }
101
3
}
102
103
/// Standard Welch's t-test
104
#[must_use]
105
6
pub fn analyze_t_test(control: &[f64], treatment: &[f64], alpha: f64) -> AnalysisResult {
106
6
    let n1 = control.len() as f64;
107
6
    let n2 = treatment.len() as f64;
108
109
6
    let mean1 = control.iter().sum::<f64>() / n1;
110
6
    let mean2 = treatment.iter().sum::<f64>() / n2;
111
112
45
    let 
var16
=
control6
.
iter6
().
map6
(|x| (x - mean1).powi(2)).
sum6
::<f64>() /
(n1 - 1.0)6
;
113
45
    let 
var26
=
treatment6
.
iter6
().
map6
(|x| (x - mean2).powi(2)).
sum6
::<f64>() /
(n2 - 1.0)6
;
114
115
6
    let se = (var1 / n1 + var2 / n2).sqrt();
116
6
    let t_stat = (mean2 - mean1) / se;
117
118
    // Approximate p-value using normal distribution (valid for large n)
119
6
    let p_value = 2.0 * (1.0 - normal_cdf(t_stat.abs()));
120
121
6
    AnalysisResult {
122
6
        control_mean: mean1,
123
6
        treatment_mean: mean2,
124
6
        effect_size: (mean2 - mean1) / mean1,
125
6
        p_value,
126
6
        significant: p_value < alpha,
127
6
        method: TestMethod::TTest,
128
6
    }
129
6
}
130
131
/// Compute skewness of a distribution
132
5
fn compute_skewness(data: &[f64]) -> f64 {
133
5
    let n = data.len() as f64;
134
5
    let mean = data.iter().sum::<f64>() / n;
135
40
    let 
std_dev5
= (
data5
.
iter5
().
map5
(|x| (x - mean).powi(2)).
sum5
::<f64>() /
n5
).
sqrt5
();
136
137
5
    if std_dev < 1e-10 {
138
0
        return 0.0;
139
5
    }
140
141
5
    let m3 = data
142
5
        .iter()
143
40
        .
map5
(|x| ((x - mean) / std_dev).powi(3))
144
5
        .sum::<f64>()
145
5
        / n;
146
5
    m3
147
5
}
148
149
// ============================================================================
150
// Mann-Whitney U Test (Box et al. 2005)
151
// ============================================================================
152
153
/// Effect size interpretation per Cohen's conventions
154
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155
pub enum EffectSizeInterpretation {
156
    /// |r| < 0.1 - trivial effect
157
    Negligible,
158
    /// 0.1 <= |r| < 0.3 - small effect
159
    Small,
160
    /// 0.3 <= |r| < 0.5 - medium effect
161
    Medium,
162
    /// |r| >= 0.5 - large effect
163
    Large,
164
}
165
166
/// Result of Mann-Whitney U test
167
#[derive(Debug, Clone)]
168
pub struct MannWhitneyResult {
169
    /// The U statistic (minimum of U1 and U2)
170
    pub u_statistic: f64,
171
    /// Z-score for normal approximation
172
    pub z_score: f64,
173
    /// P-value (two-tailed)
174
    pub p_value: f64,
175
    /// Whether result is significant at alpha=0.05
176
    pub significant: bool,
177
    /// Effect size (rank-biserial correlation)
178
    pub effect_size: f64,
179
    /// Interpretation of effect size
180
    pub effect_interpretation: EffectSizeInterpretation,
181
    /// Test method identifier
182
    pub method: TestMethod,
183
}
184
185
/// Mann-Whitney U test for non-parametric comparison
186
///
187
/// Also known as Wilcoxon rank-sum test. Compares two independent samples
188
/// without assuming normality. Preferred when:
189
/// - Distribution is heavily skewed (skewness > 2)
190
/// - Sample sizes are small (n < 15)
191
/// - Outliers are present and meaningful
192
///
193
/// ## Algorithm
194
///
195
/// 1. Combine and rank all observations
196
/// 2. Handle ties by assigning average ranks
197
/// 3. Compute U statistic from rank sums
198
/// 4. Use normal approximation for p-value
199
///
200
/// ## Citation
201
///
202
/// Box, G. E. P., Hunter, J. S., & Hunter, W. G. (2005).
203
/// *Statistics for Experimenters*. Wiley-Interscience.
204
#[must_use]
205
6
pub fn mann_whitney_u(control: &[f64], treatment: &[f64]) -> MannWhitneyResult {
206
6
    let n1 = control.len();
207
6
    let n2 = treatment.len();
208
209
    // Combine samples with group labels
210
6
    let mut combined: Vec<(f64, usize)> = control
211
6
        .iter()
212
28
        .
map6
(|&x| (x, 0)) // Group 0 = control
213
28
        .
chain6
(
treatment6
.
iter6
().
map6
(|&x| (x, 1))) // Group 1 = treatment
214
6
        .collect();
215
216
    // Sort by value
217
80
    
combined6
.
sort_by6
(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
218
219
    // Assign ranks with tie handling
220
6
    let ranks = assign_ranks_with_ties(&combined);
221
222
    // Sum ranks for control group
223
6
    let r1: f64 = ranks
224
6
        .iter()
225
56
        .
filter6
(|(_, group)| *group == 0)
226
6
        .map(|(rank, _)| rank)
227
6
        .sum();
228
229
    // Calculate U statistics
230
6
    let u1 = r1 - (n1 * (n1 + 1)) as f64 / 2.0;
231
6
    let u2 = (n1 * n2) as f64 - u1;
232
6
    let u_statistic = u1.min(u2);
233
234
    // Normal approximation (valid for n1, n2 >= 5)
235
6
    let mu = (n1 * n2) as f64 / 2.0;
236
6
    let sigma = ((n1 * n2 * (n1 + n2 + 1)) as f64 / 12.0).sqrt();
237
238
6
    let z_score = if sigma > 0.0 {
239
6
        (u_statistic - mu) / sigma
240
    } else {
241
0
        0.0
242
    };
243
244
    // Two-tailed p-value
245
6
    let p_value = 2.0 * (1.0 - normal_cdf(z_score.abs()));
246
247
    // Effect size: rank-biserial correlation
248
    // r = 1 - (2U)/(n1*n2)
249
6
    let effect_size = 1.0 - (2.0 * u_statistic) / (n1 * n2) as f64;
250
251
6
    let effect_interpretation = interpret_effect_size(effect_size.abs());
252
253
6
    MannWhitneyResult {
254
6
        u_statistic,
255
6
        z_score,
256
6
        p_value,
257
6
        significant: p_value < 0.05,
258
6
        effect_size,
259
6
        effect_interpretation,
260
6
        method: TestMethod::MannWhitneyU,
261
6
    }
262
6
}
263
264
/// Assign ranks to sorted values, handling ties by averaging
265
6
fn assign_ranks_with_ties(sorted: &[(f64, usize)]) -> Vec<(f64, usize)> {
266
6
    let mut ranks = Vec::with_capacity(sorted.len());
267
6
    let mut i = 0;
268
269
48
    while i < sorted.len() {
270
42
        let value = sorted[i].0;
271
42
        let mut j = i;
272
273
        // Find extent of tie
274
98
        while j < sorted.len() && 
(sorted[j].0 - value).abs() < 1e-1092
{
275
56
            j += 1;
276
56
        }
277
278
        // Average rank for tied values
279
        // Ranks are 1-indexed: positions i..j get ranks (i+1)..(j+1)
280
56
        let 
avg_rank42
:
f6442
= (
(i + 1)..=(j)42
).
map42
(|r| r as f64).
sum42
::<f64>() /
(j - i) as f6442
;
281
282
        // Iterate over the slice instead of using index
283
56
        for item in 
sorted42
.
iter42
().
take42
(
j42
).
skip42
(
i42
) {
284
56
            ranks.push((avg_rank, item.1));
285
56
        }
286
287
42
        i = j;
288
    }
289
290
6
    ranks
291
6
}
292
293
/// Interpret effect size using Cohen's conventions
294
6
fn interpret_effect_size(r: f64) -> EffectSizeInterpretation {
295
6
    if r < 0.1 {
296
1
        EffectSizeInterpretation::Negligible
297
5
    } else if r < 0.3 {
298
1
        EffectSizeInterpretation::Small
299
4
    } else if r < 0.5 {
300
2
        EffectSizeInterpretation::Medium
301
    } else {
302
2
        EffectSizeInterpretation::Large
303
    }
304
6
}
305
306
// ============================================================================
307
// Automatic Test Selection (per Gemini review)
308
// ============================================================================
309
310
/// Minimum sample size for parametric tests
311
const MIN_PARAMETRIC_SAMPLE_SIZE: usize = 15;
312
313
/// Skewness threshold for log-transform
314
const SKEWNESS_THRESHOLD: f64 = 1.0;
315
316
/// Analyze with automatic test selection based on data characteristics
317
///
318
/// Selection criteria (per Box et al. 2005 recommendations):
319
/// - Small samples (n < 15): Mann-Whitney U
320
/// - Highly skewed (|skewness| > 1): Log-transform if possible, else Mann-Whitney
321
/// - Normal-ish data: Welch's t-test
322
#[must_use]
323
2
pub fn analyze_with_auto_select(
324
2
    control: &[f64],
325
2
    treatment: &[f64],
326
2
    config: &AnalysisConfig,
327
2
) -> AnalysisResult {
328
2
    let min_n = control.len().min(treatment.len());
329
330
    // Small samples: always use non-parametric
331
2
    if min_n < MIN_PARAMETRIC_SAMPLE_SIZE {
332
1
        let mw = mann_whitney_u(control, treatment);
333
1
        return AnalysisResult {
334
1
            control_mean: median(control),
335
1
            treatment_mean: median(treatment),
336
1
            effect_size: mw.effect_size,
337
1
            p_value: mw.p_value,
338
1
            significant: mw.significant,
339
1
            method: TestMethod::MannWhitneyU,
340
1
        };
341
1
    }
342
343
    // Check skewness
344
1
    let skewness = compute_skewness(control);
345
346
1
    if config.auto_detect_skew && skewness.abs() > SKEWNESS_THRESHOLD {
347
        // Check if all values are positive (required for log-transform)
348
20
        let 
all_positive1
=
control.iter()1
.
all1
(|&x| x > 0.0) &&
treatment.iter()1
.
all1
(|&x| x > 0.0);
349
350
1
        if all_positive {
351
1
            analyze_log_transform(control, treatment, config.alpha)
352
        } else {
353
            // Can't log-transform, use Mann-Whitney
354
0
            let mw = mann_whitney_u(control, treatment);
355
0
            AnalysisResult {
356
0
                control_mean: median(control),
357
0
                treatment_mean: median(treatment),
358
0
                effect_size: mw.effect_size,
359
0
                p_value: mw.p_value,
360
0
                significant: mw.significant,
361
0
                method: TestMethod::MannWhitneyU,
362
0
            }
363
        }
364
    } else {
365
0
        analyze_t_test(control, treatment, config.alpha)
366
    }
367
2
}
368
369
/// Calculate median of a slice
370
2
fn median(data: &[f64]) -> f64 {
371
2
    let mut sorted = data.to_vec();
372
8
    
sorted2
.
sort_by2
(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
373
374
2
    let n = sorted.len();
375
2
    if n == 0 {
376
0
        return 0.0;
377
2
    }
378
379
2
    if n.is_multiple_of(2) {
380
0
        f64::midpoint(sorted[n / 2 - 1], sorted[n / 2])
381
    } else {
382
2
        sorted[n / 2]
383
    }
384
2
}
385
386
/// Normal CDF approximation (Abramowitz and Stegun)
387
#[allow(clippy::unreadable_literal)] // Standard statistical constants
388
12
fn normal_cdf(x: f64) -> f64 {
389
12
    let a1 = 0.254_829_592;
390
12
    let a2 = -0.284_496_736;
391
12
    let a3 = 1.421_413_741;
392
12
    let a4 = -1.453_152_027;
393
12
    let a5 = 1.061_405_429;
394
12
    let p = 0.327_591;
395
396
12
    let sign = if x < 0.0 { 
-1.00
} else { 1.0 };
397
12
    let x = x.abs() / std::f64::consts::SQRT_2;
398
399
12
    let t = 1.0 / (1.0 + p * x);
400
12
    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
401
402
12
    0.5 * (1.0 + sign * y)
403
12
}
404
405
#[cfg(test)]
406
mod tests {
407
    use super::*;
408
409
    // ========================================================================
410
    // Mann-Whitney U Test (Non-parametric, per Box et al. 2005)
411
    // ========================================================================
412
413
    #[test]
414
1
    fn test_mann_whitney_identical_samples() {
415
1
        let control = vec![1.0, 2.0, 3.0, 4.0, 5.0];
416
1
        let treatment = vec![1.0, 2.0, 3.0, 4.0, 5.0];
417
418
1
        let result = mann_whitney_u(&control, &treatment);
419
420
        // Identical samples should have no significant difference
421
1
        assert!(!result.significant);
422
1
        assert!(result.effect_size.abs() < 0.1); // Negligible effect
423
1
    }
424
425
    #[test]
426
1
    fn test_mann_whitney_completely_separated() {
427
1
        let control = vec![1.0, 2.0, 3.0, 4.0, 5.0];
428
1
        let treatment = vec![10.0, 11.0, 12.0, 13.0, 14.0];
429
430
1
        let result = mann_whitney_u(&control, &treatment);
431
432
        // Completely separated should be highly significant
433
1
        assert!(result.significant);
434
1
        assert!(result.effect_size.abs() > 0.8); // Large effect
435
1
        assert_eq!(result.u_statistic, 0.0); // No overlap
436
1
    }
437
438
    #[test]
439
1
    fn test_mann_whitney_handles_ties() {
440
1
        let control = vec![1.0, 2.0, 2.0, 3.0, 3.0];
441
1
        let treatment = vec![2.0, 2.0, 3.0, 4.0, 5.0];
442
443
1
        let result = mann_whitney_u(&control, &treatment);
444
445
        // Should handle ties correctly (average ranks)
446
1
        assert!(result.p_value > 0.0 && result.p_value <= 1.0);
447
1
    }
448
449
    #[test]
450
1
    fn test_mann_whitney_effect_size_interpretation() {
451
        // Small effect
452
1
        let control = vec![1.0, 2.0, 3.0, 4.0, 5.0];
453
1
        let treatment = vec![1.5, 2.5, 3.5, 4.5, 5.5];
454
1
        let result = mann_whitney_u(&control, &treatment);
455
1
        assert!(
matches!0
(
456
1
            result.effect_interpretation,
457
            EffectSizeInterpretation::Small | EffectSizeInterpretation::Negligible
458
        ));
459
1
    }
460
461
    #[test]
462
1
    fn test_mann_whitney_returns_correct_method() {
463
1
        let control = vec![1.0, 2.0, 3.0];
464
1
        let treatment = vec![4.0, 5.0, 6.0];
465
1
        let result = mann_whitney_u(&control, &treatment);
466
1
        assert_eq!(result.method, TestMethod::MannWhitneyU);
467
1
    }
468
469
    // ========================================================================
470
    // Auto Test Selection (per Gemini review recommendation)
471
    // ========================================================================
472
473
    #[test]
474
1
    fn test_auto_select_uses_mann_whitney_for_small_samples() {
475
        // Small samples (n < 15) should use non-parametric
476
1
        let control = vec![1.0, 2.0, 3.0, 4.0, 5.0];
477
1
        let treatment = vec![2.0, 3.0, 4.0, 5.0, 6.0];
478
1
        let config = AnalysisConfig {
479
1
            alpha: 0.05,
480
1
            auto_detect_skew: true,
481
1
        };
482
483
1
        let result = analyze_with_auto_select(&control, &treatment, &config);
484
485
        // Small samples should trigger Mann-Whitney
486
1
        assert_eq!(result.method, TestMethod::MannWhitneyU);
487
1
    }
488
489
    #[test]
490
1
    fn test_auto_select_uses_log_transform_for_latency_like_data() {
491
        // Generate log-normal-ish data (typical latency distribution)
492
1
        let control: Vec<f64> = vec![
493
            10.0, 12.0, 11.0, 15.0, 100.0, 13.0, 14.0, 11.0, 12.0, 10.0, 11.0, 12.0, 13.0, 14.0,
494
            15.0, 16.0, 17.0, 18.0, 19.0, 200.0,
495
        ];
496
1
        let treatment: Vec<f64> = vec![
497
            8.0, 9.0, 10.0, 11.0, 50.0, 9.0, 10.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
498
            8.0, 9.0, 10.0, 11.0, 80.0,
499
        ];
500
501
1
        let config = AnalysisConfig {
502
1
            alpha: 0.05,
503
1
            auto_detect_skew: true,
504
1
        };
505
506
1
        let result = analyze_with_auto_select(&control, &treatment, &config);
507
508
        // Skewed data should use log-transform (if n >= 15) or Mann-Whitney
509
1
        assert!(
matches!0
(
510
1
            result.method,
511
            TestMethod::LogTransformTTest | TestMethod::MannWhitneyU
512
        ));
513
1
    }
514
515
    // ========================================================================
516
    // Original Tests
517
    // ========================================================================
518
519
    #[test]
520
1
    fn test_t_test_no_difference() {
521
1
        let control = vec![1.0, 2.0, 3.0, 4.0, 5.0];
522
1
        let treatment = vec![1.1, 2.1, 3.1, 4.1, 5.1];
523
1
        let result = analyze_t_test(&control, &treatment, 0.05);
524
1
        assert!(!result.significant); // Small effect, not significant
525
1
    }
526
527
    #[test]
528
1
    fn test_t_test_significant_difference() {
529
1
        let control = vec![1.0, 2.0, 3.0, 4.0, 5.0];
530
1
        let treatment = vec![10.0, 11.0, 12.0, 13.0, 14.0];
531
1
        let result = analyze_t_test(&control, &treatment, 0.05);
532
1
        assert!(result.significant); // Large effect
533
1
    }
534
535
    #[test]
536
1
    fn test_log_transform_latency() {
537
        // Simulate log-normal latency (ms)
538
1
        let control = vec![10.0, 12.0, 15.0, 100.0, 11.0]; // Has outlier
539
1
        let treatment = vec![8.0, 9.0, 10.0, 50.0, 8.5];
540
1
        let result = analyze_log_transform(&control, &treatment, 0.05);
541
1
        assert!(result.treatment_mean < result.control_mean);
542
1
        assert_eq!(result.method, TestMethod::LogTransformTTest);
543
1
    }
544
545
    #[test]
546
1
    fn test_auto_detect_skewness() {
547
        // Highly skewed data should use log-transform
548
1
        let control = vec![1.0, 1.1, 1.2, 1.3, 100.0]; // Skewed
549
1
        let treatment = vec![1.0, 1.1, 1.2, 1.3, 1.4];
550
1
        let config = AnalysisConfig::default();
551
1
        let result = analyze(&control, &treatment, &config);
552
1
        assert_eq!(result.method, TestMethod::LogTransformTTest);
553
1
    }
554
555
    #[test]
556
1
    fn test_normal_data_uses_t_test() {
557
        // Symmetric data should use t-test
558
1
        let control = vec![1.0, 2.0, 3.0, 4.0, 5.0];
559
1
        let treatment = vec![2.0, 3.0, 4.0, 5.0, 6.0];
560
1
        let config = AnalysisConfig::default();
561
1
        let result = analyze(&control, &treatment, &config);
562
1
        assert_eq!(result.method, TestMethod::TTest);
563
1
    }
564
565
    #[test]
566
1
    fn test_skewness_calculation() {
567
        // Symmetric data has ~0 skewness
568
1
        let symmetric = vec![1.0, 2.0, 3.0, 4.0, 5.0];
569
1
        assert!(compute_skewness(&symmetric).abs() < 0.5);
570
571
        // Right-skewed data has positive skewness
572
1
        let skewed = vec![1.0, 1.0, 1.0, 1.0, 100.0];
573
1
        assert!(compute_skewness(&skewed) > 1.0);
574
1
    }
575
}