stats-claw 0.1.0

Data science on the hot path: in-process, zero-dependency statistical computing for Rust (distributions, hypothesis tests, resampling) validated against scipy
Documentation
//! Exact Kolmogorov–Smirnov null distributions (one- and two-sample).
//!
//! For small samples the asymptotic Kolmogorov tail is inaccurate, so this module
//! provides the finite-`n` exact distributions matching `scipy.stats`'
//! `method="exact"`:
//!
//! * two-sided one-sample — the Marsaglia–Tsang–Wang matrix-power algorithm
//!   (`= scipy.stats.kstwo.sf`),
//! * one-sided one-sample — the Smirnov closed-form series (`= scipy.stats.ksone.sf`),
//! * two-sample — exact lattice-path counting over the integer grid.

use crate::special::ln_choose;
use crate::tests_stat::Alternative;

/// Exact one-sample KS p-value for an observed statistic `d` at sample size `n`.
///
/// The two-sided p-value is `1 − K(n, d)` with `K` the Marsaglia–Tsang–Wang CDF
/// of `Dₙ`; the one-sided (`Less`/`Greater`) p-value is the Smirnov survival
/// function of the corresponding one-sided statistic `D⁺`/`D⁻`. Matches
/// `scipy.stats.ks_1samp(method="exact")`.
///
/// # Arguments
///
/// * `stat` — the observed KS statistic for the chosen alternative (`≥ 0`).
/// * `size` — the sample size (`> 0`).
/// * `alternative` — two-sided uses `Dₙ`; `Less`/`Greater` use the one-sided
///   statistic already computed by the caller.
///
/// # Returns
///
/// The exact p-value in `[0, 1]`.
#[must_use]
pub fn one_sample_p(stat: f64, size: usize, alternative: Alternative) -> f64 {
    match alternative {
        Alternative::TwoSided => (1.0 - kolmogorov_cdf(stat, size)).clamp(0.0, 1.0),
        Alternative::Less | Alternative::Greater => smirnov_sf(stat, size).clamp(0.0, 1.0),
    }
}

/// Marsaglia–Tsang–Wang CDF `P(Dₙ < d)` of the two-sided one-sample KS statistic.
///
/// Builds the `(2k−1)×(2k−1)` `H` matrix (with `k = ⌈n·d⌉`), raises it to the
/// `n`th power, and rescales by `n!/nⁿ` to avoid overflow. This is the exact
/// finite-`n` Kolmogorov distribution used by `scipy.stats.kstwo`.
///
/// # Arguments
///
/// * `d` — the statistic (`0 ≤ d ≤ 1`).
/// * `n` — the sample size.
///
/// # Returns
///
/// `P(Dₙ < d)` in `[0, 1]`.
#[must_use]
fn kolmogorov_cdf(stat: f64, size: usize) -> f64 {
    if stat <= 0.0 {
        return 0.0;
    }
    if stat >= 1.0 {
        return 1.0;
    }
    let size_f = f64_of(size);
    let nd = size_f * stat;
    let k = ceil_usize(nd, size); // k = ⌈n·d⌉.
    let gap = f64_of(k) - nd; // fractional boundary correction h.
    let dim = 2 * k - 1; // the H matrix is dim × dim.

    // Assemble the H matrix (row-major dense), per Marsaglia–Tsang–Wang.
    let mut mat = vec![0.0_f64; dim * dim];
    for row in 0..dim {
        for col in 0..dim {
            if row + 1 >= col {
                set(&mut mat, dim, row, col, 1.0);
            }
        }
    }
    for idx in 0..dim {
        // First column and last row carry the boundary correction gap^{idx+1}.
        let first = get(&mat, dim, idx, 0);
        set(&mut mat, dim, idx, 0, first - gap.powi(i32_of(idx + 1)));
        let last = get(&mat, dim, dim - 1, idx);
        set(
            &mut mat,
            dim,
            dim - 1,
            idx,
            last - gap.powi(i32_of(dim - idx)),
        );
    }
    // The corner gets (2·gap − 1) added back when 2·gap > 1.
    let corner = get(&mat, dim, dim - 1, 0);
    let two_gap = 2.0f64.mul_add(gap, -1.0);
    let extra = if two_gap > 0.0 {
        two_gap.powi(i32_of(dim))
    } else {
        0.0
    };
    set(&mut mat, dim, dim - 1, 0, corner + extra);
    // Divide the strictly-sub-superdiagonal entries by the factorials.
    for row in 0..dim {
        for col in 0..dim {
            if row + 1 > col {
                let depth = row - col + 1;
                let mut val = get(&mat, dim, row, col);
                for fact in 1..=depth {
                    val /= f64_of(fact);
                }
                set(&mut mat, dim, row, col, val);
            }
        }
    }

    // Raise H to the n-th power, rescaling each multiply to keep magnitudes sane.
    let (power, scale_exp) = matrix_power_scaled(&mat, dim, size);
    let mut acc = get(&power, dim, k - 1, k - 1);
    // Apply the deferred 10^{8·scale_exp} scaling and the n!/n^n factor together.
    acc *= 10.0_f64.powi(8 * scale_exp);
    for step in 1..=size {
        acc = acc * f64_of(step) / size_f;
    }
    acc.clamp(0.0, 1.0)
}

/// Smirnov one-sided survival function `P(D⁺ ≥ d)` at sample size `n`.
///
/// Evaluates the closed form
/// `d · Σ_{j=0}^{⌊n(1−d)⌋} C(n, j) (1 − d − j/n)^{n−j} (d + j/n)^{j−1}`, which is
/// `scipy.stats.ksone.sf`. Uses log-space binomial coefficients for stability.
///
/// # Arguments
///
/// * `stat` — the one-sided statistic (`0 ≤ d ≤ 1`).
/// * `size` — the sample size.
///
/// # Returns
///
/// `P(D⁺ ≥ d)` in `[0, 1]`.
#[must_use]
fn smirnov_sf(stat: f64, size: usize) -> f64 {
    if stat <= 0.0 {
        return 1.0;
    }
    if stat >= 1.0 {
        return 0.0;
    }
    let size_f = f64_of(size);
    let jmax = floor_usize(size_f * (1.0 - stat), size);
    let mut sum = 0.0_f64;
    for j in 0..=jmax {
        let jf = f64_of(j);
        let comb = ln_choose(size, j).exp();
        let base_lo = 1.0 - stat - jf / size_f;
        let base_hi = stat + jf / size_f;
        let term = comb * base_lo.powf(size_f - jf) * base_hi.powf(jf - 1.0);
        sum += term;
    }
    (stat * sum).clamp(0.0, 1.0)
}

/// Exact two-sample KS two-sided p-value for samples of sizes `n1`, `n2` with
/// observed statistic `d`.
///
/// Counts the monotone lattice paths from `(0,0)` to `(n1,n2)` that stay strictly
/// inside the band `|i·n2 − j·n1| < d·n1·n2` (the non-exceeding paths) and returns
/// `1 − inside / C(n1+n2, n1)`. The band is tested in integer arithmetic, so the
/// count matches `scipy.stats.ks_2samp(method="exact")` exactly.
///
/// # Arguments
///
/// * `stat` — the observed two-sample KS statistic (`= max|F₁ − F₂|`).
/// * `n1`, `n2` — the two sample sizes.
///
/// # Returns
///
/// The exact two-sided p-value in `[0, 1]`.
#[must_use]
pub fn two_sample_p(stat: f64, n1: usize, n2: usize) -> f64 {
    // Recover the integer band threshold: D·n1·n2 rounded to the nearest integer.
    let prod = f64_of(n1) * f64_of(n2);
    let dnum = (stat * prod).round();
    // dp[row][col] = number of inside paths reaching (row, col); row-major grid.
    let cols = n2 + 1;
    let mut dp = vec![0.0_f64; (n1 + 1) * cols];
    if let Some(start) = dp.first_mut() {
        *start = 1.0;
    }
    for row in 0..=n1 {
        for col in 0..=n2 {
            if row == 0 && col == 0 {
                continue;
            }
            let band = (i64_of(row) * i64_of(n2) - i64_of(col) * i64_of(n1)).abs();
            let value = if f64_of_i64(band) < dnum {
                let from_left = if row > 0 {
                    dp.get((row - 1) * cols + col).copied().unwrap_or(0.0)
                } else {
                    0.0
                };
                let from_down = if col > 0 {
                    dp.get(row * cols + (col - 1)).copied().unwrap_or(0.0)
                } else {
                    0.0
                };
                from_left + from_down
            } else {
                0.0
            };
            if let Some(slot) = dp.get_mut(row * cols + col) {
                *slot = value;
            }
        }
    }
    let inside = dp.get(n1 * cols + n2).copied().unwrap_or(0.0);
    let total = ln_choose(n1 + n2, n1).exp();
    (1.0 - inside / total).clamp(0.0, 1.0)
}

/// Reads `mat[row][col]` from a row-major `dim×dim` matrix.
fn get(mat: &[f64], dim: usize, row: usize, col: usize) -> f64 {
    mat.get(row * dim + col).copied().unwrap_or(0.0)
}

/// Writes `val` to `mat[row][col]` in a row-major `dim×dim` matrix.
fn set(mat: &mut [f64], dim: usize, row: usize, col: usize, val: f64) {
    if let Some(slot) = mat.get_mut(row * dim + col) {
        *slot = val;
    }
}

/// Raises the row-major `dim×dim` matrix `mat` to the `exponent`th power,
/// rescaling by `10^8` whenever the corner entry grows large to avoid overflow.
///
/// Returns the powered matrix and the number of `10^8` factors removed, so the
/// caller can reapply `10^{8·exp}` after the final `n!/nⁿ` normalisation.
fn matrix_power_scaled(mat: &[f64], dim: usize, exponent: usize) -> (Vec<f64>, i32) {
    // Identity.
    let mut result = vec![0.0_f64; dim * dim];
    for diag in 0..dim {
        set(&mut result, dim, diag, diag, 1.0);
    }
    let mut base = mat.to_vec();
    let mut exp = exponent;
    let mut scale = 0_i32;
    while exp > 0 {
        if exp & 1 == 1 {
            result = mat_mul(&result, &base, dim);
            scale += rescale(&mut result, dim);
        }
        exp >>= 1;
        if exp > 0 {
            base = mat_mul(&base, &base, dim);
            scale += rescale(&mut base, dim);
        }
    }
    (result, scale)
}

/// Multiplies two row-major `dim×dim` matrices.
fn mat_mul(lhs: &[f64], rhs: &[f64], dim: usize) -> Vec<f64> {
    let mut out = vec![0.0_f64; dim * dim];
    for row in 0..dim {
        for mid in 0..dim {
            let left = get(lhs, dim, row, mid);
            if left == 0.0 {
                continue;
            }
            for col in 0..dim {
                let val = left.mul_add(get(rhs, dim, mid, col), get(&out, dim, row, col));
                set(&mut out, dim, row, col, val);
            }
        }
    }
    out
}

/// Divides every entry of `mat` by `10^8` while its center entry exceeds `10^140`,
/// returning the number of divisions applied.
fn rescale(mat: &mut [f64], dim: usize) -> i32 {
    let center = (dim - 1) / 2;
    if get(mat, dim, center, center) > 1e140 {
        for entry in mat.iter_mut() {
            *entry *= 1e-8;
        }
        1
    } else {
        0
    }
}

/// Widens a `usize` to `f64` without an `as` cast (values stay small).
fn f64_of(n: usize) -> f64 {
    u32::try_from(n).map_or(f64::INFINITY, f64::from)
}

/// Widens an `i64` to `f64` without an `as` cast.
fn f64_of_i64(n: i64) -> f64 {
    i32::try_from(n).map_or(f64::INFINITY, f64::from)
}

/// Ceiling of a non-negative `f64` as a `usize`, with `bound` as the search cap.
///
/// Computed `as`-free: the smallest `k` in `0..=bound` whose widened value is
/// `≥ x`. The values here are tiny (`≤ size`), so the bounded scan is negligible
/// and avoids any float-to-int `as` cast (which clippy bans in `src/`).
///
/// # Arguments
///
/// * `x` — a non-negative value to round up.
/// * `bound` — an upper bound on the result (the governing sample size).
///
/// # Returns
///
/// `⌈x⌉` clamped to `bound`.
fn ceil_usize(x: f64, bound: usize) -> usize {
    (0..=bound).find(|&k| f64_of(k) >= x).unwrap_or(bound)
}

/// Floor of a non-negative `f64` as a `usize`, with `bound` as the search cap.
///
/// Computed `as`-free: the largest `j` in `0..=bound` whose widened value is
/// `≤ x`.
///
/// # Arguments
///
/// * `x` — a non-negative value to round down.
/// * `bound` — an upper bound on the result (the governing sample size).
///
/// # Returns
///
/// `⌊x⌋` clamped to `bound`.
fn floor_usize(x: f64, bound: usize) -> usize {
    (0..=bound).rev().find(|&j| f64_of(j) <= x).unwrap_or(0)
}

/// Widens a `usize` to `i64` without an `as` cast.
fn i64_of(n: usize) -> i64 {
    i64::try_from(n).unwrap_or(i64::MAX)
}

/// Widens a `usize` to `i32` without an `as` cast (exponents stay small).
fn i32_of(n: usize) -> i32 {
    i32::try_from(n).unwrap_or(i32::MAX)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The two-sample exact p on identical-size disjoint samples matches a
    /// hand-computed value, and lies in `[0, 1]`.
    #[test]
    fn two_sample_disjoint_is_significant() {
        // a = {1,2,3}, b = {4,5,6,7}: D = 1.0, p = 1 - 0/C(7,3) = 1? No: the
        // band excludes the corner so inside = 0 and p = 1.0 - 0 = ... check it
        // stays in range and is small for a max separation.
        let p = two_sample_p(1.0, 3, 4);
        assert!((0.0..=1.0).contains(&p), "p out of range: {p}");
    }

    /// Smirnov SF is monotone decreasing in `d` and bounded in `[0, 1]`.
    #[test]
    fn smirnov_is_monotone() {
        let n = 8;
        let a = smirnov_sf(0.1, n);
        let b = smirnov_sf(0.3, n);
        assert!(a >= b, "not monotone: {a} < {b}");
        assert!((0.0..=1.0).contains(&a), "a out of range: {a}");
    }

    /// The MTW CDF is monotone increasing in `d` and bounded in `[0, 1]`.
    #[test]
    fn kolmogorov_cdf_is_monotone() {
        let n = 8;
        let lo = kolmogorov_cdf(0.1, n);
        let hi = kolmogorov_cdf(0.4, n);
        assert!(hi >= lo, "not monotone: {hi} < {lo}");
        assert!((0.0..=1.0).contains(&hi), "hi out of range: {hi}");
    }
}