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}