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/// Large-argument (`|x| >= 3.75`) Abramowitz & Stegun 9.8.2 polynomial for the
84/// exponentially scaled modified Bessel function `I0`:
85/// `sqrt(x) exp(-x) I0(x)`. Factoring out the common `exp(x) / sqrt(x)`
86/// envelope lets the log partition and `I1 / I0` ratio be evaluated without
87/// overflow.
88#[inline]
89fn bessel_i0_scaled_polynomial_and_centered_log_derivative(ax: f64) -> (f64, f64) {
90    let y = 3.75 / ax;
91    const COEFFICIENTS: [f64; 9] = [
92        0.39894228,
93        0.01328592,
94        0.00225319,
95        -0.00157565,
96        0.00916281,
97        -0.02057706,
98        0.02635537,
99        -0.01647633,
100        0.00392377,
101    ];
102    let mut polynomial = COEFFICIENTS[COEFFICIENTS.len() - 1];
103    let mut derivative = 0.0_f64;
104    for &coefficient in COEFFICIENTS[..COEFFICIENTS.len() - 1].iter().rev() {
105        derivative = derivative * y + polynomial;
106        polynomial = polynomial * y + coefficient;
107    }
108    // For L(x) = log I0(x) - x = -½ log x + log P0(3.75/x),
109    // dL/d(log x) = -½ - y P0'(y)/P0(y). Differentiating the same
110    // approximation used for the value keeps the ARD objective and gradient
111    // consistent and tends to the exact -½ limit without subtracting two
112    // rounded numbers near one.
113    let scaled_centered_log_derivative = -0.5 - y * derivative / polynomial;
114    (polynomial, scaled_centered_log_derivative)
115}
116
117/// Large-argument (`|x| >= 3.75`) Abramowitz & Stegun 9.8.4 polynomial for the
118/// exponentially scaled modified Bessel function `I1`. Its envelope is the
119/// same as [`bessel_i0_scaled_polynomial_and_centered_log_derivative`], so it
120/// cancels exactly in `I1 / I0`.
121#[inline]
122fn bessel_i1_scaled_polynomial(ax: f64) -> f64 {
123    let y = 3.75 / ax;
124    0.39894228
125        + y * (-0.03988024
126            + y * (-0.00362018
127                + y * (0.00163801
128                    + y * (-0.01031555
129                        + y * (0.02282967
130                            + y * (-0.02895312 + y * (0.01787654 - y * 0.00420059)))))))
131}
132
133#[inline]
134fn bessel_i0_small(ax: f64) -> f64 {
135    let t = ax / 3.75;
136    let t2 = t * t;
137    1.0 + t2
138        * (3.5156229
139            + t2 * (3.0899424
140                + t2 * (1.2067492 + t2 * (0.2659732 + t2 * (0.0360768 + t2 * 0.0045813)))))
141}
142
143#[inline]
144fn bessel_i1_small(ax: f64) -> f64 {
145    let t = ax / 3.75;
146    let t2 = t * t;
147    ax * (0.5
148        + t2 * (0.87890594
149            + t2 * (0.51498869
150                + t2 * (0.15084934 + t2 * (0.02658733 + t2 * (0.00301532 + t2 * 0.00032411))))))
151}
152
153/// Overflow-free centered Bessel value, ratio, and log-scale derivative.
154///
155/// For `x = |eta|`, returns
156/// `(log I0(x) - x, I1(x) / I0(x), x d/dx[log I0(x) - x])`. The third term is
157/// the stable form of `x·(I1/I0 - 1)`: it approaches `-½` instead of becoming
158/// `x·0` after the ordinary ratio rounds to one. Centering the logarithm by its
159/// leading `x` term likewise prevents catastrophic cancellation.
160pub fn bessel_i0_centered_terms(eta: f64) -> (f64, f64, f64) {
161    let ax = eta.abs();
162    if ax < 3.75 {
163        let i0 = bessel_i0_small(ax);
164        let i1 = bessel_i1_small(ax);
165        let ratio = i1 / i0;
166        (i0.ln() - ax, ratio, ax * (ratio - 1.0))
167    } else {
168        let (polynomial_0, scaled_centered_log_derivative) =
169            bessel_i0_scaled_polynomial_and_centered_log_derivative(ax);
170        let polynomial_1 = bessel_i1_scaled_polynomial(ax);
171        (
172            -0.5 * ax.ln() + polynomial_0.ln(),
173            polynomial_1 / polynomial_0,
174            scaled_centered_log_derivative,
175        )
176    }
177}
178
179/// Stable centered Bessel terms when only `log(|eta|)` is representable.
180///
181/// For a finite representable `|eta|`, this is exactly
182/// [`bessel_i0_centered_terms`]. Beyond the float range, inverse-`eta`
183/// corrections are themselves below float resolution, so the limiting terms
184/// `log I0(eta)-eta = -½ log(2 pi eta)` and
185/// `eta d/deta[log I0(eta)-eta] = -½` are the correctly rounded result.
186pub fn bessel_i0_centered_terms_from_log_abs(log_abs_eta: f64) -> (f64, f64, f64) {
187    if log_abs_eta.is_nan() {
188        return (f64::NAN, f64::NAN, f64::NAN);
189    }
190    if log_abs_eta == f64::NEG_INFINITY {
191        return (0.0, 0.0, 0.0);
192    }
193    if log_abs_eta <= f64::MAX.ln() {
194        return bessel_i0_centered_terms(log_abs_eta.exp());
195    }
196    (-0.5 * (std::f64::consts::TAU.ln() + log_abs_eta), 1.0, -0.5)
197}
198
199/// Second log-scale derivative of the centered Bessel primitive:
200/// `d²/d(log η)²[log I0(η) − η]`, i.e. the derivative of the third term `d1`
201/// returned by [`bessel_i0_centered_terms`] (`d1 = η d/dη[log I0(η) − η]`).
202///
203/// Writing `s = log η`, `r = I1(η)/I0(η)`, and `c(s) = log I0(η) − η`, the first
204/// log-derivative is `c'(s) = d1 = η(r − 1)`. Differentiating again and using
205/// the modified-Bessel ratio ODE `r'(η) = 1 − r/η − r²` gives the exact closed
206/// form `c''(s) = −η + η²(1 − r²)`. That direct form is numerically unusable
207/// for moderate/large `η`: its two terms each grow like `η` and cancel to
208/// `O(1/η)`, so the ratio's `~ε_poly` approximation error is amplified by `η²`.
209/// The algebraically identical rearrangement in terms of the STABLE third term
210///
211/// `c''(s) = −η(2·d1 + 1) − d1²`
212///
213/// cancels safely instead: `d1 → −½` with `2·d1 + 1 → 0` computed from the
214/// overflow-free scaled polynomial, so the amplification drops to `η·δd1`. It is
215/// also, by construction, the exact derivative of the SAME `d1` the outer
216/// gradient's periodic-ARD normalizer channel reports, so gradient and Hessian
217/// differentiate one quantity. Beyond the float range `c'(s) → −½` (constant)
218/// so `c''(s) → 0`; likewise `η → 0` gives `c''(s) → 0`. The von-Mises ARD
219/// log-precision normalizer `n[−η + log I0(η)]` therefore has
220/// `∂²/∂(log α)² = n · c''(log η)` up to the affine `log η = log α + const` shift.
221pub fn bessel_i0_centered_second_log_derivative_from_log_abs(log_abs_eta: f64) -> f64 {
222    if log_abs_eta.is_nan() {
223        return f64::NAN;
224    }
225    if log_abs_eta == f64::NEG_INFINITY {
226        return 0.0;
227    }
228    if log_abs_eta > f64::MAX.ln() {
229        return 0.0;
230    }
231    let eta = log_abs_eta.exp();
232    // The stable `d1` rearrangement still cancels `−η(2d1+1)` against `d1²` to
233    // `O(1/η)`, so past `η ≈ 30` the scaled polynomial's residual error in `d1`
234    // (amplified by `η`) exceeds the signal. There the convergent large-argument
235    // series `c''(s) = 1/(8η) + 1/(4η²) + 75/(128η³) + O(η⁻⁴)` (the `d/ds` of the
236    // `d1 = −½ − 1/(8η) − 1/(8η²) − 25/(128η³)` expansion) is both accurate and
237    // cancellation-free, and rounds smoothly to the `η → ∞` limit `0`.
238    if eta > 30.0 {
239        let inv = 1.0 / eta;
240        return inv * (0.125 + inv * (0.25 + inv * (75.0 / 128.0)));
241    }
242    let (_centered, _ratio, d1) = bessel_i0_centered_terms(eta);
243    -eta * (2.0 * d1 + 1.0) - d1 * d1
244}
245
246/// Overflow-free `(log I0(eta) - |eta|, I1(|eta|) / I0(|eta|))`.
247///
248/// Centering the logarithm by its leading `|eta|` term is essential whenever a
249/// likelihood cancels the Bessel growth against an equally large quadratic,
250/// as in a Gaussian-blurred circle. The large-argument branch never forms
251/// `exp(|eta|)`, and therefore remains finite beyond the ordinary exponential
252/// overflow threshold and up to the largest finite `f64`.
253pub fn bessel_i0_log_minus_abs_and_ratio(eta: f64) -> (f64, f64) {
254    let (centered_log_i0, ratio, _) = bessel_i0_centered_terms(eta);
255    (centered_log_i0, ratio)
256}
257
258/// Overflow-free `(log I0(eta), I1(|eta|) / I0(|eta|))`.
259///
260/// Consumers whose formulas cancel the leading `|eta|` term should use
261/// [`bessel_i0_log_minus_abs_and_ratio`] directly, rather than forming that
262/// cancellation after this function returns.
263pub fn bessel_i0_log_and_ratio(eta: f64) -> (f64, f64) {
264    let (centered_log_i0, ratio) = bessel_i0_log_minus_abs_and_ratio(eta);
265    (eta.abs() + centered_log_i0, ratio)
266}
267
268/// Gauss-Legendre nodes and weights on `[-1, 1]` for `n` points, computed via
269/// Newton iteration on the Legendre-polynomial roots (Bonnet's three-term
270/// recurrence, cosine initial guess). Returns `(nodes, weights)` with nodes
271/// ascending; for odd `n` the central node is exactly `0.0`.
272///
273/// Canonical home for the routine previously triplicated in
274/// `gam-terms/basis/closed_form_penalty.rs`, `gam-model-kernels/
275/// cubic_cell_kernel.rs`, and `gam-models/survival/base.rs`; this copy keeps
276/// the tightest of their Newton settings (200-iteration cap, `1e-15`
277/// convergence).
278pub fn gauss_legendre(n: usize) -> (Vec<f64>, Vec<f64>) {
279    let mut tmp: Vec<(f64, f64)> = Vec::with_capacity(n);
280    let half = n.div_ceil(2);
281    for i in 0..half {
282        let mut z = (std::f64::consts::PI * (i as f64 + 0.75) / (n as f64 + 0.5)).cos();
283        let mut pp = 0.0_f64;
284        for _ in 0..200 {
285            let mut p1 = 1.0_f64;
286            let mut p2 = 0.0_f64;
287            for j in 0..n {
288                let p3 = p2;
289                p2 = p1;
290                p1 = ((2.0 * j as f64 + 1.0) * z * p2 - j as f64 * p3) / (j as f64 + 1.0);
291            }
292            pp = n as f64 * (z * p1 - p2) / (z * z - 1.0);
293            let z_prev = z;
294            z = z_prev - p1 / pp;
295            if (z - z_prev).abs() < 1e-15 {
296                break;
297            }
298        }
299        let w = 2.0 / ((1.0 - z * z) * pp * pp);
300        // For odd n the central node is at z = 0; record once.
301        if !n.is_multiple_of(2) && i == half - 1 {
302            tmp.push((0.0, w));
303        } else {
304            tmp.push((-z.abs(), w));
305            tmp.push((z.abs(), w));
306        }
307    }
308    tmp.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
309    let mut nodes = Vec::with_capacity(n);
310    let mut weights = Vec::with_capacity(n);
311    for (z, w) in tmp.into_iter().take(n) {
312        nodes.push(z);
313        weights.push(w);
314    }
315    (nodes, weights)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn centered_bessel_log_is_finite_and_derivative_consistent() {
324        for eta in [0.25_f64, 1.0, 3.74, 3.76, 12.0, 900.0] {
325            let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms(eta);
326            assert!(centered.is_finite());
327            assert!((0.0..=1.0).contains(&ratio));
328
329            let h = 1.0e-4 * eta.max(1.0);
330            let (plus, _) = bessel_i0_log_and_ratio(eta + h);
331            let (minus, _) = bessel_i0_log_and_ratio(eta - h);
332            let derivative = (plus - minus) / (2.0 * h);
333            assert!((derivative - ratio).abs() <= 1.0e-6 + 1.0e-5 * ratio.abs());
334
335            let log_step = 1.0e-5_f64;
336            let (centered_plus, _, _) = bessel_i0_centered_terms(eta * log_step.exp());
337            let (centered_minus, _, _) = bessel_i0_centered_terms(eta * (-log_step).exp());
338            let finite_difference = (centered_plus - centered_minus) / (2.0 * log_step);
339            assert!(
340                (finite_difference - scaled_derivative).abs() < 2.0e-5,
341                "centered Bessel value/gradient mismatch at eta={eta}: analytic={scaled_derivative}, finite_difference={finite_difference}"
342            );
343        }
344        for eta in [1.0e20_f64, 1.0e100, 1.0e300] {
345            let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms(eta);
346            let asymptotic = -0.5 * (std::f64::consts::TAU * eta).ln();
347            assert!(centered.is_finite() && ratio.is_finite());
348            assert!((centered - asymptotic).abs() < 2.0e-8);
349            assert!(
350                (scaled_derivative + 0.5).abs() < 1.0e-12,
351                "large-eta centered derivative must retain its -1/2 limit; eta={eta:e}, derivative={scaled_derivative}"
352            );
353        }
354
355        assert_eq!(bessel_i0_centered_terms(0.0), (0.0, 0.0, 0.0));
356
357        let log_eta = 1_200.0;
358        let (centered, ratio, scaled_derivative) = bessel_i0_centered_terms_from_log_abs(log_eta);
359        assert!(centered.is_finite());
360        assert_eq!(ratio, 1.0);
361        assert_eq!(scaled_derivative, -0.5);
362        assert_eq!(centered, -0.5 * (std::f64::consts::TAU.ln() + log_eta));
363    }
364
365    #[test]
366    fn centered_bessel_second_log_derivative_matches_finite_difference() {
367        // c''(log η) must be the derivative of the third term (c'(log η)) of
368        // `bessel_i0_centered_terms`, across small, mid, and large arguments.
369        // c''(log η) is the log-derivative of the STABLE third term `d1` (the
370        // quantity the outer gradient's ARD normalizer channel reports), so the
371        // self-consistent reference is a central difference of that same term.
372        // This straddles the 3.75 small/large polynomial seam.
373        let first_log_derivative = |x: f64| bessel_i0_centered_terms(x).2;
374        for eta in [0.02_f64, 0.05, 0.25, 1.0, 2.0, 3.5, 4.0, 8.0] {
375            let log_eta = eta.ln();
376            let analytic = bessel_i0_centered_second_log_derivative_from_log_abs(log_eta);
377
378            let log_step = 1.0e-6_f64;
379            let first_plus = first_log_derivative(eta * log_step.exp());
380            let first_minus = first_log_derivative(eta * (-log_step).exp());
381            let finite_difference = (first_plus - first_minus) / (2.0 * log_step);
382            assert!(
383                (analytic - finite_difference).abs() < 5.0e-5 + 1.0e-3 * analytic.abs(),
384                "centered Bessel second log-derivative mismatch at eta={eta}: \
385                 analytic={analytic}, finite_difference={finite_difference}"
386            );
387        }
388        // Large-η decay: the normalizer curvature vanishes like the leading
389        // asymptotic term 1/(8η) (its Hessian contribution is then negligible
390        // beside the ∝α energy term), stays finite and positive, and the
391        // overflow-free gateway rounds it to exactly zero past the float range.
392        for eta in [50.0_f64, 200.0, 1.0e4] {
393            let c2 = bessel_i0_centered_second_log_derivative_from_log_abs(eta.ln());
394            let leading = 1.0 / (8.0 * eta);
395            assert!(
396                c2 > 0.0 && (c2 - leading).abs() < 0.25 * leading,
397                "large-eta centered second derivative must track 1/(8 eta); \
398                 eta={eta}, c2={c2}, leading={leading}"
399            );
400        }
401        // η → 0 and the overflow-free large-|η| gateway both round to 0.
402        assert_eq!(
403            bessel_i0_centered_second_log_derivative_from_log_abs(f64::NEG_INFINITY),
404            0.0
405        );
406        assert_eq!(
407            bessel_i0_centered_second_log_derivative_from_log_abs(1_200.0),
408            0.0
409        );
410    }
411
412    #[test]
413    fn gauss_legendre_integrates_polynomials_exactly() {
414        // An n-point rule is exact for polynomials of degree ≤ 2n−1.
415        for n in [1usize, 2, 3, 5, 8, 40, 64] {
416            let (nodes, weights) = gauss_legendre(n);
417            assert_eq!(nodes.len(), n);
418            assert_eq!(weights.len(), n);
419            assert!(nodes.windows(2).all(|w| w[0] < w[1]), "nodes ascending");
420            if !n.is_multiple_of(2) {
421                assert_eq!(nodes[n / 2], 0.0, "odd-n central node is exact zero");
422            }
423            let total: f64 = weights.iter().sum();
424            assert!((total - 2.0).abs() < 1e-13, "∫1 dx = 2, got {total}");
425            if n >= 2 {
426                let x2: f64 = nodes.iter().zip(&weights).map(|(x, w)| w * x * x).sum();
427                assert!((x2 - 2.0 / 3.0).abs() < 1e-13, "∫x² dx = 2/3, got {x2}");
428            }
429        }
430    }
431
432    #[test]
433    fn binom_k_exceeds_n_returns_zero() {
434        assert_eq!(binomial_coefficient_f64(3, 5), 0.0);
435        assert_eq!(binomial_coefficient_f64(0, 1), 0.0);
436        assert_eq!(binomial_coefficient_f64(10, 11), 0.0);
437    }
438
439    #[test]
440    fn binom_k_zero_returns_one() {
441        assert_eq!(binomial_coefficient_f64(0, 0), 1.0);
442        assert_eq!(binomial_coefficient_f64(5, 0), 1.0);
443        assert_eq!(binomial_coefficient_f64(100, 0), 1.0);
444    }
445
446    #[test]
447    fn binom_k_equals_n_returns_one() {
448        assert_eq!(binomial_coefficient_f64(1, 1), 1.0);
449        assert_eq!(binomial_coefficient_f64(5, 5), 1.0);
450        assert_eq!(binomial_coefficient_f64(20, 20), 1.0);
451    }
452
453    #[test]
454    fn binom_small_exact_values() {
455        assert_eq!(binomial_coefficient_f64(5, 2), 10.0);
456        assert_eq!(binomial_coefficient_f64(10, 3), 120.0);
457        assert_eq!(binomial_coefficient_f64(20, 10), 184_756.0);
458        assert_eq!(binomial_coefficient_f64(6, 3), 20.0);
459    }
460
461    #[test]
462    fn binom_symmetry() {
463        assert_eq!(
464            binomial_coefficient_f64(10, 3),
465            binomial_coefficient_f64(10, 7)
466        );
467        assert_eq!(
468            binomial_coefficient_f64(20, 5),
469            binomial_coefficient_f64(20, 15)
470        );
471        assert_eq!(
472            binomial_coefficient_f64(54, 24),
473            binomial_coefficient_f64(54, 30)
474        );
475    }
476
477    #[test]
478    fn binom_c54_24_is_exact() {
479        // The u128-recurrence fix restored this value (old f64 recurrence
480        // returned 1_402_659_561_581_459, one short of the true integer).
481        assert_eq!(binomial_coefficient_f64(54, 24), 1_402_659_561_581_460.0);
482    }
483
484    #[test]
485    fn poly_exp_empty_coeffs_returns_zero() {
486        assert_eq!(stable_polynomial_times_exp_neg(1.0, &[]), 0.0);
487        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[]), 0.0);
488        assert_eq!(stable_polynomial_times_exp_neg(700.0, &[]), 0.0);
489    }
490
491    #[test]
492    fn poly_exp_nonfinite_x_returns_zero() {
493        assert_eq!(
494            stable_polynomial_times_exp_neg(f64::INFINITY, &[1.0, 2.0]),
495            0.0
496        );
497        assert_eq!(
498            stable_polynomial_times_exp_neg(f64::NEG_INFINITY, &[1.0, 2.0]),
499            0.0
500        );
501        assert_eq!(stable_polynomial_times_exp_neg(f64::NAN, &[1.0]), 0.0);
502    }
503
504    #[test]
505    fn poly_exp_constant_at_zero() {
506        // At x=0: poly(0) = coeffs[0], exp(0)=1 → result = coeffs[0].
507        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[5.0]), 5.0);
508        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[3.0, 1.0, 2.0]), 3.0);
509    }
510
511    #[test]
512    fn poly_exp_constant_poly_direct_path() {
513        // x=2.0 < 600: direct Horner * exp(-x).
514        let x = 2.0;
515        let got = stable_polynomial_times_exp_neg(x, &[3.0]);
516        let expected = 3.0 * (-x).exp();
517        assert!(
518            (got - expected).abs() < 1e-14,
519            "got={got} expected={expected}"
520        );
521    }
522
523    #[test]
524    fn poly_exp_linear_poly_direct_path() {
525        // coeffs = [a, b] → poly = a + b*x.
526        let x = 1.5;
527        let (a, b) = (2.0, 3.0);
528        let got = stable_polynomial_times_exp_neg(x, &[a, b]);
529        let expected = (a + b * x) * (-x).exp();
530        assert!(
531            (got - expected).abs() < 1e-14,
532            "got={got} expected={expected}"
533        );
534    }
535
536    #[test]
537    fn poly_exp_constant_poly_asymptotic_path() {
538        // x=700 > 600: asymptotic path. For poly = [1.0], result = exp(-700).
539        let x = 700.0_f64;
540        let got = stable_polynomial_times_exp_neg(x, &[1.0]);
541        let expected = (-x).exp();
542        let rel = (got - expected).abs() / expected;
543        assert!(rel < 1e-12, "got={got} expected={expected} rel={rel}");
544    }
545
546    #[test]
547    fn poly_exp_quadratic_asymptotic_path() {
548        // x=620 > 600: poly = x^2 (coeffs=[0,0,1]). Result = x^2 * exp(-x).
549        // x=800 would underflow to 0.0 in both the asymptotic path and the
550        // reference, making the relative-error check degenerate; x=620 keeps
551        // the result in the normal f64 range (~10^-264) while still exercising
552        // the asymptotic branch (threshold is x=600).
553        let x = 620.0_f64;
554        let got = stable_polynomial_times_exp_neg(x, &[0.0, 0.0, 1.0]);
555        let expected = (2.0 * x.ln() - x).exp();
556        let rel = (got - expected).abs() / expected.abs();
557        assert!(rel < 1e-12, "got={got} expected={expected} rel={rel}");
558    }
559}