Skip to main content

stats_claw/distributions/
mod.rs

1//! Probability distributions as plain parameter structs with behaviour traits.
2//!
3//! This module defines the behaviour traits (`Pdf`/`Pmf`/`Cdf`/`Quantile`/
4//! `Moments`/`Sample`) every distribution implements, plus the shared
5//! `bisection_quantile` inverse-CDF solver and a small integer→float helper.
6//! Each concrete distribution lives in a subgroup folder (`symmetric/`,
7//! `positive/`, `sampling/`, `discrete/`) and implements these traits for its
8//! parameter struct, keeping the family uniform.
9//!
10//! The math is validated against committed `scipy.stats` golden fixtures by the
11//! `distributions_equiv` integration suite within the documented tolerances
12//! (pdf/cdf/ppf abs ≤ 1e-10, rel ≤ 1e-9 in tails; moments rel ≤ 1e-12).
13//!
14//! # Examples
15//!
16//! Build a distribution from its parameter struct and evaluate it through the
17//! behaviour traits. Here the standard normal `N(0, 1)`: its density peaks at
18//! `1/√(2π) ≈ 0.398_942` and its CDF is `0.5` at the mean.
19//!
20//! ```
21//! use stats_claw::distributions::{Cdf, Moments, Pdf, Quantile};
22//! use stats_claw::distributions::NormalDistribution;
23//!
24//! let n = NormalDistribution {
25//!     mean: 0.0,
26//!     standard_deviation: 1.0,
27//!     ..Default::default()
28//! };
29//!
30//! assert!((n.pdf(0.0) - 0.398_942_280_401_432_7).abs() < 1e-12);
31//! assert!((n.cdf(0.0) - 0.5).abs() < 1e-12);
32//! assert!((n.quantile(0.5) - 0.0).abs() < 1e-9);
33//! assert_eq!(n.mean(), Some(0.0));
34//! assert_eq!(n.variance(), Some(1.0));
35//! ```
36
37use crate::rng::SplitMix64;
38
39pub mod types;
40pub use types::*;
41pub mod discrete;
42pub mod positive;
43pub mod sampling;
44mod simd;
45pub mod symmetric;
46mod ziggurat;
47
48/// Continuous probability density at a point.
49pub trait Pdf {
50    /// Evaluates the probability density function at `x`.
51    ///
52    /// # Arguments
53    ///
54    /// * `x` — the point at which to evaluate the density; any finite `f64`.
55    ///
56    /// # Returns
57    ///
58    /// The density `f(x) ≥ 0`, in units of probability per unit of `x`. It is a
59    /// height, not a probability, and may exceed 1.
60    fn pdf(&self, x: f64) -> f64;
61}
62
63/// Discrete probability mass at an integer support point.
64pub trait Pmf {
65    /// Evaluates the probability mass function at integer `k`.
66    ///
67    /// # Arguments
68    ///
69    /// * `k` — a support point; returns `0.0` outside the distribution's support.
70    ///
71    /// # Returns
72    ///
73    /// The probability `P(X = k) ∈ [0, 1]`.
74    fn pmf(&self, k: i64) -> f64;
75}
76
77/// Cumulative distribution function.
78pub trait Cdf {
79    /// Evaluates the cumulative distribution function at `x`.
80    ///
81    /// # Arguments
82    ///
83    /// * `x` — the upper limit; any finite `f64`.
84    ///
85    /// # Returns
86    ///
87    /// `P(X ≤ x) ∈ [0, 1]`, monotonically non-decreasing in `x`.
88    fn cdf(&self, x: f64) -> f64;
89}
90
91/// Log-space cumulative and survival functions, for extreme-tail probabilities
92/// that underflow to `0.0` (or saturate to `1.0`) in linear space.
93///
94/// Mirrors `scipy.stats.<dist>.logcdf` / `logsf`. The deep tail of a continuous
95/// null distribution — where a statistical test's p-value lives — is exactly
96/// where the linear [`Cdf`] loses all precision (`1 - cdf(x)` rounds to `0.0`
97/// once `cdf(x) ≥ 1 - 2⁻⁵³`); these log-space evaluations stay finite and
98/// accurate there, so a test can report an honest log p-value.
99pub trait LogCdf {
100    /// Evaluates the natural log of the CDF, `ln P(X ≤ x)`.
101    ///
102    /// # Arguments
103    ///
104    /// * `x` — the upper limit; any finite `f64`.
105    ///
106    /// # Returns
107    ///
108    /// `ln P(X ≤ x) ∈ (−∞, 0]`, finite in the left tail where `cdf(x)` underflows.
109    fn logcdf(&self, x: f64) -> f64;
110
111    /// Evaluates the natural log of the survival function, `ln P(X > x)`.
112    ///
113    /// # Arguments
114    ///
115    /// * `x` — the lower limit; any finite `f64`.
116    ///
117    /// # Returns
118    ///
119    /// `ln P(X > x) ∈ (−∞, 0]`, finite in the right tail where `1 - cdf(x)`
120    /// underflows.
121    fn logsf(&self, x: f64) -> f64;
122}
123
124/// Inverse cumulative distribution function (quantile / percent-point).
125pub trait Quantile {
126    /// Evaluates the quantile (inverse CDF) at probability `p`.
127    ///
128    /// # Arguments
129    ///
130    /// * `p` — a probability in `[0, 1]`; `0`/`1` map to the support endpoints
131    ///   (`±∞` for unbounded support).
132    ///
133    /// # Returns
134    ///
135    /// The smallest `x` with `cdf(x) ≥ p`.
136    fn quantile(&self, p: f64) -> f64;
137}
138
139/// First two moments, reporting `None` where the moment is undefined (matching
140/// scipy's NaN for e.g. Cauchy or the T distribution with low degrees of
141/// freedom).
142pub trait Moments {
143    /// Returns the theoretical mean, or `None` if it is undefined.
144    fn mean(&self) -> Option<f64>;
145    /// Returns the theoretical variance, or `None` if it is undefined.
146    fn variance(&self) -> Option<f64>;
147}
148
149/// Draws a single variate from a seeded, reproducible RNG.
150pub trait Sample {
151    /// Draws one variate, advancing `rng`.
152    ///
153    /// # Arguments
154    ///
155    /// * `rng` — the deterministic generator; a fixed seed yields a fixed stream.
156    ///
157    /// # Returns
158    ///
159    /// A single draw from the distribution.
160    fn sample(&self, rng: &mut SplitMix64) -> f64;
161}
162
163/// Generic inverse-CDF via bracketed bisection, for distributions whose quantile
164/// has no closed form.
165///
166/// The bracket `[lo, hi]` must contain the target quantile and `cdf` must be
167/// monotone non-decreasing on it. Two hundred halvings drive the residual well
168/// below the round-trip tolerance for any double-precision bracket.
169///
170/// # Arguments
171///
172/// * `p` — target probability; `p ≤ 0` returns `lo`, `p ≥ 1` returns `hi`.
173/// * `lo` — lower bracket bound (a value with `cdf(lo) ≤ p`).
174/// * `hi` — upper bracket bound (a value with `cdf(hi) ≥ p`).
175/// * `cdf` — the monotone CDF to invert.
176///
177/// # Returns
178///
179/// The bracket midpoint after convergence — an `x` with `cdf(x) ≈ p`.
180pub(crate) fn bisection_quantile(p: f64, lo: f64, hi: f64, cdf: impl Fn(f64) -> f64) -> f64 {
181    if p <= 0.0 {
182        return lo;
183    }
184    if p >= 1.0 {
185        return hi;
186    }
187    let (mut lo, mut hi) = (lo, hi);
188    for _ in 0..200 {
189        let mid = 0.5 * (lo + hi);
190        if cdf(mid) < p {
191            lo = mid;
192        } else {
193            hi = mid;
194        }
195    }
196    0.5 * (lo + hi)
197}
198
199/// Converts a small non-negative count to `f64` losslessly by domain.
200///
201/// Distribution parameters that arrive as integers (degrees of freedom, trial
202/// counts, support indices) are far below `i32::MAX`, so routing through
203/// `i32::try_from` then the lossless `f64::from` is exact and avoids the banned
204/// `as` cast. Values outside `i32` range saturate to `i32::MAX`/`MIN`, which the
205/// supported parameter domains never reach.
206///
207/// # Arguments
208///
209/// * `n` — an integer parameter (e.g. degrees of freedom or trial count).
210///
211/// # Returns
212///
213/// `n` as an `f64`.
214pub(crate) fn count_to_f64(n: i64) -> f64 {
215    let clamped = i32::try_from(n).unwrap_or(if n < 0 { i32::MIN } else { i32::MAX });
216    f64::from(clamped)
217}