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}