Skip to main content

thermite_special/specialized/generic/
erfcx.rs

1//! `erfcx(x) = e^{x^2} erfc(x)`, the scaled complementary error function.
2//!
3//! # Motivation
4//!
5//! `erfc` underflows to zero at `x ~ 27` in binary64 and `x ~ 9` in binary32, where the
6//! true value is `e^{-x^2}/(x sqrt(pi))`, nonzero and merely unrepresentable. Every
7//! Gaussian tail, importance weight and log-likelihood past that point silently becomes
8//! zero. `erfcx` removes the exponential and is `O(1/x)`, so it stays representable for
9//! every finite argument and carries full relative accuracy the whole way.
10//!
11//! # Algorithm
12//!
13//! The Faddeeva function restricted to the imaginary axis: `w(ix) = erfcx(x)` exactly.
14//! Weideman's approximation (see [`crate::tables::weideman`]) is
15//!
16//! ```text
17//! Z = (L + iz)/(L - iz),   w(z) = 1/(sqrt(pi)(L - iz)) + 2 P(Z)/(L - iz)^2
18//! ```
19//!
20//! with `P` real. Substituting `z = ix` for real `x` makes `L - iz = L + x` and
21//! `Z = (L - x)/(L + x)`, **both real**: every complex operation in the method
22//! disappears and what is left is one reciprocal and one real Horner. There are no
23//! transcendentals at all on the non-negative side, which makes this cheaper than the
24//! `erfc` it complements.
25//!
26//! The domain is well conditioned throughout. `L + x >= L > 0` for every finite `x >= 0`,
27//! so the reciprocal needs no guard, and `Z` runs monotonically over `(-1, 1]`, so the
28//! Horner stays inside the unit disc the coefficients were fitted on. Only the
29//! infinities fall outside that, `Z` there being `(L - inf) * 0`, and they are named
30//! explicitly under `check_overflow`.
31//!
32//! Measured against mpmath at 50 digits with the `N = 40` table, the worst relative
33//! error over `x` from 0 to `1e15` is 1.22 ulp.
34//!
35//! # Negative arguments
36//!
37//! `erfcx(-x) = 2 e^{x^2} - erfcx(x)`, which genuinely overflows for `x` below about
38//! -26.6 (binary64). `erfcx` grows like `e^{x^2}` to the left, so the infinity is the
39//! correct answer rather than a failure. This is the only branch, and it is the only
40//! place an `exp` appears.
41
42use thermite::{
43    element::FloatElement,
44    math::{
45        CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _, policy::Policy,
46        specialized::SpecializedTranscendentalMath,
47    },
48    prelude::*,
49};
50
51use crate::tables::weideman::{Weideman, WeidemanTables, weideman_n};
52
53/// `erfcx` by the `N`-term Weideman approximation on the imaginary axis.
54///
55/// `N` is a literal at every call site (the ladder in [`erfcx_internal`] instantiates it
56/// as one of 8/16/24/32/40), which is what lets the trip count and the coefficient loads
57/// fold.
58#[inline(always)]
59pub fn erfcx_with<V, E, P, const N: usize>(x: V, l: E, a: &[E; N]) -> V
60where
61    E: FloatElement,
62    V: FloatVector<Element = E> + SpecializedTranscendentalMath<E>,
63    P: Policy,
64{
65    let l = V::splat(l);
66    let ax = x.abs();
67
68    // L + |x| >= L > 0: the one reciprocal, and it needs no guard.
69    let r = (l + ax).approx_reciprocal_p::<P>();
70    let z = (l - ax) * r;
71
72    let p = z.poly_rev_n_p::<P, _>(a);
73
74    // w = r/sqrt(pi) + 2 P r^2, grouped so the second `r` multiplies once.
75    let mut y = r * (p + p).mul_adde(r, V::FRAC_1_SQRT_PI);
76
77    // erfcx(-|x|) = 2 e^{x^2} - erfcx(|x|). Overflows to +inf below x ~ -26.6 in
78    // binary64, which is the true behaviour of the function and not a guard failure.
79    let neg = x.cmp_lt(V::ZERO);
80    if const { P::POLICY.avoid_branching } || thermite::unlikely(neg.any()) {
81        let refl = (ax * ax).exp_p::<P>();
82        y = neg.select(refl + refl - y, y);
83    }
84
85    if const { P::POLICY.check_overflow } {
86        // Both infinities need saying. At +inf the reciprocal is 0 but `Z` is
87        // `(L - inf) * 0`, i.e. NaN, which the Horner then spreads, and the limit is 0. At
88        // -inf the reflection is `inf - NaN` for the same reason, and the limit is +inf.
89        y = x.cmp_eq(V::INFINITY).select(V::ZERO, y);
90        y = x.cmp_eq(V::NEG_INFINITY).select(V::INFINITY, y);
91    }
92
93    y
94}
95
96/// [`erfcx_with`], with `N` and the table chosen by the precision policy.
97#[inline(always)]
98pub fn erfcx_internal<V, E, P>(x: V) -> V
99where
100    E: FloatElement + WeidemanTables,
101    V: FloatVector<Element = E> + SpecializedTranscendentalMath<E>,
102    P: Policy,
103{
104    macro_rules! tier {
105        ($n:literal) => {
106            erfcx_with::<V, E, P, $n>(x, <E as Weideman<$n>>::L, &<E as Weideman<$n>>::A)
107        };
108    }
109
110    // Spelled out at each arm rather than bound to a `let`: a `const` block cannot
111    // capture a local, even one whose initializer is itself constant.
112    macro_rules! is {
113        ($n:literal) => {
114            const { weideman_n(P::POLICY.precision, <E as WeidemanTables>::MAX_N) <= $n }
115        };
116    }
117
118    if is!(8) {
119        tier!(8)
120    } else if is!(16) {
121        tier!(16)
122    } else if is!(24) {
123        tier!(24)
124    } else if is!(32) {
125        tier!(32)
126    } else {
127        tier!(40)
128    }
129}
Last built: 2026-09-08 21:35:55 UTC