thermite_special/specialized/generic/polygamma.rs
1use thermite::{
2 LargeInt,
3 element::FloatElementWithBits,
4 math::{CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _, policy::Policy},
5 prelude::*,
6};
7
8use crate::specialized::SpecializedSpecialMath;
9use crate::tables::bernoulli::BernoulliNumbers;
10use crate::tables::cot_pi::CotPiDerivatives;
11use crate::tables::factorial::Factorials;
12
13/// Shared polygamma (`psi_n`) implementation for all real element types.
14///
15/// `$\psi_n(x) = \frac{\mathrm{d}^n}{\mathrm{d}x^n}\psi(x)$`, the (n+1)-th derivative of
16/// `$\ln\Gamma$`. `n = 0` and `n = 1` delegate to the tuned [`digamma`] and [`trigamma`]
17/// kernels. `n >= 2` runs the two-region scheme Boost.Math's `polygamma_imp` uses on the
18/// positive axis, restructured for
19/// vectors:
20///
21/// * a masked forward recurrence `$\psi_n(x) = \psi_n(x+1) + (-1)^{n-1} n!\,x^{-(n+1)}$`
22/// walks every lane up to the transition point `$N = 0.4\,d_{10} + 4n$` (with `$d_{10}$`
23/// the format's decimal digits), then
24/// * the asymptotic expansion at large `x`,
25///
26/// ```math
27/// \psi_n(x) = (-1)^{n-1}\left[\frac{(n-1)!}{x^n} + \frac{n!}{2x^{n+1}}
28/// + \sum_{k\ge1} B_{2k}\,\frac{(2k+n-1)!}{(2k)!\,x^{2k+n}}\right]
29/// ```
30///
31/// evaluated by the term-ratio recurrence, so the scalar order-dependent coefficients
32/// `$(n+2k-2)(n+2k-1)/((2k-1)\,2k)$` are splatted and the vector work per term is one
33/// multiply by `$1/x^2$`. The `$B_{2k}$` come from [`BernoulliNumbers::B2N`], and the
34/// series converges well before that table ends for every `x` past the transition point.
35///
36/// Unlike Boost there is no separate near-zero zeta series: the leading `$n!/x^{n+1}$`
37/// term dominates so completely below the recurrence range that the walk loses nothing.
38///
39/// Negative arguments reflect through
40/// `$\psi_n(x) = (-1)^n\left[\psi_n(1-x) + \pi\,\frac{\mathrm{d}^n}{\mathrm{d}z^n}\cot(\pi z)\big|_{z=1-x}\right]$`,
41/// with the cot derivative's cosine polynomial from [`CotPiDerivatives::COT_PI_ROWS`]
42/// and both `sin_pi`/`cos_pi` evaluated at `x` itself, the smaller-magnitude
43/// representative (they agree with the `1 - x` values exactly, by periodicity, but
44/// carry less argument error). At the poles (zero and the negative integers) odd `n`
45/// yields `+inf`, the correct two-sided limit. Even `n` has one-sided limits of
46/// opposite sign and yields NaN when overflow checking is enabled.
47///
48/// # Current limits (deliberate, documented rather than patched)
49///
50/// * **Reflection stops at `n = 20`**, the cot-pi table's reach (Boost tabulates the
51/// same range, with its runtime coefficient recurrence past it queued work). Negative
52/// arguments at `n > 20` return NaN. The positive axis is unaffected.
53/// * **Direct powers bound the domain.** `x^(n+1)` is formed directly at arguments up
54/// to `max(x, N)`, so lanes where `(n+1) log10(max(x, N))` exceeds the format's
55/// decimal exponent range (~300 for f64, ~36 for f32) flush to zero even where
56/// `psi_n` itself is representable (e.g. `psi_100(1e4) ~ -9.4e-245`), and `n!`
57/// likewise overflows at `n >= 171` (f64) / `n >= 35` (f32). Boost rescues both
58/// with log-domain arithmetic. That is queued work, and the tests pin the boundary.
59///
60/// [`digamma`]: SpecializedSpecialMath::digamma
61/// [`trigamma`]: SpecializedSpecialMath::trigamma
62#[inline(always)]
63pub fn polygamma_impl<P, E, V>(x_in: V, n: u32) -> V
64where
65 P: Policy,
66 E: FloatElementWithBits + BernoulliNumbers + CotPiDerivatives + Factorials,
67 V: FloatVectorWithBits<Element = E> + SpecializedSpecialMath<E>,
68{
69 if n == 0 {
70 return SpecializedSpecialMath::digamma::<P>(x_in);
71 }
72 if n == 1 {
73 return SpecializedSpecialMath::trigamma::<P>(x_in);
74 }
75
76 let x0 = x_in.flush_denormals_p::<P>();
77
78 let reflect = x0.cmp_le(V::ZERO);
79 let sign_neg = (n - 1) & 1 == 1;
80
81 // Every order-dependent coefficient is exact integer scalar math lifted into E
82 // without casts (`from_int`/`from_ratio` are exact-or-panic, and stay exact here:
83 // the largest integer formed is bounded by the factorial table's reach, well under
84 // 2^24), or a correctly rounded factorial from the generated table. No f64
85 // arithmetic appears, so the kernel is indifferent to E's width.
86 let (fac_nm1, fac_n) = match (E::FACTORIALS.get(n as usize - 1), E::FACTORIALS.get(n as usize)) {
87 (Some(&a), Some(&b)) => (V::splat(a), V::splat(b)),
88 (Some(&a), None) => (V::splat(a), <V as FloatVector>::INFINITY),
89 _ => {
90 // n! overflows E outright, the documented large-n limit (see above). The
91 // reflected side's sign depends on the cot term, so it gets NaN, not inf.
92 let inf = if sign_neg {
93 <V as FloatVector>::NEG_INFINITY
94 } else {
95 <V as FloatVector>::INFINITY
96 };
97 return (reflect | x0.is_nan()).select(V::NAN, inf);
98 }
99 };
100
101 // Transition point N = 0.4 * digits10 + 4n, Boost's choice: far enough out that the
102 // Bernoulli series below converges geometrically from its first term. 12/100
103 // approximates 0.4 log10(2) closely enough that every format lands on Boost's
104 // integer value.
105 let d4d = (12 * (E::MANTISSA_BITS + 1)) / 100;
106 let threshold = V::splat(E::from_int((d4d + 4 * n) as LargeInt));
107
108 // Reflected lanes work at z = 1 - x >= 1. The cot term is added at the end.
109 let mut x = reflect.select(V::ONE - x0, x0);
110
111 // --- Forward recurrence: psi_n(x) = psi_n(x + 1) + (-1)^(n-1) n! x^-(n+1) ---
112 // The positive powi then one reciprocal (rather than powi of the reciprocal) keeps
113 // the rcp's error out of the squaring chain, and overflow of x^(n+1) only happens
114 // where the true term underflows anyway (the two failure regions coincide).
115 let mut rec = V::ZERO;
116 let mut active = x.cmp_lt(threshold);
117 while active.any() {
118 V::_loop_hint();
119
120 let t = x.powi_p::<P>(n as i32 + 1).approx_reciprocal_p::<P>();
121 rec = rec.add_c(active, t);
122 x = x.add_c(active, V::ONE);
123 active = x.cmp_lt(threshold);
124 }
125
126 // --- Asymptotic tail at x >= N ---
127 let zr = x.approx_reciprocal_p::<P>();
128 let z2r = zr * zr;
129
130 // lead = (n-1)! / x^n. The first two terms fold into lead * (1 + n/(2x)).
131 let lead = fac_nm1 * x.powi_p::<P>(n as i32).approx_reciprocal_p::<P>();
132 let mut asum = (V::splat(E::from_ratio(n as LargeInt, 2)) * zr).mul_adde(lead, lead);
133
134 // part = (n+1)! / (2 x^(n+2)), the k = 1 series term without its Bernoulli number.
135 // n (n + 1) / 2 is a triangular number: exact.
136 let mut part = lead * z2r * V::splat(E::from_int(n as LargeInt * (n as LargeInt + 1) / 2));
137
138 let eps = <V as FloatVector>::EPSILON;
139 let mut k = 1usize;
140 loop {
141 V::_loop_hint();
142
143 let term = part * V::splat(E::B2N[k - 1]);
144 asum += term;
145
146 // The table ends exactly where B_2k overflows E, but convergence always wins first
147 // for x past the transition point, so this bound is a backstop.
148 if k >= E::B2N.len() || term.abs().cmp_le(asum.abs() * eps).all() {
149 break;
150 }
151
152 // The ratio uses the _incremented_ k: part_{k+1}/part_k = (n+2k)(n+2k+1)/((2k+1)(2k+2)).
153 // Both integer products are exact in E (bounded by the table reach, < 2^24), so
154 // the ratio costs one scalar division's rounding, then splats.
155 k += 1;
156 let nk = n as LargeInt + 2 * k as LargeInt;
157 let k2 = 2 * k as LargeInt;
158 let ratio = E::from_int((nk - 2) * (nk - 1)) / E::from_int((k2 - 1) * k2);
159 part = part * z2r * V::splat(ratio);
160 }
161
162 // Both regions carry the same (-1)^(n-1) prefactor, so it is applied once.
163 let mut res = rec.mul_adde(fac_n, asum);
164 if sign_neg {
165 res = -res;
166 }
167
168 // --- Reflection: psi_n(x) = (-1)^n [psi_n(1 - x) + pi * cot^(n)(pi (1 - x))] ---
169 if const { P::POLICY.avoid_branching } || reflect.any() {
170 if (n as usize) <= E::COT_PI_ROWS.len() {
171 let row = E::COT_PI_ROWS[n as usize - 1];
172
173 // By periodicity sin_pi/cos_pi of 1 - x are +-sin_pi/cos_pi of x, and x is
174 // always the smaller-magnitude representative on this path, so the argument
175 // carries less error. cos powers keep every polynomial term zero at
176 // half-integers, right where the derivative bottoms out, so no cancellation.
177 let s = x0.sin_pi_p::<P>();
178 let c = -x0.cos_pi_p::<P>();
179 let c2 = c * c;
180
181 let mut poly = V::splat(row[row.len() - 1]);
182 let mut j = row.len() - 1;
183 while j > 0 {
184 j -= 1;
185 poly = poly.mul_adde(c2, V::splat(row[j]));
186 }
187 if n & 1 == 0 {
188 poly *= c;
189 }
190
191 // pi * pi^n P / s^(n+1) = (pi/s)^(n+1) P. At the poles s is +-0 and the
192 // even power makes this +inf regardless of the zero's sign.
193 let cot_term = (V::PI / s).powi_p::<P>(n as i32 + 1) * poly;
194
195 let total = res + cot_term;
196 res = reflect.select(if n & 1 == 1 { -total } else { total }, res);
197 } else {
198 // Past the cot-pi table, a documented limit (see above).
199 res = reflect.select(V::NAN, res);
200 }
201
202 // Zero and the negative integers: odd n has a definite two-sided limit of
203 // +inf (pinned here even though the arithmetic above already produces it).
204 // Even n diverges with opposite signs, so checked policies get NaN.
205 let pole = reflect & x0.floor().cmp_eq(x0);
206 if n & 1 == 1 {
207 res = pole.select(<V as FloatVector>::INFINITY, res);
208 } else if const { P::POLICY.check_overflow } {
209 res = pole.select(V::NAN, res);
210 }
211 }
212
213 res
214}