## 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
    }