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}