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