Skip to main content

thermite_special/specialized/generic/
lgamma1p.rs

1//! `$\ln\Gamma(1+v)$` and `$\Gamma(1+v)-1$` on `$\lvert v\rvert \le 1/2$`, both signs at once.
2//!
3//! # Why these exist as their own functions
4//!
5//! `$\Gamma(1+v) - 1$` cannot be computed as `tgamma(1 + v) - 1`. Near zero
6//! `$\Gamma(1+v) \approx 1 - \gamma v$`, so subtracting one throws away every bit that is not
7//! in `$\gamma v$`. About 7 bits lost at `$v = 10^{-2}$`, 27 at `$10^{-8}$`, all of them by
8//! `$10^{-16}$`.
9//!
10//! That matters because Temme's series, the small-`x` arm for `$Y_\nu$`, forms
11//!
12//! ```math
13//! g_1 = \frac{g_+ - g_-}{(1+g_+)(1+g_-)\,2v}, \qquad g_\pm = \Gamma(1\pm v) - 1
14//! ```
15//!
16//! which is `$0/0$` as `$v \to 0$` and needs both `$g_\pm$` to full relative accuracy to
17//! resolve it. Boost carries a dedicated `tgamma1pm1` for exactly this reason.
18//!
19//! # The route taken, and what it avoids
20//!
21//! `$\Gamma(1+v) - 1 = \mathrm{expm1}(\ln\Gamma(1+v))$`, with `$\ln\Gamma(1+v)$` from its
22//! `$\zeta$` series. The series has **no subtraction of near-equal quantities anywhere**, so it
23//! is uniformly accurate across the range including at `$v = 0$`, where it simply returns zero.
24//!
25//! The alternative was a fitted rational, which would have needed the parked minimax tooling.
26//! Every coefficient here is `$\zeta(k)/k$` computed to 60 digits and rounded once: exact
27//! constants, not an approximation. See [`crate::tables::lgamma1p`].
28//!
29//! Both signs come out of one evaluation, because the series splits by parity and Temme wants
30//! `$\pm v$` anyway.
31
32use thermite::{
33    math::{TranscendentalMathWithPolicy, policy::Policy},
34    prelude::*,
35};
36
37use thermite::element::FloatElement;
38
39use crate::tables::lgamma1p::LogGamma1p;
40
41/// `$(\ln\Gamma(1+v),\; \ln\Gamma(1-v))$` for `$\lvert v\rvert \le 1/2$`.
42///
43/// Outside that range the series has not been given enough terms and the result is wrong. The
44/// caller reduces the order first. Exact at `$v = 0$`, where both halves are zero.
45#[inline(always)]
46pub fn lgamma1p_pair<P, E, V, const NE: usize, const NO: usize>(v: V, t: &LogGamma1p<E, NE, NO>) -> (V, V)
47where
48    E: FloatElement,
49    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
50    P: Policy,
51{
52    let w = v * v;
53
54    // Even `k` contributes `w * P_even(w)`, odd `k` contributes `v * P_odd(w)`. The sign of
55    // `v` only reaches the odd half, which is the whole point of splitting them.
56    let even = w * w.poly_n_p::<P, NE>(&t.even);
57    let odd = v * w.poly_n_p::<P, NO>(&t.odd);
58
59    (even + odd, even - odd)
60}
61
62/// `$(\Gamma(1+v) - 1,\; \Gamma(1-v) - 1)$` for `$\lvert v\rvert \le 1/2$`.
63///
64/// Boost calls the single-sign form `tgamma1pm1`. See the [module docs](self) for why
65/// `tgamma(1 + v) - 1` is not an acceptable substitute.
66#[inline(always)]
67pub fn tgamma1pm1_pair<P, E, V, const NE: usize, const NO: usize>(v: V, t: &LogGamma1p<E, NE, NO>) -> (V, V)
68where
69    E: FloatElement,
70    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
71    P: Policy,
72{
73    let (lp, lm) = lgamma1p_pair::<P, E, V, NE, NO>(v, t);
74
75    // `expm1` rather than `exp - 1`, for the same reason the series exists: both arguments go
76    // to zero with `v`, and so must both results.
77    (lp.exp_m1_p::<P>(), lm.exp_m1_p::<P>())
78}
79
80#[cfg(all(test, any(target_arch = "x86", target_arch = "x86_64")))]
81mod tests {
82    use super::*;
83
84    use thermite::Vector;
85    use thermite::math::policy::policies::Precision;
86
87    use crate::tables::lgamma1p::LGAMMA1P_F64;
88
89    type V = Vector<f64>;
90
91    fn lg(v: f64) -> (f64, f64) {
92        let (a, b) = lgamma1p_pair::<Precision, f64, V, 25, 25>(V::splat(v), &LGAMMA1P_F64);
93        (a.extract::<0>(), b.extract::<0>())
94    }
95
96    fn tg(v: f64) -> (f64, f64) {
97        let (a, b) = tgamma1pm1_pair::<Precision, f64, V, 25, 25>(V::splat(v), &LGAMMA1P_F64);
98        (a.extract::<0>(), b.extract::<0>())
99    }
100
101    fn rel(got: f64, want: f64) -> f64 {
102        if want == 0.0 {
103            return if got == 0.0 { 0.0 } else { f64::INFINITY };
104        }
105        ((got - want) / want).abs()
106    }
107
108    /// Against mpmath at 60 digits, across the whole `|v| <= 1/2` range and both signs.
109    #[test]
110    fn lgamma1p_matches_a_high_precision_reference() {
111        // (v, lnGamma(1+v), lnGamma(1-v))
112        const ROWS: &[(f64, f64, f64)] = &[
113            (0.5, -0.12078223763524522, 0.5723649429247001),
114            (0.25, -0.09827183642181316, 0.20328095143129538),
115            (0.1, -0.04987244125983972, 0.06637623973474296),
116            (0.01, -0.005690307946069646, 0.005854806764709776),
117            (0.001, -0.0005763935982833696, 0.0005780385328913797),
118            (1e-08, -5.772156566768626e-09, 5.772156731262032e-09),
119            (0.0, 0.0, 0.0),
120        ];
121
122        for &(v, want_p, want_m) in ROWS {
123            let (gp, gm) = lg(v);
124            assert!(rel(gp, want_p) <= 4e-16, "lnGamma(1+{v}): got {gp}, want {want_p}");
125            assert!(rel(gm, want_m) <= 4e-16, "lnGamma(1-{v}): got {gm}, want {want_m}");
126        }
127    }
128
129    /// The property the whole module exists for: `Gamma(1+v) - 1` keeps full **relative**
130    /// accuracy as `v -> 0`, where `tgamma(1 + v) - 1` has none left.
131    ///
132    /// At `v = 1e-8` the true value is about `-5.77e-9`, and forming it by subtraction leaves
133    /// roughly 27 bits. The naive route is checked here to be as bad as claimed rather than
134    /// merely asserted to be.
135    #[test]
136    fn tgamma1pm1_survives_where_subtraction_does_not() {
137        for &(v, want) in &[
138            (1e-2f64, -0.005674148808493963),
139            (1e-4, -5.771167683758092e-05),
140            (1e-6, -5.77214675846445e-07),
141            (1e-8, -5.77215655010973e-09),
142            (1e-12, -5.772156649005438e-13),
143        ] {
144            let (gp, _) = tg(v);
145            assert!(
146                rel(gp, want) <= 8e-16,
147                "Gamma(1+{v})-1: got {gp}, want {want}, rel {:e}",
148                rel(gp, want)
149            );
150        }
151
152        // Exactly zero at zero, both signs. A series has no reason to miss this, but the
153        // `expm1` in front of it would if it were `exp - 1`.
154        assert_eq!(tg(0.0), (0.0, 0.0));
155    }
156
157    /// The odd half carries the sign and the even half does not, so `v` and `-v` must simply
158    /// swap the pair. Bit-exact: it is one polynomial pair and a sign, not two evaluations.
159    #[test]
160    fn the_two_signs_are_one_evaluation() {
161        for &v in &[0.5f64, 0.3, 0.05, 1e-6] {
162            let (a, b) = lg(v);
163            let (c, d) = lg(-v);
164            assert_eq!(a.to_bits(), d.to_bits(), "lnGamma(1+v) must equal lnGamma(1-(-v))");
165            assert_eq!(b.to_bits(), c.to_bits(), "lnGamma(1-v) must equal lnGamma(1+(-v))");
166        }
167    }
168}
Last built: 2026-09-08 21:35:55 UTC