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/// Gauss-Legendre nodes and weights on `[-1, 1]` for `n` points, computed via
84/// Newton iteration on the Legendre-polynomial roots (Bonnet's three-term
85/// recurrence, cosine initial guess). Returns `(nodes, weights)` with nodes
86/// ascending; for odd `n` the central node is exactly `0.0`.
87///
88/// Canonical home for the routine previously triplicated in
89/// `gam-terms/basis/closed_form_penalty.rs`, `gam-model-kernels/
90/// cubic_cell_kernel.rs`, and `gam-models/survival/base.rs`; this copy keeps
91/// the tightest of their Newton settings (200-iteration cap, `1e-15`
92/// convergence).
93pub fn gauss_legendre(n: usize) -> (Vec<f64>, Vec<f64>) {
94    let mut tmp: Vec<(f64, f64)> = Vec::with_capacity(n);
95    let half = n.div_ceil(2);
96    for i in 0..half {
97        let mut z = (std::f64::consts::PI * (i as f64 + 0.75) / (n as f64 + 0.5)).cos();
98        let mut pp = 0.0_f64;
99        for _ in 0..200 {
100            let mut p1 = 1.0_f64;
101            let mut p2 = 0.0_f64;
102            for j in 0..n {
103                let p3 = p2;
104                p2 = p1;
105                p1 = ((2.0 * j as f64 + 1.0) * z * p2 - j as f64 * p3) / (j as f64 + 1.0);
106            }
107            pp = n as f64 * (z * p1 - p2) / (z * z - 1.0);
108            let z_prev = z;
109            z = z_prev - p1 / pp;
110            if (z - z_prev).abs() < 1e-15 {
111                break;
112            }
113        }
114        let w = 2.0 / ((1.0 - z * z) * pp * pp);
115        // For odd n the central node is at z = 0; record once.
116        if !n.is_multiple_of(2) && i == half - 1 {
117            tmp.push((0.0, w));
118        } else {
119            tmp.push((-z.abs(), w));
120            tmp.push((z.abs(), w));
121        }
122    }
123    tmp.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
124    let mut nodes = Vec::with_capacity(n);
125    let mut weights = Vec::with_capacity(n);
126    for (z, w) in tmp.into_iter().take(n) {
127        nodes.push(z);
128        weights.push(w);
129    }
130    (nodes, weights)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn gauss_legendre_integrates_polynomials_exactly() {
139        // An n-point rule is exact for polynomials of degree ≤ 2n−1.
140        for n in [1usize, 2, 3, 5, 8, 40, 64] {
141            let (nodes, weights) = gauss_legendre(n);
142            assert_eq!(nodes.len(), n);
143            assert_eq!(weights.len(), n);
144            assert!(nodes.windows(2).all(|w| w[0] < w[1]), "nodes ascending");
145            if !n.is_multiple_of(2) {
146                assert_eq!(nodes[n / 2], 0.0, "odd-n central node is exact zero");
147            }
148            let total: f64 = weights.iter().sum();
149            assert!((total - 2.0).abs() < 1e-13, "∫1 dx = 2, got {total}");
150            if n >= 2 {
151                let x2: f64 = nodes.iter().zip(&weights).map(|(x, w)| w * x * x).sum();
152                assert!((x2 - 2.0 / 3.0).abs() < 1e-13, "∫x² dx = 2/3, got {x2}");
153            }
154        }
155    }
156
157    #[test]
158    fn binom_k_exceeds_n_returns_zero() {
159        assert_eq!(binomial_coefficient_f64(3, 5), 0.0);
160        assert_eq!(binomial_coefficient_f64(0, 1), 0.0);
161        assert_eq!(binomial_coefficient_f64(10, 11), 0.0);
162    }
163
164    #[test]
165    fn binom_k_zero_returns_one() {
166        assert_eq!(binomial_coefficient_f64(0, 0), 1.0);
167        assert_eq!(binomial_coefficient_f64(5, 0), 1.0);
168        assert_eq!(binomial_coefficient_f64(100, 0), 1.0);
169    }
170
171    #[test]
172    fn binom_k_equals_n_returns_one() {
173        assert_eq!(binomial_coefficient_f64(1, 1), 1.0);
174        assert_eq!(binomial_coefficient_f64(5, 5), 1.0);
175        assert_eq!(binomial_coefficient_f64(20, 20), 1.0);
176    }
177
178    #[test]
179    fn binom_small_exact_values() {
180        assert_eq!(binomial_coefficient_f64(5, 2), 10.0);
181        assert_eq!(binomial_coefficient_f64(10, 3), 120.0);
182        assert_eq!(binomial_coefficient_f64(20, 10), 184_756.0);
183        assert_eq!(binomial_coefficient_f64(6, 3), 20.0);
184    }
185
186    #[test]
187    fn binom_symmetry() {
188        assert_eq!(
189            binomial_coefficient_f64(10, 3),
190            binomial_coefficient_f64(10, 7)
191        );
192        assert_eq!(
193            binomial_coefficient_f64(20, 5),
194            binomial_coefficient_f64(20, 15)
195        );
196        assert_eq!(
197            binomial_coefficient_f64(54, 24),
198            binomial_coefficient_f64(54, 30)
199        );
200    }
201
202    #[test]
203    fn binom_c54_24_is_exact() {
204        // The u128-recurrence fix restored this value (old f64 recurrence
205        // returned 1_402_659_561_581_459, one short of the true integer).
206        assert_eq!(binomial_coefficient_f64(54, 24), 1_402_659_561_581_460.0);
207    }
208
209    #[test]
210    fn poly_exp_empty_coeffs_returns_zero() {
211        assert_eq!(stable_polynomial_times_exp_neg(1.0, &[]), 0.0);
212        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[]), 0.0);
213        assert_eq!(stable_polynomial_times_exp_neg(700.0, &[]), 0.0);
214    }
215
216    #[test]
217    fn poly_exp_nonfinite_x_returns_zero() {
218        assert_eq!(
219            stable_polynomial_times_exp_neg(f64::INFINITY, &[1.0, 2.0]),
220            0.0
221        );
222        assert_eq!(
223            stable_polynomial_times_exp_neg(f64::NEG_INFINITY, &[1.0, 2.0]),
224            0.0
225        );
226        assert_eq!(stable_polynomial_times_exp_neg(f64::NAN, &[1.0]), 0.0);
227    }
228
229    #[test]
230    fn poly_exp_constant_at_zero() {
231        // At x=0: poly(0) = coeffs[0], exp(0)=1 → result = coeffs[0].
232        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[5.0]), 5.0);
233        assert_eq!(stable_polynomial_times_exp_neg(0.0, &[3.0, 1.0, 2.0]), 3.0);
234    }
235
236    #[test]
237    fn poly_exp_constant_poly_direct_path() {
238        // x=2.0 < 600: direct Horner * exp(-x).
239        let x = 2.0;
240        let got = stable_polynomial_times_exp_neg(x, &[3.0]);
241        let expected = 3.0 * (-x).exp();
242        assert!(
243            (got - expected).abs() < 1e-14,
244            "got={got} expected={expected}"
245        );
246    }
247
248    #[test]
249    fn poly_exp_linear_poly_direct_path() {
250        // coeffs = [a, b] → poly = a + b*x.
251        let x = 1.5;
252        let (a, b) = (2.0, 3.0);
253        let got = stable_polynomial_times_exp_neg(x, &[a, b]);
254        let expected = (a + b * x) * (-x).exp();
255        assert!(
256            (got - expected).abs() < 1e-14,
257            "got={got} expected={expected}"
258        );
259    }
260
261    #[test]
262    fn poly_exp_constant_poly_asymptotic_path() {
263        // x=700 > 600: asymptotic path. For poly = [1.0], result = exp(-700).
264        let x = 700.0_f64;
265        let got = stable_polynomial_times_exp_neg(x, &[1.0]);
266        let expected = (-x).exp();
267        let rel = (got - expected).abs() / expected;
268        assert!(rel < 1e-12, "got={got} expected={expected} rel={rel}");
269    }
270
271    #[test]
272    fn poly_exp_quadratic_asymptotic_path() {
273        // x=620 > 600: poly = x^2 (coeffs=[0,0,1]). Result = x^2 * exp(-x).
274        // x=800 would underflow to 0.0 in both the asymptotic path and the
275        // reference, making the relative-error check degenerate; x=620 keeps
276        // the result in the normal f64 range (~10^-264) while still exercising
277        // the asymptotic branch (threshold is x=600).
278        let x = 620.0_f64;
279        let got = stable_polynomial_times_exp_neg(x, &[0.0, 0.0, 1.0]);
280        let expected = (2.0 * x.ln() - x).exp();
281        let rel = (got - expected).abs() / expected.abs();
282        assert!(rel < 1e-12, "got={got} expected={expected} rel={rel}");
283    }
284}