Skip to main content

u_analytics/
testing.rs

1//! Hypothesis testing.
2//!
3//! Parametric and non-parametric statistical tests: t-tests, ANOVA,
4//! chi-squared tests, and normality tests.
5//!
6//! # Examples
7//!
8//! ```
9//! use u_analytics::testing::{one_sample_t_test, TestResult};
10//!
11//! let data = [5.1, 4.9, 5.2, 5.0, 4.8, 5.3, 5.1, 4.9];
12//! let result = one_sample_t_test(&data, 5.0).unwrap();
13//! assert!(result.p_value > 0.05); // cannot reject H₀: μ = 5.0
14//! ```
15
16use u_numflow::special;
17use u_numflow::stats;
18
19/// Result of a hypothesis test.
20#[derive(Debug, Clone, Copy)]
21pub struct TestResult {
22    /// Test statistic (t, F, χ², or z depending on test).
23    pub statistic: f64,
24    /// Degrees of freedom (may be fractional for Welch).
25    pub df: f64,
26    /// Two-tailed p-value.
27    pub p_value: f64,
28}
29
30// ---------------------------------------------------------------------------
31// t-tests
32// ---------------------------------------------------------------------------
33
34/// One-sample t-test: H₀: μ = μ₀.
35///
36/// # Algorithm
37///
38/// t = (x̄ - μ₀) / (s / √n), df = n-1.
39///
40/// # Returns
41///
42/// `None` if fewer than 2 observations or non-finite values.
43///
44/// # Examples
45///
46/// ```
47/// use u_analytics::testing::one_sample_t_test;
48///
49/// let data = [2.0, 4.0, 6.0, 8.0, 10.0];
50/// let r = one_sample_t_test(&data, 6.0).unwrap();
51/// assert!(r.p_value > 0.5); // mean is 6.0
52/// ```
53pub fn one_sample_t_test(data: &[f64], mu0: f64) -> Option<TestResult> {
54    let n = data.len();
55    if n < 2 {
56        return None;
57    }
58    if data.iter().any(|v| !v.is_finite()) || !mu0.is_finite() {
59        return None;
60    }
61
62    let mean = stats::mean(data)?;
63    let sd = stats::std_dev(data)?;
64
65    if sd < 1e-300 {
66        return None; // zero variance
67    }
68
69    let t = (mean - mu0) / (sd / (n as f64).sqrt());
70    let df = (n - 1) as f64;
71    let p_value = 2.0 * (1.0 - special::t_distribution_cdf(t.abs(), df));
72
73    Some(TestResult {
74        statistic: t,
75        df,
76        p_value,
77    })
78}
79
80/// Two-sample Welch t-test: H₀: μ₁ = μ₂ (unequal variances).
81///
82/// # Algorithm
83///
84/// t = (x̄₁ - x̄₂) / √(s₁²/n₁ + s₂²/n₂)
85/// df = Welch-Satterthwaite approximation.
86///
87/// # Returns
88///
89/// `None` if either sample has fewer than 2 observations.
90///
91/// # References
92///
93/// Welch (1947). "The generalization of Student's problem when several
94/// different population variances are involved". Biometrika, 34, 28–35.
95///
96/// # Examples
97///
98/// ```
99/// use u_analytics::testing::two_sample_t_test;
100///
101/// let a = [5.1, 4.9, 5.2, 5.0, 4.8];
102/// let b = [7.1, 6.9, 7.2, 7.0, 6.8];
103/// let r = two_sample_t_test(&a, &b).unwrap();
104/// assert!(r.p_value < 0.01); // means clearly differ
105/// ```
106pub fn two_sample_t_test(a: &[f64], b: &[f64]) -> Option<TestResult> {
107    let n1 = a.len();
108    let n2 = b.len();
109    if n1 < 2 || n2 < 2 {
110        return None;
111    }
112    if a.iter().any(|v| !v.is_finite()) || b.iter().any(|v| !v.is_finite()) {
113        return None;
114    }
115
116    let mean1 = stats::mean(a)?;
117    let mean2 = stats::mean(b)?;
118    let var1 = stats::variance(a)?;
119    let var2 = stats::variance(b)?;
120
121    let n1f = n1 as f64;
122    let n2f = n2 as f64;
123
124    let se_sq = var1 / n1f + var2 / n2f;
125    if se_sq < 1e-300 {
126        return None;
127    }
128
129    let t = (mean1 - mean2) / se_sq.sqrt();
130
131    // Welch-Satterthwaite degrees of freedom
132    let v1 = var1 / n1f;
133    let v2 = var2 / n2f;
134    let df = (v1 + v2).powi(2) / (v1 * v1 / (n1f - 1.0) + v2 * v2 / (n2f - 1.0));
135
136    let p_value = 2.0 * (1.0 - special::t_distribution_cdf(t.abs(), df));
137
138    Some(TestResult {
139        statistic: t,
140        df,
141        p_value,
142    })
143}
144
145/// Paired t-test: H₀: mean difference = 0.
146///
147/// # Algorithm
148///
149/// Computes differences dᵢ = xᵢ - yᵢ, then applies one-sample t-test
150/// with μ₀ = 0.
151///
152/// # Returns
153///
154/// `None` if fewer than 2 pairs, slices differ in length, or non-finite values.
155///
156/// # Examples
157///
158/// ```
159/// use u_analytics::testing::paired_t_test;
160///
161/// let before = [5.0, 6.0, 7.0, 8.0, 9.0];
162/// let after  = [5.5, 6.2, 7.1, 8.3, 9.4];
163/// let r = paired_t_test(&before, &after).unwrap();
164/// assert!(r.statistic < 0.0); // after > before
165/// ```
166pub fn paired_t_test(x: &[f64], y: &[f64]) -> Option<TestResult> {
167    if x.len() != y.len() || x.len() < 2 {
168        return None;
169    }
170
171    let diffs: Vec<f64> = x.iter().zip(y.iter()).map(|(&a, &b)| a - b).collect();
172    one_sample_t_test(&diffs, 0.0)
173}
174
175// ---------------------------------------------------------------------------
176// ANOVA
177// ---------------------------------------------------------------------------
178
179/// Result of one-way ANOVA.
180#[derive(Debug, Clone)]
181pub struct AnovaResult {
182    /// F-statistic.
183    pub f_statistic: f64,
184    /// Degrees of freedom between groups.
185    pub df_between: usize,
186    /// Degrees of freedom within groups.
187    pub df_within: usize,
188    /// p-value.
189    pub p_value: f64,
190    /// Sum of squares between groups.
191    pub ss_between: f64,
192    /// Sum of squares within groups.
193    pub ss_within: f64,
194    /// Mean square between.
195    pub ms_between: f64,
196    /// Mean square within.
197    pub ms_within: f64,
198    /// Group means.
199    pub group_means: Vec<f64>,
200    /// Grand mean.
201    pub grand_mean: f64,
202}
203
204/// One-way ANOVA: H₀: all group means are equal.
205///
206/// # Algorithm
207///
208/// F = MS_between / MS_within where
209/// MS_between = SS_between / (k-1),
210/// MS_within = SS_within / (N-k).
211///
212/// # Returns
213///
214/// `None` if fewer than 2 groups, any group has fewer than 2 observations,
215/// or non-finite values.
216///
217/// # References
218///
219/// Fisher (1925). "Statistical Methods for Research Workers".
220///
221/// # Examples
222///
223/// ```
224/// use u_analytics::testing::one_way_anova;
225///
226/// let group1 = [5.0, 6.0, 7.0, 5.5, 6.5];
227/// let group2 = [8.0, 9.0, 8.5, 9.5, 8.0];
228/// let group3 = [4.0, 3.0, 3.5, 4.5, 4.0];
229/// let r = one_way_anova(&[&group1, &group2, &group3]).unwrap();
230/// assert!(r.p_value < 0.01); // means clearly differ
231/// ```
232pub fn one_way_anova(groups: &[&[f64]]) -> Option<AnovaResult> {
233    let k = groups.len();
234    if k < 2 {
235        return None;
236    }
237
238    for g in groups {
239        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
240            return None;
241        }
242    }
243
244    let total_n: usize = groups.iter().map(|g| g.len()).sum();
245
246    // Grand mean
247    let grand_sum: f64 = groups.iter().flat_map(|g| g.iter()).sum();
248    let grand_mean = grand_sum / total_n as f64;
249
250    // Group means
251    let group_means: Vec<f64> = groups
252        .iter()
253        .map(|g| g.iter().sum::<f64>() / g.len() as f64)
254        .collect();
255
256    // Sum of squares
257    let ss_between: f64 = groups
258        .iter()
259        .zip(group_means.iter())
260        .map(|(g, &gm)| g.len() as f64 * (gm - grand_mean).powi(2))
261        .sum();
262
263    let ss_within: f64 = groups
264        .iter()
265        .zip(group_means.iter())
266        .map(|(g, &gm)| g.iter().map(|&x| (x - gm).powi(2)).sum::<f64>())
267        .sum();
268
269    let df_between = k - 1;
270    let df_within = total_n - k;
271
272    if df_within == 0 {
273        return None;
274    }
275
276    let ms_between = ss_between / df_between as f64;
277    let ms_within = ss_within / df_within as f64;
278
279    let f_statistic = if ms_within > 1e-300 {
280        ms_between / ms_within
281    } else {
282        f64::INFINITY
283    };
284
285    let p_value = if f_statistic.is_infinite() {
286        0.0
287    } else {
288        1.0 - special::f_distribution_cdf(f_statistic, df_between as f64, df_within as f64)
289    };
290
291    Some(AnovaResult {
292        f_statistic,
293        df_between,
294        df_within,
295        p_value,
296        ss_between,
297        ss_within,
298        ms_between,
299        ms_within,
300        group_means,
301        grand_mean,
302    })
303}
304
305// ---------------------------------------------------------------------------
306// Chi-squared tests
307// ---------------------------------------------------------------------------
308
309/// Chi-squared goodness-of-fit test: H₀: observed matches expected distribution.
310///
311/// # Algorithm
312///
313/// χ² = Σ (Oᵢ - Eᵢ)² / Eᵢ, df = k-1.
314///
315/// # Returns
316///
317/// `None` if fewer than 2 categories, any expected frequency ≤ 0, or
318/// slices differ in length.
319///
320/// # Examples
321///
322/// ```
323/// use u_analytics::testing::chi_squared_goodness_of_fit;
324///
325/// let observed = [50.0, 30.0, 20.0];
326/// let expected = [40.0, 35.0, 25.0];
327/// let r = chi_squared_goodness_of_fit(&observed, &expected).unwrap();
328/// assert!(r.statistic > 0.0);
329/// ```
330pub fn chi_squared_goodness_of_fit(observed: &[f64], expected: &[f64]) -> Option<TestResult> {
331    let k = observed.len();
332    if k < 2 || k != expected.len() {
333        return None;
334    }
335
336    for &e in expected {
337        if e <= 0.0 || !e.is_finite() {
338            return None;
339        }
340    }
341    for &o in observed {
342        if o < 0.0 || !o.is_finite() {
343            return None;
344        }
345    }
346
347    let chi2: f64 = observed
348        .iter()
349        .zip(expected.iter())
350        .map(|(&o, &e)| (o - e).powi(2) / e)
351        .sum();
352
353    let df = (k - 1) as f64;
354    let p_value = 1.0 - special::chi_squared_cdf(chi2, df);
355
356    Some(TestResult {
357        statistic: chi2,
358        df,
359        p_value,
360    })
361}
362
363/// Chi-squared test of independence on a contingency table.
364///
365/// # Arguments
366///
367/// * `table` — Flat row-major contingency table (rows × cols observed frequencies).
368/// * `n_rows` — Number of rows.
369/// * `n_cols` — Number of columns.
370///
371/// # Algorithm
372///
373/// Expected: Eᵢⱼ = (row_sumᵢ × col_sumⱼ) / N.
374/// χ² = Σᵢⱼ (Oᵢⱼ - Eᵢⱼ)² / Eᵢⱼ, df = (r-1)(c-1).
375///
376/// # Returns
377///
378/// `None` if fewer than 2 rows or columns, any cell is negative, or
379/// any marginal is zero.
380///
381/// # Examples
382///
383/// ```
384/// use u_analytics::testing::chi_squared_independence;
385///
386/// // 2×2 contingency table
387/// let table = [30.0, 10.0, 20.0, 40.0];
388/// let r = chi_squared_independence(&table, 2, 2).unwrap();
389/// assert!(r.p_value < 0.01);
390/// ```
391pub fn chi_squared_independence(table: &[f64], n_rows: usize, n_cols: usize) -> Option<TestResult> {
392    if n_rows < 2 || n_cols < 2 || table.len() != n_rows * n_cols {
393        return None;
394    }
395
396    for &v in table {
397        if v < 0.0 || !v.is_finite() {
398            return None;
399        }
400    }
401
402    // Row sums and column sums
403    let mut row_sums = vec![0.0; n_rows];
404    let mut col_sums = vec![0.0; n_cols];
405    let mut total = 0.0;
406
407    for i in 0..n_rows {
408        for j in 0..n_cols {
409            let val = table[i * n_cols + j];
410            row_sums[i] += val;
411            col_sums[j] += val;
412            total += val;
413        }
414    }
415
416    if total <= 0.0 {
417        return None;
418    }
419
420    // Check no zero marginals
421    for &r in &row_sums {
422        if r <= 0.0 {
423            return None;
424        }
425    }
426    for &c in &col_sums {
427        if c <= 0.0 {
428            return None;
429        }
430    }
431
432    // Compute chi-squared statistic
433    let mut chi2 = 0.0;
434    for i in 0..n_rows {
435        for j in 0..n_cols {
436            let observed = table[i * n_cols + j];
437            let expected = row_sums[i] * col_sums[j] / total;
438            chi2 += (observed - expected).powi(2) / expected;
439        }
440    }
441
442    let df = ((n_rows - 1) * (n_cols - 1)) as f64;
443    let p_value = 1.0 - special::chi_squared_cdf(chi2, df);
444
445    Some(TestResult {
446        statistic: chi2,
447        df,
448        p_value,
449    })
450}
451
452// ---------------------------------------------------------------------------
453// Normality tests
454// ---------------------------------------------------------------------------
455
456/// Jarque-Bera normality test: H₀: data is normally distributed.
457///
458/// # Algorithm
459///
460/// ```text
461/// JB = (n/6) · [g₁² + g₂²/4],   p = exp(−JB/2)
462/// ```
463///
464/// `g₁` and `g₂` are the **moment** coefficients of skewness and excess
465/// kurtosis (`m₃/m₂^(3/2)` and `m₄/m₂² − 3`), which is what Jarque and Bera
466/// wrote the statistic in and what SciPy, R and statsmodels compute. The
467/// bias-adjusted `G₁`/`G₂` that [`u_numflow::stats::skewness`] and
468/// [`u_numflow::stats::kurtosis`] report -- Excel's `SKEW()`/`KURT()` -- are a
469/// different pair of estimators, and substituting them moves JB by tens of
470/// percent at the sample sizes this test is used at.
471///
472/// `JB ~ χ²(2)` under H₀, and a χ² with two degrees of freedom is an
473/// exponential: its survival function is `exp(−JB/2)` exactly. Taking that
474/// instead of `1 − cdf` keeps the p-value to full relative precision in the
475/// tail, where `1 − cdf` rounds to zero.
476///
477/// # Returns
478///
479/// `None` if fewer than 8 observations or non-finite values.
480///
481/// # References
482///
483/// Jarque & Bera (1987). "A test for normality of observations and
484/// regression residuals". International Statistical Review, 55(2), 163–172.
485///
486/// # Examples
487///
488/// ```
489/// use u_analytics::testing::jarque_bera_test;
490///
491/// // Near-normal data
492/// let data = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
493/// let r = jarque_bera_test(&data).unwrap();
494/// assert!(r.p_value > 0.05); // cannot reject normality
495/// ```
496pub fn jarque_bera_test(data: &[f64]) -> Option<TestResult> {
497    let n = data.len();
498    if n < 8 {
499        return None;
500    }
501    if data.iter().any(|v| !v.is_finite()) {
502        return None;
503    }
504
505    let g1 = stats::skewness_moment(data)?;
506    let g2 = stats::kurtosis_moment(data)?;
507
508    let nf = n as f64;
509    let jb = (nf / 6.0) * (g1 * g1 + g2 * g2 / 4.0);
510    // χ²(2) is Exponential(1/2): sf(x) = exp(−x/2), no cancellation in the tail.
511    let p_value = (-jb / 2.0).exp();
512
513    Some(TestResult {
514        statistic: jb,
515        df: 2.0,
516        p_value,
517    })
518}
519
520/// Result of the Anderson-Darling normality test.
521#[derive(Debug, Clone, Copy)]
522pub struct AndersonDarlingResult {
523    /// The A² test statistic (raw, before sample-size correction).
524    pub statistic: f64,
525    /// The modified statistic A*² = A² × (1 + 0.75/n + 2.25/n²).
526    pub statistic_star: f64,
527    /// The p-value. Small values reject the null hypothesis of normality.
528    pub p_value: f64,
529}
530
531/// Upper-tail p-value for the Anderson-Darling A*² statistic, using the
532/// D'Agostino & Stephens (1986) piecewise approximation.
533///
534/// # Numerical range
535///
536/// The large-A*² branch `exp(1.2937 − 5.709·A*² + 0.0186·A*²²)` is a bounded-range
537/// fit: its positive quadratic term turns the exponent back *upward* past the
538/// vertex A*² = 5.709 / (2·0.0186) ≈ 153.47. Left unguarded, for a strongly
539/// non-normal large sample (A*² in the hundreds) the exponent grows positive, the
540/// exponential overflows, and after `clamp(0, 1)` the p-value flips to exactly
541/// `1.0` ("perfectly normal") while A*² is simultaneously huge and still growing —
542/// an internally inconsistent result. Clamping the evaluation point to the vertex
543/// makes the p-value monotonically non-increasing in A*², plateauing at a tiny
544/// (~5e-190) representable floor instead of jumping to 1.
545///
546/// # References
547///
548/// - D'Agostino & Stephens (1986). "Tests based on EDF statistics". In
549///   *Goodness-of-Fit Techniques*. Marcel Dekker.
550fn ad_upper_tail_pvalue(a2_star: f64) -> f64 {
551    // Vertex of the upper-branch quadratic 1.2937 − 5.709·x + 0.0186·x²; beyond it
552    // the fit is invalid and non-monotone, so evaluation is clamped here.
553    const UPPER_BRANCH_VERTEX: f64 = 153.467_741_935_483_87; // 5.709 / (2 * 0.0186)
554
555    let p = if a2_star >= 0.6 {
556        let x = a2_star.min(UPPER_BRANCH_VERTEX);
557        (1.2937 - 5.709 * x + 0.0186 * x * x).exp()
558    } else if a2_star > 0.34 {
559        (0.9177 - 4.279 * a2_star - 1.38 * a2_star * a2_star).exp()
560    } else if a2_star > 0.2 {
561        1.0 - (-8.318 + 42.796 * a2_star - 59.938 * a2_star * a2_star).exp()
562    } else {
563        1.0 - (-13.436 + 101.14 * a2_star - 223.73 * a2_star * a2_star).exp()
564    };
565    p.clamp(0.0, 1.0)
566}
567
568/// Anderson-Darling normality test: H₀: data is normally distributed.
569///
570/// More sensitive to tail deviations than Kolmogorov-Smirnov.
571///
572/// # Algorithm
573///
574/// 1. Standardize sorted data: zᵢ = (x₍ᵢ₎ - x̄) / s
575/// 2. Compute A² = -n - (1/n) Σᵢ (2i-1) [ln Φ(zᵢ) + ln(1 - Φ(z_{n+1-i}))]
576/// 3. Apply Stephens (1986) correction: A*² = A² (1 + 0.75/n + 2.25/n²)
577/// 4. Compute p-value from piecewise exponential approximation
578///
579/// # Returns
580///
581/// `None` if n < 8, all values identical, or non-finite values.
582///
583/// # References
584///
585/// - Anderson & Darling (1952). "Asymptotic theory of certain goodness of
586///   fit criteria based on stochastic processes". Annals of Mathematical
587///   Statistics, 23(2), 193–212.
588/// - Stephens (1986). "Tests based on EDF statistics". In D'Agostino &
589///   Stephens (Eds.), Goodness-of-Fit Techniques. Marcel Dekker.
590///
591/// # Examples
592///
593/// ```
594/// use u_analytics::testing::anderson_darling_test;
595///
596/// let data = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
597/// let r = anderson_darling_test(&data).unwrap();
598/// assert!(r.p_value > 0.05); // cannot reject normality
599/// ```
600pub fn anderson_darling_test(data: &[f64]) -> Option<AndersonDarlingResult> {
601    let n = data.len();
602    if n < 8 {
603        return None;
604    }
605    if data.iter().any(|v| !v.is_finite()) {
606        return None;
607    }
608
609    let mean = stats::mean(data)?;
610    let sd = stats::std_dev(data)?;
611
612    if sd < 1e-300 {
613        return None; // zero variance
614    }
615
616    // Sort data
617    let mut x: Vec<f64> = data.to_vec();
618    x.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
619
620    let nf = n as f64;
621
622    // Compute A² statistic
623    let mut s = 0.0;
624    for i in 0..n {
625        let z = (x[i] - mean) / sd;
626        let z_rev = (x[n - 1 - i] - mean) / sd;
627        // Both tails evaluated directly, so an outlier contributes its true
628        // ln-probability instead of a subtraction that rounds to ln(0); the
629        // floor only guards arguments beyond the f64 range of the tail (|z| > 38).
630        let ln_phi = special::standard_normal_cdf(z).max(f64::MIN_POSITIVE).ln();
631        let ln_sf_rev = special::standard_normal_sf(z_rev)
632            .max(f64::MIN_POSITIVE)
633            .ln();
634
635        let coeff = (2 * (i + 1) - 1) as f64;
636        s += coeff * (ln_phi + ln_sf_rev);
637    }
638
639    let a2 = -nf - s / nf;
640
641    // Stephens (1986) correction for sample size
642    let a2_star = a2 * (1.0 + 0.75 / nf + 2.25 / (nf * nf));
643
644    // P-value from piecewise approximation (D'Agostino & Stephens 1986), with the
645    // large-A*² branch clamped to its valid range (see `ad_upper_tail_pvalue`).
646    let p = ad_upper_tail_pvalue(a2_star);
647
648    Some(AndersonDarlingResult {
649        statistic: a2,
650        statistic_star: a2_star,
651        p_value: p,
652    })
653}
654
655/// Result of the Anderson-Darling normality test (Stephens 1974 variant).
656///
657/// This variant uses the `statistic_modified` field name and accepts n ≥ 3,
658/// making it suitable for small-sample normality checking before control
659/// charts and Box-Cox capability analysis.
660#[derive(Debug, Clone, Copy)]
661pub struct AdNormalityResult {
662    /// The A² test statistic (raw).
663    pub statistic: f64,
664    /// The modified statistic A²* = A² · (1 + 0.75/n + 2.25/n²).
665    pub statistic_modified: f64,
666    /// Approximate p-value from Stephens (1974) piecewise approximation.
667    pub p_value: f64,
668}
669
670/// Anderson-Darling normality test (Stephens 1974): H₀: data is normally distributed.
671///
672/// Suitable for small samples (n ≥ 3). Provides the A²* modified statistic and
673/// approximate p-values using Stephens (1974) piecewise exponential formulae.
674/// Prefer this function when you need a lightweight normality pre-check before
675/// applying control charts or Box-Cox capability analysis.
676///
677/// # Algorithm
678///
679/// 1. Sort ascending, compute mean and std_dev.
680/// 2. Standardize: zᵢ = (x_(i) − mean) / std.
681/// 3. A² = −n − (1/n) · Σᵢ₌₀ⁿ⁻¹ (2i+1) · [ln Φ(zᵢ) + ln(1 − Φ(z_{n−1−i}))].
682/// 4. A²* = A² · (1 + 0.75/n + 2.25/n²).
683/// 5. p-value from Stephens (1974) piecewise approximation.
684///
685/// # Returns
686///
687/// `None` if n < 3, any non-finite value, or std_dev < 1e-15 (degenerate data).
688///
689/// # References
690///
691/// - Stephens, M. A. (1974). "EDF statistics for goodness of fit and some
692///   comparisons". *Journal of the American Statistical Association*, 69(347), 730–737.
693///
694/// # Examples
695///
696/// ```
697/// use u_analytics::testing::anderson_darling_normality;
698///
699/// let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
700/// let r = anderson_darling_normality(&data).unwrap();
701/// assert!(r.p_value > 0.05); // cannot reject normality
702/// ```
703pub fn anderson_darling_normality(data: &[f64]) -> Option<AdNormalityResult> {
704    let n = data.len();
705    if n < 3 {
706        return None;
707    }
708    if data.iter().any(|v| !v.is_finite()) {
709        return None;
710    }
711
712    let mean = stats::mean(data)?;
713    let sd = stats::std_dev(data)?;
714
715    if sd < 1e-15 {
716        return None;
717    }
718
719    let mut x: Vec<f64> = data.to_vec();
720    x.sort_by(|a, b| a.partial_cmp(b).expect("values are finite"));
721
722    let nf = n as f64;
723
724    let mut s = 0.0;
725    for i in 0..n {
726        let z_i = (x[i] - mean) / sd;
727        let z_rev = (x[n - 1 - i] - mean) / sd;
728
729        let ln_phi_i = special::standard_normal_cdf(z_i)
730            .max(f64::MIN_POSITIVE)
731            .ln();
732        let ln_sf_rev = special::standard_normal_sf(z_rev)
733            .max(f64::MIN_POSITIVE)
734            .ln();
735
736        let coeff = (2 * i + 1) as f64;
737        s += coeff * (ln_phi_i + ln_sf_rev);
738    }
739
740    let a2 = -nf - s / nf;
741    let a2_star = a2 * (1.0 + 0.75 / nf + 2.25 / (nf * nf));
742
743    // Piecewise p-value approximation, sharing the range-clamped upper-tail branch
744    // (see `ad_upper_tail_pvalue`) so large A*² plateaus near 0 instead of
745    // overflowing to exactly 1.
746    let p = ad_upper_tail_pvalue(a2_star);
747
748    Some(AdNormalityResult {
749        statistic: a2,
750        statistic_modified: a2_star,
751        p_value: p,
752    })
753}
754
755/// Result of the Shapiro-Wilk normality test.
756#[derive(Debug, Clone, Copy)]
757pub struct ShapiroWilkResult {
758    /// The W statistic (0 < W ≤ 1). Values close to 1 suggest normality.
759    pub w: f64,
760    /// The p-value. Small values reject the null hypothesis of normality.
761    pub p_value: f64,
762}
763
764/// Shapiro-Wilk normality test: H₀: data is normally distributed.
765///
766/// The most powerful general normality test for small to moderate samples.
767///
768/// # Algorithm
769///
770/// Uses the Royston (1992, 1995) algorithm (AS R94):
771/// 1. Compute coefficients from normal order statistics (Blom approximation)
772/// 2. Calculate W = (Σ aᵢ x₍ᵢ₎)² / Σ (xᵢ - x̄)²
773/// 3. Transform W to z-score via log-normal approximation
774/// 4. Compute p-value from standard normal distribution
775///
776/// # Supported range
777///
778/// n = 3..5000. Returns `None` outside this range.
779///
780/// # Returns
781///
782/// `None` if n < 3, n > 5000, all values identical, or non-finite values.
783///
784/// # References
785///
786/// - Shapiro & Wilk (1965). "An analysis of variance test for normality".
787///   Biometrika, 52(3–4), 591–611.
788/// - Royston (1992). "Approximating the Shapiro-Wilk W-test for
789///   non-normality". Statistics and Computing, 2, 117–119.
790/// - Royston (1995). "Remark AS R94: A remark on Algorithm AS 181".
791///   Applied Statistics, 44(4), 547–551.
792///
793/// # Examples
794///
795/// ```
796/// use u_analytics::testing::shapiro_wilk_test;
797///
798/// let data = [-1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5];
799/// let r = shapiro_wilk_test(&data).unwrap();
800/// assert!(r.w > 0.9);
801/// assert!(r.p_value > 0.05); // cannot reject normality
802/// ```
803pub fn shapiro_wilk_test(data: &[f64]) -> Option<ShapiroWilkResult> {
804    let n = data.len();
805    if !(3..=5000).contains(&n) {
806        return None;
807    }
808    if data.iter().any(|v| !v.is_finite()) {
809        return None;
810    }
811
812    // Sort data
813    let mut x: Vec<f64> = data.to_vec();
814    x.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
815
816    if x[n - 1] - x[0] < 1e-300 {
817        return None; // all values identical
818    }
819
820    let nn2 = n / 2;
821
822    // Special case n = 3
823    if n == 3 {
824        return shapiro_wilk_n3(&x);
825    }
826
827    // Compute coefficients via Royston algorithm
828    let a = sw_coefficients(n, nn2)?;
829
830    // Compute W statistic
831    let w = sw_statistic(&x, &a, n, nn2);
832
833    if !(0.0..=1.0 + 1e-10).contains(&w) {
834        return None;
835    }
836    let w = w.min(1.0);
837
838    // Compute p-value
839    let p_value = sw_p_value(w, n);
840
841    Some(ShapiroWilkResult {
842        w,
843        p_value: p_value.clamp(0.0, 1.0),
844    })
845}
846
847// Shapiro-Wilk: n=3 exact formula
848fn shapiro_wilk_n3(x: &[f64]) -> Option<ShapiroWilkResult> {
849    // For n=3: a = [sqrt(1/2), 0, -sqrt(1/2)]
850    let a1 = std::f64::consts::FRAC_1_SQRT_2; // 0.7071...
851    let mean = (x[0] + x[1] + x[2]) / 3.0;
852    let ss = x.iter().map(|&v| (v - mean).powi(2)).sum::<f64>();
853    if ss < 1e-300 {
854        return None;
855    }
856
857    let numerator = a1 * (x[2] - x[0]);
858    let w = (numerator * numerator) / ss;
859    let w = w.clamp(0.75, 1.0);
860
861    // Exact p-value for n=3: p = 1 - (6/pi) * arccos(sqrt(w))
862    let p = 1.0 - (6.0 / std::f64::consts::PI) * w.sqrt().acos();
863    let p = p.clamp(0.0, 1.0);
864
865    Some(ShapiroWilkResult { w, p_value: p })
866}
867
868// Royston polynomial coefficients (AS R94)
869const SW_C1: [f64; 6] = [0.0, 0.221157, -0.147981, -2.07119, 4.434685, -2.706056];
870const SW_C2: [f64; 6] = [0.0, 0.042981, -0.293762, -1.752461, 5.682633, -3.582633];
871const SW_C3: [f64; 4] = [0.544, -0.39978, 0.025054, -6.714e-4];
872const SW_C4: [f64; 4] = [1.3822, -0.77857, 0.062767, -0.0020322];
873const SW_C5: [f64; 4] = [-1.5861, -0.31082, -0.083751, 0.0038915];
874const SW_C6: [f64; 3] = [-0.4803, -0.082676, 0.0030302];
875const SW_G: [f64; 2] = [-2.273, 0.459];
876
877// Evaluate polynomial: c[0] + c[1]*x + c[2]*x^2 + ... (Horner's method)
878fn sw_poly(c: &[f64], x: f64) -> f64 {
879    let mut result = c[c.len() - 1];
880    for i in (0..c.len() - 1).rev() {
881        result = result * x + c[i];
882    }
883    result
884}
885
886// Compute Shapiro-Wilk coefficients using Royston's algorithm
887fn sw_coefficients(n: usize, nn2: usize) -> Option<Vec<f64>> {
888    let mut a = vec![0.0; nn2];
889
890    // Blom's approximation for expected normal order statistics
891    let mut m = vec![0.0; nn2];
892    let mut summ2 = 0.0;
893    for (i, mi) in m.iter_mut().enumerate() {
894        // m[i] corresponds to the (i+1)-th order statistic expectation
895        let p = (i as f64 + 1.0 - 0.375) / (n as f64 + 0.25);
896        *mi = special::inverse_normal_cdf(p);
897        summ2 += *mi * *mi;
898    }
899    summ2 *= 2.0;
900    let ssumm2 = summ2.sqrt();
901    let rsn = 1.0 / (n as f64).sqrt();
902
903    // First coefficient: polynomial correction
904    let a1 = sw_poly(&SW_C1, rsn) - m[0] / ssumm2;
905
906    if n <= 5 {
907        // For n=4,5: only a[0] is corrected
908        let fac_sq = summ2 - 2.0 * m[0] * m[0];
909        let one_minus = 1.0 - 2.0 * a1 * a1;
910        if fac_sq <= 0.0 || one_minus <= 0.0 {
911            return None;
912        }
913        let fac = (fac_sq / one_minus).sqrt();
914        a[0] = a1;
915        for i in 1..nn2 {
916            a[i] = -m[i] / fac;
917        }
918    } else {
919        // For n>5: a[0] and a[1] are corrected
920        let a2 = -m[1] / ssumm2 + sw_poly(&SW_C2, rsn);
921        let fac_sq = summ2 - 2.0 * m[0] * m[0] - 2.0 * m[1] * m[1];
922        let one_minus = 1.0 - 2.0 * a1 * a1 - 2.0 * a2 * a2;
923        if fac_sq <= 0.0 || one_minus <= 0.0 {
924            return None;
925        }
926        let fac = (fac_sq / one_minus).sqrt();
927        a[0] = a1;
928        a[1] = a2;
929        for i in 2..nn2 {
930            a[i] = -m[i] / fac;
931        }
932    }
933
934    Some(a)
935}
936
937// Compute W statistic from sorted data, coefficients, and range
938fn sw_statistic(x: &[f64], a: &[f64], n: usize, nn2: usize) -> f64 {
939    // Numerator: (sum a_i * (x_{n+1-i} - x_i))^2
940    let mut sa = 0.0;
941    for i in 0..nn2 {
942        sa += a[i] * (x[n - 1 - i] - x[i]);
943    }
944
945    // Denominator: sum of squares about the mean
946    let mean = x.iter().sum::<f64>() / n as f64;
947    let ss: f64 = x.iter().map(|&v| (v - mean).powi(2)).sum();
948
949    if ss < 1e-300 {
950        return 1.0; // degenerate
951    }
952
953    (sa * sa) / ss
954}
955
956// Compute p-value from W statistic using Royston's transformation
957fn sw_p_value(w: f64, n: usize) -> f64 {
958    let nf = n as f64;
959
960    if n == 3 {
961        // Should not reach here (handled separately), but just in case
962        let p = 1.0 - (6.0 / std::f64::consts::PI) * w.sqrt().acos();
963        return p.clamp(0.0, 1.0);
964    }
965
966    let w1 = 1.0 - w;
967    if w1 <= 0.0 {
968        return 1.0; // perfectly normal
969    }
970
971    let y = w1.ln();
972
973    if n <= 11 {
974        // Small sample: gamma + log transformation
975        let gamma = sw_poly(&SW_G, nf);
976        if y >= gamma {
977            return 0.0; // extremely non-normal
978        }
979        let y2 = -(gamma - y).ln();
980        let m = sw_poly(&SW_C3, nf);
981        let s = sw_poly(&SW_C4, nf).exp();
982        if s < 1e-300 {
983            return 0.0;
984        }
985        let z = (y2 - m) / s;
986        special::standard_normal_sf(z)
987    } else {
988        // Large sample: log-normal transformation
989        let xx = nf.ln();
990        let m = sw_poly(&SW_C5, xx);
991        let s = sw_poly(&SW_C6, xx).exp();
992        if s < 1e-300 {
993            return 0.0;
994        }
995        let z = (y - m) / s;
996        special::standard_normal_sf(z)
997    }
998}
999
1000// ---------------------------------------------------------------------------
1001// Non-parametric tests
1002// ---------------------------------------------------------------------------
1003
1004/// Mann-Whitney U test: H₀: the two populations have the same distribution.
1005///
1006/// Non-parametric alternative to the two-sample t-test. Does not assume
1007/// normality.
1008///
1009/// # Algorithm
1010///
1011/// 1. Combine samples, rank all observations (average ranks for ties)
1012/// 2. U₁ = R₁ - n₁(n₁+1)/2 where R₁ = sum of ranks in sample 1
1013/// 3. p-value (two-sided):
1014///    - **no ties and both samples ≤ 50**: the exact null distribution of U,
1015///      by the recursion P(m, n, u) = m/(m+n)·P(m−1, n, u−n) + n/(m+n)·P(m, n−1, u)
1016///    - otherwise: normal approximation with continuity correction,
1017///      z = (|U₁ − μ| − ½) / σ, μ = n₁n₂/2, σ² with the tie correction
1018///
1019/// The normal approximation alone understates p for small samples: with
1020/// three observations each and complete separation it gives 0.0495 where
1021/// the smallest achievable p is 0.100.
1022///
1023/// # Returns
1024///
1025/// `None` if either sample has fewer than 2 observations or non-finite values.
1026///
1027/// # References
1028///
1029/// - Mann & Whitney (1947). "On a test of whether one of two random
1030///   variables is stochastically larger than the other". Annals of
1031///   Mathematical Statistics, 18(1), 50–60.
1032///
1033/// # Examples
1034///
1035/// ```
1036/// use u_analytics::testing::mann_whitney_u_test;
1037///
1038/// let a = [1.0, 2.0, 3.0, 4.0, 5.0];
1039/// let b = [6.0, 7.0, 8.0, 9.0, 10.0];
1040/// let r = mann_whitney_u_test(&a, &b).unwrap();
1041/// assert!(r.p_value < 0.05);
1042/// ```
1043pub fn mann_whitney_u_test(a: &[f64], b: &[f64]) -> Option<TestResult> {
1044    let n1 = a.len();
1045    let n2 = b.len();
1046    if n1 < 2 || n2 < 2 {
1047        return None;
1048    }
1049    if a.iter().any(|v| !v.is_finite()) || b.iter().any(|v| !v.is_finite()) {
1050        return None;
1051    }
1052
1053    let n = n1 + n2;
1054    let n1f = n1 as f64;
1055    let n2f = n2 as f64;
1056    let nf = n as f64;
1057
1058    // Combine and rank
1059    let mut combined: Vec<(f64, usize)> = Vec::with_capacity(n);
1060    for &v in a {
1061        combined.push((v, 0)); // group 0 = sample a
1062    }
1063    for &v in b {
1064        combined.push((v, 1)); // group 1 = sample b
1065    }
1066    combined.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
1067
1068    // Assign average ranks and track ties
1069    let ranks = average_ranks(&combined);
1070
1071    // Sum of ranks for sample a
1072    let r1: f64 = combined
1073        .iter()
1074        .zip(ranks.iter())
1075        .filter(|((_, g), _)| *g == 0)
1076        .map(|(_, &r)| r)
1077        .sum();
1078
1079    // U statistic
1080    let u1 = r1 - n1f * (n1f + 1.0) / 2.0;
1081
1082    // Tie correction
1083    let tie_correction = compute_tie_correction(&combined);
1084
1085    // Normal approximation
1086    let mu = n1f * n2f / 2.0;
1087    let sigma_sq = n1f * n2f / 12.0 * (nf + 1.0 - tie_correction / (nf * (nf - 1.0)));
1088
1089    if sigma_sq <= 0.0 {
1090        return None;
1091    }
1092
1093    let p_value = if tie_correction == 0.0 && n1 <= EXACT_MAX_N && n2 <= EXACT_MAX_N {
1094        // Without ties U is an integer in 0..=n1·n2 and its null
1095        // distribution is symmetric about n1·n2/2.
1096        let u = u1.round() as usize;
1097        let tail = u.min(n1 * n2 - u);
1098        (2.0 * mann_whitney_exact_cdf(n1, n2, tail)).min(1.0)
1099    } else {
1100        continuity_corrected_p(u1, mu, sigma_sq)
1101    };
1102
1103    Some(TestResult {
1104        statistic: u1,
1105        df: 0.0, // not applicable for non-parametric
1106        p_value,
1107    })
1108}
1109
1110/// Largest sample size for which the rank tests use their exact null
1111/// distribution (when there are no ties). Above it the normal approximation
1112/// with continuity correction is accurate to well under 0.001 in p.
1113const EXACT_MAX_N: usize = 50;
1114
1115/// Two-sided p of a rank statistic by the normal approximation with
1116/// continuity correction.
1117fn continuity_corrected_p(statistic: f64, mu: f64, sigma_sq: f64) -> f64 {
1118    let z = ((statistic - mu).abs() - 0.5).max(0.0) / sigma_sq.sqrt();
1119    (2.0 * special::standard_normal_sf(z)).min(1.0)
1120}
1121
1122/// P(U ≤ u) for the Mann-Whitney statistic with sample sizes `m`, `n` and
1123/// no ties.
1124///
1125/// Every arrangement of the two samples' ranks is equally likely under H₀,
1126/// and the last-ranked observation belongs to the first sample with
1127/// probability m/(m+n), in which case it adds n to U:
1128/// P(m, n, u) = m/(m+n)·P(m−1, n, u−n) + n/(m+n)·P(m, n−1, u).
1129/// O(m·n·(m·n)) time, O(n·m·n) memory -- a few megabytes at m = n = 50.
1130fn mann_whitney_exact_cdf(m: usize, n: usize, u: usize) -> f64 {
1131    let max_u = m * n;
1132    // row[j] = distribution of U for (i, j), indices 0..=i·j.
1133    let mut prev: Vec<Vec<f64>> = (0..=n).map(|_| vec![1.0]).collect(); // i = 0
1134    for i in 1..=m {
1135        let mut cur: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
1136        cur.push(vec![1.0]); // j = 0: U = 0
1137        for j in 1..=n {
1138            let mut dist = vec![0.0; i * j + 1];
1139            let wi = i as f64 / (i + j) as f64;
1140            let wj = j as f64 / (i + j) as f64;
1141            // Last observation from sample 1: U shifts by j.
1142            for (k, &pk) in prev[j].iter().enumerate() {
1143                dist[k + j] += wi * pk;
1144            }
1145            // Last observation from sample 2: U unchanged.
1146            for (k, &pk) in cur[j - 1].iter().enumerate() {
1147                dist[k] += wj * pk;
1148            }
1149            cur.push(dist);
1150        }
1151        prev = cur;
1152    }
1153    prev[n].iter().take(u.min(max_u) + 1).sum::<f64>().min(1.0)
1154}
1155
1156/// P(T⁺ ≤ t) for the Wilcoxon signed-rank statistic with `n` non-zero,
1157/// untied differences: each rank enters T⁺ with probability ½ independently.
1158fn wilcoxon_exact_cdf(n: usize, t: usize) -> f64 {
1159    let max_t = n * (n + 1) / 2;
1160    let mut dist = vec![0.0; max_t + 1];
1161    dist[0] = 1.0;
1162    let mut reach = 0;
1163    for rank in 1..=n {
1164        reach += rank;
1165        for s in (rank..=reach).rev() {
1166            dist[s] = 0.5 * dist[s] + 0.5 * dist[s - rank];
1167        }
1168        for d in dist.iter_mut().take(rank) {
1169            *d *= 0.5;
1170        }
1171    }
1172    dist.iter().take(t.min(max_t) + 1).sum::<f64>().min(1.0)
1173}
1174
1175/// Wilcoxon signed-rank test: H₀: median of differences = 0.
1176///
1177/// Non-parametric alternative to the paired t-test. Does not assume
1178/// normality of differences.
1179///
1180/// # Algorithm
1181///
1182/// 1. Compute differences dᵢ = xᵢ - yᵢ, discard zeros
1183/// 2. Rank |dᵢ| (average ranks for ties)
1184/// 3. T⁺ = sum of ranks where dᵢ > 0
1185/// 4. p-value (two-sided):
1186///    - **no tied |dᵢ| and n ≤ 50**: the exact null distribution of T⁺
1187///      (each rank enters T⁺ with probability ½)
1188///    - otherwise: normal approximation with continuity correction,
1189///      z = (|T⁺ − μ| − ½) / σ, μ = n(n+1)/4, σ² with the tie correction
1190///
1191/// The normal approximation alone understates p for small samples: five
1192/// differences all of one sign give 0.043 where the exact p is 0.0625.
1193///
1194/// # Returns
1195///
1196/// `None` if fewer than 2 non-zero differences, slices differ in length,
1197/// or non-finite values.
1198///
1199/// # References
1200///
1201/// - Wilcoxon (1945). "Individual comparisons by ranking methods".
1202///   Biometrics Bulletin, 1(6), 80–83.
1203///
1204/// # Examples
1205///
1206/// ```
1207/// use u_analytics::testing::wilcoxon_signed_rank_test;
1208///
1209/// let before = [5.0, 6.0, 7.0, 8.0, 9.0];
1210/// let after  = [6.0, 7.5, 8.0, 9.5, 11.0];
1211/// let r = wilcoxon_signed_rank_test(&after, &before).unwrap();
1212/// assert!(r.statistic > 0.0); // T+ sum of positive ranks
1213/// ```
1214pub fn wilcoxon_signed_rank_test(x: &[f64], y: &[f64]) -> Option<TestResult> {
1215    if x.len() != y.len() || x.len() < 2 {
1216        return None;
1217    }
1218    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
1219        return None;
1220    }
1221
1222    // Compute differences and discard zeros
1223    let diffs: Vec<f64> = x
1224        .iter()
1225        .zip(y.iter())
1226        .map(|(&a, &b)| a - b)
1227        .filter(|&d| d.abs() > 1e-300)
1228        .collect();
1229
1230    let nr = diffs.len();
1231    if nr < 2 {
1232        return None;
1233    }
1234
1235    let nf = nr as f64;
1236
1237    // Sort by absolute difference and rank
1238    let mut abs_diffs: Vec<(f64, usize)> = diffs
1239        .iter()
1240        .enumerate()
1241        .map(|(i, &d)| (d.abs(), i))
1242        .collect();
1243    abs_diffs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1244
1245    // Assign average ranks
1246    let ranks = average_ranks(&abs_diffs);
1247
1248    // T+ = sum of ranks where the original difference is positive
1249    let t_plus: f64 = abs_diffs
1250        .iter()
1251        .zip(ranks.iter())
1252        .filter(|((_, orig_idx), _)| diffs[*orig_idx] > 0.0)
1253        .map(|(_, &r)| r)
1254        .sum();
1255
1256    // Tie correction for variance
1257    let tie_correction_val = compute_tie_correction(&abs_diffs);
1258
1259    // Normal approximation
1260    let mu = nf * (nf + 1.0) / 4.0;
1261    let sigma_sq = nf * (nf + 1.0) * (2.0 * nf + 1.0) / 24.0 - tie_correction_val / 48.0;
1262
1263    if sigma_sq <= 0.0 {
1264        return None;
1265    }
1266
1267    let p_value = if tie_correction_val == 0.0 && nr <= EXACT_MAX_N {
1268        // Without ties T⁺ is an integer in 0..=n(n+1)/2, symmetric about
1269        // n(n+1)/4.
1270        let t = t_plus.round() as usize;
1271        let tail = t.min(nr * (nr + 1) / 2 - t);
1272        (2.0 * wilcoxon_exact_cdf(nr, tail)).min(1.0)
1273    } else {
1274        continuity_corrected_p(t_plus, mu, sigma_sq)
1275    };
1276
1277    Some(TestResult {
1278        statistic: t_plus,
1279        df: 0.0,
1280        p_value,
1281    })
1282}
1283
1284// Assign average ranks to sorted (value, group_or_index) pairs.
1285// Handles ties by assigning the average of the tied ranks.
1286fn average_ranks(sorted: &[(f64, usize)]) -> Vec<f64> {
1287    let n = sorted.len();
1288    let mut ranks = vec![0.0; n];
1289    let mut i = 0;
1290    while i < n {
1291        let mut j = i + 1;
1292        while j < n && (sorted[j].0 - sorted[i].0).abs() < 1e-12 {
1293            j += 1;
1294        }
1295        // Positions i..j are tied; average rank = (i+1 + j) / 2
1296        let avg_rank = (i + 1 + j) as f64 / 2.0;
1297        for rank in ranks.iter_mut().take(j).skip(i) {
1298            *rank = avg_rank;
1299        }
1300        i = j;
1301    }
1302    ranks
1303}
1304
1305// Compute tie correction factor: Σ tₖ(tₖ² - 1) for all tie groups
1306fn compute_tie_correction(sorted: &[(f64, usize)]) -> f64 {
1307    let n = sorted.len();
1308    let mut correction = 0.0;
1309    let mut i = 0;
1310    while i < n {
1311        let mut j = i + 1;
1312        while j < n && (sorted[j].0 - sorted[i].0).abs() < 1e-12 {
1313            j += 1;
1314        }
1315        let t = (j - i) as f64;
1316        if t > 1.0 {
1317            correction += t * (t * t - 1.0);
1318        }
1319        i = j;
1320    }
1321    correction
1322}
1323
1324/// Kruskal-Wallis test: H₀: all groups have the same distribution.
1325///
1326/// Non-parametric alternative to one-way ANOVA. Does not assume normality.
1327///
1328/// # Algorithm
1329///
1330/// 1. Combine all groups, rank observations (average ranks for ties)
1331/// 2. H = (12 / N(N+1)) Σ nᵢ (R̄ᵢ - R̄)² with tie correction
1332/// 3. H ~ χ²(k-1) under H₀
1333///
1334/// # Returns
1335///
1336/// `None` if fewer than 2 groups, any group has fewer than 2 observations,
1337/// or non-finite values.
1338///
1339/// # References
1340///
1341/// - Kruskal & Wallis (1952). "Use of ranks in one-criterion variance
1342///   analysis". JASA, 47(260), 583–621.
1343///
1344/// # Examples
1345///
1346/// ```
1347/// use u_analytics::testing::kruskal_wallis_test;
1348///
1349/// let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
1350/// let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
1351/// let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
1352/// let r = kruskal_wallis_test(&[&g1, &g2, &g3]).unwrap();
1353/// assert!(r.p_value < 0.01);
1354/// ```
1355pub fn kruskal_wallis_test(groups: &[&[f64]]) -> Option<TestResult> {
1356    let k = groups.len();
1357    if k < 2 {
1358        return None;
1359    }
1360    for g in groups {
1361        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1362            return None;
1363        }
1364    }
1365
1366    let total_n: usize = groups.iter().map(|g| g.len()).sum();
1367    let nf = total_n as f64;
1368
1369    // Combine all observations with group labels
1370    let mut combined: Vec<(f64, usize)> = Vec::with_capacity(total_n);
1371    for (gi, g) in groups.iter().enumerate() {
1372        for &v in *g {
1373            combined.push((v, gi));
1374        }
1375    }
1376    combined.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1377
1378    let ranks = average_ranks(&combined);
1379
1380    // Sum of ranks per group
1381    let mut rank_sums = vec![0.0; k];
1382    for ((_, gi), &r) in combined.iter().zip(ranks.iter()) {
1383        rank_sums[*gi] += r;
1384    }
1385
1386    // H statistic: H = (12 / N(N+1)) * Σ Rᵢ²/nᵢ - 3(N+1)
1387    let mean_rank = (nf + 1.0) / 2.0;
1388    let mut h = 0.0;
1389    for (gi, g) in groups.iter().enumerate() {
1390        let ni = g.len() as f64;
1391        let mean_rank_i = rank_sums[gi] / ni;
1392        h += ni * (mean_rank_i - mean_rank).powi(2);
1393    }
1394    h *= 12.0 / (nf * (nf + 1.0));
1395
1396    // Tie correction: divide by 1 - Σtₖ(tₖ²-1) / (N³ - N)
1397    let tie_corr = compute_tie_correction(&combined);
1398    let denom = 1.0 - tie_corr / (nf * nf * nf - nf);
1399    if denom > 1e-15 {
1400        h /= denom;
1401    }
1402
1403    let df = (k - 1) as f64;
1404    let p_value = 1.0 - special::chi_squared_cdf(h, df);
1405
1406    Some(TestResult {
1407        statistic: h,
1408        df,
1409        p_value,
1410    })
1411}
1412
1413// ---------------------------------------------------------------------------
1414// Variance tests
1415// ---------------------------------------------------------------------------
1416
1417/// Levene test for equality of variances: H₀: all groups have equal variance.
1418///
1419/// Robust to non-normality. Uses the **median** variant (Brown-Forsythe),
1420/// which is recommended for non-normal data.
1421///
1422/// # Algorithm
1423///
1424/// 1. Compute zᵢⱼ = |xᵢⱼ - median(groupᵢ)|
1425/// 2. Apply one-way ANOVA on the zᵢⱼ values
1426///
1427/// # Returns
1428///
1429/// `None` if fewer than 2 groups, any group < 2 observations, or non-finite values.
1430///
1431/// # References
1432///
1433/// - Levene (1960). "Robust tests for equality of variances". In
1434///   Olkin (Ed.), Contributions to Probability and Statistics.
1435/// - Brown & Forsythe (1974). "Robust tests for the equality of variances".
1436///   JASA, 69(346), 364–367.
1437///
1438/// # Examples
1439///
1440/// ```
1441/// use u_analytics::testing::levene_test;
1442///
1443/// let g1 = [4.9, 5.0, 5.0, 5.1, 5.0]; // tight cluster (low variance)
1444/// let g2 = [0.0, 3.0, 5.0, 7.0, 10.0]; // wide spread (high variance)
1445/// let r = levene_test(&[&g1, &g2]).unwrap();
1446/// assert!(r.p_value < 0.05); // clear variance difference
1447/// ```
1448pub fn levene_test(groups: &[&[f64]]) -> Option<TestResult> {
1449    let k = groups.len();
1450    if k < 2 {
1451        return None;
1452    }
1453    for g in groups {
1454        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1455            return None;
1456        }
1457    }
1458
1459    // Compute z-values: |x - median(group)| (Brown-Forsythe variant)
1460    let z_groups: Vec<Vec<f64>> = groups
1461        .iter()
1462        .map(|g| {
1463            let median = stats::median(g).unwrap_or(0.0);
1464            g.iter().map(|&x| (x - median).abs()).collect()
1465        })
1466        .collect();
1467
1468    // Apply ANOVA on the z-values
1469    let z_refs: Vec<&[f64]> = z_groups.iter().map(|v| v.as_slice()).collect();
1470    let anova = one_way_anova(&z_refs)?;
1471
1472    Some(TestResult {
1473        statistic: anova.f_statistic,
1474        df: anova.df_between as f64,
1475        p_value: anova.p_value,
1476    })
1477}
1478
1479// ---------------------------------------------------------------------------
1480// Multiple comparison correction
1481// ---------------------------------------------------------------------------
1482
1483/// Bonferroni correction: adjusts p-values for multiple comparisons.
1484///
1485/// adjusted_pᵢ = min(pᵢ × m, 1.0) where m = number of tests.
1486///
1487/// # Returns
1488///
1489/// `None` if the slice is empty or a value is not a probability in `[0, 1]`
1490/// (a NaN included).
1491pub fn bonferroni_correction(p_values: &[f64]) -> Option<Vec<f64>> {
1492    if p_values.is_empty() || p_values.iter().any(|v| !(0.0..=1.0).contains(v)) {
1493        return None;
1494    }
1495    let m = p_values.len() as f64;
1496    Some(p_values.iter().map(|&p| (p * m).min(1.0)).collect())
1497}
1498
1499/// Benjamini-Hochberg FDR correction.
1500///
1501/// Controls false discovery rate at level α.
1502///
1503/// # Algorithm
1504///
1505/// 1. Sort p-values.
1506/// 2. For rank i (1-indexed): adjusted_pᵢ = pᵢ × m / i.
1507/// 3. Enforce monotonicity (cumulative minimum from right).
1508///
1509/// # Returns
1510///
1511/// `None` if the slice is empty or a value is not a probability in `[0, 1]`
1512/// (a NaN included).
1513///
1514/// # References
1515///
1516/// Benjamini & Hochberg (1995). "Controlling the false discovery rate".
1517/// JRSS-B, 57(1), 289–300.
1518pub fn benjamini_hochberg(p_values: &[f64]) -> Option<Vec<f64>> {
1519    let m = p_values.len();
1520    if m == 0 || p_values.iter().any(|v| !(0.0..=1.0).contains(v)) {
1521        return None;
1522    }
1523
1524    // Sort indices by p-value
1525    let mut indices: Vec<usize> = (0..m).collect();
1526    indices.sort_by(|&a, &b| {
1527        p_values[a]
1528            .partial_cmp(&p_values[b])
1529            .unwrap_or(std::cmp::Ordering::Equal)
1530    });
1531
1532    let mf = m as f64;
1533    let mut adjusted = vec![0.0; m];
1534
1535    // Compute adjusted p-values
1536    let mut cummin = f64::INFINITY;
1537    for (rank_rev, &orig_idx) in indices.iter().enumerate().rev() {
1538        let rank = rank_rev + 1; // 1-indexed
1539        let adj = (p_values[orig_idx] * mf / rank as f64).min(1.0);
1540        cummin = cummin.min(adj);
1541        adjusted[orig_idx] = cummin;
1542    }
1543
1544    Some(adjusted)
1545}
1546
1547// ---------------------------------------------------------------------------
1548// Bartlett test for equality of variances
1549// ---------------------------------------------------------------------------
1550
1551/// Bartlett test for equality of variances: H₀: all groups have equal variance.
1552///
1553/// Assumes data are from **normal** distributions. For non-normal data, prefer
1554/// [`levene_test`] (Brown-Forsythe variant).
1555///
1556/// # Algorithm
1557///
1558/// 1. Compute pooled variance: s²ₚ = Σ(nᵢ-1)s²ᵢ / (N-k)
1559/// 2. Numerator: (N-k) ln(s²ₚ) - Σ(nᵢ-1) ln(s²ᵢ)
1560/// 3. Correction factor: C = 1 + [1/(3(k-1))] × [Σ 1/(nᵢ-1) - 1/(N-k)]
1561/// 4. Statistic: T = numerator / C ~ χ²(k-1)
1562///
1563/// # Returns
1564///
1565/// `None` if fewer than 2 groups, any group < 2 observations, any group has
1566/// zero variance, or non-finite values.
1567///
1568/// # References
1569///
1570/// - Bartlett (1937). "Properties of sufficiency and statistical tests".
1571///   Proceedings of the Royal Society A, 160(901), 268–282.
1572///
1573/// # Examples
1574///
1575/// ```
1576/// use u_analytics::testing::bartlett_test;
1577///
1578/// let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // variance ~2.5
1579/// let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // variance ~250
1580/// let r = bartlett_test(&[&g1, &g2]).unwrap();
1581/// assert!(r.p_value < 0.01); // strongly different variances
1582/// ```
1583pub fn bartlett_test(groups: &[&[f64]]) -> Option<TestResult> {
1584    let k = groups.len();
1585    if k < 2 {
1586        return None;
1587    }
1588
1589    let mut sizes = Vec::with_capacity(k);
1590    let mut vars = Vec::with_capacity(k);
1591    let mut n_total: usize = 0;
1592
1593    for g in groups {
1594        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1595            return None;
1596        }
1597        let n = g.len();
1598        let v = stats::variance(g)?;
1599        if v <= 0.0 {
1600            return None; // zero variance → ln undefined
1601        }
1602        sizes.push(n);
1603        vars.push(v);
1604        n_total += n;
1605    }
1606
1607    let nk = n_total - k; // N - k
1608    if nk == 0 {
1609        return None;
1610    }
1611    let nk_f = nk as f64;
1612
1613    // Pooled variance
1614    let s2_pooled: f64 = sizes
1615        .iter()
1616        .zip(vars.iter())
1617        .map(|(&n, &v)| (n as f64 - 1.0) * v)
1618        .sum::<f64>()
1619        / nk_f;
1620
1621    if s2_pooled <= 0.0 {
1622        return None;
1623    }
1624
1625    // Numerator: (N-k) ln(s²ₚ) - Σ(nᵢ-1) ln(s²ᵢ)
1626    let num = nk_f * s2_pooled.ln()
1627        - sizes
1628            .iter()
1629            .zip(vars.iter())
1630            .map(|(&n, &v)| (n as f64 - 1.0) * v.ln())
1631            .sum::<f64>();
1632
1633    // Correction factor C
1634    let sum_recip: f64 = sizes.iter().map(|&n| 1.0 / (n as f64 - 1.0)).sum();
1635    let c = 1.0 + (sum_recip - 1.0 / nk_f) / (3.0 * (k as f64 - 1.0));
1636
1637    let statistic = num / c;
1638    let df = (k - 1) as f64;
1639    let p_value = 1.0 - special::chi_squared_cdf(statistic, df);
1640
1641    Some(TestResult {
1642        statistic,
1643        df,
1644        p_value,
1645    })
1646}
1647
1648// ---------------------------------------------------------------------------
1649// Fisher exact test (2×2)
1650// ---------------------------------------------------------------------------
1651
1652/// Fisher exact test for a 2×2 contingency table.
1653///
1654/// Tests H₀: the two categorical variables are independent.
1655/// Unlike the chi-squared test, this is exact and valid for small samples.
1656///
1657/// # Arguments
1658///
1659/// The 2×2 table is specified as four cell counts:
1660///
1661/// ```text
1662///          Col1   Col2
1663///   Row1 |  a   |  b  |
1664///   Row2 |  c   |  d  |
1665/// ```
1666///
1667/// # Algorithm
1668///
1669/// 1. Compute probability of observed table via hypergeometric distribution
1670///    (using log-factorials for numerical stability).
1671/// 2. Enumerate all tables with the same marginals.
1672/// 3. Two-tailed p-value = sum of probabilities ≤ P(observed).
1673///
1674/// # Returns
1675///
1676/// `None` if any marginal total is zero (degenerate table).
1677///
1678/// # References
1679///
1680/// - Fisher (1922). "On the interpretation of χ² from contingency tables,
1681///   and the calculation of P". JRSS, 85(1), 87–94.
1682///
1683/// # Examples
1684///
1685/// ```
1686/// use u_analytics::testing::fisher_exact_test;
1687///
1688/// // Tea-tasting experiment
1689/// let r = fisher_exact_test(3, 1, 1, 3).unwrap();
1690/// assert!(r.p_value > 0.05); // not significant at 5%
1691/// ```
1692pub fn fisher_exact_test(a: u64, b: u64, c: u64, d: u64) -> Option<TestResult> {
1693    let row1 = a + b;
1694    let row2 = c + d;
1695    let col1 = a + c;
1696    let col2 = b + d;
1697    let n = a + b + c + d;
1698
1699    // Degenerate if any marginal is zero
1700    if row1 == 0 || row2 == 0 || col1 == 0 || col2 == 0 {
1701        return None;
1702    }
1703
1704    // Log-probability of a specific table given marginals
1705    let log_prob = |a_i: u64| -> f64 {
1706        let b_i = row1 - a_i;
1707        let c_i = col1 - a_i;
1708        let d_i = row2 - c_i;
1709        ln_factorial(row1) + ln_factorial(row2) + ln_factorial(col1) + ln_factorial(col2)
1710            - ln_factorial(a_i)
1711            - ln_factorial(b_i)
1712            - ln_factorial(c_i)
1713            - ln_factorial(d_i)
1714            - ln_factorial(n)
1715    };
1716
1717    // Range of valid values for cell a
1718    let a_min = col1.saturating_sub(row2);
1719    let a_max = row1.min(col1);
1720
1721    let log_p_obs = log_prob(a);
1722
1723    // Two-tailed: sum probabilities ≤ P(observed)
1724    let mut p_value = 0.0;
1725    for a_i in a_min..=a_max {
1726        let lp = log_prob(a_i);
1727        // Use small tolerance for floating-point comparison
1728        if lp <= log_p_obs + 1e-10 {
1729            p_value += lp.exp();
1730        }
1731    }
1732
1733    // Clamp to [0, 1]
1734    let p_value = p_value.min(1.0);
1735
1736    // Odds ratio: (a*d) / (b*c)
1737    let odds_ratio = if b > 0 && c > 0 {
1738        (a as f64 * d as f64) / (b as f64 * c as f64)
1739    } else {
1740        f64::INFINITY
1741    };
1742
1743    Some(TestResult {
1744        statistic: odds_ratio,
1745        df: 1.0,
1746        p_value,
1747    })
1748}
1749
1750// ---------------------------------------------------------------------------
1751// Mann-Kendall trend test
1752// ---------------------------------------------------------------------------
1753
1754/// Result of the Mann-Kendall trend test.
1755#[derive(Debug, Clone, Copy)]
1756pub struct MannKendallResult {
1757    /// Mann-Kendall S statistic: Σ sign(xⱼ - xᵢ) for all i < j.
1758    pub s_statistic: i64,
1759    /// Variance of S (with tie correction).
1760    pub variance: f64,
1761    /// Z statistic (with continuity correction).
1762    pub z_statistic: f64,
1763    /// Two-tailed p-value.
1764    pub p_value: f64,
1765    /// Kendall's tau: S / [n(n-1)/2]. Range [-1, 1].
1766    pub kendall_tau: f64,
1767    /// Sen's slope estimator: median of (xⱼ - xᵢ)/(j - i) for all i < j.
1768    pub sen_slope: f64,
1769}
1770
1771/// Mann-Kendall non-parametric trend test with Sen's slope estimator.
1772///
1773/// Tests H₀: no monotonic trend vs H₁: monotonic trend exists.
1774/// Assumes serially independent observations (no autocorrelation).
1775///
1776/// # Algorithm
1777///
1778/// 1. S = Σᵢ<ⱼ sign(xⱼ - xᵢ)
1779/// 2. Var(S) = \[n(n-1)(2n+5) - Σ tₖ(tₖ-1)(2tₖ+5)\] / 18 (tie-corrected)
1780/// 3. Z = (S-1)/√Var(S) if S>0, 0 if S=0, (S+1)/√Var(S) if S<0
1781/// 4. Sen's slope = median of all pairwise slopes (xⱼ - xᵢ)/(j - i)
1782///
1783/// References:
1784/// - Mann (1945), "Nonparametric tests against trend"
1785/// - Kendall (1975), "Rank Correlation Methods"
1786/// - Sen (1968), "Estimates of the regression coefficient based on Kendall's tau"
1787///
1788/// # Complexity
1789///
1790/// O(n² log n) — pairwise comparisons O(n²) plus median finding O(n² log n²).
1791///
1792/// # Returns
1793///
1794/// `None` if fewer than 4 data points, non-finite values, or zero variance.
1795///
1796/// # Examples
1797///
1798/// ```
1799/// use u_analytics::testing::mann_kendall_test;
1800///
1801/// // Clear upward trend
1802/// let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
1803/// let r = mann_kendall_test(&data).unwrap();
1804/// assert!(r.p_value < 0.01);
1805/// assert!(r.kendall_tau > 0.8);
1806/// assert!(r.sen_slope > 0.0);
1807/// ```
1808pub fn mann_kendall_test(data: &[f64]) -> Option<MannKendallResult> {
1809    let n = data.len();
1810    if n < 4 || data.iter().any(|v| !v.is_finite()) {
1811        return None;
1812    }
1813
1814    // Step 1: Compute S statistic
1815    let mut s: i64 = 0;
1816    for i in 0..n - 1 {
1817        for j in (i + 1)..n {
1818            let diff = data[j] - data[i];
1819            if diff > 0.0 {
1820                s += 1;
1821            } else if diff < 0.0 {
1822                s -= 1;
1823            }
1824        }
1825    }
1826
1827    // Step 2: Compute tie groups
1828    let mut sorted: Vec<f64> = data.to_vec();
1829    sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1830
1831    let mut tie_correction: f64 = 0.0;
1832    let mut current_count: usize = 1;
1833    for i in 1..sorted.len() {
1834        if (sorted[i] - sorted[i - 1]).abs() < 1e-10 {
1835            current_count += 1;
1836        } else {
1837            if current_count > 1 {
1838                let t = current_count as f64;
1839                tie_correction += t * (t - 1.0) * (2.0 * t + 5.0);
1840            }
1841            current_count = 1;
1842        }
1843    }
1844    if current_count > 1 {
1845        let t = current_count as f64;
1846        tie_correction += t * (t - 1.0) * (2.0 * t + 5.0);
1847    }
1848
1849    // Step 3: Variance with tie correction
1850    let nf = n as f64;
1851    let variance = (nf * (nf - 1.0) * (2.0 * nf + 5.0) - tie_correction) / 18.0;
1852
1853    if variance < 1e-300 {
1854        return None; // All values identical
1855    }
1856
1857    // Step 4: Z with continuity correction
1858    let sigma = variance.sqrt();
1859    let z_statistic = if s > 0 {
1860        (s as f64 - 1.0) / sigma
1861    } else if s < 0 {
1862        (s as f64 + 1.0) / sigma
1863    } else {
1864        0.0
1865    };
1866
1867    // Step 5: Two-tailed p-value
1868    let p_value = 2.0 * special::standard_normal_sf(z_statistic.abs());
1869    let p_value = p_value.clamp(0.0, 1.0);
1870
1871    // Step 6: Kendall's tau
1872    let kendall_tau = (2 * s) as f64 / (nf * (nf - 1.0));
1873
1874    // Step 7: Sen's slope = median of pairwise slopes
1875    let mut slopes = Vec::with_capacity(n * (n - 1) / 2);
1876    for i in 0..n - 1 {
1877        for j in (i + 1)..n {
1878            let dx = (j - i) as f64;
1879            slopes.push((data[j] - data[i]) / dx);
1880        }
1881    }
1882    slopes.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1883    let m = slopes.len();
1884    let sen_slope = if m % 2 == 0 {
1885        (slopes[m / 2 - 1] + slopes[m / 2]) / 2.0
1886    } else {
1887        slopes[m / 2]
1888    };
1889
1890    Some(MannKendallResult {
1891        s_statistic: s,
1892        variance,
1893        z_statistic,
1894        p_value,
1895        kendall_tau,
1896        sen_slope,
1897    })
1898}
1899
1900/// Natural log of n! using Stirling/ln_gamma for large values.
1901fn ln_factorial(n: u64) -> f64 {
1902    if n <= 1 {
1903        return 0.0;
1904    }
1905    // ln(n!) = ln_gamma(n+1)
1906    special::ln_gamma(n as f64 + 1.0)
1907}
1908
1909// ---------------------------------------------------------------------------
1910// Augmented Dickey-Fuller test
1911// ---------------------------------------------------------------------------
1912
1913/// Result of the Augmented Dickey-Fuller (ADF) unit root test.
1914#[derive(Debug, Clone)]
1915pub struct AdfResult {
1916    /// ADF test statistic (t-ratio for γ̂).
1917    pub statistic: f64,
1918    /// Number of lags used.
1919    pub n_lags: usize,
1920    /// Number of observations used in the regression.
1921    pub n_obs: usize,
1922    /// Critical values at 1%, 5%, 10% significance levels.
1923    pub critical_values: [f64; 3],
1924    /// Whether the null hypothesis (unit root) is rejected at each level.
1925    pub rejected: [bool; 3],
1926}
1927
1928/// Model specification for the ADF test.
1929#[derive(Debug, Clone, Copy)]
1930pub enum AdfModel {
1931    /// No constant, no trend: Δyₜ = γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1932    None,
1933    /// Constant only (default): Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1934    Constant,
1935    /// Constant + linear trend: Δyₜ = α + βt + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1936    ConstantTrend,
1937}
1938
1939/// Augmented Dickey-Fuller (ADF) unit root test for stationarity.
1940///
1941/// Tests H₀: unit root (non-stationary) vs H₁: stationary.
1942///
1943/// # Algorithm
1944///
1945/// 1. Constructs Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1946/// 2. Estimates via OLS
1947/// 3. Tests t-ratio for γ against Dickey-Fuller critical values
1948///
1949/// When `max_lags` is `None`, lag length is selected by AIC (Schwert rule
1950/// for maximum). When `Some(p)`, exactly `p` lags are used.
1951///
1952/// Reference: Dickey & Fuller (1979), "Distribution of the Estimators for
1953/// Autoregressive Time Series with a Unit Root"
1954///
1955/// # Returns
1956///
1957/// `None` if fewer than 10 data points, non-finite values, or OLS fails.
1958///
1959/// # Examples
1960///
1961/// ```
1962/// use u_analytics::testing::{adf_test, AdfModel};
1963///
1964/// // Stationary series: strong mean-reversion
1965/// let mut data = vec![0.0_f64; 40];
1966/// for i in 1..40 {
1967///     data[i] = 0.3 * data[i - 1] + [0.5, -0.8, 0.3, -0.6, 0.9,
1968///         -0.4, 0.7, -0.2, 0.1, -0.5][i % 10];
1969/// }
1970/// let r = adf_test(&data, AdfModel::Constant, None).unwrap();
1971/// assert!(r.statistic.is_finite());
1972/// assert_eq!(r.critical_values.len(), 3);
1973/// ```
1974pub fn adf_test(data: &[f64], model: AdfModel, max_lags: Option<usize>) -> Option<AdfResult> {
1975    let n = data.len();
1976    if n < 10 || data.iter().any(|v| !v.is_finite()) {
1977        return None;
1978    }
1979
1980    // Compute differences
1981    let dy: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
1982
1983    // Determine lag count
1984    let best_lag = match max_lags {
1985        Some(p) => p, // Use exact lag count when specified
1986        None => {
1987            // Schwert (1989) rule for maximum lag
1988            let schwert = (12.0 * (n as f64 / 100.0).powf(0.25)).floor() as usize;
1989            let p_max = schwert.min(n / 3);
1990            // Select optimal lag by AIC
1991            select_adf_lag(data, &dy, model, p_max)
1992        }
1993    };
1994
1995    // Run OLS regression with selected lag
1996    adf_ols(data, &dy, model, best_lag)
1997}
1998
1999/// Selects optimal lag for ADF by minimizing AIC.
2000fn select_adf_lag(data: &[f64], dy: &[f64], model: AdfModel, p_max: usize) -> usize {
2001    let mut best_aic = f64::INFINITY;
2002    let mut best_p = 0;
2003
2004    for p in 0..=p_max {
2005        if let Some((aic, _)) = adf_ols_aic(data, dy, model, p) {
2006            if aic < best_aic {
2007                best_aic = aic;
2008                best_p = p;
2009            }
2010        }
2011    }
2012
2013    best_p
2014}
2015
2016/// Builds the ADF design matrix and dependent variable.
2017///
2018/// Returns (design_matrix_row_major, y_dep, n_rows, n_cols, gamma_col_index).
2019#[allow(clippy::type_complexity)]
2020fn adf_build_matrix(
2021    data: &[f64],
2022    dy: &[f64],
2023    model: AdfModel,
2024    p: usize,
2025) -> Option<(Vec<f64>, Vec<f64>, usize, usize, usize)> {
2026    let start = p + 1;
2027    if start >= dy.len() || dy.len() - start < 5 {
2028        return None;
2029    }
2030    let m = dy.len() - start;
2031
2032    let y_dep: Vec<f64> = dy[start..].to_vec();
2033
2034    // Count columns: intercept + y_{t-1} + [trend] + p lags
2035    let has_intercept = !matches!(model, AdfModel::None);
2036    let has_trend = matches!(model, AdfModel::ConstantTrend);
2037    let ncols = has_intercept as usize + 1 + has_trend as usize + p;
2038
2039    // Build row-major design matrix
2040    let mut x_data = Vec::with_capacity(m * ncols);
2041    let mut gamma_col = 0;
2042
2043    for i in 0..m {
2044        let t = start + i;
2045        if has_intercept {
2046            x_data.push(1.0); // intercept
2047            gamma_col = 1;
2048        }
2049        x_data.push(data[t]); // y_{t-1}
2050        if has_trend {
2051            x_data.push((t + 1) as f64); // trend
2052        }
2053        for lag in 1..=p {
2054            x_data.push(dy[t - lag]);
2055        }
2056    }
2057
2058    Some((x_data, y_dep, m, ncols, gamma_col))
2059}
2060
2061/// Lightweight OLS for ADF: returns (gamma_t_stat, rss, k).
2062///
2063/// Solves X'Xβ = X'y using Cholesky-like decomposition (Gaussian elimination).
2064fn adf_ols_core(
2065    x_data: &[f64],
2066    y: &[f64],
2067    m: usize,
2068    ncols: usize,
2069    gamma_col: usize,
2070) -> Option<(f64, f64, usize)> {
2071    // Compute X'X (ncols × ncols, symmetric)
2072    let mut xtx = vec![0.0_f64; ncols * ncols];
2073    for i in 0..m {
2074        let row = &x_data[i * ncols..(i + 1) * ncols];
2075        for j in 0..ncols {
2076            for k in j..ncols {
2077                xtx[j * ncols + k] += row[j] * row[k];
2078            }
2079        }
2080    }
2081    // Mirror upper to lower
2082    for j in 0..ncols {
2083        for k in (j + 1)..ncols {
2084            xtx[k * ncols + j] = xtx[j * ncols + k];
2085        }
2086    }
2087
2088    // Compute X'y (ncols × 1)
2089    let mut xty = vec![0.0_f64; ncols];
2090    for i in 0..m {
2091        let row = &x_data[i * ncols..(i + 1) * ncols];
2092        for j in 0..ncols {
2093            xty[j] += row[j] * y[i];
2094        }
2095    }
2096
2097    // Solve via Gaussian elimination with partial pivoting
2098    let mut augmented = vec![0.0_f64; ncols * (ncols + 1)];
2099    for i in 0..ncols {
2100        for j in 0..ncols {
2101            augmented[i * (ncols + 1) + j] = xtx[i * ncols + j];
2102        }
2103        augmented[i * (ncols + 1) + ncols] = xty[i];
2104    }
2105
2106    for col in 0..ncols {
2107        // Partial pivoting
2108        let mut max_row = col;
2109        let mut max_val = augmented[col * (ncols + 1) + col].abs();
2110        for row in (col + 1)..ncols {
2111            let val = augmented[row * (ncols + 1) + col].abs();
2112            if val > max_val {
2113                max_val = val;
2114                max_row = row;
2115            }
2116        }
2117        if max_val < 1e-15 {
2118            return None; // Singular
2119        }
2120        if max_row != col {
2121            for j in 0..=ncols {
2122                let a = col * (ncols + 1) + j;
2123                let b = max_row * (ncols + 1) + j;
2124                augmented.swap(a, b);
2125            }
2126        }
2127
2128        let pivot = augmented[col * (ncols + 1) + col];
2129        for row in (col + 1)..ncols {
2130            let factor = augmented[row * (ncols + 1) + col] / pivot;
2131            for j in col..=ncols {
2132                let above = augmented[col * (ncols + 1) + j];
2133                augmented[row * (ncols + 1) + j] -= factor * above;
2134            }
2135        }
2136    }
2137
2138    // Back-substitution
2139    let mut beta = vec![0.0_f64; ncols];
2140    for i in (0..ncols).rev() {
2141        let mut sum = augmented[i * (ncols + 1) + ncols];
2142        for j in (i + 1)..ncols {
2143            sum -= augmented[i * (ncols + 1) + j] * beta[j];
2144        }
2145        beta[i] = sum / augmented[i * (ncols + 1) + i];
2146    }
2147
2148    // Compute residuals and RSS
2149    let mut rss = 0.0;
2150    for i in 0..m {
2151        let row = &x_data[i * ncols..(i + 1) * ncols];
2152        let y_hat: f64 = row.iter().zip(beta.iter()).map(|(&x, &b)| x * b).sum();
2153        let resid = y[i] - y_hat;
2154        rss += resid * resid;
2155    }
2156
2157    // Standard error of coefficients
2158    let df = m - ncols;
2159    if df == 0 {
2160        return None;
2161    }
2162    let mse = rss / df as f64;
2163
2164    // Compute (X'X)^{-1} via Gauss-Jordan elimination to get variance of γ̂
2165    let mut xtx_aug = vec![0.0_f64; ncols * ncols * 2]; // xtx | I
2166    for i in 0..ncols {
2167        for j in 0..ncols {
2168            xtx_aug[i * 2 * ncols + j] = xtx[i * ncols + j];
2169        }
2170        xtx_aug[i * 2 * ncols + ncols + i] = 1.0;
2171    }
2172
2173    // Gauss-Jordan elimination
2174    for col in 0..ncols {
2175        let mut max_row = col;
2176        let mut max_val = xtx_aug[col * 2 * ncols + col].abs();
2177        for row in (col + 1)..ncols {
2178            let val = xtx_aug[row * 2 * ncols + col].abs();
2179            if val > max_val {
2180                max_val = val;
2181                max_row = row;
2182            }
2183        }
2184        if max_val < 1e-15 {
2185            return None;
2186        }
2187        if max_row != col {
2188            for j in 0..(2 * ncols) {
2189                let a = col * 2 * ncols + j;
2190                let b = max_row * 2 * ncols + j;
2191                xtx_aug.swap(a, b);
2192            }
2193        }
2194
2195        let pivot = xtx_aug[col * 2 * ncols + col];
2196        for j in 0..(2 * ncols) {
2197            xtx_aug[col * 2 * ncols + j] /= pivot;
2198        }
2199        for row in 0..ncols {
2200            if row == col {
2201                continue;
2202            }
2203            let factor = xtx_aug[row * 2 * ncols + col];
2204            for j in 0..(2 * ncols) {
2205                let above = xtx_aug[col * 2 * ncols + j];
2206                xtx_aug[row * 2 * ncols + j] -= factor * above;
2207            }
2208        }
2209    }
2210
2211    // Extract diagonal element for gamma column
2212    let var_gamma = mse * xtx_aug[gamma_col * 2 * ncols + ncols + gamma_col];
2213    if var_gamma <= 0.0 {
2214        return None;
2215    }
2216    let se_gamma = var_gamma.sqrt();
2217    let t_gamma = beta[gamma_col] / se_gamma;
2218
2219    Some((t_gamma, rss, ncols))
2220}
2221
2222/// Runs ADF OLS and returns AIC + number of observations.
2223fn adf_ols_aic(data: &[f64], dy: &[f64], model: AdfModel, p: usize) -> Option<(f64, usize)> {
2224    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p)?;
2225    let (_t_stat, rss, k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2226    let aic = 2.0 * k as f64 + m as f64 * (rss / m as f64).ln();
2227    Some((aic, m))
2228}
2229
2230/// Runs the actual ADF OLS and returns the test result.
2231fn adf_ols(data: &[f64], dy: &[f64], model: AdfModel, p: usize) -> Option<AdfResult> {
2232    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p)?;
2233    let (gamma_t, _rss, _k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2234
2235    let critical_values = adf_critical_values(model, m);
2236
2237    let rejected = [
2238        gamma_t <= critical_values[0],
2239        gamma_t <= critical_values[1],
2240        gamma_t <= critical_values[2],
2241    ];
2242
2243    Some(AdfResult {
2244        statistic: gamma_t,
2245        n_lags: p,
2246        n_obs: m,
2247        critical_values,
2248        rejected,
2249    })
2250}
2251
2252/// MacKinnon (1994) critical values for ADF test.
2253///
2254/// Returns [1%, 5%, 10%] critical values based on sample size.
2255fn adf_critical_values(model: AdfModel, n: usize) -> [f64; 3] {
2256    // MacKinnon (1994) regression-based approximation:
2257    // cv(n) = τ_∞ + τ₁/n + τ₂/n²
2258    //
2259    // Coefficients from MacKinnon (2010), Table 1.
2260    let (tau_inf, tau1, tau2): ([f64; 3], [f64; 3], [f64; 3]) = match model {
2261        AdfModel::None => (
2262            [-2.5658, -1.9393, -1.6156],
2263            [-1.960, -0.398, -0.181],
2264            [-10.04, 0.0, 0.0],
2265        ),
2266        AdfModel::Constant => (
2267            [-3.4336, -2.8621, -2.5671],
2268            [-5.999, -2.738, -1.438],
2269            [-29.25, -8.36, -4.48],
2270        ),
2271        AdfModel::ConstantTrend => (
2272            [-3.9638, -3.4126, -3.1279],
2273            [-8.353, -4.039, -2.418],
2274            [-47.44, -17.83, -7.58],
2275        ),
2276    };
2277
2278    let nf = n as f64;
2279    let inv_n = 1.0 / nf;
2280    let inv_n2 = inv_n * inv_n;
2281
2282    [
2283        tau_inf[0] + tau1[0] * inv_n + tau2[0] * inv_n2,
2284        tau_inf[1] + tau1[1] * inv_n + tau2[1] * inv_n2,
2285        tau_inf[2] + tau1[2] * inv_n + tau2[2] * inv_n2,
2286    ]
2287}
2288
2289#[cfg(test)]
2290mod tests {
2291    use super::*;
2292
2293    #[test]
2294    fn corrections_refuse_what_is_not_a_probability() {
2295        assert!(bonferroni_correction(&[0.01, 1.5]).is_none());
2296        assert!(benjamini_hochberg(&[-0.1, 0.2]).is_none());
2297        assert_eq!(bonferroni_correction(&[0.01, 0.2]), Some(vec![0.02, 0.4]));
2298    }
2299
2300    // -----------------------------------------------------------------------
2301    // Anderson-Darling large-n p-value overflow (upstream-014)
2302    // -----------------------------------------------------------------------
2303
2304    /// Public-domain Mulberry32 PRNG (matches the upstream-014 reproduction).
2305    fn mulberry32(seed: u32) -> impl FnMut() -> f64 {
2306        let mut a = seed;
2307        move || {
2308            a = a.wrapping_add(0x6d2b_79f5);
2309            let mut t = a;
2310            t = (t ^ (t >> 15)).wrapping_mul(t | 1);
2311            t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 61));
2312            ((t ^ (t >> 14)) as f64) / 4_294_967_296.0
2313        }
2314    }
2315
2316    /// Regression (upstream-014): for a fixed, clearly non-normal distribution
2317    /// shape (exponential), as n grows the A*² statistic grows monotonically and
2318    /// the p-value must keep tracking toward 0 — it must NEVER jump to exactly 1.0
2319    /// ("perfectly normal") while A*² is simultaneously large and growing. Before
2320    /// the fix, the p-value overflowed to exactly 1 past n≈7000.
2321    #[test]
2322    fn ad_pvalue_no_overflow_large_nonnormal_n() {
2323        let mut rng = mulberry32(0x5350_4331);
2324        let full: Vec<f64> = (0..20_000)
2325            .map(|_| {
2326                let u = rng();
2327                -(1.0 - u).ln() // inverse-CDF exponential, rate=1
2328            })
2329            .collect();
2330
2331        let mut prev_a2 = 0.0_f64;
2332        let mut prev_p = f64::INFINITY;
2333        for &n in &[3000usize, 5000, 6000, 7000, 8000, 10000, 16000, 20000] {
2334            let r = anderson_darling_normality(&full[..n]).expect("computes");
2335            // A*² must keep growing for an increasingly non-normal large sample.
2336            assert!(
2337                r.statistic_modified > prev_a2,
2338                "A*² must grow with n; n={n} A*²={} prev={prev_a2}",
2339                r.statistic_modified
2340            );
2341            // The core defect: p must never flip to exactly 1.0 while A*² is huge.
2342            assert!(
2343                r.p_value < 0.5,
2344                "clearly non-normal data (n={n}, A*²={}) must not report p={} ≈ normal",
2345                r.statistic_modified,
2346                r.p_value
2347            );
2348            // Monotone non-increasing: p tracks the growing statistic downward.
2349            assert!(
2350                r.p_value <= prev_p + 1e-12,
2351                "p must be non-increasing as A*² grows; n={n} p={} prev={prev_p}",
2352                r.p_value
2353            );
2354            prev_a2 = r.statistic_modified;
2355            prev_p = r.p_value;
2356        }
2357    }
2358
2359    /// Both AD entry points share the range-clamped upper-tail branch, so both
2360    /// must be immune to the overflow.
2361    #[test]
2362    fn ad_both_functions_immune_to_overflow() {
2363        let mut rng = mulberry32(0x0bad_c0de);
2364        let data: Vec<f64> = (0..9000).map(|_| -(1.0 - rng()).ln()).collect();
2365        let a = anderson_darling_test(&data).expect("computes");
2366        let b = anderson_darling_normality(&data).expect("computes");
2367        assert!(
2368            a.statistic_star > 300.0,
2369            "expected large A*², got {}",
2370            a.statistic_star
2371        );
2372        assert!(
2373            a.p_value < 0.5,
2374            "test() overflowed toward normal: p={}",
2375            a.p_value
2376        );
2377        assert!(
2378            b.p_value < 0.5,
2379            "normality() overflowed toward normal: p={}",
2380            b.p_value
2381        );
2382    }
2383
2384    // -----------------------------------------------------------------------
2385    // One-sample t-test
2386    // -----------------------------------------------------------------------
2387
2388    #[test]
2389    fn one_sample_null_true() {
2390        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2391        let r = one_sample_t_test(&data, 5.0).expect("should compute");
2392        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2393    }
2394
2395    #[test]
2396    fn one_sample_null_false() {
2397        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2398        let r = one_sample_t_test(&data, 10.0).expect("should compute");
2399        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2400    }
2401
2402    #[test]
2403    fn one_sample_edge_cases() {
2404        assert!(one_sample_t_test(&[1.0], 0.0).is_none()); // n < 2
2405        assert!(one_sample_t_test(&[5.0, 5.0, 5.0], 5.0).is_none()); // zero var
2406        assert!(one_sample_t_test(&[1.0, f64::NAN, 3.0], 2.0).is_none());
2407    }
2408
2409    // -----------------------------------------------------------------------
2410    // Two-sample t-test
2411    // -----------------------------------------------------------------------
2412
2413    #[test]
2414    fn two_sample_same_mean() {
2415        let a = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2416        let b = [5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 5.1, 4.9];
2417        let r = two_sample_t_test(&a, &b).expect("should compute");
2418        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2419    }
2420
2421    #[test]
2422    fn two_sample_different_means() {
2423        let a = [1.0, 2.0, 3.0, 2.0, 1.5, 2.5];
2424        let b = [10.0, 11.0, 12.0, 10.5, 11.5, 10.5];
2425        let r = two_sample_t_test(&a, &b).expect("should compute");
2426        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2427    }
2428
2429    #[test]
2430    fn two_sample_different_sizes() {
2431        let a = [1.0, 2.0, 3.0];
2432        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
2433        let r = two_sample_t_test(&a, &b).expect("should compute");
2434        assert!(r.p_value < 0.05);
2435    }
2436
2437    #[test]
2438    fn two_sample_edge_cases() {
2439        assert!(two_sample_t_test(&[1.0], &[2.0, 3.0]).is_none());
2440        assert!(two_sample_t_test(&[1.0, 2.0], &[3.0]).is_none());
2441    }
2442
2443    // -----------------------------------------------------------------------
2444    // Paired t-test
2445    // -----------------------------------------------------------------------
2446
2447    #[test]
2448    fn paired_no_difference() {
2449        let x = [5.0, 6.0, 7.0, 8.0, 9.0];
2450        let y = [5.1, 5.9, 7.1, 7.9, 9.1];
2451        let r = paired_t_test(&x, &y).expect("should compute");
2452        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2453    }
2454
2455    #[test]
2456    fn paired_significant_difference() {
2457        // Differences have non-zero variance
2458        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
2459        let after = [6.2, 7.1, 8.3, 9.0, 10.4, 11.1, 12.2, 13.3];
2460        let r = paired_t_test(&before, &after).expect("should compute");
2461        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2462        assert!(r.statistic < 0.0); // after > before
2463    }
2464
2465    #[test]
2466    fn paired_edge_cases() {
2467        assert!(paired_t_test(&[1.0, 2.0], &[3.0]).is_none()); // length mismatch
2468        assert!(paired_t_test(&[1.0], &[2.0]).is_none()); // n < 2
2469    }
2470
2471    // -----------------------------------------------------------------------
2472    // ANOVA
2473    // -----------------------------------------------------------------------
2474
2475    #[test]
2476    fn anova_same_means() {
2477        let g1 = [5.0, 5.1, 4.9, 5.0, 5.1];
2478        let g2 = [5.0, 5.2, 4.8, 5.1, 4.9];
2479        let g3 = [5.1, 4.9, 5.0, 5.0, 5.1];
2480        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2481        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2482    }
2483
2484    #[test]
2485    fn anova_different_means() {
2486        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2487        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2488        let g3 = [10.0, 11.0, 12.0, 11.0, 10.5];
2489        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2490        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2491        assert!(r.df_between == 2);
2492        assert!(r.df_within == 12);
2493    }
2494
2495    #[test]
2496    fn anova_ss_decomposition() {
2497        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2498        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2499        let r = one_way_anova(&[&g1, &g2]).expect("should compute");
2500        // SS_total = SS_between + SS_within
2501        let all_data: Vec<f64> = g1.iter().chain(g2.iter()).copied().collect();
2502        let ss_total: f64 = all_data.iter().map(|&x| (x - r.grand_mean).powi(2)).sum();
2503        assert!(
2504            (ss_total - (r.ss_between + r.ss_within)).abs() < 1e-10,
2505            "SS decomposition: {ss_total} vs {} + {}",
2506            r.ss_between,
2507            r.ss_within
2508        );
2509    }
2510
2511    #[test]
2512    fn anova_edge_cases() {
2513        let g1 = [1.0, 2.0, 3.0];
2514        assert!(one_way_anova(&[&g1]).is_none()); // < 2 groups
2515    }
2516
2517    // -----------------------------------------------------------------------
2518    // Chi-squared goodness of fit
2519    // -----------------------------------------------------------------------
2520
2521    #[test]
2522    fn chi2_gof_uniform() {
2523        // Perfect uniform distribution
2524        let observed = [25.0, 25.0, 25.0, 25.0];
2525        let expected = [25.0, 25.0, 25.0, 25.0];
2526        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2527        assert!((r.statistic).abs() < 1e-15);
2528        assert!((r.p_value - 1.0).abs() < 0.01);
2529    }
2530
2531    #[test]
2532    fn chi2_gof_significant() {
2533        let observed = [90.0, 10.0];
2534        let expected = [50.0, 50.0];
2535        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2536        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2537    }
2538
2539    #[test]
2540    fn chi2_gof_edge_cases() {
2541        assert!(chi_squared_goodness_of_fit(&[10.0], &[10.0]).is_none()); // < 2
2542        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0, 0.0]).is_none()); // expected 0
2543        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0]).is_none()); // mismatch
2544    }
2545
2546    // -----------------------------------------------------------------------
2547    // Chi-squared independence
2548    // -----------------------------------------------------------------------
2549
2550    #[test]
2551    fn chi2_independence_significant() {
2552        // Strong association
2553        let table = [30.0, 10.0, 10.0, 50.0];
2554        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2555        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2556        assert!((r.df - 1.0).abs() < 1e-10);
2557    }
2558
2559    #[test]
2560    fn chi2_independence_not_significant() {
2561        // No association
2562        let table = [25.0, 25.0, 25.0, 25.0];
2563        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2564        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2565    }
2566
2567    #[test]
2568    fn chi2_independence_3x3() {
2569        let table = [10.0, 20.0, 30.0, 40.0, 30.0, 20.0, 20.0, 25.0, 25.0];
2570        let r = chi_squared_independence(&table, 3, 3).expect("should compute");
2571        assert!((r.df - 4.0).abs() < 1e-10);
2572        assert!(r.p_value < 0.05);
2573    }
2574
2575    #[test]
2576    fn chi2_independence_edge_cases() {
2577        assert!(chi_squared_independence(&[10.0, 20.0], 1, 2).is_none()); // 1 row
2578        assert!(chi_squared_independence(&[10.0, 20.0], 2, 1).is_none()); // 1 col
2579        assert!(chi_squared_independence(&[10.0], 2, 2).is_none()); // wrong size
2580    }
2581
2582    // -----------------------------------------------------------------------
2583    // Jarque-Bera
2584    // -----------------------------------------------------------------------
2585
2586    #[test]
2587    fn jb_normal_data() {
2588        // Symmetric, light-tailed data
2589        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2590        let r = jarque_bera_test(&data).expect("should compute");
2591        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2592    }
2593
2594    #[test]
2595    fn jb_skewed_data() {
2596        // Highly right-skewed
2597        let data = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 20.0, 50.0];
2598        let r = jarque_bera_test(&data).expect("should compute");
2599        assert!(r.p_value < 0.05, "p = {}", r.p_value);
2600    }
2601
2602    /// The statistic is the one the cited paper defines, which is the one
2603    /// SciPy, R and statsmodels report -- moment ratios, not the bias-adjusted
2604    /// estimators Excel's SKEW()/KURT() return.
2605    ///
2606    /// Reference values computed from the definition, independently of the
2607    /// implementation: g1 = m3/m2^1.5, g2 = m4/m2^2 - 3, JB = n/6 (g1^2 + g2^2/4).
2608    #[test]
2609    fn jb_is_the_moment_form_the_paper_defines() {
2610        let data = [
2611            2.1, 3.4, 1.9, 5.6, 2.2, 3.1, 4.8, 2.9, 3.3, 7.2, 2.5, 3.0, 4.1, 2.7, 3.8, 2.4, 6.1,
2612            3.6, 2.8, 3.2,
2613        ];
2614        let r = jarque_bera_test(&data).expect("20 finite points");
2615        assert!(
2616            (r.statistic - 5.563_714_337_9).abs() < 1e-8,
2617            "JB = {}",
2618            r.statistic
2619        );
2620
2621        // What it used to compute, from the adjusted estimators: 35 % higher on
2622        // this sample. The gap is not a rounding difference to be tolerated.
2623        let n = data.len() as f64;
2624        let big_g1 = u_numflow::stats::skewness(&data).expect("same data");
2625        let big_g2 = u_numflow::stats::kurtosis(&data).expect("same data");
2626        let adjusted = (n / 6.0) * (big_g1 * big_g1 + big_g2 * big_g2 / 4.0);
2627        assert!(
2628            adjusted > r.statistic * 1.3,
2629            "the two forms differ by more than a third here: {adjusted} vs {}",
2630            r.statistic
2631        );
2632    }
2633
2634    /// chi-squared with two degrees of freedom is an exponential, so the
2635    /// p-value is `exp(-JB/2)` exactly -- and stays a number where `1 - cdf`
2636    /// rounds to zero.
2637    #[test]
2638    fn jb_p_value_keeps_its_digits_in_the_tail() {
2639        // A ramp with one far outlier: JB near 93, where `1 - cdf` is exactly
2640        // zero in f64 and the true p-value is about 7e-21.
2641        let mut data: Vec<f64> = (1..20).map(f64::from).collect();
2642        data.push(60.0);
2643        let r = jarque_bera_test(&data).expect("20 finite points");
2644        assert!(r.statistic > 90.0, "JB = {}", r.statistic);
2645        assert!(
2646            1.0 - special::chi_squared_cdf(r.statistic, 2.0) == 0.0,
2647            "this sample is only interesting because the complement underflows"
2648        );
2649        assert!(
2650            r.p_value > 0.0,
2651            "the complement returns 0 here; this must not"
2652        );
2653        assert!(
2654            (r.p_value - 6.702e-21).abs() < 1e-23,
2655            "p = {} (expected about 6.7e-21)",
2656            r.p_value
2657        );
2658        assert!(
2659            (r.p_value - (-r.statistic / 2.0).exp()).abs() <= f64::EPSILON * r.p_value,
2660            "p = {} vs exp(-JB/2) = {}",
2661            r.p_value,
2662            (-r.statistic / 2.0).exp()
2663        );
2664        // And it still agrees with the distribution it claims, where that is
2665        // computable at all.
2666        let small = jarque_bera_test(&[-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0])
2667            .expect("10 finite points");
2668        let via_cdf = 1.0 - special::chi_squared_cdf(small.statistic, 2.0);
2669        assert!(
2670            (small.p_value - via_cdf).abs() < 1e-12,
2671            "{} vs {via_cdf}",
2672            small.p_value
2673        );
2674    }
2675
2676    #[test]
2677    fn jb_edge_cases() {
2678        assert!(jarque_bera_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
2679    }
2680
2681    // -----------------------------------------------------------------------
2682    // Anderson-Darling
2683    // -----------------------------------------------------------------------
2684
2685    #[test]
2686    fn ad_normal_data() {
2687        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2688        let r = anderson_darling_test(&data).expect("should compute");
2689        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2690        assert!(r.statistic > 0.0, "A2 = {}", r.statistic);
2691        assert!(r.statistic_star > r.statistic, "A*2 should be > A2");
2692    }
2693
2694    #[test]
2695    fn ad_skewed_data() {
2696        // Exponential-like data — not normal
2697        let data = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3, 2.1, 3.4, 5.5, 8.9, 14.4, 23.3];
2698        let r = anderson_darling_test(&data).expect("should compute");
2699        assert!(
2700            r.p_value < 0.05,
2701            "p = {} (should reject normality)",
2702            r.p_value
2703        );
2704    }
2705
2706    #[test]
2707    fn ad_bimodal_data() {
2708        // Bimodal data — clearly not normal
2709        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
2710        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
2711        let r = anderson_darling_test(&data).expect("should compute");
2712        assert!(
2713            r.p_value < 0.01,
2714            "p = {} (bimodal should reject normality)",
2715            r.p_value
2716        );
2717    }
2718
2719    #[test]
2720    fn ad_large_normal_sample() {
2721        let n = 100;
2722        let data: Vec<f64> = (1..=n)
2723            .map(|i| {
2724                let p = (i as f64 - 0.5) / n as f64;
2725                special::inverse_normal_cdf(p)
2726            })
2727            .collect();
2728        let r = anderson_darling_test(&data).expect("should compute");
2729        assert!(r.p_value > 0.05, "p = {} for normal quantiles", r.p_value);
2730    }
2731
2732    #[test]
2733    fn ad_edge_cases() {
2734        assert!(anderson_darling_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
2735        assert!(anderson_darling_test(&[5.0; 10]).is_none()); // constant
2736        assert!(anderson_darling_test(&[1.0, f64::NAN, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).is_none());
2737    }
2738
2739    #[test]
2740    fn ad_p_value_ranges() {
2741        // Test different A*² ranges for p-value formula
2742        // Use datasets that produce different A*² magnitudes
2743
2744        // Near-normal → small A*² (< 0.2 range)
2745        let near_normal = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
2746        let r = anderson_darling_test(&near_normal).expect("should compute");
2747        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2748
2749        // Heavy-tailed → large A*² (>= 0.6 range)
2750        let heavy_tail = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3, 2.1, 3.4, 5.5, 8.9, 14.4, 23.3];
2751        let r = anderson_darling_test(&heavy_tail).expect("should compute");
2752        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2753    }
2754
2755    // -----------------------------------------------------------------------
2756    // Anderson-Darling normality (anderson_darling_normality)
2757    // -----------------------------------------------------------------------
2758
2759    #[test]
2760    fn ad_normal_data_large_p() {
2761        // Clearly normal data → cannot reject normality (p > 0.05)
2762        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2763        let r = anderson_darling_normality(&data).unwrap();
2764        assert!(r.p_value > 0.05, "p={}", r.p_value);
2765    }
2766
2767    #[test]
2768    fn ad_exponential_data_small_p() {
2769        // Clearly non-normal (exponential) → reject normality (p < 0.05)
2770        let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
2771        let r = anderson_darling_normality(&data).unwrap();
2772        assert!(r.p_value < 0.05, "p={}", r.p_value);
2773    }
2774
2775    #[test]
2776    fn ad_statistic_non_negative() {
2777        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
2778        let r = anderson_darling_normality(&data).unwrap();
2779        assert!(r.statistic >= 0.0);
2780        assert!(r.statistic_modified >= r.statistic);
2781    }
2782
2783    #[test]
2784    fn ad_p_value_in_range() {
2785        let data = [5.0, 5.1, 4.9, 5.05, 4.95, 5.02, 4.98, 5.0];
2786        let r = anderson_darling_normality(&data).unwrap();
2787        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2788    }
2789
2790    #[test]
2791    fn ad_insufficient_data() {
2792        assert!(anderson_darling_normality(&[1.0, 2.0]).is_none()); // n < 3
2793    }
2794
2795    #[test]
2796    fn ad_degenerate_data() {
2797        // All same value → std = 0 → None
2798        assert!(anderson_darling_normality(&[5.0, 5.0, 5.0, 5.0]).is_none());
2799    }
2800
2801    #[test]
2802    fn ad_modified_statistic_formula() {
2803        // A²* > A² for any finite n
2804        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
2805        let r = anderson_darling_normality(&data).unwrap();
2806        assert!(r.statistic_modified > r.statistic - 1e-10);
2807    }
2808
2809    /// Validates A²* = A² · (1 + 0.75/n + 2.25/n²) against manual computation.
2810    ///
2811    /// Reference: Stephens (1974), "EDF statistics for goodness of fit",
2812    /// J. Amer. Statist. Assoc. 69(347), 730–737 (composite normal case).
2813    #[test]
2814    fn ad_correction_factor_formula() {
2815        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2816        let n = data.len() as f64;
2817        let r = anderson_darling_normality(&data).unwrap();
2818
2819        // Verify the Stephens correction formula A²* = A² · (1 + 0.75/n + 2.25/n²)
2820        let expected_star = r.statistic * (1.0 + 0.75 / n + 2.25 / (n * n));
2821        assert!(
2822            (r.statistic_modified - expected_star).abs() < 1e-12,
2823            "A²* = {}, expected = {}",
2824            r.statistic_modified,
2825            expected_star
2826        );
2827
2828        // A² must be positive (it is a sum of log terms with negative coefficient)
2829        assert!(
2830            r.statistic >= 0.0,
2831            "A² = {} must be non-negative",
2832            r.statistic
2833        );
2834    }
2835
2836    /// Validates that the A² summation formula implements:
2837    /// A² = -n - (1/n)·Σᵢ₌₀ⁿ⁻¹ (2i+1)·[ln Φ(zᵢ) + ln(1 − Φ(z_{n−1−i}))]
2838    ///
2839    /// This tests the formula structure by checking that a perfectly symmetric
2840    /// dataset centered at 0 produces a smaller A² than a skewed dataset.
2841    #[test]
2842    fn ad_formula_structure_symmetric_vs_skewed() {
2843        // Symmetric around mean → smaller A²
2844        let symmetric = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 0.0];
2845        // Right-skewed → larger A² (heavier right tail deviates from normality)
2846        let skewed: Vec<f64> = (1..=10).map(|i| (i as f64 * 0.5).exp()).collect();
2847
2848        let r_sym = anderson_darling_normality(&symmetric).unwrap();
2849        let r_skew = anderson_darling_normality(&skewed).unwrap();
2850
2851        assert!(
2852            r_sym.statistic < r_skew.statistic,
2853            "Symmetric A²={} should be less than skewed A²={}",
2854            r_sym.statistic,
2855            r_skew.statistic
2856        );
2857    }
2858
2859    /// Validates p-value invariants for the Anderson-Darling test.
2860    ///
2861    /// - Normal data: p > 0.05 (cannot reject normality)
2862    /// - Exponential data: p < 0.05 (reject normality)
2863    #[test]
2864    fn ad_pvalue_invariants() {
2865        // Near-normal data — tightly clustered, should not reject normality
2866        let normal_data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2867        let r_normal = anderson_darling_normality(&normal_data).unwrap();
2868        assert!(
2869            r_normal.p_value > 0.05,
2870            "Normal data: p = {} (expected > 0.05)",
2871            r_normal.p_value
2872        );
2873
2874        // Exponential data — heavy right tail, should reject normality
2875        let exp_data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
2876        let r_exp = anderson_darling_normality(&exp_data).unwrap();
2877        assert!(
2878            r_exp.p_value < 0.05,
2879            "Exponential data: p = {} (expected < 0.05)",
2880            r_exp.p_value
2881        );
2882    }
2883
2884    // -----------------------------------------------------------------------
2885    // Shapiro-Wilk
2886    // -----------------------------------------------------------------------
2887
2888    #[test]
2889    fn sw_normal_data() {
2890        // Approximately normal data (symmetric, bell-shaped)
2891        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2892        let r = shapiro_wilk_test(&data).expect("should compute");
2893        assert!(r.w > 0.9, "W = {}", r.w);
2894        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2895    }
2896
2897    #[test]
2898    fn sw_bimodal_data() {
2899        // Bimodal data — clearly not normal
2900        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
2901        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
2902        let r = shapiro_wilk_test(&data).expect("should compute");
2903        assert!(
2904            r.p_value < 0.01,
2905            "p = {} (bimodal should reject normality)",
2906            r.p_value
2907        );
2908    }
2909
2910    #[test]
2911    fn sw_n3() {
2912        let data = [1.0, 2.0, 3.0];
2913        let r = shapiro_wilk_test(&data).expect("n=3 should work");
2914        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
2915        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
2916    }
2917
2918    #[test]
2919    fn sw_n4() {
2920        let data = [1.0, 2.0, 3.0, 4.0];
2921        let r = shapiro_wilk_test(&data).expect("n=4 should work");
2922        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
2923        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
2924    }
2925
2926    #[test]
2927    fn sw_n5() {
2928        let data = [-1.0, -0.5, 0.0, 0.5, 1.0];
2929        let r = shapiro_wilk_test(&data).expect("n=5 should work");
2930        assert!(r.w > 0.9, "W = {}", r.w);
2931        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2932    }
2933
2934    #[test]
2935    fn sw_skewed_data() {
2936        // Exponential-like data — not normal
2937        let data = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3, 2.1, 3.4, 5.5, 8.9, 14.4, 23.3];
2938        let r = shapiro_wilk_test(&data).expect("should compute");
2939        assert!(
2940            r.p_value < 0.05,
2941            "p = {} (skewed data should reject normality)",
2942            r.p_value
2943        );
2944    }
2945
2946    #[test]
2947    fn sw_large_normal_sample() {
2948        // Generate pseudo-normal data via Box-Muller-like approach
2949        // Use linearly spaced quantiles from standard normal
2950        let n = 100;
2951        let data: Vec<f64> = (1..=n)
2952            .map(|i| {
2953                let p = (i as f64 - 0.5) / n as f64;
2954                special::inverse_normal_cdf(p)
2955            })
2956            .collect();
2957        let r = shapiro_wilk_test(&data).expect("should compute");
2958        assert!(r.w > 0.99, "W = {} for normal quantiles", r.w);
2959        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2960    }
2961
2962    #[test]
2963    fn sw_w_bounded() {
2964        // W should be in (0, 1] for any valid data
2965        let datasets: Vec<Vec<f64>> = vec![
2966            vec![1.0, 2.0, 3.0],
2967            vec![1.0, 1.0, 2.0, 3.0, 3.0],
2968            vec![0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
2969            (0..20).map(|i| (i as f64).powi(2)).collect(),
2970        ];
2971        for (idx, data) in datasets.iter().enumerate() {
2972            let r = shapiro_wilk_test(data).unwrap_or_else(|| panic!("dataset {idx} should work"));
2973            assert!(r.w > 0.0 && r.w <= 1.0, "dataset {idx}: W = {}", r.w);
2974            assert!(
2975                r.p_value >= 0.0 && r.p_value <= 1.0,
2976                "dataset {idx}: p = {}",
2977                r.p_value
2978            );
2979        }
2980    }
2981
2982    #[test]
2983    fn sw_edge_cases() {
2984        assert!(shapiro_wilk_test(&[1.0, 2.0]).is_none()); // n < 3
2985        assert!(shapiro_wilk_test(&[]).is_none()); // empty
2986        assert!(shapiro_wilk_test(&[5.0, 5.0, 5.0]).is_none()); // constant
2987        assert!(shapiro_wilk_test(&[1.0, f64::NAN, 3.0]).is_none()); // NaN
2988        assert!(shapiro_wilk_test(&[1.0, f64::INFINITY, 3.0]).is_none()); // Inf
2989    }
2990
2991    #[test]
2992    fn sw_n5001_rejected() {
2993        let data: Vec<f64> = (0..5001).map(|i| i as f64).collect();
2994        assert!(shapiro_wilk_test(&data).is_none()); // n > 5000
2995    }
2996
2997    // -----------------------------------------------------------------------
2998    // Mann-Whitney U
2999    // -----------------------------------------------------------------------
3000
3001    #[test]
3002    fn mw_clearly_different() {
3003        let a = [1.0, 2.0, 3.0, 4.0, 5.0];
3004        let b = [6.0, 7.0, 8.0, 9.0, 10.0];
3005        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3006        assert!(r.p_value < 0.05, "p = {}", r.p_value);
3007    }
3008
3009    #[test]
3010    fn mw_same_distribution() {
3011        let a = [1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0];
3012        let b = [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0];
3013        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3014        assert!(
3015            r.p_value > 0.3,
3016            "p = {} (interleaved, same dist)",
3017            r.p_value
3018        );
3019    }
3020
3021    #[test]
3022    fn mw_with_ties() {
3023        let a = [1.0, 2.0, 2.0, 3.0, 3.0];
3024        let b = [3.0, 4.0, 4.0, 5.0, 5.0];
3025        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3026        assert!(r.p_value < 0.05, "p = {} (shifted with ties)", r.p_value);
3027    }
3028
3029    #[test]
3030    fn mw_different_sizes() {
3031        let a = [1.0, 2.0, 3.0];
3032        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
3033        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3034        assert!(r.p_value < 0.05);
3035    }
3036
3037    #[test]
3038    fn mw_edge_cases() {
3039        assert!(mann_whitney_u_test(&[1.0], &[2.0, 3.0]).is_none()); // n1 < 2
3040        assert!(mann_whitney_u_test(&[1.0, 2.0], &[3.0]).is_none()); // n2 < 2
3041        assert!(mann_whitney_u_test(&[1.0, f64::NAN], &[2.0, 3.0]).is_none());
3042    }
3043
3044    // -----------------------------------------------------------------------
3045    // Wilcoxon signed-rank
3046    // -----------------------------------------------------------------------
3047
3048    #[test]
3049    fn wilcoxon_significant_increase() {
3050        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
3051        let after = [6.5, 7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5];
3052        let r = wilcoxon_signed_rank_test(&before, &after).expect("should compute");
3053        assert!(r.p_value < 0.05, "p = {} (consistent increase)", r.p_value);
3054    }
3055
3056    #[test]
3057    fn wilcoxon_no_difference() {
3058        let x = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3059        let y = [5.1, 5.9, 7.1, 7.9, 9.1, 9.9];
3060        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3061        assert!(r.p_value > 0.3, "p = {} (small random diffs)", r.p_value);
3062    }
3063
3064    #[test]
3065    fn wilcoxon_with_ties() {
3066        // Some differences are equal in magnitude
3067        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
3068        let y = [0.0, 1.0, 2.0, 3.0, 4.0]; // constant difference = 1.0
3069        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3070        // All differences positive with ties in magnitude
3071        assert!(r.statistic > 0.0);
3072    }
3073
3074    #[test]
3075    fn wilcoxon_with_zero_diffs() {
3076        // Some pairs are equal → zero differences discarded
3077        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
3078        let y = [1.0, 2.0, 3.0, 3.0, 4.0]; // first 3 are zero diffs
3079        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3080        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3081    }
3082
3083    #[test]
3084    fn wilcoxon_edge_cases() {
3085        assert!(wilcoxon_signed_rank_test(&[1.0, 2.0], &[3.0]).is_none()); // mismatch
3086        assert!(wilcoxon_signed_rank_test(&[1.0], &[2.0]).is_none()); // n < 2
3087                                                                      // All zero differences → fewer than 2 non-zero diffs
3088        assert!(wilcoxon_signed_rank_test(&[5.0, 5.0], &[5.0, 5.0]).is_none());
3089    }
3090
3091    /// Oracle: the exact p by enumerating every arrangement of the ranks
3092    /// (Python, independent of the recursion). The normal approximation this
3093    /// replaced reported the second column -- always too small, and below
3094    /// 0.05 in the first and third rows where the exact p is not.
3095    #[test]
3096    fn mann_whitney_small_samples_use_the_exact_distribution() {
3097        // (n1, n2, U, exact p, old normal-approximation p)
3098        let cases = [
3099            (3, 3, 0, 0.1000, 0.0495),
3100            (4, 4, 0, 0.0286, 0.0209),
3101            (4, 4, 1, 0.0571, 0.0433),
3102            (5, 5, 2, 0.0317, 0.0283),
3103            (8, 8, 13, 0.0499, 0.0460),
3104            (10, 10, 23, 0.0433, 0.0413),
3105        ];
3106        for (n1, n2, u, exact, old) in cases {
3107            let (a, b) = separated_samples(n1, n2, u);
3108            let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3109            assert_eq!(r.statistic, u as f64);
3110            assert!(
3111                (r.p_value - exact).abs() < 1e-4,
3112                "n={n1},{n2} U={u}: p={} want {exact} (old {old})",
3113                r.p_value
3114            );
3115        }
3116    }
3117
3118    /// Two samples of sizes `n1`, `n2` with no ties whose U₁ is exactly `u`:
3119    /// sample 1 takes the lowest ranks, then its top member is moved up past
3120    /// `u` members of sample 2 one step at a time.
3121    fn separated_samples(n1: usize, n2: usize, u: usize) -> (Vec<f64>, Vec<f64>) {
3122        let mut ranks_a: Vec<usize> = (1..=n1).collect();
3123        let mut remaining = u;
3124        for k in (0..n1).rev() {
3125            let room = n2.min(remaining);
3126            ranks_a[k] += room;
3127            remaining -= room;
3128            if remaining == 0 {
3129                break;
3130            }
3131        }
3132        let ranks_b: Vec<usize> = (1..=n1 + n2).filter(|r| !ranks_a.contains(r)).collect();
3133        (
3134            ranks_a.iter().map(|&r| r as f64).collect(),
3135            ranks_b.iter().map(|&r| r as f64).collect(),
3136        )
3137    }
3138
3139    /// Oracle: exact p by enumerating all 2ⁿ sign assignments (Python).
3140    #[test]
3141    fn wilcoxon_small_samples_use_the_exact_distribution() {
3142        // (n, T+, exact p)
3143        let cases = [
3144            (5, 0, 0.0625),
3145            (5, 1, 0.125),
3146            (6, 2, 0.09375),
3147            (8, 5, 0.078125),
3148            (10, 8, 0.048828125),
3149            (12, 14, 0.0522460938),
3150        ];
3151        for (n, t_plus, exact) in cases {
3152            // Differences 1..=n; the ranks summing to t_plus are positive.
3153            let positive = ranks_summing_to(n, t_plus);
3154            let x: Vec<f64> = (1..=n)
3155                .map(|r| {
3156                    if positive.contains(&r) {
3157                        r as f64
3158                    } else {
3159                        -(r as f64)
3160                    }
3161                })
3162                .collect();
3163            let y = vec![0.0; n];
3164            let r = wilcoxon_signed_rank_test(&x, &y).expect("valid samples");
3165            assert_eq!(r.statistic, t_plus as f64);
3166            assert!(
3167                (r.p_value - exact).abs() < 1e-9,
3168                "n={n} T+={t_plus}: p={} want {exact}",
3169                r.p_value
3170            );
3171        }
3172    }
3173
3174    /// A set of distinct ranks from 1..=n summing to `target` (greedy from the top).
3175    fn ranks_summing_to(n: usize, target: usize) -> Vec<usize> {
3176        let mut out = Vec::new();
3177        let mut left = target;
3178        for r in (1..=n).rev() {
3179            if r <= left {
3180                out.push(r);
3181                left -= r;
3182            }
3183        }
3184        assert_eq!(left, 0);
3185        out
3186    }
3187
3188    /// Above the exact range, or with ties, the p comes from the normal
3189    /// approximation with continuity correction.
3190    #[test]
3191    fn rank_tests_fall_back_to_continuity_corrected_normal() {
3192        let a: Vec<f64> = (0..60).map(|i| i as f64).collect();
3193        let b: Vec<f64> = (0..60).map(|i| i as f64 + 20.5).collect();
3194        let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3195        let (n1, n2) = (60.0, 60.0);
3196        let mu = n1 * n2 / 2.0;
3197        let sigma = (n1 * n2 * (n1 + n2 + 1.0) / 12.0_f64).sqrt();
3198        let z = ((r.statistic - mu).abs() - 0.5) / sigma;
3199        let want = 2.0 * special::standard_normal_sf(z);
3200        assert!((r.p_value - want).abs() < 1e-12);
3201
3202        // At the boundary the two methods agree closely.
3203        let a: Vec<f64> = (0..50).map(|i| i as f64).collect();
3204        let b: Vec<f64> = (0..50).map(|i| i as f64 + 10.5).collect();
3205        let exact = mann_whitney_u_test(&a, &b).expect("valid samples").p_value;
3206        let mu = 1250.0;
3207        let sigma = (2500.0 * 101.0 / 12.0_f64).sqrt();
3208        let u = mann_whitney_u_test(&a, &b)
3209            .expect("valid samples")
3210            .statistic;
3211        let approx = continuity_corrected_p(u, mu, sigma * sigma);
3212        assert!(
3213            (exact - approx).abs() < 1e-3,
3214            "exact {exact} vs approx {approx}"
3215        );
3216    }
3217
3218    // -----------------------------------------------------------------------
3219    // Kruskal-Wallis
3220    // -----------------------------------------------------------------------
3221
3222    #[test]
3223    fn kw_clearly_different() {
3224        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3225        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3226        let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
3227        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3228        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3229        assert!((r.df - 2.0).abs() < 1e-10);
3230    }
3231
3232    #[test]
3233    fn kw_same_distribution() {
3234        let g1 = [1.0, 3.0, 5.0, 7.0, 9.0];
3235        let g2 = [2.0, 4.0, 6.0, 8.0, 10.0];
3236        let r = kruskal_wallis_test(&[&g1, &g2]).expect("should compute");
3237        assert!(r.p_value > 0.3, "p = {} (interleaved)", r.p_value);
3238    }
3239
3240    #[test]
3241    fn kw_with_ties() {
3242        let g1 = [1.0, 2.0, 2.0, 3.0];
3243        let g2 = [3.0, 4.0, 4.0, 5.0];
3244        let g3 = [5.0, 6.0, 6.0, 7.0];
3245        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3246        assert!(r.statistic > 0.0);
3247    }
3248
3249    #[test]
3250    fn kw_edge_cases() {
3251        let g1 = [1.0, 2.0, 3.0];
3252        assert!(kruskal_wallis_test(&[&g1]).is_none()); // < 2 groups
3253    }
3254
3255    // -----------------------------------------------------------------------
3256    // Levene (Brown-Forsythe)
3257    // -----------------------------------------------------------------------
3258
3259    #[test]
3260    fn levene_equal_variance() {
3261        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3262        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3263        let r = levene_test(&[&g1, &g2]).expect("should compute");
3264        // Both have same spread, different means → equal variance
3265        assert!(r.p_value > 0.3, "p = {} (equal variance)", r.p_value);
3266    }
3267
3268    #[test]
3269    fn levene_unequal_variance() {
3270        let g1 = [4.5, 4.8, 5.0, 5.2, 5.5]; // small spread
3271        let g2 = [0.0, 2.0, 5.0, 8.0, 10.0]; // large spread
3272        let r = levene_test(&[&g1, &g2]).expect("should compute");
3273        assert!(r.p_value < 0.05, "p = {} (unequal variance)", r.p_value);
3274    }
3275
3276    #[test]
3277    fn levene_three_groups() {
3278        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3279        let g2 = [0.0, 3.0, 5.0, 7.0, 10.0];
3280        let g3 = [-5.0, 0.0, 5.0, 10.0, 15.0];
3281        let r = levene_test(&[&g1, &g2, &g3]).expect("should compute");
3282        assert!(r.df >= 2.0);
3283    }
3284
3285    #[test]
3286    fn levene_edge_cases() {
3287        let g1 = [1.0, 2.0, 3.0];
3288        assert!(levene_test(&[&g1]).is_none()); // < 2 groups
3289    }
3290
3291    // -----------------------------------------------------------------------
3292    // Multiple comparison correction
3293    // -----------------------------------------------------------------------
3294
3295    #[test]
3296    fn bonferroni_basic() {
3297        let ps = [0.01, 0.04, 0.03, 0.005];
3298        let adj = bonferroni_correction(&ps).expect("should compute");
3299        assert!((adj[0] - 0.04).abs() < 1e-10);
3300        assert!((adj[1] - 0.16).abs() < 1e-10);
3301        assert!((adj[2] - 0.12).abs() < 1e-10);
3302        assert!((adj[3] - 0.02).abs() < 1e-10);
3303    }
3304
3305    #[test]
3306    fn bonferroni_capped_at_one() {
3307        let ps = [0.5, 0.6];
3308        let adj = bonferroni_correction(&ps).expect("should compute");
3309        assert!((adj[0] - 1.0).abs() < 1e-10);
3310        assert!((adj[1] - 1.0).abs() < 1e-10);
3311    }
3312
3313    #[test]
3314    fn bh_basic() {
3315        let ps = [0.01, 0.04, 0.03, 0.005];
3316        let adj = benjamini_hochberg(&ps).expect("should compute");
3317        // All adjusted p-values should be >= original
3318        for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
3319            assert!(
3320                adjusted >= orig - 1e-15,
3321                "adj[{i}] = {adjusted} < original {orig}"
3322            );
3323        }
3324        // Adjusted p-values should still be ordered (weakly) by original order
3325        // after reordering by original p-value
3326    }
3327
3328    #[test]
3329    fn bh_all_significant() {
3330        let ps = [0.001, 0.002, 0.003];
3331        let adj = benjamini_hochberg(&ps).expect("should compute");
3332        for &a in &adj {
3333            assert!(a < 0.05);
3334        }
3335    }
3336
3337    #[test]
3338    fn correction_edge_cases() {
3339        assert!(bonferroni_correction(&[]).is_none());
3340        assert!(benjamini_hochberg(&[]).is_none());
3341        assert!(bonferroni_correction(&[f64::NAN]).is_none());
3342    }
3343
3344    // -----------------------------------------------------------------------
3345    // Bartlett test
3346    // -----------------------------------------------------------------------
3347
3348    #[test]
3349    fn bartlett_equal_variances() {
3350        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0];
3351        let g2 = [12.0, 13.0, 14.0, 15.0, 16.0];
3352        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3353        assert!(
3354            r.p_value > 0.9,
3355            "equal variance → p high, got {}",
3356            r.p_value
3357        );
3358    }
3359
3360    #[test]
3361    fn bartlett_unequal_variances() {
3362        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // var ≈ 2.5
3363        let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // var ≈ 250
3364        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3365        assert!(
3366            r.p_value < 0.01,
3367            "very different variances → p < 0.01, got {}",
3368            r.p_value
3369        );
3370        assert!((r.df - 1.0).abs() < 1e-10); // k-1 = 1
3371    }
3372
3373    #[test]
3374    fn bartlett_three_groups() {
3375        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3376        let g2 = [1.5, 2.5, 3.5, 4.5, 5.5];
3377        let g3 = [10.0, 30.0, 50.0, 70.0, 90.0]; // much higher variance
3378        let r = bartlett_test(&[&g1, &g2, &g3]).expect("should compute");
3379        assert!(r.p_value < 0.05, "one group with high variance");
3380        assert!((r.df - 2.0).abs() < 1e-10); // k-1 = 2
3381    }
3382
3383    #[test]
3384    fn bartlett_edge_cases() {
3385        let g1 = [1.0, 2.0, 3.0];
3386        assert!(bartlett_test(&[&g1]).is_none()); // < 2 groups
3387
3388        let g2 = [5.0, 5.0, 5.0]; // zero variance
3389        assert!(bartlett_test(&[&g1, &g2]).is_none());
3390
3391        let g3 = [1.0]; // group too small
3392        assert!(bartlett_test(&[&g1, &g3]).is_none());
3393    }
3394
3395    // -----------------------------------------------------------------------
3396    // Fisher exact test
3397    // -----------------------------------------------------------------------
3398
3399    #[test]
3400    fn fisher_tea_tasting() {
3401        // Classic Fisher tea-tasting: [[3,1],[1,3]]
3402        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3403        // Known two-tailed p ≈ 0.4857
3404        assert!(
3405            (r.p_value - 0.4857).abs() < 0.01,
3406            "p ≈ 0.4857, got {}",
3407            r.p_value
3408        );
3409    }
3410
3411    #[test]
3412    fn fisher_significant() {
3413        // Strong association: [[10, 0], [0, 10]]
3414        let r = fisher_exact_test(10, 0, 0, 10).expect("should compute");
3415        assert!(r.p_value < 0.001, "perfect association → p very small");
3416    }
3417
3418    #[test]
3419    fn fisher_no_association() {
3420        // Proportional table: [[5, 5], [5, 5]]
3421        let r = fisher_exact_test(5, 5, 5, 5).expect("should compute");
3422        assert!(
3423            r.p_value > 0.9,
3424            "no association → p ≈ 1.0, got {}",
3425            r.p_value
3426        );
3427    }
3428
3429    #[test]
3430    fn fisher_small_table() {
3431        // [[1, 0], [0, 1]]
3432        let r = fisher_exact_test(1, 0, 0, 1).expect("should compute");
3433        assert!(r.p_value > 0.0 && r.p_value <= 1.0);
3434    }
3435
3436    #[test]
3437    fn fisher_asymmetric() {
3438        // [[8, 2], [1, 5]]
3439        let r = fisher_exact_test(8, 2, 1, 5).expect("should compute");
3440        assert!(r.p_value < 0.05, "significant association");
3441    }
3442
3443    #[test]
3444    fn fisher_edge_cases() {
3445        // Zero marginals → None
3446        assert!(fisher_exact_test(0, 0, 1, 2).is_none()); // row1 = 0
3447        assert!(fisher_exact_test(1, 2, 0, 0).is_none()); // row2 = 0
3448        assert!(fisher_exact_test(0, 1, 0, 2).is_none()); // col1 = 0
3449    }
3450
3451    #[test]
3452    fn fisher_odds_ratio() {
3453        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3454        // OR = (3*3)/(1*1) = 9
3455        assert!(
3456            (r.statistic - 9.0).abs() < 1e-10,
3457            "OR = 9, got {}",
3458            r.statistic
3459        );
3460    }
3461
3462    // -----------------------------------------------------------------------
3463    // Mann-Kendall trend test
3464    // -----------------------------------------------------------------------
3465
3466    #[test]
3467    fn mk_increasing_trend() {
3468        let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
3469        let r = mann_kendall_test(&data).expect("should compute");
3470        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3471        assert!(r.kendall_tau > 0.8, "tau = {}", r.kendall_tau);
3472        assert!(r.sen_slope > 0.0, "slope = {}", r.sen_slope);
3473        assert!(r.s_statistic > 0);
3474    }
3475
3476    #[test]
3477    fn mk_decreasing_trend() {
3478        let data = [10.0, 9.2, 8.5, 7.1, 6.3, 5.0, 4.2, 3.1, 2.0, 1.1];
3479        let r = mann_kendall_test(&data).expect("should compute");
3480        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3481        assert!(r.kendall_tau < -0.8, "tau = {}", r.kendall_tau);
3482        assert!(r.sen_slope < 0.0, "slope = {}", r.sen_slope);
3483        assert!(r.s_statistic < 0);
3484    }
3485
3486    #[test]
3487    fn mk_no_trend() {
3488        // Random-looking data with no clear trend
3489        let data = [5.0, 3.0, 7.0, 2.0, 8.0, 4.0, 6.0, 1.0, 9.0, 5.0];
3490        let r = mann_kendall_test(&data).expect("should compute");
3491        // Should not detect significant trend
3492        assert!(
3493            r.p_value > 0.05,
3494            "p = {} (should be > 0.05 for no trend)",
3495            r.p_value
3496        );
3497    }
3498
3499    #[test]
3500    fn mk_perfect_monotone() {
3501        let data: Vec<f64> = (0..10).map(|i| i as f64).collect();
3502        let r = mann_kendall_test(&data).expect("should compute");
3503        // Perfect monotone: S = n(n-1)/2 = 45, tau = 1.0
3504        assert_eq!(r.s_statistic, 45);
3505        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3506        assert!((r.sen_slope - 1.0).abs() < 1e-10);
3507    }
3508
3509    #[test]
3510    fn mk_with_ties() {
3511        let data = [1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 5.0];
3512        let r = mann_kendall_test(&data).expect("should compute");
3513        assert!(r.s_statistic > 0);
3514        // Tie correction should reduce variance
3515        let n = data.len() as f64;
3516        let base_var = n * (n - 1.0) * (2.0 * n + 5.0) / 18.0;
3517        assert!(r.variance < base_var, "ties should reduce variance");
3518    }
3519
3520    #[test]
3521    fn mk_edge_cases() {
3522        // Too few data points
3523        assert!(mann_kendall_test(&[1.0, 2.0, 3.0]).is_none());
3524        // NaN
3525        assert!(mann_kendall_test(&[1.0, f64::NAN, 3.0, 4.0]).is_none());
3526        // All identical (zero variance)
3527        assert!(mann_kendall_test(&[5.0, 5.0, 5.0, 5.0]).is_none());
3528    }
3529
3530    #[test]
3531    fn mk_minimum_n() {
3532        // n = 4 should work
3533        let data = [1.0, 2.0, 3.0, 4.0];
3534        let r = mann_kendall_test(&data).expect("n=4 should work");
3535        assert_eq!(r.s_statistic, 6); // C(4,2) = 6 pairs, all positive
3536        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3537    }
3538
3539    #[test]
3540    fn mk_sen_slope_robust_to_outlier() {
3541        // Mostly linear (slope ≈ 1) with one outlier
3542        let data = [1.0, 2.0, 3.0, 100.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3543        let r = mann_kendall_test(&data).expect("should compute");
3544        // Sen's slope should be close to 1.0 despite the outlier at index 3
3545        assert!(
3546            (r.sen_slope - 1.0).abs() < 0.5,
3547            "sen slope = {}, expected ≈ 1.0",
3548            r.sen_slope
3549        );
3550    }
3551
3552    // -----------------------------------------------------------------------
3553    // ADF test
3554    // -----------------------------------------------------------------------
3555
3556    #[test]
3557    fn adf_stationary_mean_reverting() {
3558        // Strongly mean-reverting AR(1) process: y_t = 0.3*y_{t-1} + noise
3559        // This is clearly stationary (|ρ| < 1)
3560        let data = [
3561            0.5, 0.45, -0.2, 0.14, 0.54, -0.04, 0.39, -0.18, 0.35, 0.01, -0.3, 0.21, 0.47, -0.06,
3562            0.38, -0.25, 0.12, 0.44, -0.13, 0.36, -0.09, 0.27, 0.51, -0.15, 0.33, -0.22, 0.18,
3563            0.42, -0.08, 0.31, -0.19, 0.25, 0.48, -0.11, 0.37, -0.24, 0.15, 0.43, -0.07, 0.34,
3564        ];
3565        let r = adf_test(&data, AdfModel::Constant, None).expect("should compute");
3566        // Should reject H₀ (series is stationary)
3567        assert!(
3568            r.rejected[2],
3569            "should reject at 10%: stat={}, cv={}",
3570            r.statistic, r.critical_values[2]
3571        );
3572    }
3573
3574    #[test]
3575    fn adf_nonstationary_random_walk() {
3576        // Cumulative sum simulates random walk (non-stationary)
3577        let increments = [
3578            0.1, -0.2, 0.15, -0.05, 0.3, -0.1, 0.2, -0.15, 0.25, -0.08, 0.12, -0.18, 0.22, -0.07,
3579            0.16, -0.11, 0.19, -0.14, 0.21, -0.09, 0.13, -0.17, 0.24, -0.06, 0.18, -0.12, 0.2,
3580            -0.13, 0.15, -0.1,
3581        ];
3582        let mut walk = Vec::with_capacity(increments.len());
3583        let mut cum = 0.0;
3584        for &inc in &increments {
3585            cum += inc;
3586            walk.push(cum);
3587        }
3588        let r = adf_test(&walk, AdfModel::Constant, None).expect("should compute");
3589        // Random walk should NOT reject at 1%
3590        assert!(
3591            !r.rejected[0],
3592            "should NOT reject at 1%: stat={}, cv={}",
3593            r.statistic, r.critical_values[0]
3594        );
3595    }
3596
3597    #[test]
3598    fn adf_with_fixed_lags() {
3599        // Use wider oscillation to avoid near-singular design matrix
3600        let data: Vec<f64> = (0..50)
3601            .map(|i| (i as f64 * 0.5).sin() + 0.02 * i as f64)
3602            .collect();
3603        let r = adf_test(&data, AdfModel::Constant, Some(2)).expect("should compute");
3604        assert_eq!(r.n_lags, 2);
3605        assert!(r.statistic.is_finite());
3606    }
3607
3608    #[test]
3609    fn adf_constant_trend_model() {
3610        // Linear trend is unit-root-like under "constant" model
3611        // but with "constant+trend" model, it should be recognized
3612        let data: Vec<f64> = (0..30)
3613            .map(|i| i as f64 + (i as f64 * 0.3).sin() * 0.5)
3614            .collect();
3615        let r = adf_test(&data, AdfModel::ConstantTrend, None).expect("should compute");
3616        assert!(r.statistic.is_finite());
3617        assert_eq!(r.critical_values.len(), 3);
3618    }
3619
3620    #[test]
3621    fn adf_edge_cases() {
3622        // Too few data points
3623        assert!(adf_test(&[1.0; 9], AdfModel::Constant, None).is_none());
3624        // NaN
3625        let mut data = vec![0.0; 20];
3626        data[5] = f64::NAN;
3627        assert!(adf_test(&data, AdfModel::Constant, None).is_none());
3628    }
3629
3630    #[test]
3631    fn adf_critical_values_constant() {
3632        // Verify critical values are reasonable for n=100
3633        let cv = adf_critical_values(AdfModel::Constant, 100);
3634        // At n=100: approximately -3.51, -2.89, -2.58
3635        assert!(cv[0] < -3.4 && cv[0] > -3.6, "1% cv = {}", cv[0]);
3636        assert!(cv[1] < -2.8 && cv[1] > -3.0, "5% cv = {}", cv[1]);
3637        assert!(cv[2] < -2.5 && cv[2] > -2.7, "10% cv = {}", cv[2]);
3638    }
3639
3640    #[test]
3641    fn adf_critical_values_ordering() {
3642        let cv = adf_critical_values(AdfModel::Constant, 50);
3643        // 1% < 5% < 10% (more negative for stricter levels)
3644        assert!(cv[0] < cv[1], "1% ({}) should be < 5% ({})", cv[0], cv[1]);
3645        assert!(cv[1] < cv[2], "5% ({}) should be < 10% ({})", cv[1], cv[2]);
3646    }
3647}
3648
3649#[cfg(test)]
3650mod proptests {
3651    use super::*;
3652    use proptest::prelude::*;
3653
3654    proptest! {
3655        #[test]
3656        fn one_sample_p_bounded(
3657            data in proptest::collection::vec(-1e3_f64..1e3, 3..=30),
3658            mu0 in -1e3_f64..1e3
3659        ) {
3660            if let Some(r) = one_sample_t_test(&data, mu0) {
3661                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3662            }
3663        }
3664
3665        #[test]
3666        fn two_sample_p_bounded(
3667            a in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3668            b in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3669        ) {
3670            if let Some(r) = two_sample_t_test(&a, &b) {
3671                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3672            }
3673        }
3674
3675        #[test]
3676        fn anova_p_bounded(
3677            g1 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
3678            g2 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
3679            g3 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
3680        ) {
3681            if let Some(r) = one_way_anova(&[&g1, &g2, &g3]) {
3682                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3683                prop_assert!(r.f_statistic >= 0.0, "F = {}", r.f_statistic);
3684            }
3685        }
3686
3687        #[test]
3688        fn bonferroni_monotone(
3689            ps in proptest::collection::vec(0.001_f64..1.0, 2..=10)
3690        ) {
3691            let adj = bonferroni_correction(&ps).expect("should compute");
3692            for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
3693                prop_assert!(adjusted >= orig - 1e-15,
3694                    "adj[{i}] = {adjusted} < orig = {orig}");
3695            }
3696        }
3697
3698        #[test]
3699        fn shapiro_wilk_p_bounded(
3700            data in proptest::collection::vec(-1e3_f64..1e3, 3..=50)
3701        ) {
3702            if let Some(r) = shapiro_wilk_test(&data) {
3703                prop_assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
3704                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3705            }
3706        }
3707
3708        #[test]
3709        fn anderson_darling_p_bounded(
3710            data in proptest::collection::vec(-1e3_f64..1e3, 8..=100)
3711        ) {
3712            if let Some(r) = anderson_darling_test(&data) {
3713                prop_assert!(r.statistic >= 0.0, "A2 = {}", r.statistic);
3714                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3715            }
3716        }
3717
3718        #[test]
3719        fn mann_whitney_p_bounded(
3720            a in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3721            b in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3722        ) {
3723            if let Some(r) = mann_whitney_u_test(&a, &b) {
3724                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3725                prop_assert!(r.statistic >= 0.0, "U = {}", r.statistic);
3726            }
3727        }
3728
3729        #[test]
3730        fn wilcoxon_p_bounded(
3731            diffs in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3732        ) {
3733            let zeros: Vec<f64> = vec![0.0; diffs.len()];
3734            if let Some(r) = wilcoxon_signed_rank_test(&diffs, &zeros) {
3735                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3736            }
3737        }
3738
3739        #[test]
3740        fn bartlett_p_bounded(
3741            g1 in proptest::collection::vec(0.1_f64..100.0, 3..=15),
3742            g2 in proptest::collection::vec(0.1_f64..100.0, 3..=15),
3743        ) {
3744            if let Some(r) = bartlett_test(&[&g1, &g2]) {
3745                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3746                prop_assert!(r.statistic >= 0.0, "T = {}", r.statistic);
3747            }
3748        }
3749
3750        #[test]
3751        fn fisher_p_bounded(
3752            a in 0_u64..20,
3753            b in 0_u64..20,
3754            c in 0_u64..20,
3755            d in 0_u64..20,
3756        ) {
3757            if let Some(r) = fisher_exact_test(a, b, c, d) {
3758                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3759            }
3760        }
3761
3762        #[test]
3763        fn mann_kendall_p_bounded(
3764            data in proptest::collection::vec(-1e3_f64..1e3, 4..=30)
3765        ) {
3766            if let Some(r) = mann_kendall_test(&data) {
3767                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3768                prop_assert!(r.kendall_tau >= -1.0 && r.kendall_tau <= 1.0,
3769                    "tau = {}", r.kendall_tau);
3770            }
3771        }
3772
3773        #[test]
3774        fn mann_kendall_monotone_increasing(n in 5_usize..=30) {
3775            let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
3776            let r = mann_kendall_test(&data).expect("should compute");
3777            prop_assert!((r.kendall_tau - 1.0).abs() < 1e-10,
3778                "tau should be 1.0 for monotone, got {}", r.kendall_tau);
3779            prop_assert!(r.sen_slope > 0.0, "slope should be positive");
3780        }
3781    }
3782}