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}