Skip to main content

stats_claw/distributions/
mod.rs

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