Skip to main content

thermite_special/specialized/generic/bessel/
jy_real.rs

1//! Bessel functions at **arbitrary real order**, starting with the large-`x` Hankel arm.
2//!
3//! Separate from [`bessel_jy`](super::jy), which is the integer-order family and is
4//! built from fitted minimax rationals. Nothing here uses a coefficient table at any order:
5//! every term comes from a running ratio, which is what makes an arbitrary `$\nu$` possible.
6//!
7//! # The Hankel expansion, and why it needs no convergence test
8//!
9//! ```math
10//! J_\nu(x) \sim \sqrt{\frac{2}{\pi x}}\left(P(\nu,x)\cos\omega - Q(\nu,x)\sin\omega\right),
11//! \qquad \omega = x - \left(\tfrac{\nu}{2} + \tfrac{1}{4}\right)\pi
12//! ```
13//!
14//! with `$P$` the even and `$Q$` the odd part of `$\sum_k a_k(\nu)/x^k$`, and
15//!
16//! ```math
17//! \frac{a_k}{a_{k-1}} = \frac{\mu - (2k-1)^2}{8kx}, \qquad \mu = 4\nu^2
18//! ```
19//!
20//! This series **diverges**. The ratio is about `$k/2x$`, so terms shrink while `$k < 2x$` and
21//! grow forever after, and the least term (the floor on achievable accuracy) sits at
22//! `$k^{*} = x + \sqrt{x^2 + \nu^2}$` with magnitude around `$e^{-2x}$`.
23//!
24//! So there is nothing to converge to, and the stopping point is an index rather than a
25//! tolerance: the Counted discipline from
26//! [`iterate`](thermite::math::algorithms::iterate), not a convergence test.
27//!
28//! It does **not** use [`sum_counted`](thermite::math::algorithms::sum_counted), though, and
29//! the reason is worth recording. Producing `$Y_\nu$` as well as `$J_\nu$` needs `$P$` and
30//! `$Q$` kept apart, which is **two accumulators**, and every driver in `iterate` carries one.
31//! An earlier `$J$`-only version did fold the two into a single sum by rotating the trig
32//! factor through `$\cos, -\sin, -\cos, \sin$`, and that worked, but it cannot produce `$Y$`.
33//! Running the ratio chain twice to get both is worse than carrying one extra accumulator.
34//!
35//! That makes four places wanting a paired-accumulator driver: thermite-compensated's
36//! `sin_cos`, this, `cf2_pq` below, and `sum_counted`'s now-vacant slot.
37//!
38//! Boost instead _tries_ the series and returns a `bool` (`hankel_PQ`),
39//! bailing when consecutive terms stop halving, which happens at `$k \approx x$`, only
40//! halfway to the least term. Measured, that costs it about two units of `$x$` at small order
41//! and makes the arm unreachable entirely for `$\nu \ge 8$`, where its guard trips on the very
42//! first term. A try-and-fail arm is also the one shape a packet cannot do cheaply, since
43//! every lane would have to agree on whether the attempt worked.
44//!
45//! # Where it is usable, measured rather than assumed
46//!
47//! Smallest `$x$` reaching 1 eps under optimal truncation, from
48//! `notes/special/tools/model_hankel_divergence.py`:
49//!
50//! | `$\nu$` | 0 | 1/3 | 1/2 | 1 | 3 | 5 | 8 | 12 |
51//! |---|---|---|---|---|---|---|---|---|
52//! | binary64 | 17.0 | 16.5 | 1.0 | 17.0 | 17.0 | 17.0 | 18.0 | 25.0 |
53//! | binary32 | 6.5 | 6.5 | 1.0 | 6.5 | 7.5 | 7.5 | 11.0 | 24.5 |
54//!
55//! Flat in `$\nu$` up to about 5, then rising roughly `$1.75\nu$` (see
56//! [`hankel_usable_from`]). `$\nu = 1/2$` is exact at any `$x$` because `$\mu = (2\nu)^2$` with
57//! `$2\nu$` an odd integer makes `$\mu - (2k-1)^2$` vanish at `$k = \nu - 1/2$` and the series
58//! **terminates**. That is the same fact as "half-integer order is elementary", seen from the
59//! asymptotic side.
60//!
61//! # Term counts
62//!
63//! Terms needed to reach tolerance, worst case **at the gate** and falling from there, since
64//! the stop is at tolerance rather than at the floor:
65//!
66//! | `$x$` | 17 | 20 | 30 | 60 | 300 | 1000 |
67//! |---|---|---|---|---|---|---|
68//! | binary64 | **29** | 20 | 14 | 10 | 7 | 5 |
69//! | binary32 | **5** | 5 | 4 | 4 | 3 | 2 |
70//!
71//! `N` is a const generic so the loop unrolls, and the caller picks it from that table.
72//! Carrying the worst case everywhere costs terms at large `$x$` that are already below
73//! epsilon. Harmless numerically, and an open optimization rather than a correctness
74//! question.
75
76use thermite::{
77    math::{
78        TranscendentalMathWithPolicy,
79        algorithms::{sum_pair, sum_ratio},
80        policy::Policy,
81        specialized::SpecializedTranscendentalMath,
82    },
83    prelude::*,
84};
85
86use thermite::element::FloatElement;
87use thermite::{LargeInt, const_element, const_splat};
88
89use crate::specialized::SpecializedSpecialMath;
90use crate::specialized::generic::lgamma1p::tgamma1pm1_pair;
91use crate::tables::lgamma1p::LogGamma1p;
92
93/// The smallest `x` at which the Hankel arm reaches full precision for order `nu`.
94///
95/// Only ever used as a _gate_, so being slightly conservative is free and being optimistic is
96/// not. `nu` was swept to 12 when this was fitted. Past that the uniform Debye regime takes
97/// over and this form should not be trusted. See the table in the
98/// [module documentation](self).
99///
100/// `FLOOR_N / FLOOR_D` is the format's constant floor, which comes from `$e^{-2x} <
101/// \varepsilon$` and is therefore per-format: **`<17, 1>` for binary64 and `<13, 2>` for
102/// binary32**, both measured rather than derived. It is a const generic for the same reason
103/// `N` is: the caller knows its format and the value should be materialized, not computed.
104#[inline(always)]
105pub fn hankel_usable_from<E, V, const FLOOR_N: LargeInt, const FLOOR_D: LargeInt>(nu: V) -> V
106where
107    E: FloatElement,
108    V: FloatVector<Element = E>,
109{
110    let floor = V::splat(<E as FloatElement>::ConstRatio::<FLOOR_N, FLOOR_D>::VALUE);
111
112    // The order term, `1.75|nu| + 4`, fitted to the measured rise past `nu ~ 5`.
113    nu.abs()
114        .mul_adde(const_splat!(ratio <E>: 7 / 4), const_splat!(int <E>: 4))
115        .max(floor)
116}
117
118/// `$J_\nu(x)$` by the Hankel expansion, at arbitrary real order, for `x` past
119/// [`hankel_usable_from`].
120///
121/// `N` is the term count. See the [module documentation](self) for the measured table. The
122/// caller is responsible for the gate. This does not check it, and below the gate the answer
123/// is simply the best a divergent series can do, which is not enough.
124///
125/// # The phase is never formed directly
126///
127/// `$\omega = x - (\nu/2 + 1/4)\pi$` cannot be computed that way: subtracting an irrational
128/// from a large `$x$` destroys exactly the low-order bits that set the phase. The addition
129/// formulae are used instead, with `$\sin$` and `$\cos$` of the `$\nu$`-dependent part taken
130/// once through [`sincos_pi`](thermite::math::RealMath::sincos_pi) so `$\pi$` never multiplies
131/// anything large. Boost's own comment says the same thing about the
132/// same expansion.
133///
134/// # `x_lo`: a second word of the argument, for the phase alone
135///
136/// Past the gate the error of this arm is the **phase**: `sin x` and `cos x` are as accurate
137/// as `x` itself, and a caller whose `x` was computed (Airy's `zeta = (2/3)|z|^{3/2}`)
138/// has already lost `x eps / 2` of it to rounding. `x_lo` is that rounding, when the caller
139/// has it, and enters here through the addition formulae to first order:
140/// `sin(x + lo) = sin x + lo cos x`, `cos(x + lo) = cos x - lo sin x`. Two FMAs. The
141/// amplitude series does not need it: its sensitivity to `x` is `O(1/x)`. Callers without a
142/// second word pass zero, which is exact.
143#[inline(always)]
144pub fn hankel_jy_nu<P, E, V, const N: usize>(nu: V, x: V, x_lo: V) -> (V, V)
145where
146    E: FloatElement,
147    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
148    P: Policy,
149{
150    let (sin_c, cos_c) = nu.mul_adde(V::HALF, const_splat!(ratio <E>: 1 / 4)).sincos_pi_p::<P>();
151    let (sin_x0, cos_x0) = x.sin_cos_p::<P>();
152    let sin_x = x_lo.mul_adde(cos_x0, sin_x0);
153    let cos_x = x_lo.nmul_adde(sin_x0, cos_x0);
154
155    // cos(w) and sin(w) with w = x - c*pi, through the addition formulae.
156    let cos_w = cos_x.mul_adde(cos_c, sin_x * sin_c);
157    let sin_w = sin_x.mul_sube(cos_c, cos_x * sin_c);
158
159    let mu = (nu + nu) * (nu + nu);
160    let inv_x = V::ONE / x;
161
162    // `a` is `a_k / x^k`. The ratio already carries the `1/x`. `2k-1` rides alongside as a
163    // vector stepped by a constant, and `1/(8k)` is a compile-time scalar once the loop
164    // unrolls, so no term pays a vector division or an integer-to-float convert.
165    let mut a = V::ONE;
166    let mut j = V::ONE;
167
168    // `P` takes the even `k`, `Q` the odd, and the sign `sigma_k` runs `+ + - - + + - -`,
169    // flipping on every EVEN `k`. Unrolling the loop two at a time makes the parity static,
170    // so neither the destination nor the flip costs a branch.
171    let mut p = V::ONE;
172    let mut q = V::ZERO;
173    let mut sgn = V::ONE;
174
175    let mut k = 1usize;
176    while k <= N {
177        V::_loop_hint();
178
179        // Odd k -> Q, sign unchanged.
180        a *= j.nmul_adde(j, mu) * (inv_x * V::splat(E::from_ratio(1, 8 * k as LargeInt)));
181        j += V::TWO;
182        q = sgn.mul_adde(a, q);
183        k += 1;
184
185        if k > N {
186            break;
187        }
188
189        // Even k -> P, and the sign flips first.
190        a *= j.nmul_adde(j, mu) * (inv_x * V::splat(E::from_ratio(1, 8 * k as LargeInt)));
191        j += V::TWO;
192        sgn = -sgn;
193        p = sgn.mul_adde(a, p);
194        k += 1;
195    }
196
197    // sqrt(2/(pi x)), the envelope both kinds ride on.
198    let amp = (V::FRAC_2_PI / x).sqrt();
199
200    (amp * p.mul_sube(cos_w, q * sin_w), amp * p.mul_adde(sin_w, q * cos_w))
201}
202
203/// `$J_\nu(x)$` by its ascending series, at arbitrary real order, for small `x`.
204///
205/// ```math
206/// J_\nu(x) = \sum_{k\ge 0} \frac{(-1)^k}{k!\,\Gamma(\nu+k+1)}\left(\frac{x}{2}\right)^{\nu+2k}
207/// ```
208///
209/// advanced by the ratio `$t_{k+1}/t_k = -\frac{(x/2)^2}{(k+1)(\nu+k+1)}$`, so the only
210/// per-order quantity is the seed. No table at any order, like the rest of this module.
211///
212/// # Domain: roughly `$x \lesssim 6$`, and the limit is cancellation not convergence
213///
214/// The series converges everywhere, and Boost's own comment says so ("this series will
215/// actually converge rapidly for all small x - say up to x < 20") before adding "but the
216/// first few terms are large and divergent which leads to large errors :-(".
217/// That is the real bound.
218///
219/// The terms peak near `$k \approx x$` at a magnitude around `$e^{x}/(\pi x)$` while the
220/// answer is `$O(x^{-1/2})$`, so summing them loses about
221/// `$1.4427x - \log_2\sqrt{2\pi x}$` bits. Measured envelope-relative at `$\nu = 1/3$` in
222/// binary64: **0.00 eps at `x = 8`, 158 at `x = 10`, 6.1e3 at `x = 12`**. So it is usable to
223/// about 8 and comfortable to 6, and the `(6, 16)` hole between here and
224/// [`hankel_j_nu`] is a real gap that neither arm covers
225/// (`notes/special/tools/model_fractional_arms.py`).
226///
227/// Nothing here detects that. The caller gates on `x`.
228///
229/// # The seed sets the accuracy floor
230///
231/// Every term is proportional to `$t_0 = (x/2)^\nu / \Gamma(\nu+1)$`, so the seed's relative
232/// error passes straight through to the result and nothing later can recover it. That is one
233/// `powf` and one `tgamma`, so the floor is roughly their combined error (about 2 ulp), and
234/// no amount of extra terms improves it.
235///
236/// `$\Gamma(\nu+1)$` has poles at negative integer `$\nu$`, where the seed becomes zero rather
237/// than infinite. Negative **integer** orders never arrive here: they reflect through
238/// `$J_{-n} = (-1)^n J_n$` before any kernel sees them.
239#[inline(always)]
240pub fn series_j_nu<P, E, V>(nu: V, x: V, needed: V::Mask) -> V
241where
242    E: FloatElement,
243    V: FloatVector<Element = E> + SpecializedTranscendentalMath<E> + SpecializedSpecialMath<E>,
244    P: Policy,
245{
246    if needed.none() {
247        return V::ZERO;
248    }
249
250    let half_x = x * V::HALF;
251
252    let seed = <V as SpecializedTranscendentalMath<E>>::powf::<P>(half_x, nu)
253        / <V as SpecializedSpecialMath<E>>::tgamma::<P>(nu + V::ONE);
254
255    let neg_half_sq = -(half_x * half_x);
256
257    // The same ladder `SpecializedRealMath::tolerance` uses: a multiple of EPSILON, so it
258    // means the same thing in binary32 and binary64.
259    let tol = <V as FloatConsts>::EPSILON.scale(E::from_int(const { P::POLICY.precision.tolerance() }));
260
261    // `k` is carried as a vector and stepped by one rather than converted from the driver's
262    // runtime index, for the same reason as in `hankel_j_nu`.
263    let mut kf = V::ONE;
264
265    // Additive discipline: the terms go to zero, so converged lanes freeze themselves and
266    // `sum_ratio` needs no per-lane select. Its tolerance is relative to the LARGEST term,
267    // which is what this series needs. The sum passes through zero at every root of
268    // `J_nu`, so a test relative to the running sum would be meaningless there.
269    // `needed` keeps lanes bound for another region from setting the trip count here: the
270    // terms of a large-`x` lane peak at `k ~ x`, so one stray lane can multiply the packet's
271    // work several times over for a value the region select then discards.
272    let sum = sum_ratio::<V, P, _>(tol, needed, seed, move |_k, term| {
273        let next = term * neg_half_sq / (kf * (nu + kf));
274        kf += V::ONE;
275        next
276    });
277
278    // Non-convergence is only reachable outside the documented domain, and the partial sum is
279    // still the best available answer there.
280    match sum {
281        Ok(v) | Err(v) => v,
282    }
283}
284
285/// The convergence tolerance both continued fractions below run at.
286///
287/// **Deliberately not the policy ladder.** `PrecisionPolicy::tolerance` gives 100x `EPSILON`
288/// even at `Average`, and that is the right knob when the loop's output _is_ the answer, as
289/// in `expint_fraction`. Here both fractions feed a Wronskian normalization that divides by
290/// `q + gamma(p - t)`, so slack in either one is amplified rather than passed through, and the
291/// modelled 7.6 eps result was measured at this tolerance. Tiering it is an open item, and one
292/// that has to be measured end to end rather than reasoned about.
293#[inline(always)]
294fn cf_tolerance<E, V>() -> V
295where
296    E: FloatElement,
297    V: FloatVector<Element = E>,
298{
299    <V as FloatConsts>::EPSILON.scale(const_element!(int <E>: 2))
300}
301
302/// `$J_{\nu+1}(x)/J_\nu(x)$` by modified Lentz, **and the sign of `$J_\nu$`**.
303///
304/// `b_0 = 0`, `a_j = -1`, `b_j = 2(\nu+j)/x` (A&S 9.1.73). Boost's `CF1_jy`.
305///
306/// # Why the sign has to come from here
307///
308/// Steed recovers `$\lvert J_\nu\rvert$` from a square root, so the sign has to arrive
309/// separately. This fraction is the only place it exists: it is the parity of the number
310/// of sign changes in the denominator chain. A magnitude-only Lentz (core's
311/// [`lentz`](thermite::math::algorithms::lentz) included) throws it away, which is why this
312/// is written out locally rather than delegating.
313///
314/// # Converged lanes are frozen, and counting instead would not work
315///
316/// The running value is built by **multiplication** and the fraction runs at the noise
317/// floor, `$2\varepsilon$`, so the freeze rule in
318/// [`iterate`](thermite::math::algorithms::iterate) applies in its strict form: extra steps
319/// past convergence multiply by a `$\Delta$` that is only approximately one and walk the lane
320/// off its answer. Since the trip count varies from about 20 to 35 across this arm's range, a
321/// _counted_ loop would give the early lanes 15 extra multiplies, worth roughly 15 eps of
322/// drift against a 7.6 eps target. So this converges and freezes. It does not count.
323/// # `needed` is not an optimization
324///
325/// A packet spans regions, so this runs whenever **any** lane is in the Steed band, and the
326/// lanes that are not must not be allowed to hold the loop open. `needed` seeds the active
327/// mask, so an out-of-band lane never delays convergence and never contributes an iteration.
328///
329/// This matters more here than almost anywhere else in the crate, because the trip count is
330/// not merely different out of band. It explodes. A lane at `x = 0.01` heading for Temme
331/// would drag CF2 to about **5400** iterations, for a value that is then discarded.
332#[inline(always)]
333fn cf1_j_ratio<P, E, V>(nu: V, x: V, needed: V::Mask) -> (V, V::Mask)
334where
335    E: FloatElement,
336    V: FloatVector<Element = E>,
337    P: Policy,
338{
339    let tol = cf_tolerance::<E, V>();
340
341    // Boost uses `sqrt(min)` rather than `min` so that squaring a substituted value cannot
342    // underflow to zero further down the recurrence.
343    let tiny = V::MIN_POSITIVE.sqrt();
344    let two_over_x = V::TWO / x;
345
346    let mut c = tiny;
347    let mut f = tiny;
348    let mut d = V::ZERO;
349
350    let mut negative = <V::Mask as GenericMask>::FALSY;
351    // Seeded from `needed`, not TRUTHY (see the note above).
352    let mut active = needed;
353
354    let mut kf = V::ONE;
355    let mut i = 0usize;
356
357    while i < P::POLICY.max_iterations {
358        V::_loop_hint();
359
360        let b = (nu + kf) * two_over_x;
361
362        // a = -1 throughout, so `b + a/c` and `b + a*d` are a subtract apiece.
363        let cn = b - V::ONE / c;
364        c = cn.is_zero().select(tiny, cn);
365
366        let dn = b - d;
367        d = V::ONE / dn.is_zero().select(tiny, dn);
368
369        let delta = c * d;
370        f = f.mul_c(active, delta);
371
372        // The sign chain must stop when the lane does: a frozen lane whose `d` is
373        // still flipping would accumulate sign changes its answer never saw.
374        negative ^= active & d.is_negative();
375
376        active &= (delta - V::ONE).abs().cmp_gt(tol);
377        if active.none() {
378            break;
379        }
380
381        kf += V::ONE;
382        i += 1;
383    }
384
385    (-f, negative)
386}
387
388/// `$(p, q)$` where `$p + iq$` is the logarithmic derivative of `$H^{(1)}_\nu(x) = J_\nu + iY_\nu$`.
389///
390/// Boost's `CF2_jy`. This **is** complex arithmetic (a complex Lentz)
391/// written out into real components rather than carried in a complex type, which is what Boost
392/// does and for the reason its own comment gives: the `std::complex` version measured about
393/// ten times slower. Six accumulators (`cr, ci, dr, di, fr, fi`) instead of three, and no
394/// complex type anywhere.
395///
396/// The first step is special because `$a_1$` is **purely imaginary**, `$i(1/4 - \nu^2)/x$`.
397/// Expanding `$i\alpha/(c_r + ic_i)$` with `$c_i = 1$` gives the two lines that look wrong
398/// next to the loop body and are not.
399///
400/// Converged lanes are frozen for the same reason as in [`cf1_j_ratio`]: `fr`/`fi` accumulate
401/// by complex multiplication at the noise floor.
402#[inline(always)]
403fn cf2_pq<P, E, V>(nu: V, x: V, needed: V::Mask) -> (V, V)
404where
405    E: FloatElement,
406    V: FloatVector<Element = E>,
407    P: Policy,
408{
409    let tol = cf_tolerance::<E, V>();
410    let tiny = V::MIN_POSITIVE.sqrt();
411
412    let nu2 = nu * nu;
413    let br = x + x;
414    let mut bi = V::TWO;
415
416    let mut fr = -(V::HALF / x);
417    let mut fi = V::ONE;
418
419    // First step: `a` is purely imaginary, exactly once.
420    let quarter: V = const_splat!(ratio <E>: 1 / 4);
421    let a1 = (quarter - nu2) / x;
422    let temp = fr.mul_adde(fr, V::ONE);
423    let mut ci = bi + a1 * fr / temp;
424    let mut cr = br + a1 / temp;
425    let mut dr = br;
426    let mut di = bi;
427
428    // Seeded from `needed`, for the reason given on `cf1_j_ratio`.
429    let mut active = needed;
430    let mut kf: V = const_splat!(ratio <E>: 3 / 2);
431    let mut i = 0usize;
432
433    loop {
434        // Guard both against collapsing to zero before either is inverted.
435        let c_small = (cr.abs() + ci.abs()).cmp_lt(tiny);
436        cr = c_small.select(tiny, cr);
437        let d_small = (dr.abs() + di.abs()).cmp_lt(tiny);
438        dr = d_small.select(tiny, dr);
439
440        // 1/D, as a complex reciprocal: one division, two multiplies.
441        let rn = V::ONE / dr.mul_adde(dr, di * di);
442        dr *= rn;
443        di = -(di * rn);
444
445        let delta_r = cr.mul_sube(dr, ci * di);
446        let delta_i = ci.mul_adde(dr, cr * di);
447
448        // f *= delta, complex, frozen per lane.
449        let next_r = fr.mul_sube(delta_r, fi * delta_i);
450        let next_i = fr.mul_adde(delta_i, fi * delta_r);
451        fr = active.select(next_r, fr);
452        fi = active.select(next_i, fi);
453
454        active &= ((delta_r - V::ONE).abs() + delta_i.abs()).cmp_gt(tol);
455        i += 1;
456        if active.none() || i >= P::POLICY.max_iterations {
457            break;
458        }
459
460        // a_k = (k - 1/2)^2 - nu^2 for k >= 2, real from here on.
461        let a = kf.mul_sube(kf, nu2);
462        bi += V::TWO;
463
464        // `a / |C|^2` once. The same real factor scales both components.
465        let at = a / cr.mul_adde(cr, ci * ci);
466        cr = at.mul_adde(cr, br);
467        ci = at.nmul_adde(ci, bi);
468        dr = a.mul_adde(dr, br);
469        di = bi + a * di;
470
471        kf += V::ONE;
472    }
473
474    (fr, fi)
475}
476
477/// `$(J_\nu, Y_u, Y_{u+1})$` by Steed's method at the **reduced order** `$u = \nu - n$`,
478/// `$\lvert u\rvert \le 1/2$`, for the band between the two other arms.
479///
480/// # The order reduction is not optional
481///
482/// Boost runs CF1 at `$\nu$`, recurs the `$J$` ratio **down** to `$u$`, runs CF2 and the
483/// Wronskian at `$u$`, and walks `$Y$` back up (its `x > 2` branch).
484/// Running everything at `$\nu$` directly is fine for `$\nu \in [-1/3, 2]$` and catastrophic
485/// above: at `$\nu = 7.4$`, `$x = 2.07$`, `$Y$` measured 4.3 million ULP and `$J$` 678
486/// against mpmath.
487///
488/// The reason is CF2. The Thompson-Barnett fraction for `$H'/H$` converges for every
489/// `$x > 0$`, but its accuracy collapses once `$\nu$` is well above `$x$`, the same fact
490/// that makes the modified twin reduce its order for _both_ `$K$` arms. CF1 has no such
491/// problem, so the ratio it delivers at `$\nu$` is walked down instead, and the walk is the
492/// stable direction for `$J$`. Below `$\lvert\nu\rvert \le 1/2$` the reduction is the
493/// identity and this is exactly the six-line arm.
494///
495/// # The pieces
496///
497/// CF1 at `$\nu$` gives `$f_\nu = J_{\nu+1}/J_\nu$` and the sign of `$J_\nu$`. The
498/// three-term recurrence walked down from `$\nu$` with a tiny seed (`prev`, `cur` proportional
499/// to `$J_{k+1}$`, `$J_k$`) reaches `$u$` with two things in hand: `$f_u = J_{u+1}/J_u$` and
500/// the scaling `$J_\nu/J_u$`. CF2 at `$u$` and the Wronskian
501/// `$J_u Y'_u - J'_u Y_u = 2/\pi x$` then give `$\lvert J_u\rvert$`, `$Y_u$` and `$Y_{u+1}$`.
502/// The sign of `$J_u$` is the sign of `$J_\nu$` times the sign the walk ended on. The
503/// caller walks `$Y$` up, which it does for the Temme arm anyway.
504///
505/// # Measured
506///
507/// At small order the arithmetic is unchanged: `notes/special/tools/model_steed.py`,
508/// envelope-relative against mpmath at 60 digits, over `x` in `[4, 30]` and `nu` in
509/// `[-1/3, 2]`, **worst 7.62 eps for `J`, 5.74 for `Y`**. The iteration counts move in
510/// opposite directions: CF1 grows with `x` (20 to 35 across the gap), CF2 shrinks (16 to 8),
511/// so their sum is nearly flat over the region this covers.
512#[inline(always)]
513pub fn steed_jy_nu<P, E, V>(nu: V, n: V, u: V, x: V, needed: V::Mask) -> (V, V, V)
514where
515    E: FloatElement,
516    V: FloatVector<Element = E>,
517    P: Policy,
518{
519    // Region skip: if no lane is in the Steed band, none of this runs at all. The two
520    // continued fractions are the most expensive thing in the file, so this is the difference
521    // between a packet of small `x` paying for Steed and not.
522    if needed.none() {
523        return (V::ZERO, V::ZERO, V::ZERO);
524    }
525
526    let (fv, negative) = cf1_j_ratio::<P, E, V>(nu, x, needed);
527
528    // Walk the ratio down from `nu` to `u`, each lane its own `n` steps:
529    // `J_{k-1} = (2(u+k)/x) J_k - J_{k+1}`. Boost's tiny seed keeps the chain, which grows
530    // like `J_u / J_nu`, away from overflow. At `n = 0` it is untouched and the ratio is one.
531    let init = V::MIN_POSITIVE.sqrt();
532    let two_over_x = V::TWO / x;
533    let half_eps = <V as FloatConsts>::EPSILON * V::HALF;
534
535    let mut prev = fv * init;
536    let mut cur = init;
537    let mut k = n;
538    let mut i = 0usize;
539
540    while i < P::POLICY.max_iterations {
541        let active = needed & k.cmp_ge(V::ONE);
542        if active.none() {
543            break;
544        }
545        V::_loop_hint();
546
547        let next = ((u + k) * two_over_x).mul_sube(cur, prev);
548        // Boost: an exact cancellation to zero breaks the ratio below, so pretend a bit survived.
549        let next = next.is_zero().select(prev * half_eps, next);
550        prev = active.select(cur, prev);
551        cur = active.select(next, cur);
552
553        k -= V::ONE;
554        i += 1;
555    }
556
557    // Boost's `over` branch: a chain that left the range gives nothing usable, so the ratio
558    // is zero and `f_u` a harmless one rather than NaN.
559    let over = !cur.is_finite();
560    let ratio = over.select(V::ZERO, init / cur);
561    let fu = over.select(V::ONE, prev / cur);
562
563    let (p, q) = cf2_pq::<P, E, V>(u, x, needed);
564
565    // t = J'_u / J_u, from J'_u = (u/x) J_u - J_{u+1}.
566    let t = u / x - fu;
567
568    // Boost's guard: gamma cancelling exactly to zero breaks everything below it, so pretend
569    // one bit survived. Its only known trigger is v = 8.5, x = 4*pi.
570    let gamma = (p - t) / q;
571    let gamma = gamma.is_zero().select(u * <V as FloatConsts>::EPSILON / x, gamma);
572
573    // The Wronskian supplies the magnitude of `J_u`. The sign is `J_nu`'s times the walk's.
574    let w = V::FRAC_2_PI / x;
575    let magnitude = (w / gamma.mul_adde(p - t, q)).sqrt();
576    let j_u = magnitude.neg_c(negative ^ cur.is_negative());
577
578    let y_u = gamma * j_u;
579    let y_u1 = y_u * (u / x - p - q / gamma);
580
581    (j_u * ratio, y_u, y_u1)
582}
583
584/// `$(Y_\nu(x), Y_{\nu+1}(x))$` by Temme's series, for **small `x` and `$\lvert\nu\rvert \le
585/// 1/2$`**.
586///
587/// Temme, _Journal of Computational Physics_ vol 21, 343 (1976). Boost's `temme_jy`.
588///
589/// # Why this arm exists at all
590///
591/// Steed handles `$Y_\nu$` from about `x = 0.5` upward, so this is not filling a hole in
592/// accuracy so much as one in **cost**, and then a hole in accuracy underneath it. Measured,
593/// Steed's CF2 needs 22 iterations at `x = 4`, 150 at `0.5`, and **5392** at `0.01` (its trip
594/// count grows like `$1/x$`), and below `0.5` it stops being accurate at all (2400 eps at
595/// `x = 0.1`, 261000 at `0.01`).
596///
597/// Temme, over the same range, is **at most 12 terms and 2.19 eps**
598/// (`notes/special/tools/model_temme.py`). So it is both cheaper and better below `x = 2`,
599/// which is exactly where Boost switches.
600///
601/// # `|nu| <= 1/2` is a precondition
602///
603/// Not a suggestion: the series is built around `$\Gamma(1\pm\nu)$` near one. The caller
604/// reduces the order and recurs `$Y$` upward, which is stable because `$Y$` is the dominant
605/// solution.
606///
607/// # Four limits, and why the guards are wide
608///
609/// `d`, `e`, `g1` and `vspv` are each `$0/0$` at `$\nu = 0$` with a finite limit. `d` is
610/// exactly [`sinhc`](thermite::math::RealMath::sinhc) and needs no guard. The other three
611/// are `select`s on `$\lvert\nu\rvert < \varepsilon$`, wide rather than `== 0`, matching
612/// Boost. A wide guard costs nothing here: the substituted limit is correct to several
613/// digits well before `$\nu$` reaches `$\varepsilon$`.
614#[inline(always)]
615pub fn temme_y_nu<P, E, V, const NE: usize, const NO: usize>(
616    nu: V,
617    x: V,
618    needed: V::Mask,
619    t: &LogGamma1p<E, NE, NO>,
620) -> (V, V)
621where
622    E: FloatElement,
623    V: FloatVector<Element = E> + TranscendentalMathWithPolicy + SpecializedTranscendentalMath<E>,
624    P: Policy,
625{
626    if needed.none() {
627        return (V::ZERO, V::ZERO);
628    }
629
630    let (gp, gm) = tgamma1pm1_pair::<P, E, V, NE, NO>(nu, t);
631
632    let half = nu * V::HALF;
633    let spv = nu.sin_pi_p::<P>();
634    let spv2 = half.sin_pi_p::<P>();
635
636    let log_half_x = (x * V::HALF).ln_p::<P>();
637    let sigma = -(log_half_x * nu);
638
639    // `sinh(sigma)/sigma`, which is what `sinhc` is, so this limit needs no select.
640    let d = sigma.sinhc_p::<P>();
641
642    let tiny = <V as FloatConsts>::EPSILON;
643    let at_zero = nu.abs().cmp_lt(tiny);
644
645    // The three remaining 0/0 limits. The unused arm may be NaN, but `select` is bitwise, so it
646    // does not propagate.
647    let e = at_zero.select(
648        nu * <V as FloatConsts>::PI_SQUARED * V::HALF,
649        (spv2 * spv2 + spv2 * spv2) / nu,
650    );
651
652    let denom = (V::ONE + gp) * (V::ONE + gm) * V::TWO;
653    let g1 = at_zero.select(-<V as FloatConsts>::EULER_GAMMA, (gp - gm) / (denom * nu));
654    let g2 = (V::TWO + gp + gm) / denom;
655    let vspv = at_zero.select(<V as FloatConsts>::FRAC_1_PI, nu / spv);
656
657    let mut f = (g1 * sigma.cosh_p::<P>() - g2 * log_half_x * d) * (vspv + vspv);
658
659    let xp = <V as SpecializedTranscendentalMath<E>>::powf::<P>(x * V::HALF, nu);
660    let mut p = vspv / (xp * (V::ONE + gm));
661    let mut q = vspv * xp / (V::ONE + gp);
662
663    let g0 = f + e * q;
664    let h0 = p;
665    let mut coef = V::ONE;
666
667    let nu2 = nu * nu;
668    let quarter: V = const_splat!(ratio <E>: 1 / 4);
669    let coef_mult = -(x * x * quarter);
670    let tol = <V as FloatConsts>::EPSILON;
671
672    // `Y_v` and `Y_{v+1}` ride one `coef` chain, which is the paired-Additive shape, so this
673    // is `sum_pair` rather than a hand-rolled loop with two accumulators.
674    let mut kf = V::ONE;
675    let step = move || {
676        // One reciprocal serves all three: `k^2 - nu^2` is `(k - nu)(k + nu)`.
677        let inv = V::ONE / kf.mul_sube(kf, nu2);
678        f = kf.mul_adde(f, p + q) * inv;
679        p *= (kf + nu) * inv;
680        q *= (kf - nu) * inv;
681        let g = f + e * q;
682        let h = p - kf * g;
683        coef *= coef_mult / kf;
684        kf += V::ONE;
685
686        (coef * g, coef * h)
687    };
688
689    // Non-convergence is only reachable outside the documented domain, and the partial pair is
690    // still the best available answer there.
691    let (sum, sum1) = match sum_pair::<V, P, _>(tol, needed, (g0, h0), step) {
692        Ok(v) | Err(v) => v,
693    };
694
695    (-sum, -(sum1 + sum1) / x)
696}
697
698/// `$(J_\nu(x), Y_\nu(x))$` at **arbitrary real order**, over the whole positive axis.
699///
700/// The region select over the four arms. Everything above this line is a piece. This is the
701/// function.
702///
703/// # Three regions, chosen to avoid overlap rather than to minimise cost
704///
705/// | `x` | `J` | `Y` |
706/// |---|---|---|
707/// | `<= 2` | [`series_j_nu`] | [`temme_y_nu`] + upward recurrence |
708/// | `2 .. gate` | [`steed_jy_nu`] | same pass |
709/// | `>= gate` | [`hankel_jy_nu`] | same pass |
710///
711/// `gate` is [`hankel_usable_from`]. The ascending series is usable to about `x = 6` and Steed
712/// from about `0.5`, so the `2` boundary sits inside both their ranges. It is where Steed
713/// stops being cheap (its CF2 needs 42 iterations at `x = 2` and 5392 at `0.01`) rather than
714/// where it stops being right. Boost splits at the same place for the same reason.
715///
716/// **Every lane pays for every region any lane is in.** Each arm therefore receives the mask
717/// of lanes that actually want it, so a lane bound elsewhere cannot extend an iteration. See
718/// [`iterate`](thermite::math::algorithms::iterate).
719///
720/// # Negative order, and why it is a rotation rather than a sign
721///
722/// At non-integer `$\nu$`, `$J_\nu$` and `$J_{-\nu}$` are **linearly independent** (not a sign
723/// apart, as they are at whole orders), so the pair rotates:
724///
725/// ```math
726/// J_{-\nu} = J_\nu\cos\nu\pi - Y_\nu\sin\nu\pi, \qquad
727/// Y_{-\nu} = J_\nu\sin\nu\pi + Y_\nu\cos\nu\pi
728/// ```
729///
730/// Everything is computed at `$\lvert\nu\rvert$` and rotated once at the end. At whole orders
731/// `$\sin\nu\pi$` vanishes and this collapses to the familiar `$(-1)^n$`.
732///
733/// # Order reduction, and only where it is needed
734///
735/// [`temme_y_nu`] requires `$\lvert\nu\rvert \le 1/2$`. So in the small-`x` region the order is
736/// split as `$\nu = m + u$` with `$m$` whole and `$\lvert u\rvert \le 1/2$`, Temme evaluated at
737/// `$u$`, and `$Y$` walked up `$m$` steps by `$Y_{k+1} = (2k/x)Y_k - Y_{k-1}$`. That direction
738/// is stable because `$Y$` is the dominant solution, the mirror of `$J$`, where the same
739/// direction is the unstable one.
740///
741/// The other two regions need no reduction. Steed and the Hankel expansion take `$\nu$`
742/// directly.
743#[inline(always)]
744#[allow(clippy::too_many_arguments)]
745pub fn bessel_jy_real<
746    P,
747    E,
748    V,
749    const NH: usize,
750    const NE: usize,
751    const NO: usize,
752    const FN: LargeInt,
753    const FD: LargeInt,
754>(
755    nu: V,
756    x: V,
757    x_lo: V,
758    t: &LogGamma1p<E, NE, NO>,
759) -> (V, V)
760where
761    E: FloatElement,
762    V: FloatVector<Element = E>
763        + TranscendentalMathWithPolicy
764        + SpecializedTranscendentalMath<E>
765        + SpecializedSpecialMath<E>,
766    P: Policy,
767{
768    // Work at |nu| throughout and rotate once at the end.
769    let a = nu.abs();
770
771    let gate = hankel_usable_from::<E, V, FN, FD>(a);
772
773    // Lanes off the positive axis (zero, negative, NaN) are kept out of every convergence
774    // mask. A NaN term never passes a tolerance test, so such a lane would otherwise hold a
775    // series open to `max_iterations`: measured 11.5 ms against 5 us for a packet with one
776    // `x = 0` lane. The origin gets its limits below. The rest is NaN.
777    let valid = x.cmp_gt(V::ZERO);
778    let zero = x.is_zero();
779    let near = x.cmp_le(V::TWO) & valid;
780    let far = x.cmp_ge(gate);
781    let mid = !near & !far & valid;
782
783    // ---- x <= 2: the ascending series for J, Temme for Y --------------------------------
784    let j_near = series_j_nu::<P, E, V>(a, x, near);
785
786    // Both `Y` arms below the Hankel gate work at the reduced order, `|u| <= 1/2`: Temme
787    // because its series is built around `Gamma(1 +- u)`, Steed because CF2 collapses
788    // once the order is well above `x`. `m` is whole, `u` is the remainder.
789    let m = a.round();
790    let u = a - m;
791    let (yu_near, yu1_near) = temme_y_nu::<P, E, V, NE, NO>(u, x, near, t);
792
793    // ---- 2 < x < gate: Steed at the reduced order gives J_a and the Y pair at u ---------
794    let (j_mid, yu_mid, yu1_mid) = steed_jy_nu::<P, E, V>(a, m, u, x, mid);
795
796    // ---- Y upward from `u` to `a`, for both arms at once --------------------------------
797    //
798    // `Y` is the dominant solution, so this is its stable direction. Bounded by `m`, and
799    // every lane freezes at its own order the way `hermitev` does.
800    let below_gate = near | mid;
801    let mut y_prev = near.select(yu_near, yu_mid);
802    let mut y_cur = near.select(yu1_near, yu1_mid);
803
804    let two_over_x = V::TWO / x;
805    let mut k = V::ONE;
806    let mut step = V::ONE;
807    let mut i = 0usize;
808    while i < P::POLICY.max_iterations {
809        let live = below_gate & step.cmp_le(m);
810        if live.none() {
811            break;
812        }
813        V::_loop_hint();
814
815        let next = (u + k).mul_sube(two_over_x * y_cur, y_prev);
816        y_prev = live.select(y_cur, y_prev);
817        y_cur = live.select(next, y_cur);
818
819        k += V::ONE;
820        step += V::ONE;
821        i += 1;
822    }
823    // After exactly `m` steps `y_prev` holds `Y_{u+m} = Y_a`, and at `m = 0` the loop never
824    // ran so it still holds the arm's own `Y_u`. Both cases are the same variable.
825    let y_low = y_prev;
826
827    // ---- x >= gate: the Hankel expansion gives both ------------------------------------
828    let (j_far, y_far) = hankel_jy_nu::<P, E, V, NH>(a, x, x_lo);
829
830    let j_abs = near.select(j_near, mid.select(j_mid, j_far));
831    let y_abs = far.select(y_far, y_low);
832
833    // The origin: `J_a(0) = 0` and `Y_a(0) = -inf` for every `a > 0` (whole orders never
834    // reach here), and the rotation below turns those into the right signed infinities at
835    // negative order.
836    let j_abs = zero.select(V::ZERO, j_abs);
837    let y_abs = zero.select(V::NEG_INFINITY, y_abs);
838
839    // ---- the reflection, as a rotation --------------------------------------------------
840    let (sin_a, cos_a) = a.sincos_pi_p::<P>();
841    let reflected = nu.is_negative();
842
843    let j = reflected.select(j_abs.mul_sube(cos_a, y_abs * sin_a), j_abs);
844    let y = reflected.select(j_abs.mul_adde(sin_a, y_abs * cos_a), y_abs);
845
846    // Both vanish at infinity, where the Hankel arm's `sin_cos` is NaN.
847    let inf = x.cmp_eq(V::INFINITY);
848    let j = inf.select(V::ZERO, j);
849    let y = inf.select(V::ZERO, y);
850
851    // Off the positive axis and not at the origin: complex at non-integer order, so NaN.
852    let bad = !valid & !zero;
853    (bad.select(V::NAN, j), bad.select(V::NAN, y))
854}
855
856#[cfg(all(test, any(target_arch = "x86", target_arch = "x86_64")))]
857mod tests {
858    use super::*;
859
860    use thermite::Vector;
861    use thermite::backend::x86_v2::X86V2;
862    use thermite::math::policy::policies::{Precision, Reference};
863
864    use crate::SpecialMathWithPolicy;
865    use crate::bessel::{J, Y};
866
867    type V = Vector<f64>;
868
869    const N64: usize = 29;
870
871    /// The pre-reduction shape, `(J_nu, Y_nu)`: the reduced-order arm plus the upward
872    /// `Y` walk the kernel does for it.
873    fn steed_full<W: FloatVector<Element = f64>>(nu: W, x: W, needed: W::Mask) -> (W, W) {
874        let n = nu.round();
875        let u = nu - n;
876        let (j, mut yp, mut yc) = steed_jy_nu::<Precision, f64, W>(nu, n, u, x, needed);
877        let two_over_x = W::TWO / x;
878        let mut k = W::ONE;
879        let mut step = W::ONE;
880        loop {
881            let live = step.cmp_le(n);
882            if live.none() {
883                break;
884            }
885            let next = (u + k).mul_sube(two_over_x * yc, yp);
886            yp = live.select(yc, yp);
887            yc = live.select(next, yc);
888            k += W::ONE;
889            step += W::ONE;
890        }
891        (j, yp)
892    }
893
894    /// One lane is enough here, deliberately. This kernel has no lane-divergent behaviour at
895    /// all: `N` is const, there are no masks, and no lane can take a different path, so a
896    /// wider register would exercise nothing a single lane does not.
897    fn j_nu(nu: f64, x: f64) -> f64 {
898        hankel_jy_nu::<Precision, f64, V, N64>(V::splat(nu), V::splat(x), V::ZERO)
899            .0
900            .extract::<0>()
901    }
902
903    /// Plain relative error, for `Y` at small `x` where the function diverges and there is no
904    /// nearby zero to make it meaningless.
905    fn rel(got: f64, want: f64) -> f64 {
906        if want == 0.0 {
907            return if got == 0.0 { 0.0 } else { f64::INFINITY };
908        }
909        ((got - want) / want).abs()
910    }
911
912    /// Envelope-relative, the contract for a function with infinitely many zeros: dividing by
913    /// the true value alone is meaningless at one.
914    fn env_rel(got: f64, want: f64, x: f64) -> f64 {
915        let env = (2.0 / (core::f64::consts::PI * x)).sqrt();
916        (got - want).abs() / env.max(want.abs())
917    }
918
919    /// `J_{1/2}(x) = sqrt(2/(pi x)) sin(x)`, exactly: the one order with a closed form, and
920    /// the one where the asymptotic series terminates rather than diverging.
921    ///
922    /// This is the strongest available check because the reference has no error of its own.
923    #[test]
924    fn half_integer_order_matches_its_closed_form() {
925        for &x in &[20.0f64, 30.0, 55.0, 100.0, 400.0] {
926            let want = (2.0 / (core::f64::consts::PI * x)).sqrt() * x.sin();
927            let got = j_nu(0.5, x);
928            assert!(
929                env_rel(got, want, x) <= 4e-16,
930                "J_1/2({x}): got {got}, want {want}, env-rel {:e}",
931                env_rel(got, want, x)
932            );
933        }
934    }
935
936    /// Whole orders against the shipped integer kernel at `Reference`, which is libm
937    /// bit-for-bit. Two completely independent implementations (this one a table-free
938    /// asymptotic series, that one a fitted minimax rational), so agreement is meaningful.
939    #[test]
940    fn whole_orders_match_the_integer_kernel() {
941        for &x in &[20.0f64, 30.0, 55.0, 100.0] {
942            for n in [0i32, 1, 2, 5] {
943                let want = match n {
944                    0 => V::splat(x).bessel_n_p::<Reference, J, 0>(),
945                    1 => V::splat(x).bessel_n_p::<Reference, J, 1>(),
946                    2 => V::splat(x).bessel_n_p::<Reference, J, 2>(),
947                    _ => V::splat(x).bessel_n_p::<Reference, J, 5>(),
948                }
949                .extract::<0>();
950
951                let got = j_nu(n as f64, x);
952                assert!(
953                    env_rel(got, want, x) <= 8e-16,
954                    "J_{n}({x}): hankel {got}, libm {want}, env-rel {:e}",
955                    env_rel(got, want, x)
956                );
957            }
958        }
959    }
960
961    /// Thirds: the Airy orders, and the reason this kernel exists.
962    ///
963    /// `libm` has no fractional-order Bessel, so there is nothing to differentially test
964    /// against. These are mpmath at 60 digits. **Both signs are included**, because
965    /// `$J_\nu$` and `$J_{-\nu}$` are linearly independent at non-integer order. They are
966    /// genuinely different functions, not a sign apart, and Airy needs both.
967    #[test]
968    fn airy_orders_match_a_high_precision_reference() {
969        // (nu, x, J_nu(x)) from mpmath, dps = 60.
970        const ROWS: &[(f64, f64, f64)] = &[
971            (0.3333333333333333, 20.0, 0.176060580012939),
972            (0.3333333333333333, 30.0, -0.13334053387426162),
973            (0.3333333333333333, 55.0, -0.10331600929280815),
974            (0.3333333333333333, 100.0, -0.02127124485370254),
975            (0.3333333333333333, 400.0, -0.03821195249289747),
976            (0.6666666666666666, 20.0, 0.1390482612211654),
977            (0.6666666666666666, 30.0, -0.1448985197420506),
978            (0.6666666666666666, 55.0, -0.10455814523765926),
979            (0.6666666666666666, 100.0, -0.056778819380529484),
980            (0.6666666666666666, 400.0, -0.027373238316694467),
981            (-0.3333333333333333, 20.0, 0.11295251588168025),
982            (-0.3333333333333333, 30.0, -0.01588153828630604),
983            (-0.3333333333333333, 55.0, -0.0256708694632734),
984            (-0.3333333333333333, 100.0, 0.05596216843421023),
985            (-0.3333333333333333, 400.0, -0.02903303875870895),
986            (-0.6666666666666666, 20.0, 0.02731702987415274),
987            (-0.6666666666666666, 30.0, 0.059390728756765904),
988            (-0.6666666666666666, 55.0, 0.03032108324903861),
989            (-0.6666666666666666, 100.0, 0.07693648950955431),
990            (-0.6666666666666666, 400.0, -0.011446867797140254),
991        ];
992
993        let mut worst = 0.0f64;
994        for &(nu, x, want) in ROWS {
995            let got = j_nu(nu, x);
996            let e = env_rel(got, want, x);
997            assert!(e <= 8e-16, "J_{nu}({x}): got {got}, want {want}, env-rel {e:e}");
998            worst = worst.max(e);
999        }
1000        let _ = worst;
1001    }
1002
1003    /// What the ascending series can possibly deliver at `x`, envelope-relative.
1004    ///
1005    /// Its terms peak near `k ~ x` at about `e^x/(pi x)` while the answer rides an envelope of
1006    /// `sqrt(2/pi x)`, so the summation loses that ratio, in eps, to cancellation. **A flat
1007    /// gate here would be wrong in both directions**: it fails at the top of the range, or it
1008    /// gets loosened until it hides the growth and stops testing anything.
1009    ///
1010    /// The constant floor is the seed. Every term is proportional to
1011    /// `(x/2)^nu / Gamma(nu+1)`, so one `powf` and one `tgamma` set a floor no number of terms
1012    /// can improve on.
1013    fn series_bound(x: f64) -> f64 {
1014        let env = (2.0 / (core::f64::consts::PI * x)).sqrt();
1015        let peak = x.exp() / (core::f64::consts::PI * x);
1016
1017        8e-16 + f64::EPSILON * peak / env
1018    }
1019
1020    /// The ascending series, checked the same three independent ways as the Hankel arm but at
1021    /// small `x`, where the series is the arm that works.
1022    #[test]
1023    fn ascending_series_matches_three_references() {
1024        let s = |nu: f64, x: f64| {
1025            series_j_nu::<Precision, f64, V>(V::splat(nu), V::splat(x), GenericMask::TRUTHY).extract::<0>()
1026        };
1027
1028        // 1. `J_{1/2}` in closed form (exact, no reference error of its own).
1029        for &x in &[0.25f64, 1.0, 2.5, 4.0, 6.0] {
1030            let want = (2.0 / (core::f64::consts::PI * x)).sqrt() * x.sin();
1031            let got = s(0.5, x);
1032            assert!(
1033                env_rel(got, want, x) <= series_bound(x),
1034                "series J_1/2({x}): got {got}, want {want}, env-rel {:e}",
1035                env_rel(got, want, x)
1036            );
1037        }
1038
1039        // 2. Whole orders against libm, through the shipped integer kernel.
1040        for &x in &[0.25f64, 1.0, 2.5, 4.0, 6.0] {
1041            for n in [0i32, 1, 2, 5] {
1042                let want = match n {
1043                    0 => V::splat(x).bessel_n_p::<Reference, J, 0>(),
1044                    1 => V::splat(x).bessel_n_p::<Reference, J, 1>(),
1045                    2 => V::splat(x).bessel_n_p::<Reference, J, 2>(),
1046                    _ => V::splat(x).bessel_n_p::<Reference, J, 5>(),
1047                }
1048                .extract::<0>();
1049
1050                let got = s(n as f64, x);
1051                assert!(
1052                    env_rel(got, want, x) <= series_bound(x),
1053                    "series J_{n}({x}): got {got}, libm {want}, env-rel {:e}",
1054                    env_rel(got, want, x)
1055                );
1056            }
1057        }
1058
1059        // 3. Thirds against mpmath at 60 digits, both signs.
1060        const ROWS: &[(f64, f64, f64)] = &[
1061            (0.3333333333333333, 0.25, 0.5533835954964775),
1062            (0.3333333333333333, 1.0, 0.730876402169448),
1063            (0.3333333333333333, 2.5, 0.19832093341860813),
1064            (0.3333333333333333, 4.0, -0.355427373454576),
1065            (0.3333333333333333, 6.0, -0.010674739474189045),
1066            (0.6666666666666666, 0.25, 0.2743443899886516),
1067            (0.6666666666666666, 1.0, 0.5979499736736285),
1068            (0.6666666666666666, 2.5, 0.3872124247708436),
1069            (0.6666666666666666, 4.0, -0.2325440850267039),
1070            (0.6666666666666666, 6.0, -0.16459872936403688),
1071            (-0.3333333333333333, 0.25, 1.4425215418779371),
1072            (-0.3333333333333333, 1.0, 0.6068875050465293),
1073            (-0.3333333333333333, 2.5, -0.3004751607573633),
1074            (-0.3333333333333333, 4.0, -0.33309316424600427),
1075            (-0.3333333333333333, 6.0, 0.2763443142062459),
1076            (-0.6666666666666666, 0.25, 1.4235474737365985),
1077            (-0.6666666666666666, 1.0, 0.18834029212239412),
1078            (-0.6666666666666666, 2.5, -0.47837308180342863),
1079            (-0.6666666666666666, 4.0, -0.1656584296075688),
1080            (-0.6666666666666666, 6.0, 0.32615507556979645),
1081        ];
1082        for &(nu, x, want) in ROWS {
1083            let got = s(nu, x);
1084            let e = env_rel(got, want, x);
1085            assert!(
1086                e <= series_bound(x),
1087                "series J_{nu}({x}): got {got}, want {want}, env-rel {e:e}, bound {:e}",
1088                series_bound(x)
1089            );
1090        }
1091    }
1092
1093    /// The two arms do **not** overlap. This test pins that rather than pretending otherwise.
1094    ///
1095    /// The first attempt at this test compared them where both were "valid" and failed,
1096    /// correctly: there is no such `x`. The series dies to cancellation around 8 and the
1097    /// Hankel arm is not usable until 17, so `(6, 16)` is covered by **neither**. That is the
1098    /// measured gap from `notes/special/tools/model_fractional_arms.py`, and the reason a third
1099    /// arm is still owed.
1100    ///
1101    /// Locking it down matters because a later change that appears to extend either arm's
1102    /// range is far more likely to be a broken test than a real result.
1103    #[test]
1104    fn neither_arm_covers_the_measured_gap() {
1105        const NU: f64 = 1.0 / 3.0;
1106        // J_{1/3} at x = 12, mpmath at 60 digits (inside the gap).
1107        const X: f64 = 12.0;
1108        const TRUE: f64 = -0.0703213677045818;
1109
1110        let series = series_j_nu::<Precision, f64, V>(V::splat(NU), V::splat(X), GenericMask::TRUTHY).extract::<0>();
1111        let hankel = j_nu(NU, X);
1112
1113        // Both are far outside their ranges here. "Far" means hundreds of eps or worse, so a
1114        // gate at 1e-13 (roughly 450 eps) is generous to both and still fails loudly if
1115        // either one ever genuinely reaches into the gap.
1116        let s_err = env_rel(series, TRUE, X);
1117        let h_err = env_rel(hankel, TRUE, X);
1118
1119        assert!(
1120            s_err > 1e-13 && h_err > 1e-13,
1121            "the gap at x = {X} appears to have closed: series {s_err:e}, hankel {h_err:e}. \
1122             If that is real, the third arm may no longer be needed - re-measure before \
1123             deleting this test."
1124        );
1125    }
1126
1127    /// `Y_nu` from the Hankel arm at large `x`, which now returns both kinds from one `P`/`Q`
1128    /// pass. Same three references as its `J` half.
1129    #[test]
1130    fn hankel_y_matches_three_references() {
1131        let y_nu = |nu: f64, x: f64| {
1132            hankel_jy_nu::<Precision, f64, V, N64>(V::splat(nu), V::splat(x), V::ZERO)
1133                .1
1134                .extract::<0>()
1135        };
1136
1137        // 1. `Y_{1/2}(x) = -sqrt(2/pi x) cos x`, exact.
1138        for &x in &[20.0f64, 30.0, 55.0, 100.0, 400.0] {
1139            let want = -(2.0 / (core::f64::consts::PI * x)).sqrt() * x.cos();
1140            let got = y_nu(0.5, x);
1141            assert!(env_rel(got, want, x) <= 4e-16, "Y_1/2({x}): got {got}, want {want}");
1142        }
1143
1144        // 2. Whole orders against libm.
1145        for &x in &[20.0f64, 30.0, 55.0, 100.0] {
1146            for n in [0i32, 1, 2, 5] {
1147                let want = match n {
1148                    0 => V::splat(x).bessel_n_p::<Reference, Y, 0>(),
1149                    1 => V::splat(x).bessel_n_p::<Reference, Y, 1>(),
1150                    2 => V::splat(x).bessel_n_p::<Reference, Y, 2>(),
1151                    _ => V::splat(x).bessel_n_p::<Reference, Y, 5>(),
1152                }
1153                .extract::<0>();
1154                let got = y_nu(n as f64, x);
1155                assert!(env_rel(got, want, x) <= 8e-16, "Y_{n}({x}): got {got}, libm {want}");
1156            }
1157        }
1158
1159        // 3. Thirds against mpmath at 60 digits, both signs.
1160        const ROWS: &[(f64, f64, f64)] = &[
1161            (0.3333333333333333, 20.0, -0.028777707635715168),
1162            (0.3333333333333333, 30.0, -0.05864577231670508),
1163            (0.3333333333333333, 55.0, -0.030007358986895383),
1164            (0.3333333333333333, 100.0, -0.0769005049621365),
1165            (0.6666666666666666, 20.0, -0.11182254014899551),
1166            (0.6666666666666666, 30.0, 0.015078692908077526),
1167            (0.6666666666666666, 55.0, 0.025354902147023572),
1168            (0.6666666666666666, 100.0, -0.056057339204074165),
1169            (-0.3333333333333333, 20.0, 0.1380840810783704),
1170            (-0.3333333333333333, 30.0, -0.14479917584764257),
1171            (-0.3333333333333333, 55.0, -0.1044779681586487),
1172            (-0.3333333333333333, 100.0, -0.05687169089449366),
1173        ];
1174        for &(nu, x, want) in ROWS {
1175            let got = y_nu(nu, x);
1176            assert!(
1177                env_rel(got, want, x) <= 8e-16,
1178                "Y_{nu}({x}): got {got}, want {want}, env-rel {:e}",
1179                env_rel(got, want, x)
1180            );
1181        }
1182    }
1183
1184    /// Steed across the gap, `J` and `Y` together, checked the same three independent ways.
1185    ///
1186    /// This is the arm that bridges `(6, 16)`, so the grid sits squarely inside it, exactly
1187    /// where `neither_arm_covers_the_measured_gap` asserts the other two fail.
1188    #[test]
1189    fn steed_matches_three_references_across_the_gap() {
1190        let st = |nu: f64, x: f64| {
1191            let (j, y) = steed_full::<V>(V::splat(nu), V::splat(x), GenericMask::TRUTHY);
1192            (j.extract::<0>(), y.extract::<0>())
1193        };
1194
1195        // 1. `nu = 1/2` in closed form, for both kinds (exact references).
1196        for &x in &[6.0f64, 8.0, 11.0, 14.0, 16.0] {
1197            let amp = (2.0 / (core::f64::consts::PI * x)).sqrt();
1198            let (j, y) = st(0.5, x);
1199            assert!(
1200                env_rel(j, amp * x.sin(), x) <= 2e-15,
1201                "steed J_1/2({x}): got {j}, want {}",
1202                amp * x.sin()
1203            );
1204            assert!(
1205                env_rel(y, -amp * x.cos(), x) <= 2e-15,
1206                "steed Y_1/2({x}): got {y}, want {}",
1207                -amp * x.cos()
1208            );
1209        }
1210
1211        // 2. Whole orders against libm, both kinds.
1212        for &x in &[6.0f64, 8.0, 11.0, 14.0, 16.0] {
1213            for n in [0i32, 1, 2] {
1214                let (wj, wy) = match n {
1215                    0 => (
1216                        V::splat(x).bessel_n_p::<Reference, J, 0>(),
1217                        V::splat(x).bessel_n_p::<Reference, Y, 0>(),
1218                    ),
1219                    1 => (
1220                        V::splat(x).bessel_n_p::<Reference, J, 1>(),
1221                        V::splat(x).bessel_n_p::<Reference, Y, 1>(),
1222                    ),
1223                    _ => (
1224                        V::splat(x).bessel_n_p::<Reference, J, 2>(),
1225                        V::splat(x).bessel_n_p::<Reference, Y, 2>(),
1226                    ),
1227                };
1228                let (j, y) = st(n as f64, x);
1229                assert!(
1230                    env_rel(j, wj.extract::<0>(), x) <= 4e-15,
1231                    "steed J_{n}({x}): got {j}, libm {}",
1232                    wj.extract::<0>()
1233                );
1234                assert!(
1235                    env_rel(y, wy.extract::<0>(), x) <= 4e-15,
1236                    "steed Y_{n}({x}): got {y}, libm {}",
1237                    wy.extract::<0>()
1238                );
1239            }
1240        }
1241
1242        // 3. Thirds against mpmath at 60 digits, both signs and both kinds.
1243        const ROWS: &[(f64, f64, f64, f64)] = &[
1244            (0.3333333333333333, 6.0, -0.010674739474189045, -0.3252579921009493),
1245            (0.3333333333333333, 8.0, 0.25977616110834967, 0.10958779463360625),
1246            (0.3333333333333333, 10.0, -0.18614516704869577, 0.1702011178826876),
1247            (0.3333333333333333, 12.0, -0.0703213677045818, -0.2192743582206475),
1248            (0.3333333333333333, 14.0, 0.21168092934398272, 0.02545667339212697),
1249            (0.3333333333333333, 16.0, -0.10416268410664775, 0.17008275621757885),
1250            (0.6666666666666666, 6.0, -0.16459872936403688, -0.28158032064897237),
1251            (0.6666666666666666, 8.0, 0.2807877136273063, -0.02922717844123111),
1252            (0.6666666666666666, 10.0, -0.08014960330431577, 0.23937232657540727),
1253            (0.6666666666666666, 12.0, -0.1684756369795518, -0.15717219617399347),
1254            (0.6666666666666666, 14.0, 0.1971137944823384, -0.0814947648718344),
1255            (0.6666666666666666, 16.0, -0.007241052782211041, 0.19937736879861026),
1256            (-0.3333333333333333, 6.0, 0.2763443142062459, -0.17187359161390292),
1257            (-0.3333333333333333, 8.0, 0.03498226645675983, 0.279766652134233),
1258            (-0.3333333333333333, 10.0, -0.24047107536326526, -0.07610588451452473),
1259            (-0.3333333333333333, 12.0, 0.15473648076531898, -0.17053726997135818),
1260            (-0.3333333333333333, 14.0, 0.08379433881856603, 0.19604939900465135),
1261            (-0.3333333333333333, 16.0, -0.19937732968342284, -0.005166152453941126),
1262        ];
1263        for &(nu, x, wj, wy) in ROWS {
1264            let (j, y) = st(nu, x);
1265            assert!(
1266                env_rel(j, wj, x) <= 4e-15,
1267                "steed J_{nu}({x}): got {j}, want {wj}, env-rel {:e}",
1268                env_rel(j, wj, x)
1269            );
1270            assert!(
1271                env_rel(y, wy, x) <= 4e-15,
1272                "steed Y_{nu}({x}): got {y}, want {wy}, env-rel {:e}",
1273                env_rel(y, wy, x)
1274            );
1275        }
1276    }
1277
1278    /// The sign of `J_nu` comes from CF1's denominator chain, not from the square root that
1279    /// produces its magnitude. `J_{1/3}` changes sign between these two points, so a lost or
1280    /// stuck sign chain shows up here and nowhere in a magnitude-only check.
1281    #[test]
1282    fn steed_recovers_the_sign_from_cf1() {
1283        let sign_at = |x: f64| {
1284            steed_full::<V>(V::splat(1.0 / 3.0), V::splat(x), GenericMask::TRUTHY)
1285                .0
1286                .extract::<0>()
1287        };
1288
1289        // J_{1/3} is negative at 6 and 12, positive at 8 and 14, straddling two of its roots.
1290        assert!(sign_at(6.0) < 0.0, "J_1/3(6) should be negative, got {}", sign_at(6.0));
1291        assert!(sign_at(8.0) > 0.0, "J_1/3(8) should be positive, got {}", sign_at(8.0));
1292        assert!(
1293            sign_at(12.0) < 0.0,
1294            "J_1/3(12) should be negative, got {}",
1295            sign_at(12.0)
1296        );
1297        assert!(
1298            sign_at(14.0) > 0.0,
1299            "J_1/3(14) should be positive, got {}",
1300            sign_at(14.0)
1301        );
1302    }
1303
1304    /// Temme's series at small `x`, both returned orders, against mpmath at 60 digits.
1305    ///
1306    /// This is the arm that covers where Steed gets expensive and then fails: at `x = 0.01`
1307    /// Steed's CF2 needs 5392 iterations and is 261000 eps wrong, and Temme is 12 terms and a
1308    /// couple of eps.
1309    #[test]
1310    fn temme_matches_a_high_precision_reference() {
1311        use crate::tables::lgamma1p::LGAMMA1P_F64;
1312
1313        let ty = |nu: f64, x: f64| {
1314            let (a, b) =
1315                temme_y_nu::<Precision, f64, V, 25, 25>(V::splat(nu), V::splat(x), GenericMask::TRUTHY, &LGAMMA1P_F64);
1316            (a.extract::<0>(), b.extract::<0>())
1317        };
1318
1319        // (nu, x, Y_nu(x), Y_{nu+1}(x))
1320        const ROWS: &[(f64, f64, f64, f64)] = &[
1321            (0.0, 0.01, -3.005455637083646, -63.67859628206066),
1322            (0.0, 0.1, -1.5342386513503667, -6.4589510947020266),
1323            (0.0, 0.5, -0.44451873350670656, -1.471472392670243),
1324            (0.0, 1.0, 0.08825696421567696, -0.7812128213002887),
1325            (0.0, 2.0, 0.5103756726497451, -0.10703243154093754),
1326            (0.3333333333333333, 0.01, -4.876068267087222, -332.47855994042806),
1327            (0.3333333333333333, 0.1, -2.0682565649661906, -15.537743860478967),
1328            (0.3333333333333333, 0.5, -0.8406278260433777, -2.0532379702305654),
1329            (0.3333333333333333, 1.0, -0.2788016412759921, -0.9850592357315765),
1330            (0.3333333333333333, 2.0, 0.3431999662603444, -0.3080031737866188),
1331            (-0.3333333333333333, 0.01, -2.2722011190011107, -14.758583491338225),
1332            (-0.3333333333333333, 0.1, -0.6775147311988818, -3.23872328913618),
1333            (-0.3333333333333333, 0.5, 0.16237467777288853, -1.1316060101031433),
1334            (-0.3333333333333333, 1.0, 0.4935567106673179, -0.562703214974633),
1335            (-0.3333333333333333, 2.0, 0.5551971179944987, 0.1198934536190353),
1336            (0.5, 0.01, -7.97844666907276, -797.9244540335553),
1337            (0.5, 0.1, -2.5105273689585093, -25.357166629911095),
1338            (0.5, 0.5, -0.9902458802434049, -2.521465550421338),
1339            (0.5, 1.0, -0.4310988680183761, -1.1024955751601793),
1340            (0.5, 2.0, 0.23478571040624846, -0.3956232813587035),
1341            (0.25, 0.01, -4.046477065077802, -217.02001233018106),
1342            (0.25, 0.1, -1.9117683212071752, -12.303757510699864),
1343            (0.25, 0.5, -0.756843545694496, -1.8715902300683556),
1344            (0.25, 1.0, -0.19442175367716438, -0.9319659251969881),
1345            (0.25, 2.0, 0.39273839961538504, -0.2609445010948933),
1346        ];
1347
1348        for &(nu, x, w0, w1) in ROWS {
1349            let (y0, y1) = ty(nu, x);
1350            // `Y` diverges as `x -> 0`, so plain relative error is the right contract here:
1351            // there is no zero nearby to make it meaningless.
1352            assert!(rel(y0, w0) <= 8e-15, "Y_{nu}({x}): got {y0}, want {w0}");
1353            assert!(rel(y1, w1) <= 8e-15, "Y_{nu}+1({x}): got {y1}, want {w1}");
1354        }
1355    }
1356
1357    /// The four `0/0` limits at `nu = 0` must be the finite ones, not NaN.
1358    ///
1359    /// `d`, `e`, `g1` and `vspv` are each `0/0` there. `d` is [`sinhc`] and needs no guard.
1360    /// The other three are selects, and a select whose live arm is the NaN gives NaN.
1361    #[test]
1362    fn temme_is_finite_at_and_around_zero_order() {
1363        use crate::tables::lgamma1p::LGAMMA1P_F64;
1364
1365        // Each order gets its OWN reference. `Y` is not flat near zero. It varies linearly in
1366        // `nu` with slope about 13.6 at `x = 1`, so `Y_{1e-8}` differs from `Y_0` in the
1367        // seventh digit. An earlier version of this test compared everything against the
1368        // `nu = 0` value and failed the kernel for being correct.
1369        const ROWS: &[(f64, f64, f64)] = &[
1370            (0.0, 0.08825696421567696, -0.7812128213002887),
1371            (1e-300, 0.08825696421567696, -0.7812128213002887),
1372            (1e-30, 0.08825696421567696, -0.7812128213002887),
1373            (1e-17, 0.08825696421567694, -0.7812128213002887),
1374            (1e-16, 0.08825696421567684, -0.7812128213002888),
1375            (1e-08, 0.08825695219597983, -0.7812128273300175),
1376            (-1e-08, 0.08825697623537414, -0.78121281527056),
1377        ];
1378
1379        for &(nu, w0, w1) in ROWS {
1380            let (a, b) = temme_y_nu::<Precision, f64, V, 25, 25>(
1381                V::splat(nu),
1382                V::splat(1.0),
1383                GenericMask::TRUTHY,
1384                &LGAMMA1P_F64,
1385            );
1386            let (y0, y1) = (a.extract::<0>(), b.extract::<0>());
1387
1388            assert!(y0.is_finite() && y1.is_finite(), "nu = {nu} gave ({y0}, {y1})");
1389            assert!(rel(y0, w0) <= 8e-15, "Y_{nu}(1): got {y0}, want {w0}");
1390            assert!(rel(y1, w1) <= 8e-15, "Y_{nu}+1(1): got {y1}, want {w1}");
1391        }
1392    }
1393
1394    /// End to end: `bessel_jy_real` across **all three regions**, both kinds, positive and
1395    /// negative order, whole and fractional, small and large `|nu|`.
1396    ///
1397    /// This is the test that says the pieces compose. Each arm has its own check above, but
1398    /// only this one exercises the region select, the order reduction and the negative-order
1399    /// rotation together.
1400    #[test]
1401    fn bessel_jy_real_covers_the_whole_axis() {
1402        use crate::tables::lgamma1p::LGAMMA1P_F64;
1403
1404        let jy = |nu: f64, x: f64| {
1405            let (j, y) = bessel_jy_real::<Precision, f64, V, N64, 25, 25, 17, 1>(
1406                V::splat(nu),
1407                V::splat(x),
1408                V::ZERO,
1409                &LGAMMA1P_F64,
1410            );
1411            (j.extract::<0>(), y.extract::<0>())
1412        };
1413
1414        // (nu, x, J, Y) from mpmath at 50 digits. `x` spans every region boundary: 0.05 and
1415        // 0.5 are Temme, 1.5 straddles the 2 cut, 4 and 9 are Steed, 25 and 80 are Hankel.
1416        const ROWS: &[(f64, f64, f64, f64)] = &[
1417            (0.3333333333333333, 0.05, 0.32729164001955063, -2.724609099171694),
1418            (0.3333333333333333, 0.5, 0.672830829497946, -0.8406278260433777),
1419            (0.3333333333333333, 1.5, 0.6371326370648923, 0.09661008776662783),
1420            (0.3333333333333333, 4.0, -0.355427373454576, 0.17941676634394849),
1421            (0.3333333333333333, 9.0, 0.04514673992769786, 0.2619881509685795),
1422            (0.3333333333333333, 25.0, 0.020097162141383115, -0.1582974186494417),
1423            (0.3333333333333333, 80.0, -0.08819978440003455, -0.013358849535984083),
1424            (2.25, 0.05, 9.746930842421557e-05, -1451.8894167512065),
1425            (2.25, 0.5, 0.01700515517725076, -8.601107604647282),
1426            (2.25, 1.5, 0.17207040140276186, -1.0952365333165308),
1427            (2.25, 4.0, 0.4150977707888228, 0.11743330302206845),
1428            (2.25, 9.0, 0.06283886940664354, -0.2625685257863794),
1429            (2.25, 25.0, -0.055753132743452054, 0.14984908706204303),
1430            (2.25, 80.0, 0.08491093735158971, 0.027402058043983494),
1431            (-0.6666666666666666, 0.05, 4.357750582173945, 2.6252688861482163),
1432            (-0.6666666666666666, 0.5, 0.7683441764822306, 0.9324008688393952),
1433            (-0.6666666666666666, 1.5, -0.1623262567895261, 0.641516073554232),
1434            (-0.6666666666666666, 4.0, -0.16565842960756885, -0.36416171910470596),
1435            (-0.6666666666666666, 9.0, -0.2630406542532977, 0.04035719142945025),
1436            (-0.6666666666666666, 25.0, 0.1581810722120303, 0.021154005791818097),
1437            (-0.6666666666666666, 80.0, 0.013542732047879152, -0.08817291207412467),
1438            (5.5, 1.5, 0.0006543566107377901, -92.08800019920933),
1439            (5.5, 4.0, 0.08260584990805442, -1.0576777947628146),
1440            (5.5, 9.0, 0.08438779749107019, 0.2848318597461538),
1441            (5.5, 25.0, -0.14408915895213564, -0.07304429387418315),
1442            (5.5, 80.0, -0.006865341718278464, 0.08904676635224425),
1443            (-2.25, 1.5, 0.896121327384749, -0.6527770320379809),
1444            (-2.25, 4.0, 0.2104805636761565, 0.37655633348423506),
1445            (-2.25, 9.0, 0.2300977757892373, -0.1412301944301702),
1446            (-2.25, 25.0, -0.14538272385147266, 0.06653588738089529),
1447            (-2.25, 80.0, 0.040664918536847075, 0.0794172806595833),
1448        ];
1449
1450        let mut worst = 0.0f64;
1451        for &(nu, x, wj, wy) in ROWS {
1452            let (j, y) = jy(nu, x);
1453            let ej = env_rel(j, wj, x);
1454            let ey = env_rel(y, wy, x);
1455            assert!(ej <= 4e-14, "J_{nu}({x}): got {j}, want {wj}, env-rel {ej:e}");
1456            assert!(ey <= 4e-14, "Y_{nu}({x}): got {y}, want {wy}, env-rel {ey:e}");
1457            worst = worst.max(ej).max(ey);
1458        }
1459        assert!(worst < 4e-14, "worst {worst:e}");
1460    }
1461
1462    /// A single packet spanning every region at once, against the same lanes computed alone.
1463    ///
1464    /// The region select runs all three arms whenever any lane needs one, so this is the case
1465    /// the `needed` masks exist for, and the one where a mask threaded to the wrong arm shows
1466    /// up as a wrong answer rather than merely as wasted work.
1467    #[test]
1468    fn a_packet_spanning_every_region_agrees_with_single_lanes() {
1469        use crate::tables::lgamma1p::LGAMMA1P_F64;
1470        type W = Vector<<X86V2 as Simd>::f64x2>;
1471
1472        // Lane 0 in Temme's region, lane 1 past the Hankel gate.
1473        let nu = W::splat(1.0 / 3.0);
1474        let x = W::splat(0.5).insert::<1>(25.0);
1475
1476        let (j, y) = bessel_jy_real::<Precision, f64, W, N64, 25, 25, 17, 1>(nu, x, W::ZERO, &LGAMMA1P_F64);
1477
1478        for (lane, xv) in [(0usize, 0.5f64), (1, 25.0)] {
1479            let (j1, y1) = bessel_jy_real::<Precision, f64, V, N64, 25, 25, 17, 1>(
1480                V::splat(1.0 / 3.0),
1481                V::splat(xv),
1482                V::ZERO,
1483                &LGAMMA1P_F64,
1484            );
1485            assert_eq!(
1486                j.extractv(lane).to_bits(),
1487                j1.extract::<0>().to_bits(),
1488                "lane {lane} (x = {xv}) J differs from the same lane alone"
1489            );
1490            assert_eq!(
1491                y.extractv(lane).to_bits(),
1492                y1.extract::<0>().to_bits(),
1493                "lane {lane} (x = {xv}) Y differs from the same lane alone"
1494            );
1495        }
1496    }
1497
1498    /// Out-of-band lanes must not reach the in-band answers, and an all-masked call must not
1499    /// run at all.
1500    ///
1501    /// A packet spans regions, so Steed runs whenever _any_ lane is in its band. A lane at
1502    /// `x = 0.01` heading for a different arm would otherwise hold CF2 open for about 5400
1503    /// iterations for a value that is discarded.
1504    ///
1505    /// **What this cannot check is the saving.** A lane that is masked but still iterating
1506    /// produces the same answers (it is frozen once converged either way), so a missing mask
1507    /// costs time and changes nothing observable. Catching _that_ needs instrumentation or a
1508    /// benchmark, not an assertion. What is checked here is the part that can be: masked lanes
1509    /// do not corrupt, and a fully masked call short-circuits.
1510    #[test]
1511    fn out_of_band_lanes_do_not_reach_the_answer() {
1512        type WS = Vector<<X86V2 as Simd>::f64x2>;
1513
1514        let nu = WS::splat(1.0 / 3.0);
1515        // Lane 0 is in the Steed band, lane 1 far below it and masked off.
1516        let x = WS::splat(10.0).insert::<1>(0.01);
1517        let needed = x.cmp_gt(WS::splat(2.0));
1518
1519        let (j, y) = steed_full::<WS>(nu, x, needed);
1520
1521        // The in-band lane must match the value it has on its own, bit for bit.
1522        let (j1, y1) = steed_full::<V>(V::splat(1.0 / 3.0), V::splat(10.0), GenericMask::TRUTHY);
1523        assert_eq!(
1524            j.extractv(0).to_bits(),
1525            j1.extract::<0>().to_bits(),
1526            "an out-of-band lane changed the in-band J"
1527        );
1528        assert_eq!(
1529            y.extractv(0).to_bits(),
1530            y1.extract::<0>().to_bits(),
1531            "an out-of-band lane changed the in-band Y"
1532        );
1533
1534        // Nothing in band at all: the whole thing is skipped.
1535        let (jz, yz) = steed_full::<WS>(nu, WS::splat(0.01), GenericMask::FALSY);
1536        assert_eq!(jz.extractv(0), 0.0, "a fully masked call must not compute");
1537        assert_eq!(yz.extractv(0), 0.0, "a fully masked call must not compute");
1538    }
1539
1540    /// The gate must be conservative, never optimistic: below what it returns, the divergent
1541    /// series has not yet reached full precision and no term count fixes that.
1542    #[test]
1543    fn the_gate_matches_the_measured_thresholds() {
1544        // <17, 1> is the binary64 floor from the module's table.
1545        let g = |nu: f64| hankel_usable_from::<f64, V, 17, 1>(V::splat(nu)).extract::<0>();
1546
1547        // Measured: 16.5 to 17.0 for orders 0 through 5, rising after.
1548        for &nu in &[0.0f64, 1.0 / 3.0, 1.0, 3.0, 5.0] {
1549            assert!(
1550                g(nu) >= 17.0,
1551                "gate at nu = {nu} is {} , below the measured 17.0",
1552                g(nu)
1553            );
1554        }
1555        // Measured 18.0 at nu = 8 and 25.0 at nu = 12.
1556        assert!(g(8.0) >= 18.0, "gate at nu = 8 is {}", g(8.0));
1557        assert!(g(12.0) >= 25.0, "gate at nu = 12 is {}", g(12.0));
1558
1559        // Symmetric in the sign of the order.
1560        assert_eq!(g(-3.0), g(3.0), "the gate must not depend on the sign of nu");
1561    }
1562}
Last built: 2026-09-08 21:35:55 UTC