Skip to main content

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}