Skip to main content

gam_math/
probability.rs

1use statrs::function::{beta::inv_beta_reg, erf::erfc};
2
3/// Quantile (inverse CDF) of a Beta distribution with shape parameters `a > 0`
4/// and `b > 0` at probability `p`: the value `x in [0, 1]` with
5/// `I_x(a, b) = p`, where `I` is the regularized incomplete beta.
6///
7/// `p <= 0` maps to the support floor and `p >= 1` to the support ceiling. A
8/// non-finite or non-positive shape yields `NaN`.
9pub fn beta_quantile(p: f64, a: f64, b: f64) -> f64 {
10    if !(a.is_finite() && a > 0.0 && b.is_finite() && b > 0.0) {
11        return f64::NAN;
12    }
13    if !p.is_finite() || p <= 0.0 {
14        return 0.0;
15    }
16    if p >= 1.0 {
17        return 1.0;
18    }
19    inv_beta_reg(a, b, p)
20}
21
22/// Standard normal PDF phi(x).
23#[inline]
24pub fn normal_pdf(x: f64) -> f64 {
25    const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
26    INV_SQRT_2PI * (-0.5 * x * x).exp()
27}
28
29/// Standard normal CDF Phi(x) evaluated via the exact special-function identity
30///
31///   Phi(x) = 0.5 * erfc(-x / sqrt(2)).
32///
33/// This is the exact Gaussian CDF semantics used throughout the codebase. The
34/// numerical `erfc` implementation may use internal approximations, but the
35/// returned function is the standard normal CDF itself rather than a separate
36/// polynomial surrogate surface.
37#[inline]
38pub fn normal_cdf(x: f64) -> f64 {
39    0.5 * statrs::function::erf::erfc(-x / std::f64::consts::SQRT_2)
40}
41
42/// Scaled complementary error function `erfcx(x) = exp(x²) · erfc(x)`,
43/// specialized to `x ≥ 0`.  Returns `1.0` for `x ≤ 0` and `0.0` for
44/// `x = +∞`.  For `0 < x < 26` uses the direct `exp(x²)·erfc(x)` form;
45/// beyond that the (otherwise overflowing) `exp(x²)` is replaced by a
46/// 4-term asymptotic expansion `(1/(x√π))·(1 − 1/(2x²) + 3/(4x⁴) − …)`,
47/// keeping relative accuracy near machine epsilon. The non-negative
48/// restriction lets the caller skip the reflection identity.
49#[inline]
50pub fn erfcx_nonnegative(x: f64) -> f64 {
51    if !x.is_finite() {
52        return if x.is_sign_positive() {
53            0.0
54        } else {
55            f64::INFINITY
56        };
57    }
58    if x <= 0.0 {
59        return 1.0;
60    }
61    if x < 26.0 {
62        ((x * x).min(700.0)).exp() * erfc(x)
63    } else {
64        let inv = 1.0 / x;
65        let inv2 = inv * inv;
66        let poly = 1.0 - 0.5 * inv2 + 0.75 * inv2 * inv2 - 1.875 * inv2 * inv2 * inv2
67            + 6.5625 * inv2 * inv2 * inv2 * inv2;
68        inv * poly / std::f64::consts::PI.sqrt()
69    }
70}
71
72/// Computes `log(1 - exp(-a))` for `a >= 0` without cancellation.
73#[inline]
74pub fn log1mexp_positive(a: f64) -> f64 {
75    assert!(a >= 0.0, "log1mexp_positive requires a >= 0: a={a}");
76    if a > core::f64::consts::LN_2 {
77        (-(-a).exp()).ln_1p()
78    } else if a > 0.0 {
79        (-(-a).exp_m1()).ln()
80    } else {
81        f64::NEG_INFINITY
82    }
83}
84
85/// Numerically stable signed log-sum-exp.  Given pairs
86/// `(log|aⱼ|, sign(aⱼ))` (with `signs[j] ∈ {−1, 0, +1}`), returns
87/// `(log|S|, sign(S))` for `S = Σⱼ signs[j]·exp(log_mags[j])`.  Positive
88/// and negative magnitudes are reduced separately with the standard
89/// log-sum-exp trick (subtract the max, sum, log, add back); the two
90/// partial sums are then combined via `log(|p − n|) =
91/// max(log p, log n) + log1mexp(|log p − log n|)`, preserving accuracy
92/// even when `p ≈ n` (catastrophic cancellation regime).  When all
93/// signs are zero or all magnitudes are `−∞`, returns
94/// `(NEG_INFINITY, 0.0)`.
95///
96/// A `+∞` log-magnitude denotes an infinite-magnitude term (`exp(+∞) = +∞`)
97/// and dominates the sum: if it appears only with positive sign the result
98/// is `(+∞, +1)`; only with negative sign, `(+∞, −1)` (a log-magnitude of
99/// `+∞` with sign `−1` encodes the value `−∞`); with both signs the sum is
100/// the indeterminate `+∞ − ∞`, returned as `(NaN, 0.0)`.  A `−∞`
101/// log-magnitude is `exp(−∞) = 0` and is correctly dropped.
102pub fn signed_log_sum_exp(log_mags: &[f64], signs: &[f64]) -> (f64, f64) {
103    // Infinite-magnitude terms dominate any finite contribution, so resolve
104    // them before the finite log-sum-exp reduction below. `−∞` log-magnitudes
105    // are `exp(−∞) = 0` and need no special handling.
106    let mut has_pos_inf = false;
107    let mut has_neg_inf = false;
108    for (idx, &lm) in log_mags.iter().enumerate() {
109        if lm == f64::INFINITY {
110            if signs[idx] > 0.0 {
111                has_pos_inf = true;
112            } else if signs[idx] < 0.0 {
113                has_neg_inf = true;
114            }
115        }
116    }
117    match (has_pos_inf, has_neg_inf) {
118        // P = +∞, N = +∞ ⇒ indeterminate +∞ − ∞.
119        (true, true) => return (f64::NAN, 0.0),
120        // P = +∞, N < ∞ ⇒ S = +∞.
121        (true, false) => return (f64::INFINITY, 1.0),
122        // N = +∞, P < ∞ ⇒ S = −∞, encoded as log-magnitude +∞ with sign −1.
123        (false, true) => return (f64::INFINITY, -1.0),
124        (false, false) => {}
125    }
126
127    let mut pos_max = f64::NEG_INFINITY;
128    let mut neg_max = f64::NEG_INFINITY;
129    for (idx, &lm) in log_mags.iter().enumerate() {
130        if signs[idx] > 0.0 {
131            pos_max = pos_max.max(lm);
132        } else if signs[idx] < 0.0 {
133            neg_max = neg_max.max(lm);
134        }
135    }
136
137    let mut pos_sum = 0.0_f64;
138    let mut neg_sum = 0.0_f64;
139    for (idx, &lm) in log_mags.iter().enumerate() {
140        if !lm.is_finite() {
141            continue;
142        }
143        if signs[idx] > 0.0 {
144            pos_sum += (lm - pos_max).exp();
145        } else if signs[idx] < 0.0 {
146            neg_sum += (lm - neg_max).exp();
147        }
148    }
149
150    let log_pos = if pos_sum > 0.0 {
151        pos_max + pos_sum.ln()
152    } else {
153        f64::NEG_INFINITY
154    };
155    let log_neg = if neg_sum > 0.0 {
156        neg_max + neg_sum.ln()
157    } else {
158        f64::NEG_INFINITY
159    };
160
161    if log_pos == f64::NEG_INFINITY && log_neg == f64::NEG_INFINITY {
162        // Both partial sums are empty: no terms at all, all signs zero, or every
163        // magnitude `−∞` (each `exp(−∞) = 0`). The signed sum is exactly `0`, so
164        // the contract requires `(−∞, 0.0)` — NOT the positive-sum convention,
165        // which would mislabel a zero as `+1` and corrupt any downstream cascade
166        // that reads back the sign.
167        return (f64::NEG_INFINITY, 0.0);
168    }
169    if log_neg == f64::NEG_INFINITY {
170        return (log_pos, 1.0);
171    }
172    if log_pos == f64::NEG_INFINITY {
173        return (log_neg, -1.0);
174    }
175    if log_pos > log_neg {
176        let gap = log_pos - log_neg;
177        (log_pos + log1mexp_positive(gap), 1.0)
178    } else if log_neg > log_pos {
179        let gap = log_neg - log_pos;
180        (log_neg + log1mexp_positive(gap), -1.0)
181    } else {
182        (f64::NEG_INFINITY, 0.0)
183    }
184}
185
186/// Numerically stable `ln Φ(x)` for the standard normal CDF.  For `x ≥ 0`
187/// computes `ln(Φ(x))` directly with a small floor against underflow; for
188/// `x < 0` rewrites
189/// `ln Φ(x) = −u² + ln(½·erfcx(u))`, `u = −x/√2`,
190/// which preserves digits all the way into the deep left tail (no
191/// `ln(0)`).  Returns `±∞` and `NaN` at the corresponding inputs.
192#[inline]
193pub fn normal_logcdf(x: f64) -> f64 {
194    if x == f64::INFINITY {
195        return 0.0;
196    }
197    if x == f64::NEG_INFINITY {
198        return f64::NEG_INFINITY;
199    }
200    if x.is_nan() {
201        return f64::NAN;
202    }
203    if x < 0.0 {
204        let u = -x / std::f64::consts::SQRT_2;
205        -u * u + (0.5 * erfcx_nonnegative(u).max(1e-300)).ln()
206    } else {
207        normal_cdf(x).clamp(1e-300, 1.0).ln()
208    }
209}
210
211/// Numerically stable `ln(1 − Φ(x)) = ln Φ(−x)` for the standard normal
212/// survival function.  Delegates to `normal_logcdf(-x)` so the deep-right
213/// tail benefits from the same `erfcx`-based representation.
214#[inline]
215pub fn normal_logsf(x: f64) -> f64 {
216    normal_logcdf(-x)
217}
218
219/// Joint evaluation of `ln Φ(x)` and the Mills-ratio analogue
220/// `φ(x) / Φ(x)`, signed for the symmetric branch.  Used by the latent
221/// probit families where the inverse-link gradient needs the ratio and
222/// the likelihood needs the log-CDF on the same `x`; computing both in
223/// one call shares the `erfcx` evaluation that dominates the cost in the
224/// deep tail.
225#[inline]
226pub fn signed_probit_logcdf_and_mills_ratio(x: f64) -> (f64, f64) {
227    if x == f64::INFINITY {
228        return (0.0, 0.0);
229    }
230    if x == f64::NEG_INFINITY {
231        return (f64::NEG_INFINITY, f64::INFINITY);
232    }
233    if x.is_nan() {
234        return (f64::NAN, f64::NAN);
235    }
236    if x < 0.0 {
237        let u = -x / std::f64::consts::SQRT_2;
238        let ex = erfcx_nonnegative(u).max(1e-300);
239        let log_cdf = -u * u + (0.5 * ex).ln();
240        let lambda = (2.0 / std::f64::consts::PI).sqrt() / ex;
241        (log_cdf, lambda)
242    } else {
243        let cdf = normal_cdf(x).clamp(1e-300, 1.0);
244        let lambda = normal_pdf(x) / cdf;
245        (cdf.ln(), lambda)
246    }
247}
248
249/// Standard normal quantile Φ⁻¹(p) using Acklam's rational approximation.
250#[inline]
251pub fn standard_normal_quantile(p: f64) -> Result<f64, String> {
252    if !(p.is_finite() && p > 0.0 && p < 1.0) {
253        return Err(format!("normal quantile requires p in (0,1), got {p}"));
254    }
255
256    const A: [f64; 6] = [
257        -3.969_683_028_665_376e1,
258        2.209_460_984_245_205e2,
259        -2.759_285_104_469_687e2,
260        1.383_577_518_672_69e2,
261        -3.066_479_806_614_716e1,
262        2.506_628_277_459_239,
263    ];
264    const B: [f64; 5] = [
265        -5.447_609_879_822_406e1,
266        1.615_858_368_580_409e2,
267        -1.556_989_798_598_866e2,
268        6.680_131_188_771_972e1,
269        -1.328_068_155_288_572e1,
270    ];
271    const C: [f64; 6] = [
272        -7.784_894_002_430_293e-3,
273        -3.223_964_580_411_365e-1,
274        -2.400_758_277_161_838,
275        -2.549_732_539_343_734,
276        4.374_664_141_464_968,
277        2.938_163_982_698_783,
278    ];
279    const D: [f64; 4] = [
280        7.784_695_709_041_462e-3,
281        3.224_671_290_700_398e-1,
282        2.445_134_137_142_996,
283        3.754_408_661_907_416,
284    ];
285    const P_LOW: f64 = 0.02425;
286    const P_HIGH: f64 = 1.0 - P_LOW;
287
288    let mut x = if p < P_LOW {
289        let q = (-2.0 * p.ln()).sqrt();
290        (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
291            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
292    } else if p <= P_HIGH {
293        let q = p - 0.5;
294        let r = q * q;
295        (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
296            / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
297    } else {
298        let q = (-2.0 * (1.0 - p).ln()).sqrt();
299        -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
300            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
301    };
302    for _ in 0..2 {
303        let density = normal_pdf(x);
304        if !(density.is_finite() && density > 0.0) {
305            break;
306        }
307        // Residual F(x) − p, formed without catastrophic cancellation in
308        // either tail. For an upper-tail iterate `x > 0`, `normal_cdf(x)`
309        // saturates to ~1, so the direct `normal_cdf(x) − p` annihilates the
310        // tiny residual the polish must act on; instead use the upper-tail
311        // complement `F(x) − p = (1 − p) − 0.5·erfc(x/√2)`, where both terms
312        // are the small upper-tail quantities (`1 − p` is exact by Sterbenz
313        // for `p ∈ [½,1)`). For `x ≤ 0`, `normal_cdf(x) = 0.5·erfc(|x|/√2)` is
314        // itself the faithfully carried small lower-tail value, so the direct
315        // form is already cancellation-free.
316        let residual = if x > 0.0 {
317            (1.0 - p) - 0.5 * erfc(x / std::f64::consts::SQRT_2)
318        } else {
319            normal_cdf(x) - p
320        };
321        let correction = residual / density;
322        let denominator = 1.0 + 0.5 * x * correction;
323        if !(correction.is_finite() && denominator.is_finite() && denominator != 0.0) {
324            break;
325        }
326        let step = correction / denominator;
327        if !step.is_finite() {
328            break;
329        }
330        x -= step;
331        if step.abs() <= 2.0 * f64::EPSILON * x.abs().max(1.0) {
332            break;
333        }
334    }
335    Ok(x)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    const TOL: f64 = 1e-12;
343
344    fn rel_err(got: f64, expected: f64) -> f64 {
345        (got - expected).abs() / expected.abs().max(1e-300)
346    }
347
348    #[test]
349    fn beta_quantile_matches_known_reference_values() {
350        let cases: [(f64, f64, f64, f64); 8] = [
351            (0.025, 2.0, 2.0, 0.094_299_3),
352            (0.975, 2.0, 2.0, 0.905_700_7),
353            (0.5, 2.0, 2.0, 0.5),
354            (0.025, 0.8, 4.0, 0.002_339_1),
355            (0.975, 0.8, 4.0, 0.564_717_3),
356            (0.025, 5.0, 1.5, 0.408_549_1),
357            (0.5, 20.0, 80.0, 0.197_994_8),
358            (0.975, 20.0, 80.0, 0.283_367_6),
359        ];
360        for (p, a, b, expected) in cases {
361            let got = beta_quantile(p, a, b);
362            let abs = (got - expected).abs();
363            assert!(
364                abs < 1e-5,
365                "beta_quantile(p={p}, a={a}, b={b}) = {got}, expected ≈ {expected} (abs err {abs})"
366            );
367        }
368    }
369
370    #[test]
371    fn beta_quantile_boundaries_and_degeneracy() {
372        assert_eq!(beta_quantile(0.0, 2.0, 3.0), 0.0);
373        assert_eq!(beta_quantile(-0.5, 2.0, 3.0), 0.0);
374        assert_eq!(beta_quantile(1.0, 2.0, 3.0), 1.0);
375        assert_eq!(beta_quantile(1.5, 2.0, 3.0), 1.0);
376        assert!(beta_quantile(0.5, -1.0, 3.0).is_nan());
377        assert!(beta_quantile(0.5, 2.0, 0.0).is_nan());
378        assert!(beta_quantile(0.5, f64::NAN, 3.0).is_nan());
379        let mut prev = 0.0;
380        for i in 1..100 {
381            let p = i as f64 / 100.0;
382            let q = beta_quantile(p, 3.0, 5.0);
383            assert!(q > prev, "beta quantile not increasing at p={p}");
384            prev = q;
385        }
386    }
387
388    // ── normal_pdf ────────────────────────────────────────────────────────────
389
390    #[test]
391    fn normal_pdf_at_zero() {
392        let expected = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
393        assert!((normal_pdf(0.0) - expected).abs() < TOL);
394    }
395
396    #[test]
397    fn normal_pdf_symmetry() {
398        for &x in &[0.5, 1.0, 2.0, 3.0, 5.0] {
399            assert_eq!(normal_pdf(x), normal_pdf(-x), "symmetry failed at x={x}");
400        }
401    }
402
403    #[test]
404    fn normal_pdf_positive() {
405        for &x in &[-5.0, -1.0, 0.0, 1.0, 5.0] {
406            assert!(normal_pdf(x) > 0.0, "pdf should be positive at x={x}");
407        }
408    }
409
410    // ── normal_cdf ────────────────────────────────────────────────────────────
411
412    #[test]
413    fn normal_cdf_at_zero_is_half() {
414        assert!((normal_cdf(0.0) - 0.5).abs() < TOL);
415    }
416
417    #[test]
418    fn normal_cdf_symmetry() {
419        for &x in &[0.5, 1.0, 2.0, 3.0] {
420            let sum = normal_cdf(x) + normal_cdf(-x);
421            assert!(
422                (sum - 1.0).abs() < TOL,
423                "cdf symmetry failed at x={x}: sum={sum}"
424            );
425        }
426    }
427
428    #[test]
429    fn normal_cdf_bounds() {
430        assert!(normal_cdf(10.0) > 0.9999);
431        assert!(normal_cdf(-10.0) < 1e-22);
432        assert!(normal_cdf(0.0) > 0.0);
433        assert!(normal_cdf(0.0) < 1.0);
434    }
435
436    #[test]
437    fn normal_cdf_at_1_96_near_0975() {
438        // Phi(1.96) ≈ 0.975 — canonical two-sided 5% critical value.
439        let p = normal_cdf(1.959_963_985);
440        assert!((p - 0.975).abs() < 1e-8, "p={p}");
441    }
442
443    // ── erfcx_nonnegative ─────────────────────────────────────────────────────
444
445    #[test]
446    fn erfcx_at_nonpositive_returns_one() {
447        assert_eq!(erfcx_nonnegative(0.0), 1.0);
448        assert_eq!(erfcx_nonnegative(-1.0), 1.0);
449        assert_eq!(erfcx_nonnegative(-100.0), 1.0);
450    }
451
452    #[test]
453    fn erfcx_positive_inf_returns_zero() {
454        assert_eq!(erfcx_nonnegative(f64::INFINITY), 0.0);
455    }
456
457    #[test]
458    fn erfcx_negative_inf_returns_inf() {
459        assert_eq!(erfcx_nonnegative(f64::NEG_INFINITY), f64::INFINITY);
460    }
461
462    #[test]
463    fn erfcx_small_positive_matches_direct() {
464        use statrs::function::erf::erfc;
465        for &x in &[0.1_f64, 0.5, 1.0, 5.0, 10.0, 25.0] {
466            let got = erfcx_nonnegative(x);
467            let expected = (x * x).exp() * erfc(x);
468            let err = rel_err(got, expected);
469            assert!(
470                err < 1e-10,
471                "x={x}: got={got} expected={expected} rel={err}"
472            );
473        }
474    }
475
476    #[test]
477    fn erfcx_large_x_positive_and_finite() {
478        // For x >= 26 the asymptotic branch must remain positive and finite.
479        let got = erfcx_nonnegative(50.0);
480        assert!(got.is_finite() && got > 0.0, "erfcx(50)={got}");
481        // Leading asymptotic term: 1/(x*sqrt(pi)).
482        let asymptotic = 1.0 / (50.0 * std::f64::consts::PI.sqrt());
483        assert!(
484            rel_err(got, asymptotic) < 1e-3,
485            "got={got} asymptotic={asymptotic}"
486        );
487    }
488
489    // ── log1mexp_positive ─────────────────────────────────────────────────────
490
491    #[test]
492    fn log1mexp_at_zero_is_neg_inf() {
493        assert_eq!(log1mexp_positive(0.0), f64::NEG_INFINITY);
494    }
495
496    #[test]
497    fn log1mexp_recovers_log_one_minus_exp() {
498        // Verify exp(log1mexp(a)) + exp(-a) ≈ 1 for several a > 0. This
499        // roundtrip avoids computing `(1 - exp(-a)).ln()` directly, which
500        // suffers catastrophic cancellation for large a (e.g. a=20 where
501        // `1.0 - exp(-20)` loses 9 decimal digits from the subtraction).
502        for &a in &[0.001_f64, 0.5, std::f64::consts::LN_2, 1.0, 5.0, 20.0] {
503            let lm = log1mexp_positive(a);
504            let roundtrip = lm.exp() + (-a).exp();
505            assert!(
506                (roundtrip - 1.0).abs() < 1e-14,
507                "a={a}: exp(log1mexp(a)) + exp(-a) = {roundtrip}, expected 1.0"
508            );
509        }
510    }
511
512    #[test]
513    fn log1mexp_at_ln2_is_neg_ln2() {
514        let ln2 = std::f64::consts::LN_2;
515        let got = log1mexp_positive(ln2);
516        assert!((got - (-ln2)).abs() < TOL, "got={got}");
517    }
518
519    // ── signed_log_sum_exp ────────────────────────────────────────────────────
520
521    #[test]
522    fn slse_all_positive_single() {
523        let (lm, sg) = signed_log_sum_exp(&[2.0], &[1.0]);
524        assert!((lm - 2.0).abs() < TOL);
525        assert!((sg - 1.0).abs() < TOL);
526    }
527
528    #[test]
529    fn slse_difference_recovers_log2() {
530        // 3 - 1 = 2 → log|2| = ln(2), sign = +1.
531        let log3 = 3.0_f64.ln();
532        let log1 = 0.0_f64; // ln(1)
533        let (lm, sg) = signed_log_sum_exp(&[log3, log1], &[1.0, -1.0]);
534        assert!((lm - 2.0_f64.ln()).abs() < TOL, "lm={lm}");
535        assert!((sg - 1.0).abs() < TOL, "sg={sg}");
536    }
537
538    #[test]
539    fn slse_cancellation_gives_neg_inf() {
540        // a - a = 0 → log|0| = -∞.
541        let ln2 = 2.0_f64.ln();
542        let (lm, sg) = signed_log_sum_exp(&[ln2, ln2], &[1.0, -1.0]);
543        assert_eq!(lm, f64::NEG_INFINITY);
544        assert_eq!(sg, 0.0);
545    }
546
547    #[test]
548    fn slse_empty_returns_neg_inf_with_zero_sign() {
549        // With no terms the sum is exactly 0, so the docstring contract is
550        // `(−∞, 0.0)`. (This test previously encoded the buggy `+1.0` positive-sum
551        // convention, which contradicted both the docstring and the cancellation
552        // test below; rewritten to the correct zero sign.)
553        let (lm, sg) = signed_log_sum_exp(&[], &[]);
554        assert_eq!(lm, f64::NEG_INFINITY);
555        assert_eq!(sg, 0.0);
556    }
557
558    #[test]
559    fn slse_all_zero_signs_return_zero_sign() {
560        // A single term whose sign is 0 contributes nothing; S = 0 ⇒ (−∞, 0.0).
561        let (lm, sg) = signed_log_sum_exp(&[0.0], &[0.0]);
562        assert_eq!(lm, f64::NEG_INFINITY);
563        assert_eq!(sg, 0.0);
564    }
565
566    #[test]
567    fn slse_all_neg_inf_magnitudes_return_zero_sign() {
568        // Every magnitude is exp(−∞) = 0 regardless of sign, so the sum is 0 and
569        // the reported sign must be 0.0, not +1.0.
570        let (lm, sg) = signed_log_sum_exp(&[f64::NEG_INFINITY, f64::NEG_INFINITY], &[1.0, -1.0]);
571        assert_eq!(lm, f64::NEG_INFINITY);
572        assert_eq!(sg, 0.0);
573    }
574
575    #[test]
576    fn slse_pos_inf_dominates() {
577        let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, 1.0], &[1.0, -1.0]);
578        assert_eq!(lm, f64::INFINITY);
579        assert_eq!(sg, 1.0);
580    }
581
582    #[test]
583    fn slse_neg_inf_dominates() {
584        let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, 1.0], &[-1.0, 1.0]);
585        assert_eq!(lm, f64::INFINITY);
586        assert_eq!(sg, -1.0);
587    }
588
589    #[test]
590    fn slse_both_inf_signs_gives_nan() {
591        let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, f64::INFINITY], &[1.0, -1.0]);
592        assert!(lm.is_nan());
593        assert_eq!(sg, 0.0);
594    }
595
596    // ── normal_logcdf ─────────────────────────────────────────────────────────
597
598    #[test]
599    fn logcdf_at_zero_is_log_half() {
600        let got = normal_logcdf(0.0);
601        let expected = 0.5_f64.ln();
602        assert!((got - expected).abs() < TOL, "got={got}");
603    }
604
605    #[test]
606    fn logcdf_pos_inf_is_zero() {
607        assert_eq!(normal_logcdf(f64::INFINITY), 0.0);
608    }
609
610    #[test]
611    fn logcdf_neg_inf_is_neg_inf() {
612        assert_eq!(normal_logcdf(f64::NEG_INFINITY), f64::NEG_INFINITY);
613    }
614
615    #[test]
616    fn logcdf_nan_is_nan() {
617        assert!(normal_logcdf(f64::NAN).is_nan());
618    }
619
620    #[test]
621    fn logcdf_matches_log_cdf_for_moderate_x() {
622        for &x in &[-2.0_f64, -1.0, 0.0, 1.0, 2.0, 3.0] {
623            let got = normal_logcdf(x);
624            let expected = normal_cdf(x).ln();
625            assert!(
626                (got - expected).abs() < 1e-10,
627                "x={x}: got={got} expected={expected}"
628            );
629        }
630    }
631
632    #[test]
633    fn logcdf_deep_left_tail_stays_finite() {
634        // For very negative x, normal_cdf(x) underflows to 0, but logcdf should
635        // remain finite and large-negative.
636        let got = normal_logcdf(-20.0);
637        assert!(got.is_finite() && got < -100.0, "logcdf(-20)={got}");
638    }
639
640    // ── normal_logsf ─────────────────────────────────────────────────────────
641
642    #[test]
643    fn logsf_at_zero_is_log_half() {
644        let got = normal_logsf(0.0);
645        let expected = 0.5_f64.ln();
646        assert!((got - expected).abs() < TOL, "got={got}");
647    }
648
649    #[test]
650    fn logsf_mirrors_logcdf() {
651        // logsf(x) = logcdf(-x) by definition.
652        for &x in &[-3.0_f64, -1.0, 0.0, 1.0, 3.0] {
653            assert_eq!(normal_logsf(x), normal_logcdf(-x));
654        }
655    }
656
657    // ── signed_probit_logcdf_and_mills_ratio ──────────────────────────────────
658
659    #[test]
660    fn probit_at_pos_inf() {
661        let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::INFINITY);
662        assert_eq!(lc, 0.0);
663        assert_eq!(mr, 0.0);
664    }
665
666    #[test]
667    fn probit_at_neg_inf() {
668        let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::NEG_INFINITY);
669        assert_eq!(lc, f64::NEG_INFINITY);
670        assert_eq!(mr, f64::INFINITY);
671    }
672
673    #[test]
674    fn probit_nan_propagates() {
675        let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::NAN);
676        assert!(lc.is_nan() && mr.is_nan());
677    }
678
679    #[test]
680    fn probit_at_zero_logcdf_and_mills() {
681        let (lc, mr) = signed_probit_logcdf_and_mills_ratio(0.0);
682        assert!((lc - 0.5_f64.ln()).abs() < TOL, "lc={lc}");
683        // phi(0)/Phi(0) = 0.3989.../0.5 ≈ 0.7979.
684        assert!((mr - 0.797_884_560_802_865).abs() < 1e-10, "mr={mr}");
685    }
686
687    #[test]
688    fn probit_positive_branch_matches_logcdf() {
689        for &x in &[0.5_f64, 1.0, 2.0, 3.0] {
690            let (lc, mr) = signed_probit_logcdf_and_mills_ratio(x);
691            let lc_ref = normal_logcdf(x);
692            let mr_ref = normal_pdf(x) / normal_cdf(x);
693            assert!(
694                (lc - lc_ref).abs() < 1e-10,
695                "x={x}: lc={lc} lc_ref={lc_ref}"
696            );
697            assert!(
698                (mr - mr_ref).abs() < 1e-10,
699                "x={x}: mr={mr} mr_ref={mr_ref}"
700            );
701        }
702    }
703
704    #[test]
705    fn probit_negative_branch_matches_logcdf() {
706        for &x in &[-0.5_f64, -1.0, -2.0, -5.0] {
707            let (lc, mr) = signed_probit_logcdf_and_mills_ratio(x);
708            let lc_ref = normal_logcdf(x);
709            assert!(
710                (lc - lc_ref).abs() < 1e-10,
711                "x={x}: lc={lc} lc_ref={lc_ref}"
712            );
713            assert!(mr.is_finite() && mr > 0.0, "x={x}: mr={mr}");
714        }
715    }
716
717    // ── standard_normal_quantile ──────────────────────────────────────────────
718
719    #[test]
720    fn quantile_rejects_out_of_range() {
721        assert!(standard_normal_quantile(0.0).is_err());
722        assert!(standard_normal_quantile(1.0).is_err());
723        assert!(standard_normal_quantile(-0.1).is_err());
724        assert!(standard_normal_quantile(1.1).is_err());
725        assert!(standard_normal_quantile(f64::NAN).is_err());
726    }
727
728    #[test]
729    fn quantile_at_half_is_near_zero() {
730        let q = standard_normal_quantile(0.5).unwrap();
731        assert!(q.abs() < 1e-10, "quantile(0.5)={q}");
732    }
733
734    #[test]
735    fn quantile_at_0975_is_near_196() {
736        let q = standard_normal_quantile(0.975).unwrap();
737        assert!((q - 1.959_963_985).abs() < 1e-7, "q={q}");
738    }
739
740    #[test]
741    fn quantile_antisymmetry() {
742        let q_lo = standard_normal_quantile(0.1).unwrap();
743        let q_hi = standard_normal_quantile(0.9).unwrap();
744        assert!((q_lo + q_hi).abs() < 1e-10, "q_lo={q_lo} q_hi={q_hi}");
745    }
746
747    #[test]
748    fn quantile_roundtrip_cdf() {
749        for &p in &[
750            0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 0.999,
751        ] {
752            let q = standard_normal_quantile(p).unwrap();
753            let p_back = normal_cdf(q);
754            assert!(
755                (p_back - p).abs() < 1e-10,
756                "roundtrip failed at p={p}: q={q} p_back={p_back}"
757            );
758        }
759    }
760}