thermite_special/specialized/generic/poisson.rs
1//! Loader's saddle-point pieces for densities of the shape `$x^k e^{-x}/\Gamma(k+1)$`.
2//!
3//! The Poisson mass `$e^{-\lambda}\lambda^k/k!$`, the Gamma density, and the seed of the
4//! orthonormal Laguerre functions are all this shape, and the obvious spelling
5//! `exp(k ln lambda - lgamma(k+1) - lambda)` computes an `O(1)` answer as the exponential
6//! of a difference of large terms: half an ulp of `lgamma(k+1) = O(k ln k)` becomes that
7//! many ulp of the result. Loader (2000, "Fast and accurate computation of binomial
8//! probabilities", the form R's `dpois` uses) rewrites it as
9//!
10//! ```math
11//! \frac{\lambda^k e^{-\lambda}}{k!} = \frac{e^{-\mathrm{stirlerr}(k) - \mathrm{bd0}(k, \lambda)}}{\sqrt{2\pi k}}
12//! ```
13//!
14//! with the two pieces below, both *small* where the density is not negligible, so the
15//! exponential amplifies nothing.
16
17use thermite::{
18 element::FloatElement,
19 math::{
20 CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _,
21 policy::{Policy, PrecisionPolicy},
22 },
23 prelude::*,
24};
25
26use crate::specialized::SpecializedSpecialMath;
27
28
29/// Below this the Stirling series in [`stirlerr`] is not accurate to binary64 at any
30/// depth (it is asymptotic, and the smallest term at `n = 9` is under `1e-18`, at `n = 6`
31/// it is `1e-14`). Callers handle `n < STIRLERR_MIN` some other way: a table for integers,
32/// or `Gamma(n+1)` directly, which is cheap and well conditioned at small argument.
33pub const STIRLERR_MIN: thermite::LargeInt = 9;
34
35/// Terms of the `1/n^2` series in [`stirlerr`] by tier, at `n >= STIRLERR_MIN`. The
36/// dropped term bounds the *absolute* error, which is the relative error of whatever
37/// density it feeds: 9 terms is `1e-18`, 6 is `2.5e-15` (a measured 5 ulp at `n = 9`, so
38/// `Average` keeps all 9, the three FMAs being nothing), 5 is `1e-13`, and the single
39/// `1/(12n)` term is `3.8e-6`. `Worst` forgoes the series and keeps just that.
40#[inline(always)]
41pub const fn stirlerr_terms(precision: PrecisionPolicy) -> usize {
42 match precision {
43 PrecisionPolicy::Worst => 1,
44 PrecisionPolicy::Medium => 5,
45 _ => 9,
46 }
47}
48
49/// Terms of the odd series in [`bd0`] by tier, inside `|v| < 1/5`.
50///
51/// The dropped term is `2k v^{2T+1}/(2T+1)`, an *absolute* error in the exponent and so
52/// a relative error of `k` times `0.2^{2T+1}/(2T+1)` in the density: 12 terms is
53/// `k * 7e-19`, 8 is `k * 1e-13`, 5 is `k * 4e-9`. For comparison the
54/// direct form at the window edge is off by about `k * eps / 2` from its own
55/// cancellation, so 12 terms matches it in binary64 and 5 in binary32.
56#[inline(always)]
57pub const fn bd0_terms(precision: PrecisionPolicy) -> usize {
58 match precision {
59 PrecisionPolicy::Worst => 5,
60 PrecisionPolicy::Medium => 8,
61 _ => 12,
62 }
63}
64
65/// Stirling's error `$\mathrm{stirlerr}(n) = \ln n! - \left[(n + \tfrac12)\ln n - n + \tfrac12 \ln 2\pi\right]$`,
66/// for `n >= STIRLERR_MIN`, by the Bernoulli series
67///
68/// ```math
69/// \frac{1}{12n} - \frac{1}{360n^3} + \frac{1}{1260n^5} - \frac{1}{1680n^7} + \frac{1}{1188n^9} - \dots
70/// ```
71///
72/// with [`stirlerr_terms`] terms. Cost is a reciprocal and a short Horner ladder. Below
73/// `STIRLERR_MIN` the series does not converge to double precision (see there); this
74/// function does not check, it just returns the truncated series.
75#[inline(always)]
76pub fn stirlerr<P, E, V>(n: V) -> V
77where
78 P: Policy,
79 E: FloatElement,
80 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
81{
82 // B_{2k} / (2k (2k-1)), k = 1..=9.
83 let s: [E; 9] = [
84 <E as FloatElement>::ConstRatio::<1, 12>::VALUE,
85 <E as FloatElement>::ConstRatio::<1, 360>::VALUE,
86 <E as FloatElement>::ConstRatio::<1, 1260>::VALUE,
87 <E as FloatElement>::ConstRatio::<1, 1680>::VALUE,
88 <E as FloatElement>::ConstRatio::<1, 1188>::VALUE,
89 <E as FloatElement>::ConstRatio::<691, 360360>::VALUE,
90 <E as FloatElement>::ConstRatio::<1, 156>::VALUE,
91 <E as FloatElement>::ConstRatio::<3617, 122400>::VALUE,
92 <E as FloatElement>::ConstRatio::<43867, 244188>::VALUE,
93 ];
94 let terms = const { stirlerr_terms(P::POLICY.precision) };
95
96 let rn = n.approx_reciprocal_p::<P>();
97 let rnn = rn * rn;
98
99 // p = S_0 - rnn (S_1 - rnn (S_2 - ...)), then p / n.
100 let mut p = V::splat(s[terms - 1]);
101 let mut k = terms - 1;
102 while k > 0 {
103 k -= 1;
104 p = rnn.nmul_adde(p, V::splat(s[k]));
105 }
106
107 p * rn
108}
109
110/// The binomial/Poisson deviance `bd0(k, lambda) = k ln(k/lambda) + lambda - k >= 0` in its
111/// peak form: `(k - lambda) v + 2k sum_{j>=1} v^{2j+1}/(2j+1)` for `v = (k - lambda)/(k + lambda)`
112/// (from `ln((1+v)/(1-v)) = 2 atanh v`), [`bd0_terms`] terms, full precision inside
113/// `|v| < 1/5`. Away from the peak the direct form is fine and [`pmf_parts`] uses it
114/// (folded with the rest of the exponent); this is only the part that needs care.
115/// `diff` and `v` are passed in because callers have them.
116#[inline(always)]
117pub fn bd0_series<P, E, V>(k: V, diff: V, v: V) -> V
118where
119 P: Policy,
120 E: FloatElement,
121 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
122{
123 let terms = const { bd0_terms(P::POLICY.precision) };
124 let vv = v * v;
125
126 // sum_{j=1..T} vv^{j-1} / (2j+1), Horner.
127 let mut s = V::splat(E::from_ratio(1, 2 * terms as thermite::LargeInt + 1));
128 let mut j = terms;
129 while j > 1 {
130 j -= 1;
131 s = vv.mul_adde(s, V::splat(E::from_ratio(1, 2 * j as thermite::LargeInt + 1)));
132 }
133 diff.mul_adde(v, ((k + k) * (vv * v)) * s)
134}
135
136/// Shift a real `k >= 0` up into the Stirling region: `n = k + m` with integer
137/// `m = STIRLERR_MIN - floor(k)` (so `n` is in `[9, 10)`), and `prod = (k+1)(k+2)...(k+m)`,
138/// so that `Gamma(k+1) = Gamma(n+1) / prod`. Lanes already at or past `STIRLERR_MIN` get
139/// `m = 0`, `n = k`, `prod = 1`.
140///
141/// This is how the small-`k` case shares the large-`k` machinery instead of calling
142/// `lgamma`: a masked product of at most 10 factors, then the same `stirlerr(n)`. It is
143/// also more accurate than `lgamma` there, since the product's error stays relative and
144/// `n` is small enough that `n ln n` is only ~20.
145#[inline(always)]
146fn shift_to_stirling<P, E, V>(k: V) -> (V, V, V::Mask)
147where
148 P: Policy,
149 E: FloatElement,
150 V: FloatVector<Element = E>,
151{
152 let min = V::splat(<E as FloatElement>::ConstInt::<STIRLERR_MIN>::VALUE);
153 let large = k.cmp_ge(min);
154
155 // The usual case (one weight per call, already in range) pays only the compare.
156 if const { !P::POLICY.avoid_branching } && large.all() {
157 return (k, V::ONE, large);
158 }
159
160 // m = 9 - floor(k) for k < 9, else 0. k > -1 so m <= 10.
161 let m = large.select(V::ZERO, min - k.floor());
162 let n = k + m;
163
164 let mut prod = V::ONE;
165 let mut i = 1;
166 while i <= 10 {
167 let fi = V::splat(E::from_int(i));
168 let keep = m.cmp_ge(fi);
169 prod = keep.select(prod * (k + fi), prod);
170 i += 1;
171 }
172 (n, prod, large)
173}
174
175/// The shared core of every `$x^k e^{-x}/\Gamma(k+1)$` shape here: the Poisson mass, its
176/// log, and the Laguerre-function seed. Returns `(rest_hi, rest_lo, large, prod, n)` such
177/// that
178///
179/// ```text
180/// P(k; lambda) = exp(rest_hi + rest_lo - [large ? 0 : lambda]) * prod / sqrt(2 pi n)
181/// ```
182///
183/// `rest_lo` is the second word of the exponent, nonzero only on the shifted lanes (see
184/// the split below) and zero for `k >= 9` and the peak series. Callers must pass it to
185/// [`exp_two_sum`]; dropping it cost `poisson_pmf` 14 ulp.
186///
187/// where `n`, `prod` are from [`shift_to_stirling`] (`n = k`, `prod = 1` for `k >= 9`) and
188/// `rest` is one of three things, per lane, always with `-stirlerr(n)`:
189///
190/// - `k >= 9` near the peak (`|k - lambda| < 0.2 (k + lambda)`): `-bd0(k, lambda)` as its
191/// series, with nothing large in it.
192/// - `k >= 9` off the peak: `-bd0` directly as `-(k ln(k/lambda) - (k - lambda))`. The
193/// ratio goes into the `ln` whole and `k - lambda` is exact when they are close, so this
194/// is a few ulp too, *unlike* `k (ln k - ln lambda)` (40x worse when `k ~ lambda`) or
195/// splitting `-lambda` off (`k ln(k/lambda) + k` is then large on its own, and both were
196/// measured at 80-150 ulp for `k = 100`, `lambda = 150`).
197/// - `k < 9`: `k ln lambda - n ln n + n`, the shifted Stirling form, with `- lambda`
198/// **left out** so the caller adds it with a TwoSum, it being the one large term there.
199///
200/// The last two share one `ln`, of `k / lambda` or `n` by lane. `ln lambda` is only formed
201/// if some lane is small. Under `ALL_LARGE` (a caller who knows `k >= 9` everywhere) the
202/// shift and `ln lambda` fold away. Uniform vectors skip whichever branch no lane needs.
203/// `k = 0` gives `0 * ln 0 = NaN` at `lambda = 0`; callers pin that.
204#[inline(always)]
205pub fn pmf_parts<P, E, V, const ALL_LARGE: bool>(k: V, lambda: V) -> (V, V, V::Mask, V, V)
206where
207 P: Policy,
208 E: FloatElement,
209 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
210{
211 let (n, prod, large) = if const { ALL_LARGE } {
212 (k, V::ONE, V::Mask::TRUTHY)
213 } else {
214 shift_to_stirling::<P, E, V>(k)
215 };
216
217 let st = stirlerr::<P, E, V>(n);
218
219 // Peak lanes: |v| < 1/5, and only where the shift did nothing (bd0 is about k itself).
220 let diff = n - lambda;
221 let v = diff / (n + lambda);
222 let near = large & v.abs().cmp_lt(V::splat(<E as FloatElement>::ConstRatio::<1, 5>::VALUE));
223
224 if const { !P::POLICY.avoid_branching } && near.all() {
225 return (-(st + bd0_series::<P, E, V>(n, diff, v)), V::ZERO, large, prod, n);
226 }
227
228 let all_large = const { ALL_LARGE } || (const { !P::POLICY.avoid_branching } && large.all());
229
230 // Large: -(k ln(k/lambda) - diff). Small: k ln lambda - n ln n + n. One ln between them.
231 let kl = if all_large {
232 V::ZERO
233 } else {
234 // Dodge `0 * ln 0` on the NaN itself, not on `k = 0`. Selecting on `k.is_zero()`
235 // has the same value but discards `d/dk = ln lambda`, which a `Dual` seeded on `k`
236 // lost at `k = 0`. Only `0 * inf` (lambda zero or infinite) needs dodging.
237 let kl = k * lambda.ln_p::<P>();
238 large.select(V::ZERO, kl.is_nan().select(V::ZERO, kl))
239 };
240
241 // The shifted lanes never form `n ln n` as one rounded product: `exp` turns absolute
242 // error in the exponent into relative error in the density, and `n ln n ~ 19.8`, so
243 // one rounding is ~8 ulp (measured 14.4 ulp median on `poisson_pmf(0, lambda)`).
244 // Every shifted lane is in one binade, `n = 9 + frac(k)`, so split at its left edge:
245 //
246 // n ln n = 9 ln 9 + f ln 9 + n ln(1 + f/9), f = n - 9 (exact, Sterbenz)
247 //
248 // Only the leading term is large enough to need two words (`E::NINE_LN_9_HI/LO`).
249 // `LN_9` stays one word: it multiplies `f < 1`, and adding its low word measured no
250 // change at any `k`. `ln_1p` and not `ln(1 + t)`: forming `1 + t` rounds, and `n`
251 // times that is 4.5 ulp.
252 let l = large.select(n / lambda, n).ln_p::<P>();
253
254 let (plain, plain_lo) = if all_large {
255 // No shifted lane to compensate.
256 (n.nmul_adde(l, kl + diff) - st, V::ZERO)
257 } else {
258 let f = n - V::splat(<E as FloatElement>::ConstInt::<STIRLERR_MIN>::VALUE);
259 let t = f * V::splat(<E as FloatElement>::ConstRatio::<1, STIRLERR_MIN>::VALUE);
260 let l1 = t.ln_1p_p::<P>();
261
262 let small = f.mul_adde(V::LN_9, n * l1);
263
264 // TwoSum against the constant head: `hi` is the rounded `n ln n`, `lo` its
265 // residual. Knuth's form, since `small` can be zero at integer `k`. Through
266 // `exp_two_sum` rather than inline so `algebraic-scalar` cannot fold it.
267 let (hi, resid) = V::exp_two_sum(V::NINE_LN_9_HI, small);
268 let lo = resid + V::NINE_LN_9_LO;
269
270 // Shifted lanes subtract the two-word head. `n - hi` is exact there (both are
271 // multiples of `2^-49` and the difference is under 16), so keep this association.
272 let a = large.select(n.nmul_adde(l, kl + diff), (n - hi) + kl);
273
274 // Second residual, from the rounding of `a - st`. Dropping it costs 1.06 ulp
275 // against 0.33. Through the same hook with `-st` (exact negation); the old
276 // inline `((a - p) - st)` was foldable under `algebraic-scalar`.
277 let (p, resid_p) = V::exp_two_sum(a, -st);
278
279 (p, large.select(V::ZERO, resid_p - lo))
280 };
281
282 if const { !P::POLICY.avoid_branching } && near.none() {
283 return (plain, plain_lo, large, prod, n);
284 }
285
286 (
287 near.select(-(st + bd0_series::<P, E, V>(n, diff, v)), plain),
288 near.select(V::ZERO, plain_lo),
289 large,
290 prod,
291 n,
292 )
293}
294
295/// `exp(base + rest_hi + rest_lo)` where `base` is a large exact-ish number (`-lambda`,
296/// `x/4`) and the `rest` pair is small: TwoSum recovers the rounding of the sum, and
297/// `e^{s + lo} = e^s (1 + lo)` to first order. Without it the sum rounds to half an ulp of
298/// `base`, which the exponential turns into hundreds of ulp.
299///
300/// `rest_lo` is the caller's own second word, added to the residual this function already
301/// recovers. It exists because the residual alone is not enough: `base`'s rounding is only
302/// half the problem, and `rest` arrives from [`pmf_parts`] carrying an error of its own
303/// that no amount of care in *this* sum can recover. Measured 2026-09-07 on
304/// `poisson_pmf(0, lambda)`, which is exactly `e^-lambda`: 14.39 ulp with `rest_lo`
305/// dropped, 0.33 with it.
306/// The TwoSum itself is [`SpecializedSpecialMath::exp_two_sum`], not spelled here: an
307/// error-free transformation written as `+` and `-` is only error-free when those are
308/// strict, and on the scalar backend under `algebraic-scalar` they are not. The trait
309/// method's default therefore returns a zero residual and this degrades cleanly to
310/// `exp(base + rest_hi + rest_lo)`; `ps`/`pd` and `Dual` override it to recover the real
311/// thing. See that method's docs.
312#[inline(always)]
313pub fn exp_sum<P, E, V>(base: V, rest_hi: V, rest_lo: V) -> V
314where
315 P: Policy,
316 E: FloatElement,
317 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
318{
319 let (s, resid) = V::exp_two_sum(base, rest_hi);
320 let lo = resid + rest_lo;
321 let es = s.exp_p::<P>();
322 es.mul_adde(lo, es)
323}
324
325/// The Poisson mass `$e^{-\lambda}\lambda^k/k!$` at real `k >= 0`, `lambda >= 0` (`LOG =
326/// false`), or its log (`LOG = true`), through [`pmf_parts`]. Real `k` because the Gamma
327/// density is the same function (`dgamma(x; a) = pmf(a - 1; x)` for `a >= 1`).
328///
329/// Edges: `lambda = 0` gives `1` at `k = 0` and `0` above; `k = 0` is `e^{-lambda}` to a
330/// few ulp (it goes through the shifted Stirling form like any other small `k`).
331#[inline(always)]
332pub fn poisson_pmf<P, E, V, const LOG: bool>(k: V, lambda: V) -> V
333where
334 P: Policy,
335 E: FloatElement,
336 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
337{
338 let (rest, rest_lo, large, prod, n) = pmf_parts::<P, E, V, false>(k, lambda);
339 let tau_n = n * V::splat(E::TAU);
340 let neg_half = V::splat(<E as FloatElement>::ConstRatio::<{ -1 }, 2>::VALUE);
341
342 // Every lane at or past STIRLERR_MIN: rest already carries -lambda, prod is 1, and
343 // lambda = 0 falls out (ln(k/0) = inf makes rest -inf). Nothing to pin.
344 if const { !P::POLICY.avoid_branching } && large.all() {
345 return if const { LOG } {
346 tau_n.ln_p::<P>().mul_adde(neg_half, rest)
347 } else {
348 rest.exp_p::<P>().approx_div_sqrt_p::<P>(tau_n)
349 };
350 }
351
352 let base = large.select(V::ZERO, -lambda);
353
354 // lambda = 0 on a shifted lane: rest is -inf for k > 0 (0, right), but k = 0 has its
355 // 0 * ln 0 dodged and would come out 1 only to a few ulp, so pin both.
356 let lambda_zero = lambda.is_zero();
357 let k_zero = k.is_zero();
358
359 if const { LOG } {
360 // rest + base + ln prod - ln(2 pi n) / 2
361 let l = tau_n.ln_p::<P>().mul_adde(neg_half, ((base + rest) + rest_lo) + prod.ln_p::<P>());
362 lambda_zero.select(k_zero.select(V::ZERO, V::NEG_INFINITY), l)
363 } else {
364 let p = (exp_sum::<P, E, V>(base, rest, rest_lo) * prod).approx_div_sqrt_p::<P>(tau_n);
365 lambda_zero.select(k_zero.select(V::ONE, V::ZERO), p)
366 }
367}