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/// The part of `x·x` that `f64` cannot hold: `x² = x*x + square_residual(x)`,
27/// exactly, for every `x` whose square neither overflows nor goes subnormal.
28///
29/// This exists because of what `exp` does to a squared argument. Rounding
30/// `x*x` perturbs it by at most `ulp(x²)/2` — a RELATIVE perturbation of
31/// `ε/2`, which is unremarkable on its own. But `exp` converts a relative
32/// perturbation `δ` of its ARGUMENT into a relative perturbation `x²·δ` of
33/// its RESULT, so `exp(x*x)` carries `x²·ε/2` relative error: `3.7e-14` at
34/// `x = 26`, and `7.7e-14` at the `x ≈ 37` where `φ(x)` finally underflows.
35/// That is two orders worse than the `exp` evaluation's own rounding, and it
36/// is the error `erfcx` and `normal_pdf` were both actually delivering.
37///
38/// The residual is the whole of that discarded term and is itself exactly
39/// representable (Dekker's two-product theorem, in its one-FMA form), so
40/// `exp(x²) = exp(x*x)·exp(residual)` and `exp(residual) = 1 + residual` to
41/// `O(residual²)` — below `1e-27` over the entire domain either caller uses.
42/// One multiply by `1 + residual` therefore buys back every digit, and the
43/// callers below apply it fused so the correction itself costs one more
44/// rounding and nothing else.
45///
46/// `mul_add` is a single instruction wherever FMA is in the baseline ISA
47/// (aarch64, and x86-64 built with `+fma`); on a baseline x86-64 build it is
48/// a `glibc` call, measured at ~2.5 ns. Against `erfcx`'s 38 ns that is 9%;
49/// against `normal_pdf`'s 6.2 ns it is 40% of a function that is nowhere the
50/// bottleneck of a row loop that also assembles a design row and a Hessian
51/// block. Both callers guard the pathological arguments BEFORE calling this,
52/// so it never has to defend `±∞` (whose residual would be `NaN`).
53#[inline]
54fn square_residual(x: f64, rounded_square: f64) -> f64 {
55 x.mul_add(x, -rounded_square)
56}
57
58/// Standard normal PDF phi(x).
59///
60/// The squared argument is carried exactly (see [`square_residual`]); without
61/// that, `exp(-½·fl(x*x))` degrades like `x²·ε/2` and reaches `5.7e-14`
62/// relative before `φ` underflows, against the `3.3e-16` it holds with.
63#[inline]
64pub fn normal_pdf(x: f64) -> f64 {
65 const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
66 let rounded_square = x * x;
67 let head = INV_SQRT_2PI * (-0.5 * rounded_square).exp();
68 if head == 0.0 || head.is_nan() {
69 // The pdf underflowed or `x` was `±∞` (head `0`), or `x` was `NaN`.
70 // Neither admits a relative correction, and `±∞` would feed the
71 // residual an `∞ − ∞`; return the limit the plain form gives.
72 return head;
73 }
74 let residual = square_residual(x, rounded_square);
75 head.mul_add(-0.5 * residual, head)
76}
77
78/// Standard normal CDF Phi(x) evaluated via the exact special-function identity
79///
80/// Phi(x) = 0.5 * erfc(-x / sqrt(2)).
81///
82/// This is the exact Gaussian CDF semantics used throughout the codebase. The
83/// numerical `erfc` implementation may use internal approximations, but the
84/// returned function is the standard normal CDF itself rather than a separate
85/// polynomial surrogate surface.
86#[inline]
87pub fn normal_cdf(x: f64) -> f64 {
88 0.5 * erfc(-x / std::f64::consts::SQRT_2)
89}
90
91/// Scaled complementary error function `erfcx(x) = exp(x²) · erfc(x)`,
92/// specialized to the closed domain `x ∈ [0, +∞]`.
93///
94/// `+∞` maps to the exact limiting value `0`; `NaN` and negative inputs map to
95/// `NaN` because they violate this restricted kernel's domain. For
96/// `0 ≤ x < 26` the direct `exp(x²)·erfc(x)` form is finite. Beyond that point
97/// a six-correction asymptotic expansion avoids overflow while retaining the
98/// representable subnormal tail. At the switch, the first omitted term is
99/// below `2e-17` relative to the leading term.
100///
101/// The direct branch carries `x²` exactly (see [`square_residual`]). Without
102/// that correction the branch degraded like `x²·ε/2` — `1.4e-14` at `x = 10`,
103/// `5.7e-14` by the top of its range — while the asymptotic branch that takes
104/// over at `26` was already delivering `3e-16`. The seam was therefore a
105/// 190-fold step DOWN in error at the point where the code switches to what
106/// reads like the fallback, and the whole `[0, 26)` interval, where every
107/// probit / Mills / log-CDF consumer actually lives, was the inaccurate side.
108/// Both branches now hold `< 5e-16`, so the crossover is invisible.
109#[inline]
110pub fn erfcx_nonnegative(x: f64) -> f64 {
111 if x.is_nan() || x < 0.0 {
112 return f64::NAN;
113 }
114 if x == f64::INFINITY {
115 return 0.0;
116 }
117 if x < 26.0 {
118 // `x` is finite and in `[0, 26)`, so the square is exact-splittable and
119 // `head` is finite and strictly positive (`erfc(26⁻) ≈ 1e-295`).
120 let rounded_square = x * x;
121 let head = rounded_square.exp() * erfc(x);
122 head.mul_add(square_residual(x, rounded_square), head)
123 } else {
124 let inv = 1.0 / x;
125 let inv2 = inv * inv;
126 // erfcx(x) ~ 1/(sqrt(pi)x) * sum_n (-1)^n (2n-1)!!/(2x^2)^n.
127 // Horner form keeps the correction well scaled when `inv2` is tiny.
128 let poly = 1.0
129 + inv2
130 * (-0.5
131 + inv2
132 * (0.75
133 + inv2
134 * (-1.875
135 + inv2 * (6.5625 + inv2 * (-29.53125 + inv2 * 162.421875)))));
136 inv * poly * INV_SQRT_PI
137 }
138}
139
140/// Computes `log(1 - exp(-a))` for `a >= 0` without cancellation.
141#[inline]
142pub fn log1mexp_positive(a: f64) -> f64 {
143 assert!(a >= 0.0, "log1mexp_positive requires a >= 0: a={a}");
144 if a > core::f64::consts::LN_2 {
145 (-(-a).exp()).ln_1p()
146 } else if a > 0.0 {
147 (-(-a).exp_m1()).ln()
148 } else {
149 f64::NEG_INFINITY
150 }
151}
152
153/// Numerically stable signed log-sum-exp. Given pairs
154/// `(log|aⱼ|, sign(aⱼ))` (with `signs[j] ∈ {−1, 0, +1}`), returns
155/// `(log|S|, sign(S))` for `S = Σⱼ signs[j]·exp(log_mags[j])`. Positive
156/// and negative magnitudes are reduced separately with the standard
157/// log-sum-exp trick (subtract the max, sum, log, add back); the two
158/// partial sums are then combined via `log(|p − n|) =
159/// max(log p, log n) + log1mexp(|log p − log n|)`, preserving accuracy
160/// even when `p ≈ n` (catastrophic cancellation regime). When all
161/// signs are zero or all magnitudes are `−∞`, returns
162/// `(NEG_INFINITY, 0.0)`.
163///
164/// A `+∞` log-magnitude denotes an infinite-magnitude term (`exp(+∞) = +∞`)
165/// and dominates the sum: if it appears only with positive sign the result
166/// is `(+∞, +1)`; only with negative sign, `(+∞, −1)` (a log-magnitude of
167/// `+∞` with sign `−1` encodes the value `−∞`); with both signs the sum is
168/// the indeterminate `+∞ − ∞`, returned as `(NaN, 0.0)`. A `−∞`
169/// log-magnitude is `exp(−∞) = 0` and is correctly dropped.
170pub fn signed_log_sum_exp(log_mags: &[f64], signs: &[f64]) -> (f64, f64) {
171 // Infinite-magnitude terms dominate any finite contribution, so resolve
172 // them before the finite log-sum-exp reduction below. `−∞` log-magnitudes
173 // are `exp(−∞) = 0` and need no special handling.
174 let mut has_pos_inf = false;
175 let mut has_neg_inf = false;
176 for (idx, &lm) in log_mags.iter().enumerate() {
177 if lm == f64::INFINITY {
178 if signs[idx] > 0.0 {
179 has_pos_inf = true;
180 } else if signs[idx] < 0.0 {
181 has_neg_inf = true;
182 }
183 }
184 }
185 match (has_pos_inf, has_neg_inf) {
186 // P = +∞, N = +∞ ⇒ indeterminate +∞ − ∞.
187 (true, true) => return (f64::NAN, 0.0),
188 // P = +∞, N < ∞ ⇒ S = +∞.
189 (true, false) => return (f64::INFINITY, 1.0),
190 // N = +∞, P < ∞ ⇒ S = −∞, encoded as log-magnitude +∞ with sign −1.
191 (false, true) => return (f64::INFINITY, -1.0),
192 (false, false) => {}
193 }
194
195 let mut pos_max = f64::NEG_INFINITY;
196 let mut neg_max = f64::NEG_INFINITY;
197 for (idx, &lm) in log_mags.iter().enumerate() {
198 if signs[idx] > 0.0 {
199 pos_max = pos_max.max(lm);
200 } else if signs[idx] < 0.0 {
201 neg_max = neg_max.max(lm);
202 }
203 }
204
205 let mut pos_sum = 0.0_f64;
206 let mut neg_sum = 0.0_f64;
207 for (idx, &lm) in log_mags.iter().enumerate() {
208 if !lm.is_finite() {
209 continue;
210 }
211 if signs[idx] > 0.0 {
212 pos_sum += (lm - pos_max).exp();
213 } else if signs[idx] < 0.0 {
214 neg_sum += (lm - neg_max).exp();
215 }
216 }
217
218 let log_pos = if pos_sum > 0.0 {
219 pos_max + pos_sum.ln()
220 } else {
221 f64::NEG_INFINITY
222 };
223 let log_neg = if neg_sum > 0.0 {
224 neg_max + neg_sum.ln()
225 } else {
226 f64::NEG_INFINITY
227 };
228
229 if log_pos == f64::NEG_INFINITY && log_neg == f64::NEG_INFINITY {
230 // Both partial sums are empty: no terms at all, all signs zero, or every
231 // magnitude `−∞` (each `exp(−∞) = 0`). The signed sum is exactly `0`, so
232 // the contract requires `(−∞, 0.0)` — NOT the positive-sum convention,
233 // which would mislabel a zero as `+1` and corrupt any downstream cascade
234 // that reads back the sign.
235 return (f64::NEG_INFINITY, 0.0);
236 }
237 if log_neg == f64::NEG_INFINITY {
238 return (log_pos, 1.0);
239 }
240 if log_pos == f64::NEG_INFINITY {
241 return (log_neg, -1.0);
242 }
243 if log_pos > log_neg {
244 let gap = log_pos - log_neg;
245 (log_pos + log1mexp_positive(gap), 1.0)
246 } else if log_neg > log_pos {
247 let gap = log_neg - log_pos;
248 (log_neg + log1mexp_positive(gap), -1.0)
249 } else {
250 (f64::NEG_INFINITY, 0.0)
251 }
252}
253
254/// Numerically stable `ln Φ(x)` for the standard normal CDF. For `x ≥ 0`,
255/// evaluates `ln(1 - 0.5 erfc(x/sqrt(2)))` with `ln_1p`, retaining the small
256/// negative result after `Φ(x)` itself rounds to one. For `x < 0`, rewrites
257/// `ln Φ(x) = −u² + ln(½·erfcx(u))`, `u = −x/√2`,
258/// which preserves digits throughout the representable left tail without a
259/// probability floor. Returns the corresponding IEEE limit at infinities and
260/// propagates `NaN`.
261#[inline]
262pub fn normal_logcdf(x: f64) -> f64 {
263 if x == f64::INFINITY {
264 return 0.0;
265 }
266 if x == f64::NEG_INFINITY {
267 return f64::NEG_INFINITY;
268 }
269 if x.is_nan() {
270 return f64::NAN;
271 }
272 if x < 0.0 {
273 let (u, scaled_tail) = negative_normal_tail_components(x);
274 negative_normal_logcdf_from_scaled_tail(u, scaled_tail)
275 } else {
276 let upper_tail = 0.5 * erfc(x / std::f64::consts::SQRT_2);
277 (-upper_tail).ln_1p()
278 }
279}
280
281/// Numerically stable `ln(1 − Φ(x)) = ln Φ(−x)` for the standard normal
282/// survival function. Delegates to `normal_logcdf(-x)` so the deep-right
283/// tail benefits from the same `erfcx`-based representation.
284#[inline]
285pub fn normal_logsf(x: f64) -> f64 {
286 normal_logcdf(-x)
287}
288
289/// Joint evaluation of `ln Φ(x)` and the Mills-ratio analogue
290/// `φ(x) / Φ(x)`, signed for the symmetric branch. Used by the latent
291/// probit families where the inverse-link gradient needs the ratio and
292/// the likelihood needs the log-CDF on the same `x`; computing both in
293/// one call shares the `erfcx` evaluation that dominates the cost in the
294/// deep tail.
295#[inline]
296pub fn signed_probit_logcdf_and_mills_ratio(x: f64) -> (f64, f64) {
297 if x == f64::INFINITY {
298 return (0.0, 0.0);
299 }
300 if x == f64::NEG_INFINITY {
301 return (f64::NEG_INFINITY, f64::INFINITY);
302 }
303 if x.is_nan() {
304 return (f64::NAN, f64::NAN);
305 }
306 if x < 0.0 {
307 let (u, scaled_tail) = negative_normal_tail_components(x);
308 (
309 negative_normal_logcdf_from_scaled_tail(u, scaled_tail),
310 SQRT_2_OVER_PI / scaled_tail,
311 )
312 } else {
313 let upper_tail = 0.5 * erfc(x / std::f64::consts::SQRT_2);
314 let cdf = 1.0 - upper_tail;
315 let lambda = normal_pdf(x) / cdf;
316 ((-upper_tail).ln_1p(), lambda)
317 }
318}
319
320#[inline]
321fn negative_normal_tail_components(x: f64) -> (f64, f64) {
322 assert!(x.is_finite() && x < 0.0);
323 let u = -x / std::f64::consts::SQRT_2;
324 (u, erfcx_nonnegative(u))
325}
326
327#[inline]
328fn negative_normal_logcdf_from_scaled_tail(u: f64, scaled_tail: f64) -> f64 {
329 -u * u + scaled_tail.ln() - std::f64::consts::LN_2
330}
331
332/// Stable value and first four derivatives of `ln Φ(x)`.
333///
334/// The moderate regime uses the exact Mills-ratio recurrence, with the brackets
335/// collected in `q = λ + x` once `x < 0` so that they do not cancel as `λ`
336/// closes on `−x`. In the deep left tail, differentiating the Laplace continued
337/// fraction
338///
339/// `φ(t)/Φ(-t) = t + 1/(t + 2/(t + 3/(...)))`, `t = -x`,
340///
341/// carries the small correction to `t` independently, so `f'' -> -1` and the
342/// higher derivatives approach zero without subtracting nearly equal `f64`s.
343/// In the right tail, signed log-magnitude sums preserve polynomially weighted
344/// derivatives even when `φ(x)/Φ(x)` itself has rounded to zero.
345#[inline]
346pub fn normal_logcdf_derivatives(x: f64) -> [f64; 5] {
347 if x.is_nan() {
348 return [f64::NAN; 5];
349 }
350 if x == f64::INFINITY {
351 return [0.0; 5];
352 }
353 if x == f64::NEG_INFINITY {
354 return [f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0, 0.0];
355 }
356
357 const RIGHT_LOG_MAGNITUDE_SWITCH: f64 = 8.0;
358 if x <= LEFT_CONTINUED_FRACTION_SWITCH {
359 return normal_logcdf_derivatives_left_tail(x);
360 }
361 if x >= RIGHT_LOG_MAGNITUDE_SWITCH {
362 return normal_logcdf_derivatives_right_tail(x);
363 }
364
365 let (log_cdf, lambda) = signed_probit_logcdf_and_mills_ratio(x);
366 let x2 = x * x;
367 if x < 0.0 {
368 // Left of the origin the brackets below are collected in the SAME Mills
369 // correction `q = λ + x` the continued-fraction branch carries, because
370 // written in `λ` they cancel catastrophically long before the branch
371 // ends. `λ(x) → −x` as `x → −∞`, so every term of, say,
372 // `(x³−3x) + (7x²−4)λ + 12xλ² + 6λ³` grows like `|x|³` while their sum
373 // decays: at `x = −4` they are `−52`, `456`, `−857`, `453` and add to
374 // `−0.0023`, a cancellation of 380000 that costs eleven digits. In `q`
375 // the same bracket is `−6q³ + 6xq² + (4−x²)q − x`, whose terms are
376 // `−0.069`, `−1.22`, `−2.71`, `4` — a cancellation of 1847, three
377 // orders milder. The reformulation is exact (`λ = q − x` substituted and
378 // re-collected), costs the same flops, and buys 16–34x across the whole
379 // branch: worst over `x ∈ [−4, 0]` falls from `4.5e−11` to `2.8e−12`.
380 //
381 // `q` itself is safe to form here: `λ/2 ≤ |x| ≤ 2λ` holds over most of
382 // the range, so `λ + x` is EXACT by Sterbenz, and where it is not (`x`
383 // near 0) `q` is the same size as `λ` and nothing cancels. That is the
384 // whole reason the rewrite works — it moves the cancellation out of the
385 // brackets and into a subtraction that has none.
386 //
387 // Past the origin `q → x` is no longer small, the `λ` form has nothing
388 // to cancel (`λ → 0` and `x² − 1` dominates), and it is the more
389 // accurate of the two — hence the sign test rather than a blanket swap.
390 let q = lambda + x;
391 let q2 = q * q;
392 return [
393 log_cdf,
394 lambda,
395 -lambda * q,
396 lambda * (2.0 * q2 - x * q - 1.0),
397 lambda * (-6.0 * q2 * q + 6.0 * x * q2 + (4.0 - x2) * q - x),
398 ];
399 }
400 let lambda2 = lambda * lambda;
401 let lambda3 = lambda2 * lambda;
402 [
403 log_cdf,
404 lambda,
405 -lambda * (x + lambda),
406 lambda * (x2 - 1.0 + 3.0 * x * lambda + 2.0 * lambda2),
407 -lambda
408 * ((x * x2 - 3.0 * x) + (7.0 * x2 - 4.0) * lambda + 12.0 * x * lambda2 + 6.0 * lambda3),
409 ]
410}
411
412#[derive(Clone, Copy)]
413struct MillsCorrectionDerivatives {
414 value: f64,
415 first: f64,
416 second: f64,
417 third: f64,
418}
419
420/// `x` at or below which the left-tail Mills ratio is taken from the Laplace
421/// continued fraction rather than from `erfcx`. Equivalently `t = −x ≥ 4`.
422const LEFT_CONTINUED_FRACTION_SWITCH: f64 = -4.0;
423
424/// The Laplace continued-fraction **correction** to the left-tail Mills ratio,
425///
426/// `q(t) = λ(−t) − t = 1/(t + 2/(t + 3/(...)))`, `λ(x) = φ(x)/Φ(x)`,
427///
428/// together with its first three derivatives in `t`. Requires `t ≥ 4`.
429///
430/// `q` is the whole content of the left tail that is NOT the leading `t`: it
431/// decays like `1/t − 2/t³ + 10/t⁵ − ...`, and every operation building it is
432/// a division or an addition of positive quantities, so it carries full
433/// relative precision no matter how small it gets. That is the property its
434/// two consumers need, and it is why the correction is returned separately
435/// instead of pre-added to `t`:
436///
437/// * [`normal_logcdf_derivatives_left_tail`] needs `f'' = −(1 + q')` and the
438/// higher derivatives, which tend to `−1` and `0` and would be destroyed by
439/// differencing nearly equal `f64`s.
440/// * [`cone_boundary_log_factor_and_derivatives`] needs `∂corr/∂a = b − q(t)`,
441/// which is the same statement one substitution away (#2306 §4).
442///
443/// Recovering `q` from a separately computed `λ` — `q = λ − t` — is exactly the
444/// cancellation this exists to avoid, and it is not a small effect: at `t = 1e8`
445/// it costs every significant digit, and past `t ≈ 2e8` it returns the wrong
446/// SIGN. The reference itself has to be carried at ~120 decimal digits before it
447/// reproduces what this recursion gives in binary64.
448#[inline]
449fn mills_correction_continued_fraction(t: f64) -> MillsCorrectionDerivatives {
450 assert!(t.is_finite() && t >= 4.0);
451 let mut q = MillsCorrectionDerivatives {
452 value: 0.0,
453 first: 0.0,
454 second: 0.0,
455 third: 0.0,
456 };
457 // The truncation error is damped by a product of the continued-fraction
458 // sensitivities `n/(t + q)^2`, so the depth must be sized at `t = 4` — the
459 // LEAST converged point of the domain, and the one the log-CDF branch sits
460 // exactly on. Each successive derivative converges roughly 15x slower than
461 // the last, because differentiating the recursion multiplies each level's
462 // contribution by another factor of that same sensitivity. Measured against
463 // a 60-digit reference at `t = 4`:
464 //
465 // ```text
466 // q q' q'' q'''
467 // 32 1.9e-15 7.0e-14 1.4e-12 2.1e-11
468 // 64 2.3e-23 1.4e-21 4.4e-20 1.0e-18
469 // ```
470 //
471 // 32 levels is enough for the VALUE and nothing else: it leaves `q'''` — the
472 // fourth log-CDF derivative — wrong in its eleventh digit. The depths that
473 // first reach `1e-17` at `t = 4` are 41, 47, 53 and 60 for the four
474 // channels, so 64 covers the worst of them with ~200x of margin, and the
475 // requirement falls off fast enough (33 levels at `t = 6`, 24 at `t = 8`,
476 // 12 at `t = 20`) that one constant sized for the edge is safe everywhere
477 // above it. The extra levels are pure convergence — every step divides
478 // positive quantities — so they cannot destabilise a large `t`.
479 for n in (1..=64).rev() {
480 let denominator = t + q.value;
481 let inv_denominator = denominator.recip();
482 let value = f64::from(n) / denominator;
483 let denominator_first = 1.0 + q.first;
484 let a = denominator_first * inv_denominator;
485 let b = q.second * inv_denominator;
486 let c = q.third * inv_denominator;
487 q = MillsCorrectionDerivatives {
488 value,
489 first: -value * denominator_first / denominator,
490 second: value * (2.0 * a * a - b),
491 third: value * (-6.0 * a * a * a + 6.0 * a * b - c),
492 };
493 }
494 q
495}
496
497#[inline]
498fn normal_logcdf_derivatives_left_tail(x: f64) -> [f64; 5] {
499 assert!(x.is_finite() && x <= LEFT_CONTINUED_FRACTION_SWITCH);
500 let t = -x;
501 let q = mills_correction_continued_fraction(t);
502 [
503 normal_logcdf(x),
504 t + q.value,
505 -(1.0 + q.first),
506 q.second,
507 -q.third,
508 ]
509}
510
511#[inline]
512fn normal_logcdf_derivatives_right_tail(x: f64) -> [f64; 5] {
513 assert!(x.is_finite() && x >= 8.0);
514 const LOG_SQRT_2PI: f64 = 0.918_938_533_204_672_7;
515 let log_cdf = normal_logcdf(x);
516 let u = x / std::f64::consts::SQRT_2;
517 let log_lambda = -u * u - LOG_SQRT_2PI - log_cdf;
518 let log_x = x.ln();
519 let inv_x2 = x.recip() * x.recip();
520
521 let first = log_lambda.exp();
522 let second = signed_exp_sum(&[log_x + log_lambda, 2.0 * log_lambda], &[-1.0, -1.0]);
523 let third = signed_exp_sum(
524 &[
525 2.0 * log_x + (-inv_x2).ln_1p() + log_lambda,
526 3.0_f64.ln() + log_x + 2.0 * log_lambda,
527 2.0_f64.ln() + 3.0 * log_lambda,
528 ],
529 &[1.0, 1.0, 1.0],
530 );
531 let fourth = signed_exp_sum(
532 &[
533 3.0 * log_x + (-3.0 * inv_x2).ln_1p() + log_lambda,
534 7.0_f64.ln() + 2.0 * log_x + (-(4.0 / 7.0) * inv_x2).ln_1p() + 2.0 * log_lambda,
535 12.0_f64.ln() + log_x + 3.0 * log_lambda,
536 6.0_f64.ln() + 4.0 * log_lambda,
537 ],
538 &[-1.0, -1.0, -1.0, -1.0],
539 );
540 [log_cdf, first, second, third, fourth]
541}
542
543#[inline]
544fn signed_exp_sum(log_magnitudes: &[f64], signs: &[f64]) -> f64 {
545 let (log_magnitude, sign) = signed_log_sum_exp(log_magnitudes, signs);
546 if sign == 0.0 {
547 0.0
548 } else {
549 sign * log_magnitude.exp()
550 }
551}
552
553#[inline]
554fn acklam_lower_tail_quantile_from_log_probability(log_p: f64) -> f64 {
555 const C: [f64; 6] = [
556 -7.784_894_002_430_293e-3,
557 -3.223_964_580_411_365e-1,
558 -2.400_758_277_161_838,
559 -2.549_732_539_343_734,
560 4.374_664_141_464_968,
561 2.938_163_982_698_783,
562 ];
563 const D: [f64; 4] = [
564 7.784_695_709_041_462e-3,
565 3.224_671_290_700_398e-1,
566 2.445_134_137_142_996,
567 3.754_408_661_907_416,
568 ];
569 let q = (-2.0 * log_p).sqrt();
570 (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
571 / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
572}
573
574/// Standard normal quantile Φ⁻¹(p) using Acklam's rational approximation.
575#[inline]
576pub fn standard_normal_quantile(p: f64) -> Result<f64, String> {
577 if !(p.is_finite() && p > 0.0 && p < 1.0) {
578 return Err(format!("normal quantile requires p in (0,1), got {p}"));
579 }
580
581 const A: [f64; 6] = [
582 -3.969_683_028_665_376e1,
583 2.209_460_984_245_205e2,
584 -2.759_285_104_469_687e2,
585 1.383_577_518_672_69e2,
586 -3.066_479_806_614_716e1,
587 2.506_628_277_459_239,
588 ];
589 const B: [f64; 5] = [
590 -5.447_609_879_822_406e1,
591 1.615_858_368_580_409e2,
592 -1.556_989_798_598_866e2,
593 6.680_131_188_771_972e1,
594 -1.328_068_155_288_572e1,
595 ];
596 const P_LOW: f64 = 0.02425;
597 const P_HIGH: f64 = 1.0 - P_LOW;
598
599 let mut x = if p < P_LOW {
600 acklam_lower_tail_quantile_from_log_probability(p.ln())
601 } else if p <= P_HIGH {
602 let q = p - 0.5;
603 let r = q * q;
604 (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
605 / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
606 } else {
607 -acklam_lower_tail_quantile_from_log_probability((1.0 - p).ln())
608 };
609 for _ in 0..2 {
610 let density = normal_pdf(x);
611 if !(density.is_finite() && density > 0.0) {
612 break;
613 }
614 // Residual F(x) − p, formed without catastrophic cancellation in
615 // either tail. For an upper-tail iterate `x > 0`, `normal_cdf(x)`
616 // saturates to ~1, so the direct `normal_cdf(x) − p` annihilates the
617 // tiny residual the polish must act on; instead use the upper-tail
618 // complement `F(x) − p = (1 − p) − 0.5·erfc(x/√2)`, where both terms
619 // are the small upper-tail quantities (`1 − p` is exact by Sterbenz
620 // for `p ∈ [½,1)`). For `x ≤ 0`, `normal_cdf(x) = 0.5·erfc(|x|/√2)` is
621 // itself the faithfully carried small lower-tail value, so the direct
622 // form is already cancellation-free.
623 let residual = if x > 0.0 {
624 (1.0 - p) - 0.5 * erfc(x / std::f64::consts::SQRT_2)
625 } else {
626 normal_cdf(x) - p
627 };
628 let correction = residual / density;
629 let denominator = 1.0 + 0.5 * x * correction;
630 if !(correction.is_finite() && denominator.is_finite() && denominator != 0.0) {
631 break;
632 }
633 let step = correction / denominator;
634 if !step.is_finite() {
635 break;
636 }
637 x -= step;
638 if step.abs() <= 2.0 * f64::EPSILON * x.abs().max(1.0) {
639 break;
640 }
641 }
642 Ok(x)
643}
644
645/// Standard normal quantile from `log_p = ln Φ(x)`.
646///
647/// Unlike [`standard_normal_quantile`], this remains defined when `Φ(x)` is
648/// smaller than the least positive `f64`, and when `Φ(x)` is so close to one
649/// that exponentiating `log_p` rounds to exactly one. Acklam's lower-tail
650/// approximation supplies the initial point; Newton polishing solves
651/// `ln Φ(x) = log_p` with the stable log-CDF and Mills ratio, so neither tail
652/// forms a probability-space subtraction.
653#[inline]
654pub fn standard_normal_quantile_from_log_cdf(log_p: f64) -> Result<f64, String> {
655 if !(log_p.is_finite() && log_p < 0.0) {
656 return Err(format!(
657 "normal log-quantile requires finite log_p < 0, got {log_p}"
658 ));
659 }
660
661 if log_p > -std::f64::consts::LN_2 {
662 // Reflect through the upper tail without forming `1 - exp(log_p)`.
663 let log_q = (-log_p.exp_m1()).ln();
664 return standard_normal_quantile_from_log_cdf(log_q).map(|x| -x);
665 }
666
667 let p = log_p.exp();
668 let mut x = if p > 0.0 {
669 standard_normal_quantile(p)?
670 } else {
671 acklam_lower_tail_quantile_from_log_probability(log_p)
672 };
673 for _ in 0..4 {
674 let (current_log_p, mills_ratio) = signed_probit_logcdf_and_mills_ratio(x);
675 if !(current_log_p.is_finite() && mills_ratio.is_finite() && mills_ratio > 0.0) {
676 break;
677 }
678 let step = (current_log_p - log_p) / mills_ratio;
679 if !step.is_finite() {
680 break;
681 }
682 x -= step;
683 if step.abs() <= 2.0 * f64::EPSILON * x.abs().max(1.0) {
684 break;
685 }
686 }
687 Ok(x)
688}
689
690/// Log of the standardized one-sided truncated-Gaussian boundary factor for
691/// the constrained-LAML cone correction (gam#2306 §4).
692///
693/// For a constraint coordinate with Lagrange multiplier `μ ≥ 0`, normal
694/// curvature `h > 0`, and signed interior slack `s ≥ 0`, the exact 1-D
695/// boundary integral is
696///
697/// ```text
698/// ∫_{−s}^{∞} exp(−μ·u − ½·h·u²) du
699/// = √(2π/h) · exp(μ²/(2h)) · Φ(s·√h − μ/√h),
700/// ```
701///
702/// and the correction of the Laplace criterion RELATIVE to the unrestricted
703/// Gaussian factor `√(2π/h)` is, in the standardized arguments
704/// `a = μ/√h ≥ 0`, `b = s·√h ≥ 0`:
705///
706/// ```text
707/// corr(a, b) = a²/2 + ln Φ(b − a).
708/// ```
709///
710/// Key limits (the #2306 derivation's continuity contract): an activating
711/// row (`a = 0`, `b = 0`) contributes exactly `ln ½` (the half-Gaussian); a
712/// deep interior row (`b − a → ∞`) contributes `→ 0`, reducing byte-exactly
713/// to the unrestricted LAML; a hard-pushed active row (`a → ∞`, `b = 0`)
714/// follows the exact linear-decay limit `corr → −ln(a·√(2π))`.
715///
716/// Evaluated FUSED: computing `a²/2` and `ln Φ(b−a)` as two separate f64
717/// terms cancels catastrophically once `a ≳ 10⁴` (both grow like `±a²/2`).
718/// On the `b < a` branch the sum collapses analytically to
719/// `a·b − b²/2 + ln(erfcx((a−b)/√2)/2)`, which is cancellation-free (for an
720/// active row, `b = 0`, it is a single `erfcx` evaluation).
721#[must_use]
722pub fn cone_boundary_log_factor(mu_over_sqrt_h: f64, slack_times_sqrt_h: f64) -> f64 {
723 let a = mu_over_sqrt_h;
724 let b = slack_times_sqrt_h;
725 if !(a.is_finite() && b.is_finite()) || a < 0.0 || b < 0.0 {
726 return f64::NAN;
727 }
728 let xi = b - a;
729 if xi >= 0.0 {
730 // Interior-dominant: ln Φ(ξ) is a small negative number and a²/2 is
731 // exact; no cancellation between them (a ≤ b here, so a²/2 ≤ ab −
732 // b²/2 + O(1) stays modest whenever the factor itself is modest).
733 0.5 * a * a + normal_logcdf(xi)
734 } else {
735 // Active-dominant: fused analytic collapse of a²/2 + ln Φ(−(a−b)).
736 let u = (a - b) / std::f64::consts::SQRT_2;
737 a * b - 0.5 * b * b + (0.5 * erfcx_nonnegative(u)).ln()
738 }
739}
740
741/// [`cone_boundary_log_factor`] together with its exact partial derivatives
742/// in the standardized arguments — the pieces the outer ρ-gradient chains
743/// through `(μ̃, h̃, s)(ρ)` (gam#2306 §4 "the g-factors differentiate in
744/// closed form"). With `ξ = b − a` and the Mills ratio `λ(ξ) = φ(ξ)/Φ(ξ)`:
745///
746/// ```text
747/// ∂corr/∂a = a − λ(ξ), ∂corr/∂b = λ(ξ).
748/// ```
749///
750/// `∂corr/∂b = λ(ξ)` is a single `erfcx` evaluation and needs nothing further.
751///
752/// `∂corr/∂a` does. Written literally as `a − λ(ξ)` it is a subtraction of two
753/// quantities that both grow like `a`, because `λ(−t) = t + q(t)` with
754/// `q(t) ~ 1/t`: the answer is the SMALL correction `q`, and forming it by
755/// subtraction destroys `log₁₀(a²·ε)` digits of it. The value
756/// [`cone_boundary_log_factor`] is fused precisely to dodge the twin of this
757/// cancellation, and the gradient has to be fused the same way rather than
758/// re-derived from a `λ` that has already lost the digits.
759///
760/// So on the active branch the correction is taken directly from the Laplace
761/// continued fraction ([`mills_correction_continued_fraction`]), the same one
762/// the left-tail log-CDF derivatives use, under the substitution
763///
764/// ```text
765/// ξ = b − a, t = −ξ = a − b ⇒ ∂corr/∂a = a − λ(ξ) = a − (t + q(t)) = b − q(t),
766/// ```
767///
768/// which is cancellation-free for every `a`: `b ≥ 0` and `q(t) ∈ (0, ¼]`. The
769/// deep-active limit `∂corr/∂a → −1/a` then holds to full relative precision
770/// instead of to none, and the sign is right (the factor is strictly decreasing
771/// in `a`, so `∂corr/∂a < 0` whenever `b = 0`).
772///
773/// Measured on `a ∈ [10⁻², 10¹⁴] × b ∈ {0, …, 10³}` against a 250-digit
774/// reference: the subtractive form reaches `6.1e6` relative error and turns
775/// positive past `a ≈ 2e8`; this form is within `7.5e-16` — about 3 ulp — of
776/// the truth, measured against the magnitudes entering the subtraction rather
777/// than against the result. That is the right denominator because `∂corr/∂a`
778/// genuinely passes through ZERO along the curve `b = q(a − b)` (the factor is
779/// increasing in `a` for slack rows and decreasing for active ones), and no
780/// representation carries relative precision across its own root; near it the
781/// error is bounded in absolute terms by `ε·b`, which is what a gradient
782/// consumer needs.
783#[must_use]
784pub fn cone_boundary_log_factor_and_derivatives(
785 mu_over_sqrt_h: f64,
786 slack_times_sqrt_h: f64,
787) -> (f64, f64, f64) {
788 let a = mu_over_sqrt_h;
789 let b = slack_times_sqrt_h;
790 let value = cone_boundary_log_factor(a, b);
791 if value.is_nan() {
792 // The value's domain guard (finite, non-negative `a` and `b`) is the
793 // function's domain; a gradient off it is not defined either, and
794 // returning a finite one next to a NaN value would read as usable.
795 return (value, f64::NAN, f64::NAN);
796 }
797 let xi = b - a;
798 let (_, mills) = signed_probit_logcdf_and_mills_ratio(xi);
799 let d_a = if xi <= LEFT_CONTINUED_FRACTION_SWITCH {
800 b - mills_correction_continued_fraction(-xi).value
801 } else {
802 // `|ξ| < 4`, so `λ(ξ) < λ(−4) ≈ 4.26` and `a = b − ξ` is bounded by it:
803 // the subtraction is between two `O(1)` quantities and loses nothing
804 // that matters.
805 a - mills
806 };
807 (value, d_a, mills)
808}
809
810#[cfg(test)]
811mod cone_boundary_factor_tests {
812 use super::*;
813
814 /// Adaptive-free Simpson quadrature of the exact 1-D boundary integral
815 /// `∫_{−s}^{U} exp(−μu − ½hu²) du` on a truncation `U` chosen so the
816 /// discarded tail is below 1e-18 of the mass.
817 fn quadrature_log_relative_factor(mu: f64, h: f64, s: f64) -> f64 {
818 let upper = ((-mu / h) + 12.0 / h.sqrt()).max(-s + 12.0 / h.sqrt());
819 let lower = -s;
820 let n = 40_000usize;
821 let step = (upper - lower) / n as f64;
822 let f = |u: f64| (-mu * u - 0.5 * h * u * u).exp();
823 let mut acc = f(lower) + f(upper);
824 for i in 1..n {
825 let u = lower + step * i as f64;
826 acc += if i % 2 == 1 { 4.0 } else { 2.0 } * f(u);
827 }
828 let integral = acc * step / 3.0;
829 (integral / (2.0 * std::f64::consts::PI / h).sqrt()).ln()
830 }
831
832 /// The closed form must match direct quadrature of the defining integral
833 /// across active (s=0), interior (μ=0), and mixed regimes (gam#2306 §4).
834 #[test]
835 fn boundary_factor_matches_quadrature_across_regimes() {
836 let cases: [(f64, f64, f64); 8] = [
837 (0.0, 1.0, 0.0), // activating row: exactly ln ½
838 (0.0, 4.0, 0.0), // curvature does not move the standardized value
839 (2.5, 1.0, 0.0), // active with a real multiplier
840 (30.0, 9.0, 0.0), // deep linear-decay limit
841 (0.0, 1.0, 0.7), // interior near-boundary
842 (0.0, 2.0, 4.0), // interior far: → 0
843 (1.5, 0.5, 2.0), // mixed multiplier + slack
844 (4.0, 2.0, 1.0), // active-dominant mixed
845 ];
846 for &(mu, h, s) in &cases {
847 let a = mu / h.sqrt();
848 let b = s * h.sqrt();
849 let closed = cone_boundary_log_factor(a, b);
850 let quad = quadrature_log_relative_factor(mu, h, s);
851 assert!(
852 (closed - quad).abs() <= 1e-9 * (1.0 + quad.abs()),
853 "(μ={mu}, h={h}, s={s}): closed {closed} vs quadrature {quad}"
854 );
855 }
856 assert!(
857 (cone_boundary_log_factor(0.0, 0.0) - 0.5_f64.ln()).abs() < 1e-15,
858 "an activating row must contribute exactly the half-Gaussian ln ½"
859 );
860 }
861
862 /// The deep-active limit is the exact linear decay `corr → −ln(a·√(2π))`,
863 /// and the deep-interior limit vanishes — the two continuity anchors that
864 /// make the constrained criterion reduce to the unrestricted LAML away
865 /// from the boundary.
866 #[test]
867 fn boundary_factor_limits_are_exact() {
868 let a = 1.0e6;
869 let expected = -(a * (2.0 * std::f64::consts::PI).sqrt()).ln();
870 let got = cone_boundary_log_factor(a, 0.0);
871 assert!(
872 (got - expected).abs() <= 1e-9 * expected.abs(),
873 "deep-active: got {got}, expected {expected}"
874 );
875 let interior = cone_boundary_log_factor(0.0, 40.0);
876 assert!(
877 interior.abs() < 1e-300 || interior > -1e-12,
878 "deep-interior must vanish; got {interior}"
879 );
880 }
881
882 /// The deep-active GRADIENT has to survive as far as the deep-active VALUE
883 /// does. `∂corr/∂a = a − λ(−a)` is the small residual left by two terms
884 /// that both grow like `a`, so writing it as that subtraction loses
885 /// `log₁₀(a²·ε)` digits: at `a = 1e6` it was already 4 digits down, at
886 /// `a = 2e8` it came back POSITIVE, and past `a = 5e8` it was flat zero
887 /// while the true value is `−2e-9`. The value alongside it was correct to
888 /// 15 digits the whole way, which is what made the defect quiet.
889 ///
890 /// The reference here is the asymptotic series of the Mills correction,
891 /// `λ(−a) = a + 1/a − 2/a³ + 10/a⁵ − 74/a⁷ + …` (so `∂corr/∂a = −1/a +
892 /// 2/a³ − …`), which is the cheapest exact statement of the limit and is
893 /// good to well past f64 from `a = 100` up. Finite differences cannot gate
894 /// this: the quantity under test is smaller than any usable FD step's own
895 /// truncation error.
896 #[test]
897 fn boundary_factor_active_gradient_holds_to_the_representable_limit() {
898 let mut a = 100.0_f64;
899 while a <= 1.0e14 {
900 let (_, d_a, _) = cone_boundary_log_factor_and_derivatives(a, 0.0);
901 let inv = 1.0 / a;
902 let expected = -inv + 2.0 * inv.powi(3) - 10.0 * inv.powi(5) + 74.0 * inv.powi(7);
903 assert!(
904 d_a < 0.0,
905 "corr is strictly decreasing in a at b=0, so ∂a must stay negative; \
906 got {d_a} at a={a}"
907 );
908 assert!(
909 (d_a - expected).abs() <= 1.0e-13 * expected.abs(),
910 "deep-active ∂a at a={a}: got {d_a}, expected {expected} \
911 (rel {:.3e})",
912 (d_a - expected).abs() / expected.abs()
913 );
914 a *= 10.0;
915 }
916 }
917
918 /// `∂corr/∂a + ∂corr/∂b = a` identically, since the two partials are
919 /// `a − λ(ξ)` and `λ(ξ)` for the same `ξ`. The two are now computed by
920 /// different routes in the active branch — a continued fraction and an
921 /// `erfcx` — so this is the gate that they still describe one function.
922 #[test]
923 fn boundary_factor_partials_sum_to_a() {
924 for &a in &[0.0_f64, 0.5, 3.0, 4.0, 12.0, 1.0e3, 1.0e7, 1.0e12] {
925 for &b in &[0.0_f64, 1.0e-3, 0.9, 5.0, 1.0e3] {
926 let (_, d_a, d_b) = cone_boundary_log_factor_and_derivatives(a, b);
927 assert!(
928 (d_a + d_b - a).abs() <= 1.0e-14 * a.max(d_b).max(1.0),
929 "(a={a}, b={b}): ∂a {d_a} + ∂b {d_b} = {} ≠ a",
930 d_a + d_b
931 );
932 }
933 }
934 }
935
936 /// The continued-fraction branch and the direct `a − λ` form must agree
937 /// just inside the `ξ ≤ −4` switch, where the subtraction still has most of
938 /// its digits. Without this, the branch could be precise and WRONG — the
939 /// accuracy gate above pins a limit the continued fraction could hit while
940 /// disagreeing with the function it is supposed to be differentiating.
941 ///
942 /// The band is set by the instrument being compared against, not by taste.
943 /// `direct` is `a − λ` with `λ` from the `erfcx` route, whose measured
944 /// relative accuracy is `~5e-14` (libm `erfc` plus the `exp(x²)` multiply);
945 /// its absolute error is therefore `~5e-14·λ`, and the subtraction cannot
946 /// remove it. Note how little room that leaves already: the amplification
947 /// `λ/|a−λ|` is 18x at `a = 4` and 403x at `a = 20`, so at the top of this
948 /// range the direct form is down to ~11 correct digits — five short — while
949 /// the continued fraction still matches a 250-digit reference to 16. This
950 /// test is deliberately capped at `a = 20` for that reason; it is the last
951 /// place the two CAN be compared.
952 #[test]
953 fn boundary_factor_active_branch_agrees_with_the_direct_form_where_both_are_valid() {
954 const LAMBDA_REL_ACCURACY: f64 = 5.0e-14;
955 for &a in &[4.0_f64, 4.5, 6.0, 9.0, 20.0] {
956 for &b in &[0.0_f64, 0.25, 1.5] {
957 if b - a > LEFT_CONTINUED_FRACTION_SWITCH {
958 continue; // not on the continued-fraction branch
959 }
960 let (_, d_a, _) = cone_boundary_log_factor_and_derivatives(a, b);
961 let (_, mills) = signed_probit_logcdf_and_mills_ratio(b - a);
962 let direct = a - mills;
963 assert!(
964 (d_a - direct).abs() <= LAMBDA_REL_ACCURACY * mills,
965 "(a={a}, b={b}): continued fraction {d_a} vs direct {direct} \
966 (gap {:.3e}, budget {:.3e})",
967 (d_a - direct).abs(),
968 LAMBDA_REL_ACCURACY * mills
969 );
970 }
971 }
972 }
973
974 /// A gradient off the domain must not read as usable next to a NaN value.
975 #[test]
976 fn boundary_factor_derivatives_are_nan_off_the_domain() {
977 for &(a, b) in &[
978 (-1.0_f64, 0.0_f64),
979 (1.0, -1.0),
980 (f64::NAN, 1.0),
981 (f64::INFINITY, 0.0),
982 ] {
983 let (v, d_a, d_b) = cone_boundary_log_factor_and_derivatives(a, b);
984 assert!(
985 v.is_nan() && d_a.is_nan() && d_b.is_nan(),
986 "(a={a}, b={b}) is off-domain: got value {v}, ∂a {d_a}, ∂b {d_b}"
987 );
988 }
989 }
990
991 /// Closed-form partials against finite differences of the value
992 /// (test-only FD; the production gradient consumes the analytic form).
993 /// The domain is `a, b ≥ 0`, so a coordinate sitting exactly on the
994 /// boundary uses a one-sided forward difference instead of stepping
995 /// outside the domain (where the factor is deliberately NaN).
996 #[test]
997 fn boundary_factor_derivatives_match_finite_differences() {
998 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)];
999 let step = 1e-6;
1000 let fd = |lo: f64, mid: f64, hi: f64, coord: f64| -> f64 {
1001 if coord >= step {
1002 (hi - lo) / (2.0 * step)
1003 } else {
1004 (hi - mid) / step
1005 }
1006 };
1007 for &(a, b) in &cases {
1008 let (_, d_a, d_b) = cone_boundary_log_factor_and_derivatives(a, b);
1009 let fd_a = fd(
1010 cone_boundary_log_factor((a - step).max(0.0), b),
1011 cone_boundary_log_factor(a, b),
1012 cone_boundary_log_factor(a + step, b),
1013 a,
1014 );
1015 let fd_b = fd(
1016 cone_boundary_log_factor(a, (b - step).max(0.0)),
1017 cone_boundary_log_factor(a, b),
1018 cone_boundary_log_factor(a, b + step),
1019 b,
1020 );
1021 // One-sided differences on boundary coordinates carry O(step)
1022 // truncation error, so the band is a few multiples of step.
1023 assert!(
1024 (d_a - fd_a).abs() <= 5e-6 * (1.0 + fd_a.abs()),
1025 "(a={a}, b={b}): ∂a analytic {d_a} vs FD {fd_a}"
1026 );
1027 assert!(
1028 (d_b - fd_b).abs() <= 5e-6 * (1.0 + fd_b.abs()),
1029 "(a={a}, b={b}): ∂b analytic {d_b} vs FD {fd_b}"
1030 );
1031 }
1032 }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038
1039 const TOL: f64 = 1e-12;
1040
1041 fn rel_err(got: f64, expected: f64) -> f64 {
1042 (got - expected).abs() / expected.abs().max(1e-300)
1043 }
1044
1045 #[test]
1046 fn beta_quantile_matches_known_reference_values() {
1047 let cases: [(f64, f64, f64, f64); 8] = [
1048 (0.025, 2.0, 2.0, 0.094_299_3),
1049 (0.975, 2.0, 2.0, 0.905_700_7),
1050 (0.5, 2.0, 2.0, 0.5),
1051 (0.025, 0.8, 4.0, 0.002_339_1),
1052 (0.975, 0.8, 4.0, 0.564_717_3),
1053 (0.025, 5.0, 1.5, 0.408_549_1),
1054 (0.5, 20.0, 80.0, 0.197_994_8),
1055 (0.975, 20.0, 80.0, 0.283_367_6),
1056 ];
1057 for (p, a, b, expected) in cases {
1058 let got = beta_quantile(p, a, b);
1059 let abs = (got - expected).abs();
1060 assert!(
1061 abs < 1e-5,
1062 "beta_quantile(p={p}, a={a}, b={b}) = {got}, expected ≈ {expected} (abs err {abs})"
1063 );
1064 }
1065 }
1066
1067 #[test]
1068 fn beta_quantile_boundaries_and_degeneracy() {
1069 assert_eq!(beta_quantile(0.0, 2.0, 3.0), 0.0);
1070 assert_eq!(beta_quantile(-0.5, 2.0, 3.0), 0.0);
1071 assert_eq!(beta_quantile(1.0, 2.0, 3.0), 1.0);
1072 assert_eq!(beta_quantile(1.5, 2.0, 3.0), 1.0);
1073 assert!(beta_quantile(0.5, -1.0, 3.0).is_nan());
1074 assert!(beta_quantile(0.5, 2.0, 0.0).is_nan());
1075 assert!(beta_quantile(0.5, f64::NAN, 3.0).is_nan());
1076 let mut prev = 0.0;
1077 for i in 1..100 {
1078 let p = i as f64 / 100.0;
1079 let q = beta_quantile(p, 3.0, 5.0);
1080 assert!(q > prev, "beta quantile not increasing at p={p}");
1081 prev = q;
1082 }
1083 }
1084
1085 // ── normal_pdf ────────────────────────────────────────────────────────────
1086
1087 #[test]
1088 fn normal_pdf_at_zero() {
1089 let expected = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
1090 assert!((normal_pdf(0.0) - expected).abs() < TOL);
1091 }
1092
1093 #[test]
1094 fn normal_pdf_symmetry() {
1095 for &x in &[0.5, 1.0, 2.0, 3.0, 5.0] {
1096 assert_eq!(normal_pdf(x), normal_pdf(-x), "symmetry failed at x={x}");
1097 }
1098 }
1099
1100 /// `x*x` is exact-splittable and the split is what `exp` needs.
1101 ///
1102 /// Two independent statements, because the correction is only worth what
1103 /// its residual is worth. First, `x*x + residual` is `x²` EXACTLY: checked
1104 /// against a Veltkamp/Dekker split, which reaches the same residual through
1105 /// pure multiplies and adds and shares no code path with the `mul_add`
1106 /// route. Second, the residual is not decorative — for these arguments it
1107 /// is a relative perturbation of `x²` big enough that `exp` amplifies it
1108 /// past a single ulp of the result.
1109 #[test]
1110 fn square_residual_completes_the_rounded_square_exactly() {
1111 // 2^27 + 1: Veltkamp's splitting factor, exact for any `x` whose
1112 // scaled form does not overflow.
1113 const SPLIT: f64 = 134_217_729.0;
1114 let mut saw_amplified = false;
1115 for &x in &[
1116 0.1, 0.7, 1.3, 2.9, 6.1, 10.5, 14.3, 19.7, 23.9, 25.9999, 34.7,
1117 ] {
1118 let rounded = x * x;
1119 let residual = square_residual(x, rounded);
1120
1121 let c = x * SPLIT;
1122 let head = c - (c - x);
1123 let tail = x - head;
1124 let dekker = ((head * head - rounded) + 2.0 * head * tail) + tail * tail;
1125 assert_eq!(
1126 residual, dekker,
1127 "x={x}: mul_add residual {residual:e} != Dekker residual {dekker:e}"
1128 );
1129
1130 // `exp` multiplies a relative argument perturbation by the argument.
1131 let amplified = (residual / rounded).abs() * rounded;
1132 if amplified > f64::EPSILON {
1133 saw_amplified = true;
1134 }
1135 }
1136 assert!(
1137 saw_amplified,
1138 "no test argument had a residual `exp` could amplify past one ulp; \
1139 the correction under test would be untested"
1140 );
1141 }
1142
1143 /// `φ(x)` against an EXTERNAL high-precision reference (mpmath, dps=60).
1144 ///
1145 /// Every argument here has an INEXACT square, which is the whole point.
1146 /// `exp(−½·fl(x*x))` misplaces the argument by `x²·ε/2` RELATIVE, and `exp`
1147 /// hands that straight back as relative error in the result: `1.4e-14` at
1148 /// `x ≈ 17`, `5.7e-14` by `x ≈ 35`, where `φ` is still a normal `f64`. Only
1149 /// the top of the range makes that visible, so the table has to reach it —
1150 /// a `φ` table that stops at `x = 5` cannot tell the two forms apart.
1151 ///
1152 /// `1.5e-15` (≈7 ulp) is the portability allowance: `f64::exp` is the
1153 /// platform libm and the only part of this that is not fixed by the crate
1154 /// graph, and it is worth ~1 ulp on the implementations in use. That still
1155 /// leaves 38x of margin against the defect at the top of the table.
1156 #[test]
1157 fn normal_pdf_matches_high_precision_reference() {
1158 const TOLERANCE: f64 = 1.5e-15;
1159 let refs: &[(f64, f64)] = &[
1160 (0.5, 0.35206532676429947),
1161 (1.0, 0.24197072451914334),
1162 (2.5, 0.017528300493568537),
1163 (4.0, 0.00013383022576488534),
1164 (7.3, 1.0693837871541648e-12),
1165 (11.9, 7.090702668428078e-32),
1166 (17.4, 7.201308152719057e-67),
1167 (23.6, 4.555989824112156e-122),
1168 (29.1, 5.229437243665329e-185),
1169 (34.7, 1.368008224488383e-262),
1170 ];
1171 for &(x, reference) in refs {
1172 // The small arguments anchor the ordinary range; the large ones are
1173 // where the defect lives, and every one of THOSE has to have a
1174 // square `f64` cannot hold or it exercises nothing.
1175 assert!(
1176 x <= 5.0 || square_residual(x, x * x) != 0.0,
1177 "x={x} squares exactly, so it cannot exercise the correction"
1178 );
1179 let rel = rel_err(normal_pdf(x), reference);
1180 assert!(
1181 rel < TOLERANCE,
1182 "normal_pdf({x}) = {:.17e}, reference {reference:.17e}, rel {rel:.3e}",
1183 normal_pdf(x)
1184 );
1185 }
1186 }
1187
1188 /// `φ` off the ordinary domain, where the square has no usable residual:
1189 /// `±∞` squares to `∞` and would hand the correction an `∞ − ∞`.
1190 #[test]
1191 fn normal_pdf_nonfinite_and_underflowed_arguments() {
1192 assert_eq!(normal_pdf(f64::INFINITY), 0.0);
1193 assert_eq!(normal_pdf(f64::NEG_INFINITY), 0.0);
1194 assert!(normal_pdf(f64::NAN).is_nan());
1195 // Past ~38.6 the pdf underflows; it must reach zero, not NaN.
1196 assert_eq!(normal_pdf(40.0), 0.0);
1197 assert_eq!(normal_pdf(-40.0), 0.0);
1198 assert_eq!(normal_pdf(f64::MAX), 0.0);
1199 // Just inside the underflow edge the result is subnormal but positive.
1200 let edge = normal_pdf(38.0);
1201 assert!(edge > 0.0 && edge.is_subnormal(), "phi(38) = {edge:e}");
1202 }
1203
1204 #[test]
1205 fn normal_pdf_positive() {
1206 for &x in &[-5.0, -1.0, 0.0, 1.0, 5.0] {
1207 assert!(normal_pdf(x) > 0.0, "pdf should be positive at x={x}");
1208 }
1209 }
1210
1211 // ── normal_cdf ────────────────────────────────────────────────────────────
1212
1213 #[test]
1214 fn normal_cdf_at_zero_is_half() {
1215 assert!((normal_cdf(0.0) - 0.5).abs() < TOL);
1216 }
1217
1218 #[test]
1219 fn normal_cdf_symmetry() {
1220 for &x in &[0.5, 1.0, 2.0, 3.0] {
1221 let sum = normal_cdf(x) + normal_cdf(-x);
1222 assert!(
1223 (sum - 1.0).abs() < TOL,
1224 "cdf symmetry failed at x={x}: sum={sum}"
1225 );
1226 }
1227 }
1228
1229 #[test]
1230 fn normal_cdf_bounds() {
1231 assert!(normal_cdf(10.0) > 0.9999);
1232 assert!(normal_cdf(-10.0) < 1e-22);
1233 assert!(normal_cdf(0.0) > 0.0);
1234 assert!(normal_cdf(0.0) < 1.0);
1235 }
1236
1237 #[test]
1238 fn normal_cdf_at_1_96_near_0975() {
1239 // Phi(1.96) ≈ 0.975 — canonical two-sided 5% critical value.
1240 let p = normal_cdf(1.959_963_985);
1241 assert!((p - 0.975).abs() < 1e-8, "p={p}");
1242 }
1243
1244 // ── erfcx_nonnegative ─────────────────────────────────────────────────────
1245
1246 #[test]
1247 fn erfcx_zero_is_one_and_negative_domain_is_rejected() {
1248 assert_eq!(erfcx_nonnegative(0.0), 1.0);
1249 assert!(erfcx_nonnegative(-f64::MIN_POSITIVE).is_nan());
1250 assert!(erfcx_nonnegative(-1.0).is_nan());
1251 assert!(erfcx_nonnegative(f64::NEG_INFINITY).is_nan());
1252 }
1253
1254 #[test]
1255 fn erfcx_positive_inf_returns_zero() {
1256 assert_eq!(erfcx_nonnegative(f64::INFINITY), 0.0);
1257 }
1258
1259 #[test]
1260 fn erfcx_nan_propagates() {
1261 assert!(erfcx_nonnegative(f64::NAN).is_nan());
1262 }
1263
1264 #[test]
1265 fn erfcx_small_positive_matches_direct() {
1266 use libm::erfc;
1267 for &x in &[0.1_f64, 0.5, 1.0, 5.0, 10.0, 25.0] {
1268 let got = erfcx_nonnegative(x);
1269 let expected = (x * x).exp() * erfc(x);
1270 let err = rel_err(got, expected);
1271 assert!(
1272 err < 1e-10,
1273 "x={x}: got={got} expected={expected} rel={err}"
1274 );
1275 }
1276 }
1277
1278 #[test]
1279 fn erfcx_large_x_positive_and_finite() {
1280 // For x >= 26 the asymptotic branch must remain positive and finite.
1281 let got = erfcx_nonnegative(50.0);
1282 assert!(got.is_finite() && got > 0.0, "erfcx(50)={got}");
1283 // Leading asymptotic term: 1/(x*sqrt(pi)).
1284 let asymptotic = 1.0 / (50.0 * std::f64::consts::PI.sqrt());
1285 assert!(
1286 rel_err(got, asymptotic) < 1e-3,
1287 "got={got} asymptotic={asymptotic}"
1288 );
1289 }
1290
1291 /// The two branches must describe one function across `x = 26`.
1292 ///
1293 /// Note WHY the plain `exp(x*x)·erfc(x)` below is a legitimate oracle at
1294 /// this particular argument and nowhere else: `26² = 676` is exactly
1295 /// representable, so the rounded square carries no residual and the direct
1296 /// form is momentarily as good as the corrected one. That is also exactly
1297 /// why this check was blind to the `x²·ε/2` defect it looks like it should
1298 /// have caught — at `25.9` the same comparison would have failed by
1299 /// `5.7e-14`, but the seam was only ever probed at the one point in the
1300 /// neighbourhood where the defect vanishes. The bit-adjacent step below
1301 /// cannot substitute for it either: `d(ln erfcx)/dx ≈ −2x` at the switch,
1302 /// so one ulp of `x` moves the true value by `1.8e-13`, three times the
1303 /// defect. It takes a reference at a DISTANCE from the seam — the table in
1304 /// `erfcx_matches_high_precision_reference` — to see the defect at all.
1305 #[test]
1306 fn erfcx_asymptotic_switch_matches_finite_direct_identity() {
1307 let switch = 26.0_f64;
1308 assert_eq!(
1309 square_residual(switch, switch * switch),
1310 0.0,
1311 "676 must be exact for the direct form below to be an oracle"
1312 );
1313 let direct = (switch * switch).exp() * erfc(switch);
1314 let asymptotic = erfcx_nonnegative(switch);
1315 assert!(
1316 rel_err(asymptotic, direct) < 1.0e-15,
1317 "switch mismatch: asymptotic={asymptotic:.17e}, direct={direct:.17e}"
1318 );
1319
1320 // Continuity across the branch cut, up to how fast the function itself
1321 // moves over one ulp of `x` (`|d ln erfcx/dx| ≈ 2x` ⇒ ~1.9e-13 here).
1322 let immediately_below = f64::from_bits(switch.to_bits() - 1);
1323 let below = erfcx_nonnegative(immediately_below);
1324 let step = 2.0 * switch * (switch - immediately_below);
1325 assert!(
1326 rel_err(asymptotic, below) < 2.0 * step,
1327 "discontinuous switch: below={below:.17e}, at={asymptotic:.17e}, \
1328 one-ulp travel {step:.3e}"
1329 );
1330 }
1331
1332 #[test]
1333 fn erfcx_preserves_representable_subnormal_tail() {
1334 let tail = erfcx_nonnegative(f64::MAX);
1335 assert!(tail > 0.0 && tail.is_subnormal(), "erfcx(MAX)={tail:e}");
1336 }
1337
1338 /// Absolute-accuracy pin against an EXTERNAL high-precision reference
1339 /// (mpmath, dps=60) spanning the direct branch `[0.1, 26)`. This is the
1340 /// root-cause guard: the previous `exp(x²)·erfc(x)` direct form was built on
1341 /// `statrs::erfc`, whose ~1e-10 relative accuracy silently poisoned every
1342 /// downstream probit / Mills / log-CDF derivative.
1343 ///
1344 /// The table had a SECOND job it was not doing. Of its twelve arguments,
1345 /// eleven — `0.5`, `2`, `3.5`, `6`, `9`, `13`, `18`, `22`, `25.5`, and the
1346 /// two whose squares are far too small to matter — square EXACTLY in `f64`,
1347 /// so `fl(x*x) = x²` and the `x²·ε/2` error the rounded square feeds `exp`
1348 /// was identically zero at every one of them. The twelfth, `25.9999`, does
1349 /// not square exactly; it was the one point in the table where the defect
1350 /// was live, and its literal had been recorded WITH the defect in it —
1351 /// `0.021683668126370212` against a true `0.021683668126369115`, off by
1352 /// `5.1e-14`. Three independent high-precision routes (`exp(x²)·erfc(x)`,
1353 /// the 12-term asymptotic series, and a 400-level Laplace continued
1354 /// fraction) and `scipy.special.erfcx` all agree on the corrected value.
1355 /// A `1e-13` tolerance then accepted a reference that was itself wrong by
1356 /// half the tolerance, which is how a 190x accuracy defect sat under a
1357 /// test named for high precision.
1358 ///
1359 /// So the table now RUNS ON arguments with inexact squares (`10.5`,
1360 /// `14.3`, `19.7`, `23.9` alongside the original grid) and the tolerance is
1361 /// `1.5e-15` — 38x below the defect at the top of the range, and still ~7
1362 /// ulp of headroom for the platform `f64::exp` (the only part of this path
1363 /// not pinned by the crate graph; `erfc` comes from the `libm` crate and is
1364 /// identical everywhere).
1365 #[test]
1366 fn erfcx_matches_high_precision_reference() {
1367 const TOLERANCE: f64 = 1.5e-15;
1368 // (x, mpmath exp(x²)·erfc(x) at dps=60, rounded to f64).
1369 let refs: &[(f64, f64)] = &[
1370 (0.1, 0.8964569799691267),
1371 (0.5, 0.6156903441929259),
1372 (1.0, 0.427583576155807),
1373 (2.0, 0.25539567631050575),
1374 (3.5, 0.1552936556088943),
1375 (6.0, 0.09277656780053835),
1376 (9.0, 0.06230772403777468),
1377 (10.5, 0.05349189974656412),
1378 (13.0, 0.043271921864609694),
1379 (14.3, 0.0393580473372741),
1380 (18.0, 0.03129571781590521),
1381 (19.7, 0.028602309402825203),
1382 (22.0, 0.025618570005879453),
1383 (23.9, 0.023585649371803793),
1384 (25.5, 0.022108108052519827),
1385 (25.9999, 0.021683668126369115),
1386 ];
1387 for &(x, reference) in refs {
1388 let got = erfcx_nonnegative(x);
1389 let rel = rel_err(got, reference);
1390 assert!(
1391 rel < TOLERANCE,
1392 "erfcx({x}) = {got:.17e}, reference {reference:.17e}, rel {rel:.3e}"
1393 );
1394 }
1395 // The point of the added arguments: at least four of them must have a
1396 // square `f64` cannot hold, or the table is back to testing nothing.
1397 let inexact = refs
1398 .iter()
1399 .filter(|&&(x, _)| square_residual(x, x * x) != 0.0)
1400 .count();
1401 assert!(
1402 inexact >= 4,
1403 "only {inexact} of {} reference arguments have an inexact square",
1404 refs.len()
1405 );
1406 }
1407
1408 // ── log1mexp_positive ─────────────────────────────────────────────────────
1409
1410 #[test]
1411 fn log1mexp_at_zero_is_neg_inf() {
1412 assert_eq!(log1mexp_positive(0.0), f64::NEG_INFINITY);
1413 }
1414
1415 #[test]
1416 fn log1mexp_recovers_log_one_minus_exp() {
1417 // Verify exp(log1mexp(a)) + exp(-a) ≈ 1 for several a > 0. This
1418 // roundtrip avoids computing `(1 - exp(-a)).ln()` directly, which
1419 // suffers catastrophic cancellation for large a (e.g. a=20 where
1420 // `1.0 - exp(-20)` loses 9 decimal digits from the subtraction).
1421 for &a in &[0.001_f64, 0.5, std::f64::consts::LN_2, 1.0, 5.0, 20.0] {
1422 let lm = log1mexp_positive(a);
1423 let roundtrip = lm.exp() + (-a).exp();
1424 assert!(
1425 (roundtrip - 1.0).abs() < 1e-14,
1426 "a={a}: exp(log1mexp(a)) + exp(-a) = {roundtrip}, expected 1.0"
1427 );
1428 }
1429 }
1430
1431 #[test]
1432 fn log1mexp_at_ln2_is_neg_ln2() {
1433 let ln2 = std::f64::consts::LN_2;
1434 let got = log1mexp_positive(ln2);
1435 assert!((got - (-ln2)).abs() < TOL, "got={got}");
1436 }
1437
1438 // ── signed_log_sum_exp ────────────────────────────────────────────────────
1439
1440 #[test]
1441 fn slse_all_positive_single() {
1442 let (lm, sg) = signed_log_sum_exp(&[2.0], &[1.0]);
1443 assert!((lm - 2.0).abs() < TOL);
1444 assert!((sg - 1.0).abs() < TOL);
1445 }
1446
1447 #[test]
1448 fn slse_difference_recovers_log2() {
1449 // 3 - 1 = 2 → log|2| = ln(2), sign = +1.
1450 let log3 = 3.0_f64.ln();
1451 let log1 = 0.0_f64; // ln(1)
1452 let (lm, sg) = signed_log_sum_exp(&[log3, log1], &[1.0, -1.0]);
1453 assert!((lm - 2.0_f64.ln()).abs() < TOL, "lm={lm}");
1454 assert!((sg - 1.0).abs() < TOL, "sg={sg}");
1455 }
1456
1457 #[test]
1458 fn slse_cancellation_gives_neg_inf() {
1459 // a - a = 0 → log|0| = -∞.
1460 let ln2 = 2.0_f64.ln();
1461 let (lm, sg) = signed_log_sum_exp(&[ln2, ln2], &[1.0, -1.0]);
1462 assert_eq!(lm, f64::NEG_INFINITY);
1463 assert_eq!(sg, 0.0);
1464 }
1465
1466 #[test]
1467 fn slse_empty_returns_neg_inf_with_zero_sign() {
1468 // With no terms the sum is exactly 0, so the docstring contract is
1469 // `(−∞, 0.0)`. (This test previously encoded the buggy `+1.0` positive-sum
1470 // convention, which contradicted both the docstring and the cancellation
1471 // test below; rewritten to the correct zero sign.)
1472 let (lm, sg) = signed_log_sum_exp(&[], &[]);
1473 assert_eq!(lm, f64::NEG_INFINITY);
1474 assert_eq!(sg, 0.0);
1475 }
1476
1477 #[test]
1478 fn slse_all_zero_signs_return_zero_sign() {
1479 // A single term whose sign is 0 contributes nothing; S = 0 ⇒ (−∞, 0.0).
1480 let (lm, sg) = signed_log_sum_exp(&[0.0], &[0.0]);
1481 assert_eq!(lm, f64::NEG_INFINITY);
1482 assert_eq!(sg, 0.0);
1483 }
1484
1485 #[test]
1486 fn slse_all_neg_inf_magnitudes_return_zero_sign() {
1487 // Every magnitude is exp(−∞) = 0 regardless of sign, so the sum is 0 and
1488 // the reported sign must be 0.0, not +1.0.
1489 let (lm, sg) = signed_log_sum_exp(&[f64::NEG_INFINITY, f64::NEG_INFINITY], &[1.0, -1.0]);
1490 assert_eq!(lm, f64::NEG_INFINITY);
1491 assert_eq!(sg, 0.0);
1492 }
1493
1494 #[test]
1495 fn slse_pos_inf_dominates() {
1496 let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, 1.0], &[1.0, -1.0]);
1497 assert_eq!(lm, f64::INFINITY);
1498 assert_eq!(sg, 1.0);
1499 }
1500
1501 #[test]
1502 fn slse_neg_inf_dominates() {
1503 let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, 1.0], &[-1.0, 1.0]);
1504 assert_eq!(lm, f64::INFINITY);
1505 assert_eq!(sg, -1.0);
1506 }
1507
1508 #[test]
1509 fn slse_both_inf_signs_gives_nan() {
1510 let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, f64::INFINITY], &[1.0, -1.0]);
1511 assert!(lm.is_nan());
1512 assert_eq!(sg, 0.0);
1513 }
1514
1515 // ── normal_logcdf ─────────────────────────────────────────────────────────
1516
1517 #[test]
1518 fn logcdf_at_zero_is_log_half() {
1519 let got = normal_logcdf(0.0);
1520 let expected = 0.5_f64.ln();
1521 assert!((got - expected).abs() < TOL, "got={got}");
1522 }
1523
1524 #[test]
1525 fn logcdf_pos_inf_is_zero() {
1526 assert_eq!(normal_logcdf(f64::INFINITY), 0.0);
1527 }
1528
1529 #[test]
1530 fn logcdf_neg_inf_is_neg_inf() {
1531 assert_eq!(normal_logcdf(f64::NEG_INFINITY), f64::NEG_INFINITY);
1532 }
1533
1534 #[test]
1535 fn logcdf_nan_is_nan() {
1536 assert!(normal_logcdf(f64::NAN).is_nan());
1537 }
1538
1539 #[test]
1540 fn logcdf_matches_log_cdf_for_moderate_x() {
1541 for &x in &[-2.0_f64, -1.0, 0.0, 1.0, 2.0, 3.0] {
1542 let got = normal_logcdf(x);
1543 let expected = normal_cdf(x).ln();
1544 assert!(
1545 (got - expected).abs() < 1e-10,
1546 "x={x}: got={got} expected={expected}"
1547 );
1548 }
1549 }
1550
1551 #[test]
1552 fn logcdf_deep_left_tail_stays_finite() {
1553 // For very negative x, normal_cdf(x) underflows to 0, but logcdf should
1554 // remain finite and large-negative.
1555 let got = normal_logcdf(-20.0);
1556 assert!(got.is_finite() && got < -100.0, "logcdf(-20)={got}");
1557 }
1558
1559 #[test]
1560 fn logcdf_positive_tail_does_not_round_through_unit_cdf() {
1561 let x = 10.0_f64;
1562 let got = normal_logcdf(x);
1563 let expected = (-0.5 * erfc(x / std::f64::consts::SQRT_2)).ln_1p();
1564 assert!(
1565 got < 0.0,
1566 "logcdf(10) must retain its negative tail: {got:e}"
1567 );
1568 assert_eq!(got.to_bits(), expected.to_bits());
1569 }
1570
1571 #[test]
1572 fn log_cdf_quantile_round_trips_both_unrepresentable_tails() {
1573 for x in [-1.0e6, -40.0, -10.0, -2.0, 0.0, 2.0, 10.0] {
1574 let log_p = normal_logcdf(x);
1575 let recovered = standard_normal_quantile_from_log_cdf(log_p)
1576 .expect("finite strict log-CDF has a quantile");
1577 assert!(
1578 (recovered - x).abs() <= 2.0e-12 * x.abs().max(1.0),
1579 "log-quantile round trip at x={x}: log_p={log_p}, recovered={recovered}"
1580 );
1581 }
1582 }
1583
1584 // ── normal_logsf ─────────────────────────────────────────────────────────
1585
1586 #[test]
1587 fn logsf_at_zero_is_log_half() {
1588 let got = normal_logsf(0.0);
1589 let expected = 0.5_f64.ln();
1590 assert!((got - expected).abs() < TOL, "got={got}");
1591 }
1592
1593 #[test]
1594 fn logsf_mirrors_logcdf() {
1595 // logsf(x) = logcdf(-x) by definition.
1596 for &x in &[-3.0_f64, -1.0, 0.0, 1.0, 3.0] {
1597 assert_eq!(normal_logsf(x), normal_logcdf(-x));
1598 }
1599 }
1600
1601 // ── signed_probit_logcdf_and_mills_ratio ──────────────────────────────────
1602
1603 #[test]
1604 fn probit_at_pos_inf() {
1605 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::INFINITY);
1606 assert_eq!(lc, 0.0);
1607 assert_eq!(mr, 0.0);
1608 }
1609
1610 #[test]
1611 fn probit_at_neg_inf() {
1612 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::NEG_INFINITY);
1613 assert_eq!(lc, f64::NEG_INFINITY);
1614 assert_eq!(mr, f64::INFINITY);
1615 }
1616
1617 #[test]
1618 fn probit_nan_propagates() {
1619 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::NAN);
1620 assert!(lc.is_nan() && mr.is_nan());
1621 }
1622
1623 #[test]
1624 fn probit_at_zero_logcdf_and_mills() {
1625 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(0.0);
1626 assert!((lc - 0.5_f64.ln()).abs() < TOL, "lc={lc}");
1627 // phi(0)/Phi(0) = 0.3989.../0.5 ≈ 0.7979.
1628 assert!((mr - 0.797_884_560_802_865).abs() < 1e-10, "mr={mr}");
1629 }
1630
1631 #[test]
1632 fn probit_positive_branch_matches_logcdf() {
1633 for &x in &[0.5_f64, 1.0, 2.0, 3.0] {
1634 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(x);
1635 let lc_ref = normal_logcdf(x);
1636 let mr_ref = normal_pdf(x) / normal_cdf(x);
1637 assert!(
1638 (lc - lc_ref).abs() < 1e-10,
1639 "x={x}: lc={lc} lc_ref={lc_ref}"
1640 );
1641 assert!(
1642 (mr - mr_ref).abs() < 1e-10,
1643 "x={x}: mr={mr} mr_ref={mr_ref}"
1644 );
1645 }
1646 }
1647
1648 #[test]
1649 fn probit_negative_branch_matches_logcdf() {
1650 for &x in &[-0.5_f64, -1.0, -2.0, -5.0] {
1651 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(x);
1652 let lc_ref = normal_logcdf(x);
1653 assert!(
1654 (lc - lc_ref).abs() < 1e-10,
1655 "x={x}: lc={lc} lc_ref={lc_ref}"
1656 );
1657 assert!(mr.is_finite() && mr > 0.0, "x={x}: mr={mr}");
1658 }
1659 }
1660
1661 #[test]
1662 fn probit_mills_ratio_has_no_deep_tail_floor() {
1663 let x = -1.0e305_f64;
1664 let (log_cdf, mills_ratio) = signed_probit_logcdf_and_mills_ratio(x);
1665 assert_eq!(log_cdf, f64::NEG_INFINITY);
1666 assert!(mills_ratio.is_finite());
1667 assert!(
1668 ((mills_ratio / -x) - 1.0).abs() < 5.0e-15,
1669 "mills({x:e})={mills_ratio:e}"
1670 );
1671 }
1672
1673 #[test]
1674 fn normal_logcdf_derivative_stack_has_honest_infinite_limits() {
1675 assert_eq!(normal_logcdf_derivatives(f64::INFINITY), [0.0; 5]);
1676 assert_eq!(
1677 normal_logcdf_derivatives(f64::NEG_INFINITY),
1678 [f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0, 0.0]
1679 );
1680 assert!(
1681 normal_logcdf_derivatives(f64::NAN)
1682 .into_iter()
1683 .all(f64::is_nan)
1684 );
1685
1686 for x in [-1.0e200_f64, 1.0e200_f64] {
1687 let derivatives = normal_logcdf_derivatives(x);
1688 assert!(
1689 derivatives.into_iter().all(|value| !value.is_nan()),
1690 "NaN derivative at x={x:e}: {derivatives:?}"
1691 );
1692 }
1693 }
1694
1695 #[test]
1696 fn normal_logcdf_left_tail_derivatives_do_not_cancel() {
1697 let x = -1.0e100_f64;
1698 let derivatives = normal_logcdf_derivatives(x);
1699 assert_eq!(derivatives[2], -1.0);
1700 assert!(derivatives[3] > 0.0 && derivatives[3].is_finite());
1701 assert!(
1702 (derivatives[3] / 2.0e-300 - 1.0).abs() < 2.0e-14,
1703 "third derivative={:e}",
1704 derivatives[3]
1705 );
1706 assert_eq!(derivatives[4], 0.0);
1707 }
1708
1709 #[test]
1710 fn normal_logcdf_right_tail_preserves_weighted_subnormal_derivatives() {
1711 let derivatives = normal_logcdf_derivatives(38.6);
1712 assert_eq!(derivatives[1], 0.0);
1713 assert!(derivatives[2] < 0.0 && derivatives[2].is_subnormal());
1714 assert!(derivatives[3] > 0.0 && derivatives[3].is_subnormal());
1715 assert!(derivatives[4] < 0.0 && derivatives[4].is_subnormal());
1716 }
1717
1718 #[test]
1719 fn normal_logcdf_tail_stack_is_finite_difference_consistent() {
1720 let h = 1.0e-4_f64;
1721 for x in [-8.0_f64, -4.0, 8.0, 20.0] {
1722 let center = normal_logcdf_derivatives(x);
1723 let left = normal_logcdf_derivatives(x - h);
1724 let right = normal_logcdf_derivatives(x + h);
1725 for order in 1..=3 {
1726 let finite_difference = (right[order] - left[order]) / (2.0 * h);
1727 let expected = center[order + 1];
1728 let relative = (finite_difference - expected).abs() / expected.abs().max(1.0e-300);
1729 assert!(
1730 relative < 2.0e-5,
1731 "x={x}, order={order}: fd={finite_difference:e}, expected={expected:e}, rel={relative:e}"
1732 );
1733 }
1734 }
1735 }
1736
1737 /// Absolute-accuracy pin of the full `ln Φ(x)` derivative tower against an
1738 /// EXTERNAL high-precision reference (mpmath, dps=60), covering all three
1739 /// branches (continued-fraction left tail at x=−4, the moderate Mills
1740 /// recurrence for x∈(−4, 8), and both signs). Before the `erfc` root-cause
1741 /// fix the moderate branch's `λ = φ/Φ` inherited `statrs::erfc`'s ~1e-10
1742 /// error, so `f''` was wrong by ~1e-9 near the −4 seam; this pins every
1743 /// entry to `2e-11` relative, catching that regression head-on rather than
1744 /// through a seam-straddling finite difference.
1745 #[test]
1746 fn normal_logcdf_derivative_tower_matches_high_precision_reference() {
1747 // (x, [value, f', f'', f''', f''''] from mpmath at dps=60).
1748 let refs: &[(f64, [f64; 5])] = &[
1749 (
1750 -4.0,
1751 [
1752 -10.360101486527291,
1753 4.2256071444894711,
1754 -0.95332716160257737,
1755 0.017856339307658426,
1756 0.0095065764315958691,
1757 ],
1758 ),
1759 // Two points well inside the continued-fraction branch, where the
1760 // truncation the depth controls is the ONLY error source: at -4 the
1761 // branch is at its least converged, and these confirm it stays put.
1762 (
1763 -10.0,
1764 [
1765 -53.231285150512471,
1766 10.098093233962512,
1767 -0.99055462217434374,
1768 0.0017864003921165069,
1769 0.00049785382237944016,
1770 ],
1771 ),
1772 (
1773 -6.0,
1774 [
1775 -20.736768949974706,
1776 6.1584826045445989,
1777 -0.97601236321083323,
1778 0.0069535374991643118,
1779 0.0028992056785575027,
1780 ],
1781 ),
1782 (
1783 -2.0,
1784 [
1785 -3.7831843336820319,
1786 2.3732155328228409,
1787 -0.88572089958591874,
1788 0.059355861291565813,
1789 0.039421993865946813,
1790 ],
1791 ),
1792 (
1793 -1.0,
1794 [
1795 -1.8410216450092635,
1796 1.5251352761609812,
1797 -0.80090233442965121,
1798 0.11693119540604883,
1799 0.07917498368074563,
1800 ],
1801 ),
1802 (
1803 -0.3,
1804 [
1805 -0.96210281816885066,
1806 0.99816596885848332,
1807 -0.69688551072964971,
1808 0.18398317992442132,
1809 0.11037564722092704,
1810 ],
1811 ),
1812 (
1813 0.5,
1814 [
1815 -0.36894641528865639,
1816 0.50916043383703349,
1817 -0.5138245643036329,
1818 0.27099012446870783,
1819 0.088167801929197554,
1820 ],
1821 ),
1822 (
1823 2.0,
1824 [
1825 -0.023012909328963488,
1826 0.055247862678989959,
1827 -0.11354805168857645,
1828 0.18439481503247759,
1829 -0.18785468561160969,
1830 ],
1831 ),
1832 ];
1833 // The moderate-branch statrs regression produced ~1e-9 errors in f''.
1834 // The bound used to sit at 1e-10 to respect what was called the
1835 // continued-fraction branch's "inherent" ~2e-11 in f''''; that was not
1836 // inherent but a depth, and at 64 levels the branch reproduces this
1837 // 60-digit reference EXACTLY at x = -4, -6 and -10. What remains is the
1838 // moderate branch, where the brackets are already collected in `q` and
1839 // the floor is `λ`'s own relative error amplified by `λ/q` (18.7 at the
1840 // switch): 1.8e-13 at x = -2, the worst point here. 1e-11 keeps 55x of
1841 // headroom over that while still failing the 32-level truncation head-on.
1842 for &(x, reference) in refs {
1843 let got = normal_logcdf_derivatives(x);
1844 for (order, (&g, &r)) in got.iter().zip(reference.iter()).enumerate() {
1845 let rel = (g - r).abs() / r.abs().max(1.0e-3);
1846 assert!(
1847 rel < 1.0e-11,
1848 "normal_logcdf_derivatives({x})[{order}] = {g:.17e}, reference {r:.17e}, \
1849 rel {rel:.3e} >= 1e-11"
1850 );
1851 }
1852 }
1853 }
1854
1855 // ── standard_normal_quantile ──────────────────────────────────────────────
1856
1857 #[test]
1858 fn quantile_rejects_out_of_range() {
1859 assert!(standard_normal_quantile(0.0).is_err());
1860 assert!(standard_normal_quantile(1.0).is_err());
1861 assert!(standard_normal_quantile(-0.1).is_err());
1862 assert!(standard_normal_quantile(1.1).is_err());
1863 assert!(standard_normal_quantile(f64::NAN).is_err());
1864 }
1865
1866 #[test]
1867 fn quantile_at_half_is_near_zero() {
1868 let q = standard_normal_quantile(0.5).unwrap();
1869 assert!(q.abs() < 1e-10, "quantile(0.5)={q}");
1870 }
1871
1872 #[test]
1873 fn quantile_at_0975_is_near_196() {
1874 let q = standard_normal_quantile(0.975).unwrap();
1875 assert!((q - 1.959_963_984_540_054).abs() < 1e-14, "q={q}");
1876 }
1877
1878 /// `standard_normal_quantile` and its log-CDF sibling, against a 120-digit
1879 /// root of `Φ(x) = p` (respectively `ln Φ(x) = log_p`).
1880 ///
1881 /// The seed is Acklam's rational approximation, whose accuracy is `1.15e-9`
1882 /// relative; the two Halley steps after it are what make the result
1883 /// ulp-accurate. Deleting the polish loop entirely leaves EVERY other
1884 /// quantile test in this module green except `quantile_roundtrip_cdf`, and
1885 /// that one only by a factor of 1.9 — so the polish had no real gate. This
1886 /// table is that gate: it fails by six orders if the seed ships unpolished.
1887 ///
1888 /// The grid straddles Acklam's own `P_LOW = 0.02425` branch on both sides,
1889 /// runs out to `p = 1e-300` where the seed is far from the root, and covers
1890 /// the reflected upper tail where the residual must be formed from
1891 /// `(1 − p) − ½erfc(x/√2)` rather than `Φ(x) − p`.
1892 #[test]
1893 fn normal_quantiles_match_independent_high_precision_reference() {
1894 const QUANTILE_REFERENCE: [[f64; 2]; 22] = [
1895 [1e-300, -37.0470962993612],
1896 [1e-100, -21.273453560965326],
1897 [1e-20, -9.262340089798407],
1898 [1e-08, -5.612001244174789],
1899 [0.001, -3.0902323061678136],
1900 [0.02424, -1.9731366119445441],
1901 [0.02425, -1.972961051311885],
1902 [0.02426, -1.9727855514678605],
1903 [0.05, -1.6448536269514726],
1904 [0.1, -1.2815515655446004],
1905 [0.25, -0.6744897501960817],
1906 [0.4, -0.2533471031357997],
1907 [0.5, 0.0],
1908 [0.6, 0.2533471031357997],
1909 [0.75, 0.6744897501960817],
1910 [0.9, 1.2815515655446006],
1911 [0.95, 1.6448536269514722],
1912 [0.975, 1.9599639845400538],
1913 [0.99, 2.3263478740408408],
1914 [0.999, 3.090232306167813],
1915 [0.99999999, 5.612001243305505],
1916 [0.9999999999999999, 8.209536151601387],
1917 ];
1918 for [p, want] in QUANTILE_REFERENCE {
1919 let got = standard_normal_quantile(p).expect("p in (0,1) has a quantile");
1920 let error = (got - want).abs();
1921 // `Φ⁻¹(½) = 0` exactly, so it is the one absolute comparison.
1922 let budget = if want == 0.0 {
1923 1e-16
1924 } else {
1925 4e-15 * want.abs()
1926 };
1927 assert!(
1928 error <= budget,
1929 "Φ⁻¹({p}): got {got:.17e}, want {want:.17e} (error {error:.3e} > {budget:.3e})"
1930 );
1931 }
1932
1933 const LOG_CDF_QUANTILE_REFERENCE: [[f64; 2]; 9] = [
1934 [-0.7, -0.008559478582480282],
1935 [-2.0, -1.1015196284987503],
1936 [-10.0, -3.913946240531893],
1937 [-50.0, -9.674825283612357],
1938 [-200.0, -19.803669380301212],
1939 [-1000.0, -44.6157477319694],
1940 [-10000.0, -141.37983987312717],
1941 [-100000.0, -447.1978936785251],
1942 [-1000000.0, -1414.2077829910174],
1943 ];
1944 for [log_p, want] in LOG_CDF_QUANTILE_REFERENCE {
1945 let got =
1946 standard_normal_quantile_from_log_cdf(log_p).expect("finite log_p < 0 has a root");
1947 let error = (got - want).abs();
1948 // Rounding `log_p` itself to `f64` already moves the root by
1949 // `ulp(log_p)·dx/d(log_p)`, and `dx/d(log_p) = Φ/φ = 1/λ` — about
1950 // `1.25` near `p = ½` and `≈ 1/|x|` in the deep tail. That input
1951 // conditioning, not the solver, is what limits `log_p = −0.7`,
1952 // where the root sits at `−0.00856` and one ulp of `0.7` is already
1953 // `1.4e-16` of it.
1954 let conditioning = 8.0 * f64::EPSILON * log_p.abs() / want.abs().max(0.8);
1955 let budget = 4e-15 * want.abs() + conditioning;
1956 assert!(
1957 error <= budget,
1958 "Φ⁻¹(exp({log_p})): got {got:.17e}, want {want:.17e} \
1959 (error {error:.3e} > {budget:.3e})"
1960 );
1961 }
1962 }
1963
1964 #[test]
1965 fn quantile_antisymmetry() {
1966 let q_lo = standard_normal_quantile(0.1).unwrap();
1967 let q_hi = standard_normal_quantile(0.9).unwrap();
1968 assert!((q_lo + q_hi).abs() < 1e-10, "q_lo={q_lo} q_hi={q_hi}");
1969 }
1970
1971 #[test]
1972 fn quantile_roundtrip_cdf() {
1973 for &p in &[
1974 0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 0.999,
1975 ] {
1976 let q = standard_normal_quantile(p).unwrap();
1977 let p_back = normal_cdf(q);
1978 // RELATIVE, and sized by what the round trip can cost: a few ulp of
1979 // `q` propagated through `φ(q)`, plus a couple of ulp from `erfc`
1980 // itself. The former absolute `1e-10` bar was two orders looser than
1981 // an unpolished Acklam seed at its worst point.
1982 assert!(
1983 (p_back - p).abs() <= 1e-14 * p,
1984 "roundtrip failed at p={p}: q={q} p_back={p_back}"
1985 );
1986 }
1987 }
1988}