Skip to main content

gam_math/
probability.rs

1use libm::erfc;
2use statrs::function::beta::inv_beta_reg;
3
4const INV_SQRT_PI: f64 = 0.564_189_583_547_756_3;
5const SQRT_2_OVER_PI: f64 = 0.797_884_560_802_865_4;
6
7/// Quantile (inverse CDF) of a Beta distribution with shape parameters `a > 0`
8/// and `b > 0` at probability `p`: the value `x in [0, 1]` with
9/// `I_x(a, b) = p`, where `I` is the regularized incomplete beta.
10///
11/// `p <= 0` maps to the support floor and `p >= 1` to the support ceiling. A
12/// non-finite or non-positive shape yields `NaN`.
13pub fn beta_quantile(p: f64, a: f64, b: f64) -> f64 {
14    if !(a.is_finite() && a > 0.0 && b.is_finite() && b > 0.0) {
15        return f64::NAN;
16    }
17    if !p.is_finite() || p <= 0.0 {
18        return 0.0;
19    }
20    if p >= 1.0 {
21        return 1.0;
22    }
23    inv_beta_reg(a, b, p)
24}
25
26/// Standard normal PDF phi(x).
27#[inline]
28pub fn normal_pdf(x: f64) -> f64 {
29    const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
30    INV_SQRT_2PI * (-0.5 * x * x).exp()
31}
32
33/// Standard normal CDF Phi(x) evaluated via the exact special-function identity
34///
35///   Phi(x) = 0.5 * erfc(-x / sqrt(2)).
36///
37/// This is the exact Gaussian CDF semantics used throughout the codebase. The
38/// numerical `erfc` implementation may use internal approximations, but the
39/// returned function is the standard normal CDF itself rather than a separate
40/// polynomial surrogate surface.
41#[inline]
42pub fn normal_cdf(x: f64) -> f64 {
43    0.5 * erfc(-x / std::f64::consts::SQRT_2)
44}
45
46/// Scaled complementary error function `erfcx(x) = exp(x²) · erfc(x)`,
47/// specialized to the closed domain `x ∈ [0, +∞]`.
48///
49/// `+∞` maps to the exact limiting value `0`; `NaN` and negative inputs map to
50/// `NaN` because they violate this restricted kernel's domain. For
51/// `0 ≤ x < 26` the direct `exp(x²)·erfc(x)` form is finite. Beyond that point
52/// a six-correction asymptotic expansion avoids overflow while retaining the
53/// representable subnormal tail. At the switch, the first omitted term is
54/// below `2e-17` relative to the leading term.
55#[inline]
56pub fn erfcx_nonnegative(x: f64) -> f64 {
57    if x.is_nan() || x < 0.0 {
58        return f64::NAN;
59    }
60    if x == f64::INFINITY {
61        return 0.0;
62    }
63    if x < 26.0 {
64        (x * x).exp() * erfc(x)
65    } else {
66        let inv = 1.0 / x;
67        let inv2 = inv * inv;
68        // erfcx(x) ~ 1/(sqrt(pi)x) * sum_n (-1)^n (2n-1)!!/(2x^2)^n.
69        // Horner form keeps the correction well scaled when `inv2` is tiny.
70        let poly = 1.0
71            + inv2
72                * (-0.5
73                    + inv2
74                        * (0.75
75                            + inv2
76                                * (-1.875
77                                    + inv2 * (6.5625 + inv2 * (-29.53125 + inv2 * 162.421875)))));
78        inv * poly * INV_SQRT_PI
79    }
80}
81
82/// Computes `log(1 - exp(-a))` for `a >= 0` without cancellation.
83#[inline]
84pub fn log1mexp_positive(a: f64) -> f64 {
85    assert!(a >= 0.0, "log1mexp_positive requires a >= 0: a={a}");
86    if a > core::f64::consts::LN_2 {
87        (-(-a).exp()).ln_1p()
88    } else if a > 0.0 {
89        (-(-a).exp_m1()).ln()
90    } else {
91        f64::NEG_INFINITY
92    }
93}
94
95/// Numerically stable signed log-sum-exp.  Given pairs
96/// `(log|aⱼ|, sign(aⱼ))` (with `signs[j] ∈ {−1, 0, +1}`), returns
97/// `(log|S|, sign(S))` for `S = Σⱼ signs[j]·exp(log_mags[j])`.  Positive
98/// and negative magnitudes are reduced separately with the standard
99/// log-sum-exp trick (subtract the max, sum, log, add back); the two
100/// partial sums are then combined via `log(|p − n|) =
101/// max(log p, log n) + log1mexp(|log p − log n|)`, preserving accuracy
102/// even when `p ≈ n` (catastrophic cancellation regime).  When all
103/// signs are zero or all magnitudes are `−∞`, returns
104/// `(NEG_INFINITY, 0.0)`.
105///
106/// A `+∞` log-magnitude denotes an infinite-magnitude term (`exp(+∞) = +∞`)
107/// and dominates the sum: if it appears only with positive sign the result
108/// is `(+∞, +1)`; only with negative sign, `(+∞, −1)` (a log-magnitude of
109/// `+∞` with sign `−1` encodes the value `−∞`); with both signs the sum is
110/// the indeterminate `+∞ − ∞`, returned as `(NaN, 0.0)`.  A `−∞`
111/// log-magnitude is `exp(−∞) = 0` and is correctly dropped.
112pub fn signed_log_sum_exp(log_mags: &[f64], signs: &[f64]) -> (f64, f64) {
113    // Infinite-magnitude terms dominate any finite contribution, so resolve
114    // them before the finite log-sum-exp reduction below. `−∞` log-magnitudes
115    // are `exp(−∞) = 0` and need no special handling.
116    let mut has_pos_inf = false;
117    let mut has_neg_inf = false;
118    for (idx, &lm) in log_mags.iter().enumerate() {
119        if lm == f64::INFINITY {
120            if signs[idx] > 0.0 {
121                has_pos_inf = true;
122            } else if signs[idx] < 0.0 {
123                has_neg_inf = true;
124            }
125        }
126    }
127    match (has_pos_inf, has_neg_inf) {
128        // P = +∞, N = +∞ ⇒ indeterminate +∞ − ∞.
129        (true, true) => return (f64::NAN, 0.0),
130        // P = +∞, N < ∞ ⇒ S = +∞.
131        (true, false) => return (f64::INFINITY, 1.0),
132        // N = +∞, P < ∞ ⇒ S = −∞, encoded as log-magnitude +∞ with sign −1.
133        (false, true) => return (f64::INFINITY, -1.0),
134        (false, false) => {}
135    }
136
137    let mut pos_max = f64::NEG_INFINITY;
138    let mut neg_max = f64::NEG_INFINITY;
139    for (idx, &lm) in log_mags.iter().enumerate() {
140        if signs[idx] > 0.0 {
141            pos_max = pos_max.max(lm);
142        } else if signs[idx] < 0.0 {
143            neg_max = neg_max.max(lm);
144        }
145    }
146
147    let mut pos_sum = 0.0_f64;
148    let mut neg_sum = 0.0_f64;
149    for (idx, &lm) in log_mags.iter().enumerate() {
150        if !lm.is_finite() {
151            continue;
152        }
153        if signs[idx] > 0.0 {
154            pos_sum += (lm - pos_max).exp();
155        } else if signs[idx] < 0.0 {
156            neg_sum += (lm - neg_max).exp();
157        }
158    }
159
160    let log_pos = if pos_sum > 0.0 {
161        pos_max + pos_sum.ln()
162    } else {
163        f64::NEG_INFINITY
164    };
165    let log_neg = if neg_sum > 0.0 {
166        neg_max + neg_sum.ln()
167    } else {
168        f64::NEG_INFINITY
169    };
170
171    if log_pos == f64::NEG_INFINITY && log_neg == f64::NEG_INFINITY {
172        // Both partial sums are empty: no terms at all, all signs zero, or every
173        // magnitude `−∞` (each `exp(−∞) = 0`). The signed sum is exactly `0`, so
174        // the contract requires `(−∞, 0.0)` — NOT the positive-sum convention,
175        // which would mislabel a zero as `+1` and corrupt any downstream cascade
176        // that reads back the sign.
177        return (f64::NEG_INFINITY, 0.0);
178    }
179    if log_neg == f64::NEG_INFINITY {
180        return (log_pos, 1.0);
181    }
182    if log_pos == f64::NEG_INFINITY {
183        return (log_neg, -1.0);
184    }
185    if log_pos > log_neg {
186        let gap = log_pos - log_neg;
187        (log_pos + log1mexp_positive(gap), 1.0)
188    } else if log_neg > log_pos {
189        let gap = log_neg - log_pos;
190        (log_neg + log1mexp_positive(gap), -1.0)
191    } else {
192        (f64::NEG_INFINITY, 0.0)
193    }
194}
195
196/// Numerically stable `ln Φ(x)` for the standard normal CDF. For `x ≥ 0`,
197/// evaluates `ln(1 - 0.5 erfc(x/sqrt(2)))` with `ln_1p`, retaining the small
198/// negative result after `Φ(x)` itself rounds to one. For `x < 0`, rewrites
199/// `ln Φ(x) = −u² + ln(½·erfcx(u))`, `u = −x/√2`,
200/// which preserves digits throughout the representable left tail without a
201/// probability floor. Returns the corresponding IEEE limit at infinities and
202/// propagates `NaN`.
203#[inline]
204pub fn normal_logcdf(x: f64) -> f64 {
205    if x == f64::INFINITY {
206        return 0.0;
207    }
208    if x == f64::NEG_INFINITY {
209        return f64::NEG_INFINITY;
210    }
211    if x.is_nan() {
212        return f64::NAN;
213    }
214    if x < 0.0 {
215        let (u, scaled_tail) = negative_normal_tail_components(x);
216        negative_normal_logcdf_from_scaled_tail(u, scaled_tail)
217    } else {
218        let upper_tail = 0.5 * erfc(x / std::f64::consts::SQRT_2);
219        (-upper_tail).ln_1p()
220    }
221}
222
223/// Numerically stable `ln(1 − Φ(x)) = ln Φ(−x)` for the standard normal
224/// survival function.  Delegates to `normal_logcdf(-x)` so the deep-right
225/// tail benefits from the same `erfcx`-based representation.
226#[inline]
227pub fn normal_logsf(x: f64) -> f64 {
228    normal_logcdf(-x)
229}
230
231/// Joint evaluation of `ln Φ(x)` and the Mills-ratio analogue
232/// `φ(x) / Φ(x)`, signed for the symmetric branch.  Used by the latent
233/// probit families where the inverse-link gradient needs the ratio and
234/// the likelihood needs the log-CDF on the same `x`; computing both in
235/// one call shares the `erfcx` evaluation that dominates the cost in the
236/// deep tail.
237#[inline]
238pub fn signed_probit_logcdf_and_mills_ratio(x: f64) -> (f64, f64) {
239    if x == f64::INFINITY {
240        return (0.0, 0.0);
241    }
242    if x == f64::NEG_INFINITY {
243        return (f64::NEG_INFINITY, f64::INFINITY);
244    }
245    if x.is_nan() {
246        return (f64::NAN, f64::NAN);
247    }
248    if x < 0.0 {
249        let (u, scaled_tail) = negative_normal_tail_components(x);
250        (
251            negative_normal_logcdf_from_scaled_tail(u, scaled_tail),
252            SQRT_2_OVER_PI / scaled_tail,
253        )
254    } else {
255        let upper_tail = 0.5 * erfc(x / std::f64::consts::SQRT_2);
256        let cdf = 1.0 - upper_tail;
257        let lambda = normal_pdf(x) / cdf;
258        ((-upper_tail).ln_1p(), lambda)
259    }
260}
261
262#[inline]
263fn negative_normal_tail_components(x: f64) -> (f64, f64) {
264    assert!(x.is_finite() && x < 0.0);
265    let u = -x / std::f64::consts::SQRT_2;
266    (u, erfcx_nonnegative(u))
267}
268
269#[inline]
270fn negative_normal_logcdf_from_scaled_tail(u: f64, scaled_tail: f64) -> f64 {
271    -u * u + scaled_tail.ln() - std::f64::consts::LN_2
272}
273
274/// Stable value and first four derivatives of `ln Φ(x)`.
275///
276/// The moderate regime uses the exact Mills-ratio recurrence. In the deep
277/// left tail, differentiating the Laplace continued fraction
278///
279/// `φ(t)/Φ(-t) = t + 1/(t + 2/(t + 3/(...)))`, `t = -x`,
280///
281/// carries the small correction to `t` independently, so `f'' -> -1` and the
282/// higher derivatives approach zero without subtracting nearly equal `f64`s.
283/// In the right tail, signed log-magnitude sums preserve polynomially weighted
284/// derivatives even when `φ(x)/Φ(x)` itself has rounded to zero.
285#[inline]
286pub fn normal_logcdf_derivatives(x: f64) -> [f64; 5] {
287    if x.is_nan() {
288        return [f64::NAN; 5];
289    }
290    if x == f64::INFINITY {
291        return [0.0; 5];
292    }
293    if x == f64::NEG_INFINITY {
294        return [f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0, 0.0];
295    }
296
297    const LEFT_CONTINUED_FRACTION_SWITCH: f64 = -4.0;
298    const RIGHT_LOG_MAGNITUDE_SWITCH: f64 = 8.0;
299    if x <= LEFT_CONTINUED_FRACTION_SWITCH {
300        return normal_logcdf_derivatives_left_tail(x);
301    }
302    if x >= RIGHT_LOG_MAGNITUDE_SWITCH {
303        return normal_logcdf_derivatives_right_tail(x);
304    }
305
306    let (log_cdf, lambda) = signed_probit_logcdf_and_mills_ratio(x);
307    let lambda2 = lambda * lambda;
308    let lambda3 = lambda2 * lambda;
309    let x2 = x * x;
310    [
311        log_cdf,
312        lambda,
313        -lambda * (x + lambda),
314        lambda * (x2 - 1.0 + 3.0 * x * lambda + 2.0 * lambda2),
315        -lambda
316            * ((x * x2 - 3.0 * x) + (7.0 * x2 - 4.0) * lambda + 12.0 * x * lambda2 + 6.0 * lambda3),
317    ]
318}
319
320#[derive(Clone, Copy)]
321struct MillsCorrectionDerivatives {
322    value: f64,
323    first: f64,
324    second: f64,
325    third: f64,
326}
327
328#[inline]
329fn normal_logcdf_derivatives_left_tail(x: f64) -> [f64; 5] {
330    assert!(x.is_finite() && x <= -4.0);
331    let t = -x;
332    let mut q = MillsCorrectionDerivatives {
333        value: 0.0,
334        first: 0.0,
335        second: 0.0,
336        third: 0.0,
337    };
338    // The truncation error is damped by a product of the continued-fraction
339    // sensitivities `n/(t + q)^2`. At t >= 4, 32 levels put that product below
340    // binary64 roundoff while keeping this uncommon derivative path compact.
341    for n in (1..=32).rev() {
342        let denominator = t + q.value;
343        let inv_denominator = denominator.recip();
344        let value = f64::from(n) / denominator;
345        let denominator_first = 1.0 + q.first;
346        let a = denominator_first * inv_denominator;
347        let b = q.second * inv_denominator;
348        let c = q.third * inv_denominator;
349        q = MillsCorrectionDerivatives {
350            value,
351            first: -value * denominator_first / denominator,
352            second: value * (2.0 * a * a - b),
353            third: value * (-6.0 * a * a * a + 6.0 * a * b - c),
354        };
355    }
356    [
357        normal_logcdf(x),
358        t + q.value,
359        -(1.0 + q.first),
360        q.second,
361        -q.third,
362    ]
363}
364
365#[inline]
366fn normal_logcdf_derivatives_right_tail(x: f64) -> [f64; 5] {
367    assert!(x.is_finite() && x >= 8.0);
368    const LOG_SQRT_2PI: f64 = 0.918_938_533_204_672_7;
369    let log_cdf = normal_logcdf(x);
370    let u = x / std::f64::consts::SQRT_2;
371    let log_lambda = -u * u - LOG_SQRT_2PI - log_cdf;
372    let log_x = x.ln();
373    let inv_x2 = x.recip() * x.recip();
374
375    let first = log_lambda.exp();
376    let second = signed_exp_sum(&[log_x + log_lambda, 2.0 * log_lambda], &[-1.0, -1.0]);
377    let third = signed_exp_sum(
378        &[
379            2.0 * log_x + (-inv_x2).ln_1p() + log_lambda,
380            3.0_f64.ln() + log_x + 2.0 * log_lambda,
381            2.0_f64.ln() + 3.0 * log_lambda,
382        ],
383        &[1.0, 1.0, 1.0],
384    );
385    let fourth = signed_exp_sum(
386        &[
387            3.0 * log_x + (-3.0 * inv_x2).ln_1p() + log_lambda,
388            7.0_f64.ln() + 2.0 * log_x + (-(4.0 / 7.0) * inv_x2).ln_1p() + 2.0 * log_lambda,
389            12.0_f64.ln() + log_x + 3.0 * log_lambda,
390            6.0_f64.ln() + 4.0 * log_lambda,
391        ],
392        &[-1.0, -1.0, -1.0, -1.0],
393    );
394    [log_cdf, first, second, third, fourth]
395}
396
397#[inline]
398fn signed_exp_sum(log_magnitudes: &[f64], signs: &[f64]) -> f64 {
399    let (log_magnitude, sign) = signed_log_sum_exp(log_magnitudes, signs);
400    if sign == 0.0 {
401        0.0
402    } else {
403        sign * log_magnitude.exp()
404    }
405}
406
407/// Standard normal quantile Φ⁻¹(p) using Acklam's rational approximation.
408#[inline]
409pub fn standard_normal_quantile(p: f64) -> Result<f64, String> {
410    if !(p.is_finite() && p > 0.0 && p < 1.0) {
411        return Err(format!("normal quantile requires p in (0,1), got {p}"));
412    }
413
414    const A: [f64; 6] = [
415        -3.969_683_028_665_376e1,
416        2.209_460_984_245_205e2,
417        -2.759_285_104_469_687e2,
418        1.383_577_518_672_69e2,
419        -3.066_479_806_614_716e1,
420        2.506_628_277_459_239,
421    ];
422    const B: [f64; 5] = [
423        -5.447_609_879_822_406e1,
424        1.615_858_368_580_409e2,
425        -1.556_989_798_598_866e2,
426        6.680_131_188_771_972e1,
427        -1.328_068_155_288_572e1,
428    ];
429    const C: [f64; 6] = [
430        -7.784_894_002_430_293e-3,
431        -3.223_964_580_411_365e-1,
432        -2.400_758_277_161_838,
433        -2.549_732_539_343_734,
434        4.374_664_141_464_968,
435        2.938_163_982_698_783,
436    ];
437    const D: [f64; 4] = [
438        7.784_695_709_041_462e-3,
439        3.224_671_290_700_398e-1,
440        2.445_134_137_142_996,
441        3.754_408_661_907_416,
442    ];
443    const P_LOW: f64 = 0.02425;
444    const P_HIGH: f64 = 1.0 - P_LOW;
445
446    let mut x = if p < P_LOW {
447        let q = (-2.0 * p.ln()).sqrt();
448        (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
449            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
450    } else if p <= P_HIGH {
451        let q = p - 0.5;
452        let r = q * q;
453        (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
454            / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
455    } else {
456        let q = (-2.0 * (1.0 - p).ln()).sqrt();
457        -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
458            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
459    };
460    for _ in 0..2 {
461        let density = normal_pdf(x);
462        if !(density.is_finite() && density > 0.0) {
463            break;
464        }
465        // Residual F(x) − p, formed without catastrophic cancellation in
466        // either tail. For an upper-tail iterate `x > 0`, `normal_cdf(x)`
467        // saturates to ~1, so the direct `normal_cdf(x) − p` annihilates the
468        // tiny residual the polish must act on; instead use the upper-tail
469        // complement `F(x) − p = (1 − p) − 0.5·erfc(x/√2)`, where both terms
470        // are the small upper-tail quantities (`1 − p` is exact by Sterbenz
471        // for `p ∈ [½,1)`). For `x ≤ 0`, `normal_cdf(x) = 0.5·erfc(|x|/√2)` is
472        // itself the faithfully carried small lower-tail value, so the direct
473        // form is already cancellation-free.
474        let residual = if x > 0.0 {
475            (1.0 - p) - 0.5 * erfc(x / std::f64::consts::SQRT_2)
476        } else {
477            normal_cdf(x) - p
478        };
479        let correction = residual / density;
480        let denominator = 1.0 + 0.5 * x * correction;
481        if !(correction.is_finite() && denominator.is_finite() && denominator != 0.0) {
482            break;
483        }
484        let step = correction / denominator;
485        if !step.is_finite() {
486            break;
487        }
488        x -= step;
489        if step.abs() <= 2.0 * f64::EPSILON * x.abs().max(1.0) {
490            break;
491        }
492    }
493    Ok(x)
494}
495
496/// Log of the standardized one-sided truncated-Gaussian boundary factor for
497/// the constrained-LAML cone correction (gam#2306 §4).
498///
499/// For a constraint coordinate with Lagrange multiplier `μ ≥ 0`, normal
500/// curvature `h > 0`, and signed interior slack `s ≥ 0`, the exact 1-D
501/// boundary integral is
502///
503/// ```text
504///   ∫_{−s}^{∞} exp(−μ·u − ½·h·u²) du
505///     = √(2π/h) · exp(μ²/(2h)) · Φ(s·√h − μ/√h),
506/// ```
507///
508/// and the correction of the Laplace criterion RELATIVE to the unrestricted
509/// Gaussian factor `√(2π/h)` is, in the standardized arguments
510/// `a = μ/√h ≥ 0`, `b = s·√h ≥ 0`:
511///
512/// ```text
513///   corr(a, b) = a²/2 + ln Φ(b − a).
514/// ```
515///
516/// Key limits (the #2306 derivation's continuity contract): an activating
517/// row (`a = 0`, `b = 0`) contributes exactly `ln ½` (the half-Gaussian); a
518/// deep interior row (`b − a → ∞`) contributes `→ 0`, reducing byte-exactly
519/// to the unrestricted LAML; a hard-pushed active row (`a → ∞`, `b = 0`)
520/// follows the exact linear-decay limit `corr → −ln(a·√(2π))`.
521///
522/// Evaluated FUSED: computing `a²/2` and `ln Φ(b−a)` as two separate f64
523/// terms cancels catastrophically once `a ≳ 10⁴` (both grow like `±a²/2`).
524/// On the `b < a` branch the sum collapses analytically to
525/// `a·b − b²/2 + ln(erfcx((a−b)/√2)/2)`, which is cancellation-free (for an
526/// active row, `b = 0`, it is a single `erfcx` evaluation).
527#[must_use]
528pub fn cone_boundary_log_factor(mu_over_sqrt_h: f64, slack_times_sqrt_h: f64) -> f64 {
529    let a = mu_over_sqrt_h;
530    let b = slack_times_sqrt_h;
531    if !(a.is_finite() && b.is_finite()) || a < 0.0 || b < 0.0 {
532        return f64::NAN;
533    }
534    let xi = b - a;
535    if xi >= 0.0 {
536        // Interior-dominant: ln Φ(ξ) is a small negative number and a²/2 is
537        // exact; no cancellation between them (a ≤ b here, so a²/2 ≤ ab −
538        // b²/2 + O(1) stays modest whenever the factor itself is modest).
539        0.5 * a * a + normal_logcdf(xi)
540    } else {
541        // Active-dominant: fused analytic collapse of a²/2 + ln Φ(−(a−b)).
542        let u = (a - b) / std::f64::consts::SQRT_2;
543        a * b - 0.5 * b * b + (0.5 * erfcx_nonnegative(u)).ln()
544    }
545}
546
547/// [`cone_boundary_log_factor`] together with its exact partial derivatives
548/// in the standardized arguments — the pieces the outer ρ-gradient chains
549/// through `(μ̃, h̃, s)(ρ)` (gam#2306 §4 "the g-factors differentiate in
550/// closed form"). With `ξ = b − a` and the Mills ratio `λ(ξ) = φ(ξ)/Φ(ξ)`:
551///
552/// ```text
553///   ∂corr/∂a = a − λ(ξ),      ∂corr/∂b = λ(ξ).
554/// ```
555///
556/// Both are cancellation-free: `λ` comes from the shared `erfcx` evaluation,
557/// and at the linear-decay limit `a − λ(−a) → −1/a` exactly.
558#[must_use]
559pub fn cone_boundary_log_factor_and_derivatives(
560    mu_over_sqrt_h: f64,
561    slack_times_sqrt_h: f64,
562) -> (f64, f64, f64) {
563    let value = cone_boundary_log_factor(mu_over_sqrt_h, slack_times_sqrt_h);
564    let xi = slack_times_sqrt_h - mu_over_sqrt_h;
565    let (_, mills) = signed_probit_logcdf_and_mills_ratio(xi);
566    (value, mu_over_sqrt_h - mills, mills)
567}
568
569#[cfg(test)]
570mod cone_boundary_factor_tests {
571    use super::*;
572
573    /// Adaptive-free Simpson quadrature of the exact 1-D boundary integral
574    /// `∫_{−s}^{U} exp(−μu − ½hu²) du` on a truncation `U` chosen so the
575    /// discarded tail is below 1e-18 of the mass.
576    fn quadrature_log_relative_factor(mu: f64, h: f64, s: f64) -> f64 {
577        let upper = ((-mu / h) + 12.0 / h.sqrt()).max(-s + 12.0 / h.sqrt());
578        let lower = -s;
579        let n = 40_000usize;
580        let step = (upper - lower) / n as f64;
581        let f = |u: f64| (-mu * u - 0.5 * h * u * u).exp();
582        let mut acc = f(lower) + f(upper);
583        for i in 1..n {
584            let u = lower + step * i as f64;
585            acc += if i % 2 == 1 { 4.0 } else { 2.0 } * f(u);
586        }
587        let integral = acc * step / 3.0;
588        (integral / (2.0 * std::f64::consts::PI / h).sqrt()).ln()
589    }
590
591    /// The closed form must match direct quadrature of the defining integral
592    /// across active (s=0), interior (μ=0), and mixed regimes (gam#2306 §4).
593    #[test]
594    fn boundary_factor_matches_quadrature_across_regimes() {
595        let cases: [(f64, f64, f64); 8] = [
596            (0.0, 1.0, 0.0),  // activating row: exactly ln ½
597            (0.0, 4.0, 0.0),  // curvature does not move the standardized value
598            (2.5, 1.0, 0.0),  // active with a real multiplier
599            (30.0, 9.0, 0.0), // deep linear-decay limit
600            (0.0, 1.0, 0.7),  // interior near-boundary
601            (0.0, 2.0, 4.0),  // interior far: → 0
602            (1.5, 0.5, 2.0),  // mixed multiplier + slack
603            (4.0, 2.0, 1.0),  // active-dominant mixed
604        ];
605        for &(mu, h, s) in &cases {
606            let a = mu / h.sqrt();
607            let b = s * h.sqrt();
608            let closed = cone_boundary_log_factor(a, b);
609            let quad = quadrature_log_relative_factor(mu, h, s);
610            assert!(
611                (closed - quad).abs() <= 1e-9 * (1.0 + quad.abs()),
612                "(μ={mu}, h={h}, s={s}): closed {closed} vs quadrature {quad}"
613            );
614        }
615        assert!(
616            (cone_boundary_log_factor(0.0, 0.0) - 0.5_f64.ln()).abs() < 1e-15,
617            "an activating row must contribute exactly the half-Gaussian ln ½"
618        );
619    }
620
621    /// The deep-active limit is the exact linear decay `corr → −ln(a·√(2π))`,
622    /// and the deep-interior limit vanishes — the two continuity anchors that
623    /// make the constrained criterion reduce to the unrestricted LAML away
624    /// from the boundary.
625    #[test]
626    fn boundary_factor_limits_are_exact() {
627        let a = 1.0e6;
628        let expected = -(a * (2.0 * std::f64::consts::PI).sqrt()).ln();
629        let got = cone_boundary_log_factor(a, 0.0);
630        assert!(
631            (got - expected).abs() <= 1e-9 * expected.abs(),
632            "deep-active: got {got}, expected {expected}"
633        );
634        let interior = cone_boundary_log_factor(0.0, 40.0);
635        assert!(
636            interior.abs() < 1e-300 || interior > -1e-12,
637            "deep-interior must vanish; got {interior}"
638        );
639    }
640
641    /// Closed-form partials against finite differences of the value
642    /// (test-only FD; the production gradient consumes the analytic form).
643    /// The domain is `a, b ≥ 0`, so a coordinate sitting exactly on the
644    /// boundary uses a one-sided forward difference instead of stepping
645    /// outside the domain (where the factor is deliberately NaN).
646    #[test]
647    fn boundary_factor_derivatives_match_finite_differences() {
648        let cases: [(f64, f64); 5] = [(0.3, 0.0), (2.0, 0.5), (0.0, 1.2), (5.0, 0.2), (0.7, 3.0)];
649        let step = 1e-6;
650        let fd = |lo: f64, mid: f64, hi: f64, coord: f64| -> f64 {
651            if coord >= step {
652                (hi - lo) / (2.0 * step)
653            } else {
654                (hi - mid) / step
655            }
656        };
657        for &(a, b) in &cases {
658            let (_, d_a, d_b) = cone_boundary_log_factor_and_derivatives(a, b);
659            let fd_a = fd(
660                cone_boundary_log_factor((a - step).max(0.0), b),
661                cone_boundary_log_factor(a, b),
662                cone_boundary_log_factor(a + step, b),
663                a,
664            );
665            let fd_b = fd(
666                cone_boundary_log_factor(a, (b - step).max(0.0)),
667                cone_boundary_log_factor(a, b),
668                cone_boundary_log_factor(a, b + step),
669                b,
670            );
671            // One-sided differences on boundary coordinates carry O(step)
672            // truncation error, so the band is a few multiples of step.
673            assert!(
674                (d_a - fd_a).abs() <= 5e-6 * (1.0 + fd_a.abs()),
675                "(a={a}, b={b}): ∂a analytic {d_a} vs FD {fd_a}"
676            );
677            assert!(
678                (d_b - fd_b).abs() <= 5e-6 * (1.0 + fd_b.abs()),
679                "(a={a}, b={b}): ∂b analytic {d_b} vs FD {fd_b}"
680            );
681        }
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    const TOL: f64 = 1e-12;
690
691    fn rel_err(got: f64, expected: f64) -> f64 {
692        (got - expected).abs() / expected.abs().max(1e-300)
693    }
694
695    #[test]
696    fn beta_quantile_matches_known_reference_values() {
697        let cases: [(f64, f64, f64, f64); 8] = [
698            (0.025, 2.0, 2.0, 0.094_299_3),
699            (0.975, 2.0, 2.0, 0.905_700_7),
700            (0.5, 2.0, 2.0, 0.5),
701            (0.025, 0.8, 4.0, 0.002_339_1),
702            (0.975, 0.8, 4.0, 0.564_717_3),
703            (0.025, 5.0, 1.5, 0.408_549_1),
704            (0.5, 20.0, 80.0, 0.197_994_8),
705            (0.975, 20.0, 80.0, 0.283_367_6),
706        ];
707        for (p, a, b, expected) in cases {
708            let got = beta_quantile(p, a, b);
709            let abs = (got - expected).abs();
710            assert!(
711                abs < 1e-5,
712                "beta_quantile(p={p}, a={a}, b={b}) = {got}, expected ≈ {expected} (abs err {abs})"
713            );
714        }
715    }
716
717    #[test]
718    fn beta_quantile_boundaries_and_degeneracy() {
719        assert_eq!(beta_quantile(0.0, 2.0, 3.0), 0.0);
720        assert_eq!(beta_quantile(-0.5, 2.0, 3.0), 0.0);
721        assert_eq!(beta_quantile(1.0, 2.0, 3.0), 1.0);
722        assert_eq!(beta_quantile(1.5, 2.0, 3.0), 1.0);
723        assert!(beta_quantile(0.5, -1.0, 3.0).is_nan());
724        assert!(beta_quantile(0.5, 2.0, 0.0).is_nan());
725        assert!(beta_quantile(0.5, f64::NAN, 3.0).is_nan());
726        let mut prev = 0.0;
727        for i in 1..100 {
728            let p = i as f64 / 100.0;
729            let q = beta_quantile(p, 3.0, 5.0);
730            assert!(q > prev, "beta quantile not increasing at p={p}");
731            prev = q;
732        }
733    }
734
735    // ── normal_pdf ────────────────────────────────────────────────────────────
736
737    #[test]
738    fn normal_pdf_at_zero() {
739        let expected = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
740        assert!((normal_pdf(0.0) - expected).abs() < TOL);
741    }
742
743    #[test]
744    fn normal_pdf_symmetry() {
745        for &x in &[0.5, 1.0, 2.0, 3.0, 5.0] {
746            assert_eq!(normal_pdf(x), normal_pdf(-x), "symmetry failed at x={x}");
747        }
748    }
749
750    #[test]
751    fn normal_pdf_positive() {
752        for &x in &[-5.0, -1.0, 0.0, 1.0, 5.0] {
753            assert!(normal_pdf(x) > 0.0, "pdf should be positive at x={x}");
754        }
755    }
756
757    // ── normal_cdf ────────────────────────────────────────────────────────────
758
759    #[test]
760    fn normal_cdf_at_zero_is_half() {
761        assert!((normal_cdf(0.0) - 0.5).abs() < TOL);
762    }
763
764    #[test]
765    fn normal_cdf_symmetry() {
766        for &x in &[0.5, 1.0, 2.0, 3.0] {
767            let sum = normal_cdf(x) + normal_cdf(-x);
768            assert!(
769                (sum - 1.0).abs() < TOL,
770                "cdf symmetry failed at x={x}: sum={sum}"
771            );
772        }
773    }
774
775    #[test]
776    fn normal_cdf_bounds() {
777        assert!(normal_cdf(10.0) > 0.9999);
778        assert!(normal_cdf(-10.0) < 1e-22);
779        assert!(normal_cdf(0.0) > 0.0);
780        assert!(normal_cdf(0.0) < 1.0);
781    }
782
783    #[test]
784    fn normal_cdf_at_1_96_near_0975() {
785        // Phi(1.96) ≈ 0.975 — canonical two-sided 5% critical value.
786        let p = normal_cdf(1.959_963_985);
787        assert!((p - 0.975).abs() < 1e-8, "p={p}");
788    }
789
790    // ── erfcx_nonnegative ─────────────────────────────────────────────────────
791
792    #[test]
793    fn erfcx_zero_is_one_and_negative_domain_is_rejected() {
794        assert_eq!(erfcx_nonnegative(0.0), 1.0);
795        assert!(erfcx_nonnegative(-f64::MIN_POSITIVE).is_nan());
796        assert!(erfcx_nonnegative(-1.0).is_nan());
797        assert!(erfcx_nonnegative(f64::NEG_INFINITY).is_nan());
798    }
799
800    #[test]
801    fn erfcx_positive_inf_returns_zero() {
802        assert_eq!(erfcx_nonnegative(f64::INFINITY), 0.0);
803    }
804
805    #[test]
806    fn erfcx_nan_propagates() {
807        assert!(erfcx_nonnegative(f64::NAN).is_nan());
808    }
809
810    #[test]
811    fn erfcx_small_positive_matches_direct() {
812        use libm::erfc;
813        for &x in &[0.1_f64, 0.5, 1.0, 5.0, 10.0, 25.0] {
814            let got = erfcx_nonnegative(x);
815            let expected = (x * x).exp() * erfc(x);
816            let err = rel_err(got, expected);
817            assert!(
818                err < 1e-10,
819                "x={x}: got={got} expected={expected} rel={err}"
820            );
821        }
822    }
823
824    #[test]
825    fn erfcx_large_x_positive_and_finite() {
826        // For x >= 26 the asymptotic branch must remain positive and finite.
827        let got = erfcx_nonnegative(50.0);
828        assert!(got.is_finite() && got > 0.0, "erfcx(50)={got}");
829        // Leading asymptotic term: 1/(x*sqrt(pi)).
830        let asymptotic = 1.0 / (50.0 * std::f64::consts::PI.sqrt());
831        assert!(
832            rel_err(got, asymptotic) < 1e-3,
833            "got={got} asymptotic={asymptotic}"
834        );
835    }
836
837    #[test]
838    fn erfcx_asymptotic_switch_matches_finite_direct_identity() {
839        let switch = 26.0_f64;
840        let direct = (switch * switch).exp() * erfc(switch);
841        let asymptotic = erfcx_nonnegative(switch);
842        assert!(
843            rel_err(asymptotic, direct) < 5.0e-14,
844            "switch mismatch: asymptotic={asymptotic:.17e}, direct={direct:.17e}"
845        );
846
847        let immediately_below = f64::from_bits(switch.to_bits() - 1);
848        let below = erfcx_nonnegative(immediately_below);
849        assert!(
850            rel_err(asymptotic, below) < 5.0e-14,
851            "discontinuous switch: below={below:.17e}, at={asymptotic:.17e}"
852        );
853    }
854
855    #[test]
856    fn erfcx_preserves_representable_subnormal_tail() {
857        let tail = erfcx_nonnegative(f64::MAX);
858        assert!(tail > 0.0 && tail.is_subnormal(), "erfcx(MAX)={tail:e}");
859    }
860
861    /// Absolute-accuracy pin against an EXTERNAL high-precision reference
862    /// (mpmath, dps=60) spanning the direct branch `[0.1, 26)`. This is the
863    /// root-cause guard: the previous `exp(x²)·erfc(x)` direct form was built on
864    /// `statrs::erfc`, whose ~1e-10 relative accuracy silently poisoned every
865    /// downstream probit / Mills / log-CDF derivative. The `1e-13` tolerance is
866    /// far below what any ~1e-10 `erfc` can meet, so a regression to a
867    /// low-accuracy complementary error function fails here immediately —
868    /// independent of the seam-continuity check above.
869    #[test]
870    fn erfcx_matches_high_precision_reference() {
871        // (x, mpmath exp(x²)·erfc(x) at dps=60, rounded to f64).
872        let refs: &[(f64, f64)] = &[
873            (0.1, 0.89645697996912664),
874            (0.5, 0.61569034419292587),
875            (1.0, 0.427583576155807),
876            (2.0, 0.25539567631050574),
877            (3.5, 0.1552936556088943),
878            (6.0, 0.092776567800538354),
879            (9.0, 0.062307724037774684),
880            (13.0, 0.043271921864609693),
881            (18.0, 0.03129571781590521),
882            (22.0, 0.025618570005879453),
883            (25.5, 0.022108108052519827),
884            (25.9999, 0.021683668126370212),
885        ];
886        for &(x, reference) in refs {
887            let got = erfcx_nonnegative(x);
888            let rel = (got - reference).abs() / reference.abs();
889            assert!(
890                rel < 1.0e-13,
891                "erfcx({x}) = {got:.17e}, reference {reference:.17e}, rel {rel:.3e} >= 1e-13"
892            );
893        }
894    }
895
896    // ── log1mexp_positive ─────────────────────────────────────────────────────
897
898    #[test]
899    fn log1mexp_at_zero_is_neg_inf() {
900        assert_eq!(log1mexp_positive(0.0), f64::NEG_INFINITY);
901    }
902
903    #[test]
904    fn log1mexp_recovers_log_one_minus_exp() {
905        // Verify exp(log1mexp(a)) + exp(-a) ≈ 1 for several a > 0. This
906        // roundtrip avoids computing `(1 - exp(-a)).ln()` directly, which
907        // suffers catastrophic cancellation for large a (e.g. a=20 where
908        // `1.0 - exp(-20)` loses 9 decimal digits from the subtraction).
909        for &a in &[0.001_f64, 0.5, std::f64::consts::LN_2, 1.0, 5.0, 20.0] {
910            let lm = log1mexp_positive(a);
911            let roundtrip = lm.exp() + (-a).exp();
912            assert!(
913                (roundtrip - 1.0).abs() < 1e-14,
914                "a={a}: exp(log1mexp(a)) + exp(-a) = {roundtrip}, expected 1.0"
915            );
916        }
917    }
918
919    #[test]
920    fn log1mexp_at_ln2_is_neg_ln2() {
921        let ln2 = std::f64::consts::LN_2;
922        let got = log1mexp_positive(ln2);
923        assert!((got - (-ln2)).abs() < TOL, "got={got}");
924    }
925
926    // ── signed_log_sum_exp ────────────────────────────────────────────────────
927
928    #[test]
929    fn slse_all_positive_single() {
930        let (lm, sg) = signed_log_sum_exp(&[2.0], &[1.0]);
931        assert!((lm - 2.0).abs() < TOL);
932        assert!((sg - 1.0).abs() < TOL);
933    }
934
935    #[test]
936    fn slse_difference_recovers_log2() {
937        // 3 - 1 = 2 → log|2| = ln(2), sign = +1.
938        let log3 = 3.0_f64.ln();
939        let log1 = 0.0_f64; // ln(1)
940        let (lm, sg) = signed_log_sum_exp(&[log3, log1], &[1.0, -1.0]);
941        assert!((lm - 2.0_f64.ln()).abs() < TOL, "lm={lm}");
942        assert!((sg - 1.0).abs() < TOL, "sg={sg}");
943    }
944
945    #[test]
946    fn slse_cancellation_gives_neg_inf() {
947        // a - a = 0 → log|0| = -∞.
948        let ln2 = 2.0_f64.ln();
949        let (lm, sg) = signed_log_sum_exp(&[ln2, ln2], &[1.0, -1.0]);
950        assert_eq!(lm, f64::NEG_INFINITY);
951        assert_eq!(sg, 0.0);
952    }
953
954    #[test]
955    fn slse_empty_returns_neg_inf_with_zero_sign() {
956        // With no terms the sum is exactly 0, so the docstring contract is
957        // `(−∞, 0.0)`. (This test previously encoded the buggy `+1.0` positive-sum
958        // convention, which contradicted both the docstring and the cancellation
959        // test below; rewritten to the correct zero sign.)
960        let (lm, sg) = signed_log_sum_exp(&[], &[]);
961        assert_eq!(lm, f64::NEG_INFINITY);
962        assert_eq!(sg, 0.0);
963    }
964
965    #[test]
966    fn slse_all_zero_signs_return_zero_sign() {
967        // A single term whose sign is 0 contributes nothing; S = 0 ⇒ (−∞, 0.0).
968        let (lm, sg) = signed_log_sum_exp(&[0.0], &[0.0]);
969        assert_eq!(lm, f64::NEG_INFINITY);
970        assert_eq!(sg, 0.0);
971    }
972
973    #[test]
974    fn slse_all_neg_inf_magnitudes_return_zero_sign() {
975        // Every magnitude is exp(−∞) = 0 regardless of sign, so the sum is 0 and
976        // the reported sign must be 0.0, not +1.0.
977        let (lm, sg) = signed_log_sum_exp(&[f64::NEG_INFINITY, f64::NEG_INFINITY], &[1.0, -1.0]);
978        assert_eq!(lm, f64::NEG_INFINITY);
979        assert_eq!(sg, 0.0);
980    }
981
982    #[test]
983    fn slse_pos_inf_dominates() {
984        let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, 1.0], &[1.0, -1.0]);
985        assert_eq!(lm, f64::INFINITY);
986        assert_eq!(sg, 1.0);
987    }
988
989    #[test]
990    fn slse_neg_inf_dominates() {
991        let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, 1.0], &[-1.0, 1.0]);
992        assert_eq!(lm, f64::INFINITY);
993        assert_eq!(sg, -1.0);
994    }
995
996    #[test]
997    fn slse_both_inf_signs_gives_nan() {
998        let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, f64::INFINITY], &[1.0, -1.0]);
999        assert!(lm.is_nan());
1000        assert_eq!(sg, 0.0);
1001    }
1002
1003    // ── normal_logcdf ─────────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn logcdf_at_zero_is_log_half() {
1007        let got = normal_logcdf(0.0);
1008        let expected = 0.5_f64.ln();
1009        assert!((got - expected).abs() < TOL, "got={got}");
1010    }
1011
1012    #[test]
1013    fn logcdf_pos_inf_is_zero() {
1014        assert_eq!(normal_logcdf(f64::INFINITY), 0.0);
1015    }
1016
1017    #[test]
1018    fn logcdf_neg_inf_is_neg_inf() {
1019        assert_eq!(normal_logcdf(f64::NEG_INFINITY), f64::NEG_INFINITY);
1020    }
1021
1022    #[test]
1023    fn logcdf_nan_is_nan() {
1024        assert!(normal_logcdf(f64::NAN).is_nan());
1025    }
1026
1027    #[test]
1028    fn logcdf_matches_log_cdf_for_moderate_x() {
1029        for &x in &[-2.0_f64, -1.0, 0.0, 1.0, 2.0, 3.0] {
1030            let got = normal_logcdf(x);
1031            let expected = normal_cdf(x).ln();
1032            assert!(
1033                (got - expected).abs() < 1e-10,
1034                "x={x}: got={got} expected={expected}"
1035            );
1036        }
1037    }
1038
1039    #[test]
1040    fn logcdf_deep_left_tail_stays_finite() {
1041        // For very negative x, normal_cdf(x) underflows to 0, but logcdf should
1042        // remain finite and large-negative.
1043        let got = normal_logcdf(-20.0);
1044        assert!(got.is_finite() && got < -100.0, "logcdf(-20)={got}");
1045    }
1046
1047    #[test]
1048    fn logcdf_positive_tail_does_not_round_through_unit_cdf() {
1049        let x = 10.0_f64;
1050        let got = normal_logcdf(x);
1051        let expected = (-0.5 * erfc(x / std::f64::consts::SQRT_2)).ln_1p();
1052        assert!(
1053            got < 0.0,
1054            "logcdf(10) must retain its negative tail: {got:e}"
1055        );
1056        assert_eq!(got.to_bits(), expected.to_bits());
1057    }
1058
1059    // ── normal_logsf ─────────────────────────────────────────────────────────
1060
1061    #[test]
1062    fn logsf_at_zero_is_log_half() {
1063        let got = normal_logsf(0.0);
1064        let expected = 0.5_f64.ln();
1065        assert!((got - expected).abs() < TOL, "got={got}");
1066    }
1067
1068    #[test]
1069    fn logsf_mirrors_logcdf() {
1070        // logsf(x) = logcdf(-x) by definition.
1071        for &x in &[-3.0_f64, -1.0, 0.0, 1.0, 3.0] {
1072            assert_eq!(normal_logsf(x), normal_logcdf(-x));
1073        }
1074    }
1075
1076    // ── signed_probit_logcdf_and_mills_ratio ──────────────────────────────────
1077
1078    #[test]
1079    fn probit_at_pos_inf() {
1080        let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::INFINITY);
1081        assert_eq!(lc, 0.0);
1082        assert_eq!(mr, 0.0);
1083    }
1084
1085    #[test]
1086    fn probit_at_neg_inf() {
1087        let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::NEG_INFINITY);
1088        assert_eq!(lc, f64::NEG_INFINITY);
1089        assert_eq!(mr, f64::INFINITY);
1090    }
1091
1092    #[test]
1093    fn probit_nan_propagates() {
1094        let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::NAN);
1095        assert!(lc.is_nan() && mr.is_nan());
1096    }
1097
1098    #[test]
1099    fn probit_at_zero_logcdf_and_mills() {
1100        let (lc, mr) = signed_probit_logcdf_and_mills_ratio(0.0);
1101        assert!((lc - 0.5_f64.ln()).abs() < TOL, "lc={lc}");
1102        // phi(0)/Phi(0) = 0.3989.../0.5 ≈ 0.7979.
1103        assert!((mr - 0.797_884_560_802_865).abs() < 1e-10, "mr={mr}");
1104    }
1105
1106    #[test]
1107    fn probit_positive_branch_matches_logcdf() {
1108        for &x in &[0.5_f64, 1.0, 2.0, 3.0] {
1109            let (lc, mr) = signed_probit_logcdf_and_mills_ratio(x);
1110            let lc_ref = normal_logcdf(x);
1111            let mr_ref = normal_pdf(x) / normal_cdf(x);
1112            assert!(
1113                (lc - lc_ref).abs() < 1e-10,
1114                "x={x}: lc={lc} lc_ref={lc_ref}"
1115            );
1116            assert!(
1117                (mr - mr_ref).abs() < 1e-10,
1118                "x={x}: mr={mr} mr_ref={mr_ref}"
1119            );
1120        }
1121    }
1122
1123    #[test]
1124    fn probit_negative_branch_matches_logcdf() {
1125        for &x in &[-0.5_f64, -1.0, -2.0, -5.0] {
1126            let (lc, mr) = signed_probit_logcdf_and_mills_ratio(x);
1127            let lc_ref = normal_logcdf(x);
1128            assert!(
1129                (lc - lc_ref).abs() < 1e-10,
1130                "x={x}: lc={lc} lc_ref={lc_ref}"
1131            );
1132            assert!(mr.is_finite() && mr > 0.0, "x={x}: mr={mr}");
1133        }
1134    }
1135
1136    #[test]
1137    fn probit_mills_ratio_has_no_deep_tail_floor() {
1138        let x = -1.0e305_f64;
1139        let (log_cdf, mills_ratio) = signed_probit_logcdf_and_mills_ratio(x);
1140        assert_eq!(log_cdf, f64::NEG_INFINITY);
1141        assert!(mills_ratio.is_finite());
1142        assert!(
1143            ((mills_ratio / -x) - 1.0).abs() < 5.0e-15,
1144            "mills({x:e})={mills_ratio:e}"
1145        );
1146    }
1147
1148    #[test]
1149    fn normal_logcdf_derivative_stack_has_honest_infinite_limits() {
1150        assert_eq!(normal_logcdf_derivatives(f64::INFINITY), [0.0; 5]);
1151        assert_eq!(
1152            normal_logcdf_derivatives(f64::NEG_INFINITY),
1153            [f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0, 0.0]
1154        );
1155        assert!(
1156            normal_logcdf_derivatives(f64::NAN)
1157                .into_iter()
1158                .all(f64::is_nan)
1159        );
1160
1161        for x in [-1.0e200_f64, 1.0e200_f64] {
1162            let derivatives = normal_logcdf_derivatives(x);
1163            assert!(
1164                derivatives.into_iter().all(|value| !value.is_nan()),
1165                "NaN derivative at x={x:e}: {derivatives:?}"
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn normal_logcdf_left_tail_derivatives_do_not_cancel() {
1172        let x = -1.0e100_f64;
1173        let derivatives = normal_logcdf_derivatives(x);
1174        assert_eq!(derivatives[2], -1.0);
1175        assert!(derivatives[3] > 0.0 && derivatives[3].is_finite());
1176        assert!(
1177            (derivatives[3] / 2.0e-300 - 1.0).abs() < 2.0e-14,
1178            "third derivative={:e}",
1179            derivatives[3]
1180        );
1181        assert_eq!(derivatives[4], 0.0);
1182    }
1183
1184    #[test]
1185    fn normal_logcdf_right_tail_preserves_weighted_subnormal_derivatives() {
1186        let derivatives = normal_logcdf_derivatives(38.6);
1187        assert_eq!(derivatives[1], 0.0);
1188        assert!(derivatives[2] < 0.0 && derivatives[2].is_subnormal());
1189        assert!(derivatives[3] > 0.0 && derivatives[3].is_subnormal());
1190        assert!(derivatives[4] < 0.0 && derivatives[4].is_subnormal());
1191    }
1192
1193    #[test]
1194    fn normal_logcdf_tail_stack_is_finite_difference_consistent() {
1195        let h = 1.0e-4_f64;
1196        for x in [-8.0_f64, -4.0, 8.0, 20.0] {
1197            let center = normal_logcdf_derivatives(x);
1198            let left = normal_logcdf_derivatives(x - h);
1199            let right = normal_logcdf_derivatives(x + h);
1200            for order in 1..=3 {
1201                let finite_difference = (right[order] - left[order]) / (2.0 * h);
1202                let expected = center[order + 1];
1203                let relative = (finite_difference - expected).abs() / expected.abs().max(1.0e-300);
1204                assert!(
1205                    relative < 2.0e-5,
1206                    "x={x}, order={order}: fd={finite_difference:e}, expected={expected:e}, rel={relative:e}"
1207                );
1208            }
1209        }
1210    }
1211
1212    /// Absolute-accuracy pin of the full `ln Φ(x)` derivative tower against an
1213    /// EXTERNAL high-precision reference (mpmath, dps=60), covering all three
1214    /// branches (continued-fraction left tail at x=−4, the moderate Mills
1215    /// recurrence for x∈(−4, 8), and both signs). Before the `erfc` root-cause
1216    /// fix the moderate branch's `λ = φ/Φ` inherited `statrs::erfc`'s ~1e-10
1217    /// error, so `f''` was wrong by ~1e-9 near the −4 seam; this pins every
1218    /// entry to `2e-11` relative, catching that regression head-on rather than
1219    /// through a seam-straddling finite difference.
1220    #[test]
1221    fn normal_logcdf_derivative_tower_matches_high_precision_reference() {
1222        // (x, [value, f', f'', f''', f''''] from mpmath at dps=60).
1223        let refs: &[(f64, [f64; 5])] = &[
1224            (
1225                -4.0,
1226                [
1227                    -10.360101486527291,
1228                    4.2256071444894711,
1229                    -0.95332716160257737,
1230                    0.017856339307658426,
1231                    0.0095065764315958691,
1232                ],
1233            ),
1234            (
1235                -2.0,
1236                [
1237                    -3.7831843336820319,
1238                    2.3732155328228409,
1239                    -0.88572089958591874,
1240                    0.059355861291565813,
1241                    0.039421993865946813,
1242                ],
1243            ),
1244            (
1245                -1.0,
1246                [
1247                    -1.8410216450092635,
1248                    1.5251352761609812,
1249                    -0.80090233442965121,
1250                    0.11693119540604883,
1251                    0.07917498368074563,
1252                ],
1253            ),
1254            (
1255                -0.3,
1256                [
1257                    -0.96210281816885066,
1258                    0.99816596885848332,
1259                    -0.69688551072964971,
1260                    0.18398317992442132,
1261                    0.11037564722092704,
1262                ],
1263            ),
1264            (
1265                0.5,
1266                [
1267                    -0.36894641528865639,
1268                    0.50916043383703349,
1269                    -0.5138245643036329,
1270                    0.27099012446870783,
1271                    0.088167801929197554,
1272                ],
1273            ),
1274            (
1275                2.0,
1276                [
1277                    -0.023012909328963488,
1278                    0.055247862678989959,
1279                    -0.11354805168857645,
1280                    0.18439481503247759,
1281                    -0.18785468561160969,
1282                ],
1283            ),
1284        ];
1285        // The moderate-branch statrs regression produced ~1e-9 errors in f'';
1286        // 1e-10 catches that head-on while respecting the deep-left-tail
1287        // continued-fraction branch's inherent ~2e-11 accuracy in f'''' (its
1288        // 32-level derivative propagation, not the `erfc` path this pins).
1289        for &(x, reference) in refs {
1290            let got = normal_logcdf_derivatives(x);
1291            for (order, (&g, &r)) in got.iter().zip(reference.iter()).enumerate() {
1292                let rel = (g - r).abs() / r.abs().max(1.0e-3);
1293                assert!(
1294                    rel < 1.0e-10,
1295                    "normal_logcdf_derivatives({x})[{order}] = {g:.17e}, reference {r:.17e}, \
1296                     rel {rel:.3e} >= 1e-10"
1297                );
1298            }
1299        }
1300    }
1301
1302    // ── standard_normal_quantile ──────────────────────────────────────────────
1303
1304    #[test]
1305    fn quantile_rejects_out_of_range() {
1306        assert!(standard_normal_quantile(0.0).is_err());
1307        assert!(standard_normal_quantile(1.0).is_err());
1308        assert!(standard_normal_quantile(-0.1).is_err());
1309        assert!(standard_normal_quantile(1.1).is_err());
1310        assert!(standard_normal_quantile(f64::NAN).is_err());
1311    }
1312
1313    #[test]
1314    fn quantile_at_half_is_near_zero() {
1315        let q = standard_normal_quantile(0.5).unwrap();
1316        assert!(q.abs() < 1e-10, "quantile(0.5)={q}");
1317    }
1318
1319    #[test]
1320    fn quantile_at_0975_is_near_196() {
1321        let q = standard_normal_quantile(0.975).unwrap();
1322        assert!((q - 1.959_963_985).abs() < 1e-7, "q={q}");
1323    }
1324
1325    #[test]
1326    fn quantile_antisymmetry() {
1327        let q_lo = standard_normal_quantile(0.1).unwrap();
1328        let q_hi = standard_normal_quantile(0.9).unwrap();
1329        assert!((q_lo + q_hi).abs() < 1e-10, "q_lo={q_lo} q_hi={q_hi}");
1330    }
1331
1332    #[test]
1333    fn quantile_roundtrip_cdf() {
1334        for &p in &[
1335            0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 0.999,
1336        ] {
1337            let q = standard_normal_quantile(p).unwrap();
1338            let p_back = normal_cdf(q);
1339            assert!(
1340                (p_back - p).abs() < 1e-10,
1341                "roundtrip failed at p={p}: q={q} p_back={p_back}"
1342            );
1343        }
1344    }
1345}