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