gam_math/probability.rs
1use libm::{erf, erfc};
2use statrs::function::{
3 beta::{beta_reg, inv_beta_reg, ln_beta},
4 gamma::gamma_ur,
5};
6
7const INV_SQRT_PI: f64 = 0.564_189_583_547_756_3;
8const SQRT_2_OVER_PI: f64 = 0.797_884_560_802_865_4;
9
10/// Quantile (inverse CDF) of a Beta distribution with shape parameters `a > 0`
11/// and `b > 0` at probability `p`: the value `x in [0, 1]` with
12/// `I_x(a, b) = p`, where `I` is the regularized incomplete beta.
13///
14/// `p <= 0` maps to the support floor and `p >= 1` to the support ceiling. A
15/// non-finite or non-positive shape yields `NaN`.
16pub fn beta_quantile(p: f64, a: f64, b: f64) -> f64 {
17 if !(a.is_finite() && a > 0.0 && b.is_finite() && b > 0.0) {
18 return f64::NAN;
19 }
20 if !p.is_finite() || p <= 0.0 {
21 return 0.0;
22 }
23 if p >= 1.0 {
24 return 1.0;
25 }
26 match lower_tail_beta_quantile(p, a, b) {
27 Some(x) => x,
28 None => inv_beta_reg(a, b, p),
29 }
30}
31
32/// `Beta⁻¹(p; a, b)` on the branch where the answer is small enough for the
33/// ascending series to be exact, or `None` when it is not.
34///
35/// `inv_beta_reg` converges on an ABSOLUTE tolerance in `x`, so it cannot
36/// resolve a quantile below about `1e-16`: it stalls and returns a number in
37/// the `1e-17..1e-19` band unrelated to the answer. That band is not exotic —
38/// it is the ordinary lower tail of a beta-regression predictive interval
39/// whenever the mean is small. For `Beta(0.04, 3.96)` at `p = 0.025`, the
40/// shapes a mean of `0.01` with a fifth of the Bernoulli variance produces, it
41/// returned `6.7e-18` where the truth is `1.5e-41` (#2528).
42///
43/// The lower tail has a convergent ascending series,
44///
45/// ```text
46/// I_x(a,b) = x^a / B(a,b) · S(x), S(x) = Σ_{k≥0} c_k x^k,
47/// c_k = (1−b)_k / (k!·(a+k)), c_0 = 1/a
48/// ```
49///
50/// whose leading term inverts in closed form to
51/// `x₀ = exp([ln p + ln a + ln B(a,b)] / a)`. Refining it in `y = ln x` rather
52/// than in `x` is what removes the floor: the answer's own variable becomes the
53/// iteration variable, so an absolute step tolerance in `y` is a RELATIVE
54/// tolerance in `x` and there is nothing to stall against. The iteration is
55/// also better conditioned than the one it replaces —
56/// `G(y) = ln I_{e^y}(a,b) − ln p` has `G′(y) = a + x·S′(x)/S(x) → a`, a
57/// constant, where the `x`-space derivative `∂I/∂x` spans hundreds of orders
58/// over the same range.
59///
60/// Underflow then becomes something the function can state rather than paper
61/// over: a true quantile below `f64::MIN_POSITIVE` reaches `exp(y) = 0`, which
62/// is the correctly rounded answer, instead of a spurious positive floor a
63/// caller cannot distinguish from a resolved bound.
64///
65/// The branch condition is `x·max(1, b) ≤ ½`, which is derived rather than
66/// tuned. The term ratio is `|x·(k+1−b)/(k+1)|·(a+k)/(a+k+1)`, and
67/// `|k+1−b| ≤ (k+1)·max(1, b)` for every `k ≥ 0`, so the condition bounds every
68/// ratio by `½` and the series reaches `f64` resolution in at most
69/// [`BETA_SERIES_MAX_TERMS`] terms. It is the same boundary, for the same
70/// reason, that `crates/gam-terms/src/basis/polylog.rs` uses for its own
71/// ascending series.
72fn lower_tail_beta_quantile(p: f64, a: f64, b: f64) -> Option<f64> {
73 let ln_b = ln_beta(a, b);
74 if !ln_b.is_finite() {
75 return None;
76 }
77 // Leading-order inverse: `I_x ≈ x^a / (a·B(a,b))` as `x → 0`.
78 let mut y = (p.ln() + a.ln() + ln_b) / a;
79 if !y.is_finite() {
80 return None;
81 }
82 // Reject before iterating if the seed is outside the series branch. The
83 // seed underestimates `x` for `b < 1` and overestimates it for `b > 1`, by
84 // a factor that is itself `1 + O(x)`, so a seed comfortably inside the
85 // branch keeps every iterate inside it.
86 let ratio_bound = (0.5_f64).ln() - b.max(1.0).ln();
87 if !(y <= ratio_bound) {
88 return None;
89 }
90 let ln_p = p.ln();
91 for _ in 0..BETA_NEWTON_MAX_STEPS {
92 let x = y.exp();
93 if x * b.max(1.0) > 0.5 {
94 return None;
95 }
96 let (sum, derivative_sum) = beta_ascending_series(x, a, b)?;
97 if !(sum.is_finite() && sum > 0.0 && derivative_sum.is_finite()) {
98 return None;
99 }
100 // `G(y) = a·y − ln B(a,b) + ln S(e^y) − ln p`.
101 let g = a * y - ln_b + sum.ln() - ln_p;
102 let g_prime = a + x * derivative_sum / sum;
103 if !(g.is_finite() && g_prime.is_finite() && g_prime > 0.0) {
104 return None;
105 }
106 let step = g / g_prime;
107 if !step.is_finite() {
108 return None;
109 }
110 y -= step;
111 // Absolute in `y` is relative in `x`, which is the whole point.
112 if step.abs() <= f64::EPSILON * y.abs().max(1.0) {
113 break;
114 }
115 }
116 let x = y.exp();
117 if x.is_finite() && (0.0..=1.0).contains(&x) {
118 Some(x)
119 } else {
120 None
121 }
122}
123
124/// `(S(x), S′(x))` for `S(x) = Σ_{k≥0} (1−b)_k · x^k / (k!·(a+k))`.
125///
126/// Accumulated by the ratio `t_{k+1} = t_k·(k+1−b)/(k+1)` on the Pochhammer
127/// factor, so no factorial or gamma is formed. `None` if the guard term count
128/// is exhausted, which the caller's branch condition makes unreachable.
129fn beta_ascending_series(x: f64, a: f64, b: f64) -> Option<(f64, f64)> {
130 let mut pochhammer_over_factorial = 1.0_f64;
131 let mut power = 1.0_f64;
132 let mut sum = 1.0 / a;
133 let mut derivative_sum = 0.0_f64;
134 for k in 1..=BETA_SERIES_MAX_TERMS {
135 let kf = k as f64;
136 pochhammer_over_factorial *= (kf - b) / kf;
137 let coefficient = pochhammer_over_factorial / (a + kf);
138 // `power` holds `x^{k-1}` here, which is what `S′` wants.
139 derivative_sum += kf * coefficient * power;
140 power *= x;
141 let term = coefficient * power;
142 sum += term;
143 if term.abs() <= f64::EPSILON * sum.abs() {
144 return Some((sum, derivative_sum));
145 }
146 }
147 None
148}
149
150/// `I_x(a,b)` from `ln(x)`, retaining a representable result when `x` itself
151/// underflows.
152///
153/// The ordinary `beta_reg(a,b,x)` interface necessarily loses every result
154/// whose beta argument is below the smallest subnormal, even when the
155/// regularized integral is much larger because `a < 1`. On the derived
156/// ascending-series branch, `x` appears only in the well-scaled correction
157/// `S(x)` while its leading power stays in log space:
158///
159/// `ln I_x(a,b) = a·ln(x) − ln B(a,b) + ln S(x)`.
160///
161/// The same term-ratio proof used by [`lower_tail_beta_quantile`] supplies the
162/// branch boundary. Outside that boundary, the ordinary regularized-beta
163/// implementation receives a representable argument and remains the canonical
164/// general evaluator.
165fn regularized_beta_lower_from_log_x(log_x: f64, a: f64, b: f64) -> f64 {
166 if !(a.is_finite() && a > 0.0 && b.is_finite() && b > 0.0) || log_x.is_nan() || log_x > 0.0 {
167 return f64::NAN;
168 }
169 if log_x == 0.0 {
170 return 1.0;
171 }
172 if log_x == f64::NEG_INFINITY {
173 return 0.0;
174 }
175
176 let series_limit = (0.5_f64).ln() - b.max(1.0).ln();
177 if log_x <= series_limit {
178 let x = log_x.exp();
179 let Some((sum, _)) = beta_ascending_series(x, a, b) else {
180 return f64::NAN;
181 };
182 let log_beta = ln_beta(a, b);
183 if !(sum.is_finite() && sum > 0.0 && log_beta.is_finite()) {
184 return f64::NAN;
185 }
186 return (a * log_x - log_beta + sum.ln()).exp();
187 }
188
189 beta_reg(a, b, log_x.exp())
190}
191
192/// `ln(1 / (1 + exp(log_ratio)))` without overflowing or rounding a
193/// representable small unit fraction to zero.
194#[inline]
195fn log_reciprocal_one_plus_exp(log_ratio: f64) -> f64 {
196 if log_ratio <= 0.0 {
197 -log_ratio.exp().ln_1p()
198 } else {
199 -log_ratio - (-log_ratio).exp().ln_1p()
200 }
201}
202
203/// Guard term count for [`beta_ascending_series`]. The caller's `x·max(1,b) ≤ ½`
204/// branch bounds every term ratio by `½`, so the series reaches one ulp of an
205/// `O(1/a)` partial sum in at most `53` terms; this is the non-convergence
206/// guard, not the expected count.
207const BETA_SERIES_MAX_TERMS: usize = 128;
208
209/// Guard step count for the log-space Newton. From a seed whose relative error
210/// is `O(x)` the iteration is quadratic, so it converges in two or three steps
211/// over the whole branch; this is the non-convergence guard.
212const BETA_NEWTON_MAX_STEPS: usize = 32;
213
214/// The part of `x·x` that `f64` cannot hold: `x² = x*x + square_residual(x)`,
215/// exactly, for every `x` whose square neither overflows nor goes subnormal.
216///
217/// This exists because of what `exp` does to a squared argument. Rounding
218/// `x*x` perturbs it by at most `ulp(x²)/2` — a RELATIVE perturbation of
219/// `ε/2`, which is unremarkable on its own. But `exp` converts a relative
220/// perturbation `δ` of its ARGUMENT into a relative perturbation `x²·δ` of
221/// its RESULT, so `exp(x*x)` carries `x²·ε/2` relative error: `3.7e-14` at
222/// `x = 26`, and `7.7e-14` at the `x ≈ 37` where `φ(x)` finally underflows.
223/// That is two orders worse than the `exp` evaluation's own rounding, and it
224/// is the error `erfcx` and `normal_pdf` were both actually delivering.
225///
226/// The residual is the whole of that discarded term and is itself exactly
227/// representable (Dekker's two-product theorem, in its one-FMA form), so
228/// `exp(x²) = exp(x*x)·exp(residual)` and `exp(residual) = 1 + residual` to
229/// `O(residual²)` — below `1e-27` over the entire domain either caller uses.
230/// One multiply by `1 + residual` therefore buys back every digit, and the
231/// callers below apply it fused so the correction itself costs one more
232/// rounding and nothing else.
233///
234/// `mul_add` is a single instruction wherever FMA is in the baseline ISA
235/// (aarch64, and x86-64 built with `+fma`); on a baseline x86-64 build it is
236/// a `glibc` call, measured at ~2.5 ns. Against `erfcx`'s 38 ns that is 9%;
237/// against `normal_pdf`'s 6.2 ns it is 40% of a function that is nowhere the
238/// bottleneck of a row loop that also assembles a design row and a Hessian
239/// block. Both callers guard the pathological arguments BEFORE calling this,
240/// so it never has to defend `±∞` (whose residual would be `NaN`).
241#[inline]
242fn square_residual(x: f64, rounded_square: f64) -> f64 {
243 x.mul_add(x, -rounded_square)
244}
245
246/// Standard normal PDF phi(x).
247///
248/// The squared argument is carried exactly (see `square_residual`); without
249/// that, `exp(-½·fl(x*x))` degrades like `x²·ε/2` and reaches `5.7e-14`
250/// relative before `φ` underflows, against the `3.3e-16` it holds with.
251#[inline]
252pub fn normal_pdf(x: f64) -> f64 {
253 const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
254 let rounded_square = x * x;
255 let head = INV_SQRT_2PI * (-0.5 * rounded_square).exp();
256 if head == 0.0 || head.is_nan() {
257 // The pdf underflowed or `x` was `±∞` (head `0`), or `x` was `NaN`.
258 // Neither admits a relative correction, and `±∞` would feed the
259 // residual an `∞ − ∞`; return the limit the plain form gives.
260 return head;
261 }
262 let residual = square_residual(x, rounded_square);
263 head.mul_add(-0.5 * residual, head)
264}
265
266/// Standard normal CDF Phi(x) evaluated via the exact special-function identity
267///
268/// Phi(x) = 0.5 * erfc(-x / sqrt(2)).
269///
270/// This is the exact Gaussian CDF semantics used throughout the codebase. The
271/// numerical `erfc` implementation may use internal approximations, but the
272/// returned function is the standard normal CDF itself rather than a separate
273/// polynomial surrogate surface.
274#[inline]
275pub fn normal_cdf(x: f64) -> f64 {
276 0.5 * erfc(-x / std::f64::consts::SQRT_2)
277}
278
279/// Two-sided standard-normal probability `P(|Z| ≥ |z|)`.
280///
281/// The exact symmetric identity is `erfc(|z|/√2)`. Evaluating that identity
282/// directly avoids both the cancellation in `2·(1 − Φ(|z|))` and an
283/// unnecessary rounding from multiplying a one-sided tail by two.
284#[inline]
285pub fn normal_two_sided_probability(z: f64) -> f64 {
286 erfc(z.abs() / std::f64::consts::SQRT_2)
287}
288
289/// Two-sided Student-t probability `P(|T_ν| ≥ |t|)`.
290///
291/// For finite `ν > 0`,
292///
293/// `P(|T_ν| ≥ |t|) = I_x(ν/2, 1/2)`, `x = ν / (ν + t²)`.
294///
295/// Neither `t²` nor `x` is formed directly. Their ratio is carried as
296/// `ln(t²/ν)`, and the regularized beta receives `ln(x)`. This matters beyond
297/// avoiding overflow: for `ν = 1` and `t = f64::MAX`, `x` underflows to zero
298/// although the Cauchy tail is still a representable subnormal. The log-beta
299/// series preserves that probability. Invalid degrees of freedom produce
300/// `NaN`; infinite statistics map to the exact limiting probability zero.
301pub fn student_t_two_sided_probability(t: f64, degrees_of_freedom: f64) -> f64 {
302 let half_df = 0.5 * degrees_of_freedom;
303 if t.is_nan() || !(degrees_of_freedom.is_finite() && degrees_of_freedom > 0.0 && half_df > 0.0)
304 {
305 return f64::NAN;
306 }
307 if t.is_infinite() {
308 return 0.0;
309 }
310
311 let log_t_squared_over_df = 2.0 * t.abs().ln() - degrees_of_freedom.ln();
312 let log_x = log_reciprocal_one_plus_exp(log_t_squared_over_df);
313 regularized_beta_lower_from_log_x(log_x, half_df, 0.5)
314}
315
316/// Chi-squared survival probability `P(X_ν > statistic)`.
317///
318/// Uses the regularized upper incomplete gamma directly instead of
319/// reconstructing a small tail as `1 − P(ν/2, statistic/2)`.
320pub fn chi_square_sf(statistic: f64, degrees_of_freedom: f64) -> f64 {
321 let half_df = 0.5 * degrees_of_freedom;
322 if statistic.is_nan()
323 || statistic < 0.0
324 || !(degrees_of_freedom.is_finite() && degrees_of_freedom > 0.0 && half_df > 0.0)
325 {
326 return f64::NAN;
327 }
328 if statistic == 0.0 {
329 return 1.0;
330 }
331 if statistic == f64::INFINITY {
332 return 0.0;
333 }
334 gamma_ur(half_df, 0.5 * statistic)
335}
336
337/// One `λ_j · χ²_{h_j}` term of a linear combination of independent
338/// chi-squares, with the weight's SIGN and the term's degrees of freedom both
339/// carried explicitly.
340///
341/// Two things separate this from the `&[f64]` weight list
342/// `weighted_chi_square_sf` takes, and each of them is a distribution the
343/// one-degree-of-freedom non-negative form cannot express:
344///
345/// * **A negative weight makes a RATIO a tail.** `P(A/B > t)` for independent
346/// non-negative `A`, `B` is `P(A − tB > 0)`, so every F-shaped reference —
347/// any statistic whose scale was estimated from the same data — is a
348/// *signed* combination evaluated at zero. The classical `F_{a,b}` is the
349/// two-term case `λ = (1, −t·a/b)`, `h = (a, b)`.
350/// * **A multiplicity is not `h` copies of a weight.** It is, mathematically,
351/// but the Imhof integrand costs one `atan` and one `ln` per TERM, and a
352/// residual sum of squares carries `n − p` unit weights. Folding them into
353/// one term with `h = n − p` is what makes an `n`-sized reference cost the
354/// same as a `p`-sized one.
355#[derive(Clone, Copy, Debug, PartialEq)]
356pub struct WeightedChiSquareTerm {
357 /// `λ_j`, of either sign. A zero weight contributes nothing and is dropped.
358 pub weight: f64,
359 /// `h_j > 0`. Real rather than integral: a two-moment summary of a spectrum
360 /// is a chi-square with a fractional shape, and this type is what carries it.
361 pub degrees_of_freedom: f64,
362}
363
364/// `signed_weighted_chi_square_sf` at a caller-chosen absolute accuracy,
365/// returning the bound actually achieved alongside the value.
366///
367/// # Method
368///
369/// Imhof's (1961) inversion in its general central form, of which the
370/// non-negative unit-`h` case documented on `weighted_chi_square_sf` is the
371/// specialization:
372///
373/// ```text
374/// P(Q > x) = 1/2 + (1/π) ∫_0^∞ sin θ(u) / (u ρ(u)) du,
375/// θ(u) = ½ Σ_j h_j arctan(λ_j u) − ½ x u,
376/// ρ(u) = Π_j (1 + λ_j² u²)^{h_j/4}.
377/// ```
378///
379/// Nothing in the derivation asks `λ_j > 0` — `arctan` is odd and `λ²` is even,
380/// so a negative weight simply turns its part of the phase the other way.
381///
382/// # Two truncation bounds, because one of them stops working at `x = 0`
383///
384/// The oscillatory bound `16/(x·U·ρ(U))` documented on
385/// `weighted_chi_square_sf` divides by `x`, and the ratio references this
386/// signed form exists for are evaluated at exactly `x = 0`, where the phase
387/// stops turning at all: `θ(u) → (π/4)·Σ_j h_j·sgn(λ_j)`, a constant. There is
388/// no oscillation left to cancel, so the alternating-series argument yields
389/// nothing.
390///
391/// What replaces it is the AMPLITUDE, which the same `x = 0` makes strong
392/// rather than weak. For `u ≥ U` and `t = u/U ≥ 1`,
393/// `(1 + λ²u²)/(1 + λ²U²) ≥ (1 + t²)/2 ≥ t` on every term ACTIVE at `U`
394/// (`|λ_j|·U ≥ 1`) and `≥ 1` on the rest, so `ρ(u) ≥ ρ(U)·t^{H/4}` with
395/// `H = Σ_{active} h_j` and
396///
397/// ```text
398/// |tail(U)| ≤ ∫_U^∞ du/(u ρ(u)) ≤ 4 / (H · ρ(U)).
399/// ```
400///
401/// This is a bound on the answer, not a guess about it, and it is the CHEAP
402/// one exactly where the oscillatory bound is unavailable: a ratio reference
403/// carries the residual `χ²_{n−p}`, so `H` is of order `n` and `ρ` grows like
404/// `U^{n/2}` — a handful of panels. Both bounds are evaluated and the smaller
405/// is taken, which also strictly improves the non-negative case at small `x`,
406/// where `16/(x·U·ρ)` is what used to make the sweep long.
407///
408/// # Phase monotonicity, generalized
409///
410/// The oscillatory bound is valid only past the point where `|θ′| ≥ x/4`. With
411/// mixed signs `φ′(u) = ½ Σ_j h_j λ_j/(1 + λ_j²u²)` is no longer monotone in
412/// `u`, so the test is applied to `½ Σ_j h_j |λ_j|/(1 + λ_j²u²)` — an upper
413/// bound on `|φ′|` that IS decreasing, hence a condition at `U` that holds for
414/// every `u ≥ U`. On non-negative weights the two expressions coincide.
415///
416/// # Exact special cases
417///
418/// * no nonzero weight — `Q ≡ 0`;
419/// * all weights positive and `x ≤ 0`, or all negative and `x ≥ 0` — the
420/// inequality is decided by the support;
421/// * all weights bit-identical — `Q = λ·χ²_{Σh}` exactly, on either sign.
422///
423/// Returns `NaN` if any weight is non-finite, if any degrees-of-freedom is not
424/// finite and positive, or if `statistic` is `NaN`.
425pub fn signed_weighted_chi_square_sf_to_tolerance(
426 terms: &[WeightedChiSquareTerm],
427 statistic: f64,
428 absolute_tolerance: f64,
429) -> (f64, f64) {
430 let tolerance = if absolute_tolerance.is_finite() && absolute_tolerance > 0.0 {
431 absolute_tolerance
432 } else {
433 WEIGHTED_CHI_SQUARE_TOLERANCE
434 };
435 if statistic.is_nan() {
436 return (f64::NAN, f64::NAN);
437 }
438 let mut active = Vec::with_capacity(terms.len());
439 for term in terms {
440 if !term.weight.is_finite()
441 || !(term.degrees_of_freedom.is_finite() && term.degrees_of_freedom > 0.0)
442 {
443 return (f64::NAN, f64::NAN);
444 }
445 if term.weight != 0.0 {
446 active.push(*term);
447 }
448 }
449 if active.is_empty() {
450 // `Q` is identically zero: it exceeds a negative threshold with
451 // certainty and a non-negative one never.
452 return (if statistic < 0.0 { 1.0 } else { 0.0 }, 0.0);
453 }
454 let all_positive = active.iter().all(|term| term.weight > 0.0);
455 let all_negative = active.iter().all(|term| term.weight < 0.0);
456 if all_positive && statistic <= 0.0 {
457 // `Q > 0` almost surely once one weight is positive.
458 return (1.0, 0.0);
459 }
460 if all_negative && statistic >= 0.0 {
461 // `Q < 0` almost surely once every weight is negative.
462 return (0.0, 0.0);
463 }
464 let first = active[0].weight;
465 if active.iter().all(|term| term.weight == first) {
466 let total_df: f64 = active.iter().map(|term| term.degrees_of_freedom).sum();
467 // `P(λ·χ² > x)` is the χ² upper tail at `x/λ` for `λ > 0` and the LOWER
468 // tail there for `λ < 0`, because dividing by a negative number turns
469 // the inequality around.
470 let scaled = statistic / first;
471 let tail = if first > 0.0 {
472 chi_square_sf(scaled, total_df)
473 } else {
474 1.0 - chi_square_sf(scaled, total_df)
475 };
476 return (tail, 0.0);
477 }
478 imhof_survival(&active, statistic, tolerance)
479}
480
481/// Default absolute accuracy `weighted_chi_square_sf` certifies on its Imhof
482/// truncation. It is four orders below the smallest probability any consumer
483/// of a survival function resolves in practice and eleven below one, so the
484/// truncation is never the term that limits a reported tail.
485pub const WEIGHTED_CHI_SQUARE_TOLERANCE: f64 = 1e-11;
486
487/// Gauss-Legendre nodes and weights on `[-1, 1]`, 16 points. A 16-node rule is
488/// exact through degree 31, which is far beyond the smooth amplitude
489/// `1/(u ρ(u))` over one phase period; the panel width, not the node count, is
490/// what resolves the oscillation.
491const GAUSS_LEGENDRE_16: [(f64, f64); 8] = [
492 (0.095_012_509_837_637_44, 0.189_450_610_455_068_64),
493 (0.281_603_550_779_258_9, 0.182_603_415_044_923_64),
494 (0.458_016_777_657_227_37, 0.169_156_519_395_002_65),
495 (0.617_876_244_402_643_8, 0.149_595_988_816_576_7),
496 (0.755_404_408_355_003, 0.124_628_971_255_534_07),
497 (0.865_631_202_387_831_8, 0.095_158_511_682_492_6),
498 (0.944_575_023_073_232_6, 0.062_253_523_938_647_456),
499 (0.989_400_934_991_649_9, 0.027_152_459_411_754_176),
500];
501
502/// Imhof's integrand `sin θ(u) / (u ρ(u))` with the `u → 0` limit folded in.
503#[inline]
504fn imhof_integrand(terms: &[WeightedChiSquareTerm], statistic: f64, u: f64) -> f64 {
505 if u == 0.0 {
506 let mean: f64 = terms
507 .iter()
508 .map(|term| term.weight * term.degrees_of_freedom)
509 .sum();
510 return 0.5 * (mean - statistic);
511 }
512 let mut phase = -0.5 * statistic * u;
513 let mut log_rho = 0.0;
514 for term in terms {
515 let wu = term.weight * u;
516 phase += 0.5 * term.degrees_of_freedom * wu.atan();
517 log_rho += 0.25 * term.degrees_of_freedom * wu.mul_add(wu, 1.0).ln();
518 }
519 phase.sin() / (u * log_rho.exp())
520}
521
522/// `ln ρ(u)`, the Imhof amplitude exponent.
523#[inline]
524fn imhof_log_rho(terms: &[WeightedChiSquareTerm], u: f64) -> f64 {
525 terms
526 .iter()
527 .map(|term| {
528 let wu = term.weight * u;
529 0.25 * term.degrees_of_freedom * wu.mul_add(wu, 1.0).ln()
530 })
531 .sum()
532}
533
534/// `½ Σ_j h_j|w_j|/(1 + w_j²u²)`, a DECREASING upper bound on the magnitude of
535/// the non-linear part of the phase's own derivative.
536///
537/// The oscillatory truncation bound is valid only past the point where the
538/// phase is monotone with `|θ'| ≥ x/4`, which needs `|φ'(u)| ≤ x/4` for every
539/// `u` past the truncation point rather than at it. With mixed-sign weights
540/// `φ'` is not monotone, so the test is applied to this bound instead; on
541/// non-negative weights the two are the same expression.
542#[inline]
543fn imhof_phase_slack(terms: &[WeightedChiSquareTerm], u: f64) -> f64 {
544 terms
545 .iter()
546 .map(|term| {
547 let wu = term.weight * u;
548 0.5 * term.degrees_of_freedom * term.weight.abs() / wu.mul_add(wu, 1.0)
549 })
550 .sum()
551}
552
553/// `4/(H·ρ(U))`, the AMPLITUDE truncation bound, with
554/// `H = Σ_{|w_j|U ≥ 1} h_j` the degrees of freedom already active at `U`.
555///
556/// Valid unconditionally — it bounds `∫_U^∞ du/(u ρ(u))` and never looks at the
557/// phase — and it is the only bound available at `statistic = 0`, where the
558/// oscillatory one divides by zero. `None` when nothing is active yet, since
559/// `ρ` is then still flat and there is no decay to integrate against.
560/// Panel width that resolves the integrand's AMPLITUDE, as opposed to its
561/// phase.
562///
563/// The phase rule below sizes a panel so it sweeps at most one oscillation.
564/// That is necessary and it is not sufficient: `1/(u ρ(u))` has structure of
565/// its own, on the scale `1/|λ|` where `(1 + λ²u²)^{h/4}` turns over, and a
566/// panel far wider than that scale is a 16-node rule aliasing a factor it never
567/// sampled. The two rules coincide only when the phase happens to turn at the
568/// same rate the amplitude does — which is exactly what fails when the phase
569/// rate is small: a ratio reference is evaluated at `statistic = 0`, and a
570/// two-term `F`-shaped combination can have `Σ h_j|λ_j|` of order one while
571/// `max_j|λ_j|` is also of order one, so `4π/Σ h|λ| ≈ 12` against an amplitude
572/// scale of `1`. Measured on `F_{1,5}` at `f = 0.05`: the phase-only panel
573/// returned `0.8319119` against the exact `0.8319122`, an error of `3.4e-7`
574/// certified at `1e-11`.
575///
576/// The scale is not a guess. As a function of complex `u` the integrand's
577/// nearest singularities are the branch points of `(1 + λ_j²u²)^{h_j/4}` at
578/// `u = ±i/|λ_j|`; the closest is `d = 1/max_j|λ_j|`, and the `−xu/2` phase and
579/// the `1/u` are entire and removable respectively. Gauss–Legendre with `N`
580/// nodes on a panel of half-width `a` converges like `ϱ^{-2N}` in the Bernstein
581/// parameter of the largest ellipse the integrand is analytic in, and an
582/// ellipse with semi-minor axis `d` has `ϱ` solving `(ϱ − 1/ϱ)/2 = d/a`. So
583/// asking `ϱ^{-2N} ≤ tolerance` fixes the half-width:
584///
585/// ```text
586/// ϱ = tolerance^{-1/2N}, a = d / [(ϱ − 1/ϱ)/2].
587/// ```
588///
589/// This is a RATE, not a certificate: the Bernstein bound also carries the
590/// integrand's maximum modulus on that ellipse, which the ellipse touching the
591/// branch point does not bound. The node count is what carries the margin, and
592/// the margin is MEASURED rather than asserted —
593/// `the_quadrature_resolves_the_amplitude_not_only_the_phase` compares against
594/// a reference at a far finer panel and reads the achieved error off it.
595///
596/// A looser request buys a wider panel here, which is the right direction: the
597/// consumer that derives its tolerance from the resolution of the statistic it
598/// is scoring pays for what it asked for.
599#[inline]
600fn imhof_amplitude_panel(max_abs_weight: f64, tolerance: f64) -> f64 {
601 let node_count = 2.0 * GAUSS_LEGENDRE_16.len() as f64;
602 let bernstein = tolerance.recip().powf(0.5 / node_count);
603 let semi_minor_ratio = 0.5 * (bernstein - bernstein.recip());
604 if !(semi_minor_ratio > 0.0 && max_abs_weight > 0.0) {
605 return f64::INFINITY;
606 }
607 2.0 / (max_abs_weight * semi_minor_ratio)
608}
609
610#[inline]
611fn imhof_amplitude_bound(terms: &[WeightedChiSquareTerm], u: f64) -> Option<f64> {
612 let active_df: f64 = terms
613 .iter()
614 .filter(|term| term.weight.abs() * u >= 1.0)
615 .map(|term| term.degrees_of_freedom)
616 .sum();
617 (active_df > 0.0).then(|| 4.0 / (active_df * imhof_log_rho(terms, u).exp()))
618}
619
620/// Cost backstop on the Imhof panel sweep.
621///
622/// The truncation point `U` needed for a given bound scales as
623/// `(16/(x·tol·C))^{2/(2+m)}` in the number `m` of weights that are *active*
624/// (`w_j U ≳ 1`) there, and the panel count as `U·x/4π`. With three or more
625/// comparable weights that count stays in the thousands for any statistic a
626/// likelihood-ratio consumer produces, so this backstop is unreachable — it
627/// exists for the one degenerate corner where it is not: two weights spread
628/// over several orders of magnitude, with a large statistic, where the sweep
629/// would otherwise run for tens of millions of panels to buy digits far below
630/// the modelling error of any statistic being referenced against it. The
631/// achieved bound is returned rather than discarded, so a caller that lands in
632/// that corner can see it instead of inferring it.
633///
634/// The panel width is the smaller of the phase rule and
635/// `imhof_amplitude_panel`, so the count above is a LOWER bound on what the
636/// sweep costs. It moves the corner slightly closer without changing which
637/// corner it is: the amplitude panel is `2/(|λ|_max·s(tol))`, independent of
638/// the statistic, so it binds where the phase rate is small — and a small phase
639/// rate is a small truncation point, which is the cheap end.
640pub const IMHOF_MAX_PANELS: usize = 1 << 21;
641
642fn imhof_survival(terms: &[WeightedChiSquareTerm], statistic: f64, tolerance: f64) -> (f64, f64) {
643 // A panel has to resolve the WHOLE phase, not just the `−xu/2` half. The
644 // total phase rate is bounded by `|θ'(u)| = |φ'(u) − x/2| ≤ (Σ h_j|w_j| +
645 // |x|)/2` — `|φ'|` is largest at the origin, where it is `½ Σ h_j|w_j|` —
646 // so a panel of `4π/(|x| + Σ h_j|w_j|)` sweeps at most one full oscillation
647 // anywhere on the half-line. Sizing on `4π/x` alone is correct only in the
648 // tail: at a small statistic that panel is enormous while the arctan part
649 // of the phase still turns over on the scale `1/w_j`, and the 16-node rule
650 // then aliases it (measured: a monotonicity violation of ~1e-5 at
651 // `x ≈ 4e-4`). At `x = 0` — the ratio references — the arctan part is the
652 // ONLY phase there is, and sizing on it is what keeps the rule honest.
653 let rate: f64 = terms
654 .iter()
655 .map(|term| term.degrees_of_freedom * term.weight.abs())
656 .sum();
657 let phase_panel = 4.0 * std::f64::consts::PI / (statistic.abs() + rate);
658 // ...and it has to resolve the AMPLITUDE as well; see
659 // `imhof_amplitude_panel` for why the phase rule alone is not enough and
660 // where the second scale comes from.
661 let max_abs_weight = terms
662 .iter()
663 .map(|term| term.weight.abs())
664 .fold(0.0_f64, f64::max);
665 let panel = phase_panel.min(imhof_amplitude_panel(max_abs_weight, tolerance));
666 let mut integral = 0.0_f64;
667 let mut lower = 0.0_f64;
668 let mut bound = f64::INFINITY;
669 for _ in 0..IMHOF_MAX_PANELS {
670 let upper = lower + panel;
671 let half = 0.5 * (upper - lower);
672 let mid = 0.5 * (upper + lower);
673 let mut panel_value = 0.0;
674 for &(node, weight) in &GAUSS_LEGENDRE_16 {
675 let offset = half * node;
676 panel_value += weight
677 * (imhof_integrand(terms, statistic, mid + offset)
678 + imhof_integrand(terms, statistic, mid - offset));
679 }
680 integral += half * panel_value;
681 lower = upper;
682 // The amplitude bound holds unconditionally; the oscillatory one only
683 // once the phase is monotone, and only for a positive statistic.
684 // Whichever is available and smaller is the certified accuracy.
685 bound = imhof_amplitude_bound(terms, lower).unwrap_or(f64::INFINITY);
686 if statistic > 0.0 && imhof_phase_slack(terms, lower) <= 0.25 * statistic {
687 let oscillatory = 16.0 / (statistic * lower * imhof_log_rho(terms, lower).exp());
688 bound = bound.min(oscillatory);
689 }
690 if bound <= tolerance {
691 break;
692 }
693 }
694 (
695 (0.5 + integral / std::f64::consts::PI).clamp(0.0, 1.0),
696 bound,
697 )
698}
699
700/// Fisher-Snedecor survival probability `P(F_{d1,d2} > statistic)`.
701///
702/// The complementary regularized-beta identity is evaluated directly:
703///
704/// `I_x(d2/2, d1/2)`, `x = d2 / (d2 + d1·statistic)`.
705///
706/// The beta argument is derived in log space, so neither `d1·statistic` nor
707/// the denominator can overflow before a representable tail is recovered.
708pub fn fisher_snedecor_sf(
709 statistic: f64,
710 numerator_degrees_of_freedom: f64,
711 denominator_degrees_of_freedom: f64,
712) -> f64 {
713 let beta_a = 0.5 * denominator_degrees_of_freedom;
714 let beta_b = 0.5 * numerator_degrees_of_freedom;
715 if statistic.is_nan()
716 || statistic < 0.0
717 || !(numerator_degrees_of_freedom.is_finite()
718 && numerator_degrees_of_freedom > 0.0
719 && denominator_degrees_of_freedom.is_finite()
720 && denominator_degrees_of_freedom > 0.0
721 && beta_a > 0.0
722 && beta_b > 0.0)
723 {
724 return f64::NAN;
725 }
726 if statistic == 0.0 {
727 return 1.0;
728 }
729 if statistic == f64::INFINITY {
730 return 0.0;
731 }
732
733 let log_ratio =
734 numerator_degrees_of_freedom.ln() + statistic.ln() - denominator_degrees_of_freedom.ln();
735 let log_x = log_reciprocal_one_plus_exp(log_ratio);
736 regularized_beta_lower_from_log_x(log_x, beta_a, beta_b)
737}
738
739/// Scaled complementary error function `erfcx(x) = exp(x²) · erfc(x)`,
740/// specialized to the closed domain `x ∈ [0, +∞]`.
741///
742/// `+∞` maps to the exact limiting value `0`; `NaN` and negative inputs map to
743/// `NaN` because they violate this restricted kernel's domain. For
744/// `0 ≤ x < 26` the direct `exp(x²)·erfc(x)` form is finite. Beyond that point
745/// a six-correction asymptotic expansion avoids overflow while retaining the
746/// representable subnormal tail. At the switch, the first omitted term is
747/// below `2e-17` relative to the leading term.
748///
749/// The direct branch carries `x²` exactly (see `square_residual`). Without
750/// that correction the branch degraded like `x²·ε/2` — `1.4e-14` at `x = 10`,
751/// `5.7e-14` by the top of its range — while the asymptotic branch that takes
752/// over at `26` was already delivering `3e-16`. The seam was therefore a
753/// 190-fold step DOWN in error at the point where the code switches to what
754/// reads like the fallback, and the whole `[0, 26)` interval, where every
755/// probit / Mills / log-CDF consumer actually lives, was the inaccurate side.
756/// Both branches now hold `< 5e-16`, so the crossover is invisible.
757#[inline]
758pub fn erfcx_nonnegative(x: f64) -> f64 {
759 if x.is_nan() || x < 0.0 {
760 return f64::NAN;
761 }
762 if x == f64::INFINITY {
763 return 0.0;
764 }
765 if x < 26.0 {
766 // `x` is finite and in `[0, 26)`, so the square is exact-splittable and
767 // `head` is finite and strictly positive (`erfc(26⁻) ≈ 1e-295`).
768 let rounded_square = x * x;
769 let head = rounded_square.exp() * erfc(x);
770 head.mul_add(square_residual(x, rounded_square), head)
771 } else {
772 let inv = 1.0 / x;
773 let inv2 = inv * inv;
774 // erfcx(x) ~ 1/(sqrt(pi)x) * sum_n (-1)^n (2n-1)!!/(2x^2)^n.
775 // Horner form keeps the correction well scaled when `inv2` is tiny.
776 let poly = 1.0
777 + inv2
778 * (-0.5
779 + inv2
780 * (0.75
781 + inv2
782 * (-1.875
783 + inv2 * (6.5625 + inv2 * (-29.53125 + inv2 * 162.421875)))));
784 inv * poly * INV_SQRT_PI
785 }
786}
787
788/// Computes `log(1 - exp(-a))` for `a >= 0` without cancellation.
789#[inline]
790pub fn log1mexp_positive(a: f64) -> f64 {
791 assert!(a >= 0.0, "log1mexp_positive requires a >= 0: a={a}");
792 if a == f64::INFINITY {
793 // `e^{-∞}` is an exact zero, so the result is an exact (positive) zero
794 // rather than the `-0.0` that `ln_1p(-0.0)` would return.
795 return 0.0;
796 }
797 if a > core::f64::consts::LN_2 {
798 (-(-a).exp()).ln_1p()
799 } else if a > 0.0 {
800 (-(-a).exp_m1()).ln()
801 } else {
802 f64::NEG_INFINITY
803 }
804}
805
806// A finite binary64 is an integer multiple of 2^-1074. Its largest possible
807// significand occupies bits 2045..=2097 on that lattice. Thirty-three limbs
808// leave 14 carry bits, enough to sum at most 2^14-1 finite inputs exactly.
809const EXACT_BINARY64_SUM_WORDS: usize = 33;
810const EXACT_BINARY64_SUM_MAX_TERMS: usize = (1 << 14) - 1;
811const _: () = assert!(EXACT_BINARY64_SUM_WORDS * 64 == 2112);
812
813/// Why [`exact_binary64_sum_sign`] could not classify its finite exact sum.
814#[derive(Clone, Copy, Debug, Eq, PartialEq)]
815pub enum ExactBinary64SumSignError {
816 /// One input was not a finite binary64.
817 NonFiniteTerm { index: usize },
818 /// The fixed exact accumulator's structural term bound was exceeded.
819 TermCapacityExceeded { maximum: usize },
820}
821
822impl std::fmt::Display for ExactBinary64SumSignError {
823 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
824 match self {
825 Self::NonFiniteTerm { index } => {
826 write!(formatter, "exact binary64 sum term {index} is not finite")
827 }
828 Self::TermCapacityExceeded { maximum } => write!(
829 formatter,
830 "exact binary64 sum exceeds its structural {maximum}-term capacity"
831 ),
832 }
833 }
834}
835
836impl std::error::Error for ExactBinary64SumSignError {}
837
838/// Exact sign of a finite binary64 sum, independent of order and cancellation.
839///
840/// Every input is decoded as an integer significand on the common `2^-1074`
841/// lattice. Positive and negative magnitudes accumulate into separate fixed
842/// 2,112-bit unsigned integers; comparing those integers returns the sign of
843/// the exact real sum, with no floating-point reduction and no tolerance.
844///
845/// At most 16,383 terms are admitted, the largest count whose worst-case carry
846/// is structurally contained by the fixed accumulator.
847pub fn exact_binary64_sum_sign(
848 values: impl IntoIterator<Item = f64>,
849) -> Result<std::cmp::Ordering, ExactBinary64SumSignError> {
850 fn add_magnitude(
851 accumulator: &mut [u64; EXACT_BINARY64_SUM_WORDS],
852 value: f64,
853 ) -> Result<(), ExactBinary64SumSignError> {
854 let magnitude_bits = value.to_bits() & !(1_u64 << 63);
855 let exponent_bits = ((magnitude_bits >> 52) & 0x7ff) as usize;
856 let fraction = magnitude_bits & ((1_u64 << 52) - 1);
857 let (significand, shift) = if exponent_bits == 0 {
858 (fraction, 0usize)
859 } else {
860 ((1_u64 << 52) | fraction, exponent_bits - 1)
861 };
862 if significand == 0 {
863 return Ok(());
864 }
865
866 let mut word = shift / 64;
867 let offset = shift % 64;
868 let (low_sum, low_carry) = accumulator[word].overflowing_add(significand << offset);
869 accumulator[word] = low_sum;
870 word += 1;
871
872 let high = if offset == 0 {
873 0
874 } else {
875 significand >> (64 - offset)
876 };
877 let (high_sum, high_carry) = accumulator[word].overflowing_add(high);
878 let (high_sum, carry_carry) = high_sum.overflowing_add(u64::from(low_carry));
879 accumulator[word] = high_sum;
880 let mut carry = high_carry || carry_carry;
881 word += 1;
882 while carry {
883 if word == EXACT_BINARY64_SUM_WORDS {
884 return Err(ExactBinary64SumSignError::TermCapacityExceeded {
885 maximum: EXACT_BINARY64_SUM_MAX_TERMS,
886 });
887 }
888 let (sum, next_carry) = accumulator[word].overflowing_add(1);
889 accumulator[word] = sum;
890 carry = next_carry;
891 word += 1;
892 }
893 Ok(())
894 }
895
896 let mut positive = [0_u64; EXACT_BINARY64_SUM_WORDS];
897 let mut negative = [0_u64; EXACT_BINARY64_SUM_WORDS];
898 for (index, value) in values.into_iter().enumerate() {
899 if index == EXACT_BINARY64_SUM_MAX_TERMS {
900 return Err(ExactBinary64SumSignError::TermCapacityExceeded {
901 maximum: EXACT_BINARY64_SUM_MAX_TERMS,
902 });
903 }
904 if !value.is_finite() {
905 return Err(ExactBinary64SumSignError::NonFiniteTerm { index });
906 }
907 let target = if value.is_sign_negative() {
908 &mut negative
909 } else {
910 &mut positive
911 };
912 add_magnitude(target, value)?;
913 }
914 for index in (0..EXACT_BINARY64_SUM_WORDS).rev() {
915 match positive[index].cmp(&negative[index]) {
916 std::cmp::Ordering::Less => return Ok(std::cmp::Ordering::Less),
917 std::cmp::Ordering::Greater => return Ok(std::cmp::Ordering::Greater),
918 std::cmp::Ordering::Equal => {}
919 }
920 }
921 Ok(std::cmp::Ordering::Equal)
922}
923
924/// Numerically stable signed log-sum-exp. Given pairs
925/// `(log|aⱼ|, sign(aⱼ))` (with `signs[j] ∈ {−1, 0, +1}`), returns
926/// `(log|S|, sign(S))` for `S = Σⱼ signs[j]·exp(log_mags[j])`. Positive
927/// and negative magnitudes are first reduced together, after one common
928/// log-space rescaling, with a twofold compensated sum. This avoids rounding
929/// each same-sign subtotal through `ln` and `exp` before subtracting them — an
930/// avoidable loss that is amplified in cancellation-conditioned derivative
931/// cumulants. If the compensated residual lies inside its forward-error bound,
932/// the function instead uses the two-subtotal log-domain difference
933/// `log(|p − n|) = max(log p, log n) +
934/// log1mexp(|log p − log n|)`. That branch retains differences between two input
935/// logs even when their exponentials round to the same `f64`. When all signs are
936/// zero or all magnitudes are `−∞`, returns `(NEG_INFINITY, 0.0)`.
937///
938/// A `+∞` log-magnitude denotes an infinite-magnitude term (`exp(+∞) = +∞`)
939/// and dominates the sum: if it appears only with positive sign the result
940/// is `(+∞, +1)`; only with negative sign, `(+∞, −1)` (a log-magnitude of
941/// `+∞` with sign `−1` encodes the value `−∞`); with both signs the sum is
942/// the indeterminate `+∞ − ∞`, returned as `(NaN, 0.0)`. A `−∞`
943/// log-magnitude is `exp(−∞) = 0` and is correctly dropped.
944pub fn signed_log_sum_exp(log_mags: &[f64], signs: &[f64]) -> (f64, f64) {
945 // Infinite-magnitude terms dominate any finite contribution, so resolve
946 // them before the finite log-sum-exp reduction below. `−∞` log-magnitudes
947 // are `exp(−∞) = 0` and need no special handling.
948 let mut has_pos_inf = false;
949 let mut has_neg_inf = false;
950 for (idx, &lm) in log_mags.iter().enumerate() {
951 if lm == f64::INFINITY {
952 if signs[idx] > 0.0 {
953 has_pos_inf = true;
954 } else if signs[idx] < 0.0 {
955 has_neg_inf = true;
956 }
957 }
958 }
959 match (has_pos_inf, has_neg_inf) {
960 // P = +∞, N = +∞ ⇒ indeterminate +∞ − ∞.
961 (true, true) => return (f64::NAN, 0.0),
962 // P = +∞, N < ∞ ⇒ S = +∞.
963 (true, false) => return (f64::INFINITY, 1.0),
964 // N = +∞, P < ∞ ⇒ S = −∞, encoded as log-magnitude +∞ with sign −1.
965 (false, true) => return (f64::INFINITY, -1.0),
966 (false, false) => {}
967 }
968
969 let mut pos_max = f64::NEG_INFINITY;
970 let mut neg_max = f64::NEG_INFINITY;
971 for (idx, &lm) in log_mags.iter().enumerate() {
972 if signs[idx] > 0.0 {
973 pos_max = pos_max.max(lm);
974 } else if signs[idx] < 0.0 {
975 neg_max = neg_max.max(lm);
976 }
977 }
978
979 if pos_max == f64::NEG_INFINITY && neg_max == f64::NEG_INFINITY {
980 // Both partial sums are empty: no terms at all, all signs zero, or every
981 // magnitude `−∞` (each `exp(−∞) = 0`). The signed sum is exactly `0`.
982 return (f64::NEG_INFINITY, 0.0);
983 }
984
985 // First reduce the signed terms directly after one common scaling. `head`
986 // plus `tail` is a twofold sum: TwoSum recovers every addition's exact
987 // residual, so cancellation does not discard the low part of either
988 // same-sign subtotal before the final subtraction.
989 let common_max = pos_max.max(neg_max);
990 let mut signed_head = 0.0_f64;
991 let mut signed_tail = 0.0_f64;
992 let mut absolute_scaled_sum = 0.0_f64;
993 let mut finite_term_count = 0usize;
994 for (idx, &lm) in log_mags.iter().enumerate() {
995 if !lm.is_finite() || !(signs[idx] > 0.0 || signs[idx] < 0.0) {
996 continue;
997 }
998 let magnitude = (lm - common_max).exp();
999 let term = if signs[idx] > 0.0 {
1000 magnitude
1001 } else {
1002 -magnitude
1003 };
1004 let combined = signed_head + term;
1005 let shifted = combined - signed_head;
1006 let residual = (signed_head - (combined - shifted)) + (term - shifted);
1007 signed_head = combined;
1008 signed_tail += residual;
1009 absolute_scaled_sum += magnitude;
1010 finite_term_count += 1;
1011 }
1012 let signed_scaled_sum = signed_head + signed_tail;
1013
1014 // Each scaled exponential and each accumulated residual contributes at most
1015 // one working-precision rounding. This conservative Wilkinson-style bound
1016 // decides from the operation count, rather than from a fitted threshold,
1017 // whether the linear-domain residual has a trustworthy sign and magnitude.
1018 // Below the bound, retain the input-log separation in the log-domain branch.
1019 let direct_error_bound = (finite_term_count as f64 + 2.0) * f64::EPSILON * absolute_scaled_sum;
1020 if signed_scaled_sum.abs() > direct_error_bound {
1021 return (
1022 common_max + signed_scaled_sum.abs().ln(),
1023 signed_scaled_sum.signum(),
1024 );
1025 }
1026
1027 // When exponentiation itself cannot resolve the signed residual, reduce
1028 // positive and negative groups separately in log space. Their internal sums
1029 // are still twofold-compensated before taking the logarithm.
1030 let mut pos_sum = 0.0_f64;
1031 let mut pos_tail = 0.0_f64;
1032 let mut neg_sum = 0.0_f64;
1033 let mut neg_tail = 0.0_f64;
1034 for (idx, &lm) in log_mags.iter().enumerate() {
1035 if !lm.is_finite() {
1036 continue;
1037 }
1038 if signs[idx] > 0.0 {
1039 let term = (lm - pos_max).exp();
1040 let combined = pos_sum + term;
1041 let shifted = combined - pos_sum;
1042 pos_tail += (pos_sum - (combined - shifted)) + (term - shifted);
1043 pos_sum = combined;
1044 } else if signs[idx] < 0.0 {
1045 let term = (lm - neg_max).exp();
1046 let combined = neg_sum + term;
1047 let shifted = combined - neg_sum;
1048 neg_tail += (neg_sum - (combined - shifted)) + (term - shifted);
1049 neg_sum = combined;
1050 }
1051 }
1052 pos_sum += pos_tail;
1053 neg_sum += neg_tail;
1054
1055 let log_pos = if pos_sum > 0.0 {
1056 pos_max + pos_sum.ln()
1057 } else {
1058 f64::NEG_INFINITY
1059 };
1060 let log_neg = if neg_sum > 0.0 {
1061 neg_max + neg_sum.ln()
1062 } else {
1063 f64::NEG_INFINITY
1064 };
1065
1066 if log_neg == f64::NEG_INFINITY {
1067 return (log_pos, 1.0);
1068 }
1069 if log_pos == f64::NEG_INFINITY {
1070 return (log_neg, -1.0);
1071 }
1072 if log_pos > log_neg {
1073 let gap = log_pos - log_neg;
1074 (log_pos + log1mexp_positive(gap), 1.0)
1075 } else if log_neg > log_pos {
1076 let gap = log_neg - log_pos;
1077 (log_neg + log1mexp_positive(gap), -1.0)
1078 } else {
1079 (f64::NEG_INFINITY, 0.0)
1080 }
1081}
1082
1083/// Numerically stable `ln Φ(x)` for the standard normal CDF. For `x ≥ 0`,
1084/// evaluates `ln(1 - 0.5 erfc(x/sqrt(2)))` with `ln_1p`, retaining the small
1085/// negative result after `Φ(x)` itself rounds to one. For `x < 0`, rewrites
1086/// `ln Φ(x) = −u² + ln(½·erfcx(u))`, `u = −x/√2`,
1087/// which preserves digits throughout the representable left tail without a
1088/// probability floor. Returns the corresponding IEEE limit at infinities and
1089/// propagates `NaN`.
1090#[inline]
1091pub fn normal_logcdf(x: f64) -> f64 {
1092 if x == f64::INFINITY {
1093 return 0.0;
1094 }
1095 if x == f64::NEG_INFINITY {
1096 return f64::NEG_INFINITY;
1097 }
1098 if x.is_nan() {
1099 return f64::NAN;
1100 }
1101 if x < 0.0 {
1102 let (u, scaled_tail) = negative_normal_tail_components(x);
1103 negative_normal_logcdf_from_scaled_tail(u, scaled_tail)
1104 } else {
1105 let upper_tail = 0.5 * erfc(x / std::f64::consts::SQRT_2);
1106 (-upper_tail).ln_1p()
1107 }
1108}
1109
1110/// Numerically stable `ln(1 − Φ(x)) = ln Φ(−x)` for the standard normal
1111/// survival function. Delegates to `normal_logcdf(-x)` so the deep-right
1112/// tail benefits from the same `erfcx`-based representation.
1113#[inline]
1114pub fn normal_logsf(x: f64) -> f64 {
1115 normal_logcdf(-x)
1116}
1117
1118/// Joint evaluation of `ln Φ(x)` and the Mills-ratio analogue
1119/// `φ(x) / Φ(x)`, signed for the symmetric branch. Used by the latent
1120/// probit families where the inverse-link gradient needs the ratio and
1121/// the likelihood needs the log-CDF on the same `x`; computing both in
1122/// one call shares the `erfcx` evaluation that dominates the cost in the
1123/// deep tail.
1124#[inline]
1125pub fn signed_probit_logcdf_and_mills_ratio(x: f64) -> (f64, f64) {
1126 if x == f64::INFINITY {
1127 return (0.0, 0.0);
1128 }
1129 if x == f64::NEG_INFINITY {
1130 return (f64::NEG_INFINITY, f64::INFINITY);
1131 }
1132 if x.is_nan() {
1133 return (f64::NAN, f64::NAN);
1134 }
1135 if x < 0.0 {
1136 let (u, scaled_tail) = negative_normal_tail_components(x);
1137 (
1138 negative_normal_logcdf_from_scaled_tail(u, scaled_tail),
1139 SQRT_2_OVER_PI / scaled_tail,
1140 )
1141 } else {
1142 let upper_tail = 0.5 * erfc(x / std::f64::consts::SQRT_2);
1143 let cdf = 1.0 - upper_tail;
1144 let lambda = normal_pdf(x) / cdf;
1145 ((-upper_tail).ln_1p(), lambda)
1146 }
1147}
1148
1149#[inline]
1150fn negative_normal_tail_components(x: f64) -> (f64, f64) {
1151 assert!(x.is_finite() && x < 0.0);
1152 let u = -x / std::f64::consts::SQRT_2;
1153 (u, erfcx_nonnegative(u))
1154}
1155
1156#[inline]
1157fn negative_normal_logcdf_from_scaled_tail(u: f64, scaled_tail: f64) -> f64 {
1158 -u * u + scaled_tail.ln() - std::f64::consts::LN_2
1159}
1160
1161/// Stable value and first four derivatives of `ln Φ(x)`.
1162///
1163/// The moderate regime uses the exact Mills-ratio recurrence, with the brackets
1164/// collected in `q = λ + x` once `x < 0` so that they do not cancel as `λ`
1165/// closes on `−x`. In the deep left tail, differentiating the Laplace continued
1166/// fraction
1167///
1168/// `φ(t)/Φ(-t) = t + 1/(t + 2/(t + 3/(...)))`, `t = -x`,
1169///
1170/// carries the small correction to `t` independently, so `f'' -> -1` and the
1171/// higher derivatives approach zero without subtracting nearly equal `f64`s.
1172/// In the right tail, signed log-magnitude sums preserve polynomially weighted
1173/// derivatives even when `φ(x)/Φ(x)` itself has rounded to zero.
1174#[inline]
1175pub fn normal_logcdf_derivatives(x: f64) -> [f64; 5] {
1176 if x.is_nan() {
1177 return [f64::NAN; 5];
1178 }
1179 if x == f64::INFINITY {
1180 return [0.0; 5];
1181 }
1182 if x == f64::NEG_INFINITY {
1183 return [f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0, 0.0];
1184 }
1185
1186 const RIGHT_LOG_MAGNITUDE_SWITCH: f64 = 8.0;
1187 if x <= LEFT_CONTINUED_FRACTION_SWITCH {
1188 return normal_logcdf_derivatives_left_tail(x);
1189 }
1190 if x >= RIGHT_LOG_MAGNITUDE_SWITCH {
1191 return normal_logcdf_derivatives_right_tail(x);
1192 }
1193
1194 let (log_cdf, lambda) = signed_probit_logcdf_and_mills_ratio(x);
1195 let x2 = x * x;
1196 if x < 0.0 {
1197 // Left of the origin the brackets below are collected in the SAME Mills
1198 // correction `q = λ + x` the continued-fraction branch carries, because
1199 // written in `λ` they cancel catastrophically long before the branch
1200 // ends. `λ(x) → −x` as `x → −∞`, so every term of, say,
1201 // `(x³−3x) + (7x²−4)λ + 12xλ² + 6λ³` grows like `|x|³` while their sum
1202 // decays: at `x = −4` they are `−52`, `456`, `−857`, `453` and add to
1203 // `−0.0023`, a cancellation of 380000 that costs eleven digits. In `q`
1204 // the same bracket is `−6q³ + 6xq² + (4−x²)q − x`, whose terms are
1205 // `−0.069`, `−1.22`, `−2.71`, `4` — a cancellation of 1847, three
1206 // orders milder. The reformulation is exact (`λ = q − x` substituted and
1207 // re-collected), costs the same flops, and buys 16–34x across the whole
1208 // branch: worst over `x ∈ [−4, 0]` falls from `4.5e−11` to `2.8e−12`.
1209 //
1210 // `q` itself is safe to form here: `λ/2 ≤ |x| ≤ 2λ` holds over most of
1211 // the range, so `λ + x` is EXACT by Sterbenz, and where it is not (`x`
1212 // near 0) `q` is the same size as `λ` and nothing cancels. That is the
1213 // whole reason the rewrite works — it moves the cancellation out of the
1214 // brackets and into a subtraction that has none.
1215 //
1216 // Past the origin `q → x` is no longer small, the `λ` form has nothing
1217 // to cancel (`λ → 0` and `x² − 1` dominates), and it is the more
1218 // accurate of the two — hence the sign test rather than a blanket swap.
1219 let q = lambda + x;
1220 let q2 = q * q;
1221 return [
1222 log_cdf,
1223 lambda,
1224 -lambda * q,
1225 lambda * (2.0 * q2 - x * q - 1.0),
1226 lambda * (-6.0 * q2 * q + 6.0 * x * q2 + (4.0 - x2) * q - x),
1227 ];
1228 }
1229 let lambda2 = lambda * lambda;
1230 let lambda3 = lambda2 * lambda;
1231 [
1232 log_cdf,
1233 lambda,
1234 -lambda * (x + lambda),
1235 lambda * (x2 - 1.0 + 3.0 * x * lambda + 2.0 * lambda2),
1236 -lambda
1237 * ((x * x2 - 3.0 * x) + (7.0 * x2 - 4.0) * lambda + 12.0 * x * lambda2 + 6.0 * lambda3),
1238 ]
1239}
1240
1241#[derive(Clone, Copy)]
1242struct MillsCorrectionDerivatives {
1243 value: f64,
1244 first: f64,
1245 second: f64,
1246 third: f64,
1247}
1248
1249/// `x` at or below which the left-tail Mills ratio is taken from the Laplace
1250/// continued fraction rather than from `erfcx`. Equivalently `t = −x ≥ 4`.
1251const LEFT_CONTINUED_FRACTION_SWITCH: f64 = -4.0;
1252
1253/// The Laplace continued-fraction **correction** to the left-tail Mills ratio,
1254///
1255/// `q(t) = λ(−t) − t = 1/(t + 2/(t + 3/(...)))`, `λ(x) = φ(x)/Φ(x)`,
1256///
1257/// together with its first three derivatives in `t`. Requires `t ≥ 4`.
1258///
1259/// `q` is the whole content of the left tail that is NOT the leading `t`: it
1260/// decays like `1/t − 2/t³ + 10/t⁵ − ...`, and every operation building it is
1261/// a division or an addition of positive quantities, so it carries full
1262/// relative precision no matter how small it gets. That is the property its
1263/// two consumers need, and it is why the correction is returned separately
1264/// instead of pre-added to `t`:
1265///
1266/// * [`normal_logcdf_derivatives_left_tail`] needs `f'' = −(1 + q')` and the
1267/// higher derivatives, which tend to `−1` and `0` and would be destroyed by
1268/// differencing nearly equal `f64`s.
1269/// * [`cone_boundary_log_factor_and_derivatives`] needs `∂corr/∂a = b − q(t)`,
1270/// which is the same statement one substitution away (#2306 §4).
1271///
1272/// Recovering `q` from a separately computed `λ` — `q = λ − t` — is exactly the
1273/// cancellation this exists to avoid, and it is not a small effect: at `t = 1e8`
1274/// it costs every significant digit, and past `t ≈ 2e8` it returns the wrong
1275/// SIGN. The reference itself has to be carried at ~120 decimal digits before it
1276/// reproduces what this recursion gives in binary64.
1277#[inline]
1278fn mills_correction_continued_fraction(t: f64) -> MillsCorrectionDerivatives {
1279 assert!(t.is_finite() && t >= 4.0);
1280 let mut q = MillsCorrectionDerivatives {
1281 value: 0.0,
1282 first: 0.0,
1283 second: 0.0,
1284 third: 0.0,
1285 };
1286 // The truncation error is damped by a product of the continued-fraction
1287 // sensitivities `n/(t + q)^2`, so the depth must be sized at `t = 4` — the
1288 // LEAST converged point of the domain, and the one the log-CDF branch sits
1289 // exactly on. Each successive derivative converges roughly 15x slower than
1290 // the last, because differentiating the recursion multiplies each level's
1291 // contribution by another factor of that same sensitivity. Measured against
1292 // a 60-digit reference at `t = 4`:
1293 //
1294 // ```text
1295 // q q' q'' q'''
1296 // 32 1.9e-15 7.0e-14 1.4e-12 2.1e-11
1297 // 64 2.3e-23 1.4e-21 4.4e-20 1.0e-18
1298 // ```
1299 //
1300 // 32 levels is enough for the VALUE and nothing else: it leaves `q'''` — the
1301 // fourth log-CDF derivative — wrong in its eleventh digit. The depths that
1302 // first reach `1e-17` at `t = 4` are 41, 47, 53 and 60 for the four
1303 // channels, so 64 covers the worst of them with ~200x of margin, and the
1304 // requirement falls off fast enough (33 levels at `t = 6`, 24 at `t = 8`,
1305 // 12 at `t = 20`) that one constant sized for the edge is safe everywhere
1306 // above it. The extra levels are pure convergence — every step divides
1307 // positive quantities — so they cannot destabilise a large `t`.
1308 for n in (1..=64).rev() {
1309 let denominator = t + q.value;
1310 let inv_denominator = denominator.recip();
1311 let value = f64::from(n) / denominator;
1312 let denominator_first = 1.0 + q.first;
1313 let a = denominator_first * inv_denominator;
1314 let b = q.second * inv_denominator;
1315 let c = q.third * inv_denominator;
1316 q = MillsCorrectionDerivatives {
1317 value,
1318 first: -value * denominator_first / denominator,
1319 second: value * (2.0 * a * a - b),
1320 third: value * (-6.0 * a * a * a + 6.0 * a * b - c),
1321 };
1322 }
1323 q
1324}
1325
1326#[inline]
1327fn normal_logcdf_derivatives_left_tail(x: f64) -> [f64; 5] {
1328 assert!(x.is_finite() && x <= LEFT_CONTINUED_FRACTION_SWITCH);
1329 let t = -x;
1330 let q = mills_correction_continued_fraction(t);
1331 [
1332 normal_logcdf(x),
1333 t + q.value,
1334 -(1.0 + q.first),
1335 q.second,
1336 -q.third,
1337 ]
1338}
1339
1340#[inline]
1341fn normal_logcdf_derivatives_right_tail(x: f64) -> [f64; 5] {
1342 assert!(x.is_finite() && x >= 8.0);
1343 const LOG_SQRT_2PI: f64 = 0.918_938_533_204_672_7;
1344 let log_cdf = normal_logcdf(x);
1345 let u = x / std::f64::consts::SQRT_2;
1346 let log_lambda = -u * u - LOG_SQRT_2PI - log_cdf;
1347 let log_x = x.ln();
1348 let inv_x2 = x.recip() * x.recip();
1349
1350 let first = log_lambda.exp();
1351 let second = signed_exp_sum(&[log_x + log_lambda, 2.0 * log_lambda], &[-1.0, -1.0]);
1352 let third = signed_exp_sum(
1353 &[
1354 2.0 * log_x + (-inv_x2).ln_1p() + log_lambda,
1355 3.0_f64.ln() + log_x + 2.0 * log_lambda,
1356 2.0_f64.ln() + 3.0 * log_lambda,
1357 ],
1358 &[1.0, 1.0, 1.0],
1359 );
1360 let fourth = signed_exp_sum(
1361 &[
1362 3.0 * log_x + (-3.0 * inv_x2).ln_1p() + log_lambda,
1363 7.0_f64.ln() + 2.0 * log_x + (-(4.0 / 7.0) * inv_x2).ln_1p() + 2.0 * log_lambda,
1364 12.0_f64.ln() + log_x + 3.0 * log_lambda,
1365 6.0_f64.ln() + 4.0 * log_lambda,
1366 ],
1367 &[-1.0, -1.0, -1.0, -1.0],
1368 );
1369 [log_cdf, first, second, third, fourth]
1370}
1371
1372/// Value and five derivatives of `log Φ`, including the fifth-order information
1373/// drift needed by a Jeffreys-augmented outer Hessian. The existing order-four
1374/// hot path does not pay for the additional derivative.
1375pub fn normal_logcdf_derivatives_through_fifth(x: f64) -> [f64; 6] {
1376 let d = normal_logcdf_derivatives(x);
1377 let fifth = if x.is_nan() {
1378 f64::NAN
1379 } else if x.is_infinite() {
1380 0.0
1381 } else if x <= LEFT_CONTINUED_FRACTION_SWITCH {
1382 // Carry q'''' through the same positive Laplace continued fraction.
1383 // Differentiating λ=-x+q(-x) four times leaves q''''; recovering it
1384 // from λ's recurrence would cancel its entire deep-tail signal.
1385 let t = -x;
1386 let mut q = [0.0_f64; 5];
1387 // At the worst endpoint t=4 the same 64 levels as the order-four
1388 // path leave 1.94e-17 relative truncation error in q'''' (100-digit
1389 // reference); deeper in the tail convergence is faster.
1390 for n in (1..=64).rev() {
1391 let inverse = (t + q[0]).recip();
1392 let value = f64::from(n) * inverse;
1393 let a = (1.0 + q[1]) * inverse;
1394 let b = q[2] * inverse;
1395 let c = q[3] * inverse;
1396 let e = q[4] * inverse;
1397 q = [
1398 value,
1399 -value * a,
1400 value * (2.0 * a * a - b),
1401 value * (-6.0 * a * a * a + 6.0 * a * b - c),
1402 value * (24.0 * a.powi(4) - 36.0 * a * a * b + 6.0 * b * b + 8.0 * a * c - e),
1403 ];
1404 }
1405 q[4]
1406 } else if x >= 8.0 {
1407 // Hermite/Mills polynomial in signed log magnitude: the leading
1408 // x^4 φ(x) can still be representable when φ itself underflows.
1409 let log_lambda = -0.5 * x * x - 0.5 * (2.0 * std::f64::consts::PI).ln() - d[0];
1410 let log_x = x.ln();
1411 let inverse_x2 = x.recip().powi(2);
1412 signed_exp_sum(
1413 &[
1414 4.0 * log_x + (-6.0 * inverse_x2 + 3.0 * inverse_x2.powi(2)).ln_1p() + log_lambda,
1415 15.0_f64.ln()
1416 + 3.0 * log_x
1417 + (-(5.0 / 3.0) * inverse_x2).ln_1p()
1418 + 2.0 * log_lambda,
1419 50.0_f64.ln() + 2.0 * log_x + (-0.4 * inverse_x2).ln_1p() + 3.0 * log_lambda,
1420 60.0_f64.ln() + log_x + 4.0 * log_lambda,
1421 24.0_f64.ln() + 5.0 * log_lambda,
1422 ],
1423 &[1.0; 5],
1424 )
1425 } else {
1426 // λ'=-xλ-λ²; its third derivative gives λ'''' without a new
1427 // special-function evaluation.
1428 -(x + 2.0 * d[1]) * d[4] - 3.0 * d[3] - 6.0 * d[2] * d[3]
1429 };
1430 [d[0], d[1], d[2], d[3], d[4], fifth]
1431}
1432
1433#[inline]
1434fn signed_exp_sum(log_magnitudes: &[f64], signs: &[f64]) -> f64 {
1435 let (log_magnitude, sign) = signed_log_sum_exp(log_magnitudes, signs);
1436 if sign == 0.0 {
1437 0.0
1438 } else {
1439 sign * log_magnitude.exp()
1440 }
1441}
1442
1443#[inline]
1444fn acklam_lower_tail_quantile_from_log_probability(log_p: f64) -> f64 {
1445 const C: [f64; 6] = [
1446 -7.784_894_002_430_293e-3,
1447 -3.223_964_580_411_365e-1,
1448 -2.400_758_277_161_838,
1449 -2.549_732_539_343_734,
1450 4.374_664_141_464_968,
1451 2.938_163_982_698_783,
1452 ];
1453 const D: [f64; 4] = [
1454 7.784_695_709_041_462e-3,
1455 3.224_671_290_700_398e-1,
1456 2.445_134_137_142_996,
1457 3.754_408_661_907_416,
1458 ];
1459 let q = (-2.0 * log_p).sqrt();
1460 (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
1461 / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
1462}
1463
1464/// Standard normal quantile Φ⁻¹(p) using Acklam's rational approximation.
1465#[inline]
1466pub fn standard_normal_quantile(p: f64) -> Result<f64, String> {
1467 if !(p.is_finite() && p > 0.0 && p < 1.0) {
1468 return Err(format!("normal quantile requires p in (0,1), got {p}"));
1469 }
1470
1471 const A: [f64; 6] = [
1472 -3.969_683_028_665_376e1,
1473 2.209_460_984_245_205e2,
1474 -2.759_285_104_469_687e2,
1475 1.383_577_518_672_69e2,
1476 -3.066_479_806_614_716e1,
1477 2.506_628_277_459_239,
1478 ];
1479 const B: [f64; 5] = [
1480 -5.447_609_879_822_406e1,
1481 1.615_858_368_580_409e2,
1482 -1.556_989_798_598_866e2,
1483 6.680_131_188_771_972e1,
1484 -1.328_068_155_288_572e1,
1485 ];
1486 const P_LOW: f64 = 0.02425;
1487 const P_HIGH: f64 = 1.0 - P_LOW;
1488
1489 let mut x = if p < P_LOW {
1490 acklam_lower_tail_quantile_from_log_probability(p.ln())
1491 } else if p <= P_HIGH {
1492 let q = p - 0.5;
1493 let r = q * q;
1494 (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
1495 / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
1496 } else {
1497 -acklam_lower_tail_quantile_from_log_probability((1.0 - p).ln())
1498 };
1499 for _ in 0..2 {
1500 let density = normal_pdf(x);
1501 if !(density.is_finite() && density > 0.0) {
1502 break;
1503 }
1504 // Residual F(x) − p, formed without catastrophic cancellation in
1505 // either tail. For an upper-tail iterate `x > 0`, `normal_cdf(x)`
1506 // saturates to ~1, so the direct `normal_cdf(x) − p` annihilates the
1507 // tiny residual the polish must act on; instead use the upper-tail
1508 // complement `F(x) − p = (1 − p) − 0.5·erfc(x/√2)`, where both terms
1509 // are the small upper-tail quantities (`1 − p` is exact by Sterbenz
1510 // for `p ∈ [½,1)`). For `x ≤ 0`, `normal_cdf(x) = 0.5·erfc(|x|/√2)` is
1511 // itself the faithfully carried small lower-tail value, so the direct
1512 // form is already cancellation-free.
1513 let residual = if (0.25..=0.75).contains(&p) {
1514 // Central band. Both tail forms below subtract two quantities of
1515 // size ~½, so their difference carries an absolute error of one ulp
1516 // of ½ (1.1e-16) NO MATTER how small the true residual is. Since
1517 // `Δx ≈ residual_error / φ(x)`, the returned quantile then carries a
1518 // FIXED absolute error ~1.2e-16 and a relative error ~1.2e-16/|x|
1519 // that diverges as `p → ½`: measured 4.1e-14 at `p = 0.50125` and
1520 // 1.2e-03 at `p = ½ + 2.75e-14`, against ~2e-16 everywhere else in
1521 // this module. The polish cannot repair the seed there — the
1522 // residual it is handed is quantized to multiples of one ulp of ½
1523 // and is usually exactly 0, so the answer that ships is the raw
1524 // Acklam seed at its own 1.15e-9.
1525 //
1526 // Subtracting the ½ ANALYTICALLY removes it: `F(x) − p` is
1527 // `(F(x) − ½) − (p − ½)` = `½·erf(x/√2) − δ`, and both terms are now
1528 // of size |δ| with full RELATIVE accuracy — `erf` near 0 is `z·R(z²)`,
1529 // no cancellation — so the residual error is `ε·|δ|` and the relative
1530 // error in `x` is `ε` uniformly, including in the limit `x → 0`.
1531 //
1532 // The band is the exactness domain of `δ`, not a tuning choice:
1533 // Sterbenz's lemma makes `p − ½` exact for `p ∈ [¼, 1]`, and the
1534 // reflection `p ↦ 1 − p` maps that onto `[0, ¾]`, so `[¼, ¾]` is
1535 // where δ is exact on both sides. It is also where the centered form
1536 // is the better one: outside it `|x| > 0.6745` and the tail forms
1537 // carry relative accuracy in their own small quantity, which is what
1538 // the deep tails need. At the shared boundary the two agree to
1539 // within a factor of two, so nothing steps across the seam.
1540 0.5 * erf(x / std::f64::consts::SQRT_2) - (p - 0.5)
1541 } else if x > 0.0 {
1542 (1.0 - p) - 0.5 * erfc(x / std::f64::consts::SQRT_2)
1543 } else {
1544 normal_cdf(x) - p
1545 };
1546 let correction = residual / density;
1547 let denominator = 1.0 + 0.5 * x * correction;
1548 if !(correction.is_finite() && denominator.is_finite() && denominator != 0.0) {
1549 break;
1550 }
1551 let step = correction / denominator;
1552 if !step.is_finite() {
1553 break;
1554 }
1555 x -= step;
1556 if step.abs() <= 2.0 * f64::EPSILON * x.abs().max(1.0) {
1557 break;
1558 }
1559 }
1560 Ok(x)
1561}
1562
1563/// Standard normal quantile from `log_p = ln Φ(x)`.
1564///
1565/// Unlike [`standard_normal_quantile`], this remains defined when `Φ(x)` is
1566/// smaller than the least positive `f64`, and when `Φ(x)` is so close to one
1567/// that exponentiating `log_p` rounds to exactly one. Acklam's lower-tail
1568/// approximation supplies the initial point; Newton polishing solves
1569/// `ln Φ(x) = log_p` with the stable log-CDF and Mills ratio, so neither tail
1570/// forms a probability-space subtraction.
1571#[inline]
1572pub fn standard_normal_quantile_from_log_cdf(log_p: f64) -> Result<f64, String> {
1573 if !(log_p.is_finite() && log_p < 0.0) {
1574 return Err(format!(
1575 "normal log-quantile requires finite log_p < 0, got {log_p}"
1576 ));
1577 }
1578
1579 if log_p > -std::f64::consts::LN_2 {
1580 // Reflect through the upper tail without forming `1 - exp(log_p)`.
1581 let log_q = (-log_p.exp_m1()).ln();
1582 return standard_normal_quantile_from_log_cdf(log_q).map(|x| -x);
1583 }
1584
1585 let p = log_p.exp();
1586 let mut x = if p > 0.0 {
1587 standard_normal_quantile(p)?
1588 } else {
1589 acklam_lower_tail_quantile_from_log_probability(log_p)
1590 };
1591 for _ in 0..4 {
1592 let (current_log_p, mills_ratio) = signed_probit_logcdf_and_mills_ratio(x);
1593 if !(current_log_p.is_finite() && mills_ratio.is_finite() && mills_ratio > 0.0) {
1594 break;
1595 }
1596 let step = (current_log_p - log_p) / mills_ratio;
1597 if !step.is_finite() {
1598 break;
1599 }
1600 x -= step;
1601 if step.abs() <= 2.0 * f64::EPSILON * x.abs().max(1.0) {
1602 break;
1603 }
1604 }
1605 Ok(x)
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610 use super::*;
1611
1612 const TOL: f64 = 1e-12;
1613
1614 fn rel_err(got: f64, expected: f64) -> f64 {
1615 (got - expected).abs() / expected.abs().max(1e-300)
1616 }
1617
1618 #[test]
1619 fn student_t_primitives_keep_the_tail_that_one_minus_the_cdf_destroys() {
1620 // References are correctly rounded doubles from a 60-dps regularized
1621 // incomplete beta. The `nu = 10000, t = 10` row is here because a
1622 // plausible-looking hand-extrapolated literal for it (1.60e-23) is 20%
1623 // from the truth: this table has to come from the reference, not from
1624 // pattern-matching the rows above it.
1625 const ROWS: [(f64, f64, f64); 10] = [
1626 (5.0, 20.0, 2.887758186612086e-6),
1627 (5.0, 40.0, 9.205981085886477e-8),
1628 (30.0, 10.0, 2.2876257041148065e-11),
1629 (30.0, 20.0, 3.3745418328856434e-19),
1630 (30.0, 40.0, 6.863022597203202e-28),
1631 (500.0, 8.0, 4.3648313969400955e-15),
1632 (500.0, 10.0, 6.930246799119958e-22),
1633 (500.0, 20.0, 4.056001518093838e-66),
1634 (500.0, 40.0, 3.14532145912912e-158),
1635 (10000.0, 10.0, 9.816403714331914e-24),
1636 ];
1637 // Bar: 1e-11, a measured envelope rather than a derivation. Everything
1638 // below the incomplete beta is derivable -- the identity is exact and
1639 // forms no difference -- but `beta_reg` is statrs's continued fraction
1640 // and its error is a property of that implementation, so the honest
1641 // thing is to measure it and say so. Worst over this table by shape
1642 // parameter `a = nu/2`:
1643 //
1644 // a = 2.5 2e-15
1645 // a = 15 1.6e-13
1646 // a = 250 2.3e-13
1647 // a = 5000 2.0e-12
1648 //
1649 // It grows slowly with nu, which is what a continued fraction needing
1650 // more terms looks like, and it does *not* grow with tail depth -- the
1651 // nu = 500 rows sit at 2e-13 whether the answer is 1e-15 or 1e-158.
1652 // That is the distinction that matters: a fixed relative cost, not a
1653 // cancellation. Bar is 5x the worst measured.
1654 let bar = 1.0e-11;
1655 for (nu, t, want) in ROWS {
1656 let got = student_t_sf(t, nu);
1657 let rel = ((got - want) / want).abs();
1658 assert!(
1659 rel <= bar,
1660 "student_t_sf({t}, {nu}) = {got:e}, want {want:e}, relative {rel:e} > {bar:e}"
1661 );
1662 let got_two_sided = student_t_two_sided_probability(t, nu);
1663 let two_sided_rel = ((got_two_sided - 2.0 * want) / (2.0 * want)).abs();
1664 assert!(
1665 two_sided_rel <= bar,
1666 "student_t_two_sided_probability({t}, {nu}) = {got_two_sided:e}, \
1667 want {:e}, relative {two_sided_rel:e} > {bar:e}",
1668 2.0 * want
1669 );
1670 // The reflection. `1 - want` is O(1), so its own absolute error of
1671 // one ulp is a relative error of one ulp -- which is exactly why
1672 // reflecting is safe here and reconstructing the small tail is not.
1673 let lower = student_t_sf(-t, nu);
1674 assert!(
1675 (lower - (1.0 - want)).abs() <= 2.0 * f64::EPSILON,
1676 "student_t_sf({}, {nu}) = {lower}, want {}",
1677 -t,
1678 1.0 - want
1679 );
1680 }
1681 // Symmetry at the median, and the degenerate arguments.
1682 for nu in [1.0_f64, 5.0, 1e4] {
1683 assert!(
1684 (student_t_sf(0.0, nu) - 0.5).abs() <= f64::EPSILON,
1685 "median at nu = {nu}"
1686 );
1687 }
1688 assert!(student_t_sf(1.0, 0.0).is_nan(), "nu = 0 is not a t");
1689 assert!(
1690 student_t_sf(1.0, f64::INFINITY).is_nan(),
1691 "nu = inf is not a t"
1692 );
1693 assert_eq!(student_t_sf(f64::INFINITY, 5.0), 0.0, "tail beyond +inf");
1694 assert_eq!(
1695 student_t_sf(f64::NEG_INFINITY, 5.0),
1696 1.0,
1697 "tail beyond -inf"
1698 );
1699 }
1700
1701 #[test]
1702 fn normal_sf_keeps_the_upper_tail_that_one_minus_the_cdf_destroys() {
1703 // `Φ(x)` rounds to exactly 1.0 once its upper tail drops below half an
1704 // ulp of one, so `1 - normal_cdf(x)` returns exactly zero from x ~ 8.3 up
1705 // and is already 7% high at x = 8. `normal_sf` computes the tail rather
1706 // than reconstructing it. References are correctly rounded doubles from a
1707 // 60-dps `erfc(x/√2)/2`.
1708 //
1709 // Bar: `x * x * eps`, which is derived rather than chosen. Forming the
1710 // argument `u = x / √2` rounds it, a relative eps, i.e. an absolute
1711 // `u * eps`. The relative condition number of `erfc` at `u` is
1712 // `u * |erfc'(u)| / erfc(u)`, and since `erfc(u) ~ exp(-u^2) / (u√π)` for
1713 // large `u` that tends to `2u^2 = x^2`. So the returned tail inherits
1714 // `x^2 * eps` from the argument alone, before `erfc`'s own couple of ulp
1715 // -- 36 ulp at x = 6, 1370 ulp at x = 37. That is intrinsic to taking a
1716 // z score as the input: the tail is exponentially steep in `x`, so the
1717 // last bit of `x` is worth `x^2` bits of the tail. It is also
1718 // irrelevant next to what it replaces, which is a relative error of 1.
1719 const ROWS: [(f64, f64); 13] = [
1720 (0.5, 0.3085375387259869),
1721 (2.0, 0.02275013194817921),
1722 (4.0, 3.1671241833119924e-5),
1723 (5.0, 2.866515718791933e-7),
1724 (6.0, 9.86587645037698e-10),
1725 (7.0, 1.279812543885835e-12),
1726 (8.0, 6.220960574271784e-16),
1727 (8.3, 5.205569744890254e-17),
1728 (9.0, 1.1285884059538405e-19),
1729 (12.0, 1.776482112077679e-33),
1730 (20.0, 2.7536241186062337e-89),
1731 (30.0, 4.906713927148187e-198),
1732 (37.0, 5.725571222524577e-300),
1733 ];
1734 for (x, want) in ROWS {
1735 let bar = (x * x + 2.0) * f64::EPSILON;
1736 let got = normal_sf(x);
1737 let rel = ((got - want) / want).abs();
1738 assert!(
1739 rel <= bar,
1740 "normal_sf({x}) = {got:e}, want {want:e}, relative {rel:e} > {bar:e}"
1741 );
1742 let got_two_sided = normal_two_sided_probability(x);
1743 let two_sided_rel = ((got_two_sided - 2.0 * want) / (2.0 * want)).abs();
1744 assert!(
1745 two_sided_rel <= bar,
1746 "normal_two_sided_probability({x}) = {got_two_sided:e}, \
1747 want {:e}, relative {two_sided_rel:e} > {bar:e}",
1748 2.0 * want
1749 );
1750 // The value this replaces. Above the saturation point it is not a
1751 // less accurate answer, it is no answer.
1752 if x >= 8.3 {
1753 assert_eq!(
1754 1.0 - normal_cdf(x),
1755 0.0,
1756 "1 - normal_cdf({x}) is expected to have saturated"
1757 );
1758 }
1759 }
1760 // Complementarity holds wherever the sum is representable, and the
1761 // symmetry that makes a two-sided p-value a single call.
1762 for x in [-3.0_f64, -0.25, 0.0, 0.25, 3.0] {
1763 let sum = normal_sf(x) + normal_cdf(x);
1764 assert!((sum - 1.0).abs() <= 2.0 * f64::EPSILON, "sf + cdf = {sum}");
1765 assert_eq!(normal_sf(-x), normal_cdf(x), "sf(-x) != cdf(x) at {x}");
1766 }
1767 }
1768
1769 /// The final representable normal two-sided tail is subnormal. This is a
1770 /// separate absolute/ULP assertion because a conventional relative-error
1771 /// helper with a normal-number floor would make the edge vacuous.
1772 #[test]
1773 fn normal_two_sided_tail_retains_subnormal_edge() {
1774 const EXPECTED_AT_38: f64 = 5.770_856_702_007_929e-316;
1775 let got = normal_two_sided_probability(38.0);
1776 let ulps = got.to_bits().abs_diff(EXPECTED_AT_38.to_bits());
1777 assert!(
1778 got.is_subnormal() && ulps <= 128,
1779 "two-sided normal tail at z=38: got {got:.17e}, \
1780 expected {EXPECTED_AT_38:.17e}, ulps {ulps}"
1781 );
1782 assert_eq!(normal_two_sided_probability(40.0), 0.0);
1783 assert_eq!(normal_two_sided_probability(f64::INFINITY), 0.0);
1784 assert!(normal_two_sided_probability(f64::NAN).is_nan());
1785 }
1786
1787 /// `t²` and then `ν/(ν+t²)` both underflow at this edge, but the Cauchy
1788 /// tail itself is still representable. The analytic Cauchy survival law is
1789 /// an independent oracle for the log-beta implementation.
1790 #[test]
1791 fn student_t_two_sided_tail_retains_subnormal_cauchy_edge() {
1792 const EXPECTED: f64 = 3.541_315_033_259_774_5e-309;
1793 let got = student_t_two_sided_probability(f64::MAX, 1.0);
1794 let analytic = 2.0 * (1.0 / f64::MAX).atan() / std::f64::consts::PI;
1795 let pinned_ulps = got.to_bits().abs_diff(EXPECTED.to_bits());
1796 let analytic_ulps = got.to_bits().abs_diff(analytic.to_bits());
1797 assert!(
1798 got.is_subnormal() && pinned_ulps <= 512 && analytic_ulps <= 512,
1799 "Cauchy tail at f64::MAX: got {got:.17e}, pinned {EXPECTED:.17e}, \
1800 analytic {analytic:.17e}, pinned ulps {pinned_ulps}, \
1801 analytic ulps {analytic_ulps}"
1802 );
1803 }
1804
1805 #[test]
1806 fn distribution_survival_primitives_define_boundaries_and_identities() {
1807 assert_eq!(normal_sf(f64::INFINITY), 0.0);
1808 assert_eq!(normal_sf(f64::NEG_INFINITY), 1.0);
1809 assert!(normal_sf(f64::NAN).is_nan());
1810
1811 assert_eq!(student_t_two_sided_probability(0.0, 7.0), 1.0);
1812 assert_eq!(student_t_sf(0.0, 7.0), 0.5);
1813 assert!(student_t_sf(f64::NAN, 7.0).is_nan());
1814
1815 assert_eq!(chi_square_sf(0.0, 3.0), 1.0);
1816 assert_eq!(chi_square_sf(f64::INFINITY, 3.0), 0.0);
1817 assert!(chi_square_sf(-1.0, 3.0).is_nan());
1818 assert!(chi_square_sf(1.0, 0.0).is_nan());
1819
1820 assert_eq!(fisher_snedecor_sf(0.0, 3.0, 20.0), 1.0);
1821 assert_eq!(fisher_snedecor_sf(f64::INFINITY, 3.0, 20.0), 0.0);
1822 assert!(fisher_snedecor_sf(-1.0, 3.0, 20.0).is_nan());
1823 assert!(fisher_snedecor_sf(1.0, 0.0, 20.0).is_nan());
1824 assert!(fisher_snedecor_sf(1.0, 3.0, 0.0).is_nan());
1825
1826 // χ²₁ is the square of a standard normal; F₁,₁ is the square of a
1827 // Cauchy. These identities independently anchor both direct survival
1828 // implementations in a small-tail regime.
1829 let statistic = 160.0_f64;
1830 let chi_expected = normal_two_sided_probability(statistic.sqrt());
1831 let chi_got = chi_square_sf(statistic, 1.0);
1832 assert!(rel_err(chi_got, chi_expected) <= 2.0e-13);
1833
1834 let f_expected = student_t_two_sided_probability(statistic.sqrt(), 1.0);
1835 let f_got = fisher_snedecor_sf(statistic, 1.0, 1.0);
1836 assert!(rel_err(f_got, f_expected) <= 2.0e-13);
1837 }
1838
1839 #[test]
1840 /// The lower tail of a beta quantile, where `inv_beta_reg`'s absolute
1841 /// convergence tolerance in `x` used to stall (#2528).
1842 ///
1843 /// Shapes are the ones `gam_inference::probability` derives from a mean and
1844 /// a variance (`precision = mu(1-mu)/total_var - 1`), so every row is the
1845 /// lower endpoint of a 95% predictive interval a caller can actually ask
1846 /// for. References are an 80-digit bisection in `ln x` on
1847 /// `I_x(a,b) = p`; the `Beta(0.1, 0.1)` row is additionally checkable in
1848 /// closed form, since `I_x -> x^a/(a B(a,b))` gives
1849 /// `x = (p a B(a,b))^(1/a)` there.
1850 ///
1851 /// What shipped before, against the same references: `6.7e-18` for the
1852 /// first row (true `1.5e-41`, relative error 4.6e+23), `5.8e-18` for the
1853 /// second (true `6.3e-161`), and `9.6e-19` for the underflow row, whose
1854 /// true quantile is `7.7e-688` and whose only correct `f64` answer is `0`.
1855 /// The failure was not a loss of digits but a floor: every one of those
1856 /// returns is the solver's own resolution limit rather than a quantile.
1857 fn beta_quantile_resolves_the_lower_tail_below_the_solver_floor() {
1858 const CASES: [(f64, f64, f64, f64); 8] = [
1859 (0.04, 3.96, 0.025, 1.4749755854885786e-41),
1860 (0.01, 0.99, 0.025, 6.326229749489128e-161),
1861 (
1862 0.046666666666666666,
1863 2.2866666666666666,
1864 0.025,
1865 1.488779171021457e-35,
1866 ),
1867 (0.05, 0.95, 0.025, 9.875267916846768e-33),
1868 (0.1, 0.9, 0.025, 1.12479965068234e-16),
1869 (0.3, 0.7, 0.025, 7.6005358168401896e-6),
1870 (0.5, 0.5, 0.025, 1.5413331334360133e-3),
1871 (0.1, 0.1, 1.0e-4, 8.869280655550463e-38),
1872 ];
1873 let mut worst = 0.0_f64;
1874 for (a, b, p, want) in CASES {
1875 let got = beta_quantile(p, a, b);
1876 let relative = ((got - want) / want).abs();
1877 assert!(
1878 relative <= 16.0 * f64::EPSILON,
1879 "beta_quantile({p}, {a}, {b}) = {got:e}, want {want:e}, relative {relative:e}"
1880 );
1881 worst = worst.max(relative);
1882 }
1883 println!("worst relative error over the lower-tail table: {worst:e}");
1884
1885 // The true quantile here is 7.7e-688. It is not representable, so the
1886 // correctly rounded answer is zero, and a caller reading a positive
1887 // lower bound could not tell that it had underflowed.
1888 let underflowed = beta_quantile(0.025, 0.0023333333333333335, 2.3310000000000004);
1889 assert!(
1890 underflowed == 0.0,
1891 "a quantile below MIN_POSITIVE must round to zero, got {underflowed:e}"
1892 );
1893
1894 // The upper tail of the same shape is not on the series branch and is
1895 // still `inv_beta_reg`'s answer, at `inv_beta_reg`'s own accuracy. It is
1896 // asserted here so that widening the branch cannot silently move it.
1897 const UPPER: f64 = 0.12274676682071068;
1898 let upper = beta_quantile(0.975, 0.04, 3.96);
1899 assert!(
1900 ((upper - UPPER) / UPPER).abs() <= 1.0e-11,
1901 "upper tail moved: {upper:e}, want {UPPER:e}"
1902 );
1903 }
1904
1905 #[test]
1906 fn beta_quantile_matches_known_reference_values() {
1907 let cases: [(f64, f64, f64, f64); 8] = [
1908 (0.025, 2.0, 2.0, 0.094_299_3),
1909 (0.975, 2.0, 2.0, 0.905_700_7),
1910 (0.5, 2.0, 2.0, 0.5),
1911 (0.025, 0.8, 4.0, 0.002_339_1),
1912 (0.975, 0.8, 4.0, 0.564_717_3),
1913 (0.025, 5.0, 1.5, 0.408_549_1),
1914 (0.5, 20.0, 80.0, 0.197_994_8),
1915 (0.975, 20.0, 80.0, 0.283_367_6),
1916 ];
1917 for (p, a, b, expected) in cases {
1918 let got = beta_quantile(p, a, b);
1919 let abs = (got - expected).abs();
1920 assert!(
1921 abs < 1e-5,
1922 "beta_quantile(p={p}, a={a}, b={b}) = {got}, expected ≈ {expected} (abs err {abs})"
1923 );
1924 }
1925 }
1926
1927 #[test]
1928 fn beta_quantile_boundaries_and_degeneracy() {
1929 assert_eq!(beta_quantile(0.0, 2.0, 3.0), 0.0);
1930 assert_eq!(beta_quantile(-0.5, 2.0, 3.0), 0.0);
1931 assert_eq!(beta_quantile(1.0, 2.0, 3.0), 1.0);
1932 assert_eq!(beta_quantile(1.5, 2.0, 3.0), 1.0);
1933 assert!(beta_quantile(0.5, -1.0, 3.0).is_nan());
1934 assert!(beta_quantile(0.5, 2.0, 0.0).is_nan());
1935 assert!(beta_quantile(0.5, f64::NAN, 3.0).is_nan());
1936 let mut prev = 0.0;
1937 for i in 1..100 {
1938 let p = i as f64 / 100.0;
1939 let q = beta_quantile(p, 3.0, 5.0);
1940 assert!(q > prev, "beta quantile not increasing at p={p}");
1941 prev = q;
1942 }
1943 }
1944
1945 // ── normal_pdf ────────────────────────────────────────────────────────────
1946
1947 #[test]
1948 fn normal_pdf_at_zero() {
1949 let expected = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
1950 assert!((normal_pdf(0.0) - expected).abs() < TOL);
1951 }
1952
1953 #[test]
1954 fn normal_pdf_symmetry() {
1955 for &x in &[0.5, 1.0, 2.0, 3.0, 5.0] {
1956 assert_eq!(normal_pdf(x), normal_pdf(-x), "symmetry failed at x={x}");
1957 }
1958 }
1959
1960 /// `x*x` is exact-splittable and the split is what `exp` needs.
1961 ///
1962 /// Two independent statements, because the correction is only worth what
1963 /// its residual is worth. First, `x*x + residual` is `x²` EXACTLY: checked
1964 /// against a Veltkamp/Dekker split, which reaches the same residual through
1965 /// pure multiplies and adds and shares no code path with the `mul_add`
1966 /// route. Second, the residual is not decorative — for these arguments it
1967 /// is a relative perturbation of `x²` big enough that `exp` amplifies it
1968 /// past a single ulp of the result.
1969 #[test]
1970 fn square_residual_completes_the_rounded_square_exactly() {
1971 // 2^27 + 1: Veltkamp's splitting factor, exact for any `x` whose
1972 // scaled form does not overflow.
1973 const SPLIT: f64 = 134_217_729.0;
1974 let mut saw_amplified = false;
1975 for &x in &[
1976 0.1, 0.7, 1.3, 2.9, 6.1, 10.5, 14.3, 19.7, 23.9, 25.9999, 34.7,
1977 ] {
1978 let rounded = x * x;
1979 let residual = square_residual(x, rounded);
1980
1981 let c = x * SPLIT;
1982 let head = c - (c - x);
1983 let tail = x - head;
1984 let dekker = ((head * head - rounded) + 2.0 * head * tail) + tail * tail;
1985 assert_eq!(
1986 residual, dekker,
1987 "x={x}: mul_add residual {residual:e} != Dekker residual {dekker:e}"
1988 );
1989
1990 // `exp` multiplies a relative argument perturbation by the argument.
1991 let amplified = (residual / rounded).abs() * rounded;
1992 if amplified > f64::EPSILON {
1993 saw_amplified = true;
1994 }
1995 }
1996 assert!(
1997 saw_amplified,
1998 "no test argument had a residual `exp` could amplify past one ulp; \
1999 the correction under test would be untested"
2000 );
2001 }
2002
2003 /// `φ(x)` against an EXTERNAL high-precision reference (mpmath, dps=60).
2004 ///
2005 /// Every argument here has an INEXACT square, which is the whole point.
2006 /// `exp(−½·fl(x*x))` misplaces the argument by `x²·ε/2` RELATIVE, and `exp`
2007 /// hands that straight back as relative error in the result: `1.4e-14` at
2008 /// `x ≈ 17`, `5.7e-14` by `x ≈ 35`, where `φ` is still a normal `f64`. Only
2009 /// the top of the range makes that visible, so the table has to reach it —
2010 /// a `φ` table that stops at `x = 5` cannot tell the two forms apart.
2011 ///
2012 /// `1.5e-15` (≈7 ulp) is the portability allowance: `f64::exp` is the
2013 /// platform libm and the only part of this that is not fixed by the crate
2014 /// graph, and it is worth ~1 ulp on the implementations in use. That still
2015 /// leaves 38x of margin against the defect at the top of the table.
2016 #[test]
2017 fn normal_pdf_matches_high_precision_reference() {
2018 const TOLERANCE: f64 = 1.5e-15;
2019 let refs: &[(f64, f64)] = &[
2020 (0.5, 0.35206532676429947),
2021 (1.0, 0.24197072451914334),
2022 (2.5, 0.017528300493568537),
2023 (4.0, 0.00013383022576488534),
2024 (7.3, 1.0693837871541648e-12),
2025 (11.9, 7.090702668428078e-32),
2026 (17.4, 7.201308152719057e-67),
2027 (23.6, 4.555989824112156e-122),
2028 (29.1, 5.229437243665329e-185),
2029 (34.7, 1.368008224488383e-262),
2030 ];
2031 for &(x, reference) in refs {
2032 // The small arguments anchor the ordinary range; the large ones are
2033 // where the defect lives, and every one of THOSE has to have a
2034 // square `f64` cannot hold or it exercises nothing.
2035 assert!(
2036 x <= 5.0 || square_residual(x, x * x) != 0.0,
2037 "x={x} squares exactly, so it cannot exercise the correction"
2038 );
2039 let rel = rel_err(normal_pdf(x), reference);
2040 assert!(
2041 rel < TOLERANCE,
2042 "normal_pdf({x}) = {:.17e}, reference {reference:.17e}, rel {rel:.3e}",
2043 normal_pdf(x)
2044 );
2045 }
2046 }
2047
2048 /// `φ` off the ordinary domain, where the square has no usable residual:
2049 /// `±∞` squares to `∞` and would hand the correction an `∞ − ∞`.
2050 #[test]
2051 fn normal_pdf_nonfinite_and_underflowed_arguments() {
2052 assert_eq!(normal_pdf(f64::INFINITY), 0.0);
2053 assert_eq!(normal_pdf(f64::NEG_INFINITY), 0.0);
2054 assert!(normal_pdf(f64::NAN).is_nan());
2055 // Past ~38.6 the pdf underflows; it must reach zero, not NaN.
2056 assert_eq!(normal_pdf(40.0), 0.0);
2057 assert_eq!(normal_pdf(-40.0), 0.0);
2058 assert_eq!(normal_pdf(f64::MAX), 0.0);
2059 // Just inside the underflow edge the result is subnormal but positive.
2060 let edge = normal_pdf(38.0);
2061 assert!(edge > 0.0 && edge.is_subnormal(), "phi(38) = {edge:e}");
2062 }
2063
2064 #[test]
2065 fn normal_pdf_positive() {
2066 for &x in &[-5.0, -1.0, 0.0, 1.0, 5.0] {
2067 assert!(normal_pdf(x) > 0.0, "pdf should be positive at x={x}");
2068 }
2069 }
2070
2071 // ── normal_cdf ────────────────────────────────────────────────────────────
2072
2073 #[test]
2074 fn normal_cdf_at_zero_is_half() {
2075 assert!((normal_cdf(0.0) - 0.5).abs() < TOL);
2076 }
2077
2078 #[test]
2079 fn normal_cdf_symmetry() {
2080 for &x in &[0.5, 1.0, 2.0, 3.0] {
2081 let sum = normal_cdf(x) + normal_cdf(-x);
2082 assert!(
2083 (sum - 1.0).abs() < TOL,
2084 "cdf symmetry failed at x={x}: sum={sum}"
2085 );
2086 }
2087 }
2088
2089 #[test]
2090 fn normal_cdf_bounds() {
2091 assert!(normal_cdf(10.0) > 0.9999);
2092 assert!(normal_cdf(-10.0) < 1e-22);
2093 assert!(normal_cdf(0.0) > 0.0);
2094 assert!(normal_cdf(0.0) < 1.0);
2095 }
2096
2097 #[test]
2098 fn normal_cdf_at_1_96_near_0975() {
2099 // Phi(1.96) ≈ 0.975 — canonical two-sided 5% critical value.
2100 let p = normal_cdf(1.959_963_985);
2101 assert!((p - 0.975).abs() < 1e-8, "p={p}");
2102 }
2103
2104 // ── erfcx_nonnegative ─────────────────────────────────────────────────────
2105
2106 #[test]
2107 fn erfcx_zero_is_one_and_negative_domain_is_rejected() {
2108 assert_eq!(erfcx_nonnegative(0.0), 1.0);
2109 assert!(erfcx_nonnegative(-f64::MIN_POSITIVE).is_nan());
2110 assert!(erfcx_nonnegative(-1.0).is_nan());
2111 assert!(erfcx_nonnegative(f64::NEG_INFINITY).is_nan());
2112 }
2113
2114 #[test]
2115 fn erfcx_positive_inf_returns_zero() {
2116 assert_eq!(erfcx_nonnegative(f64::INFINITY), 0.0);
2117 }
2118
2119 #[test]
2120 fn erfcx_nan_propagates() {
2121 assert!(erfcx_nonnegative(f64::NAN).is_nan());
2122 }
2123
2124 #[test]
2125 fn erfcx_small_positive_matches_direct() {
2126 use libm::erfc;
2127 for &x in &[0.1_f64, 0.5, 1.0, 5.0, 10.0, 25.0] {
2128 let got = erfcx_nonnegative(x);
2129 let expected = (x * x).exp() * erfc(x);
2130 let err = rel_err(got, expected);
2131 assert!(
2132 err < 1e-10,
2133 "x={x}: got={got} expected={expected} rel={err}"
2134 );
2135 }
2136 }
2137
2138 #[test]
2139 fn erfcx_large_x_positive_and_finite() {
2140 // For x >= 26 the asymptotic branch must remain positive and finite.
2141 let got = erfcx_nonnegative(50.0);
2142 assert!(got.is_finite() && got > 0.0, "erfcx(50)={got}");
2143 // Leading asymptotic term: 1/(x*sqrt(pi)).
2144 let asymptotic = 1.0 / (50.0 * std::f64::consts::PI.sqrt());
2145 assert!(
2146 rel_err(got, asymptotic) < 1e-3,
2147 "got={got} asymptotic={asymptotic}"
2148 );
2149 }
2150
2151 /// The two branches must describe one function across `x = 26`.
2152 ///
2153 /// Note WHY the plain `exp(x*x)·erfc(x)` below is a legitimate oracle at
2154 /// this particular argument and nowhere else: `26² = 676` is exactly
2155 /// representable, so the rounded square carries no residual and the direct
2156 /// form is momentarily as good as the corrected one. That is also exactly
2157 /// why this check was blind to the `x²·ε/2` defect it looks like it should
2158 /// have caught — at `25.9` the same comparison would have failed by
2159 /// `5.7e-14`, but the seam was only ever probed at the one point in the
2160 /// neighbourhood where the defect vanishes. The bit-adjacent step below
2161 /// cannot substitute for it either: `d(ln erfcx)/dx ≈ −2x` at the switch,
2162 /// so one ulp of `x` moves the true value by `1.8e-13`, three times the
2163 /// defect. It takes a reference at a DISTANCE from the seam — the table in
2164 /// `erfcx_matches_high_precision_reference` — to see the defect at all.
2165 #[test]
2166 fn erfcx_asymptotic_switch_matches_finite_direct_identity() {
2167 let switch = 26.0_f64;
2168 assert_eq!(
2169 square_residual(switch, switch * switch),
2170 0.0,
2171 "676 must be exact for the direct form below to be an oracle"
2172 );
2173 let direct = (switch * switch).exp() * erfc(switch);
2174 let asymptotic = erfcx_nonnegative(switch);
2175 assert!(
2176 rel_err(asymptotic, direct) < 1.0e-15,
2177 "switch mismatch: asymptotic={asymptotic:.17e}, direct={direct:.17e}"
2178 );
2179
2180 // Continuity across the branch cut, up to how fast the function itself
2181 // moves over one ulp of `x` (`|d ln erfcx/dx| ≈ 2x` ⇒ ~1.9e-13 here).
2182 let immediately_below = f64::from_bits(switch.to_bits() - 1);
2183 let below = erfcx_nonnegative(immediately_below);
2184 let step = 2.0 * switch * (switch - immediately_below);
2185 assert!(
2186 rel_err(asymptotic, below) < 2.0 * step,
2187 "discontinuous switch: below={below:.17e}, at={asymptotic:.17e}, \
2188 one-ulp travel {step:.3e}"
2189 );
2190 }
2191
2192 #[test]
2193 fn erfcx_preserves_representable_subnormal_tail() {
2194 let tail = erfcx_nonnegative(f64::MAX);
2195 assert!(tail > 0.0 && tail.is_subnormal(), "erfcx(MAX)={tail:e}");
2196 }
2197
2198 /// Absolute-accuracy pin against an EXTERNAL high-precision reference
2199 /// (mpmath, dps=60) spanning the direct branch `[0.1, 26)`. This is the
2200 /// root-cause guard: the previous `exp(x²)·erfc(x)` direct form was built on
2201 /// `statrs::erfc`, whose ~1e-10 relative accuracy silently poisoned every
2202 /// downstream probit / Mills / log-CDF derivative.
2203 ///
2204 /// The table had a SECOND job it was not doing. Of its twelve arguments,
2205 /// eleven — `0.5`, `2`, `3.5`, `6`, `9`, `13`, `18`, `22`, `25.5`, and the
2206 /// two whose squares are far too small to matter — square EXACTLY in `f64`,
2207 /// so `fl(x*x) = x²` and the `x²·ε/2` error the rounded square feeds `exp`
2208 /// was identically zero at every one of them. The twelfth, `25.9999`, does
2209 /// not square exactly; it was the one point in the table where the defect
2210 /// was live, and its literal had been recorded WITH the defect in it —
2211 /// `0.021683668126370212` against a true `0.021683668126369115`, off by
2212 /// `5.1e-14`. Three independent high-precision routes (`exp(x²)·erfc(x)`,
2213 /// the 12-term asymptotic series, and a 400-level Laplace continued
2214 /// fraction) and `scipy.special.erfcx` all agree on the corrected value.
2215 /// A `1e-13` tolerance then accepted a reference that was itself wrong by
2216 /// half the tolerance, which is how a 190x accuracy defect sat under a
2217 /// test named for high precision.
2218 ///
2219 /// So the table now RUNS ON arguments with inexact squares (`10.5`,
2220 /// `14.3`, `19.7`, `23.9` alongside the original grid) and the tolerance is
2221 /// `1.5e-15` — 38x below the defect at the top of the range, and still ~7
2222 /// ulp of headroom for the platform `f64::exp` (the only part of this path
2223 /// not pinned by the crate graph; `erfc` comes from the `libm` crate and is
2224 /// identical everywhere).
2225 #[test]
2226 fn erfcx_matches_high_precision_reference() {
2227 const TOLERANCE: f64 = 1.5e-15;
2228 // (x, mpmath exp(x²)·erfc(x) at dps=60, rounded to f64).
2229 let refs: &[(f64, f64)] = &[
2230 (0.1, 0.8964569799691267),
2231 (0.5, 0.6156903441929259),
2232 (1.0, 0.427583576155807),
2233 (2.0, 0.25539567631050575),
2234 (3.5, 0.1552936556088943),
2235 (6.0, 0.09277656780053835),
2236 (9.0, 0.06230772403777468),
2237 (10.5, 0.05349189974656412),
2238 (13.0, 0.043271921864609694),
2239 (14.3, 0.0393580473372741),
2240 (18.0, 0.03129571781590521),
2241 (19.7, 0.028602309402825203),
2242 (22.0, 0.025618570005879453),
2243 (23.9, 0.023585649371803793),
2244 (25.5, 0.022108108052519827),
2245 (25.9999, 0.021683668126369115),
2246 ];
2247 for &(x, reference) in refs {
2248 let got = erfcx_nonnegative(x);
2249 let rel = rel_err(got, reference);
2250 assert!(
2251 rel < TOLERANCE,
2252 "erfcx({x}) = {got:.17e}, reference {reference:.17e}, rel {rel:.3e}"
2253 );
2254 }
2255 // The point of the added arguments: at least four of them must have a
2256 // square `f64` cannot hold, or the table is back to testing nothing.
2257 let inexact = refs
2258 .iter()
2259 .filter(|&&(x, _)| square_residual(x, x * x) != 0.0)
2260 .count();
2261 assert!(
2262 inexact >= 4,
2263 "only {inexact} of {} reference arguments have an inexact square",
2264 refs.len()
2265 );
2266 }
2267
2268 // ── log1mexp_positive ─────────────────────────────────────────────────────
2269
2270 #[test]
2271 fn log1mexp_at_zero_is_neg_inf() {
2272 assert_eq!(log1mexp_positive(0.0), f64::NEG_INFINITY);
2273 }
2274
2275 #[test]
2276 fn log1mexp_recovers_log_one_minus_exp() {
2277 // Verify exp(log1mexp(a)) + exp(-a) ≈ 1 for several a > 0. This
2278 // roundtrip avoids computing `(1 - exp(-a)).ln()` directly, which
2279 // suffers catastrophic cancellation for large a (e.g. a=20 where
2280 // `1.0 - exp(-20)` loses 9 decimal digits from the subtraction).
2281 for &a in &[0.001_f64, 0.5, std::f64::consts::LN_2, 1.0, 5.0, 20.0] {
2282 let lm = log1mexp_positive(a);
2283 let roundtrip = lm.exp() + (-a).exp();
2284 assert!(
2285 (roundtrip - 1.0).abs() < 1e-14,
2286 "a={a}: exp(log1mexp(a)) + exp(-a) = {roundtrip}, expected 1.0"
2287 );
2288 }
2289 }
2290
2291 #[test]
2292 fn log1mexp_at_ln2_is_neg_ln2() {
2293 let ln2 = std::f64::consts::LN_2;
2294 let got = log1mexp_positive(ln2);
2295 assert!((got - (-ln2)).abs() < TOL, "got={got}");
2296 }
2297
2298 // ── signed_log_sum_exp ────────────────────────────────────────────────────
2299
2300 #[test]
2301 fn slse_all_positive_single() {
2302 let (lm, sg) = signed_log_sum_exp(&[2.0], &[1.0]);
2303 assert!((lm - 2.0).abs() < TOL);
2304 assert!((sg - 1.0).abs() < TOL);
2305 }
2306
2307 #[test]
2308 fn slse_difference_recovers_log2() {
2309 // 3 - 1 = 2 → log|2| = ln(2), sign = +1.
2310 let log3 = 3.0_f64.ln();
2311 let log1 = 0.0_f64; // ln(1)
2312 let (lm, sg) = signed_log_sum_exp(&[log3, log1], &[1.0, -1.0]);
2313 assert!((lm - 2.0_f64.ln()).abs() < TOL, "lm={lm}");
2314 assert!((sg - 1.0).abs() < TOL, "sg={sg}");
2315 }
2316
2317 #[test]
2318 fn slse_cancellation_gives_neg_inf() {
2319 // a - a = 0 → log|0| = -∞.
2320 let ln2 = 2.0_f64.ln();
2321 let (lm, sg) = signed_log_sum_exp(&[ln2, ln2], &[1.0, -1.0]);
2322 assert_eq!(lm, f64::NEG_INFINITY);
2323 assert_eq!(sg, 0.0);
2324 }
2325
2326 #[test]
2327 fn slse_compensated_signed_reduction_preserves_conditioned_residual() {
2328 // High-precision truth for these exact f64 log inputs is
2329 // -7.141194316117315021451...e-13. Reducing the positive and negative
2330 // groups through separate logarithms first returned
2331 // -7.141196119493781e-13: two otherwise harmless log roundings were
2332 // amplified by the nearly cancelling subtraction.
2333 let log_magnitudes = [
2334 -8.752777116220523,
2335 -8.741767521635955,
2336 -8.77021076826994,
2337 -8.75153786858979,
2338 -8.754172660745834,
2339 -8.768217028174623,
2340 -8.756625396724502,
2341 -8.737312647396818,
2342 ];
2343 let signs = [1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0];
2344 let (log_magnitude, sign) = signed_log_sum_exp(&log_magnitudes, &signs);
2345 let got = sign * log_magnitude.exp();
2346 let truth = -7.141194316117315e-13;
2347 let legacy = -7.141196119493781e-13;
2348 assert_eq!(sign, -1.0);
2349 assert!(
2350 (got - truth).abs() < (legacy - truth).abs(),
2351 "compensated signed reduction did not improve the conditioned residual: \
2352 got={got:.17e}, truth={truth:.17e}, legacy={legacy:.17e}"
2353 );
2354 }
2355
2356 #[test]
2357 fn slse_log_domain_branch_retains_sub_ulp_two_term_gap() {
2358 // exp(-gap) rounds to 1.0 at this gap, so a purely linear-domain signed
2359 // reduction sees 1 - 1. The forward-error gate must route to the
2360 // log-domain difference, where the distinct input logs retain the gap.
2361 let gap = f64::EPSILON * 0.25;
2362 let (log_magnitude, sign) = signed_log_sum_exp(&[0.0, -gap], &[1.0, -1.0]);
2363 assert_eq!(sign, 1.0);
2364 assert_eq!(log_magnitude, log1mexp_positive(gap));
2365 }
2366
2367 #[test]
2368 fn exact_binary64_sum_sign_resolves_midpoint_and_both_adjacent_sides() {
2369 let half_upper_ulp_at_one = 2.0_f64.powi(-53);
2370 let least_subnormal = f64::from_bits(1);
2371 assert_eq!(
2372 exact_binary64_sum_sign([1.0, half_upper_ulp_at_one, -1.0, -half_upper_ulp_at_one,]),
2373 Ok(std::cmp::Ordering::Equal),
2374 "an exact rounding midpoint must compare equal"
2375 );
2376 assert_eq!(
2377 exact_binary64_sum_sign([
2378 1.0,
2379 half_upper_ulp_at_one,
2380 least_subnormal,
2381 -1.0,
2382 -half_upper_ulp_at_one,
2383 ]),
2384 Ok(std::cmp::Ordering::Greater),
2385 "one binary lattice quantum above the midpoint must compare positive"
2386 );
2387 assert_eq!(
2388 exact_binary64_sum_sign([
2389 1.0,
2390 half_upper_ulp_at_one,
2391 -least_subnormal,
2392 -1.0,
2393 -half_upper_ulp_at_one,
2394 ]),
2395 Ok(std::cmp::Ordering::Less),
2396 "one binary lattice quantum below the midpoint must compare negative"
2397 );
2398 }
2399
2400 #[test]
2401 fn exact_binary64_sum_sign_enforces_its_finite_structural_contract() {
2402 assert_eq!(
2403 exact_binary64_sum_sign([f64::MAX, -f64::MAX, f64::from_bits(1)]),
2404 Ok(std::cmp::Ordering::Greater),
2405 );
2406 assert_eq!(
2407 exact_binary64_sum_sign([0.0, f64::NAN]),
2408 Err(ExactBinary64SumSignError::NonFiniteTerm { index: 1 }),
2409 );
2410 assert_eq!(
2411 exact_binary64_sum_sign(std::iter::repeat_n(1.0, EXACT_BINARY64_SUM_MAX_TERMS + 1)),
2412 Err(ExactBinary64SumSignError::TermCapacityExceeded {
2413 maximum: EXACT_BINARY64_SUM_MAX_TERMS,
2414 }),
2415 );
2416 }
2417
2418 #[test]
2419 fn slse_empty_returns_neg_inf_with_zero_sign() {
2420 // With no terms the sum is exactly 0, so the docstring contract is
2421 // `(−∞, 0.0)`. (This test previously encoded the buggy `+1.0` positive-sum
2422 // convention, which contradicted both the docstring and the cancellation
2423 // test below; rewritten to the correct zero sign.)
2424 let (lm, sg) = signed_log_sum_exp(&[], &[]);
2425 assert_eq!(lm, f64::NEG_INFINITY);
2426 assert_eq!(sg, 0.0);
2427 }
2428
2429 #[test]
2430 fn slse_all_zero_signs_return_zero_sign() {
2431 // A single term whose sign is 0 contributes nothing; S = 0 ⇒ (−∞, 0.0).
2432 let (lm, sg) = signed_log_sum_exp(&[0.0], &[0.0]);
2433 assert_eq!(lm, f64::NEG_INFINITY);
2434 assert_eq!(sg, 0.0);
2435 }
2436
2437 #[test]
2438 fn slse_all_neg_inf_magnitudes_return_zero_sign() {
2439 // Every magnitude is exp(−∞) = 0 regardless of sign, so the sum is 0 and
2440 // the reported sign must be 0.0, not +1.0.
2441 let (lm, sg) = signed_log_sum_exp(&[f64::NEG_INFINITY, f64::NEG_INFINITY], &[1.0, -1.0]);
2442 assert_eq!(lm, f64::NEG_INFINITY);
2443 assert_eq!(sg, 0.0);
2444 }
2445
2446 #[test]
2447 fn slse_pos_inf_dominates() {
2448 let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, 1.0], &[1.0, -1.0]);
2449 assert_eq!(lm, f64::INFINITY);
2450 assert_eq!(sg, 1.0);
2451 }
2452
2453 #[test]
2454 fn slse_neg_inf_dominates() {
2455 let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, 1.0], &[-1.0, 1.0]);
2456 assert_eq!(lm, f64::INFINITY);
2457 assert_eq!(sg, -1.0);
2458 }
2459
2460 #[test]
2461 fn slse_both_inf_signs_gives_nan() {
2462 let (lm, sg) = signed_log_sum_exp(&[f64::INFINITY, f64::INFINITY], &[1.0, -1.0]);
2463 assert!(lm.is_nan());
2464 assert_eq!(sg, 0.0);
2465 }
2466
2467 // ── normal_logcdf ─────────────────────────────────────────────────────────
2468
2469 #[test]
2470 fn logcdf_at_zero_is_log_half() {
2471 let got = normal_logcdf(0.0);
2472 let expected = 0.5_f64.ln();
2473 assert!((got - expected).abs() < TOL, "got={got}");
2474 }
2475
2476 #[test]
2477 fn logcdf_pos_inf_is_zero() {
2478 assert_eq!(normal_logcdf(f64::INFINITY), 0.0);
2479 }
2480
2481 #[test]
2482 fn logcdf_neg_inf_is_neg_inf() {
2483 assert_eq!(normal_logcdf(f64::NEG_INFINITY), f64::NEG_INFINITY);
2484 }
2485
2486 #[test]
2487 fn logcdf_nan_is_nan() {
2488 assert!(normal_logcdf(f64::NAN).is_nan());
2489 }
2490
2491 #[test]
2492 fn logcdf_matches_log_cdf_for_moderate_x() {
2493 for &x in &[-2.0_f64, -1.0, 0.0, 1.0, 2.0, 3.0] {
2494 let got = normal_logcdf(x);
2495 let expected = normal_cdf(x).ln();
2496 assert!(
2497 (got - expected).abs() < 1e-10,
2498 "x={x}: got={got} expected={expected}"
2499 );
2500 }
2501 }
2502
2503 #[test]
2504 fn logcdf_deep_left_tail_stays_finite() {
2505 // For very negative x, normal_cdf(x) underflows to 0, but logcdf should
2506 // remain finite and large-negative.
2507 let got = normal_logcdf(-20.0);
2508 assert!(got.is_finite() && got < -100.0, "logcdf(-20)={got}");
2509 }
2510
2511 #[test]
2512 fn logcdf_positive_tail_does_not_round_through_unit_cdf() {
2513 let x = 10.0_f64;
2514 let got = normal_logcdf(x);
2515 let expected = (-0.5 * erfc(x / std::f64::consts::SQRT_2)).ln_1p();
2516 assert!(
2517 got < 0.0,
2518 "logcdf(10) must retain its negative tail: {got:e}"
2519 );
2520 assert_eq!(got.to_bits(), expected.to_bits());
2521 }
2522
2523 #[test]
2524 fn log_cdf_quantile_round_trips_both_unrepresentable_tails() {
2525 for x in [-1.0e6, -40.0, -10.0, -2.0, 0.0, 2.0, 10.0] {
2526 let log_p = normal_logcdf(x);
2527 let recovered = standard_normal_quantile_from_log_cdf(log_p)
2528 .expect("finite strict log-CDF has a quantile");
2529 assert!(
2530 (recovered - x).abs() <= 2.0e-12 * x.abs().max(1.0),
2531 "log-quantile round trip at x={x}: log_p={log_p}, recovered={recovered}"
2532 );
2533 }
2534 }
2535
2536 // ── normal_logsf ─────────────────────────────────────────────────────────
2537
2538 #[test]
2539 fn logsf_at_zero_is_log_half() {
2540 let got = normal_logsf(0.0);
2541 let expected = 0.5_f64.ln();
2542 assert!((got - expected).abs() < TOL, "got={got}");
2543 }
2544
2545 #[test]
2546 fn logsf_mirrors_logcdf() {
2547 // logsf(x) = logcdf(-x) by definition.
2548 for &x in &[-3.0_f64, -1.0, 0.0, 1.0, 3.0] {
2549 assert_eq!(normal_logsf(x), normal_logcdf(-x));
2550 }
2551 }
2552
2553 // ── signed_probit_logcdf_and_mills_ratio ──────────────────────────────────
2554
2555 #[test]
2556 fn probit_at_pos_inf() {
2557 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::INFINITY);
2558 assert_eq!(lc, 0.0);
2559 assert_eq!(mr, 0.0);
2560 }
2561
2562 #[test]
2563 fn probit_at_neg_inf() {
2564 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::NEG_INFINITY);
2565 assert_eq!(lc, f64::NEG_INFINITY);
2566 assert_eq!(mr, f64::INFINITY);
2567 }
2568
2569 #[test]
2570 fn probit_nan_propagates() {
2571 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(f64::NAN);
2572 assert!(lc.is_nan() && mr.is_nan());
2573 }
2574
2575 #[test]
2576 fn probit_at_zero_logcdf_and_mills() {
2577 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(0.0);
2578 assert!((lc - 0.5_f64.ln()).abs() < TOL, "lc={lc}");
2579 // phi(0)/Phi(0) = 0.3989.../0.5 ≈ 0.7979.
2580 assert!((mr - 0.797_884_560_802_865).abs() < 1e-10, "mr={mr}");
2581 }
2582
2583 #[test]
2584 fn probit_positive_branch_matches_logcdf() {
2585 for &x in &[0.5_f64, 1.0, 2.0, 3.0] {
2586 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(x);
2587 let lc_ref = normal_logcdf(x);
2588 let mr_ref = normal_pdf(x) / normal_cdf(x);
2589 assert!(
2590 (lc - lc_ref).abs() < 1e-10,
2591 "x={x}: lc={lc} lc_ref={lc_ref}"
2592 );
2593 assert!(
2594 (mr - mr_ref).abs() < 1e-10,
2595 "x={x}: mr={mr} mr_ref={mr_ref}"
2596 );
2597 }
2598 }
2599
2600 #[test]
2601 fn probit_negative_branch_matches_logcdf() {
2602 for &x in &[-0.5_f64, -1.0, -2.0, -5.0] {
2603 let (lc, mr) = signed_probit_logcdf_and_mills_ratio(x);
2604 let lc_ref = normal_logcdf(x);
2605 assert!(
2606 (lc - lc_ref).abs() < 1e-10,
2607 "x={x}: lc={lc} lc_ref={lc_ref}"
2608 );
2609 assert!(mr.is_finite() && mr > 0.0, "x={x}: mr={mr}");
2610 }
2611 }
2612
2613 #[test]
2614 fn probit_mills_ratio_has_no_deep_tail_floor() {
2615 let x = -1.0e305_f64;
2616 let (log_cdf, mills_ratio) = signed_probit_logcdf_and_mills_ratio(x);
2617 assert_eq!(log_cdf, f64::NEG_INFINITY);
2618 assert!(mills_ratio.is_finite());
2619 assert!(
2620 ((mills_ratio / -x) - 1.0).abs() < 5.0e-15,
2621 "mills({x:e})={mills_ratio:e}"
2622 );
2623 }
2624
2625 #[test]
2626 fn normal_logcdf_derivative_stack_has_honest_infinite_limits() {
2627 assert_eq!(normal_logcdf_derivatives(f64::INFINITY), [0.0; 5]);
2628 assert_eq!(
2629 normal_logcdf_derivatives(f64::NEG_INFINITY),
2630 [f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0, 0.0]
2631 );
2632 assert!(
2633 normal_logcdf_derivatives(f64::NAN)
2634 .into_iter()
2635 .all(f64::is_nan)
2636 );
2637
2638 for x in [-1.0e200_f64, 1.0e200_f64] {
2639 let derivatives = normal_logcdf_derivatives(x);
2640 assert!(
2641 derivatives.into_iter().all(|value| !value.is_nan()),
2642 "NaN derivative at x={x:e}: {derivatives:?}"
2643 );
2644 }
2645 }
2646
2647 #[test]
2648 fn normal_logcdf_left_tail_derivatives_do_not_cancel() {
2649 let x = -1.0e100_f64;
2650 let derivatives = normal_logcdf_derivatives(x);
2651 assert_eq!(derivatives[2], -1.0);
2652 assert!(derivatives[3] > 0.0 && derivatives[3].is_finite());
2653 assert!(
2654 (derivatives[3] / 2.0e-300 - 1.0).abs() < 2.0e-14,
2655 "third derivative={:e}",
2656 derivatives[3]
2657 );
2658 assert_eq!(derivatives[4], 0.0);
2659 }
2660
2661 #[test]
2662 fn normal_logcdf_right_tail_preserves_weighted_subnormal_derivatives() {
2663 let derivatives = normal_logcdf_derivatives(38.6);
2664 assert_eq!(derivatives[1], 0.0);
2665 assert!(derivatives[2] < 0.0 && derivatives[2].is_subnormal());
2666 assert!(derivatives[3] > 0.0 && derivatives[3].is_subnormal());
2667 assert!(derivatives[4] < 0.0 && derivatives[4].is_subnormal());
2668 }
2669
2670 #[test]
2671 fn normal_logcdf_tail_stack_is_finite_difference_consistent() {
2672 let h = 1.0e-4_f64;
2673 for x in [-8.0_f64, -4.0, 8.0, 20.0] {
2674 let center = normal_logcdf_derivatives(x);
2675 let left = normal_logcdf_derivatives(x - h);
2676 let right = normal_logcdf_derivatives(x + h);
2677 for order in 1..=3 {
2678 let finite_difference = (right[order] - left[order]) / (2.0 * h);
2679 let expected = center[order + 1];
2680 let relative = (finite_difference - expected).abs() / expected.abs().max(1.0e-300);
2681 assert!(
2682 relative < 2.0e-5,
2683 "x={x}, order={order}: fd={finite_difference:e}, expected={expected:e}, rel={relative:e}"
2684 );
2685 }
2686 }
2687 }
2688
2689 #[test]
2690 fn normal_logcdf_fifth_matches_fourth_derivative() {
2691 for x in [
2692 -100.0_f64, -20.0, -8.0, -4.1, -2.0, 0.0, 2.0, 7.9, 8.1, 20.0,
2693 ] {
2694 let h = 1.0e-4;
2695 let left = normal_logcdf_derivatives(x - h)[4];
2696 let right = normal_logcdf_derivatives(x + h)[4];
2697 let fd = (right - left) / (2.0 * h);
2698 let exact = normal_logcdf_derivatives_through_fifth(x)[5];
2699 let relative = (fd - exact).abs() / exact.abs().max(1.0e-300);
2700 assert!(
2701 relative < 3.0e-5,
2702 "x={x}: fifth={exact:e} fd={fd:e} relative={relative:e}"
2703 );
2704 }
2705 }
2706
2707 #[test]
2708 fn normal_logcdf_fifth_matches_high_precision_reference_979() {
2709 // mpmath 1.3, 100 decimal digits, independently differentiating
2710 // log(erfc(-x/sqrt(2))/2) five times on MSI. Includes both tails and
2711 // both sign changes of the fifth derivative.
2712 for (x, reference) in [
2713 (-100.0, 2.3928167627876771971e-9_f64),
2714 (-20.0, 6.9685881515844515659e-6),
2715 (-8.0, 4.8206401646485407099e-4),
2716 (-4.0, 6.2527728381299791162e-3),
2717 (-2.0, 2.9098988655348833645e-2),
2718 (0.0, -4.4376884626178209889e-3),
2719 (2.0, -3.1091902195183746598e-2),
2720 (8.0, 1.8769187075339757456e-11),
2721 (20.0, 8.7011802472146515797e-83),
2722 ] {
2723 let actual = normal_logcdf_derivatives_through_fifth(x)[5];
2724 let relative = (actual - reference).abs() / reference.abs();
2725 assert!(
2726 relative < 2.0e-10,
2727 "x={x}: fifth={actual:e}, reference={reference:e}, relative={relative:e}"
2728 );
2729 }
2730 // At x=38.6 the density has underflowed, but x^4*phi(x) has not.
2731 // A 400-digit reference verifies the signed-log tail calculation keeps
2732 // the fifth derivative representable (allow four subnormal ulps).
2733 let actual = normal_logcdf_derivatives_through_fifth(38.6)[5];
2734 let reference = 2.5398281413501576417e-318;
2735 assert!(actual > 0.0);
2736 assert!((actual - reference).abs() <= 4.0 * f64::from_bits(1));
2737 }
2738
2739 /// Absolute-accuracy pin of the full `ln Φ(x)` derivative tower against an
2740 /// EXTERNAL high-precision reference (mpmath, dps=60), covering all three
2741 /// branches (continued-fraction left tail at x=−4, the moderate Mills
2742 /// recurrence for x∈(−4, 8), and both signs). Before the `erfc` root-cause
2743 /// fix the moderate branch's `λ = φ/Φ` inherited `statrs::erfc`'s ~1e-10
2744 /// error, so `f''` was wrong by ~1e-9 near the −4 seam; this pins every
2745 /// entry to `2e-11` relative, catching that regression head-on rather than
2746 /// through a seam-straddling finite difference.
2747 #[test]
2748 fn normal_logcdf_derivative_tower_matches_high_precision_reference() {
2749 // (x, [value, f', f'', f''', f''''] from mpmath at dps=60).
2750 let refs: &[(f64, [f64; 5])] = &[
2751 (
2752 -4.0,
2753 [
2754 -10.360101486527291,
2755 4.2256071444894711,
2756 -0.95332716160257737,
2757 0.017856339307658426,
2758 0.0095065764315958691,
2759 ],
2760 ),
2761 // Two points well inside the continued-fraction branch, where the
2762 // truncation the depth controls is the ONLY error source: at -4 the
2763 // branch is at its least converged, and these confirm it stays put.
2764 (
2765 -10.0,
2766 [
2767 -53.231285150512471,
2768 10.098093233962512,
2769 -0.99055462217434374,
2770 0.0017864003921165069,
2771 0.00049785382237944016,
2772 ],
2773 ),
2774 (
2775 -6.0,
2776 [
2777 -20.736768949974706,
2778 6.1584826045445989,
2779 -0.97601236321083323,
2780 0.0069535374991643118,
2781 0.0028992056785575027,
2782 ],
2783 ),
2784 (
2785 -2.0,
2786 [
2787 -3.7831843336820319,
2788 2.3732155328228409,
2789 -0.88572089958591874,
2790 0.059355861291565813,
2791 0.039421993865946813,
2792 ],
2793 ),
2794 (
2795 -1.0,
2796 [
2797 -1.8410216450092635,
2798 1.5251352761609812,
2799 -0.80090233442965121,
2800 0.11693119540604883,
2801 0.07917498368074563,
2802 ],
2803 ),
2804 (
2805 -0.3,
2806 [
2807 -0.96210281816885066,
2808 0.99816596885848332,
2809 -0.69688551072964971,
2810 0.18398317992442132,
2811 0.11037564722092704,
2812 ],
2813 ),
2814 (
2815 0.5,
2816 [
2817 -0.36894641528865639,
2818 0.50916043383703349,
2819 -0.5138245643036329,
2820 0.27099012446870783,
2821 0.088167801929197554,
2822 ],
2823 ),
2824 (
2825 2.0,
2826 [
2827 -0.023012909328963488,
2828 0.055247862678989959,
2829 -0.11354805168857645,
2830 0.18439481503247759,
2831 -0.18785468561160969,
2832 ],
2833 ),
2834 ];
2835 // The moderate-branch statrs regression produced ~1e-9 errors in f''.
2836 // The bound used to sit at 1e-10 to respect what was called the
2837 // continued-fraction branch's "inherent" ~2e-11 in f''''; that was not
2838 // inherent but a depth, and at 64 levels the branch reproduces this
2839 // 60-digit reference EXACTLY at x = -4, -6 and -10. What remains is the
2840 // moderate branch, where the brackets are already collected in `q` and
2841 // the floor is `λ`'s own relative error amplified by `λ/q` (18.7 at the
2842 // switch): 1.8e-13 at x = -2, the worst point here. 1e-11 keeps 55x of
2843 // headroom over that while still failing the 32-level truncation head-on.
2844 for &(x, reference) in refs {
2845 let got = normal_logcdf_derivatives(x);
2846 for (order, (&g, &r)) in got.iter().zip(reference.iter()).enumerate() {
2847 let rel = (g - r).abs() / r.abs().max(1.0e-3);
2848 assert!(
2849 rel < 1.0e-11,
2850 "normal_logcdf_derivatives({x})[{order}] = {g:.17e}, reference {r:.17e}, \
2851 rel {rel:.3e} >= 1e-11"
2852 );
2853 }
2854 }
2855 }
2856
2857 // ── standard_normal_quantile ──────────────────────────────────────────────
2858
2859 #[test]
2860 fn quantile_rejects_out_of_range() {
2861 assert!(standard_normal_quantile(0.0).is_err());
2862 assert!(standard_normal_quantile(1.0).is_err());
2863 assert!(standard_normal_quantile(-0.1).is_err());
2864 assert!(standard_normal_quantile(1.1).is_err());
2865 assert!(standard_normal_quantile(f64::NAN).is_err());
2866 }
2867
2868 #[test]
2869 fn quantile_at_half_is_near_zero() {
2870 let q = standard_normal_quantile(0.5).unwrap();
2871 assert!(q.abs() < 1e-10, "quantile(0.5)={q}");
2872 }
2873
2874 #[test]
2875 fn quantile_at_0975_is_near_196() {
2876 let q = standard_normal_quantile(0.975).unwrap();
2877 assert!((q - 1.959_963_984_540_054).abs() < 1e-14, "q={q}");
2878 }
2879
2880 /// `standard_normal_quantile` and its log-CDF sibling, against a 120-digit
2881 /// root of `Φ(x) = p` (respectively `ln Φ(x) = log_p`).
2882 ///
2883 /// The seed is Acklam's rational approximation, whose accuracy is `1.15e-9`
2884 /// relative; the two Halley steps after it are what make the result
2885 /// ulp-accurate. Deleting the polish loop entirely leaves EVERY other
2886 /// quantile test in this module green except `quantile_roundtrip_cdf`, and
2887 /// that one only by a factor of 1.9 — so the polish had no real gate. This
2888 /// table is that gate: it fails by six orders if the seed ships unpolished.
2889 ///
2890 /// The grid straddles Acklam's own `P_LOW = 0.02425` branch on both sides,
2891 /// runs out to `p = 1e-300` where the seed is far from the root, and covers
2892 /// the reflected upper tail where the residual must be formed from
2893 /// `(1 − p) − ½erfc(x/√2)` rather than `Φ(x) − p`.
2894 /// The CENTRAL band, where the residual `F(x) − p` must never be formed
2895 /// against `½`.
2896 ///
2897 /// The sibling table above straddles Acklam's `P_LOW` branch and runs into
2898 /// both tails, but its tightest central point is `p = 0.5000000001`. That
2899 /// is not where the old residual failed. Forming `F(x) − p` as
2900 /// `(1 − p) − ½erfc(x/√2)` (or `F(x) − p` directly) subtracts two numbers
2901 /// of size ~½, so the residual carries a FIXED absolute error of one ulp of
2902 /// ½ however small the true residual is; `Δx ≈ residual_error / φ(x)` then
2903 /// pins the quantile's ABSOLUTE error at ~1.2e-16 and lets its RELATIVE
2904 /// error grow like `1.2e-16 / |x|` without bound as `p → ½`.
2905 ///
2906 /// Measured against a 50-digit `erfinv` reference at the exact `f64`
2907 /// abscissae below, before the centered residual and after:
2908 ///
2909 /// | `p` | before | after |
2910 /// |-----------------|----------|---------|
2911 /// | `½ + 2⁻⁴⁵` | 1.13e-09 | 2.3e-16 |
2912 /// | `0.5012506…` | 7.31e-15 | 2.3e-16 |
2913 /// | `0.4987493…` | 7.33e-15 | 2.3e-16 |
2914 ///
2915 /// The `1.13e-09` is not a coincidence: it is `|A[5] − √(2π)| / √(2π)`,
2916 /// Acklam's own advertised accuracy. As `p → ½` the seed reduces to
2917 /// `A[5]·(p − ½)` and the polish is handed a residual quantized to
2918 /// multiples of one ulp of ½ — usually exactly `0` — so the raw seed is
2919 /// what shipped.
2920 ///
2921 /// The bar is `4·f64::EPSILON` relative: half an ulp for the correctly
2922 /// rounded reference literal, the rest for the evaluator. Worst measured
2923 /// margin over this table is 1.0 ulp.
2924 #[test]
2925 fn normal_quantile_is_ulp_accurate_through_the_median() {
2926 // `[p, Φ⁻¹(p)]`, the second entry correctly rounded from a 50-digit
2927 // `sqrt(2)·erfinv(2p − 1)` evaluated at the EXACT binary `p`.
2928 const CENTRAL_REFERENCE: [[f64; 2]; 19] = [
2929 [0.5000000000000284, 7.124266047159724e-14],
2930 [0.4999999999999716, -7.124266047159724e-14],
2931 [0.5000000009313226, 2.3344794983332983e-09],
2932 [0.4999999990686774, -2.3344794983332983e-09],
2933 [0.5000009536743164, 2.390507006295574e-06],
2934 [0.500000001, 2.5066282037387115e-09],
2935 [0.4999999999, -2.506628482030354e-10],
2936 [0.5001, 0.00025066283008800747],
2937 [0.4999, -0.00025066283008800747],
2938 [0.51, 0.025068908258711057],
2939 [0.49, -0.025068908258711057],
2940 [0.55, 0.12566134685507416],
2941 [0.45, -0.12566134685507402],
2942 [0.6, 0.2533471031357997],
2943 [0.4, -0.2533471031357997],
2944 [0.7, 0.5244005127080407],
2945 [0.3, -0.5244005127080408],
2946 [0.75, 0.6744897501960817],
2947 [0.25, -0.6744897501960817],
2948 ];
2949 let bar = 4.0 * f64::EPSILON;
2950 let mut worst = 0.0_f64;
2951 let mut worst_at = f64::NAN;
2952 for [p, expected] in CENTRAL_REFERENCE {
2953 let got = standard_normal_quantile(p).expect("central p is in (0,1)");
2954 let relative = ((got - expected) / expected).abs();
2955 if relative > worst {
2956 worst = relative;
2957 worst_at = p;
2958 }
2959 assert!(
2960 relative <= bar,
2961 "Phi^-1({p}) = {got}, expected {expected}, relative {relative:e} > {bar:e}"
2962 );
2963 }
2964 println!("central quantile worst relative {worst:e} at p = {worst_at}");
2965 }
2966
2967 #[test]
2968 fn normal_quantiles_match_independent_high_precision_reference() {
2969 const QUANTILE_REFERENCE: [[f64; 2]; 22] = [
2970 [1e-300, -37.0470962993612],
2971 [1e-100, -21.273453560965326],
2972 [1e-20, -9.262340089798407],
2973 [1e-08, -5.612001244174789],
2974 [0.001, -3.0902323061678136],
2975 [0.02424, -1.9731366119445441],
2976 [0.02425, -1.972961051311885],
2977 [0.02426, -1.9727855514678605],
2978 [0.05, -1.6448536269514726],
2979 [0.1, -1.2815515655446004],
2980 [0.25, -0.6744897501960817],
2981 [0.4, -0.2533471031357997],
2982 [0.5, 0.0],
2983 [0.6, 0.2533471031357997],
2984 [0.75, 0.6744897501960817],
2985 [0.9, 1.2815515655446006],
2986 [0.95, 1.6448536269514722],
2987 [0.975, 1.9599639845400538],
2988 [0.99, 2.3263478740408408],
2989 [0.999, 3.090232306167813],
2990 [0.99999999, 5.612001243305505],
2991 [0.9999999999999999, 8.209536151601387],
2992 ];
2993 for [p, want] in QUANTILE_REFERENCE {
2994 let got = standard_normal_quantile(p).expect("p in (0,1) has a quantile");
2995 let error = (got - want).abs();
2996 // `Φ⁻¹(½) = 0` exactly, so it is the one absolute comparison.
2997 let budget = if want == 0.0 {
2998 1e-16
2999 } else {
3000 4e-15 * want.abs()
3001 };
3002 assert!(
3003 error <= budget,
3004 "Φ⁻¹({p}): got {got:.17e}, want {want:.17e} (error {error:.3e} > {budget:.3e})"
3005 );
3006 }
3007
3008 const LOG_CDF_QUANTILE_REFERENCE: [[f64; 2]; 9] = [
3009 [-0.7, -0.008559478582480282],
3010 [-2.0, -1.1015196284987503],
3011 [-10.0, -3.913946240531893],
3012 [-50.0, -9.674825283612357],
3013 [-200.0, -19.803669380301212],
3014 [-1000.0, -44.6157477319694],
3015 [-10000.0, -141.37983987312717],
3016 [-100000.0, -447.1978936785251],
3017 [-1000000.0, -1414.2077829910174],
3018 ];
3019 for [log_p, want] in LOG_CDF_QUANTILE_REFERENCE {
3020 let got =
3021 standard_normal_quantile_from_log_cdf(log_p).expect("finite log_p < 0 has a root");
3022 let error = (got - want).abs();
3023 // Rounding `log_p` itself to `f64` already moves the root by
3024 // `ulp(log_p)·dx/d(log_p)`, and `dx/d(log_p) = Φ/φ = 1/λ` — about
3025 // `1.25` near `p = ½` and `≈ 1/|x|` in the deep tail. That input
3026 // conditioning, not the solver, is what limits `log_p = −0.7`,
3027 // where the root sits at `−0.00856` and one ulp of `0.7` is already
3028 // `1.4e-16` of it.
3029 let conditioning = 8.0 * f64::EPSILON * log_p.abs() / want.abs().max(0.8);
3030 let budget = 4e-15 * want.abs() + conditioning;
3031 assert!(
3032 error <= budget,
3033 "Φ⁻¹(exp({log_p})): got {got:.17e}, want {want:.17e} \
3034 (error {error:.3e} > {budget:.3e})"
3035 );
3036 }
3037 }
3038
3039 #[test]
3040 fn quantile_antisymmetry() {
3041 let q_lo = standard_normal_quantile(0.1).unwrap();
3042 let q_hi = standard_normal_quantile(0.9).unwrap();
3043 assert!((q_lo + q_hi).abs() < 1e-10, "q_lo={q_lo} q_hi={q_hi}");
3044 }
3045
3046 #[test]
3047 fn quantile_roundtrip_cdf() {
3048 for &p in &[
3049 0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 0.999,
3050 ] {
3051 let q = standard_normal_quantile(p).unwrap();
3052 let p_back = normal_cdf(q);
3053 // RELATIVE, and sized by what the round trip can cost: a few ulp of
3054 // `q` propagated through `φ(q)`, plus a couple of ulp from `erfc`
3055 // itself. The former absolute `1e-10` bar was two orders looser than
3056 // an unpolished Acklam seed at its worst point.
3057 assert!(
3058 (p_back - p).abs() <= 1e-14 * p,
3059 "roundtrip failed at p={p}: q={q} p_back={p_back}"
3060 );
3061 }
3062 }
3063}
3064
3065/// The SIGNED, multiplicity-carrying form — the generalization the estimated-
3066/// scale references need (gam#2672).
3067/// Standard normal survival probability `P(Z > x)`.
3068///
3069/// This is evaluated as `½·erfc(x/√2)`, not as `1 − Φ(x)`. The latter loses
3070/// relative accuracy as soon as `Φ(x)` approaches one and becomes identically
3071/// zero for every representable `x` above roughly `8.3`, while the direct
3072/// complementary form retains the full representable tail.
3073#[inline]
3074pub fn normal_sf(x: f64) -> f64 {
3075 0.5 * erfc(x / std::f64::consts::SQRT_2)
3076}
3077
3078/// Student-t survival probability `P(T_ν > t)`.
3079///
3080/// The small tail is always obtained from
3081/// [`student_t_two_sided_probability`]. For negative `t`, subtracting its
3082/// half-tail from one constructs the large probability, where subtraction is
3083/// well conditioned.
3084pub fn student_t_sf(t: f64, degrees_of_freedom: f64) -> f64 {
3085 let two_sided = student_t_two_sided_probability(t, degrees_of_freedom);
3086 if t < 0.0 {
3087 1.0 - 0.5 * two_sided
3088 } else {
3089 0.5 * two_sided
3090 }
3091}
3092
3093#[cfg(test)]
3094mod signed_weighted_chi_square_tests {
3095 use super::*;
3096
3097 fn term(weight: f64, degrees_of_freedom: f64) -> WeightedChiSquareTerm {
3098 WeightedChiSquareTerm {
3099 weight,
3100 degrees_of_freedom,
3101 }
3102 }
3103
3104 /// THE identity the signed form exists for, against a closed form computed
3105 /// a completely different way (the regularized incomplete beta):
3106 ///
3107 /// ```text
3108 /// P(F_{a,b} > f) = P( (χ²_a/a) / (χ²_b/b) > f ) = P( χ²_a − (f·a/b)·χ²_b > 0 ).
3109 /// ```
3110 ///
3111 /// A ratio's tail IS a signed combination evaluated at zero. Fractional `a`
3112 /// is included because a two-moment summary of a smooth's null spectrum is a
3113 /// chi-square with a non-integral shape, which is exactly what this form is
3114 /// asked for.
3115 #[test]
3116 fn the_f_tail_is_the_two_term_signed_combination_at_zero() {
3117 let mut worst = 0.0_f64;
3118 for &(a, b) in &[
3119 (1.0_f64, 5.0_f64),
3120 (2.0, 17.0),
3121 (3.0, 26.0),
3122 (0.7, 24.0),
3123 (5.4, 191.0),
3124 (11.0, 4.0),
3125 ] {
3126 for &f in &[0.05_f64, 0.5, 1.0, 2.5, 9.0, 40.0] {
3127 let terms = [term(1.0, a), term(-f * a / b, b)];
3128 let (got, bound) = signed_weighted_chi_square_sf_to_tolerance(
3129 &terms,
3130 0.0,
3131 WEIGHTED_CHI_SQUARE_TOLERANCE,
3132 );
3133 let want = fisher_snedecor_sf(f, a, b);
3134 let error = (got - want).abs();
3135 worst = worst.max(error);
3136 assert!(
3137 error <= 1e-9 + bound,
3138 "F({a},{b}) at {f}: imhof {got} vs beta {want} \
3139 (error {error:.3e}, certified bound {bound:.3e})"
3140 );
3141 }
3142 }
3143 println!("worst |imhof − F| over the grid: {worst:.3e}");
3144 }
3145
3146 /// The certified bound at `statistic = 0` — where the oscillatory bound does
3147 /// not exist and the amplitude bound is the whole contract. Checked against
3148 /// a reference computed at a far stricter request, so the assertion is that
3149 /// the RETURNED bound actually bounds the error.
3150 #[test]
3151 fn the_amplitude_bound_certifies_the_zero_statistic_answer() {
3152 let cases: [&[WeightedChiSquareTerm]; 3] = [
3153 &[term(1.0, 1.0), term(-0.05, 26.0)],
3154 &[term(0.9, 1.0), term(0.2, 3.0), term(-0.01, 191.0)],
3155 &[term(1.0, 5.4), term(-2.5, 1.0), term(-0.004, 44.0)],
3156 ];
3157 for terms in cases {
3158 let (reference, reference_bound) =
3159 signed_weighted_chi_square_sf_to_tolerance(terms, 0.0, 1e-14);
3160 for tolerance in [1e-4_f64, 1e-7, 1e-10] {
3161 let (got, bound) =
3162 signed_weighted_chi_square_sf_to_tolerance(terms, 0.0, tolerance);
3163 assert!(
3164 bound <= tolerance,
3165 "asked {tolerance:.0e}, certified {bound:.3e} on {terms:?}"
3166 );
3167 assert!(
3168 (got - reference).abs() <= bound + reference_bound,
3169 "{got} vs {reference} exceeds the certified {bound:.3e} + \
3170 {reference_bound:.3e} on {terms:?}"
3171 );
3172 }
3173 }
3174 }
3175
3176 /// The panel rule has to resolve the integrand's AMPLITUDE, not only its
3177 /// phase, and this is the arm that measures whether it does.
3178 ///
3179 /// The reference is the same quadrature at a panel forced far below either
3180 /// rule (by asking for an accuracy the sizing then honours), so the
3181 /// comparison isolates the discretization from the truncation. The shapes
3182 /// are the ones where the two scales come apart: a small phase rate
3183 /// (`statistic = 0`, weights that nearly cancel) against an amplitude that
3184 /// turns over at `u ≈ 1`.
3185 ///
3186 /// Pre-fix, `F_{1,5}` at `f = 0.05` missed by `3.4e-7` while certifying
3187 /// `1e-11`.
3188 #[test]
3189 fn the_quadrature_resolves_the_amplitude_not_only_the_phase() {
3190 let cases: [&[WeightedChiSquareTerm]; 5] = [
3191 &[term(1.0, 1.0), term(-0.01, 5.0)],
3192 &[term(1.0, 1.0), term(-0.2, 2.0)],
3193 &[term(1.0, 3.0), term(-1.0, 3.0)],
3194 &[term(0.9, 1.0), term(0.2, 4.0), term(-0.05, 26.0)],
3195 &[term(1.0, 0.7), term(-0.006, 24.0)],
3196 ];
3197 let mut worst = 0.0_f64;
3198 for terms in cases {
3199 for &statistic in &[0.0_f64, 0.3, -0.2] {
3200 let (reference, reference_bound) =
3201 signed_weighted_chi_square_sf_to_tolerance(terms, statistic, 1e-15);
3202 let (got, bound) = signed_weighted_chi_square_sf_to_tolerance(
3203 terms,
3204 statistic,
3205 WEIGHTED_CHI_SQUARE_TOLERANCE,
3206 );
3207 let error = (got - reference).abs();
3208 worst = worst.max(error);
3209 assert!(
3210 error <= bound + reference_bound,
3211 "{terms:?} at {statistic}: {got} vs {reference} differs by {error:.3e}, \
3212 above the certified {bound:.3e} + {reference_bound:.3e}"
3213 );
3214 }
3215 }
3216 println!("worst discretization error against the fine-panel reference: {worst:.3e}");
3217 }
3218}