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}