Skip to main content

thermite_special/specialized/generic/bessel/
half.rs

1//! Bessel functions at **half-integer order**, where all four families are elementary.
2//!
3//! `$J_{1/2}(x) = \sqrt{2/\pi x}\,\sin x$` and `$J_{-1/2}(x) = \sqrt{2/\pi x}\,\cos x$`. The
4//! modified pair swaps the circular functions for hyperbolic ones and `$K$` is a bare
5//! exponential. Every other half-integer order follows from the same three-term recurrence the
6//! whole family obeys, so this arm needs **no continued fraction, no `$\Gamma$`, and no
7//! series**: one `sin_cos` (or one `exp_m1`), one `sqrt`, and a bounded walk.
8//!
9//! This is the spherical Bessel family wearing different clothes:
10//! `$j_n(x) = \sqrt{\pi/2x}\,J_{n+1/2}(x)$` and `$y_n(x) = \sqrt{\pi/2x}\,Y_{n+1/2}(x)$`.
11//!
12//! # Boost does not do this, and that is not an oversight to copy
13//!
14//! Boost special-cases `$\nu = 1/2$` for `cyl_bessel_i` only. Its
15//! `cyl_bessel_j` runs the full Steed machinery at every half-integer order, and its spherical
16//! functions are thin wrappers that call straight back into it. So the
17//! elementary route below is _not_ a port. It is the identity Boost declines to exploit,
18//! presumably because a scalar library gains little from it. Under SIMD it is the difference
19//! between two continued fractions and a `sin_cos`.
20//!
21//! What Boost's caution _is_ about is real: **the unstable recurrence direction is still
22//! unstable at half-integer order.** `$J$` and `$I$` are the minimal solutions and `$Y$` and
23//! `$K$` the dominant ones, exactly as at whole order, so the two arms here reuse the two
24//! shapes the integer-order kernels measured, see
25//! [`super::jy::bessel_jn_pair_impl`] and [`super::ik::bessel_in_pair_impl`].
26//!
27//! # Domain
28//!
29//! `$x > 0$`. At half-integer order these functions carry a `$\sqrt{x}$` and are genuinely
30//! complex for negative `$x$`, so unlike the integer-order entry points there is no sign to
31//! fold. The caller gets a NaN out of the `sqrt`.
32
33use thermite::{
34    math::{TranscendentalMathWithPolicy, policy::Policy},
35    prelude::*,
36};
37
38use thermite::element::FloatElement;
39
40/// The walk both oscillating half-integer families share, given their four seeds.
41///
42/// Returns `$(J_{a-1}, J_a, Y_{a-1}, Y_a)$`, the neighbour as well as the wanted order,
43/// because every derivative identity in this family reaches **down** one and the walk passes
44/// through it anyway.
45///
46/// # Why the spherical functions reuse this unchanged
47///
48/// `$j_n(x) = \sqrt{\pi/2x}\,J_{n+1/2}(x)$`, and that factor **does not depend on the order**.
49/// So the spherical family satisfies the same three-term recurrence with the same
50/// coefficients, and differs only in its seeds. Passing spherically-normalised seeds in gives
51/// spherically-normalised values out, with no rescaling anywhere and, more usefully, without
52/// ever forming the `$\sqrt{2/\pi x}$` that would then have to be cancelled against a
53/// `$\sqrt{\pi/2x}$`. See [`super::spherical`].
54///
55/// `a` is the order on the **cylindrical** grid, `n + 1/2`, in both cases.
56#[inline(always)]
57pub(super) fn walk_jy<P, E, V>(x: V, a: V, j_lo: V, j_hi: V, y_lo: V, y_hi: V) -> (V, V, V, V)
58where
59    E: FloatElement,
60    V: FloatVector<Element = E>,
61    P: Policy,
62{
63    // `m` is whole and non-negative: the number of recurrence steps up from order 1/2.
64    let m = a - V::HALF;
65    let two_over_x = V::TWO / x;
66
67    // ---- upward, for Y always and for J while the order stays under x --------------------
68    //
69    // `Y` is the dominant solution, so upward is its stable direction at every order. `J` is
70    // the minimal one and upward is safe only below `x`. The lanes where it is not are
71    // overwritten by the downward arm below.
72    let use_fwd = x.cmp_gt(a);
73
74    let mut jp = j_lo;
75    let mut jc = j_hi;
76    let mut yp = y_lo;
77    let mut yc = y_hi;
78
79    // `h` is the order the step is taken _at_: 1/2, 3/2, ... The recurrence is
80    // `f_{h+1} = (2h/x) f_h - f_{h-1}`.
81    let mut h = V::HALF;
82    let mut step = V::ONE;
83
84    loop {
85        let live = step.cmp_le(m);
86        if live.none() {
87            break;
88        }
89        V::_loop_hint();
90
91        let coeff = two_over_x * h;
92
93        let jn = coeff.mul_sube(jc, jp);
94        jp = live.select(jc, jp);
95        jc = live.select(jn, jc);
96
97        let yn = coeff.mul_sube(yc, yp);
98        yp = live.select(yc, yp);
99        yc = live.select(yn, yc);
100
101        h += V::ONE;
102        step += V::ONE;
103    }
104
105    // The seed pair is `(f_{-1/2}, f_{1/2})`, so the wanted order is the _second_ slot: after
106    // `m` steps `cur` holds order `m + 1/2`, and at `m = 0` the loop never ran and it is still
107    // the seed. (`bessel_jy_real`'s walk reads its `prev` instead, since its Temme seed pair starts
108    // _at_ the base order rather than one below it.)
109    let (mut j_prev, mut j_a) = (jp, jc);
110
111    // ---- downward on ratios, where forward is unstable -----------------------------------
112    //
113    // Identical in shape to the integer-order arm, with the order grid offset by 1/2:
114    // `r_h = J_h/J_{h-1}` satisfies `r_h = 1/(2h/x - r_{h+1})`, seeded at zero well above the
115    // wanted order and walked down. The trip count and its tier come from the same two
116    // constants the integer kernel measured.
117    if !use_fwd.all() {
118        let (cn, cd) = const { super::ik::recurrence_x_coeff(P::POLICY.precision) };
119        let coeff = V::splat(E::from_ratio(cn, cd));
120        let margin = V::splat(E::from_int(super::ik::RECURRENCE_MARGIN as _));
121
122        // Start above the wanted order by the same margin, on the half-integer grid.
123        let mut k = (!use_fwd).select(x.mul_adde(coeff, a + margin).ceil() + V::HALF, V::ZERO);
124
125        let mut r = V::ZERO;
126        let mut prod = V::ONE;
127        let mut prod_prev = V::ONE;
128        let mut r_half = V::ONE;
129
130        let three_halves = thermite::const_splat!(ratio <E>: 3 / 2);
131        let a_prev = a - V::ONE;
132
133        loop {
134            let active = k.cmp_ge(V::HALF);
135            if active.none() {
136                break;
137            }
138            V::_loop_hint();
139
140            r = active.select(V::ONE / two_over_x.mul_sube(k, r), r);
141
142            let in_range = active & k.cmp_ge(three_halves);
143            prod = (in_range & k.cmp_le(a)).select(prod * r, prod);
144            // One factor short of `prod`, which is order `a - 1`. The walk visits it either
145            // way, so carrying it costs one select rather than a second pass.
146            prod_prev = (in_range & k.cmp_le(a_prev)).select(prod_prev * r, prod_prev);
147
148            r_half = (active & k.cmp_le(V::HALF)).select(r, r_half);
149
150            k -= V::ONE;
151        }
152
153        // `J_{1/2}` vanishes at every multiple of pi and `J_{-1/2}` at every odd multiple of
154        // pi/2, and they share no zero, so normalising by whichever is larger is always safe.
155        // Seeding from `J_{-1/2}` multiplies by `r_{1/2} = J_{1/2}/J_{-1/2}`, which cancels
156        // the small value rather than dividing by it. Same trap, same fix, as the integer
157        // kernel's `J_0`/`J_1` choice.
158        let use_lo = j_lo.abs().cmp_ge(j_hi.abs());
159        let base = use_lo.select(j_lo * r_half, j_hi);
160
161        j_a = use_fwd.select(j_a, base * prod);
162        // At `a = 1/2` there is no order below `1/2` on this grid except the seed itself, and
163        // `prod_prev` is then the empty product, so this is `base`, which is `J_{1/2}` and not
164        // `J_{-1/2}`. The `m = 0` case is therefore the caller's to handle, and both callers
165        // do: it is exactly where the derivative identity folds back onto a seed.
166        j_prev = use_fwd.select(j_prev, base * prod_prev);
167    }
168
169    (j_prev, j_a, yp, yc)
170}
171
172/// `$(J_\nu(x), Y_\nu(x))$` at half-integer `$\nu$`, both signs of `$\nu$`, for `$x > 0$`.
173///
174/// `nu` must be exactly a half-odd-integer (`$\pm 1/2, \pm 3/2, \ldots$`). Whole orders do not
175/// belong here and are not detected: [`BesselOrder::simplify`](crate::BesselOrder::simplify)
176/// narrows `HalfInteger(2m)` to `Integer(m)` before any kernel sees it, which is why the tag
177/// stores a numerator.
178///
179/// # Negative order is a swap, not a rotation
180///
181/// The general rule at non-integer order is the rotation
182/// `$J_{-\nu} = J_\nu\cos\nu\pi - Y_\nu\sin\nu\pi$`. At `$\nu = m + 1/2$` the cosine vanishes
183/// **exactly** and the sine is `$(-1)^m$`, so the rotation degenerates into an exchange:
184///
185/// ```math
186/// J_{-(m+1/2)} = (-1)^{m+1}\,Y_{m+1/2}, \qquad Y_{-(m+1/2)} = (-1)^m\,J_{m+1/2}
187/// ```
188///
189/// No trigonometry is evaluated for it, and no cancellation is possible in it, which is the
190/// second reason this order class is worth its own kernel, since the general path pays a
191/// `sincos_pi` and a pair of products to reach the same answer less exactly.
192#[inline(always)]
193pub fn bessel_jy_half<P, E, V>(nu: V, x: V) -> (V, V)
194where
195    E: FloatElement,
196    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
197    P: Policy,
198{
199    let a = nu.abs();
200    let m = a - V::HALF;
201
202    // sqrt(2/(pi x)), the envelope every order here rides on.
203    let amp = (V::FRAC_2_PI / x).sqrt();
204    let (sin_x, cos_x) = x.sin_cos_p::<P>();
205
206    // The four seeds. `J_{-1/2}` and `Y_{-1/2}` are not extra work. They are the second
207    // element each recurrence needs, and they cost a negation apiece from the pair already
208    // in hand.
209    let j_lo = amp * cos_x; // J_{-1/2}
210    let j_hi = amp * sin_x; // J_{+1/2}
211    let y_lo = j_hi; // Y_{-1/2} =  J_{1/2}
212    let y_hi = -j_lo; // Y_{+1/2} = -J_{-1/2}
213
214    let (_, j_a, _, y_a) = walk_jy::<P, E, V>(x, a, j_lo, j_hi, y_lo, y_hi);
215
216    // The origin: `J_a(0) = 0` and `Y_a(0) = -inf` at every positive half-integer order, and
217    // the exchange below turns those into the right signed infinities at negative order. The
218    // seeds are `inf * 0` there, so this is a select rather than something that falls out.
219    let zero = x.is_zero();
220    let j_a = zero.select(V::ZERO, j_a);
221    let y_a = zero.select(V::NEG_INFINITY, y_a);
222
223    // Both vanish at infinity, where the seeds are `0 * NaN`.
224    let inf = x.cmp_eq(V::INFINITY);
225    let j_a = inf.select(V::ZERO, j_a);
226    let y_a = inf.select(V::ZERO, y_a);
227
228    // ---- negative order: an exchange with a sign, no trigonometry -------------------------
229    let m_odd = (m * V::HALF).fract().cmp_gt(V::ZERO);
230    let reflected = nu.is_negative();
231
232    (
233        reflected.select(y_a.neg_c(!m_odd), j_a),
234        reflected.select(j_a.neg_c(m_odd), y_a),
235    )
236}
237
238/// The walk both modified half-integer families share, given their seeds.
239///
240/// Returns `$(I_{a-1}, I_a, K_{a-1}, K_a)$`. `K`'s two seeds are equal (`$K_{-1/2} =
241/// K_{1/2}$`), so it takes one value where `$I$` takes two.
242///
243/// Reused unchanged by the modified **spherical** family for the same reason
244/// [`walk_jy`] is: `$i_n(x) = \sqrt{\pi/2x}\,I_{n+1/2}(x)$` and that factor does not depend on
245/// the order, so spherically-normalised seeds give spherically-normalised values with no
246/// rescaling. `a` is the order on the cylindrical grid, `n + 1/2`.
247///
248/// Everything here is in the scaled domain, `$(e^{-x}I,\; e^{x}K)$`. The recurrences are
249/// homogeneous, so a uniform scaling passes straight through both.
250#[inline(always)]
251pub(super) fn walk_ik<P, E, V>(x: V, a: V, i_lo: V, i_hi: V, k_seed: V, asym_scale: V) -> (V, V, V, V)
252where
253    E: FloatElement,
254    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
255    P: Policy,
256{
257    let m = a - V::HALF;
258    let two_over_x = V::TWO / x;
259
260    // ---- K upward, always stable ---------------------------------------------------------
261    //
262    // The two seeds are equal, so the pair starts degenerate and the first step is what
263    // separates them.
264    let mut kp = k_seed;
265    let mut kc = k_seed;
266
267    let mut h = V::HALF;
268    let mut step = V::ONE;
269
270    loop {
271        let live = step.cmp_le(m);
272        if live.none() {
273            break;
274        }
275        V::_loop_hint();
276
277        // K_{h+1} = K_{h-1} + (2h/x) K_h: an addition, where J/Y had a subtraction. That
278        // sign is the whole difference between a dominant solution and a minimal one.
279        let kn = (two_over_x * h).mul_adde(kc, kp);
280        kp = live.select(kc, kp);
281        kc = live.select(kn, kc);
282
283        h += V::ONE;
284        step += V::ONE;
285    }
286
287    // ---- I: the asymptotic series at large x, the ratio recurrence below -------------------
288    //
289    // The handover is what makes the tiered trip count safe. The recurrence starts at
290    // `a + 24 + c*x` with `c` the tier's coefficient (0.35 at `Best`, 0.25 at `Average`), so
291    // below the top tier its accuracy decays with `x`, and past this threshold nothing calls
292    // it. Without the arm, `sph_bessel_i_scaled(n = 2)` measured 14131 ULP at `Average`
293    // against 4.32 at `Best`. Low orders suffer most, since a larger `a` buys its own
294    // headroom. Threshold and series are the integer kernel's own (`asymptotic_series_v`
295    // takes a per-lane order): 0.99 eps for `nu = 1/3` at `x = 40`, where it takes over.
296    let thresh = (a * a * V::splat(E::from_ratio(1, 3))).max(V::splat(E::from_int(40)));
297    let use_asym = x.cmp_ge(thresh);
298
299    let (cn, cd) = const { super::ik::recurrence_x_coeff(P::POLICY.precision) };
300    let coeff = V::splat(E::from_ratio(cn, cd));
301    let margin = V::splat(E::from_int(super::ik::RECURRENCE_MARGIN as _));
302
303    // Asymptotic lanes start at zero so they cannot drag the packet's trip count, the same
304    // guard `bessel_iv_impl` uses.
305    let mut k = (!use_asym).select(x.mul_adde(coeff, a + margin).ceil() + V::HALF, V::ZERO);
306    let mut r = V::ZERO;
307    let mut prod = V::ONE;
308    let mut prod_prev = V::ONE;
309
310    let a_prev = a - V::ONE;
311
312    loop {
313        let active = k.cmp_ge(V::HALF);
314        if active.none() {
315            break;
316        }
317        V::_loop_hint();
318
319        // `+ r` here, `- r` in the J arm: the modified equation flips it, and with it the
320        // guarantee that every `r_h` lands in `(0, 1)` so nothing needs rescaling.
321        r = active.select(V::ONE / two_over_x.mul_adde(k, r), r);
322        prod = (active & k.cmp_le(a)).select(prod * r, prod);
323        prod_prev = (active & k.cmp_le(a_prev)).select(prod_prev * r, prod_prev);
324
325        k -= V::ONE;
326    }
327
328    // `prod` spans `h = 1/2 ..= a`, so it is `I_a / I_{-1/2}`. At `a = 1/2` the direct seed is
329    // better than one continued-fraction step reproducing it, the same reason the integer
330    // entry point selects its order-0 and order-1 closed forms out of the ladder.
331    let mut i_a = a.cmp_le(V::HALF).select(i_hi, i_lo * prod);
332
333    // One order lower. At `a = 1/2` that is `I_{-1/2}`, which is the seed and where
334    // `prod_prev` is the empty product, so the same expression covers it.
335    let mut i_prev = i_lo * prod_prev;
336
337    if use_asym.any() {
338        // `far_threshold` is only read on the unscaled path, and this one is scaled.
339        let far = E::from_int(50);
340
341        // The recurrence is normalization-agnostic, which is what lets the spherical family
342        // reuse this walk. An absolute series is not: `asymptotic_series_v` computes the
343        // CYLINDRICAL `e^-x I_nu`, so a spherical caller passes `sqrt(pi/2x)` as
344        // `asym_scale` to land back in its own convention.
345        i_a = use_asym.select(
346            asym_scale * super::ik::asymptotic_series_v::<P, E, V, true>(x, a, far),
347            i_a,
348        );
349        i_prev = use_asym.select(
350            asym_scale * super::ik::asymptotic_series_v::<P, E, V, true>(x, a_prev, far),
351            i_prev,
352        );
353    }
354
355    (i_prev, i_a, kp, kc)
356}
357
358/// `$(I_\nu(x), K_\nu(x))$` at half-integer `$\nu$`, both signs of `$\nu$`, for `$x > 0$`.
359///
360/// With `SCALED`, returns `$(e^{-x}I_\nu(x),\; e^{x}K_\nu(x))$`, the same convention the
361/// integer-order entry points use, and the one this kernel works in **internally regardless**,
362/// because the seeds are otherwise unrepresentable: `$\sinh x$` overflows at `$x = 710$` while
363/// `$e^{-x}\sinh x$` is `$1/2$` forever. The unscaled form is the scaled one times an
364/// exponential, and pays that exponential's `$x\,\varepsilon/2$` relative error, which is the
365/// documented reason to prefer the scaled twin on accuracy grounds, not only on range.
366///
367/// # Seeds
368///
369/// ```math
370/// I_{1/2} = \sqrt{\tfrac{2}{\pi x}}\sinh x,\quad
371/// I_{-1/2} = \sqrt{\tfrac{2}{\pi x}}\cosh x,\quad
372/// K_{1/2} = K_{-1/2} = \sqrt{\tfrac{\pi}{2x}}\,e^{-x}
373/// ```
374///
375/// Scaled, `$e^{-x}\sinh x = -\mathrm{expm1}(-2x)/2$` and `$e^{-x}\cosh x = (1 + e^{-2x})/2$`,
376/// so **one `exp_m1` supplies both** and neither loses a bit to cancellation at small `$x$`,
377/// which the algebraically equal `$(1 - e^{-2x})/2$` would.
378///
379/// # Directions
380///
381/// `$K$` is the dominant solution and walks **upward**, `$n$` steps, no trip count and no `$x$`
382/// dependence. `$I$` is the minimal one and cannot: its upward recurrence subtracts nearly
383/// equal terms for `$k \ll x$` and loses bits every step whatever the order. So `$I$` takes the
384/// downward **ratio** recurrence `$r_h = 1/(2h/x + r_{h+1})$`, seeded at zero above the wanted
385/// order, exactly as the integer-order `$I$` kernel does and with the same two tier constants.
386///
387/// Unlike `$J$` there is no zero to trip over: `$I_{-1/2} = \sqrt{2/\pi x}\cosh x$` is positive
388/// everywhere, so the normalization needs no choice between two seeds.
389///
390/// # Negative order
391///
392/// `$K$` is even in `$\nu$` at every order and needs nothing. `$I$` is not, at non-integer
393/// order, and the reflection brings `$K$` in:
394///
395/// ```math
396/// I_{-(m+1/2)}(x) = I_{m+1/2}(x) + \tfrac{2}{\pi}(-1)^m K_{m+1/2}(x)
397/// ```
398///
399/// This is a genuine subtraction when `$m$` is odd, and `$I_{-(m+1/2)}$` really does have
400/// zeros: `$I_{-3/2}$` vanishes near `$x = 1.1997$`, where `$\tanh x = 1/x$`. The contract is
401/// absolute against the larger term, not relative, for the same reason it is for `$J$` at its
402/// zeros. Boost's `bessel_ik` carries the same formula with the same exposure.
403///
404/// `far_threshold` is where the unscaled form halves its exponential, see
405/// [`unscale_i`](super::ik::unscale_i). It comes from the `BesselI` table so every `$I$`
406/// arm in the crate turns that corner at the same `x`.
407#[inline(always)]
408pub fn bessel_ik_half<P, E, V, const SCALED: bool>(nu: V, x: V, far_threshold: E) -> (V, V)
409where
410    E: FloatElement,
411    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
412    P: Policy,
413{
414    let a = nu.abs();
415    let m = a - V::HALF;
416
417    let amp = (V::FRAC_2_PI / x).sqrt();
418
419    // `e^{-2x} - 1`, from which both hyperbolic seeds follow without cancellation.
420    let e2m1 = (-(x + x)).exp_m1_p::<P>();
421
422    let i_lo = amp * (V::TWO + e2m1) * V::HALF; // e^{-x} I_{-1/2} = amp (1 + e^{-2x})/2
423    let i_hi = amp * (-e2m1) * V::HALF; //     e^{-x} I_{+1/2} = amp (1 - e^{-2x})/2
424    let k_seed = (V::FRAC_PI_2 / x).sqrt(); // e^{ x} K_{\pm 1/2}
425
426    // `asym_scale` is one: this kernel's seeds are already in the cylindrical normalization
427    // the asymptotic series produces.
428    let (_, i_a, _, k_a) = walk_ik::<P, E, V>(x, a, i_lo, i_hi, k_seed, V::ONE);
429
430    // The origin: `I_a(0) = 0` and `K_a(0) = +inf`, in either scaling. The reflection below
431    // then gives `I_{-a}(0)` its signed infinity through the `K` term.
432    let zero = x.is_zero();
433    let i_a = zero.select(V::ZERO, i_a);
434    let k_a = zero.select(V::INFINITY, k_a);
435
436    // ---- negative order ------------------------------------------------------------------
437    let m_odd = (m * V::HALF).fract().cmp_gt(V::ZERO);
438    let reflected = nu.is_negative();
439
440    // In the scaled domain the reflection's `K` term carries an extra `e^{-2x}`, since the two
441    // families are scaled in opposite directions.
442    //
443    // That factor is a **second** exponential and cannot be recovered as `1 + expm1(-2x)`:
444    // past about `x = 8` the `expm1` sits within an ulp of `-1`, so adding one back leaves
445    // `eps/2` absolute on a quantity of size `e^{-2x}` (`I_{-21/2}(15)` at 3.72e-14 against
446    // 3.85e-16 for `K`). The `any()` guard keeps the extra call off packets with no negative
447    // order, which is most of them.
448    let i_out = match reflected.any() {
449        false => i_a,
450        true => {
451            let k_term = (V::FRAC_2_PI * k_a * (-(x + x)).exp_p::<P>()).neg_c(m_odd);
452            reflected.select(i_a + k_term, i_a)
453        }
454    };
455
456    match const { SCALED } {
457        true => (i_out, k_a),
458        false => (
459            super::ik::unscale_i::<P, E, V>(i_out, x, far_threshold),
460            k_a * (-x).exp_p::<P>(),
461        ),
462    }
463}
Last built: 2026-09-08 21:35:55 UTC