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#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn centered_bessel_log_is_finite_and_derivative_consistent() {
688        for eta in [0.25_f64, 1.0, 3.74, 3.76, 12.0, 900.0] {
689            let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms(eta);
690            assert!(centered.is_finite());
691            assert!((0.0..=1.0).contains(&ratio));
692
693            // The tolerances below are sized by what a CENTRAL DIFFERENCE can
694            // resolve — roundoff `ε·|f|/h` plus truncation `h²·f'''/6` — not by
695            // what the evaluator happens to achieve. The A&S polynomials this
696            // replaced needed `1e-6`/`2e-5` here; the series/asymptotic pair
697            // leaves the finite difference itself as the limiting error.
698            let h = 1.0e-4 * eta.max(1.0);
699            let (plus, _) = bessel_i0_log_and_ratio(eta + h);
700            let (minus, _) = bessel_i0_log_and_ratio(eta - h);
701            let derivative = (plus - minus) / (2.0 * h);
702            assert!(
703                (derivative - ratio).abs() <= 1.0e-8,
704                "d/dη log I0 mismatch at eta={eta}: analytic={ratio}, finite_difference={derivative}"
705            );
706
707            let log_step = 1.0e-5_f64;
708            let (centered_plus, _, _) = bessel_i0_centered_terms(eta * log_step.exp());
709            let (centered_minus, _, _) = bessel_i0_centered_terms(eta * (-log_step).exp());
710            let finite_difference = (centered_plus - centered_minus) / (2.0 * log_step);
711            assert!(
712                (finite_difference - scaled_derivative).abs() < 1.0e-8,
713                "centered Bessel value/gradient mismatch at eta={eta}: analytic={scaled_derivative}, finite_difference={finite_difference}"
714            );
715        }
716        for eta in [1.0e20_f64, 1.0e100, 1.0e300] {
717            let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms(eta);
718            let asymptotic = -0.5 * (std::f64::consts::TAU * eta).ln();
719            assert!(centered.is_finite() && ratio.is_finite());
720            // The `log S0` remainder is below `1e-20` at these arguments, so the
721            // only admissible gap is the differing association of the two `log`
722            // groupings — a few ulp of a number of size ~`log η`.
723            assert!(
724                (centered - asymptotic).abs() < 1.0e-13,
725                "large-eta centered log must equal -½log(2πη); eta={eta:e}, centered={centered}, asymptotic={asymptotic}"
726            );
727            assert!(
728                (scaled_derivative + 0.5).abs() < 1.0e-15,
729                "large-eta centered derivative must retain its -1/2 limit; eta={eta:e}, derivative={scaled_derivative}"
730            );
731        }
732
733        assert_eq!(bessel_i0_centered_terms(0.0), (0.0, 0.0, 0.0));
734
735        let log_eta = 1_200.0;
736        let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms_from_log_abs(log_eta);
737        assert!(centered.is_finite());
738        assert_eq!(ratio, 1.0);
739        assert_eq!(scaled_derivative, -0.5);
740        assert_eq!(centered, -0.5 * (std::f64::consts::TAU.ln() + log_eta));
741    }
742
743    #[test]
744    fn centered_bessel_second_log_derivative_matches_finite_difference() {
745        // c''(log η) must be the derivative of the third term (c'(log η)) of
746        // `bessel_i0_centered_terms`, across small, mid, and large arguments.
747        // c''(log η) is the log-derivative of the STABLE third term `d1` (the
748        // quantity the outer gradient's ARD normalizer channel reports), so the
749        // self-consistent reference is a central difference of that same term.
750        // The sweep straddles every seam this function has ever had: the retired
751        // A&S 3.75 and 30 seams, and the live 1.0 (small-η rearrangement) and
752        // 20.0 (series/asymptotic) ones.
753        let first_log_derivative = |x: f64| bessel_i0_centered_terms(x).2;
754        for eta in [
755            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,
756        ] {
757            let log_eta = eta.ln();
758            let analytic = bessel_i0_centered_second_log_derivative_from_log_abs(log_eta);
759
760            let log_step = 1.0e-6_f64;
761            let first_plus = first_log_derivative(eta * log_step.exp());
762            let first_minus = first_log_derivative(eta * (-log_step).exp());
763            let finite_difference = (first_plus - first_minus) / (2.0 * log_step);
764            // `ε·|d1|/log_step ≈ 1e-10` of central-difference roundoff is the
765            // floor here; the analytic value is far better than that. The old
766            // `5e-5 + 1e-3·|analytic|` band was three orders wider than the
767            // finite difference could even be wrong by — it was sized to the
768            // 0.6% error the A&S polynomials put into `analytic`.
769            assert!(
770                (analytic - finite_difference).abs() < 1.0e-8 + 1.0e-6 * analytic.abs(),
771                "centered Bessel second log-derivative mismatch at eta={eta}: \
772                 analytic={analytic}, finite_difference={finite_difference}"
773            );
774        }
775        // Large-η decay: the normalizer curvature vanishes like the leading
776        // asymptotic term 1/(8η) (its Hessian contribution is then negligible
777        // beside the ∝α energy term), stays finite and positive, and the
778        // overflow-free gateway rounds it to exactly zero past the float range.
779        // Held against THREE terms of the expansion rather than one, so the
780        // admissible band is the size of the first omitted term (`≲ 2/η⁴`)
781        // instead of a 25% shrug.
782        for eta in [50.0_f64, 200.0, 1.0e4] {
783            let c2 = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
784            let inverse = 1.0 / eta;
785            let expansion = inverse * (0.125 + inverse * (0.25 + inverse * (75.0 / 128.0)));
786            assert!(
787                c2 > 0.0 && (c2 - expansion).abs() < 8.0 * inverse.powi(4),
788                "large-eta centered second derivative must track its own expansion; \
789                 eta={eta}, c2={c2}, expansion={expansion}"
790            );
791        }
792        // η → 0 and the overflow-free large-|η| gateway both round to 0.
793        assert_eq!(
794            bessel_i0_centered_second_log_derivative_from_log_abs(f64::NEG_INFINITY),
795            0.0
796        );
797        assert_eq!(
798            bessel_i0_centered_second_log_derivative_from_log_abs(1_200.0),
799            0.0
800        );
801    }
802
803    /// Every quantity `bessel_i0_centered_terms` and the second log-derivative
804    /// return, against an INDEPENDENT 60-decimal-digit evaluation of the same
805    /// closed forms (`mpmath.besseli`, `mpmath.diff`), rounded to `f64`.
806    ///
807    /// This is the assertion the module lacked. Everything else here is a
808    /// self-consistency check — a finite difference of the evaluator against
809    /// the evaluator — and a self-consistent evaluator can be uniformly wrong.
810    /// The A&S 9.8.x polynomials this replaced were exactly that: internally
811    /// consistent to the last digit and off the true value by up to `4e-6` in
812    /// `d1` and `6e-3` in the curvature, with steps at their branch seams. No
813    /// test in the tree compared them to anything but themselves.
814    #[test]
815    fn bessel_primitives_match_independent_high_precision_reference() {
816        // (η, log I0(η) − η, I1(η)/I0(η), η(I1/I0 − 1), d²/d(log η)²[log I0 − η])
817        const REFERENCE: [[f64; 5]; 24] = [
818            [
819                1e-06,
820                -9.9999975e-07,
821                4.999999999999375e-07,
822                -9.999995e-07,
823                -9.99999e-07,
824            ],
825            [
826                0.001,
827                -0.000999750000015625,
828                0.0004999999375000105,
829                -0.0009995000000625,
830                -0.00099900000025,
831            ],
832            [
833                0.05,
834                -0.049375097629132,
835                0.024992190753810217,
836                -0.048750390462309494,
837                -0.04750156152399669,
838            ],
839            [
840                0.25,
841                -0.23443561468661894,
842                0.12403350191792471,
843                -0.21899162452051882,
844                -0.18846151934987648,
845            ],
846            [
847                0.5,
848                -0.4384502808145187,
849                0.24249961258080194,
850                -0.378750193709599,
851                -0.2647015155254598,
852            ],
853            [
854                1.0,
855                -0.7640856414928213,
856                0.4463899658965345,
857                -0.5536100341034655,
858                -0.19926400165310923,
859            ],
860            [
861                2.0,
862                -1.1760064585170438,
863                0.697774657964008,
864                -0.604450684071984,
865                0.05244210681284669,
866            ],
867            [
868                3.75,
869                -1.5396457880279808,
870                0.8531704594530685,
871                -0.5506107770509933,
872                0.0764086000777509,
873            ],
874            [
875                5.0,
876                -1.6953182241774665,
877                0.8933831370440852,
878                -0.5330843147795739,
879                0.0466642611317311,
880            ],
881            [
882                8.0,
883                -1.941895744572186,
884                0.9352354935294386,
885                -0.5181160517644912,
886                0.02141258513583364,
887            ],
888            [
889                12.0,
890                -2.1504975008971563,
891                0.9573814053952422,
892                -0.5114231352570932,
893                0.01260162289404047,
894            ],
895            [
896                17.0,
897                -2.327961358737179,
898                0.9701275885919403,
899                -0.5078309939370159,
900                0.008361475455484893,
901            ],
902            [
903                19.5,
904                -2.397561575434808,
905                0.9740118676091061,
906                -0.5067685816224307,
907                0.007160287955186735,
908            ],
909            [
910                19.999999,
911                -2.410389546426233,
912                0.9746705066059314,
913                -0.5065898425518784,
914                0.006960420318717729,
915            ],
916            [
917                20.0,
918                -2.4103895717557258,
919                0.9746705078898071,
920                -0.5065898422038575,
921                0.006960419930170057,
922            ],
923            [
924                20.000001,
925                -2.410389597085217,
926                0.9746705091736827,
927                -0.5065898418558366,
928                0.006960419541622429,
929            ],
930            [
931                25.0,
932                -2.5232719950007563,
933                0.9797914534905159,
934                -0.5052136627371017,
935                0.005442291838848013,
936            ],
937            [
938                30.0,
939                -2.615298566828064,
940                0.9831895553653361,
941                -0.5043133390399173,
942                0.004468398461442669,
943            ],
944            [
945                64.0,
946                -2.996411436485784,
947                0.9921564935488112,
948                -0.5019844128760834,
949                0.002016497368136742,
950            ],
951            [
952                150.0,
953                -3.423420049648141,
954                0.9966610736828279,
955                -0.5008389475758167,
956                0.0008446213361703931,
957            ],
958            [
959                900.0,
960                -4.319996948727984,
961                0.9994442899516907,
962                -0.5001390434784159,
963                0.0001391983371050074,
964            ],
965            [
966                10000.0,
967                -5.524096218567699,
968                0.999949998749875,
969                -0.5000125012501954,
970                1.2502500586100053e-05,
971            ],
972            [
973                1000000.0,
974                -7.826693687186747,
975                0.999999499999875,
976                -0.500000125000125,
977                1.2500025000058594e-07,
978            ],
979            [
980                1000000000000.0,
981                -14.734449091168822,
982                0.9999999999995,
983                -0.500000000000125,
984                1.2500000000025e-13,
985            ],
986        ];
987
988        // Sized from the arithmetic, not from the outcome. The value and ratio
989        // are read off sums that cannot cancel, so they land within a few ulp.
990        // `d1` now shares that footing on BOTH branches: each reads it off a
991        // difference series accumulated in its own right — `N/S0` above the
992        // crossover, `−x(I0−I1)/I0` below it — rather than by subtracting the
993        // ratio from one. The ascending difference series does have a sign
994        // change, so it carries a condition number, but that grows only like
995        // `√x` (6.8 at the crossover) instead of the `1/(1 − I1/I0)` ≈ 40 of the
996        // naive form: tens of ulp, not thousands.
997        //
998        // The curvature is the one term still amplified, inheriting ≈ 2η from
999        // `d1`'s ABSOLUTE error — `40 · 1e−15 / 0.007 ≈ 6e−12` just under the
1000        // crossover, where `c''` is smallest and `η` already large. That is
1001        // intrinsic to reaching `c''` through a `d1` held in one f64: `q = d1+½`
1002        // is `−0.0066` there, so even a correctly rounded `d1` pins `q` no
1003        // tighter than `ulp(½)/0.0066 ≈ 1.7e−14` relative.
1004        const CENTERED_TOL: f64 = 4.0e-15;
1005        const RATIO_TOL: f64 = 4.0e-15;
1006        const D1_TOL: f64 = 4.0e-15;
1007        const CURVATURE_TOL: f64 = 2.0e-11;
1008
1009        for [eta, want_centered, want_ratio, want_d1, want_curvature] in REFERENCE {
1010            let (centered, ratio, d1) = bessel_i0_centered_terms(eta);
1011            let curvature = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
1012            let relative = |got: f64, want: f64| (got - want).abs() / want.abs();
1013            assert!(
1014                relative(centered, want_centered) < CENTERED_TOL,
1015                "log I0({eta}) − {eta}: got {centered:.17e}, want {want_centered:.17e}"
1016            );
1017            assert!(
1018                relative(ratio, want_ratio) < RATIO_TOL,
1019                "I1/I0({eta}): got {ratio:.17e}, want {want_ratio:.17e}"
1020            );
1021            assert!(
1022                relative(d1, want_d1) < D1_TOL,
1023                "η(I1/I0 − 1) at {eta}: got {d1:.17e}, want {want_d1:.17e}"
1024            );
1025            assert!(
1026                relative(curvature, want_curvature) < CURVATURE_TOL,
1027                "c''(log η) at {eta}: got {curvature:.17e}, want {want_curvature:.17e}"
1028            );
1029        }
1030    }
1031
1032    /// The three returned terms are computed from DIFFERENT representations on
1033    /// BOTH branches — `ratio` from `S1/S0` or `I1/I0`, `d1` from the difference
1034    /// series each branch accumulates in its own right — so their defining
1035    /// relations are a real cross-check everywhere, not a tautology. (On the
1036    /// ascending branch it once WAS a tautology: `d1` was literally
1037    /// `η·(ratio − 1)`, so this assertion held by construction and the
1038    /// cancellation it is meant to detect went unmeasured.) Both must hold to
1039    /// within the cancellation the naive form suffers and the pre-cancelled one
1040    /// avoids.
1041    #[test]
1042    fn bessel_centered_terms_satisfy_their_defining_relations() {
1043        for eta in [
1044            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,
1045        ] {
1046            let (_centered, ratio, d1) = bessel_i0_centered_terms(eta);
1047            // d1 ≡ η(I1/I0 − 1). Forming it this way subtracts two numbers that
1048            // agree to `1/(2η)`, so it is only good to `≈ ε·η` — which is the
1049            // whole reason `d1` is carried separately.
1050            let naive = eta * (ratio - 1.0);
1051            assert!(
1052                (d1 - naive).abs() <= 8.0 * f64::EPSILON * eta,
1053                "d1 must equal η(I1/I0 − 1) at eta={eta}: d1={d1:.17e}, naive={naive:.17e}"
1054            );
1055
1056            // c''(s) ≡ −η(2·d1 + 1) − d1², the rearrangement's starting point.
1057            // Both sides are fed the log-round-tripped argument the function
1058            // itself sees, so the ONLY admissible difference is the rounding of
1059            // the rearranged grouping: the two intermediate products are of
1060            // size `2η|d1|` and `d1²`, so a few ulp of those is the budget.
1061            //
1062            // Only checked where the naive form still HAS digits. Its two terms
1063            // both approach ¼ and cancel down to `1/(8η)`, which costs `≈ 8εη²`
1064            // in relative terms — already `1e-12` at η = 64 and total loss by
1065            // `η ≈ 1e8`. That collapse is the whole reason for the rearrangement,
1066            // so asserting agreement past it would assert nothing.
1067            if eta <= 64.0 {
1068                let round_tripped = eta.ln().exp();
1069                let (_, _, same_d1) = bessel_i0_centered_terms(round_tripped);
1070                let curvature = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
1071                let naive = -round_tripped * (2.0 * same_d1 + 1.0) - same_d1 * same_d1;
1072                let budget =
1073                    8.0 * f64::EPSILON * (2.0 * round_tripped * same_d1.abs() + same_d1 * same_d1);
1074                assert!(
1075                    (curvature - naive).abs() <= budget,
1076                    "c'' must equal −η(2d1+1) − d1² at eta={eta}: \
1077                     c2={curvature:.17e}, naive={naive:.17e}, budget={budget:.3e}"
1078                );
1079            }
1080
1081            // `I1 < I0` for every η > 0, so `I1/I0 ∈ (0,1)` and `d1 < 0`. `d1`
1082            // is NOT monotone: it falls to a global minimum
1083            // `−0.608891247247801…` at `η = 1.702379944878764…` (the root of
1084            // `d(d1)/dη`) before rising back to its `−½` limit, so it crosses
1085            // `−½` once and the useful two-sided bound is that minimum.
1086            assert!((0.0..1.0).contains(&ratio), "I1/I0({eta})={ratio} ∉ (0,1)");
1087            assert!(
1088                (-0.608_891_247_247_802..0.0).contains(&d1),
1089                "η(I1/I0 − 1) at {eta} is {d1}, outside (min d1, 0)"
1090            );
1091        }
1092    }
1093
1094    /// A branch crossover must not be observable in the output. The retired A&S
1095    /// pair stepped by `4e-6` in `d1` and `2e-4` in the curvature at its own
1096    /// 3.75 seam — a jump discontinuity in the objective and gradient an outer
1097    /// optimizer differentiates through.
1098    #[test]
1099    fn bessel_branch_crossovers_have_no_step() {
1100        // Every seam the implementation has ever carried.
1101        for seam in [1.0_f64, 3.75, 20.0, 30.0] {
1102            let delta = 1.0e-11 * seam;
1103            let (below_c, below_r, below_d1) = bessel_i0_centered_terms(seam - delta);
1104            let (above_c, above_r, above_d1) = bessel_i0_centered_terms(seam + delta);
1105            let below_c2 =
1106                bessel_i0_centered_second_log_derivative_from_log_abs((seam - delta).ln());
1107            let above_c2 =
1108                bessel_i0_centered_second_log_derivative_from_log_abs((seam + delta).ln());
1109
1110            // Over `2δ` the true functions can move by at most `2δ·|f'|`, and
1111            // every derivative here is bounded by 1 in magnitude. Anything past
1112            // that plus a few ulp is a step, not a slope.
1113            let slope_budget = 2.0 * delta + 1.0e-14;
1114            assert!(
1115                (above_c - below_c).abs() < slope_budget,
1116                "centered log steps at the {seam} seam: {below_c:.17e} -> {above_c:.17e}"
1117            );
1118            assert!(
1119                (above_r - below_r).abs() < slope_budget,
1120                "I1/I0 steps at the {seam} seam: {below_r:.17e} -> {above_r:.17e}"
1121            );
1122            assert!(
1123                (above_d1 - below_d1).abs() < slope_budget,
1124                "d1 steps at the {seam} seam: {below_d1:.17e} -> {above_d1:.17e}"
1125            );
1126            assert!(
1127                (above_c2 - below_c2).abs() < slope_budget,
1128                "c'' steps at the {seam} seam: {below_c2:.17e} -> {above_c2:.17e}"
1129            );
1130        }
1131    }
1132
1133    /// Non-finite and boundary arguments keep their documented limits, and no
1134    /// series loop can run away on them.
1135    #[test]
1136    fn bessel_primitives_handle_boundary_arguments() {
1137        let (centered, ratio, d1) = bessel_i0_centered_terms(f64::INFINITY);
1138        assert_eq!((centered, ratio, d1), (f64::NEG_INFINITY, 1.0, -0.5));
1139        let (centered, ratio, d1) = bessel_i0_centered_terms(f64::NEG_INFINITY);
1140        assert_eq!((centered, ratio, d1), (f64::NEG_INFINITY, 1.0, -0.5));
1141
1142        let (centered, ratio, d1) = bessel_i0_centered_terms(f64::NAN);
1143        assert!(centered.is_nan() && ratio.is_nan() && d1.is_nan());
1144        assert!(bessel_i0_centered_second_log_derivative_from_log_abs(f64::NAN).is_nan());
1145
1146        // I0 and I1 are even/odd, so every returned term is a function of |η|.
1147        for eta in [0.5_f64, 5.0, 25.0, 1.0e6] {
1148            assert_eq!(
1149                bessel_i0_centered_terms(-eta),
1150                bessel_i0_centered_terms(eta)
1151            );
1152        }
1153    }
1154
1155    /// The polygamma family against a 50-digit `mpmath` evaluation.
1156    ///
1157    /// These consolidate four separate hand-rolled Bernoulli-series copies that
1158    /// had drifted apart: `gam-sae` recursed to 10 and stopped at `B₆`/`B₆`,
1159    /// `gam-solve` recursed to 8 and stopped at `B₁₀`/`B₁₀`/`B₁₀`, `gam-terms`
1160    /// recursed to 8 with yet another term count. Measured against this oracle
1161    /// they were good to `7.6e−10`, `3.1e−10`, `6.3e−11`, `3.9e−11` and
1162    /// `2.6e−10` respectively — 10 to 11 digits, and mutually inconsistent at
1163    /// that scale, in code that supplies REML gradients and Hessians for the
1164    /// negative-binomial `θ`, Gamma dispersion and Beta shape channels.
1165    #[test]
1166    fn polygamma_family_matches_independent_high_precision_reference() {
1167        // (x, ψ(x), ψ₁(x), ψ₂(x), ψ₃(x))
1168        const POLYGAMMA_REFERENCE: [[f64; 5]; 22] = [
1169            [
1170                1e-08,
1171                -100000000.57721564,
1172                1.0000000000000002e+16,
1173                -2e+24,
1174                5.999999999999999e+32,
1175            ],
1176            [
1177                0.0001,
1178                -10000.577051183514,
1179                100000001.64469367,
1180                -2000000000002.403,
1181                5.999999999999999e+16,
1182            ],
1183            [
1184                0.01,
1185                -100.56088545786868,
1186                10001.621213528313,
1187                -2000002.340398677,
1188                600000006.2510618,
1189            ],
1190            [
1191                0.1,
1192                -10.423754940411076,
1193                101.43329915079275,
1194                -2001.8614573783436,
1195                60004.51287679026,
1196            ],
1197            [
1198                0.25,
1199                -4.2274535333762655,
1200                17.19732915450711,
1201                -129.32773993753693,
1202                1538.7821440091884,
1203            ],
1204            [
1205                0.5,
1206                -1.9635100260214235,
1207                4.934802200544679,
1208                -16.82879664423432,
1209                97.40909103400244,
1210            ],
1211            [
1212                1.0,
1213                -0.5772156649015329,
1214                1.6449340668482264,
1215                -2.4041138063191885,
1216                6.493939402266829,
1217            ],
1218            // The unique positive root of ψ, where the relative bound below is
1219            // vacuous and the absolute one carries the assertion.
1220            [
1221                1.4616321449683622,
1222                -9.241265521729427e-17,
1223                0.9676722454476212,
1224                -0.8855263379671844,
1225                1.5509985657339065,
1226            ],
1227            [
1228                2.0,
1229                0.42278433509846713,
1230                0.6449340668482264,
1231                -0.4041138063191886,
1232                0.49393940226682914,
1233            ],
1234            [
1235                3.5,
1236                1.103156640645243,
1237                0.3303577561002349,
1238                -0.1082040516417274,
1239                0.07030584881725205,
1240            ],
1241            // The three retired recurrence thresholds (8 and 10) and the live
1242            // one (20), each straddled.
1243            [
1244                7.0,
1245                1.8727843350984672,
1246                0.15354517795933756,
1247                -0.023530472985855238,
1248                0.007198198563125445,
1249            ],
1250            [
1251                8.0,
1252                2.01564147795561,
1253                0.1331370146940314,
1254                -0.017699569195767775,
1255                0.004699239795945104,
1256            ],
1257            [
1258                10.0,
1259                2.251752589066721,
1260                0.10516633568168575,
1261                -0.011049834970802067,
1262                0.0023199013042898686,
1263            ],
1264            [
1265                19.0,
1266                2.9178924132947808,
1267                0.05404090603769619,
1268                -0.0029197100973139254,
1269                0.0003154143837079449,
1270            ],
1271            [
1272                19.999,
1273                2.9704727201051075,
1274                0.05127345119229945,
1275                -0.0026283917972403977,
1276                0.00026941563155986057,
1277            ],
1278            [
1279                20.0,
1280                2.970523992242149,
1281                0.05127082293520312,
1282                -0.0026281224023146548,
1283                0.0002693742213396389,
1284            ],
1285            [
1286                20.001,
1287                2.970575261751068,
1288                0.05126819494748101,
1289                -0.0026278530487948894,
1290                0.0002693328196036835,
1291            ],
1292            [
1293                25.0,
1294                3.198742512851974,
1295                0.04081066325722558,
1296                -0.001665279318422468,
1297                0.0001358846365082737,
1298            ],
1299            [
1300                100.0,
1301                4.600161852738087,
1302                0.010050166663333571,
1303                -0.00010100499983335,
1304                2.030199990001333e-06,
1305            ],
1306            [
1307                10000.0,
1308                9.210290371142849,
1309                0.00010000500016666666,
1310                -1.000100005e-08,
1311                2.00030002e-12,
1312            ],
1313            [
1314                100000000.0,
1315                18.420680738952367,
1316                1.000000005e-08,
1317                -1.00000001e-16,
1318                2.0000000300000002e-24,
1319            ],
1320            [
1321                1000000000000000.0,
1322                34.538776394910684,
1323                1.0000000000000005e-15,
1324                -1.000000000000001e-30,
1325                2.000000000000003e-45,
1326            ],
1327        ];
1328
1329        for [x, want_psi, want_psi1, want_psi2, want_psi3] in POLYGAMMA_REFERENCE {
1330            // `ψ` crosses zero at x ≈ 1.4616, and the recurrence sums up to 20
1331            // reciprocals whose partial sums dwarf a near-zero result, so the
1332            // absolute term is what applies there. Everywhere else the relative
1333            // term binds. `1e-14` relative is 4 orders tighter than the loosest
1334            // implementation this replaced.
1335            let checks = [
1336                ("ψ", digamma(x), want_psi),
1337                ("ψ₁", trigamma(x), want_psi1),
1338                ("ψ₂", tetragamma(x), want_psi2),
1339                ("ψ₃", pentagamma(x), want_psi3),
1340            ];
1341            for (name, got, want) in checks {
1342                let error = (got - want).abs();
1343                let budget = 1e-14 * want.abs() + 1e-15;
1344                assert!(
1345                    error <= budget,
1346                    "{name}({x}): got {got:.17e}, want {want:.17e} \
1347                     (error {error:.3e} > {budget:.3e})"
1348                );
1349            }
1350        }
1351    }
1352
1353    /// The recurrences and the asymptotic series must agree where they meet,
1354    /// and each function must be the derivative of the one before it. Both were
1355    /// true of the copies this replaces only to their own `1e-10`.
1356    #[test]
1357    fn polygamma_family_is_seamless_and_mutually_consistent() {
1358        for threshold in [8.0_f64, 10.0, 20.0] {
1359            let delta = 1.0e-11 * threshold;
1360            for f in [digamma as fn(f64) -> f64, trigamma, tetragamma, pentagamma] {
1361                let below = f(threshold - delta);
1362                let above = f(threshold + delta);
1363                // Every one of these has |f'| < 1 at x ≥ 8, so the true change
1364                // over 2δ is below 2δ. Anything more is a step.
1365                assert!(
1366                    (above - below).abs() < 2.0 * delta + 1.0e-15,
1367                    "polygamma step at the {threshold} seam: {below:.17e} -> {above:.17e}"
1368                );
1369            }
1370        }
1371
1372        // ψ_{n+1} = dψ_n/dx, checked by a central difference whose own error
1373        // (roundoff ε|f|/h plus truncation h²f'''/6) is the limit here.
1374        for x in [0.75_f64, 1.5, 4.0, 9.0, 19.5, 21.0, 60.0] {
1375            let h = 1.0e-4 * x;
1376            for (name, value, derivative) in [
1377                ("ψ", digamma as fn(f64) -> f64, trigamma as fn(f64) -> f64),
1378                ("ψ₁", trigamma, tetragamma),
1379                ("ψ₂", tetragamma, pentagamma),
1380            ] {
1381                let finite_difference = (value(x + h) - value(x - h)) / (2.0 * h);
1382                let analytic = derivative(x);
1383                assert!(
1384                    (finite_difference - analytic).abs() <= 1e-6 * analytic.abs().max(1e-3),
1385                    "d{name}/dx at {x}: analytic={analytic:.17e}, fd={finite_difference:.17e}"
1386                );
1387            }
1388        }
1389
1390        // Non-positive and non-finite arguments are outside the domain.
1391        for bad in [
1392            0.0_f64,
1393            -1.0,
1394            -0.5,
1395            f64::NAN,
1396            f64::INFINITY,
1397            f64::NEG_INFINITY,
1398        ] {
1399            assert!(digamma(bad).is_nan(), "digamma({bad}) must be NaN");
1400            assert!(trigamma(bad).is_nan(), "trigamma({bad}) must be NaN");
1401            assert!(tetragamma(bad).is_nan(), "tetragamma({bad}) must be NaN");
1402            assert!(pentagamma(bad).is_nan(), "pentagamma({bad}) must be NaN");
1403        }
1404    }
1405
1406    #[test]
1407    fn gauss_legendre_integrates_polynomials_exactly() {
1408        // An n-point rule is exact for polynomials of degree ≤ 2n−1.
1409        for n in [1usize, 2, 3, 5, 8, 40, 64] {
1410            let (nodes, weights) = gauss_legendre(n);
1411            assert_eq!(nodes.len(), n);
1412            assert_eq!(weights.len(), n);
1413            assert!(nodes.windows(2).all(|w| w[0] < w[1]), "nodes ascending");
1414            if !n.is_multiple_of(2) {
1415                assert_eq!(nodes[n / 2], 0.0, "odd-n central node is exact zero");
1416            }
1417            let total: f64 = weights.iter().sum();
1418            assert!((total - 2.0).abs() < 1e-13, "∫1 dx = 2, got {total}");
1419            if n >= 2 {
1420                let x2: f64 = nodes.iter().zip(&weights).map(|(x, w)| w * x * x).sum();
1421                assert!((x2 - 2.0 / 3.0).abs() < 1e-13, "∫x² dx = 2/3, got {x2}");
1422            }
1423            // Degrees 0 and 2 alone exercise almost none of the rule — the
1424            // weights barely matter there. Assert the whole `2n−1` guarantee.
1425            //
1426            // The odd degrees integrate to zero by symmetry, so the error is
1427            // measured against `Σ|w·xᵈ|`, the size of the terms that had to
1428            // cancel, rather than against the vanishing answer. For the even
1429            // degrees every term is positive and that denominator IS the exact
1430            // value, so the same expression is the ordinary relative error.
1431            for degree in 0..(2 * n) {
1432                let term = |(x, w): (&f64, &f64)| w * x.powi(degree as i32);
1433                let quadrature: f64 = nodes.iter().zip(&weights).map(term).sum();
1434                let magnitude: f64 = nodes.iter().zip(&weights).map(|p| term(p).abs()).sum();
1435                let exact = if degree % 2 == 1 {
1436                    0.0
1437                } else {
1438                    2.0 / (degree as f64 + 1.0)
1439                };
1440                let scale = magnitude.max(exact);
1441                // `n = 1` puts its only node at exactly zero, so every odd
1442                // degree has nothing to cancel and must come out exactly zero.
1443                if scale == 0.0 {
1444                    assert_eq!(quadrature, 0.0, "n={n}, x^{degree}");
1445                    continue;
1446                }
1447                assert!(
1448                    (quadrature - exact).abs() / scale < 1.0e-13,
1449                    "n={n} rule must integrate x^{degree} exactly: got {quadrature:.17e}, \
1450                     want {exact:.17e}"
1451                );
1452            }
1453        }
1454    }
1455
1456    /// The nodes are Newton-converged to ~1 ulp, but the weights are read off
1457    /// `P_n'` and were being evaluated one Newton step BEHIND the node they are
1458    /// paired with. Legendre's equation turns that lag into `2·2z/(1−z²)` times
1459    /// the node offset, so it is invisible in the nodes and plainly visible
1460    /// here: at `n = 16` the weights carried `8.3e−14`, against the `1.9e−15`
1461    /// they carry once `P_n'` is re-evaluated at the returned node.
1462    #[test]
1463    fn gauss_legendre_weights_match_independent_high_precision_reference() {
1464        // (node, weight) over the positive half, from a 50-digit root solve;
1465        // the rule is symmetric, so the negative half is the mirror image.
1466        const GL8: [(f64, f64); 4] = [
1467            (0.183434642495649805, 0.362683783378361983),
1468            (0.525532409916328986, 0.313706645877887287),
1469            (0.796666477413626740, 0.222381034453374471),
1470            (0.960289856497536232, 0.101228536290376259),
1471        ];
1472        const GL16: [(f64, f64); 8] = [
1473            (0.0950125098376374402, 0.189450610455068496),
1474            (0.281603550779258913, 0.182603415044923589),
1475            (0.458016777657227386, 0.169156519395002538),
1476            (0.617876244402643748, 0.149595988816576732),
1477            (0.755404408355003034, 0.124628971255533872),
1478            (0.865631202387831744, 0.0951585116824927848),
1479            (0.944575023073232576, 0.0622535239386478929),
1480            (0.989400934991649933, 0.0271524594117540949),
1481        ];
1482
1483        // The nodes are a Newton root to within a few ulp of 1. The weights sit
1484        // an order looser because `2z/(1−z²)` amplifies whatever the node's
1485        // residual is — but two orders TIGHTER than the stale-derivative form,
1486        // which is what this pins.
1487        const NODE_TOL: f64 = 4.0e-16;
1488        const WEIGHT_TOL: f64 = 1.0e-14;
1489
1490        for (n, reference) in [(8usize, &GL8[..]), (16, &GL16[..])] {
1491            let (nodes, weights) = gauss_legendre(n);
1492            for (k, &(want_node, want_weight)) in reference.iter().enumerate() {
1493                // Positive half, ascending, is the back half of the rule.
1494                let index = n / 2 + k;
1495                let (got_node, got_weight) = (nodes[index], weights[index]);
1496                assert!(
1497                    (got_node - want_node).abs() < NODE_TOL,
1498                    "n={n} node {index}: got {got_node:.17e}, want {want_node:.17e}"
1499                );
1500                let relative = (got_weight - want_weight).abs() / want_weight.abs();
1501                assert!(
1502                    relative < WEIGHT_TOL,
1503                    "n={n} weight {index}: got {got_weight:.17e}, want {want_weight:.17e}, \
1504                     rel {relative:.3e}"
1505                );
1506                // Symmetry: the mirrored entry must be bit-identical.
1507                let mirror = n / 2 - 1 - k;
1508                assert_eq!(
1509                    nodes[mirror], -got_node,
1510                    "n={n} node {mirror} mirrors {index}"
1511                );
1512                assert_eq!(weights[mirror], got_weight, "n={n} weight {mirror} mirrors");
1513            }
1514        }
1515    }
1516
1517    #[test]
1518    fn binom_k_exceeds_n_returns_zero() {
1519        assert_eq!(binomial_coefficient_f64(3, 5), 0.0);
1520        assert_eq!(binomial_coefficient_f64(0, 1), 0.0);
1521        assert_eq!(binomial_coefficient_f64(10, 11), 0.0);
1522    }
1523
1524    #[test]
1525    fn binom_k_zero_returns_one() {
1526        assert_eq!(binomial_coefficient_f64(0, 0), 1.0);
1527        assert_eq!(binomial_coefficient_f64(5, 0), 1.0);
1528        assert_eq!(binomial_coefficient_f64(100, 0), 1.0);
1529    }
1530
1531    #[test]
1532    fn binom_k_equals_n_returns_one() {
1533        assert_eq!(binomial_coefficient_f64(1, 1), 1.0);
1534        assert_eq!(binomial_coefficient_f64(5, 5), 1.0);
1535        assert_eq!(binomial_coefficient_f64(20, 20), 1.0);
1536    }
1537
1538    #[test]
1539    fn binom_small_exact_values() {
1540        assert_eq!(binomial_coefficient_f64(5, 2), 10.0);
1541        assert_eq!(binomial_coefficient_f64(10, 3), 120.0);
1542        assert_eq!(binomial_coefficient_f64(20, 10), 184_756.0);
1543        assert_eq!(binomial_coefficient_f64(6, 3), 20.0);
1544    }
1545
1546    #[test]
1547    fn binom_symmetry() {
1548        assert_eq!(
1549            binomial_coefficient_f64(10, 3),
1550            binomial_coefficient_f64(10, 7)
1551        );
1552        assert_eq!(
1553            binomial_coefficient_f64(20, 5),
1554            binomial_coefficient_f64(20, 15)
1555        );
1556        assert_eq!(
1557            binomial_coefficient_f64(54, 24),
1558            binomial_coefficient_f64(54, 30)
1559        );
1560    }
1561
1562    #[test]
1563    fn binom_c54_24_is_exact() {
1564        // The u128-recurrence fix restored this value (old f64 recurrence
1565        // returned 1_402_659_561_581_459, one short of the true integer).
1566        assert_eq!(binomial_coefficient_f64(54, 24), 1_402_659_561_581_460.0);
1567    }
1568
1569    #[test]
1570    fn poly_exp_empty_coeffs_returns_zero() {
1571        assert_eq!(stable_polynomial_times_exp_neg(1.0, &[]), 0.0);
1572        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[]), 0.0);
1573        assert_eq!(stable_polynomial_times_exp_neg(700.0, &[]), 0.0);
1574    }
1575
1576    #[test]
1577    fn poly_exp_nonfinite_x_returns_zero() {
1578        assert_eq!(
1579            stable_polynomial_times_exp_neg(f64::INFINITY, &[1.0, 2.0]),
1580            0.0
1581        );
1582        assert_eq!(
1583            stable_polynomial_times_exp_neg(f64::NEG_INFINITY, &[1.0, 2.0]),
1584            0.0
1585        );
1586        assert_eq!(stable_polynomial_times_exp_neg(f64::NAN, &[1.0]), 0.0);
1587    }
1588
1589    #[test]
1590    fn poly_exp_constant_at_zero() {
1591        // At x=0: poly(0) = coeffs[0], exp(0)=1 → result = coeffs[0].
1592        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[5.0]), 5.0);
1593        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[3.0, 1.0, 2.0]), 3.0);
1594    }
1595
1596    #[test]
1597    fn poly_exp_constant_poly_direct_path() {
1598        // x=2.0 < 600: direct Horner * exp(-x).
1599        let x = 2.0;
1600        let got = stable_polynomial_times_exp_neg(x, &[3.0]);
1601        let expected = 3.0 * (-x).exp();
1602        assert!(
1603            (got - expected).abs() < 1e-14,
1604            "got={got} expected={expected}"
1605        );
1606    }
1607
1608    #[test]
1609    fn poly_exp_linear_poly_direct_path() {
1610        // coeffs = [a, b] → poly = a + b*x.
1611        let x = 1.5;
1612        let (a, b) = (2.0, 3.0);
1613        let got = stable_polynomial_times_exp_neg(x, &[a, b]);
1614        let expected = (a + b * x) * (-x).exp();
1615        assert!(
1616            (got - expected).abs() < 1e-14,
1617            "got={got} expected={expected}"
1618        );
1619    }
1620
1621    #[test]
1622    fn poly_exp_constant_poly_asymptotic_path() {
1623        // x=700 > 600: asymptotic path. For poly = [1.0], result = exp(-700).
1624        let x = 700.0_f64;
1625        let got = stable_polynomial_times_exp_neg(x, &[1.0]);
1626        let expected = (-x).exp();
1627        let rel = (got - expected).abs() / expected;
1628        assert!(rel < 1e-12, "got={got} expected={expected} rel={rel}");
1629    }
1630
1631    #[test]
1632    fn poly_exp_quadratic_asymptotic_path() {
1633        // x=620 > 600: poly = x^2 (coeffs=[0,0,1]). Result = x^2 * exp(-x).
1634        // x=800 would underflow to 0.0 in both the asymptotic path and the
1635        // reference, making the relative-error check degenerate; x=620 keeps
1636        // the result in the normal f64 range (~10^-264) while still exercising
1637        // the asymptotic branch (threshold is x=600).
1638        let x = 620.0_f64;
1639        let got = stable_polynomial_times_exp_neg(x, &[0.0, 0.0, 1.0]);
1640        let expected = (2.0 * x.ln() - x).exp();
1641        let rel = (got - expected).abs() / expected.abs();
1642        assert!(rel < 1e-12, "got={got} expected={expected} rel={rel}");
1643    }
1644
1645    /// Pins the measured accuracy of `c''(log η)` against an 80-digit
1646    /// reference, per regime, so the branch structure cannot silently drift.
1647    ///
1648    /// The tolerances are the MEASURED worst case in each regime plus a factor
1649    /// of two, not aspirations: the `1 ≤ η < 20` band is bounded below by the
1650    /// `8η²·κ(η)·ε` floor of the ascending representation (see
1651    /// [`bessel_i0_centered_second_log_derivative_from_log_abs`]), and 1e-11 is
1652    /// what that floor permits at the top of the band. Tightening it needs a
1653    /// wider-than-`f64` `d1`, not a smaller constant here.
1654    #[test]
1655    fn centered_bessel_second_log_derivative_matches_high_precision_reference() {
1656        // (η, c''(log η) to 20 significant digits, tolerance).
1657        const CASES: [(f64, f64, f64); 13] = [
1658            (0.5, -0.2647015155254598, 1e-14),
1659            (1.0, -0.19926400165310923, 1e-14),
1660            (2.0, 0.05244210681284669, 1e-13),
1661            (5.0, 0.0466642611317311, 1e-13),
1662            (10.0, 0.015837019843595493, 1e-12),
1663            (15.0, 0.009659446256568909, 1e-11),
1664            // The worst point of the whole domain, just under the crossover.
1665            (18.85, 0.00743799786561837, 1e-11),
1666            (19.99, 0.006964307582746309, 1e-11),
1667            // First point on the asymptotic side: two orders better, at once.
1668            (20.0, 0.006960419930170057, 1e-12),
1669            (25.0, 0.005442291838848013, 1e-14),
1670            (50.0, 0.0026049656149811874, 1e-15),
1671            (200.0, 0.0006313242744933583, 1e-15),
1672            (1e4, 1.2502500586100053e-05, 1e-15),
1673        ];
1674        for (eta, expected, tolerance) in CASES {
1675            let got = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
1676            let relative = (got - expected).abs() / expected.abs();
1677            assert!(
1678                relative < tolerance,
1679                "eta={eta}: got={got} expected={expected} rel={relative:e} tol={tolerance:e}"
1680            );
1681        }
1682    }
1683
1684    /// The crossover at `BESSEL_ASYMPTOTIC_THRESHOLD` is a step DOWN in error,
1685    /// so the value itself must still be continuous across it to within what
1686    /// the worse (ascending) side delivers — nothing tighter is available, and
1687    /// nothing looser would catch a branch that had been mis-derived.
1688    ///
1689    /// The step size matters and is not free to enlarge. `c''` genuinely varies:
1690    /// `|dc''/dη| / |c''| = 1/η`, so a step `δ` moves the true value by `δ/η`
1691    /// RELATIVE. At `δ = 1e−9` that is `5e−11` — larger than the seam being
1692    /// measured, and a test written that way reports the function's own slope
1693    /// as a discontinuity. `1e−11` puts the true variation at `5e−13`, an order
1694    /// under the ascending branch's `8.9e−12` floor, while still clearing
1695    /// `ulp(20) = 3.6e−15` by four orders.
1696    #[test]
1697    fn centered_bessel_second_log_derivative_is_continuous_across_the_crossover() {
1698        const STEP: f64 = 1e-11;
1699        let below = bessel_i0_centered_second_log_derivative_from_log_abs(
1700            (BESSEL_ASYMPTOTIC_THRESHOLD - STEP).ln(),
1701        );
1702        let above =
1703            bessel_i0_centered_second_log_derivative_from_log_abs(BESSEL_ASYMPTOTIC_THRESHOLD.ln());
1704        assert!(
1705            below != above,
1706            "step {STEP:e} was rounded away; the two sides are the same evaluation"
1707        );
1708        let jump = (below - above).abs() / above.abs();
1709        assert!(
1710            jump < 3e-11,
1711            "seam jump {jump:e}: below={below} above={above}"
1712        );
1713    }
1714}