Skip to main content

stats_claw/distributions/symmetric/
normal.rs

1//! Normal (Gaussian) distribution numerics, for the
2//! [`NormalDistribution`].
3//!
4//! Equivalent to `scipy.stats.norm(loc=mean, scale=standard_deviation)`: the
5//! density is the closed-form Gaussian, the CDF is `½(1 + erf(z))`, and the
6//! quantile inverts it via a Newton-refined inverse error function. Sampling uses
7//! the RNG's cached Box–Muller `standard_normal`.
8//!
9//! # Examples
10//!
11//! ```
12//! use stats_claw::distributions::Pdf;
13//! use stats_claw::distributions::NormalDistribution;
14//!
15//! let n = NormalDistribution { mean: 0.0, standard_deviation: 1.0, ..Default::default() };
16//! // Peak density of the standard normal is 1/sqrt(2π) ≈ 0.398_942.
17//! assert!((n.pdf(0.0) - 0.398_942_28).abs() < 1e-6, "peak was {}", n.pdf(0.0));
18//! ```
19
20use super::super::{Cdf, LogCdf, Moments, Pdf, Quantile, Sample};
21use crate::distributions::NormalDistribution;
22use crate::rng::SplitMix64;
23use crate::special::{erf, ln_erfc};
24use std::f64::consts::{LN_2, PI, SQRT_2};
25
26/// `1/√(2π)`, the standard-normal density's normalization constant, precomputed
27/// so the batch `pdf` hot path needs no `sqrt` per element.
28const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
29
30impl NormalDistribution {
31    /// Evaluates the density at every point of `xs`, writing the results into
32    /// `out`, using the fastest available native-SIMD path (NEON / AVX2 / scalar).
33    ///
34    /// This is the dense batch hot path the perf gate measures (`pdf` is the
35    /// gated metric). It is numerically identical to calling [`Pdf::pdf`] per
36    /// element (the SIMD `exp` agrees with the scalar `exp` to ≤ 1e-12 relative),
37    /// so callers needing a single value should still use [`Pdf::pdf`].
38    ///
39    /// # Arguments
40    ///
41    /// * `xs` — the evaluation grid.
42    /// * `out` — the output buffer; should have the same length as `xs`. If the
43    ///   lengths differ, only the leading `min(xs.len(), out.len())` entries are
44    ///   written.
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use stats_claw::distributions::Pdf;
50    /// use stats_claw::distributions::NormalDistribution;
51    ///
52    /// let n = NormalDistribution { mean: 0.0, standard_deviation: 1.0, ..Default::default() };
53    /// let xs = [-1.0, 0.0, 1.0];
54    /// let mut out = [0.0; 3];
55    /// n.pdf_batch(&xs, &mut out);
56    /// assert!((out[1] - n.pdf(0.0)).abs() < 1e-12);
57    /// ```
58    pub fn pdf_batch(&self, xs: &[f64], out: &mut [f64]) {
59        super::super::simd::normal_pdf_into(self, xs, out);
60    }
61
62    /// Evaluates the CDF at every point of `xs`, writing the results into `out`.
63    ///
64    /// Numerically identical to calling [`Cdf::cdf`] per element; provided as the
65    /// batch-layout counterpart of [`Self::pdf_batch`]. The `erf` evaluation stays
66    /// scalar (Cody rational), so this is a convenience over the dense grid rather
67    /// than a vectorized kernel.
68    ///
69    /// # Arguments
70    ///
71    /// * `xs` — the evaluation grid.
72    /// * `out` — the output buffer; same-length contract as [`Self::pdf_batch`].
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use stats_claw::distributions::Cdf;
78    /// use stats_claw::distributions::NormalDistribution;
79    ///
80    /// let n = NormalDistribution { mean: 0.0, standard_deviation: 1.0, ..Default::default() };
81    /// let xs = [0.0];
82    /// let mut out = [0.0; 1];
83    /// n.cdf_batch(&xs, &mut out);
84    /// assert!((out[0] - 0.5).abs() < 1e-12);
85    /// ```
86    pub fn cdf_batch(&self, xs: &[f64], out: &mut [f64]) {
87        super::super::simd::normal_cdf_into(self, xs, out);
88    }
89
90    /// Draws `out.len()` variates into `out` using the native-SIMD batch ziggurat
91    /// (NEON / AVX2 / scalar), the dense batch sampling hot path.
92    ///
93    /// This is the batch counterpart of [`Sample::sample`]. Where `sample` draws one
94    /// variate by cached Box–Muller (and is the path the equivalence/GoF fixtures
95    /// pin), `sample_batch` fills a whole buffer by the Marsaglia–Tsang ziggurat,
96    /// vectorized per CPU — the workload that races numpy's `norm.rvs`. Both are
97    /// statistically N(mean, σ²); they do **not** produce the same stream for a given
98    /// seed (different algorithms consume the RNG differently), but each is itself
99    /// reproducible for a fixed seed and CPU path.
100    ///
101    /// # Arguments
102    ///
103    /// * `rng` — the deterministic generator; advanced in a fixed per-path order so
104    ///   a fixed seed yields a reproducible buffer on a given CPU.
105    /// * `out` — the output buffer; every element is overwritten with a draw.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use stats_claw::distributions::NormalDistribution;
111    /// use stats_claw::rng::SplitMix64;
112    ///
113    /// let n = NormalDistribution { mean: 0.0, standard_deviation: 1.0, ..Default::default() };
114    /// let mut rng = SplitMix64::new(7);
115    /// let mut out = [0.0; 4];
116    /// n.sample_batch(&mut rng, &mut out);
117    /// // Same seed, same CPU path → identical buffer.
118    /// let mut rng2 = SplitMix64::new(7);
119    /// let mut out2 = [0.0; 4];
120    /// n.sample_batch(&mut rng2, &mut out2);
121    /// assert_eq!(out, out2);
122    /// ```
123    pub fn sample_batch(&self, rng: &mut SplitMix64, out: &mut [f64]) {
124        super::super::ziggurat::normal_sample_into(self.mean, self.standard_deviation, rng, out);
125    }
126}
127
128impl Pdf for NormalDistribution {
129    fn pdf(&self, x: f64) -> f64 {
130        // Hoist the reciprocal scale and normalization out of the transcendental:
131        // one `mul`, one `exp`, two `mul` — no per-call `sqrt` or division.
132        let inv_sigma = 1.0 / self.standard_deviation;
133        let z = (x - self.mean) * inv_sigma;
134        (-0.5 * z * z).exp() * (INV_SQRT_2PI * inv_sigma)
135    }
136}
137
138impl Cdf for NormalDistribution {
139    fn cdf(&self, x: f64) -> f64 {
140        // Fold the 1/(σ√2) scale into a single reciprocal-multiply so the hot
141        // path is one `mul` plus the `erf` rational eval — no per-call division.
142        let inv_scale = 1.0 / (self.standard_deviation * SQRT_2);
143        let z = (x - self.mean) * inv_scale;
144        0.5 * (1.0 + erf(z))
145    }
146}
147
148impl LogCdf for NormalDistribution {
149    fn logsf(&self, x: f64) -> f64 {
150        // sf(x) = erfc(z)/2 with z = (x-μ)/(σ√2); ln sf = ln erfc(z) − ln 2.
151        let z = (x - self.mean) / (self.standard_deviation * SQRT_2);
152        ln_erfc(z) - LN_2
153    }
154    fn logcdf(&self, x: f64) -> f64 {
155        // cdf(x) = sf(2μ − x) by symmetry; equivalently ln erfc(−z) − ln 2.
156        let z = (x - self.mean) / (self.standard_deviation * SQRT_2);
157        ln_erfc(-z) - LN_2
158    }
159}
160
161impl Quantile for NormalDistribution {
162    fn quantile(&self, p: f64) -> f64 {
163        let erf_target = 2.0f64.mul_add(p, -1.0);
164        self.standard_deviation
165            .mul_add(SQRT_2 * inv_erf(erf_target), self.mean)
166    }
167}
168
169impl Moments for NormalDistribution {
170    fn mean(&self) -> Option<f64> {
171        Some(self.mean)
172    }
173    fn variance(&self) -> Option<f64> {
174        Some(self.standard_deviation * self.standard_deviation)
175    }
176}
177
178impl Sample for NormalDistribution {
179    fn sample(&self, rng: &mut SplitMix64) -> f64 {
180        self.standard_deviation
181            .mul_add(rng.standard_normal(), self.mean)
182    }
183}
184
185/// Inverse error function via a rational approximation refined by Newton steps on
186/// `erf`.
187///
188/// The seed is Giles' polynomial approximation; three Newton iterations using the
189/// exact derivative `erf'(x) = 2/√π · e^(−x²)` then drive the residual to ~1e-15
190/// over the open interval `(-1, 1)`.
191///
192/// # Arguments
193///
194/// * `y` — the target erf value in `[-1, 1]`; the closed endpoints map to `±∞`.
195///
196/// # Returns
197///
198/// The `x` with `erf(x) = y`.
199fn inv_erf(y: f64) -> f64 {
200    if y <= -1.0 {
201        return f64::NEG_INFINITY;
202    }
203    if y >= 1.0 {
204        return f64::INFINITY;
205    }
206    let w = -((1.0 - y) * (1.0 + y)).ln();
207    let mut x = if w < 5.0 {
208        let w = w - 2.5;
209        let mut p: f64 = 2.810_226_36e-08;
210        for c in [
211            3.432_739_39e-07,
212            -3.523_387_7e-06,
213            -4.391_506_54e-06,
214            0.000_218_580_87,
215            -0.001_253_725_03,
216            -0.004_177_681_64,
217            0.246_640_727,
218            1.501_409_41,
219        ] {
220            p = p.mul_add(w, c);
221        }
222        p * y
223    } else {
224        let w = w.sqrt() - 3.0;
225        let mut p: f64 = -0.000_200_214_257;
226        for c in [
227            0.000_100_950_558,
228            0.001_349_343_22,
229            -0.003_673_428_44,
230            0.005_739_507_73,
231            -0.007_622_461_3,
232            0.009_438_870_47,
233            1.001_674_06,
234            2.832_976_82,
235        ] {
236            p = p.mul_add(w, c);
237        }
238        p * y
239    };
240    let two_over_sqrt_pi = 2.0 / PI.sqrt();
241    for _ in 0..3 {
242        let err = erf(x) - y;
243        x -= err / (two_over_sqrt_pi * (-x * x).exp());
244    }
245    x
246}
247
248/// Kani formal-verification harnesses for the Normal distribution (tier 2).
249///
250/// Compiled only under `cargo kani` (behind `#[cfg(kani)]`); invisible to normal
251/// build/test/clippy.
252#[cfg(kani)]
253mod verification {
254    use super::{NormalDistribution, Pdf};
255
256    /// Builds a `NormalDistribution` with symbolic `mean`/`standard_deviation` and
257    /// empty string metadata (unused by the density).
258    ///
259    /// # Arguments
260    ///
261    /// * `mean` — the symbolic location parameter.
262    /// * `standard_deviation` — the symbolic scale parameter.
263    ///
264    /// # Returns
265    ///
266    /// A distribution carrying the two symbolic numeric parameters.
267    fn make(mean: f64, standard_deviation: f64) -> NormalDistribution {
268        NormalDistribution {
269            mean,
270            standard_deviation,
271            variance: standard_deviation * standard_deviation,
272            parameterization: String::new(),
273            distribution_name: String::new(),
274            description: String::new(),
275        }
276    }
277
278    /// Proves the Normal density evaluation is panic-/overflow-free for every valid
279    /// parameterization and every finite, magnitude-bounded evaluation point.
280    ///
281    /// The mathematical invariant `0 ≤ pdf(x) ≤ 1/(σ√2π)` holds because the density
282    /// is `exp(−½z²) · (INV_SQRT_2PI / σ)` with `exp ∈ (0, 1]` for the non-positive
283    /// exponent, `INV_SQRT_2PI > 0`, and `σ > 0`. Kani cannot *prove* that bound: it
284    /// models `exp` with a sound over-approximation that does not encode
285    /// `exp(t) ∈ (0, 1]` for `t ≤ 0`, so a value assertion fails spuriously
286    /// (confirmed: `p >= 0.0` and the peak bound both report counterexamples where
287    /// the modelled `exp` returns an out-of-range value). Per the tier-2 guidance
288    /// this harness therefore verifies the tractable, non-transcendental property —
289    /// that evaluating the density never panics, overflows, or hits UB over the
290    /// whole valid parameter/argument box.
291    #[kani::proof]
292    fn normal_pdf_no_panic() {
293        let mean: f64 = kani::any();
294        let sigma: f64 = kani::any();
295        let x: f64 = kani::any();
296        kani::assume(mean.is_finite() && mean.abs() <= 1e6);
297        kani::assume(x.is_finite() && x.abs() <= 1e6);
298        kani::assume(sigma.is_finite() && sigma >= 1e-3 && sigma <= 1e6);
299        let dist = make(mean, sigma);
300        let _ = dist.pdf(x);
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    /// The standard normal peaks at `1/√(2π) ≈ 0.398_942` at the mean.
309    #[test]
310    fn standard_normal_peak_density() {
311        let n = NormalDistribution {
312            mean: 0.0,
313            standard_deviation: 1.0,
314            ..Default::default()
315        };
316        assert!(
317            (n.pdf(0.0) - 0.398_942_28).abs() < 1e-6,
318            "peak density was {}",
319            n.pdf(0.0)
320        );
321    }
322
323    /// The shared behaviour traits still let us use it polymorphically.
324    #[test]
325    fn density_decreases_away_from_mean() {
326        fn density_at(d: &impl Pdf, x: f64) -> f64 {
327            d.pdf(x)
328        }
329        let n = NormalDistribution {
330            mean: 2.0,
331            standard_deviation: 0.5,
332            ..Default::default()
333        };
334        assert!(
335            density_at(&n, 2.0) > density_at(&n, 3.0),
336            "density should fall off from the mean"
337        );
338    }
339
340    /// `logsf`/`logcdf` agree with the log of the linear `cdf` in the body and
341    /// stay finite (matching scipy) in the deep tail where `1 - cdf` underflows.
342    #[test]
343    fn logsf_matches_scipy_and_stays_finite() {
344        let n = NormalDistribution {
345            mean: 0.0,
346            standard_deviation: 1.0,
347            ..Default::default()
348        };
349        // Body: logsf(0.5) == ln(1 - cdf(0.5)).
350        let body = n.logsf(0.5);
351        assert!(
352            ((body - (1.0 - n.cdf(0.5)).ln()) / body.abs()).abs() < 1e-10,
353            "logsf body was {body}"
354        );
355        // Deep tail: linear sf underflows but logsf matches scipy norm.logsf(40).
356        let tail = n.logsf(40.0);
357        let want = -804.608_442_013_753_9;
358        assert!(tail.is_finite(), "logsf(40) was {tail}");
359        assert!(
360            ((tail - want) / want).abs() < 1e-9,
361            "logsf(40) = {tail}, want {want}"
362        );
363    }
364}