gam_math/special.rs
1//! Scalar special-function primitives shared across the workspace.
2//!
3//! These are pure (`std`/`libm`-only) numeric kernels with no upward crate
4//! dependencies, so they live in the lowest crate (`gam-math`) and can be
5//! consumed by any term/basis/inference code without inducing an SCC edge.
6
7/// Numerically stable `C(n,k) = n! / (k!·(n−k)!)` as `f64`. Uses the
8/// symmetry `C(n,k) = C(n, n−k)` to keep the loop count `min(k, n−k)`
9/// and the multiplicative recurrence `C(n,j+1) = C(n,j)·(n−j)/(j+1)`,
10/// avoiding the overflow of separate factorial evaluations. Returns
11/// `0.0` for `k > n` and exact integer results within `2^53`.
12#[inline]
13pub fn binomial_coefficient_f64(n: usize, k: usize) -> f64 {
14 if k > n {
15 return 0.0;
16 }
17 if k == 0 || k == n {
18 return 1.0;
19 }
20 let k_eff = k.min(n - k);
21 // Carry the recurrence in u128, not f64. At step `j` the running product
22 // equals the integer `C(n, j)`, which is always divisible by the next
23 // denominator `(j + 1)` (the partial product of `(j+1)` consecutive
24 // integers `(n−j)…(n)` is divisible by `(j+1)!`), so each integer division
25 // is exact and no rounding accumulates. The earlier all-`f64` recurrence
26 // divided in floating point, where `(n−j)/(j+1)` is generally inexact, and
27 // the drift pushed results off the true integer well below `2^53`
28 // (e.g. `C(54,24)` came back one short). Converting the exact `u128` at the
29 // end is bit-exact for every value at or below `2^53`.
30 let mut num: u128 = 1;
31 for j in 0..k_eff {
32 match num.checked_mul((n - j) as u128) {
33 Some(scaled) => num = scaled / (j as u128 + 1),
34 None => {
35 // The true coefficient overflows u128 — astronomically above
36 // `2^53`, where the exactness contract no longer applies.
37 // Finish the (now necessarily inexact) recurrence in f64.
38 let mut out = num as f64;
39 for jj in j..k_eff {
40 out = out * (n - jj) as f64 / (jj + 1) as f64;
41 }
42 return out;
43 }
44 }
45 }
46 num as f64
47}
48
49#[inline]
50fn horner_polynomial(x: f64, coeffs: &[f64]) -> f64 {
51 coeffs.iter().rev().fold(0.0, |acc, &c| acc * x + c)
52}
53
54/// Evaluate `(Σ_k coeffs[k]·x^k) · exp(−x)` without overflow. For moderate
55/// `x ≤ 600` uses Horner + `exp(−x)` directly; for very large `x` rewrites
56/// `xᵈ · exp(−x) = exp(d·ln x − x)` and runs Horner in `1/x`, which keeps
57/// both the polynomial sum and its multiplier inside double range. Returns
58/// `0.0` for non-finite `x` or empty `coeffs`.
59#[inline]
60pub fn stable_polynomial_times_exp_neg(x: f64, coeffs: &[f64]) -> f64 {
61 if coeffs.is_empty() || !x.is_finite() {
62 return 0.0;
63 }
64 // Below this argument `(-x).exp()` is still well-resolved, so the direct
65 // Horner-times-exp form is both accurate and cheapest. Above it the factor
66 // underflows toward zero and we switch to the convergent asymptotic tail
67 // series to retain the leading significant digits.
68 const DIRECT_EXP_SWITCH: f64 = 600.0;
69 if x <= DIRECT_EXP_SWITCH {
70 return horner_polynomial(x, coeffs) * (-x).exp();
71 }
72
73 let inv_x = x.recip();
74 let mut tail = 0.0;
75 for &c in coeffs {
76 tail = tail * inv_x + c;
77 }
78 let degree = (coeffs.len() - 1) as f64;
79 let scale = (degree * x.ln() - x).exp();
80 scale * tail
81}
82
83/// Argument at which the modified-Bessel evaluation switches from the ascending
84/// power series to the large-argument (Hankel) asymptotic expansion.
85///
86/// Both branches are accurate to a few ulp here, which is what leaves the
87/// crossover free of a visible seam. Below it the ascending series is exact up
88/// to rounding, because every one of its terms is positive and nothing cancels
89/// — with the one exception of the `I0 − I1` difference the branch also carries,
90/// whose sign change costs a documented `√x`. Above it the asymptotic
91/// expansion's optimal-truncation error is `O(e^{−2x})` — below `5e−18` already
92/// at `x = 20`, and shrinking from there.
93///
94/// That `O(e^{−2x})` is the VALUE channel's floor, and only its. Differentiating
95/// an asymptotic series term by term multiplies the `k`-th term by `k`, and both
96/// derivative accumulators additionally carry the `x²` factored out of their
97/// powers, so their own smallest term is larger by `≈ k·x² ≈ 2x³`. Measured, the
98/// curvature channel's optimal truncation is `1.6e−14` absolute at the crossover
99/// — against a numerator of `1/8` — so `c''` cannot be better than `≈ 1e−13`
100/// relative there no matter how the loop is truncated. It achieves `2.5e−13`,
101/// within a factor of two of that floor. Anyone tightening `CURVATURE_TOL`
102/// further is chasing a bound the expansion itself does not admit; the fix would
103/// have to be a different expansion, not a different stopping rule.
104///
105/// The former implementation used the single-precision Abramowitz & Stegun
106/// 9.8.1–9.8.4 minimax polynomials (crossover 3.75), whose stated accuracy is
107/// `|ε| < 2e−7`. That is seven digits short of `f64` and it was the accuracy
108/// floor of everything derived from them: `I1/I0` carried `1e−6` relative
109/// error, the von-Mises ARD gradient channel `x·(I1/I0 − 1)` carried `4e−6`,
110/// and the ARD log-precision curvature carried `6e−3` — a 0.6% error in a
111/// quantity an outer Newton step consumes as an exact second derivative, with
112/// visible jumps across both the 3.75 and the 30 branch seams.
113const BESSEL_ASYMPTOTIC_THRESHOLD: f64 = 20.0;
114
115/// Loop bound for the ascending series. It converges for every argument; below
116/// the crossover 36 terms always suffice, so the cap only bounds the loop for a
117/// non-finite argument that slipped past the guards.
118const BESSEL_SERIES_MAX_TERMS: usize = 128;
119
120/// Loop bound for the asymptotic expansion. The expansion is divergent, so it
121/// is truncated at its own smallest term long before this for every argument it
122/// is used at; the cap also keeps the coefficient recurrence itself in range.
123const BESSEL_ASYMPTOTIC_MAX_TERMS: usize = 64;
124
125/// Ascending power series `(I0(x) − 1, I1(x), I0(x) − I1(x))` for finite `x ≥ 0`.
126///
127/// `I0(x) = Σ_k (x/2)^{2k}/(k!)²` and `I1(x) = (x/2)·Σ_k (x/2)^{2k}/(k!(k+1)!)`,
128/// both carried by the ratio recurrence rather than by separate factorials.
129/// Every term is positive, so neither sum cancels and each is correct to within
130/// the accumulated rounding of its own additions.
131///
132/// `I0 − 1` is returned instead of `I0` so the caller can take `ln_1p`: as
133/// `x → 0` the wanted `log I0(x) ≈ x²/4` falls below the resolution of
134/// `1 + x²/4`, and forming `I0` first would round it away entirely.
135///
136/// `I0 − I1` is returned as a sum in its OWN right, for the same reason the
137/// large-argument branch carries its `N = Σ (b_k − c_k) x^{−(k−1)}`: the caller
138/// wants `d1 = x(I1/I0 − 1) = −x·(I0 − I1)/I0`, and `I0 − I1 ≈ I0/(2x)` is
139/// smaller than either sum by the whole factor `2x`, so forming it by
140/// subtracting the two finished sums throws away `log₂(2x)` bits — five of them
141/// by the top of this branch's range. Writing `z = x/2`, the two series share a
142/// term ratio, `u_k/t_k = z/(k+1)` with `t_k = z^{2k}/(k!)²`, so the difference
143/// is summed directly as
144///
145/// `I0 − I1 = Σ_k t_k·(1 − z/(k+1)) = Σ_k t_k·(k+1−z)/(k+1)`.
146///
147/// That sum does change sign, at `k+1 = z`, so it is not cancellation-free the
148/// way `I0` and `I1` individually are; but its terms are damped by the very
149/// factor that vanishes there, and its condition number `Σ|terms|/|Σ terms|`
150/// grows only like `√x` — 6.8 at `x = 20`, against the 40 of the naive
151/// difference. Pairing termwise is what turns those five lost bits into three.
152fn bessel_ascending_series(ax: f64) -> BesselAscending {
153 let half = 0.5 * ax;
154 let quarter_square = half * half;
155 let mut term_i0 = 1.0_f64;
156 let mut i0_minus_one = 0.0_f64;
157 let mut term_i1 = 1.0_f64;
158 let mut sum_i1 = 1.0_f64;
159 // `k = 0`: `t_0 = 1` and the pairing factor is `(0+1−z)/(0+1)`.
160 let mut i0_minus_i1 = 1.0 - half;
161 for k in 1..=BESSEL_SERIES_MAX_TERMS {
162 let kf = k as f64;
163 term_i0 *= quarter_square / (kf * kf);
164 term_i1 *= quarter_square / (kf * (kf + 1.0));
165 i0_minus_one += term_i0;
166 sum_i1 += term_i1;
167 i0_minus_i1 += term_i0 * (kf + 1.0 - half) / (kf + 1.0);
168 // The difference sum sets the stopping rule, because it is the smallest
169 // of the three: cutting off at `ε·I0` would leave IT with a relative
170 // error of `2x·ε`, which is exactly the error this pairing exists to
171 // remove. Past the peak the terms fall factorially, so demanding the
172 // extra `log₂(2x)` bits costs only a couple of iterations.
173 if term_i0 <= f64::EPSILON * i0_minus_i1.abs()
174 && term_i0 <= f64::EPSILON * (1.0 + i0_minus_one)
175 && term_i1 <= f64::EPSILON * sum_i1
176 {
177 break;
178 }
179 }
180 BesselAscending {
181 i0_minus_one,
182 i1: half * sum_i1,
183 i0_minus_i1,
184 }
185}
186
187/// The three ascending-series sums, each accumulated in its own right.
188struct BesselAscending {
189 /// `I0(x) − 1`.
190 i0_minus_one: f64,
191 /// `I1(x)`.
192 i1: f64,
193 /// `I0(x) − I1(x)`, summed termwise rather than by subtracting the two.
194 i0_minus_i1: f64,
195}
196
197/// One evaluation of the large-argument (Hankel) asymptotic expansions of `I0`
198/// and `I1`, kept in the combinations the callers need so that every leading
199/// term cancels ANALYTICALLY here instead of in floating point.
200///
201/// With `I_ν(x) ~ e^x/√(2πx) · Σ_k (−1)^k a_k(ν) x^{−k}` and
202/// `a_k(ν) = ∏_{j=1}^{k} (4ν² − (2j−1)²) / (k!·8^k)`, write `c_k = (−1)^k a_k(0)`
203/// and `b_k = (−1)^k a_k(1)`; then `c_0 = b_0 = 1` and both families obey a
204/// two-term ratio recurrence, so no coefficient table is needed.
205struct BesselAsymptotic {
206 /// `S0 = Σ_{k≥0} c_k x^{−k}`, so `I0(x) = e^x S0 / √(2πx)`.
207 s0: f64,
208 /// `S1 = Σ_{k≥0} b_k x^{−k}`, so `I1/I0 = S1/S0`.
209 s1: f64,
210 /// `N = Σ_{k≥1} (b_k − c_k) x^{−(k−1)}`, so `d1 = x(I1/I0 − 1) = N/S0`.
211 ///
212 /// The `k = 0` terms of `S1` and `S0` are both exactly `1`, so they are
213 /// dropped symbolically and the difference series starts at its own leading
214 /// term `b_1 − c_1 = −1/2` — which is precisely the `d1 → −½` limit. No
215 /// near-equal quantities are ever subtracted at run time.
216 n: f64,
217 /// `x²·S0′ = Σ_{k≥1} (−k) c_k x^{−(k−1)}`.
218 s0_scaled_derivative: f64,
219 /// `x²·N′ = Σ_{k≥2} −(k−1)(b_k − c_k) x^{−(k−2)}`.
220 ///
221 /// Both derivative accumulators carry the common `x²` factored out, which
222 /// is what keeps `c″(log x) = (x²N′·S0 − N·x²S0′)/(x·S0²)` representable —
223 /// and non-zero — out to the largest finite argument, where the unscaled
224 /// `x^{−k}` factors would have underflowed to zero.
225 n_scaled_derivative: f64,
226}
227
228fn bessel_asymptotic_series(ax: f64) -> BesselAsymptotic {
229 let inverse = 1.0 / ax;
230 let mut c = 1.0_f64;
231 let mut b = 1.0_f64;
232 let mut acc = BesselAsymptotic {
233 s0: 1.0,
234 s1: 1.0,
235 n: 0.0,
236 s0_scaled_derivative: 0.0,
237 n_scaled_derivative: 0.0,
238 };
239 // `x^{−(k−2)}` and `x^{−(k−1)}` at the current `k`, carried as their own
240 // running products so that a power which has overflowed is never multiplied
241 // by one which has underflowed.
242 let mut power_two_back = ax;
243 let mut power_one_back = 1.0_f64;
244 let mut smallest = f64::INFINITY;
245 for k in 1..=BESSEL_ASYMPTOTIC_MAX_TERMS {
246 let kf = k as f64;
247 let odd = 2.0 * kf - 1.0;
248 c *= odd * odd / (8.0 * kf);
249 b *= (odd * odd - 4.0) / (8.0 * kf);
250 let power = power_one_back * inverse;
251 let term_c = c * power;
252 // The expansion is asymptotic, not convergent: past its smallest term
253 // every further term makes the answer worse. Stopping there is what
254 // realises the `O(e^{−2x})` optimal-truncation error. The negated
255 // comparison also stops on a NaN argument.
256 if !(term_c.abs() <= smallest) {
257 break;
258 }
259 smallest = term_c.abs();
260 let difference = b - c;
261 let curvature_term = (kf - 1.0) * difference * power_two_back;
262 acc.s0 += term_c;
263 acc.s1 += b * power;
264 acc.n += difference * power_one_back;
265 acc.s0_scaled_derivative -= kf * c * power_one_back;
266 if k >= 2 {
267 acc.n_scaled_derivative -= curvature_term;
268 }
269 // `n_scaled_derivative` carries the largest power of the four sums, so
270 // once ITS increment is negligible every other one is too.
271 let scale = acc.n_scaled_derivative.abs().max(acc.n.abs());
272 if k >= 3 && curvature_term.abs() <= f64::EPSILON * scale {
273 break;
274 }
275 power_two_back = power_one_back;
276 power_one_back = power;
277 }
278 acc
279}
280
281/// Overflow-free centered Bessel value, ratio, and log-scale derivative.
282///
283/// For `x = |eta|`, returns
284/// `(log I0(x) - x, I1(x) / I0(x), x d/dx[log I0(x) - x])`. The third term is
285/// the stable form of `x·(I1/I0 - 1)`: it approaches `-½` instead of becoming
286/// `x·0` after the ordinary ratio rounds to one. Centering the logarithm by its
287/// leading `x` term likewise prevents catastrophic cancellation.
288pub fn bessel_i0_centered_terms(eta: f64) -> (f64, f64, f64) {
289 let ax = eta.abs();
290 if ax.is_nan() {
291 return (f64::NAN, f64::NAN, f64::NAN);
292 }
293 if ax.is_infinite() {
294 // `−½ log(2πx) → −∞`, `I1/I0 → 1`, and the centered log-derivative
295 // holds its exact `−½` limit.
296 return (f64::NEG_INFINITY, 1.0, -0.5);
297 }
298 if ax < BESSEL_ASYMPTOTIC_THRESHOLD {
299 let series = bessel_ascending_series(ax);
300 let i0 = 1.0 + series.i0_minus_one;
301 // `d1 = x(I1/I0 − 1) = −x·(I0 − I1)/I0`, taken from the difference the
302 // series accumulated itself. Forming `ax * (ratio - 1.0)` here instead
303 // would reintroduce the very `2x` cancellation the large-argument branch
304 // is careful to avoid, and would leave a visible accuracy seam at the
305 // crossover: `1 − ratio` is `0.025` at `x = 20`, so a correctly rounded
306 // `ratio` still pins `d1` no tighter than `4e−15`.
307 return (
308 series.i0_minus_one.ln_1p() - ax,
309 series.i1 / i0,
310 -ax * (series.i0_minus_i1 / i0),
311 );
312 }
313 let series = bessel_asymptotic_series(ax);
314 (
315 // `log I0(x) − x = −½ log(2πx) + log S0`. The `2πx` product is split so
316 // it cannot overflow just short of the largest finite argument.
317 series.s0.ln() - 0.5 * (std::f64::consts::TAU.ln() + ax.ln()),
318 series.s1 / series.s0,
319 series.n / series.s0,
320 )
321}
322
323/// Stable centered Bessel terms when only `log(|eta|)` is representable.
324///
325/// For a finite representable `|eta|`, this is exactly
326/// [`bessel_i0_centered_terms`]. Beyond the float range, inverse-`eta`
327/// corrections are themselves below float resolution, so the limiting terms
328/// `log I0(eta)-eta = -½ log(2 pi eta)` and
329/// `eta d/deta[log I0(eta)-eta] = -½` are the correctly rounded result.
330pub fn bessel_i0_centered_terms_from_log_abs(log_abs_eta: f64) -> (f64, f64, f64) {
331 if log_abs_eta.is_nan() {
332 return (f64::NAN, f64::NAN, f64::NAN);
333 }
334 if log_abs_eta == f64::NEG_INFINITY {
335 return (0.0, 0.0, 0.0);
336 }
337 if log_abs_eta <= f64::MAX.ln() {
338 return bessel_i0_centered_terms(log_abs_eta.exp());
339 }
340 (-0.5 * (std::f64::consts::TAU.ln() + log_abs_eta), 1.0, -0.5)
341}
342
343/// Second log-scale derivative of the centered Bessel primitive:
344/// `d²/d(log η)²[log I0(η) − η]`, i.e. the derivative of the third term `d1`
345/// returned by [`bessel_i0_centered_terms`] (`d1 = η d/dη[log I0(η) − η]`).
346///
347/// Writing `s = log η`, `r = I1(η)/I0(η)`, and `c(s) = log I0(η) − η`, the first
348/// log-derivative is `c'(s) = d1 = η(r − 1)`. Differentiating again and using
349/// the modified-Bessel ratio ODE `r'(η) = 1 − r/η − r²` gives the exact closed
350/// form `c''(s) = −η + η²(1 − r²)`. That direct form is numerically unusable
351/// for moderate/large `η`: its two terms each grow like `η` and cancel to
352/// `O(1/η)`, so the ratio's `~ε_poly` approximation error is amplified by `η²`.
353/// The algebraically identical rearrangement in terms of the STABLE third term
354///
355/// `c''(s) = −η(2·d1 + 1) − d1²`
356///
357/// cancels safely instead: `d1 → −½` with `2·d1 + 1 → 0`, so the amplification
358/// drops to `η·δd1`. It is also, by construction, the exact derivative of the
359/// SAME `d1` the outer gradient's periodic-ARD normalizer channel reports, so
360/// gradient and Hessian differentiate one quantity. Beyond the float range
361/// `c'(s) → −½` (constant) so `c''(s) → 0`; likewise `η → 0` gives `c''(s) → 0`.
362/// The von-Mises ARD log-precision normalizer `n[−η + log I0(η)]` therefore has
363/// `∂²/∂(log α)² = n · c''(log η)` up to the affine `log η = log α + const` shift.
364///
365/// Three regimes, each chosen so that nothing cancels in it:
366///
367/// * `η ≥ 20`: read `c''(s) = η(N′S0 − N S0′)/S0²` straight off the asymptotic
368/// expansion (`BesselAsymptotic`). Its two products are `3/16` and `1/16` at
369/// leading order — a benign ratio, where the `−η(2d1+1)` and `d1²` of the
370/// closed form both approach `¼` and cancel down to `1/(8η)`.
371/// * `1 ≤ η < 20`: the closed form, with that `¼` removed symbolically. Writing
372/// `q = d1 + ½` (which decays like `−1/(8η)`), `d1² = q² − q + ¼` and
373/// `−2ηq − ¼ = −2η(q + 1/(8η))`, leaving `c''(s) = −2η(q + 1/(8η)) + q − q²`
374/// with no constant term for the answer to be dwarfed by.
375///
376/// Removing the constant is not the same as removing the amplification, and
377/// this branch keeps the latter. `q` is only ever known to `d1`'s own absolute
378/// error, so `δc'' ≈ 2η·δd1`, while the answer it sits on is `|c''| ≈ 1/(8η)`
379/// — a RELATIVE amplification of `16η²·δd1` that grows quadratically across
380/// the branch. `d1` in turn carries `|d1|·κ(η)·ε` from the `I0 − I1` sum whose
381/// condition number `κ = Σ|terms|/|Σ terms|` grows like `√η` (3.1 at `η = 5`,
382/// 6.8 at `η = 20`), so the floor of this representation is `≈ 8η²·κ(η)·ε`:
383///
384/// ```text
385/// η 5 10 15 19 20⁻
386/// floor 3.9e−14 3.3e−13 1.0e−12 2.0e−12 2.2e−12
387/// worst 1.9e−13 1.2e−12 3.3e−12 7.4e−12 8.9e−12
388/// ```
389///
390/// Measured against an 80-digit reference over 24000 points, the branch holds
391/// a uniform 3−5x of that floor across its whole range, peaking at `8.9e−12`
392/// just under the crossover; the asymptotic branch resumes at `1.6e−13` on
393/// the far side.
394/// That step is a property of the two representations, not a mis-placed
395/// threshold: the asymptotic expansion's own truncation error at this channel
396/// is `1.0e−12` at `η = 19` and `6.6e−12` at `η = 18`, so the two curves
397/// cross within a few tenths of where the code already switches and no
398/// choice of threshold caps the band below `≈ 4e−12`.
399///
400/// Nor is it reachable by a better formula in `f64`. The cancellation is
401/// intrinsic to the ascending representation rather than to how it is
402/// collected: accumulating the whole numerator `I0 − 2η(I0 − I1)` termwise —
403/// the same pairing trick that buys the `I0 − I1` sum its `√η` — gives terms
404/// `t_k·[(k+1)(1−2η) + η²/... ]` whose `Σ|terms|/|Σ terms|` is again `8η²`,
405/// because the leading `¼` cancels BETWEEN terms of one series and not within
406/// any term. Closing the band needs `d1` carried wider than `f64`, and its one
407/// consumer — the von-Mises ARD log-precision Hessian entry, where the
408/// pre-2025 A&S polynomials delivered `6e−3` — is nine orders clear of caring.
409/// * `η < 1`: `c''(s) = −η + η²(1 − r²) = −η·[1 + d1(1 + r)]`, whose bracket
410/// tends to `1`. The rearrangement above would instead subtract two numbers
411/// that both tend to `¼` while the answer itself tends to `−η`.
412pub fn bessel_i0_centered_second_log_derivative_from_log_abs(log_abs_eta: f64) -> f64 {
413 if log_abs_eta.is_nan() {
414 return f64::NAN;
415 }
416 if log_abs_eta == f64::NEG_INFINITY {
417 return 0.0;
418 }
419 if log_abs_eta > f64::MAX.ln() {
420 return 0.0;
421 }
422 let eta = log_abs_eta.exp();
423 if eta >= BESSEL_ASYMPTOTIC_THRESHOLD {
424 let series = bessel_asymptotic_series(eta);
425 return (series.n_scaled_derivative * series.s0 - series.n * series.s0_scaled_derivative)
426 / (eta * series.s0 * series.s0);
427 }
428 let (_centered, ratio, d1) = bessel_i0_centered_terms(eta);
429 if eta < 1.0 {
430 return -eta * (1.0 + d1 * (1.0 + ratio));
431 }
432 let q = d1 + 0.5;
433 -2.0 * eta * (q + 0.125 / eta) + q - q * q
434}
435
436/// Overflow-free `(log I0(eta) - |eta|, I1(|eta|) / I0(|eta|))`.
437///
438/// Centering the logarithm by its leading `|eta|` term is essential whenever a
439/// likelihood cancels the Bessel growth against an equally large quadratic,
440/// as in a Gaussian-blurred circle. The large-argument branch never forms
441/// `exp(|eta|)`, and therefore remains finite beyond the ordinary exponential
442/// overflow threshold and up to the largest finite `f64`.
443pub fn bessel_i0_log_minus_abs_and_ratio(eta: f64) -> (f64, f64) {
444 let (centered_log_i0, ratio, _) = bessel_i0_centered_terms(eta);
445 (centered_log_i0, ratio)
446}
447
448/// Overflow-free `(log I0(eta), I1(|eta|) / I0(|eta|))`.
449///
450/// Consumers whose formulas cancel the leading `|eta|` term should use
451/// [`bessel_i0_log_minus_abs_and_ratio`] directly, rather than forming that
452/// cancellation after this function returns.
453pub fn bessel_i0_log_and_ratio(eta: f64) -> (f64, f64) {
454 let (centered_log_i0, ratio) = bessel_i0_log_minus_abs_and_ratio(eta);
455 (eta.abs() + centered_log_i0, ratio)
456}
457
458/// Argument above which the polygamma family switches from its downward
459/// recurrence to the Bernoulli asymptotic series.
460///
461/// The series is divergent, but its terms only start growing near `x ≈ πk`, so
462/// at this threshold each of the four functions below is already limited by
463/// `f64` rounding rather than by truncation — see the per-function notes for
464/// the first omitted term. The recurrence that walks a small argument up to
465/// here costs one reciprocal and one add per unit step.
466const POLYGAMMA_ASYMPTOTIC_THRESHOLD: f64 = 20.0;
467
468/// Digamma `ψ(x) = d/dx ln Γ(x)`, for `x > 0`; `NaN` otherwise.
469///
470/// Recurrence `ψ(x) = ψ(x+1) − 1/x` up to the threshold, then
471/// `ψ(x) ~ ln x − 1/(2x) − Σ_{k≥1} B_{2k}/(2k·x^{2k})`. Carried through
472/// `B₁₂`, so the first omitted term is `1/(12x¹⁴)` — `5e−20` at `x = 20`,
473/// against `ψ(20) ≈ 2.97`.
474pub fn digamma(mut x: f64) -> f64 {
475 if !(x.is_finite() && x > 0.0) {
476 return f64::NAN;
477 }
478 let mut recurrence = 0.0_f64;
479 while x < POLYGAMMA_ASYMPTOTIC_THRESHOLD {
480 recurrence -= 1.0 / x;
481 x += 1.0;
482 }
483 let inv = 1.0 / x;
484 let inv2 = inv * inv;
485 // −1/12 + w/120 − w²/252 + w³/240 − w⁴/132 + 691w⁵/32760, w = 1/x².
486 let series = horner_polynomial(
487 inv2,
488 &[
489 -1.0 / 12.0,
490 1.0 / 120.0,
491 -1.0 / 252.0,
492 1.0 / 240.0,
493 -1.0 / 132.0,
494 691.0 / 32_760.0,
495 ],
496 );
497 recurrence + x.ln() - 0.5 * inv + inv2 * series
498}
499
500/// Trigamma `ψ₁(x) = d²/dx² ln Γ(x)`, for `x > 0`; `NaN` otherwise.
501///
502/// Recurrence `ψ₁(x) = ψ₁(x+1) + 1/x²`, then
503/// `ψ₁(x) ~ 1/x + 1/(2x²) + Σ_{k≥1} B_{2k}/x^{2k+1}`. Carried through `B₁₂`,
504/// first omitted `7/(6x¹⁵)` — `4e−20` at `x = 20` against `ψ₁(20) ≈ 0.051`.
505pub fn trigamma(mut x: f64) -> f64 {
506 if !(x.is_finite() && x > 0.0) {
507 return f64::NAN;
508 }
509 let mut recurrence = 0.0_f64;
510 while x < POLYGAMMA_ASYMPTOTIC_THRESHOLD {
511 recurrence += 1.0 / (x * x);
512 x += 1.0;
513 }
514 let inv = 1.0 / x;
515 let inv2 = inv * inv;
516 // 1/6 − w/30 + w²/42 − w³/30 + 5w⁴/66 − 691w⁵/2730, w = 1/x².
517 let series = horner_polynomial(
518 inv2,
519 &[
520 1.0 / 6.0,
521 -1.0 / 30.0,
522 1.0 / 42.0,
523 -1.0 / 30.0,
524 5.0 / 66.0,
525 -691.0 / 2_730.0,
526 ],
527 );
528 recurrence + inv + 0.5 * inv2 + inv2 * inv * series
529}
530
531/// Tetragamma `ψ₂(x) = d³/dx³ ln Γ(x)`, for `x > 0`; `NaN` otherwise.
532///
533/// Recurrence `ψ₂(x) = ψ₂(x+1) − 2/x³`, then the `n = 2` case of
534/// `ψ⁽ⁿ⁾(x) ~ (−1)^{n−1}[(n−1)!/xⁿ + n!/(2x^{n+1})
535/// + Σ_k B_{2k}(2k+n−1)!/((2k)!·x^{2k+n})]`. Carried through `B₁₂`, first
536/// omitted `17.5/x¹⁶` — `3e−20` at `x = 20` against `|ψ₂(20)| ≈ 2.6e−3`.
537pub fn tetragamma(mut x: f64) -> f64 {
538 if !(x.is_finite() && x > 0.0) {
539 return f64::NAN;
540 }
541 let mut recurrence = 0.0_f64;
542 while x < POLYGAMMA_ASYMPTOTIC_THRESHOLD {
543 recurrence -= 2.0 / (x * x * x);
544 x += 1.0;
545 }
546 let inv = 1.0 / x;
547 let inv2 = inv * inv;
548 // Coefficients B_{2k}(2k+1): 1/2, −1/6, 1/6, −3/10, 5/6, −691/210.
549 let series = horner_polynomial(
550 inv2,
551 &[
552 0.5,
553 -1.0 / 6.0,
554 1.0 / 6.0,
555 -3.0 / 10.0,
556 5.0 / 6.0,
557 -691.0 / 210.0,
558 ],
559 );
560 recurrence - (inv2 + inv2 * inv + inv2 * inv2 * series)
561}
562
563/// Pentagamma `ψ₃(x) = d⁴/dx⁴ ln Γ(x)`, for `x > 0`; `NaN` otherwise.
564///
565/// Recurrence `ψ₃(x) = ψ₃(x+1) + 6/x⁴`, then the `n = 3` case of the same
566/// expansion. Carried through `B₁₂`, first omitted `280/x¹⁷` — `2e−20` at
567/// `x = 20` against `ψ₃(20) ≈ 2.6e−4`.
568pub fn pentagamma(mut x: f64) -> f64 {
569 if !(x.is_finite() && x > 0.0) {
570 return f64::NAN;
571 }
572 let mut recurrence = 0.0_f64;
573 while x < POLYGAMMA_ASYMPTOTIC_THRESHOLD {
574 recurrence += 6.0 / (x * x * x * x);
575 x += 1.0;
576 }
577 let inv = 1.0 / x;
578 let inv2 = inv * inv;
579 // Coefficients B_{2k}(2k+1)(2k+2): 2, −1, 4/3, −3, 10, −691·182/2730.
580 let series = horner_polynomial(
581 inv2,
582 &[2.0, -1.0, 4.0 / 3.0, -3.0, 10.0, -691.0 * 182.0 / 2_730.0],
583 );
584 recurrence + 2.0 * inv2 * inv + 3.0 * inv2 * inv2 + inv2 * inv2 * inv * series
585}
586
587/// Gauss-Legendre nodes and weights on `[-1, 1]` for `n` points, computed via
588/// Newton iteration on the Legendre-polynomial roots (Bonnet's three-term
589/// recurrence, cosine initial guess). Returns `(nodes, weights)` with nodes
590/// ascending; for odd `n` the central node is exactly `0.0`.
591///
592/// Canonical home for the routine previously triplicated in
593/// `gam-terms/basis/closed_form_penalty.rs`, `gam-model-kernels/
594/// cubic_cell_kernel.rs`, and `gam-models/survival/base.rs`; this copy keeps
595/// the tightest of their Newton settings (200-iteration cap, `1e-15`
596/// convergence).
597pub fn gauss_legendre(n: usize) -> (Vec<f64>, Vec<f64>) {
598 let mut tmp: Vec<(f64, f64)> = Vec::with_capacity(n);
599 let half = n.div_ceil(2);
600 for i in 0..half {
601 let mut z = (std::f64::consts::PI * (i as f64 + 0.75) / (n as f64 + 0.5)).cos();
602 // `(P_n(z), P_n'(z))` by Bonnet's recurrence, with the derivative taken
603 // from `P_n'(z) = n(z·P_n − P_{n−1})/(z² − 1)`.
604 let legendre_value_and_slope = |z: f64| {
605 let mut p1 = 1.0_f64;
606 let mut p2 = 0.0_f64;
607 for j in 0..n {
608 let p3 = p2;
609 p2 = p1;
610 p1 = ((2.0 * j as f64 + 1.0) * z * p2 - j as f64 * p3) / (j as f64 + 1.0);
611 }
612 (p1, n as f64 * (z * p1 - p2) / (z * z - 1.0))
613 };
614 for _ in 0..200 {
615 let (p1, pp) = legendre_value_and_slope(z);
616 let z_prev = z;
617 z = z_prev - p1 / pp;
618 if (z - z_prev).abs() < 1e-15 {
619 break;
620 }
621 }
622 // Re-evaluate `P_n'` AT the node being returned. The loop leaves `pp`
623 // one Newton step stale — it was formed at `z_prev`, and `z` has since
624 // moved by up to the `1e-15` break threshold — while the weight below
625 // reads the fresh `z` in its `(1 − z²)`. Mixing the two is not a wash:
626 // Legendre's equation gives `P_n'' = 2z·P_n'/(1 − z²)` at a root, so a
627 // node offset `δ` lands in the weight amplified by `2·2z/(1 − z²)`,
628 // which runs to ~5900 for the outermost node at `n = 128`. One more
629 // Bonnet pass costs `O(n)` against the `O(n·iterations)` already spent
630 // per node and removes it: worst weight error falls 8.3e-14 -> 1.9e-15
631 // at `n = 16` and 8.2e-14 -> 5.6e-15 at `n = 32`.
632 //
633 // It does NOT move the WORST case past `n ≈ 64`, where the same
634 // amplification acts instead on the node's own irreducible ~1 ulp: the
635 // outer nodes crowd toward ±1, `1 − z²` falls to `3e-4`, and the
636 // weights there hold ~3e-13 however `pp` is evaluated. (The mean still
637 // improves — 1.2e-14 -> 8.2e-15 at `n = 128` — with a handful of outer
638 // weights moving an ulp either way, which is the level the node residual
639 // already sets.) Escaping that bound needs a weight formula that does
640 // not route through `P_n'(z)` at all, not a better Newton loop.
641 //
642 // In particular it is NOT reachable by correcting for the node offset,
643 // which is the obvious thing to try next and was measured. Substituting
644 // Legendre's equation at a root collapses the weight's two sensitivities
645 // to a single `d(log w)/dz = −2z/(1 − z²)`, and the offset to the true
646 // root is one Newton step, `δ = −P_n(z)/P_n'(z)` — whose `P_n(z)` the
647 // Bonnet pass on the next line already computes and discards. So the
648 // first-order correction `w·(1 + 2z·(P_n/P_n')/(1 − z²))` is free, and
649 // it is exact: fed a `δ` from an 80-digit reference it drives the weight
650 // error to 1e-25 at every `n` tried. The derivation is not the problem.
651 //
652 // What kills it is `δ`'s own resolution. Bonnet evaluates a `P_n` that
653 // is sitting AT its root to an absolute `≈ n·ε`, so the correction
654 // carries noise `2z·(n·ε/P_n')/(1 − z²)` — and that noise is within an
655 // order of the term it is removing across the whole range (outermost
656 // node: `5.6e−16` term vs `2.8e−15` noise at `n = 16`, `1.5e−13` vs
657 // `4.6e−14` at `n = 256`). The net over `n ∈ {16..256}` is a coin flip
658 // decided by how close each node happened to land — 3.8x better at
659 // `n = 128`, 13x worse at `n = 200` — so the correction is not applied.
660 // Making it pay needs `P_n` evaluated wider than `f64`, at which point
661 // the node itself may as well be.
662 let (_, pp) = legendre_value_and_slope(z);
663 let w = 2.0 / ((1.0 - z * z) * pp * pp);
664 // For odd n the central node is at z = 0; record once.
665 if !n.is_multiple_of(2) && i == half - 1 {
666 tmp.push((0.0, w));
667 } else {
668 tmp.push((-z.abs(), w));
669 tmp.push((z.abs(), w));
670 }
671 }
672 tmp.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
673 let mut nodes = Vec::with_capacity(n);
674 let mut weights = Vec::with_capacity(n);
675 for (z, w) in tmp.into_iter().take(n) {
676 nodes.push(z);
677 weights.push(w);
678 }
679 (nodes, weights)
680}
681
682/// Gauss-Lobatto nodes and weights on `[-1, 1]`, ascending, for `n >= 2`.
683///
684/// The closed sibling of [`gauss_legendre`]: the two endpoints are nodes,
685/// and the `n - 2` interior nodes are the roots of `P'_{n-1}`, with
686///
687/// ```text
688/// w_endpoint = 2 / (n (n − 1)), w_i = 2 / (n (n − 1) P_{n−1}(x_i)²).
689/// ```
690///
691/// It integrates polynomials of degree `2n − 3` exactly, two degrees short
692/// of the open rule of the same size, which buys the property the open rule
693/// cannot have: a quantity known to matter AT a segment boundary is a node
694/// of the rule rather than a point the rule steps over. An event time in a
695/// counting process is exactly that — the intensity is read there, so the
696/// compensator must be integrated with the same instant carrying weight.
697///
698/// The interior roots come from the Chebyshev-Lobatto starting points
699/// `cos(π i / (n − 1))` by Newton on `P'_{n−1}`, whose second derivative is
700/// taken from Legendre's equation, `(1 − x²)P'' = 2xP' − m(m + 1)P`.
701pub fn gauss_lobatto(n: usize) -> (Vec<f64>, Vec<f64>) {
702 assert!(n >= 2, "a Gauss-Lobatto rule needs at least the two endpoints");
703 let m = n - 1;
704 // `(P_m, P_m', P_m'')` by Bonnet's recurrence and Legendre's equation.
705 let legendre = |x: f64| -> (f64, f64, f64) {
706 let mut p1 = 1.0_f64;
707 let mut p2 = 0.0_f64;
708 for j in 0..m {
709 let p3 = p2;
710 p2 = p1;
711 p1 = ((2.0 * j as f64 + 1.0) * x * p2 - j as f64 * p3) / (j as f64 + 1.0);
712 }
713 if (1.0 - x * x).abs() < f64::EPSILON {
714 // At the endpoints `P_m' = ±m(m+1)/2` and the second derivative
715 // is finite too, but neither is needed there: the endpoints are
716 // nodes by construction, never Newton targets.
717 return (p1, 0.0, 0.0);
718 }
719 let derivative = m as f64 * (x * p1 - p2) / (x * x - 1.0);
720 let second = (2.0 * x * derivative - (m * (m + 1)) as f64 * p1) / (1.0 - x * x);
721 (p1, derivative, second)
722 };
723 let endpoint_weight = 2.0 / (n * m) as f64;
724 let mut nodes = Vec::with_capacity(n);
725 let mut weights = Vec::with_capacity(n);
726 nodes.push(-1.0);
727 weights.push(endpoint_weight);
728 for i in 1..m {
729 let mut x = (std::f64::consts::PI * i as f64 / m as f64).cos();
730 for _ in 0..200 {
731 let (_, derivative, second) = legendre(x);
732 let previous = x;
733 x = previous - derivative / second;
734 if (x - previous).abs() < 1e-15 {
735 break;
736 }
737 }
738 let (value, _, _) = legendre(x);
739 nodes.push(x);
740 weights.push(endpoint_weight / (value * value));
741 }
742 nodes.push(1.0);
743 weights.push(endpoint_weight);
744 let mut order: Vec<usize> = (0..n).collect();
745 order.sort_by(|&a, &b| nodes[a].total_cmp(&nodes[b]));
746 let sorted_nodes: Vec<f64> = order.iter().map(|&i| nodes[i]).collect();
747 let sorted_weights: Vec<f64> = order.iter().map(|&i| weights[i]).collect();
748 (sorted_nodes, sorted_weights)
749}
750
751// ---------------------------------------------------------------------------
752// Exponential-family scalar kernels.
753//
754// The stable forms of the handful of scalar maps every deviance / KL / working-
755// weight evaluation is built from. Each is written so that its result is
756// accurate in the regime where the naive formula cancels (small arguments,
757// probabilities near the boundary) and so that no branch forms an intermediate
758// that overflows before the final, representable value. They are consumed by
759// the CPU PIRLS deviance path and by the host reference of the device PIRLS
760// row kernels; the two used to carry byte-identical private copies (#2470).
761// ---------------------------------------------------------------------------
762
763/// `softplus(x) = ln(1 + e^x)`, evaluated as `max(x, 0) + ln(1 + e^{-|x|})`
764/// so that neither tail overflows and the small-`x` result keeps full
765/// relative accuracy.
766#[inline]
767pub fn softplus(x: f64) -> f64 {
768 x.max(0.0) + (-x.abs()).exp().ln_1p()
769}
770
771/// The logistic function `σ(x) = 1 / (1 + e^{-x})`, oriented so the
772/// exponential is always of a non-positive argument.
773#[inline]
774pub fn logistic(x: f64) -> f64 {
775 if x >= 0.0 {
776 1.0 / (1.0 + (-x).exp())
777 } else {
778 let e = x.exp();
779 e / (1.0 + e)
780 }
781}
782
783/// `x·ln(y)` with the convention `0·ln(0) = 0` used by every deviance kernel.
784#[inline]
785pub fn xlogy(x: f64, y: f64) -> f64 {
786 if x == 0.0 { 0.0 } else { x * y.ln() }
787}
788
789/// `ln(e^a + e^b)` without forming either exponential at full scale; returns
790/// `-∞` when both arguments are `-∞`.
791#[inline]
792pub fn logaddexp(a: f64, b: f64) -> f64 {
793 let hi = a.max(b);
794 let lo = a.min(b);
795 if hi == f64::NEG_INFINITY {
796 f64::NEG_INFINITY
797 } else {
798 hi + (lo - hi).exp().ln_1p()
799 }
800}
801
802/// `e^x − 1 − x`, the second-order remainder of the exponential. For
803/// `|x| ≤ 1/2` the Taylor tail is summed directly so the result does not
804/// cancel against `x`; beyond that `exp_m1` is accurate on its own.
805#[inline]
806pub fn expm1_minus_x(x: f64) -> f64 {
807 if x.abs() > 0.5 {
808 return x.exp_m1() - x;
809 }
810 let mut term = 0.5 * x * x;
811 let mut sum = term;
812 let mut k = 2.0;
813 loop {
814 k += 1.0;
815 term *= x / k;
816 let next = sum + term;
817 if next == sum {
818 return next;
819 }
820 sum = next;
821 }
822}
823
824/// `ln(1 + x) − x`, the second-order remainder of the logarithm, with the
825/// same small-argument series treatment as [`expm1_minus_x`].
826#[inline]
827pub fn log1p_minus_x(x: f64) -> f64 {
828 if x.abs() > 0.5 {
829 return x.ln_1p() - x;
830 }
831 let mut power = x * x;
832 let mut sign = -1.0;
833 let mut k = 2.0;
834 let mut sum = sign * power / k;
835 loop {
836 power *= x;
837 sign = -sign;
838 k += 1.0;
839 let next = sum + sign * power / k;
840 if next == sum {
841 return next;
842 }
843 sum = next;
844 }
845}
846
847/// The relative exponential `exprel(x) = (e^x − 1) / x`, equal to `1` at
848/// `x = 0` and summed as a series for `|x| ≤ 1/2`.
849#[inline]
850pub fn exprel(x: f64) -> f64 {
851 if x == 0.0 {
852 return 1.0;
853 }
854 if x.abs() > 0.5 {
855 return x.exp_m1() / x;
856 }
857 let mut term = 1.0;
858 let mut sum = term;
859 let mut k = 1.0;
860 loop {
861 k += 1.0;
862 term *= x / k;
863 let next = sum + term;
864 if next == sum {
865 return next;
866 }
867 sum = next;
868 }
869}
870
871/// `ln(exprel(x))`, with the large-`|x|` branches written so that no
872/// exponential of a positive argument is ever formed.
873#[inline]
874pub fn log_exprel(x: f64) -> f64 {
875 if x == 0.0 {
876 0.0
877 } else if x.abs() <= 0.5 {
878 exprel(x).ln()
879 } else if x > 0.0 {
880 x + (-(-x).exp()).ln_1p() - x.ln()
881 } else {
882 (-x.exp()).ln_1p() - (-x).ln()
883 }
884}
885
886/// `ln|1 − e^x|` for `x ≠ 0`, routed through
887/// [`crate::probability::log1mexp_positive`] on both sides of the origin.
888#[inline]
889pub fn log_abs_one_minus_exp(x: f64) -> f64 {
890 if x > 0.0 {
891 x + crate::probability::log1mexp_positive(x)
892 } else {
893 crate::probability::log1mexp_positive(-x)
894 }
895}
896
897/// The Bregman divergence `bd0(x, m) = x·ln(x/m) + m − x` of the Poisson
898/// deviance (Loader's `bd0`), summed as a series in `(x − m)/(x + m)` when the
899/// two arguments are within 20% of each other so the two ~equal logarithms
900/// never cancel. `bd0(0, m) = m` exactly.
901#[inline]
902pub fn bd0(x: f64, m: f64) -> f64 {
903 if x == 0.0 {
904 return m;
905 }
906 if x == m {
907 return 0.0;
908 }
909 let hi = x.max(m);
910 let lo = x.min(m);
911 let relative_gap = (x - m).abs() / hi;
912 if relative_gap < 0.2 {
913 let v = ((x - m) / hi) / (1.0 + lo / hi);
914 let mut sum = (x - m) * v;
915 let mut ej = 2.0 * (x * v);
916 let v2 = v * v;
917 let mut denominator = 3.0;
918 loop {
919 ej *= v2;
920 let next = sum + ej / denominator;
921 if next == sum {
922 return next;
923 }
924 sum = next;
925 denominator += 2.0;
926 }
927 }
928 x * (x.ln() - m.ln()) + (m - x)
929}
930
931/// Bernoulli KL divergence in natural coordinates, `KL(σ(a) ‖ σ(b))`,
932/// without subtracting an entropy from a cross entropy. For `|b − a| ≤ 1/2`
933/// only second-order remainders are evaluated; the tail branches orient the
934/// event so the reference probability never rounds to one.
935#[inline]
936pub fn bernoulli_kl_from_logits(a: f64, b: f64) -> f64 {
937 if a == b {
938 return 0.0;
939 }
940 let h = b - a;
941 if h.abs() <= 0.5 {
942 // Orient toward the rarer reference event. Without this swap a large
943 // positive `a` rounds `σ(a)` to one and erases a representable
944 // right-tail KL channel.
945 let (p, local_h) = if a <= 0.0 {
946 (logistic(a), h)
947 } else {
948 (logistic(-a), -h)
949 };
950 let em1 = local_h.exp_m1();
951 let x = p * em1;
952 return log1p_minus_x(x) + p * expm1_minus_x(local_h);
953 }
954 if a <= 0.0 {
955 let p = logistic(a);
956 p * (a - b) + softplus(b) - softplus(a)
957 } else {
958 let q = logistic(-a);
959 q * (b - a) + softplus(-b) - softplus(-a)
960 }
961}
962
963// ---------------------------------------------------------------------------
964// Binary-exponent arithmetic.
965// ---------------------------------------------------------------------------
966
967/// Exact power-of-two decomposition `x = mantissa · 2^exponent` for a positive
968/// finite `f64`, including subnormals. The mantissa lies in `[1, 2)`.
969#[inline]
970pub fn positive_frexp(x: f64) -> (f64, i32) {
971 assert!(x.is_finite() && x > 0.0);
972 let bits = x.to_bits();
973 let raw_exp = ((bits >> 52) & 0x7ff) as i32;
974 let fraction = bits & ((1_u64 << 52) - 1);
975 if raw_exp != 0 {
976 let mantissa = f64::from_bits((1023_u64 << 52) | fraction);
977 (mantissa, raw_exp - 1023)
978 } else {
979 let leading = 63_i32 - fraction.leading_zeros() as i32;
980 let shift = 52_i32 - leading;
981 let normalized = fraction << shift;
982 let mantissa = f64::from_bits((1023_u64 << 52) | (normalized & ((1_u64 << 52) - 1)));
983 (mantissa, -1022 - shift)
984 }
985}
986
987/// `mantissa · 2^exponent` for a positive mantissa, renormalising the mantissa
988/// into `[1, 2)` first so the result overflows or underflows only when the
989/// final `f64` itself is unrepresentable. Subnormal results are formed by
990/// scaling in units of the least positive subnormal, so IEEE rounds the final
991/// value once instead of underflowing an intermediate.
992#[inline]
993pub fn scale_normalized_power_of_two(mut mantissa: f64, mut exponent: i32) -> f64 {
994 while mantissa >= 2.0 {
995 mantissa *= 0.5;
996 exponent += 1;
997 }
998 while mantissa < 1.0 {
999 mantissa *= 2.0;
1000 exponent -= 1;
1001 }
1002 if exponent > 1023 {
1003 return f64::INFINITY;
1004 }
1005 if exponent >= -1022 {
1006 let power = f64::from_bits(((exponent + 1023) as u64) << 52);
1007 return mantissa * power;
1008 }
1009 if exponent < -1075 {
1010 return 0.0;
1011 }
1012 let units = mantissa * 2.0_f64.powi(exponent + 1074);
1013 units * f64::from_bits(1)
1014}
1015
1016/// `a·b·c/d` for positive finite inputs, carrying the binary exponent
1017/// separately so an intermediate overflow or underflow cannot change a
1018/// representable final result.
1019#[inline]
1020pub fn scaled_positive_product_quotient(a: f64, b: f64, c: f64, d: f64) -> f64 {
1021 assert!(a.is_finite() && a > 0.0);
1022 assert!(b.is_finite() && b > 0.0);
1023 assert!(c.is_finite() && c > 0.0);
1024 assert!(d.is_finite() && d > 0.0);
1025 let (ma, ea) = positive_frexp(a);
1026 let (mb, eb) = positive_frexp(b);
1027 let (mc, ec) = positive_frexp(c);
1028 let (md, ed) = positive_frexp(d);
1029 scale_normalized_power_of_two((ma * mb) * (mc / md), ea + eb + ec - ed)
1030}
1031
1032#[cfg(test)]
1033mod exponential_family_kernel_tests {
1034 use super::*;
1035
1036 #[test]
1037 fn softplus_and_logistic_agree_with_their_definitions_away_from_the_tails() {
1038 for &x in &[-3.0_f64, -0.7, 0.0, 0.4, 2.5] {
1039 assert!((softplus(x) - (1.0 + x.exp()).ln()).abs() <= 4.0 * f64::EPSILON);
1040 assert!((logistic(x) - 1.0 / (1.0 + (-x).exp())).abs() <= 4.0 * f64::EPSILON);
1041 }
1042 assert_eq!(softplus(800.0), 800.0);
1043 assert_eq!(softplus(-800.0), 0.0);
1044 }
1045
1046 #[test]
1047 fn remainders_match_the_direct_formula_where_it_does_not_cancel() {
1048 for &x in &[-0.75_f64, 0.6, 1.5] {
1049 assert!((expm1_minus_x(x) - (x.exp_m1() - x)).abs() <= 8.0 * f64::EPSILON);
1050 assert!((log1p_minus_x(x) - (x.ln_1p() - x)).abs() <= 8.0 * f64::EPSILON);
1051 assert!((exprel(x) - x.exp_m1() / x).abs() <= 8.0 * f64::EPSILON);
1052 assert!((log_exprel(x) - (x.exp_m1() / x).ln()).abs() <= 8.0 * f64::EPSILON);
1053 }
1054 // The series branch keeps relative accuracy where the naive form
1055 // would return pure cancellation noise.
1056 let x = 1.0e-6;
1057 let series = expm1_minus_x(x);
1058 assert!((series - 0.5 * x * x).abs() <= 1.0e-6 * 0.5 * x * x);
1059 assert!((log1p_minus_x(x) + 0.5 * x * x).abs() <= 1.0e-6 * 0.5 * x * x);
1060 }
1061
1062 #[test]
1063 fn bd0_is_the_poisson_bregman_divergence() {
1064 assert_eq!(bd0(0.0, 2.5), 2.5);
1065 assert_eq!(bd0(3.0, 3.0), 0.0);
1066 for &(x, m) in &[(3.0_f64, 2.0_f64), (10.0, 10.5), (0.2, 7.0)] {
1067 let direct = x * (x / m).ln() + m - x;
1068 assert!((bd0(x, m) - direct).abs() <= 16.0 * f64::EPSILON * direct.abs().max(1.0));
1069 }
1070 }
1071
1072 #[test]
1073 fn bernoulli_kl_from_logits_is_the_kl_divergence_between_the_two_bernoullis() {
1074 for &(a, b) in &[(0.3_f64, -0.2_f64), (-2.0, -1.8), (4.0, 1.0), (0.0, 0.0)] {
1075 let p = logistic(a);
1076 let q = logistic(b);
1077 let direct = xlogy(p, p / q) + xlogy(1.0 - p, (1.0 - p) / (1.0 - q));
1078 assert!((bernoulli_kl_from_logits(a, b) - direct).abs() <= 1.0e-13);
1079 }
1080 }
1081
1082 #[test]
1083 fn logaddexp_and_log_abs_one_minus_exp_handle_their_edge_cases() {
1084 assert_eq!(logaddexp(f64::NEG_INFINITY, f64::NEG_INFINITY), f64::NEG_INFINITY);
1085 assert!((logaddexp(1.0, 2.0) - (1.0_f64.exp() + 2.0_f64.exp()).ln()).abs() <= 4.0 * f64::EPSILON);
1086 assert!((log_abs_one_minus_exp(-1.0) - (1.0 - (-1.0_f64).exp()).ln()).abs() <= 4.0 * f64::EPSILON);
1087 assert!((log_abs_one_minus_exp(1.0) - (1.0_f64.exp() - 1.0).ln()).abs() <= 4.0 * f64::EPSILON);
1088 }
1089
1090 #[test]
1091 fn binary_exponent_arithmetic_round_trips_and_survives_intermediate_overflow() {
1092 for &x in &[1.0_f64, 0.3, 1.0e300, 5.0e-320, f64::MIN_POSITIVE] {
1093 let (mantissa, exponent) = positive_frexp(x);
1094 assert!((1.0..2.0).contains(&mantissa));
1095 assert_eq!(scale_normalized_power_of_two(mantissa, exponent), x);
1096 }
1097 // The inputs are decimal literals, so the exact product is a few ulps
1098 // off the decimal result; the point is that no intermediate overflowed
1099 // or underflowed on the way there.
1100 let got = scaled_positive_product_quotient(1.0e-300, 1.0, 1.0e308, 1.0);
1101 assert!((got - 1.0e8).abs() <= 4.0 * f64::EPSILON * 1.0e8);
1102 let got = scaled_positive_product_quotient(1.0e-300, 1.0e-200, 1.0, 1.0e-300);
1103 assert!((got - 1.0e-200).abs() <= 4.0 * f64::EPSILON * 1.0e-200);
1104 assert_eq!(scaled_positive_product_quotient(2.0, 3.0, 5.0, 4.0), 7.5);
1105 }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111
1112 #[test]
1113 fn gauss_lobatto_includes_its_endpoints_and_is_exact_to_two_n_minus_three() {
1114 for n in 2..=12 {
1115 let (nodes, weights) = gauss_lobatto(n);
1116 assert_eq!(nodes.len(), n);
1117 assert!((nodes[0] + 1.0).abs() < 1e-15 && (nodes[n - 1] - 1.0).abs() < 1e-15);
1118 assert!(nodes.windows(2).all(|w| w[1] > w[0]), "nodes not ascending: {nodes:?}");
1119 assert!(weights.iter().all(|w| *w > 0.0), "weights {weights:?}");
1120 // Exact for every monomial up to degree 2n - 3.
1121 for degree in 0..=(2 * n - 3) {
1122 let quadrature: f64 = nodes
1123 .iter()
1124 .zip(weights.iter())
1125 .map(|(x, w)| w * x.powi(degree as i32))
1126 .sum();
1127 let exact = if degree % 2 == 1 { 0.0 } else { 2.0 / (degree as f64 + 1.0) };
1128 assert!(
1129 (quadrature - exact).abs() < 1e-12 * (1.0 + exact.abs()),
1130 "n={n} degree={degree}: {quadrature} vs {exact}"
1131 );
1132 }
1133 }
1134 }
1135
1136 #[test]
1137 fn centered_bessel_log_is_finite_and_derivative_consistent() {
1138 for eta in [0.25_f64, 1.0, 3.74, 3.76, 12.0, 900.0] {
1139 let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms(eta);
1140 assert!(centered.is_finite());
1141 assert!((0.0..=1.0).contains(&ratio));
1142
1143 // The tolerances below are sized by what a CENTRAL DIFFERENCE can
1144 // resolve — roundoff `ε·|f|/h` plus truncation `h²·f'''/6` — not by
1145 // what the evaluator happens to achieve. The A&S polynomials this
1146 // replaced needed `1e-6`/`2e-5` here; the series/asymptotic pair
1147 // leaves the finite difference itself as the limiting error.
1148 let h = 1.0e-4 * eta.max(1.0);
1149 let (plus, _) = bessel_i0_log_and_ratio(eta + h);
1150 let (minus, _) = bessel_i0_log_and_ratio(eta - h);
1151 let derivative = (plus - minus) / (2.0 * h);
1152 assert!(
1153 (derivative - ratio).abs() <= 1.0e-8,
1154 "d/dη log I0 mismatch at eta={eta}: analytic={ratio}, finite_difference={derivative}"
1155 );
1156
1157 let log_step = 1.0e-5_f64;
1158 let (centered_plus, _, _) = bessel_i0_centered_terms(eta * log_step.exp());
1159 let (centered_minus, _, _) = bessel_i0_centered_terms(eta * (-log_step).exp());
1160 let finite_difference = (centered_plus - centered_minus) / (2.0 * log_step);
1161 assert!(
1162 (finite_difference - scaled_derivative).abs() < 1.0e-8,
1163 "centered Bessel value/gradient mismatch at eta={eta}: analytic={scaled_derivative}, finite_difference={finite_difference}"
1164 );
1165 }
1166 for eta in [1.0e20_f64, 1.0e100, 1.0e300] {
1167 let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms(eta);
1168 let asymptotic = -0.5 * (std::f64::consts::TAU * eta).ln();
1169 assert!(centered.is_finite() && ratio.is_finite());
1170 // The `log S0` remainder is below `1e-20` at these arguments, so the
1171 // only admissible gap is the differing association of the two `log`
1172 // groupings — a few ulp of a number of size ~`log η`.
1173 assert!(
1174 (centered - asymptotic).abs() < 1.0e-13,
1175 "large-eta centered log must equal -½log(2πη); eta={eta:e}, centered={centered}, asymptotic={asymptotic}"
1176 );
1177 assert!(
1178 (scaled_derivative + 0.5).abs() < 1.0e-15,
1179 "large-eta centered derivative must retain its -1/2 limit; eta={eta:e}, derivative={scaled_derivative}"
1180 );
1181 }
1182
1183 assert_eq!(bessel_i0_centered_terms(0.0), (0.0, 0.0, 0.0));
1184
1185 let log_eta = 1_200.0;
1186 let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms_from_log_abs(log_eta);
1187 assert!(centered.is_finite());
1188 assert_eq!(ratio, 1.0);
1189 assert_eq!(scaled_derivative, -0.5);
1190 assert_eq!(centered, -0.5 * (std::f64::consts::TAU.ln() + log_eta));
1191 }
1192
1193 #[test]
1194 fn centered_bessel_second_log_derivative_matches_finite_difference() {
1195 // c''(log η) must be the derivative of the third term (c'(log η)) of
1196 // `bessel_i0_centered_terms`, across small, mid, and large arguments.
1197 // c''(log η) is the log-derivative of the STABLE third term `d1` (the
1198 // quantity the outer gradient's ARD normalizer channel reports), so the
1199 // self-consistent reference is a central difference of that same term.
1200 // The sweep straddles every seam this function has ever had: the retired
1201 // A&S 3.75 and 30 seams, and the live 1.0 (small-η rearrangement) and
1202 // 20.0 (series/asymptotic) ones.
1203 let first_log_derivative = |x: f64| bessel_i0_centered_terms(x).2;
1204 for eta in [
1205 0.02_f64, 0.05, 0.25, 0.999, 1.0, 1.001, 2.0, 3.5, 4.0, 8.0, 19.9, 20.1, 29.9, 30.1,
1206 ] {
1207 let log_eta = eta.ln();
1208 let analytic = bessel_i0_centered_second_log_derivative_from_log_abs(log_eta);
1209
1210 let log_step = 1.0e-6_f64;
1211 let first_plus = first_log_derivative(eta * log_step.exp());
1212 let first_minus = first_log_derivative(eta * (-log_step).exp());
1213 let finite_difference = (first_plus - first_minus) / (2.0 * log_step);
1214 // `ε·|d1|/log_step ≈ 1e-10` of central-difference roundoff is the
1215 // floor here; the analytic value is far better than that. The old
1216 // `5e-5 + 1e-3·|analytic|` band was three orders wider than the
1217 // finite difference could even be wrong by — it was sized to the
1218 // 0.6% error the A&S polynomials put into `analytic`.
1219 assert!(
1220 (analytic - finite_difference).abs() < 1.0e-8 + 1.0e-6 * analytic.abs(),
1221 "centered Bessel second log-derivative mismatch at eta={eta}: \
1222 analytic={analytic}, finite_difference={finite_difference}"
1223 );
1224 }
1225 // Large-η decay: the normalizer curvature vanishes like the leading
1226 // asymptotic term 1/(8η) (its Hessian contribution is then negligible
1227 // beside the ∝α energy term), stays finite and positive, and the
1228 // overflow-free gateway rounds it to exactly zero past the float range.
1229 // Held against THREE terms of the expansion rather than one, so the
1230 // admissible band is the size of the first omitted term (`≲ 2/η⁴`)
1231 // instead of a 25% shrug.
1232 for eta in [50.0_f64, 200.0, 1.0e4] {
1233 let c2 = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
1234 let inverse = 1.0 / eta;
1235 let expansion = inverse * (0.125 + inverse * (0.25 + inverse * (75.0 / 128.0)));
1236 assert!(
1237 c2 > 0.0 && (c2 - expansion).abs() < 8.0 * inverse.powi(4),
1238 "large-eta centered second derivative must track its own expansion; \
1239 eta={eta}, c2={c2}, expansion={expansion}"
1240 );
1241 }
1242 // η → 0 and the overflow-free large-|η| gateway both round to 0.
1243 assert_eq!(
1244 bessel_i0_centered_second_log_derivative_from_log_abs(f64::NEG_INFINITY),
1245 0.0
1246 );
1247 assert_eq!(
1248 bessel_i0_centered_second_log_derivative_from_log_abs(1_200.0),
1249 0.0
1250 );
1251 }
1252
1253 /// Every quantity `bessel_i0_centered_terms` and the second log-derivative
1254 /// return, against an INDEPENDENT 60-decimal-digit evaluation of the same
1255 /// closed forms (`mpmath.besseli`, `mpmath.diff`), rounded to `f64`.
1256 ///
1257 /// This is the assertion the module lacked. Everything else here is a
1258 /// self-consistency check — a finite difference of the evaluator against
1259 /// the evaluator — and a self-consistent evaluator can be uniformly wrong.
1260 /// The A&S 9.8.x polynomials this replaced were exactly that: internally
1261 /// consistent to the last digit and off the true value by up to `4e-6` in
1262 /// `d1` and `6e-3` in the curvature, with steps at their branch seams. No
1263 /// test in the tree compared them to anything but themselves.
1264 #[test]
1265 fn bessel_primitives_match_independent_high_precision_reference() {
1266 // (η, log I0(η) − η, I1(η)/I0(η), η(I1/I0 − 1), d²/d(log η)²[log I0 − η])
1267 const REFERENCE: [[f64; 5]; 24] = [
1268 [
1269 1e-06,
1270 -9.9999975e-07,
1271 4.999999999999375e-07,
1272 -9.999995e-07,
1273 -9.99999e-07,
1274 ],
1275 [
1276 0.001,
1277 -0.000999750000015625,
1278 0.0004999999375000105,
1279 -0.0009995000000625,
1280 -0.00099900000025,
1281 ],
1282 [
1283 0.05,
1284 -0.049375097629132,
1285 0.024992190753810217,
1286 -0.048750390462309494,
1287 -0.04750156152399669,
1288 ],
1289 [
1290 0.25,
1291 -0.23443561468661894,
1292 0.12403350191792471,
1293 -0.21899162452051882,
1294 -0.18846151934987648,
1295 ],
1296 [
1297 0.5,
1298 -0.4384502808145187,
1299 0.24249961258080194,
1300 -0.378750193709599,
1301 -0.2647015155254598,
1302 ],
1303 [
1304 1.0,
1305 -0.7640856414928213,
1306 0.4463899658965345,
1307 -0.5536100341034655,
1308 -0.19926400165310923,
1309 ],
1310 [
1311 2.0,
1312 -1.1760064585170438,
1313 0.697774657964008,
1314 -0.604450684071984,
1315 0.05244210681284669,
1316 ],
1317 [
1318 3.75,
1319 -1.5396457880279808,
1320 0.8531704594530685,
1321 -0.5506107770509933,
1322 0.0764086000777509,
1323 ],
1324 [
1325 5.0,
1326 -1.6953182241774665,
1327 0.8933831370440852,
1328 -0.5330843147795739,
1329 0.0466642611317311,
1330 ],
1331 [
1332 8.0,
1333 -1.941895744572186,
1334 0.9352354935294386,
1335 -0.5181160517644912,
1336 0.02141258513583364,
1337 ],
1338 [
1339 12.0,
1340 -2.1504975008971563,
1341 0.9573814053952422,
1342 -0.5114231352570932,
1343 0.01260162289404047,
1344 ],
1345 [
1346 17.0,
1347 -2.327961358737179,
1348 0.9701275885919403,
1349 -0.5078309939370159,
1350 0.008361475455484893,
1351 ],
1352 [
1353 19.5,
1354 -2.397561575434808,
1355 0.9740118676091061,
1356 -0.5067685816224307,
1357 0.007160287955186735,
1358 ],
1359 [
1360 19.999999,
1361 -2.410389546426233,
1362 0.9746705066059314,
1363 -0.5065898425518784,
1364 0.006960420318717729,
1365 ],
1366 [
1367 20.0,
1368 -2.4103895717557258,
1369 0.9746705078898071,
1370 -0.5065898422038575,
1371 0.006960419930170057,
1372 ],
1373 [
1374 20.000001,
1375 -2.410389597085217,
1376 0.9746705091736827,
1377 -0.5065898418558366,
1378 0.006960419541622429,
1379 ],
1380 [
1381 25.0,
1382 -2.5232719950007563,
1383 0.9797914534905159,
1384 -0.5052136627371017,
1385 0.005442291838848013,
1386 ],
1387 [
1388 30.0,
1389 -2.615298566828064,
1390 0.9831895553653361,
1391 -0.5043133390399173,
1392 0.004468398461442669,
1393 ],
1394 [
1395 64.0,
1396 -2.996411436485784,
1397 0.9921564935488112,
1398 -0.5019844128760834,
1399 0.002016497368136742,
1400 ],
1401 [
1402 150.0,
1403 -3.423420049648141,
1404 0.9966610736828279,
1405 -0.5008389475758167,
1406 0.0008446213361703931,
1407 ],
1408 [
1409 900.0,
1410 -4.319996948727984,
1411 0.9994442899516907,
1412 -0.5001390434784159,
1413 0.0001391983371050074,
1414 ],
1415 [
1416 10000.0,
1417 -5.524096218567699,
1418 0.999949998749875,
1419 -0.5000125012501954,
1420 1.2502500586100053e-05,
1421 ],
1422 [
1423 1000000.0,
1424 -7.826693687186747,
1425 0.999999499999875,
1426 -0.500000125000125,
1427 1.2500025000058594e-07,
1428 ],
1429 [
1430 1000000000000.0,
1431 -14.734449091168822,
1432 0.9999999999995,
1433 -0.500000000000125,
1434 1.2500000000025e-13,
1435 ],
1436 ];
1437
1438 // Sized from the arithmetic, not from the outcome. The value and ratio
1439 // are read off sums that cannot cancel, so they land within a few ulp.
1440 // `d1` now shares that footing on BOTH branches: each reads it off a
1441 // difference series accumulated in its own right — `N/S0` above the
1442 // crossover, `−x(I0−I1)/I0` below it — rather than by subtracting the
1443 // ratio from one. The ascending difference series does have a sign
1444 // change, so it carries a condition number, but that grows only like
1445 // `√x` (6.8 at the crossover) instead of the `1/(1 − I1/I0)` ≈ 40 of the
1446 // naive form: tens of ulp, not thousands.
1447 //
1448 // The curvature is the one term still amplified, inheriting ≈ 2η from
1449 // `d1`'s ABSOLUTE error — `40 · 1e−15 / 0.007 ≈ 6e−12` just under the
1450 // crossover, where `c''` is smallest and `η` already large. That is
1451 // intrinsic to reaching `c''` through a `d1` held in one f64: `q = d1+½`
1452 // is `−0.0066` there, so even a correctly rounded `d1` pins `q` no
1453 // tighter than `ulp(½)/0.0066 ≈ 1.7e−14` relative.
1454 const CENTERED_TOL: f64 = 4.0e-15;
1455 const RATIO_TOL: f64 = 4.0e-15;
1456 const D1_TOL: f64 = 4.0e-15;
1457 const CURVATURE_TOL: f64 = 2.0e-11;
1458
1459 for [eta, want_centered, want_ratio, want_d1, want_curvature] in REFERENCE {
1460 let (centered, ratio, d1) = bessel_i0_centered_terms(eta);
1461 let curvature = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
1462 let relative = |got: f64, want: f64| (got - want).abs() / want.abs();
1463 assert!(
1464 relative(centered, want_centered) < CENTERED_TOL,
1465 "log I0({eta}) − {eta}: got {centered:.17e}, want {want_centered:.17e}"
1466 );
1467 assert!(
1468 relative(ratio, want_ratio) < RATIO_TOL,
1469 "I1/I0({eta}): got {ratio:.17e}, want {want_ratio:.17e}"
1470 );
1471 assert!(
1472 relative(d1, want_d1) < D1_TOL,
1473 "η(I1/I0 − 1) at {eta}: got {d1:.17e}, want {want_d1:.17e}"
1474 );
1475 assert!(
1476 relative(curvature, want_curvature) < CURVATURE_TOL,
1477 "c''(log η) at {eta}: got {curvature:.17e}, want {want_curvature:.17e}"
1478 );
1479 }
1480 }
1481
1482 /// The three returned terms are computed from DIFFERENT representations on
1483 /// BOTH branches — `ratio` from `S1/S0` or `I1/I0`, `d1` from the difference
1484 /// series each branch accumulates in its own right — so their defining
1485 /// relations are a real cross-check everywhere, not a tautology. (On the
1486 /// ascending branch it once WAS a tautology: `d1` was literally
1487 /// `η·(ratio − 1)`, so this assertion held by construction and the
1488 /// cancellation it is meant to detect went unmeasured.) Both must hold to
1489 /// within the cancellation the naive form suffers and the pre-cancelled one
1490 /// avoids.
1491 #[test]
1492 fn bessel_centered_terms_satisfy_their_defining_relations() {
1493 for eta in [
1494 0.5_f64, 1.0, 5.0, 12.0, 19.999, 20.0, 20.001, 25.0, 64.0, 900.0, 1.0e6, 1.0e12,
1495 ] {
1496 let (_centered, ratio, d1) = bessel_i0_centered_terms(eta);
1497 // d1 ≡ η(I1/I0 − 1). Forming it this way subtracts two numbers that
1498 // agree to `1/(2η)`, so it is only good to `≈ ε·η` — which is the
1499 // whole reason `d1` is carried separately.
1500 let naive = eta * (ratio - 1.0);
1501 assert!(
1502 (d1 - naive).abs() <= 8.0 * f64::EPSILON * eta,
1503 "d1 must equal η(I1/I0 − 1) at eta={eta}: d1={d1:.17e}, naive={naive:.17e}"
1504 );
1505
1506 // c''(s) ≡ −η(2·d1 + 1) − d1², the rearrangement's starting point.
1507 // Both sides are fed the log-round-tripped argument the function
1508 // itself sees, so the ONLY admissible difference is the rounding of
1509 // the rearranged grouping: the two intermediate products are of
1510 // size `2η|d1|` and `d1²`, so a few ulp of those is the budget.
1511 //
1512 // Only checked where the naive form still HAS digits. Its two terms
1513 // both approach ¼ and cancel down to `1/(8η)`, which costs `≈ 8εη²`
1514 // in relative terms — already `1e-12` at η = 64 and total loss by
1515 // `η ≈ 1e8`. That collapse is the whole reason for the rearrangement,
1516 // so asserting agreement past it would assert nothing.
1517 if eta <= 64.0 {
1518 let round_tripped = eta.ln().exp();
1519 let (_, _, same_d1) = bessel_i0_centered_terms(round_tripped);
1520 let curvature = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
1521 let naive = -round_tripped * (2.0 * same_d1 + 1.0) - same_d1 * same_d1;
1522 let budget =
1523 8.0 * f64::EPSILON * (2.0 * round_tripped * same_d1.abs() + same_d1 * same_d1);
1524 assert!(
1525 (curvature - naive).abs() <= budget,
1526 "c'' must equal −η(2d1+1) − d1² at eta={eta}: \
1527 c2={curvature:.17e}, naive={naive:.17e}, budget={budget:.3e}"
1528 );
1529 }
1530
1531 // `I1 < I0` for every η > 0, so `I1/I0 ∈ (0,1)` and `d1 < 0`. `d1`
1532 // is NOT monotone: it falls to a global minimum
1533 // `−0.608891247247801…` at `η = 1.702379944878764…` (the root of
1534 // `d(d1)/dη`) before rising back to its `−½` limit, so it crosses
1535 // `−½` once and the useful two-sided bound is that minimum.
1536 assert!((0.0..1.0).contains(&ratio), "I1/I0({eta})={ratio} ∉ (0,1)");
1537 assert!(
1538 (-0.608_891_247_247_802..0.0).contains(&d1),
1539 "η(I1/I0 − 1) at {eta} is {d1}, outside (min d1, 0)"
1540 );
1541 }
1542 }
1543
1544 /// A branch crossover must not be observable in the output. The retired A&S
1545 /// pair stepped by `4e-6` in `d1` and `2e-4` in the curvature at its own
1546 /// 3.75 seam — a jump discontinuity in the objective and gradient an outer
1547 /// optimizer differentiates through.
1548 #[test]
1549 fn bessel_branch_crossovers_have_no_step() {
1550 // Every seam the implementation has ever carried.
1551 for seam in [1.0_f64, 3.75, 20.0, 30.0] {
1552 let delta = 1.0e-11 * seam;
1553 let (below_c, below_r, below_d1) = bessel_i0_centered_terms(seam - delta);
1554 let (above_c, above_r, above_d1) = bessel_i0_centered_terms(seam + delta);
1555 let below_c2 =
1556 bessel_i0_centered_second_log_derivative_from_log_abs((seam - delta).ln());
1557 let above_c2 =
1558 bessel_i0_centered_second_log_derivative_from_log_abs((seam + delta).ln());
1559
1560 // Over `2δ` the true functions can move by at most `2δ·|f'|`, and
1561 // every derivative here is bounded by 1 in magnitude. Anything past
1562 // that plus a few ulp is a step, not a slope.
1563 let slope_budget = 2.0 * delta + 1.0e-14;
1564 assert!(
1565 (above_c - below_c).abs() < slope_budget,
1566 "centered log steps at the {seam} seam: {below_c:.17e} -> {above_c:.17e}"
1567 );
1568 assert!(
1569 (above_r - below_r).abs() < slope_budget,
1570 "I1/I0 steps at the {seam} seam: {below_r:.17e} -> {above_r:.17e}"
1571 );
1572 assert!(
1573 (above_d1 - below_d1).abs() < slope_budget,
1574 "d1 steps at the {seam} seam: {below_d1:.17e} -> {above_d1:.17e}"
1575 );
1576 assert!(
1577 (above_c2 - below_c2).abs() < slope_budget,
1578 "c'' steps at the {seam} seam: {below_c2:.17e} -> {above_c2:.17e}"
1579 );
1580 }
1581 }
1582
1583 /// Non-finite and boundary arguments keep their documented limits, and no
1584 /// series loop can run away on them.
1585 #[test]
1586 fn bessel_primitives_handle_boundary_arguments() {
1587 let (centered, ratio, d1) = bessel_i0_centered_terms(f64::INFINITY);
1588 assert_eq!((centered, ratio, d1), (f64::NEG_INFINITY, 1.0, -0.5));
1589 let (centered, ratio, d1) = bessel_i0_centered_terms(f64::NEG_INFINITY);
1590 assert_eq!((centered, ratio, d1), (f64::NEG_INFINITY, 1.0, -0.5));
1591
1592 let (centered, ratio, d1) = bessel_i0_centered_terms(f64::NAN);
1593 assert!(centered.is_nan() && ratio.is_nan() && d1.is_nan());
1594 assert!(bessel_i0_centered_second_log_derivative_from_log_abs(f64::NAN).is_nan());
1595
1596 // I0 and I1 are even/odd, so every returned term is a function of |η|.
1597 for eta in [0.5_f64, 5.0, 25.0, 1.0e6] {
1598 assert_eq!(
1599 bessel_i0_centered_terms(-eta),
1600 bessel_i0_centered_terms(eta)
1601 );
1602 }
1603 }
1604
1605 /// The polygamma family against a 50-digit `mpmath` evaluation.
1606 ///
1607 /// These consolidate four separate hand-rolled Bernoulli-series copies that
1608 /// had drifted apart: `gam-sae` recursed to 10 and stopped at `B₆`/`B₆`,
1609 /// `gam-solve` recursed to 8 and stopped at `B₁₀`/`B₁₀`/`B₁₀`, `gam-terms`
1610 /// recursed to 8 with yet another term count. Measured against this oracle
1611 /// they were good to `7.6e−10`, `3.1e−10`, `6.3e−11`, `3.9e−11` and
1612 /// `2.6e−10` respectively — 10 to 11 digits, and mutually inconsistent at
1613 /// that scale, in code that supplies REML gradients and Hessians for the
1614 /// negative-binomial `θ`, Gamma dispersion and Beta shape channels.
1615 #[test]
1616 fn polygamma_family_matches_independent_high_precision_reference() {
1617 // (x, ψ(x), ψ₁(x), ψ₂(x), ψ₃(x))
1618 const POLYGAMMA_REFERENCE: [[f64; 5]; 22] = [
1619 [
1620 1e-08,
1621 -100000000.57721564,
1622 1.0000000000000002e+16,
1623 -2e+24,
1624 5.999999999999999e+32,
1625 ],
1626 [
1627 0.0001,
1628 -10000.577051183514,
1629 100000001.64469367,
1630 -2000000000002.403,
1631 5.999999999999999e+16,
1632 ],
1633 [
1634 0.01,
1635 -100.56088545786868,
1636 10001.621213528313,
1637 -2000002.340398677,
1638 600000006.2510618,
1639 ],
1640 [
1641 0.1,
1642 -10.423754940411076,
1643 101.43329915079275,
1644 -2001.8614573783436,
1645 60004.51287679026,
1646 ],
1647 [
1648 0.25,
1649 -4.2274535333762655,
1650 17.19732915450711,
1651 -129.32773993753693,
1652 1538.7821440091884,
1653 ],
1654 [
1655 0.5,
1656 -1.9635100260214235,
1657 4.934802200544679,
1658 -16.82879664423432,
1659 97.40909103400244,
1660 ],
1661 [
1662 1.0,
1663 -0.5772156649015329,
1664 1.6449340668482264,
1665 -2.4041138063191885,
1666 6.493939402266829,
1667 ],
1668 // The unique positive root of ψ, where the relative bound below is
1669 // vacuous and the absolute one carries the assertion.
1670 [
1671 1.4616321449683622,
1672 -9.241265521729427e-17,
1673 0.9676722454476212,
1674 -0.8855263379671844,
1675 1.5509985657339065,
1676 ],
1677 [
1678 2.0,
1679 0.42278433509846713,
1680 0.6449340668482264,
1681 -0.4041138063191886,
1682 0.49393940226682914,
1683 ],
1684 [
1685 3.5,
1686 1.103156640645243,
1687 0.3303577561002349,
1688 -0.1082040516417274,
1689 0.07030584881725205,
1690 ],
1691 // The three retired recurrence thresholds (8 and 10) and the live
1692 // one (20), each straddled.
1693 [
1694 7.0,
1695 1.8727843350984672,
1696 0.15354517795933756,
1697 -0.023530472985855238,
1698 0.007198198563125445,
1699 ],
1700 [
1701 8.0,
1702 2.01564147795561,
1703 0.1331370146940314,
1704 -0.017699569195767775,
1705 0.004699239795945104,
1706 ],
1707 [
1708 10.0,
1709 2.251752589066721,
1710 0.10516633568168575,
1711 -0.011049834970802067,
1712 0.0023199013042898686,
1713 ],
1714 [
1715 19.0,
1716 2.9178924132947808,
1717 0.05404090603769619,
1718 -0.0029197100973139254,
1719 0.0003154143837079449,
1720 ],
1721 [
1722 19.999,
1723 2.9704727201051075,
1724 0.05127345119229945,
1725 -0.0026283917972403977,
1726 0.00026941563155986057,
1727 ],
1728 [
1729 20.0,
1730 2.970523992242149,
1731 0.05127082293520312,
1732 -0.0026281224023146548,
1733 0.0002693742213396389,
1734 ],
1735 [
1736 20.001,
1737 2.970575261751068,
1738 0.05126819494748101,
1739 -0.0026278530487948894,
1740 0.0002693328196036835,
1741 ],
1742 [
1743 25.0,
1744 3.198742512851974,
1745 0.04081066325722558,
1746 -0.001665279318422468,
1747 0.0001358846365082737,
1748 ],
1749 [
1750 100.0,
1751 4.600161852738087,
1752 0.010050166663333571,
1753 -0.00010100499983335,
1754 2.030199990001333e-06,
1755 ],
1756 [
1757 10000.0,
1758 9.210290371142849,
1759 0.00010000500016666666,
1760 -1.000100005e-08,
1761 2.00030002e-12,
1762 ],
1763 [
1764 100000000.0,
1765 18.420680738952367,
1766 1.000000005e-08,
1767 -1.00000001e-16,
1768 2.0000000300000002e-24,
1769 ],
1770 [
1771 1000000000000000.0,
1772 34.538776394910684,
1773 1.0000000000000005e-15,
1774 -1.000000000000001e-30,
1775 2.000000000000003e-45,
1776 ],
1777 ];
1778
1779 for [x, want_psi, want_psi1, want_psi2, want_psi3] in POLYGAMMA_REFERENCE {
1780 // `ψ` crosses zero at x ≈ 1.4616, and the recurrence sums up to 20
1781 // reciprocals whose partial sums dwarf a near-zero result, so the
1782 // absolute term is what applies there. Everywhere else the relative
1783 // term binds. `1e-14` relative is 4 orders tighter than the loosest
1784 // implementation this replaced.
1785 let checks = [
1786 ("ψ", digamma(x), want_psi),
1787 ("ψ₁", trigamma(x), want_psi1),
1788 ("ψ₂", tetragamma(x), want_psi2),
1789 ("ψ₃", pentagamma(x), want_psi3),
1790 ];
1791 for (name, got, want) in checks {
1792 let error = (got - want).abs();
1793 let budget = 1e-14 * want.abs() + 1e-15;
1794 assert!(
1795 error <= budget,
1796 "{name}({x}): got {got:.17e}, want {want:.17e} \
1797 (error {error:.3e} > {budget:.3e})"
1798 );
1799 }
1800 }
1801 }
1802
1803 /// The recurrences and the asymptotic series must agree where they meet,
1804 /// and each function must be the derivative of the one before it. Both were
1805 /// true of the copies this replaces only to their own `1e-10`.
1806 #[test]
1807 fn polygamma_family_is_seamless_and_mutually_consistent() {
1808 for threshold in [8.0_f64, 10.0, 20.0] {
1809 let delta = 1.0e-11 * threshold;
1810 for f in [digamma as fn(f64) -> f64, trigamma, tetragamma, pentagamma] {
1811 let below = f(threshold - delta);
1812 let above = f(threshold + delta);
1813 // Every one of these has |f'| < 1 at x ≥ 8, so the true change
1814 // over 2δ is below 2δ. Anything more is a step.
1815 assert!(
1816 (above - below).abs() < 2.0 * delta + 1.0e-15,
1817 "polygamma step at the {threshold} seam: {below:.17e} -> {above:.17e}"
1818 );
1819 }
1820 }
1821
1822 // ψ_{n+1} = dψ_n/dx, checked by a central difference whose own error
1823 // (roundoff ε|f|/h plus truncation h²f'''/6) is the limit here.
1824 for x in [0.75_f64, 1.5, 4.0, 9.0, 19.5, 21.0, 60.0] {
1825 let h = 1.0e-4 * x;
1826 for (name, value, derivative) in [
1827 ("ψ", digamma as fn(f64) -> f64, trigamma as fn(f64) -> f64),
1828 ("ψ₁", trigamma, tetragamma),
1829 ("ψ₂", tetragamma, pentagamma),
1830 ] {
1831 let finite_difference = (value(x + h) - value(x - h)) / (2.0 * h);
1832 let analytic = derivative(x);
1833 assert!(
1834 (finite_difference - analytic).abs() <= 1e-6 * analytic.abs().max(1e-3),
1835 "d{name}/dx at {x}: analytic={analytic:.17e}, fd={finite_difference:.17e}"
1836 );
1837 }
1838 }
1839
1840 // Non-positive and non-finite arguments are outside the domain.
1841 for bad in [
1842 0.0_f64,
1843 -1.0,
1844 -0.5,
1845 f64::NAN,
1846 f64::INFINITY,
1847 f64::NEG_INFINITY,
1848 ] {
1849 assert!(digamma(bad).is_nan(), "digamma({bad}) must be NaN");
1850 assert!(trigamma(bad).is_nan(), "trigamma({bad}) must be NaN");
1851 assert!(tetragamma(bad).is_nan(), "tetragamma({bad}) must be NaN");
1852 assert!(pentagamma(bad).is_nan(), "pentagamma({bad}) must be NaN");
1853 }
1854 }
1855
1856 #[test]
1857 fn gauss_legendre_integrates_polynomials_exactly() {
1858 // An n-point rule is exact for polynomials of degree ≤ 2n−1.
1859 for n in [1usize, 2, 3, 5, 8, 40, 64] {
1860 let (nodes, weights) = gauss_legendre(n);
1861 assert_eq!(nodes.len(), n);
1862 assert_eq!(weights.len(), n);
1863 assert!(nodes.windows(2).all(|w| w[0] < w[1]), "nodes ascending");
1864 if !n.is_multiple_of(2) {
1865 assert_eq!(nodes[n / 2], 0.0, "odd-n central node is exact zero");
1866 }
1867 let total: f64 = weights.iter().sum();
1868 assert!((total - 2.0).abs() < 1e-13, "∫1 dx = 2, got {total}");
1869 if n >= 2 {
1870 let x2: f64 = nodes.iter().zip(&weights).map(|(x, w)| w * x * x).sum();
1871 assert!((x2 - 2.0 / 3.0).abs() < 1e-13, "∫x² dx = 2/3, got {x2}");
1872 }
1873 // Degrees 0 and 2 alone exercise almost none of the rule — the
1874 // weights barely matter there. Assert the whole `2n−1` guarantee.
1875 //
1876 // The odd degrees integrate to zero by symmetry, so the error is
1877 // measured against `Σ|w·xᵈ|`, the size of the terms that had to
1878 // cancel, rather than against the vanishing answer. For the even
1879 // degrees every term is positive and that denominator IS the exact
1880 // value, so the same expression is the ordinary relative error.
1881 for degree in 0..(2 * n) {
1882 let term = |(x, w): (&f64, &f64)| w * x.powi(degree as i32);
1883 let quadrature: f64 = nodes.iter().zip(&weights).map(term).sum();
1884 let magnitude: f64 = nodes.iter().zip(&weights).map(|p| term(p).abs()).sum();
1885 let exact = if degree % 2 == 1 {
1886 0.0
1887 } else {
1888 2.0 / (degree as f64 + 1.0)
1889 };
1890 let scale = magnitude.max(exact);
1891 // `n = 1` puts its only node at exactly zero, so every odd
1892 // degree has nothing to cancel and must come out exactly zero.
1893 if scale == 0.0 {
1894 assert_eq!(quadrature, 0.0, "n={n}, x^{degree}");
1895 continue;
1896 }
1897 assert!(
1898 (quadrature - exact).abs() / scale < 1.0e-13,
1899 "n={n} rule must integrate x^{degree} exactly: got {quadrature:.17e}, \
1900 want {exact:.17e}"
1901 );
1902 }
1903 }
1904 }
1905
1906 /// The nodes are Newton-converged to ~1 ulp, but the weights are read off
1907 /// `P_n'` and were being evaluated one Newton step BEHIND the node they are
1908 /// paired with. Legendre's equation turns that lag into `2·2z/(1−z²)` times
1909 /// the node offset, so it is invisible in the nodes and plainly visible
1910 /// here: at `n = 16` the weights carried `8.3e−14`, against the `1.9e−15`
1911 /// they carry once `P_n'` is re-evaluated at the returned node.
1912 #[test]
1913 fn gauss_legendre_weights_match_independent_high_precision_reference() {
1914 // (node, weight) over the positive half, from a 50-digit root solve;
1915 // the rule is symmetric, so the negative half is the mirror image.
1916 const GL8: [(f64, f64); 4] = [
1917 (0.183434642495649805, 0.362683783378361983),
1918 (0.525532409916328986, 0.313706645877887287),
1919 (0.796666477413626740, 0.222381034453374471),
1920 (0.960289856497536232, 0.101228536290376259),
1921 ];
1922 const GL16: [(f64, f64); 8] = [
1923 (0.0950125098376374402, 0.189450610455068496),
1924 (0.281603550779258913, 0.182603415044923589),
1925 (0.458016777657227386, 0.169156519395002538),
1926 (0.617876244402643748, 0.149595988816576732),
1927 (0.755404408355003034, 0.124628971255533872),
1928 (0.865631202387831744, 0.0951585116824927848),
1929 (0.944575023073232576, 0.0622535239386478929),
1930 (0.989400934991649933, 0.0271524594117540949),
1931 ];
1932
1933 // The nodes are a Newton root to within a few ulp of 1. The weights sit
1934 // an order looser because `2z/(1−z²)` amplifies whatever the node's
1935 // residual is — but two orders TIGHTER than the stale-derivative form,
1936 // which is what this pins.
1937 const NODE_TOL: f64 = 4.0e-16;
1938 const WEIGHT_TOL: f64 = 1.0e-14;
1939
1940 for (n, reference) in [(8usize, &GL8[..]), (16, &GL16[..])] {
1941 let (nodes, weights) = gauss_legendre(n);
1942 for (k, &(want_node, want_weight)) in reference.iter().enumerate() {
1943 // Positive half, ascending, is the back half of the rule.
1944 let index = n / 2 + k;
1945 let (got_node, got_weight) = (nodes[index], weights[index]);
1946 assert!(
1947 (got_node - want_node).abs() < NODE_TOL,
1948 "n={n} node {index}: got {got_node:.17e}, want {want_node:.17e}"
1949 );
1950 let relative = (got_weight - want_weight).abs() / want_weight.abs();
1951 assert!(
1952 relative < WEIGHT_TOL,
1953 "n={n} weight {index}: got {got_weight:.17e}, want {want_weight:.17e}, \
1954 rel {relative:.3e}"
1955 );
1956 // Symmetry: the mirrored entry must be bit-identical.
1957 let mirror = n / 2 - 1 - k;
1958 assert_eq!(
1959 nodes[mirror], -got_node,
1960 "n={n} node {mirror} mirrors {index}"
1961 );
1962 assert_eq!(weights[mirror], got_weight, "n={n} weight {mirror} mirrors");
1963 }
1964 }
1965 }
1966
1967 #[test]
1968 fn binom_k_exceeds_n_returns_zero() {
1969 assert_eq!(binomial_coefficient_f64(3, 5), 0.0);
1970 assert_eq!(binomial_coefficient_f64(0, 1), 0.0);
1971 assert_eq!(binomial_coefficient_f64(10, 11), 0.0);
1972 }
1973
1974 #[test]
1975 fn binom_k_zero_returns_one() {
1976 assert_eq!(binomial_coefficient_f64(0, 0), 1.0);
1977 assert_eq!(binomial_coefficient_f64(5, 0), 1.0);
1978 assert_eq!(binomial_coefficient_f64(100, 0), 1.0);
1979 }
1980
1981 #[test]
1982 fn binom_k_equals_n_returns_one() {
1983 assert_eq!(binomial_coefficient_f64(1, 1), 1.0);
1984 assert_eq!(binomial_coefficient_f64(5, 5), 1.0);
1985 assert_eq!(binomial_coefficient_f64(20, 20), 1.0);
1986 }
1987
1988 #[test]
1989 fn binom_small_exact_values() {
1990 assert_eq!(binomial_coefficient_f64(5, 2), 10.0);
1991 assert_eq!(binomial_coefficient_f64(10, 3), 120.0);
1992 assert_eq!(binomial_coefficient_f64(20, 10), 184_756.0);
1993 assert_eq!(binomial_coefficient_f64(6, 3), 20.0);
1994 }
1995
1996 #[test]
1997 fn binom_symmetry() {
1998 assert_eq!(
1999 binomial_coefficient_f64(10, 3),
2000 binomial_coefficient_f64(10, 7)
2001 );
2002 assert_eq!(
2003 binomial_coefficient_f64(20, 5),
2004 binomial_coefficient_f64(20, 15)
2005 );
2006 assert_eq!(
2007 binomial_coefficient_f64(54, 24),
2008 binomial_coefficient_f64(54, 30)
2009 );
2010 }
2011
2012 #[test]
2013 fn binom_c54_24_is_exact() {
2014 // The u128-recurrence fix restored this value (old f64 recurrence
2015 // returned 1_402_659_561_581_459, one short of the true integer).
2016 assert_eq!(binomial_coefficient_f64(54, 24), 1_402_659_561_581_460.0);
2017 }
2018
2019 #[test]
2020 fn poly_exp_empty_coeffs_returns_zero() {
2021 assert_eq!(stable_polynomial_times_exp_neg(1.0, &[]), 0.0);
2022 assert_eq!(stable_polynomial_times_exp_neg(0.0, &[]), 0.0);
2023 assert_eq!(stable_polynomial_times_exp_neg(700.0, &[]), 0.0);
2024 }
2025
2026 #[test]
2027 fn poly_exp_nonfinite_x_returns_zero() {
2028 assert_eq!(
2029 stable_polynomial_times_exp_neg(f64::INFINITY, &[1.0, 2.0]),
2030 0.0
2031 );
2032 assert_eq!(
2033 stable_polynomial_times_exp_neg(f64::NEG_INFINITY, &[1.0, 2.0]),
2034 0.0
2035 );
2036 assert_eq!(stable_polynomial_times_exp_neg(f64::NAN, &[1.0]), 0.0);
2037 }
2038
2039 #[test]
2040 fn poly_exp_constant_at_zero() {
2041 // At x=0: poly(0) = coeffs[0], exp(0)=1 → result = coeffs[0].
2042 assert_eq!(stable_polynomial_times_exp_neg(0.0, &[5.0]), 5.0);
2043 assert_eq!(stable_polynomial_times_exp_neg(0.0, &[3.0, 1.0, 2.0]), 3.0);
2044 }
2045
2046 #[test]
2047 fn poly_exp_constant_poly_direct_path() {
2048 // x=2.0 < 600: direct Horner * exp(-x).
2049 let x = 2.0;
2050 let got = stable_polynomial_times_exp_neg(x, &[3.0]);
2051 let expected = 3.0 * (-x).exp();
2052 assert!(
2053 (got - expected).abs() < 1e-14,
2054 "got={got} expected={expected}"
2055 );
2056 }
2057
2058 #[test]
2059 fn poly_exp_linear_poly_direct_path() {
2060 // coeffs = [a, b] → poly = a + b*x.
2061 let x = 1.5;
2062 let (a, b) = (2.0, 3.0);
2063 let got = stable_polynomial_times_exp_neg(x, &[a, b]);
2064 let expected = (a + b * x) * (-x).exp();
2065 assert!(
2066 (got - expected).abs() < 1e-14,
2067 "got={got} expected={expected}"
2068 );
2069 }
2070
2071 #[test]
2072 fn poly_exp_constant_poly_asymptotic_path() {
2073 // x=700 > 600: asymptotic path. For poly = [1.0], result = exp(-700).
2074 let x = 700.0_f64;
2075 let got = stable_polynomial_times_exp_neg(x, &[1.0]);
2076 let expected = (-x).exp();
2077 let rel = (got - expected).abs() / expected;
2078 assert!(rel < 1e-12, "got={got} expected={expected} rel={rel}");
2079 }
2080
2081 #[test]
2082 fn poly_exp_quadratic_asymptotic_path() {
2083 // x=620 > 600: poly = x^2 (coeffs=[0,0,1]). Result = x^2 * exp(-x).
2084 // x=800 would underflow to 0.0 in both the asymptotic path and the
2085 // reference, making the relative-error check degenerate; x=620 keeps
2086 // the result in the normal f64 range (~10^-264) while still exercising
2087 // the asymptotic branch (threshold is x=600).
2088 let x = 620.0_f64;
2089 let got = stable_polynomial_times_exp_neg(x, &[0.0, 0.0, 1.0]);
2090 let expected = (2.0 * x.ln() - x).exp();
2091 let rel = (got - expected).abs() / expected.abs();
2092 assert!(rel < 1e-12, "got={got} expected={expected} rel={rel}");
2093 }
2094
2095 /// Pins the measured accuracy of `c''(log η)` against an 80-digit
2096 /// reference, per regime, so the branch structure cannot silently drift.
2097 ///
2098 /// The tolerances are the MEASURED worst case in each regime plus a factor
2099 /// of two, not aspirations: the `1 ≤ η < 20` band is bounded below by the
2100 /// `8η²·κ(η)·ε` floor of the ascending representation (see
2101 /// [`bessel_i0_centered_second_log_derivative_from_log_abs`]), and 1e-11 is
2102 /// what that floor permits at the top of the band. Tightening it needs a
2103 /// wider-than-`f64` `d1`, not a smaller constant here.
2104 #[test]
2105 fn centered_bessel_second_log_derivative_matches_high_precision_reference() {
2106 // (η, c''(log η) to 20 significant digits, tolerance).
2107 const CASES: [(f64, f64, f64); 13] = [
2108 (0.5, -0.2647015155254598, 1e-14),
2109 (1.0, -0.19926400165310923, 1e-14),
2110 (2.0, 0.05244210681284669, 1e-13),
2111 (5.0, 0.0466642611317311, 1e-13),
2112 (10.0, 0.015837019843595493, 1e-12),
2113 (15.0, 0.009659446256568909, 1e-11),
2114 // The worst point of the whole domain, just under the crossover.
2115 (18.85, 0.00743799786561837, 1e-11),
2116 (19.99, 0.006964307582746309, 1e-11),
2117 // First point on the asymptotic side: two orders better, at once.
2118 (20.0, 0.006960419930170057, 1e-12),
2119 (25.0, 0.005442291838848013, 1e-14),
2120 (50.0, 0.0026049656149811874, 1e-15),
2121 (200.0, 0.0006313242744933583, 1e-15),
2122 (1e4, 1.2502500586100053e-05, 1e-15),
2123 ];
2124 for (eta, expected, tolerance) in CASES {
2125 let got = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
2126 let relative = (got - expected).abs() / expected.abs();
2127 assert!(
2128 relative < tolerance,
2129 "eta={eta}: got={got} expected={expected} rel={relative:e} tol={tolerance:e}"
2130 );
2131 }
2132 }
2133
2134 /// The crossover at `BESSEL_ASYMPTOTIC_THRESHOLD` is a step DOWN in error,
2135 /// so the value itself must still be continuous across it to within what
2136 /// the worse (ascending) side delivers — nothing tighter is available, and
2137 /// nothing looser would catch a branch that had been mis-derived.
2138 ///
2139 /// The step size matters and is not free to enlarge. `c''` genuinely varies:
2140 /// `|dc''/dη| / |c''| = 1/η`, so a step `δ` moves the true value by `δ/η`
2141 /// RELATIVE. At `δ = 1e−9` that is `5e−11` — larger than the seam being
2142 /// measured, and a test written that way reports the function's own slope
2143 /// as a discontinuity. `1e−11` puts the true variation at `5e−13`, an order
2144 /// under the ascending branch's `8.9e−12` floor, while still clearing
2145 /// `ulp(20) = 3.6e−15` by four orders.
2146 #[test]
2147 fn centered_bessel_second_log_derivative_is_continuous_across_the_crossover() {
2148 const STEP: f64 = 1e-11;
2149 let below = bessel_i0_centered_second_log_derivative_from_log_abs(
2150 (BESSEL_ASYMPTOTIC_THRESHOLD - STEP).ln(),
2151 );
2152 let above =
2153 bessel_i0_centered_second_log_derivative_from_log_abs(BESSEL_ASYMPTOTIC_THRESHOLD.ln());
2154 assert!(
2155 below != above,
2156 "step {STEP:e} was rounded away; the two sides are the same evaluation"
2157 );
2158 let jump = (below - above).abs() / above.abs();
2159 assert!(
2160 jump < 3e-11,
2161 "seam jump {jump:e}: below={below} above={above}"
2162 );
2163 }
2164}