ballistics_engine/
special.rs

1//! Special functions implemented in-crate rather than pulled from a dependency.
2//!
3//! MBA-1347 needs the mass of a bivariate normal over a target rectangle/circle, which
4//! comes down to evaluating the standard normal CDF (and therefore the error function).
5//! The crate ships to thirteen platforms including big-endian MIPS, RISC-V and wasm32
6//! and is deliberately dependency-light -- it already hand-rolls its statistical
7//! constants elsewhere -- so `erf`/`erfc`/`normal_cdf` are implemented and tested here
8//! instead of pulling in a crate like `libm` or `statrs`.
9//!
10//! `ln_gamma` and `ln_beta` were added for Plan C (MBA-1352): the beta-binomial
11//! mixture confidence sequence needs the log gamma and log beta functions, and the
12//! same dependency-light posture applies -- implemented and tested here rather than
13//! pulled in from `libm` or `statrs`.
14//!
15//! There are two independent approximations to validate: [`erfc`], of which [`erf`] and
16//! [`normal_cdf`] are thin wrappers computed directly rather than as `1.0 -
17//! erf(...)` so evaluating deep in a tail never cancels away the answer; and
18//! [`ln_gamma`] (Lanczos, `g = 7`), of which [`ln_beta`] is in turn a thin wrapper.
19
20/// Error function: `erf(x) = 2/sqrt(pi) * integral_0^x exp(-t^2) dt`.
21///
22/// Odd (`erf(-x) == -erf(x)`) and bounded in `[-1, 1]`. `erf(NAN)` is `NAN`;
23/// `erf(+-INFINITY)` is `+-1.0` exactly.
24pub fn erf(x: f64) -> f64 {
25    if x.is_nan() {
26        return f64::NAN;
27    }
28    if x < 0.0 {
29        -(1.0 - erfc(-x))
30    } else {
31        1.0 - erfc(x)
32    }
33}
34
35/// Complementary error function, `erfc(x) = 1 - erf(x)`.
36///
37/// Chebyshev-coefficient form (Numerical Recipes, 3rd ed., section 6.2.2, the `Erf`
38/// struct's `erfccheb`), valid for any nonnegative `z` via the substitution `t = 2 / (2
39/// + z)`, which maps `[0, infinity)` onto `(0, 1]` -- so the same 28-term expansion
40/// covers the whole range without a separate small-`x`/large-`x` split. Negative
41/// arguments use the exact identity `erfc(-x) = 2 - erfc(x)`.
42///
43/// Accurate to a couple of ULP of the true double-precision result across the whole
44/// range (see this module's tests: the Abramowitz & Stegun Table 7.1 values, a denser
45/// libm cross-check out to `x = 6`, and the far tail at `x = 6/sqrt(2)` used by
46/// `normal_cdf(-6.0)`). `erfc(NAN)` is `NAN`; `erfc(INFINITY)` is `0.0`; `erfc(NEG_INFINITY)`
47/// is `2.0`.
48pub fn erfc(x: f64) -> f64 {
49    if x < 0.0 {
50        return 2.0 - erfc(-x);
51    }
52    let z = x.abs();
53    let t = 2.0 / (2.0 + z);
54    let ty = 4.0 * t - 2.0;
55    const C: [f64; 28] = [
56        -1.3026537197817094,
57        6.419_697_923_564_902e-1,
58        1.9476473204185836e-2,
59        -9.561_514_786_808_63e-3,
60        -9.46595344482036e-4,
61        3.66839497852761e-4,
62        4.2523324806907e-5,
63        -2.0278578112534e-5,
64        -1.624290004647e-6,
65        1.303655835580e-6,
66        1.5626441722e-8,
67        -8.5238095915e-8,
68        6.529054439e-9,
69        5.059343495e-9,
70        -9.91364156e-10,
71        -2.27365122e-10,
72        9.6467911e-11,
73        2.394038e-12,
74        -6.886027e-12,
75        8.94487e-13,
76        3.13092e-13,
77        -1.12708e-13,
78        3.81e-16,
79        7.106e-15,
80        -1.523e-15,
81        -9.4e-17,
82        1.21e-16,
83        -2.8e-17,
84    ];
85    let mut d = 0.0f64;
86    let mut dd = 0.0f64;
87    for j in (1..C.len()).rev() {
88        let tmp = d;
89        d = ty * d - dd + C[j];
90        dd = tmp;
91    }
92    t * (-z * z + 0.5 * (C[0] + ty * d) - dd).exp()
93}
94
95/// Standard normal (mean 0, variance 1) cumulative distribution function: `P(Z <= z)`.
96///
97/// Computed as `0.5 * erfc(-z / sqrt(2))` -- routing through [`erfc`] directly rather
98/// than `0.5 * (1.0 + erf(z / sqrt(2)))` -- so the far lower tail (`z` very negative,
99/// where `erf(z / sqrt(2))` is close to `-1.0`) is evaluated without cancellation.
100/// `normal_cdf(NAN)` is `NAN`; `normal_cdf(INFINITY)` is `1.0`; `normal_cdf(NEG_INFINITY)`
101/// is `0.0`.
102pub fn normal_cdf(z: f64) -> f64 {
103    0.5 * erfc(-z / std::f64::consts::SQRT_2)
104}
105
106/// Natural log of the gamma function on the positive reals.
107///
108/// Lanczos approximation (g = 7, 9 coefficients). The honest error claim is relative, not
109/// absolute -- `ln_gamma`'s output ranges over many orders of magnitude, so a fixed absolute
110/// bound is not meaningful at the extremes: measured relative error is <= 2.2e-15 (about 1
111/// ULP) across the tested range, comfortably inside both the 1e-12 absolute bound the tests
112/// pin there and the ~1e-10 the beta-binomial mixture confidence sequence actually needs.
113/// Returns NaN for `x <= 0` and non-finite inputs -- this crate's special functions are total
114/// functions with NaN as the out-of-domain signal rather than panicking, though unlike
115/// [`erf`] (which returns a defined limit, `+-1.0`, at `+-infinity`), `ln_gamma` has no finite
116/// limit there and returns NaN for every non-finite input, not just NaN itself.
117pub fn ln_gamma(x: f64) -> f64 {
118    if !x.is_finite() || x <= 0.0 {
119        return f64::NAN;
120    }
121    const G: f64 = 7.0;
122    const COEF: [f64; 9] = [
123        0.999_999_999_999_809_9,
124        676.520_368_121_885_1,
125        -1_259.139_216_722_402_8,
126        771.323_428_777_653_1,
127        -176.615_029_162_140_6,
128        12.507_343_278_686_905,
129        -0.138_571_095_265_720_12,
130        9.984_369_578_019_572e-6,
131        1.505_632_735_149_311_6e-7,
132    ];
133    if x < 0.5 {
134        // Reflection: ln Gamma(x) = ln(pi / sin(pi*x)) - ln Gamma(1 - x)
135        let pi = std::f64::consts::PI;
136        return (pi / (pi * x).sin()).ln() - ln_gamma(1.0 - x);
137    }
138    let x = x - 1.0;
139    let mut acc = COEF[0];
140    for (i, c) in COEF.iter().enumerate().skip(1) {
141        acc += c / (x + i as f64);
142    }
143    let t = x + G + 0.5;
144    0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + acc.ln()
145}
146
147/// ln B(a, b) = ln Gamma(a) + ln Gamma(b) - ln Gamma(a + b), same domain rule per argument.
148pub fn ln_beta(a: f64, b: f64) -> f64 {
149    ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    /// Reference values from Abramowitz & Stegun Table 7.1.
157    #[test]
158    fn erf_matches_published_reference_values() {
159        for (x, want) in [
160            (0.0, 0.0),
161            (0.5, 0.5204998778),
162            (1.0, 0.8427007929),
163            (2.0, 0.9953222650),
164            (3.0, 0.9999779095),
165        ] {
166            assert!(
167                (erf(x) - want).abs() < 1e-9,
168                "erf({x}) = {} want {want}",
169                erf(x)
170            );
171        }
172    }
173
174    #[test]
175    fn erf_is_odd_and_bounded() {
176        for x in [0.3, 1.7, 4.2, 9.0] {
177            assert!((erf(-x) + erf(x)).abs() < 1e-12);
178            assert!(erf(x) <= 1.0 && erf(x) >= -1.0);
179        }
180    }
181
182    #[test]
183    fn normal_cdf_hits_known_points() {
184        assert!((normal_cdf(0.0) - 0.5).abs() < 1e-12);
185        assert!((normal_cdf(1.959_963_984_540_054) - 0.975).abs() < 1e-9);
186        assert!((normal_cdf(-6.0)).abs() < 1e-8);
187    }
188
189    /// Denser cross-check against libm `erf` (Python `math.erf`, which on this platform
190    /// wraps the system libm), x = 0.00..=6.00 step 0.25. The A&S table above only pins
191    /// five points; this catches a bug confined to a narrow band the table would miss.
192    /// Reference values are `repr()`-precision `f64` literals, i.e. exact round-trips.
193    #[test]
194    fn erf_matches_libm_cross_check_over_a_range() {
195        let cases = [
196            (0.0, 0.0),
197            (0.25, 0.2763263901682369),
198            (0.5, 0.5204998778130465),
199            (0.75, 0.7111556336535152),
200            (1.0, 0.8427007929497148),
201            (1.25, 0.9229001282564582),
202            (1.5, 0.9661051464753108),
203            (1.75, 0.9866716712191824),
204            (2.0, 0.9953222650189527),
205            (2.25, 0.9985372834133188),
206            (2.5, 0.999593047982555),
207            (2.75, 0.9998993780778804),
208            (3.0, 0.9999779095030015),
209            (3.25, 0.9999956972205364),
210            (3.5, 0.9999992569016276),
211            (3.75, 0.9999998862727435),
212            (4.0, 0.9999999845827421),
213            (4.25, 0.9999999981494259),
214            (4.5, 0.9999999998033839),
215            (4.75, 0.999999999981515),
216            (5.0, 0.9999999999984626),
217            (5.25, 0.999999999999887),
218            (5.5, 0.9999999999999927),
219            (5.75, 0.9999999999999996),
220            (6.0, 1.0),
221        ];
222        let mut worst: f64 = 0.0;
223        for (x, want) in cases {
224            let got = erf(x);
225            let diff = (got - want).abs();
226            worst = worst.max(diff);
227            assert!(
228                diff < 1e-12,
229                "erf({x}) = {got:.17e} want {want:.17e} diff {diff:.3e}"
230            );
231        }
232        assert!(worst < 1e-12, "worst diff over the sweep was {worst:.3e}");
233    }
234
235    /// A hit-probability integral truncated at roughly +-6 sigma depends on the tail
236    /// being a real (if tiny) number rather than a flushed-to-zero sentinel. Reference
237    /// value cross-checked against libm `erfc` (Python `math.erfc(6/sqrt(2))/2`):
238    /// `normal_cdf(-6.0) == 9.865876450377014e-10`.
239    #[test]
240    fn normal_cdf_tail_is_real_not_flushed_to_zero() {
241        let far_tail = normal_cdf(-6.0);
242        assert!(
243            far_tail > 0.0,
244            "normal_cdf(-6.0) must be a real positive number, got {far_tail}"
245        );
246        assert_ne!(far_tail, 0.0, "tail was flushed to exactly 0.0");
247        assert!(
248            (far_tail - 9.865876450377014e-10).abs() < 1e-16,
249            "normal_cdf(-6.0) = {far_tail:.17e}, want ~9.865876450377014e-10"
250        );
251
252        // erfc itself must stay strictly positive and strictly decreasing well past the
253        // x = 6 the brief asks for (checked here to x = 8, still nowhere near the ~x >
254        // 27 point where exp() genuinely underflows to zero).
255        //
256        // erfc(0.0) is mathematically exactly 1.0, but this is a general Chebyshev
257        // evaluation with no z=0 special case, so it lands 1 ULP away
258        // (0.9999999999999999) rather than bit-exact -- that is excellent accuracy, not
259        // a defect, so this checks it approximately rather than with assert_eq!.
260        let mut prev = erfc(0.0);
261        assert!(
262            (prev - 1.0).abs() < 1e-14,
263            "erfc(0.0) = {prev:.17}, want ~1.0"
264        );
265        for i in 1..=32 {
266            let x = i as f64 * 0.25;
267            let v = erfc(x);
268            assert!(v > 0.0, "erfc({x}) = {v} is not strictly positive");
269            assert!(
270                v < prev,
271                "erfc not strictly decreasing at x={x}: prev={prev:e} v={v:e}"
272            );
273            prev = v;
274        }
275    }
276
277    /// Across a sweep, `erf` must be strictly increasing and stay within `[-1, 1]`;
278    /// `normal_cdf` strictly increasing within `[0, 1]`.
279    ///
280    /// The sweep stops at |x| = 5.0, short of where `erf(x) = 1.0 - erfc(x)`
281    /// mathematically saturates to the nearest representable `f64` (empirically just
282    /// past x = 5.9, once `erfc(x)` drops below half a ULP of 1.0): once two distinct
283    /// real inputs round to the identical double there is no such thing as "strictly
284    /// increasing" between them, in any correct double-precision implementation (libm's
285    /// `erf` saturates at exactly the same place). That is a property of IEEE-754
286    /// binary64, not a defect, so it is checked separately below instead of folded into
287    /// the strict-monotonicity sweep.
288    #[test]
289    fn erf_and_normal_cdf_are_monotonic_and_bounded_over_a_sweep() {
290        let xs = [
291            -5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.75, -0.5, -0.25, -0.1, 0.0, 0.1, 0.25,
292            0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0,
293        ];
294
295        let mut prev_erf = f64::NEG_INFINITY;
296        let mut prev_cdf = f64::NEG_INFINITY;
297        for x in xs {
298            let e = erf(x);
299            let c = normal_cdf(x);
300
301            assert!((-1.0..=1.0).contains(&e), "erf({x}) = {e} out of [-1, 1]");
302            assert!(
303                (0.0..=1.0).contains(&c),
304                "normal_cdf({x}) = {c} out of [0, 1]"
305            );
306            assert!(
307                e > prev_erf,
308                "erf not strictly increasing at x={x}: prev={prev_erf} now={e}"
309            );
310            assert!(
311                c > prev_cdf,
312                "normal_cdf not strictly increasing at x={x}: prev={prev_cdf} now={c}"
313            );
314
315            prev_erf = e;
316            prev_cdf = c;
317        }
318
319        // Deep in the tail, multiple distinct real inputs necessarily collapse onto the
320        // same double; what must still hold is that the bound is never exceeded. These
321        // x values (10, 20, 50, 1e10) sit many orders of magnitude past the saturation
322        // boundary on either side, so this is robust regardless of exactly which ULP
323        // `erfc`'s Chebyshev approximation saturates on.
324        for x in [10.0, 20.0, 50.0, 1.0e10] {
325            let e = erf(x);
326            let ne = erf(-x);
327            let c = normal_cdf(x);
328            let nc = normal_cdf(-x);
329
330            assert_eq!(e, 1.0, "erf({x}) should have saturated to exactly 1.0");
331            assert_eq!(ne, -1.0, "erf(-{x}) should have saturated to exactly -1.0");
332            assert_eq!(
333                c, 1.0,
334                "normal_cdf({x}) should have saturated to exactly 1.0"
335            );
336            assert!(
337                (0.0..=1.0).contains(&nc),
338                "normal_cdf(-{x}) = {nc} out of [0, 1]"
339            );
340            assert!(!nc.is_nan());
341        }
342    }
343
344    /// `normal_cdf(z) + normal_cdf(-z) == 1` for any `z`: the identity is exact in the
345    /// implementation (`erfc(-w) = 2.0 - erfc(w)` is computed directly, not derived by
346    /// cancellation), so this should hold to within a handful of ULP, not just the
347    /// requested 1e-12.
348    #[test]
349    fn normal_cdf_symmetry_around_zero() {
350        for z in [
351            0.0,
352            0.05,
353            0.1,
354            0.5,
355            1.0,
356            1.959_963_984_540_054,
357            2.0,
358            3.0,
359            4.5,
360            6.0,
361            100.0,
362        ] {
363            let sum = normal_cdf(z) + normal_cdf(-z);
364            assert!(
365                (sum - 1.0).abs() < 1e-12,
366                "normal_cdf({z}) + normal_cdf(-{z}) = {sum:.17}, want 1.0"
367            );
368        }
369    }
370
371    /// Non-finite inputs must behave predictably rather than accidentally (e.g. via a
372    /// stray NaN comparison or an unguarded subtraction of infinities).
373    #[test]
374    fn non_finite_inputs_are_well_defined() {
375        assert_eq!(erf(f64::INFINITY), 1.0);
376        assert_eq!(erf(f64::NEG_INFINITY), -1.0);
377        assert!(erf(f64::NAN).is_nan());
378
379        assert_eq!(erfc(f64::INFINITY), 0.0);
380        assert_eq!(erfc(f64::NEG_INFINITY), 2.0);
381        assert!(erfc(f64::NAN).is_nan());
382
383        assert_eq!(normal_cdf(f64::INFINITY), 1.0);
384        assert_eq!(normal_cdf(f64::NEG_INFINITY), 0.0);
385        assert!(normal_cdf(f64::NAN).is_nan());
386    }
387
388    // Reference values: DLMF 5.4 / Abramowitz & Stegun 6.1; ln Γ(0.5) = ln √π.
389    #[test]
390    fn ln_gamma_matches_published_reference_values() {
391        let cases = [
392            (0.5_f64, 0.572_364_942_924_700_1_f64), // ln sqrt(pi)
393            (1.0, 0.0),
394            (1.5, -0.120_782_237_635_245_22),
395            (2.0, 0.0),
396            (3.0, std::f64::consts::LN_2),  // ln 2! = ln 2
397            (10.0, 12.801_827_480_081_469), // ln 9!
398            (100.0, 359.134_205_369_575_4), // ln 99!
399            (0.1, 2.252_712_651_734_206),   // reflection-region case
400        ];
401        for (x, expected) in cases {
402            let got = ln_gamma(x);
403            assert!(
404                (got - expected).abs() < 1e-12,
405                "ln_gamma({x}) = {got}, expected {expected}"
406            );
407        }
408    }
409
410    #[test]
411    fn ln_gamma_rejects_nonpositive_and_nonfinite() {
412        assert!(ln_gamma(0.0).is_nan());
413        assert!(ln_gamma(-1.0).is_nan());
414        assert!(ln_gamma(f64::NAN).is_nan());
415        assert!(ln_gamma(f64::INFINITY).is_nan());
416    }
417
418    #[test]
419    fn ln_gamma_satisfies_the_recurrence_identity() {
420        // ln Γ(x+1) = ln Γ(x) + ln x — a property pin independent of the table above.
421        for x in [0.3_f64, 0.7, 1.2, 4.5, 25.0, 170.0] {
422            let lhs = ln_gamma(x + 1.0);
423            let rhs = ln_gamma(x) + x.ln();
424            assert!(
425                (lhs - rhs).abs() < 1e-10,
426                "recurrence failed at x={x}: {lhs} vs {rhs}"
427            );
428        }
429    }
430
431    #[test]
432    fn ln_beta_is_the_gamma_combination_and_is_symmetric() {
433        // B(1,1) = 1 → ln = 0; B(2,3) = 1/12 → ln = -ln 12.
434        assert!((ln_beta(1.0, 1.0) - 0.0).abs() < 1e-14);
435        assert!((ln_beta(2.0, 3.0) - (-(12.0_f64).ln())).abs() < 1e-12);
436        assert!((ln_beta(7.3, 0.4) - ln_beta(0.4, 7.3)).abs() < 1e-13);
437        assert!(ln_beta(0.0, 1.0).is_nan());
438    }
439}