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}