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}