Skip to main content

thermite_special/specialized/generic/
sh.rs

1//! Real spherical harmonics, evaluated directly from Cartesian components.
2//!
3//! # Conventions
4//!
5//! Orthonormal real spherical harmonics. The `CS` const parameter selects the phase
6//! convention: `false` for the standard real-SH tables (sphericart, most math
7//! references), `true` for the Condon-Shortley phase. Everything below describes
8//! `CS = false`. See [the phase section](#the-condon-shortley-phase) for what the
9//! other one changes.
10//!
11//! ```math
12//! \int_{S^2} Y_{\ell m}^2 \, d\Omega = 1,
13//! \qquad
14//! Y_{\ell m} =
15//! \begin{cases}
16//! \sqrt{2}\, K_\ell^m P_\ell^m(\cos\theta)\cos(m\varphi) & m > 0 \\
17//! K_\ell^0 P_\ell(\cos\theta) & m = 0 \\
18//! \sqrt{2}\, K_\ell^{|m|} P_\ell^{|m|}(\cos\theta)\sin(|m|\varphi) & m < 0
19//! \end{cases}
20//! ```
21//!
22//! with `$K_\ell^m = \sqrt{\tfrac{2\ell+1}{4\pi}\tfrac{(\ell-m)!}{(\ell+m)!}}$` and
23//! `$P_\ell^m$` the associated Legendre functions _without_ `$(-1)^m$`. So
24//! `$Y_{00} = \sqrt{1/4\pi}$`, `$Y_{1,-1} = \sqrt{3/4\pi}\,y$`, `$Y_{10} = \sqrt{3/4\pi}\,z$`,
25//! `$Y_{11} = \sqrt{3/4\pi}\,x$`.
26//!
27//! Outputs are written in the flat `l * (l + 1) + m` order (`m` from `-l` to `l`),
28//! the layout every SH-lighting pipeline uses.
29//!
30//! ## The Condon-Shortley phase
31//!
32//! The two conventions differ by `$(-1)^{|m|}$`: odd `|m|` is negated, even `|m|`
33//! agrees exactly. Which one a body of data was projected against is _not_ recoverable
34//! from the data (the difference is invisible in any rotationally-averaged or
35//! squared quantity), so mixing them is a silent, plausible-looking wrong answer.
36//! Hence the explicit parameter rather than a fixed choice.
37//!
38//! Sloan's widely-copied `SHEval` generated code (_Efficient Spherical Harmonic
39//! Evaluation_, JCGT 2(2), 2013) **does** carry the phase. Its diagonal recurrence
40//! is `P_m^m = (1 - 2m) P_{m-1}^{m-1}`, negative for every `m >= 1`. Its order-3
41//! listing emits `pSH[3] = -0.48860251 * x`, matching `CS = true` here, while
42//! `CS = false` gives `+0.48860251 * x`.
43//!
44//! `CS` is baked into the constant table, so neither choice costs an instruction.
45//! Internally it is applied in two places, the second easy to overlook: the diagonal
46//! seeds for odd `m` (which propagates to a whole column, and to both the `+m` and
47//! `-m` slots that share it), and _every_ `z`-derivative ratio in `f`, because that
48//! ratio crosses between adjacent columns whose signs always disagree.
49//!
50//! # Algorithm
51//!
52//! No trigonometry and no division anywhere. The evaluation factors each harmonic as
53//! `$Y_{\ell,\pm m} = q_\ell^m(z) \cdot \{c_m, s_m\}$` where
54//!
55//! * `$c_m + i s_m = (x + iy)^m$`, accumulated by the complex-multiplication pair
56//!   recurrence. Since `$x + iy = \sin\theta\, e^{i\varphi}$` on the unit sphere, this
57//!   _is_ `$\sin^m\theta \{\cos, \sin\}(m\varphi)$`, i.e. the `$\sin^m\theta$` factor of
58//!   `$P_\ell^m$` moved into the azimuthal term, which removes the `$1/\sin\theta$`
59//!   pole from every recurrence (the factoring used by sphericart, Bigi et al.,
60//!   J. Chem. Phys. 159, 064802, 2023).
61//! * `$q_\ell^m(z)$` is the fully-normalized sin-factored associated Legendre part,
62//!   via the standard normalized three-term recurrences (Holmes & Featherstone 2002,
63//!   J. Geodesy 76): a constant diagonal, one `$\sqrt{2m+3}\, z$` step, then
64//!   `$q_\ell^m = a_\ell^m z\, q_{\ell-1}^m - b_\ell^m q_{\ell-2}^m$`. All coefficients
65//!   are precomputed at compile time ([`ShConsts`]). Intermediate values stay `O(1)`,
66//!   so there is no overflow at any order either format can index.
67//!
68//! Cost is `O(L^2)` FMAs per call (two per harmonic past the seeds) with zero
69//! transcendentals.
70//!
71//! # Domain and gradient semantics
72//!
73//! `(x, y, z)` is assumed to be a **unit vector**. Nothing renormalizes. Off the unit
74//! sphere the recurrences still evaluate a perfectly good polynomial in `(x, y, z)`
75//! (the one that agrees with `$Y_{\ell m}$` on the sphere), which is exactly what
76//! [`sh_d_impl`]'s derivatives differentiate: the **ambient Cartesian gradient of that
77//! polynomial form**, evaluated at the given point. This is the convention machine
78//! learning interatomic potentials and finite-difference checks want. A caller who
79//! needs the _tangential_ (spherical) gradient projects out the radial component:
80//! `g_tan = g - (g . n) n`.
81//!
82//! The derivative combinations are exact identities on the recurrence outputs:
83//! `$\partial_x c_m = m c_{m-1}$`, `$\partial_y c_m = -m s_{m-1}$` (and the mirrored
84//! pair for `$s_m$`), and `$\partial_z q_\ell^m = f_\ell^m q_\ell^{m+1}$` where
85//! `$f_\ell^m$` is a tabulated norm ratio. So the gradient pass reuses every value
86//! the value pass produced and adds no new recurrences.
87
88use core::f64::consts::PI;
89
90use thermite::{
91    math::{CoreMath, PrimalProjection, policy::Policy, scalar::Unwrap},
92    prelude::*,
93    register::FloatElement,
94};
95
96/// Triangular index base: `q_l^m` lives at `tri(l) + m`.
97///
98/// `tri(L) + L = L(L+3)/2 < (L+1)^2` for every `L`, so the triangular tables always
99/// fit in the same `N = (L+1)^2` allocation the flat output uses.
100#[inline(always)]
101pub const fn tri(l: usize) -> usize {
102    l * (l + 1) / 2
103}
104
105/// `sqrt` for positive finite values in const context.
106///
107/// Bit-shift seed plus Newton iterations. Converges to within 1 ulp long before the
108/// iteration cap for any normal positive input. Not guaranteed correctly rounded
109/// (irrelevant at 1 ulp for approximation coefficients), but fully deterministic,
110/// which is what matters for reproducible tables.
111const fn csqrt(x: f64) -> f64 {
112    assert!(x > 0.0 && x < f64::INFINITY);
113
114    // Halving the exponent bits lands within ~2x of sqrt(x), and each Newton step then
115    // squares the relative accuracy, so 6 steps are already past f64 precision.
116    let mut y = f64::from_bits((x.to_bits() >> 1) + 0x1FF8_0000_0000_0000);
117
118    let mut i = 0;
119    while i < 8 {
120        y = 0.5 * (y + x / y);
121        i += 1;
122    }
123
124    y
125}
126
127/// Precomputed recurrence coefficients for all `(l, m)` with `l <= L`.
128///
129/// Every array is sized `N = (L+1)^2` (the flat output size) rather than its exact
130/// need, because the exact sizes (`L+1`, triangular) are generic const expressions
131/// that stable Rust cannot spell in a type. The waste is compile-time data only.
132///
133/// Indexing: `qmm` and `em` by `m`, and `a`, `nb`, `f` by `tri(l) + m`.
134///
135/// Instantiated two ways. `ShTable<E, N>` over a scalar element is the compile-time
136/// form behind [`ShConsts`], read by the unrolled kernels. `ShTable<V, N>` over a
137/// _vector_ is the runtime form produced by [`sh_table_impl`] and consumed by
138/// [`sh_eval_impl`], which is what lifts the degree cap and makes the kernels work on
139/// element types that have no const table.
140///
141/// `#[repr(C)]` so that the two are layout-compatible when the element and vector
142/// types are (the scalar-math layer reinterprets `&mut ShTable<f32, N>` as
143/// `&mut ShTable<Vector<f32>, N>`). `repr(Rust)` gives no such guarantee across
144/// distinct type arguments.
145///
146/// `Clone` but deliberately not `Copy`: a table is `6 * N` elements (about 4.8 KB at
147/// `L = 4` on `f32x8`, 54 KB at `L = 16`), and implicit copies of that are not
148/// something to make easy. Pass it by reference. It is read-only after filling.
149#[repr(C)]
150#[derive(Clone, Debug)]
151pub struct ShTable<E, const N: usize> {
152    /// Diagonal values `q_m^m`, pure constants, since the `$\sin^m\theta$` that made
153    /// the diagonal `z`-dependent lives in the azimuthal recurrence instead.
154    pub qmm: [E; N],
155    /// First off-diagonal step: `q_{m+1}^m = em[m] * z * q_m^m`, `em[m] = sqrt(2m+3)`.
156    pub em: [E; N],
157    /// Three-term recurrence: `q_l^m = a * z * q_{l-1}^m + nb * q_{l-2}^m`.
158    pub a: [E; N],
159    /// The `b` coefficient, stored negated so the recurrence is a single `mul_adde`.
160    pub nb: [E; N],
161    /// `z`-derivative norm ratio: `d(q_l^m)/dz = f[tri(l)+m] * q_l^{m+1}` (zero at `m = l`).
162    pub f: [E; N],
163    /// `m` as a float, for the azimuthal derivative factor (`d c_m = m c_{m-1}` etc.).
164    pub mf: [E; N],
165}
166
167const fn build_f64<const L: usize, const N: usize, const CS: bool>() -> ShTable<f64, N> {
168    assert!(N == (L + 1) * (L + 1));
169
170    let mut t = ShTable {
171        qmm: [0.0; N],
172        em: [0.0; N],
173        a: [0.0; N],
174        nb: [0.0; N],
175        f: [0.0; N],
176        mf: [0.0; N],
177    };
178
179    let mut m = 0;
180    while m <= L {
181        t.mf[m] = m as f64;
182        m += 1;
183    }
184
185    // Diagonal: q_0^0 = Y_00 = sqrt(1/4pi), and each step multiplies by
186    // sqrt((2m+1)/(2m)), with one extra sqrt(2) at m = 1. That is the sqrt(2 - delta_{m0})
187    // of the real-harmonic normalization entering the recurrence exactly once.
188    t.qmm[0] = csqrt(1.0 / (4.0 * PI));
189
190    let mut m = 1;
191    while m <= L {
192        let mut d = csqrt((2 * m + 1) as f64 / (2 * m) as f64);
193        if m == 1 {
194            d *= csqrt(2.0);
195        }
196        t.qmm[m] = t.qmm[m - 1] * d;
197        m += 1;
198    }
199
200    let mut m = 0;
201    while m < L {
202        t.em[m] = csqrt((2 * m + 3) as f64);
203        m += 1;
204    }
205
206    let mut l = 1;
207    while l <= L {
208        let lf = l as f64;
209
210        let mut mm = 0;
211        while mm <= l {
212            let k = tri(l) + mm;
213            let mf = mm as f64;
214
215            if l >= mm + 2 {
216                // Holmes & Featherstone fully-normalized coefficients. The common
217                // sqrt(2 - delta) / 4pi prefactors cancel in the ratios, so these are
218                // identical for the m = 0 and m > 0 columns.
219                t.a[k] = csqrt(((2.0 * lf + 1.0) * (2.0 * lf - 1.0)) / ((lf - mf) * (lf + mf)));
220                t.nb[k] = -csqrt(
221                    ((2.0 * lf + 1.0) * (lf - mf - 1.0) * (lf + mf - 1.0)) / ((lf - mf) * (lf + mf) * (2.0 * lf - 3.0)),
222                );
223            }
224
225            // d(q_l^m)/dz = f * q_l^{m+1}: the norm ratio n_{l,m}/n_{l,m+1} applied to
226            // the classical dQ_l^m/dz = Q_l^{m+1}. The m = 0 column picks up a 1/sqrt(2)
227            // from sqrt(2 - delta_{m0}) changing between the columns.
228            t.f[k] = if mm == l {
229                0.0 // q_l^{l+1} = 0
230            } else if mm == 0 {
231                csqrt(lf * (lf + 1.0) / 2.0)
232            } else {
233                csqrt((lf - mf) * (lf + mf + 1.0))
234            };
235
236            mm += 1;
237        }
238
239        l += 1;
240    }
241
242    // --- Condon-Shortley phase, if requested: scale column m by (-1)^m ---
243    //
244    // Two touch-ups suffice, and the second is the one that is easy to miss.
245    //
246    // Every q_l^m in a column is generated from that column's diagonal seed by
247    // recurrences that are linear and homogeneous in it, so negating q_m^m negates
248    // the whole column. Since the +m and -m outputs share one q, both slots
249    // flip together, which is the (-1)^|m| the convention asks for.
250    //
251    // But `f` relates ADJACENT columns (d(q_l^m)/dz = f * q_l^{m+1}), whose signs
252    // now always disagree: the ratio (-1)^m / (-1)^{m+1} is -1 for every m. So the
253    // derivative ratios flip globally, independent of m's parity. Miss this and the
254    // values are right while every z-gradient carries the wrong sign.
255    if CS {
256        let mut m = 1;
257        while m <= L {
258            if m % 2 == 1 {
259                t.qmm[m] = -t.qmm[m];
260            }
261            m += 1;
262        }
263
264        let mut l = 0;
265        while l <= L {
266            let mut mm = 0;
267            // Strictly below the diagonal: f is an exact zero at mm == l, and
268            // negating that would only manufacture a -0.0.
269            while mm < l {
270                t.f[tri(l) + mm] = -t.f[tri(l) + mm];
271                mm += 1;
272            }
273            l += 1;
274        }
275    }
276
277    t
278}
279
280const fn arr_to_f32<const N: usize>(a: &[f64; N]) -> [f32; N] {
281    let mut o = [0.0f32; N];
282    let mut i = 0;
283    while i < N {
284        o[i] = a[i] as f32;
285        i += 1;
286    }
287    o
288}
289
290const fn build_f32<const L: usize, const N: usize, const CS: bool>() -> ShTable<f32, N> {
291    let t = build_f64::<L, N, CS>();
292
293    ShTable {
294        qmm: arr_to_f32(&t.qmm),
295        em: arr_to_f32(&t.em),
296        a: arr_to_f32(&t.a),
297        nb: arr_to_f32(&t.nb),
298        f: arr_to_f32(&t.f),
299        mf: arr_to_f32(&t.mf),
300    }
301}
302
303/// Compile-time spherical-harmonic coefficient tables for one element type, at one
304/// degree and one phase convention.
305///
306/// Follows the per-element const-table pattern of `thermite-complex`'s `Weideman`
307/// trait: the `f64` table is computed once in const eval and narrowed per element, so
308/// the kernels read plain constants and pay no runtime conversion. `CS` is baked into
309/// the table rather than applied at runtime, so the phase costs literally nothing.
310/// The two conventions differ only in which constants get emitted.
311pub trait ShConsts<const L: usize, const N: usize, const CS: bool>: FloatElement {
312    const TABLE: ShTable<Self, N>;
313}
314
315impl<const L: usize, const N: usize, const CS: bool> ShConsts<L, N, CS> for f64 {
316    const TABLE: ShTable<f64, N> = build_f64::<L, N, CS>();
317}
318
319impl<const L: usize, const N: usize, const CS: bool> ShConsts<L, N, CS> for f32 {
320    const TABLE: ShTable<f32, N> = build_f32::<L, N, CS>();
321}
322
323impl<V: FloatVector, const N: usize> ShTable<V, N> {
324    /// An all-zero table, to be filled by [`sh_table_impl`].
325    #[inline(always)]
326    pub fn zeroed() -> Self {
327        Self {
328            qmm: [V::ZERO; N],
329            em: [V::ZERO; N],
330            a: [V::ZERO; N],
331            nb: [V::ZERO; N],
332            f: [V::ZERO; N],
333            mf: [V::ZERO; N],
334        }
335    }
336
337    /// Lifts every entry into a composite `W` whose primal is `V`, via
338    /// [`from_primal`](PrimalProjection::from_primal), so constants with zeroed
339    /// augmentation. The identity copy when `W` is its own primal.
340    ///
341    /// Hand-rolled loops rather than `array::map`, which fails to inline in
342    /// `target_feature` code.
343    #[inline(always)]
344    pub fn lift<W>(&self) -> ShTable<W, N>
345    where
346        W: FloatVector + PrimalProjection<Primal = V>,
347    {
348        let mut out = ShTable::<W, N>::zeroed();
349        let mut i = 0;
350        while i < N {
351            out.qmm[i] = W::from_primal(self.qmm[i]);
352            out.em[i] = W::from_primal(self.em[i]);
353            out.a[i] = W::from_primal(self.a[i]);
354            out.nb[i] = W::from_primal(self.nb[i]);
355            out.f[i] = W::from_primal(self.f[i]);
356            out.mf[i] = W::from_primal(self.mf[i]);
357            i += 1;
358        }
359        out
360    }
361}
362
363/// Scalar-layer bridge for the table arguments, mirroring `thermite`'s `Unwrap for
364/// &mut [Vector<R>; N]`.
365///
366/// The `ScalarSpecialMath` aggregate runs the vector kernels at width 1, wrapping each
367/// argument on the way in. A table is a by-reference parameter, so it is reinterpreted
368/// in place rather than copied. This is sound because `Vector<R>` is
369/// `#[repr(transparent)]` over `Storage<R>`, `R: Register<Storage = R>` pins that to
370/// `R`, and [`ShTable`] is `#[repr(C)]` so the two instantiations agree on layout.
371impl<'a, R, const N: usize> Unwrap for &'a ShTable<Vector<R>, N>
372where
373    R: thermite::register::Register<Storage = R>,
374{
375    type Unwrapped = &'a ShTable<R, N>;
376
377    #[inline(always)]
378    fn wrap(value: Self::Unwrapped) -> Self {
379        // SAFETY: see the doc comment above (repr(transparent) + repr(C)).
380        unsafe { &*(value as *const ShTable<R, N> as *const ShTable<Vector<R>, N>) }
381    }
382
383    #[inline(always)]
384    fn unwrap(self) -> Self::Unwrapped {
385        // SAFETY: as in `wrap`.
386        unsafe { &*(self as *const ShTable<Vector<R>, N> as *const ShTable<R, N>) }
387    }
388}
389
390impl<'a, R, const N: usize> Unwrap for &'a mut ShTable<Vector<R>, N>
391where
392    R: thermite::register::Register<Storage = R>,
393{
394    type Unwrapped = &'a mut ShTable<R, N>;
395
396    #[inline(always)]
397    fn wrap(value: Self::Unwrapped) -> Self {
398        // SAFETY: see the doc comment above (repr(transparent) + repr(C)).
399        unsafe { &mut *(value as *mut ShTable<R, N> as *mut ShTable<Vector<R>, N>) }
400    }
401
402    #[inline(always)]
403    fn unwrap(self) -> Self::Unwrapped {
404        // SAFETY: as in `wrap`.
405        unsafe { &mut *(self as *mut ShTable<Vector<R>, N> as *mut ShTable<R, N>) }
406    }
407}
408
409// Index-safety invariant shared by everything below: `N == (L + 1)^2` is
410// const-asserted in both kernels, and for `m <= l <= L`
411//   flat:       l(l+1) + m  <=  L(L+1) + L  =  N - 1
412//   triangular: tri(l) + m  <=  L(L+3)/2    <   N
413//   column:     m <= L < N
414// so every access is in bounds by construction. The checked-indexing forms are not
415// used because their bounds checks defeat LLVM's unroller and scheduler (measured:
416// the whole kernel stayed rolled with panic paths at every store).
417
418/// Unchecked fixed-array read under the module's index invariant.
419#[inline(always)]
420fn at<T: Copy, const N: usize>(a: &[T; N], i: usize) -> T {
421    debug_assert!(i < N);
422    // SAFETY: see the index invariant above.
423    unsafe { *a.get_unchecked(i) }
424}
425
426/// Unchecked fixed-array write under the module's index invariant.
427#[inline(always)]
428fn put<T, const N: usize>(a: &mut [T; N], i: usize, v: T) {
429    debug_assert!(i < N);
430    // SAFETY: see the index invariant above.
431    unsafe {
432        *a.get_unchecked_mut(i) = v;
433    }
434}
435
436/// Writes `q * {c, s}` into the two `(l, +-m)` output slots.
437///
438/// `m = 0` stores `q` directly: `c_0 = 1` and the `-m` slot is the same slot.
439#[inline(always)]
440fn emit<V: FloatVector, const N: usize>(out: &mut [V; N], l: usize, m: usize, q: V, c: V, s: V) {
441    let base = l * (l + 1);
442
443    if m == 0 {
444        put(out, base, q);
445    } else {
446        put(out, base + m, q * c);
447        put(out, base - m, q * s);
448    }
449}
450
451/// Highest degree the stamped ladders below cover. Beyond it the kernels fall back to
452/// the rolled, runtime-coefficient path ([`sh_table_impl`] + [`sh_eval_impl`]), which
453/// is correct at any degree but roughly an order of magnitude slower. Extending the
454/// ladder is mechanical: append literals to every `0 1 2 ... 16` list.
455pub const MAX_DEGREE: usize = 16;
456
457// --- The literal ladders ---
458//
459// LLVM's unroller declines these triangular nests outright: measured on the loop
460// form, L = 4 and L = 8 produced near-identical fully-rolled code with runtime
461// l*(l+1) index arithmetic (`imul`/`shl`) and table loads through a register index,
462// even with all bounds checks elided. So the unrolling is done in the source (the
463// same guard-ladder fix the interleave engine uses): every (m, l) pair through
464// MAX_DEGREE is stamped with LITERAL indices behind `if <lit> <= L` guards. `L` is
465// a monomorphized constant, so dead pairs fold away and live indices become
466// compile-time constants: coefficient loads fold to `vbroadcast` from `.rodata`,
467// stores get fixed offsets, and no integer arithmetic survives to runtime.
468//
469// Hygiene note: the recurrence state (`q_prev`/`q_cur`, `c`/`s`) is threaded between
470// rules as `ident` arguments. Locals introduced in one expansion are invisible to
471// tokens written in another rule, but a captured ident keeps its context.
472
473/// Value-kernel ladder: z-recurrence columns fused with the `emit` sink.
474macro_rules! sh_value_columns {
475    ($L:ident, $V:ident, $t:ident, $x:ident, $y:ident, $z:ident, $out:ident) => {
476        let mut c = $V::ONE;
477        let mut s = $V::ZERO;
478        sh_value_columns!(@m $L, $V, $t, $x, $y, $z, c, s, $out;
479            0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16);
480    };
481    (@m $L:ident, $V:ident, $t:ident, $x:ident, $y:ident, $z:ident, $c:ident, $s:ident, $out:ident; $($mv:literal)*) => { $(
482        if $mv <= $L {
483            let q_diag = $V::splat(at(&$t.qmm, $mv));
484            emit($out, $mv, $mv, q_diag, $c, $s);
485
486            if $mv < $L {
487                let mut q_prev = q_diag;
488                let mut q_cur = ($z * q_prev) * $V::splat(at(&$t.em, $mv));
489                emit($out, $mv + 1, $mv, q_cur, $c, $s);
490
491                sh_value_columns!(@l $L, $V, $t, $z, $c, $s, $out, $mv, q_prev, q_cur;
492                    0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16);
493
494                // (c, s) *= (x + iy)
495                let c_next = $x.difference_of_products($c, $y, $s);
496                let s_next = $x.sum_of_products($s, $y, $c);
497                $c = c_next;
498                $s = s_next;
499            }
500        }
501    )* };
502    (@l $L:ident, $V:ident, $t:ident, $z:ident, $c:ident, $s:ident, $out:ident, $mv:literal, $qp:ident, $qc:ident; $($lv:literal)*) => { $(
503        if $lv >= $mv + 2 && $lv <= $L {
504            let q_next = ($z * $qc)
505                .mul_adde($V::splat(at(&$t.a, tri($lv) + $mv)), $qp * $V::splat(at(&$t.nb, tri($lv) + $mv)));
506            emit($out, $lv, $mv, q_next, $c, $s);
507            $qp = $qc;
508            $qc = q_next;
509        }
510    )* };
511}
512
513/// Scratch-fill ladder: the same z-recurrence with the triangular `q` array as the
514/// sink (pass 1 of [`sh_d_impl`]). Kept separate from [`sh_value_columns`] rather
515/// than parameterized by a sink callback, since the duplication is ~20 lines and the
516/// parameterized form costs far more in readability.
517macro_rules! sh_q_columns {
518    ($L:ident, $V:ident, $t:ident, $z:ident, $q:ident) => {
519        sh_q_columns!(@m $L, $V, $t, $z, $q; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16);
520    };
521    (@m $L:ident, $V:ident, $t:ident, $z:ident, $q:ident; $($mv:literal)*) => { $(
522        if $mv <= $L {
523            let q_diag = $V::splat(at(&$t.qmm, $mv));
524            put(&mut $q, tri($mv) + $mv, q_diag);
525
526            if $mv < $L {
527                let mut q_prev = q_diag;
528                let mut q_cur = ($z * q_prev) * $V::splat(at(&$t.em, $mv));
529                put(&mut $q, tri($mv + 1) + $mv, q_cur);
530
531                sh_q_columns!(@l $L, $V, $t, $z, $q, $mv, q_prev, q_cur;
532                    0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16);
533            }
534        }
535    )* };
536    (@l $L:ident, $V:ident, $t:ident, $z:ident, $q:ident, $mv:literal, $qp:ident, $qc:ident; $($lv:literal)*) => { $(
537        if $lv >= $mv + 2 && $lv <= $L {
538            let q_next = ($z * $qc)
539                .mul_adde($V::splat(at(&$t.a, tri($lv) + $mv)), $qp * $V::splat(at(&$t.nb, tri($lv) + $mv)));
540            put(&mut $q, tri($lv) + $mv, q_next);
541            $qp = $qc;
542            $qc = q_next;
543        }
544    )* };
545}
546
547/// Gradient-emission ladder (pass 2 of [`sh_d_impl`]): reads the `q` scratch, no
548/// recurrence state beyond the rolling `(c, s)` / `(cp, sp)` azimuthal window, so the
549/// inner rule is stateless.
550macro_rules! sh_grad_columns {
551    ($L:ident, $V:ident, $t:ident, $x:ident, $y:ident, $z:ident, $q:ident, $out:ident, $ddx:ident, $ddy:ident, $ddz:ident) => {
552        let mut c = $V::ONE;
553        let mut s = $V::ZERO;
554        let mut cp = $V::ZERO; // unused at m = 0 (the m factor is zero there)
555        let mut sp = $V::ZERO;
556        sh_grad_columns!(@m $L, $V, $t, $x, $y, $z, c, s, cp, sp, $q, $out, $ddx, $ddy, $ddz;
557            0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16);
558    };
559    (@m $L:ident, $V:ident, $t:ident, $x:ident, $y:ident, $z:ident, $c:ident, $s:ident, $cp:ident, $sp:ident, $q:ident, $out:ident, $ddx:ident, $ddy:ident, $ddz:ident; $($mv:literal)*) => { $(
560        if $mv <= $L {
561            let mv = $V::splat(at(&$t.mf, $mv));
562
563            sh_grad_columns!(@l $L, $V, $t, $c, $s, $cp, $sp, $q, $out, $ddx, $ddy, $ddz, $mv, mv;
564                0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16);
565
566            if $mv < $L {
567                let c_next = $x.difference_of_products($c, $y, $s);
568                let s_next = $x.sum_of_products($s, $y, $c);
569                $cp = $c;
570                $sp = $s;
571                $c = c_next;
572                $s = s_next;
573            }
574        }
575    )* };
576    (@l $L:ident, $V:ident, $t:ident, $c:ident, $s:ident, $cp:ident, $sp:ident, $q:ident, $out:ident, $ddx:ident, $ddy:ident, $ddz:ident, $mv:literal, $mfv:ident; $($lv:literal)*) => { $(
577        if $lv >= $mv && $lv <= $L {
578            let k = tri($lv) + $mv;
579            let base = $lv * ($lv + 1);
580
581            let qv = at(&$q, k);
582
583            // q_l^{m+1}, the z-derivative partner, zero on the diagonal.
584            let qn = if $mv == $lv { $V::ZERO } else { at(&$q, k + 1) };
585            let dq = $V::splat(at(&$t.f, k)) * qn;
586
587            if $mv == 0 {
588                // Y_{l,0} = q_l^0(z): no x/y dependence in the polynomial form.
589                put($out, base, qv);
590                put($ddx, base, $V::ZERO);
591                put($ddy, base, $V::ZERO);
592                put($ddz, base, dq);
593            } else {
594                put($out, base + $mv, qv * $c);
595                put($out, base - $mv, qv * $s);
596
597                // d{c,s}_m = m * {c,s}_{m-1} rotated: dc/dx = m c', dc/dy = -m s',
598                // ds/dx = m s', ds/dy = m c'.
599                let mq = $mfv * qv;
600                let mq_cp = mq * $cp;
601                let mq_sp = mq * $sp;
602
603                put($ddx, base + $mv, mq_cp);
604                put($ddy, base + $mv, -mq_sp);
605                put($ddx, base - $mv, mq_sp);
606                put($ddy, base - $mv, mq_cp);
607
608                put($ddz, base + $mv, dq * $c);
609                put($ddz, base - $mv, dq * $s);
610            }
611        }
612    )* };
613}
614
615/// All real spherical harmonics through degree `L` at the unit direction `(x, y, z)`.
616///
617/// `out[l * (l + 1) + m]` receives `$Y_{\ell m}$` for `m` in `-l..=l`, and `N` must
618/// equal `(L + 1)^2` (compile-time checked). See the module docs for
619/// conventions, the algorithm, and the unit-vector requirement.
620///
621/// The policy parameter is currently unused (the evaluation is pure polynomial
622/// arithmetic with one fixed, FMA-preferring lowering). It is accepted so the
623/// signature matches every sibling kernel and leaves room for policy-driven variants.
624// The `unused_comparisons`/`unused_assignments` allows are ladder artifacts: the
625// `0 <= L` guard of the first stamped column and the dead state hand-off of the last
626// stamped row are structurally unavoidable in machine-stamped straight-line code.
627#[allow(
628    clippy::extra_unused_type_parameters,
629    clippy::int_plus_one,
630    unused_comparisons,
631    unused_assignments
632)]
633#[inline(always)]
634pub fn sh_impl<P, E, V, const L: usize, const N: usize, const CS: bool>(x: V, y: V, z: V, out: &mut [V; N])
635where
636    P: Policy,
637    E: FloatElement + ShConsts<L, N, CS>,
638    V: FloatVector<Element = E> + CoreMath,
639{
640    const {
641        assert!(N == (L + 1) * (L + 1));
642    }
643
644    // Above the stamped ladder there is no unrolled code to run, so this delegates to
645    // the general path rather than silently leaving the high bands unwritten. Note the
646    // guard cannot live in the caller: rustc monomorphizes both arms of an `if const`
647    // whose condition involves a generic const parameter, so a cap assert here would
648    // fire from a statically-dead call site.
649    if const { L > MAX_DEGREE } {
650        let mut table = ShTable::<V, N>::zeroed();
651        sh_table_impl::<V, L, N, CS>(&mut table);
652        sh_eval_impl::<V, L, N>(&table, x, y, z, out);
653        return;
654    }
655
656    let t = &<E as ShConsts<L, N, CS>>::TABLE;
657
658    sh_value_columns!(L, V, t, x, y, z, out);
659}
660
661/// [`sh_impl`] plus the ambient Cartesian gradient of every harmonic.
662///
663/// `out` receives the values exactly as [`sh_impl`] produces them, and `ddx`/`ddy`/`ddz`
664/// receive `$\partial Y_{\ell m}/\partial\{x,y,z\}$` of the polynomial form at the
665/// given (unit) input. See the module docs for what that means off the sphere and
666/// how to project to the tangential gradient.
667///
668/// Two passes over an internal `q` scratch: the pure `z`-recurrence first, then one
669/// combining sweep that emits values and all three derivatives from tabulated ratios,
670/// with no recurrences beyond those [`sh_impl`] already runs.
671// PERF: the [V; N] scratch is zero-initialized (O(N) stores) and lives on the stack,
672// ~4 KB at L = 10 / f32x8. Fine for a leaf. Revisit (MaybeUninit or caller scratch)
673// if profiles ever notice.
674#[allow(
675    clippy::extra_unused_type_parameters,
676    clippy::int_plus_one,
677    unused_comparisons,
678    unused_assignments
679)]
680#[inline(always)]
681pub fn sh_d_impl<P, E, V, const L: usize, const N: usize, const CS: bool>(
682    x: V,
683    y: V,
684    z: V,
685    out: &mut [V; N],
686    ddx: &mut [V; N],
687    ddy: &mut [V; N],
688    ddz: &mut [V; N],
689) where
690    P: Policy,
691    E: FloatElement + ShConsts<L, N, CS>,
692    V: FloatVector<Element = E> + CoreMath,
693{
694    const {
695        assert!(N == (L + 1) * (L + 1));
696    }
697
698    // See `sh_impl`: above the ladder, delegate to the general path.
699    if const { L > MAX_DEGREE } {
700        let mut table = ShTable::<V, N>::zeroed();
701        sh_table_impl::<V, L, N, CS>(&mut table);
702        sh_eval_d_impl::<V, L, N>(&table, x, y, z, out, ddx, ddy, ddz);
703        return;
704    }
705
706    let t = &<E as ShConsts<L, N, CS>>::TABLE;
707
708    // Pass 1: every q_l^m, by column, into triangular scratch.
709    let mut q = [V::ZERO; N];
710    sh_q_columns!(L, V, t, z, q);
711
712    // Pass 2: combine scratch, tabulated ratios, and the rolling azimuthal window
713    // ((c, s) at column m, (cp, sp) at column m - 1) into values and gradients.
714    sh_grad_columns!(L, V, t, x, y, z, q, out, ddx, ddy, ddz);
715}
716
717// --- The general path: runtime coefficients, rolled loops, any degree ---
718//
719// The same normalized recurrence as the unrolled kernels above, with the constants
720// computed rather than tabulated. That buys two things the const-table path cannot
721// offer: degrees beyond `MAX_DEGREE` (whose table would be `6 * (L+1)^2` entries of
722// rodata, roughly half a megabyte at `L = 100`), and element types that have no
723// `ShConsts` impl at all, which is every composite.
724//
725// Numerically this IS the fast path, not an approximation of it: identical
726// recurrence, identical `O(1)` intermediates, no overflow at any degree. Only the
727// provenance of the coefficients differs, so the two can be diffed directly.
728//
729// All three are `#[inline(always)]`, not `#[inline]`. That is rule zero, not a
730// preference: target features propagate into a callee only when it is inlined, so a
731// merely-`#[inline]` kernel that rustc declines to inline compiles at the base ISA. It
732// was measured doing exactly that: 1851 instructions of SSE2 with no FMA and not one
733// `ymm` register, called from an AVX2 caller.
734//
735// Everything here needs nothing beyond `FloatVector` (add, mul, div, sqrt). Notably
736// `l` and `m` are carried as running `V` values incremented by `V::ONE` rather than
737// converted from integers, which keeps even the element-conversion traits out of the
738// bounds. Integer values this small are exact in any float format.
739
740/// Computes the recurrence coefficients for degree `L` into a runtime table.
741///
742/// The expensive half of the general path (two `sqrt` and two divisions per `(l, m)`),
743/// and the reason it is a separate entry point: it depends only on `L` and `CS`, never
744/// on the direction, so a caller evaluating many directions computes it once.
745///
746/// `CS` is baked in here, which is why [`sh_eval_impl`] does not take it. A filled
747/// table already knows its phase convention.
748#[allow(clippy::extra_unused_type_parameters)]
749#[inline(always)]
750pub fn sh_table_impl<V, const L: usize, const N: usize, const CS: bool>(t: &mut ShTable<V, N>)
751where
752    V: FloatVector,
753{
754    const {
755        assert!(N == (L + 1) * (L + 1));
756    }
757
758    let two = V::TWO;
759
760    // q_0^0 = sqrt(1/4pi) = (1/sqrt(pi)) / 2, exactly what the fast table seeds with.
761    let mut mag = V::FRAC_1_SQRT_PI / two;
762    put(&mut t.qmm, 0, mag);
763
764    // Diagonal: multiply by sqrt((2m+1)/(2m)) per step, with one extra sqrt(2) at
765    // m = 1 (the sqrt(2 - delta_{m0}) of the real normalization, entering once).
766    //
767    // The Condon-Shortley sign is applied to each entry as it is stored, and the
768    // recurrence is carried in `mag`, which stays unsigned. Feeding a SIGNED entry
769    // back into the next step instead would compound the phases: column m would come
770    // out with (-1)^(number of odd columns below it) rather than (-1)^m, which is
771    // right for odd m and wrong for even m.
772    let mut mv = V::ZERO;
773    let mut m = 1;
774    while m <= L {
775        mv += V::ONE;
776        let two_m = mv * two;
777        let mut d = ((two_m + V::ONE) / two_m).sqrt();
778        if m == 1 {
779            d *= two.sqrt();
780        }
781        mag *= d;
782        put(&mut t.qmm, m, if CS && m % 2 == 1 { -mag } else { mag });
783        m += 1;
784    }
785
786    let mut mv = V::ZERO;
787    let mut m = 0;
788    while m <= L {
789        put(&mut t.mf, m, mv);
790
791        if m < L {
792            put(&mut t.em, m, (mv * two + V::ONE + two).sqrt());
793        }
794
795        let mut lv = mv;
796        let mut l = m;
797        while l <= L {
798            let k = tri(l) + m;
799
800            let lm_lo = lv - mv; // l - m
801            let lm_hi = lv + mv; // l + m
802            let denom = lm_lo * lm_hi;
803            let two_l = lv * two;
804
805            if l >= m + 2 {
806                put(&mut t.a, k, ((two_l + V::ONE) * (two_l - V::ONE) / denom).sqrt());
807                put(
808                    &mut t.nb,
809                    k,
810                    -(((two_l + V::ONE) * (lm_lo - V::ONE) * (lm_hi - V::ONE)) / (denom * (two_l - two - V::ONE)))
811                        .sqrt(),
812                );
813            }
814
815            // d(q_l^m)/dz = f * q_l^{m+1}. Zero on the diagonal, and the m = 0 column
816            // picks up a 1/sqrt(2) because sqrt(2 - delta_{m0}) differs between the
817            // two columns the ratio spans.
818            if l > m {
819                let fv = if m == 0 {
820                    (lv * (lv + V::ONE) / two).sqrt()
821                } else {
822                    (lm_lo * (lm_hi + V::ONE)).sqrt()
823                };
824
825                // The ratio crosses columns m and m + 1, whose Condon-Shortley signs
826                // always disagree, so under CS every f flips regardless of parity.
827                put(&mut t.f, k, if CS { -fv } else { fv });
828            }
829
830            lv += V::ONE;
831            l += 1;
832        }
833
834        mv += V::ONE;
835        m += 1;
836    }
837}
838
839/// Evaluates all harmonics through degree `L` from a table filled by [`sh_table_impl`].
840///
841/// The rolled counterpart of [`sh_impl`], for any `L` and any `V`. The phase convention
842/// comes from the table, so there is no `CS` parameter here.
843#[allow(clippy::extra_unused_type_parameters)]
844#[inline(always)]
845pub fn sh_eval_impl<V, const L: usize, const N: usize>(t: &ShTable<V, N>, x: V, y: V, z: V, out: &mut [V; N])
846where
847    V: FloatVector + CoreMath,
848{
849    const {
850        assert!(N == (L + 1) * (L + 1));
851    }
852
853    let mut c = V::ONE;
854    let mut s = V::ZERO;
855
856    let mut m = 0;
857    while m <= L {
858        let q_diag = at(&t.qmm, m);
859        emit(out, m, m, q_diag, c, s);
860
861        if m < L {
862            let mut q_prev = q_diag;
863            let mut q_cur = (z * q_prev) * at(&t.em, m);
864            emit(out, m + 1, m, q_cur, c, s);
865
866            let mut l = m + 2;
867            while l <= L {
868                let k = tri(l) + m;
869                let q_next = (z * q_cur).mul_adde(at(&t.a, k), q_prev * at(&t.nb, k));
870                emit(out, l, m, q_next, c, s);
871
872                q_prev = q_cur;
873                q_cur = q_next;
874                l += 1;
875            }
876
877            let c_next = x.difference_of_products(c, y, s);
878            let s_next = x.sum_of_products(s, y, c);
879            c = c_next;
880            s = s_next;
881        }
882
883        m += 1;
884    }
885}
886
887/// [`sh_eval_impl`] over a table stored in `W`'s _primal_ type, each coefficient
888/// lifted through [`from_primal`](PrimalProjection::from_primal) as it is read.
889///
890/// The generic fallback behind `spherical_harmonics_with` now that tables are
891/// `Self::Primal`-typed. For a type that is its own primal (`Vector`, `Compensated`)
892/// the lift is the identity and this folds to exactly [`sh_eval_impl`], FMAs
893/// included. A composite gets correct-but-unspecialized code (its constants carry
894/// zeroed augmentation through full composite multiplies), which is why `Dual`
895/// overrides the method with [`sh_eval_mixed_impl`] instead.
896#[allow(clippy::extra_unused_type_parameters)]
897#[inline(always)]
898pub fn sh_eval_lifted_impl<W, const L: usize, const N: usize>(
899    t: &ShTable<W::Primal, N>,
900    x: W,
901    y: W,
902    z: W,
903    out: &mut [W; N],
904) where
905    W: FloatVector + PrimalProjection + CoreMath,
906{
907    const {
908        assert!(N == (L + 1) * (L + 1));
909    }
910
911    let mut c = W::ONE;
912    let mut s = W::ZERO;
913
914    let mut m = 0;
915    while m <= L {
916        let q_diag = W::from_primal(at(&t.qmm, m));
917        emit(out, m, m, q_diag, c, s);
918
919        if m < L {
920            let mut q_prev = q_diag;
921            let mut q_cur = (z * q_prev) * W::from_primal(at(&t.em, m));
922            emit(out, m + 1, m, q_cur, c, s);
923
924            let mut l = m + 2;
925            while l <= L {
926                let k = tri(l) + m;
927                let q_next = (z * q_cur).mul_adde(W::from_primal(at(&t.a, k)), q_prev * W::from_primal(at(&t.nb, k)));
928                emit(out, l, m, q_next, c, s);
929
930                q_prev = q_cur;
931                q_cur = q_next;
932                l += 1;
933            }
934
935            let c_next = x.difference_of_products(c, y, s);
936            let s_next = x.sum_of_products(s, y, c);
937            c = c_next;
938            s = s_next;
939        }
940
941        m += 1;
942    }
943}
944
945/// [`sh_eval_impl`] with the coefficients kept in a _different_, simpler type than the
946/// values.
947///
948/// The case this exists for is a composite `W` (a `Dual`, say) evaluated against a
949/// table of plain real coefficients. Every recurrence constant has a zero derivative,
950/// so carrying it as a `Dual` means computing `a.re * 0.0` cross terms for each one,
951/// which LLVM cannot fold away under strict IEEE (`a.re` could be an infinity or a
952/// NaN). Typing the table by `R`'s primal instead turns each of those into
953/// `Dual * real`, which `thermite-dual` implements as `1 + N` multiplies rather than
954/// `1 + 2N`.
955///
956/// It also shrinks the table itself, which is the larger saving in practice: a
957/// `ShTable<Dual<V, 3>, 25>` is 600 vector stores to fill, against 150 for
958/// `ShTable<V, 25>`, and the general path fills one per call.
959///
960/// Deliberately _not_ a generalization of [`sh_eval_impl`]. The single-type version
961/// folds its recurrence into `mul_adde`, and no fused multiply-add spans two operand
962/// types, so merging them would cost the real path its FMAs to benefit the composite
963/// one. The duplicated body is about twenty lines and neither copy has to compromise.
964#[allow(clippy::extra_unused_type_parameters)]
965#[inline(always)]
966pub fn sh_eval_mixed_impl<W, R, const L: usize, const N: usize>(
967    t: &ShTable<R::Primal, N>,
968    x: W,
969    y: W,
970    z: W,
971    out: &mut [W; N],
972) where
973    W: FloatVector + core::ops::Mul<R, Output = W> + CoreMath,
974    R: PrimalProjection,
975{
976    const {
977        assert!(N == (L + 1) * (L + 1));
978    }
979
980    let mut c = W::ONE;
981    let mut s = W::ZERO;
982
983    let mut m = 0;
984    while m <= L {
985        // The table is `R::Primal`-typed. `R::from_primal` lifts an entry to `R` (the
986        // identity for a plain real `R`), and `W::ONE * r` lifts that into `W` without
987        // needing a conversion trait: for a real `W` it is the identity LLVM folds
988        // away, and for a `Dual` it produces the constant with a zero derivative
989        // directly.
990        let q_diag = W::ONE * R::from_primal(at(&t.qmm, m));
991        emit(out, m, m, q_diag, c, s);
992
993        if m < L {
994            let mut q_prev = q_diag;
995            let mut q_cur = (z * q_prev) * R::from_primal(at(&t.em, m));
996            emit(out, m + 1, m, q_cur, c, s);
997
998            let mut l = m + 2;
999            while l <= L {
1000                let k = tri(l) + m;
1001                let q_next = (z * q_cur) * R::from_primal(at(&t.a, k)) + q_prev * R::from_primal(at(&t.nb, k));
1002                emit(out, l, m, q_next, c, s);
1003
1004                q_prev = q_cur;
1005                q_cur = q_next;
1006                l += 1;
1007            }
1008
1009            let c_next = x.difference_of_products(c, y, s);
1010            let s_next = x.sum_of_products(s, y, c);
1011            c = c_next;
1012            s = s_next;
1013        }
1014
1015        m += 1;
1016    }
1017}
1018
1019/// [`sh_eval_impl`] plus the ambient Cartesian gradients. The rolled counterpart of
1020/// [`sh_d_impl`], with the same two-pass structure and gradient semantics.
1021#[allow(clippy::extra_unused_type_parameters, clippy::too_many_arguments)]
1022#[inline(always)]
1023pub fn sh_eval_d_impl<V, const L: usize, const N: usize>(
1024    t: &ShTable<V, N>,
1025    x: V,
1026    y: V,
1027    z: V,
1028    out: &mut [V; N],
1029    ddx: &mut [V; N],
1030    ddy: &mut [V; N],
1031    ddz: &mut [V; N],
1032) where
1033    V: FloatVector + CoreMath,
1034{
1035    const {
1036        assert!(N == (L + 1) * (L + 1));
1037    }
1038
1039    // Pass 1: the z-recurrence into triangular scratch.
1040    let mut q = [V::ZERO; N];
1041
1042    let mut m = 0;
1043    while m <= L {
1044        let q_diag = at(&t.qmm, m);
1045        put(&mut q, tri(m) + m, q_diag);
1046
1047        if m < L {
1048            let mut q_prev = q_diag;
1049            let mut q_cur = (z * q_prev) * at(&t.em, m);
1050            put(&mut q, tri(m + 1) + m, q_cur);
1051
1052            let mut l = m + 2;
1053            while l <= L {
1054                let k = tri(l) + m;
1055                let q_next = (z * q_cur).mul_adde(at(&t.a, k), q_prev * at(&t.nb, k));
1056                put(&mut q, k, q_next);
1057
1058                q_prev = q_cur;
1059                q_cur = q_next;
1060                l += 1;
1061            }
1062        }
1063
1064        m += 1;
1065    }
1066
1067    // Pass 2: values and gradients from the scratch and the tabulated ratios.
1068    let mut c = V::ONE;
1069    let mut s = V::ZERO;
1070    let mut cp = V::ZERO;
1071    let mut sp = V::ZERO;
1072
1073    let mut m = 0;
1074    while m <= L {
1075        let mv = at(&t.mf, m);
1076
1077        let mut l = m;
1078        while l <= L {
1079            let k = tri(l) + m;
1080            let base = l * (l + 1);
1081
1082            let qv = at(&q, k);
1083            let qn = if m == l { V::ZERO } else { at(&q, k + 1) };
1084            let dq = at(&t.f, k) * qn;
1085
1086            if m == 0 {
1087                put(out, base, qv);
1088                put(ddx, base, V::ZERO);
1089                put(ddy, base, V::ZERO);
1090                put(ddz, base, dq);
1091            } else {
1092                put(out, base + m, qv * c);
1093                put(out, base - m, qv * s);
1094
1095                let mq = mv * qv;
1096                let mq_cp = mq * cp;
1097                let mq_sp = mq * sp;
1098
1099                put(ddx, base + m, mq_cp);
1100                put(ddy, base + m, -mq_sp);
1101                put(ddx, base - m, mq_sp);
1102                put(ddy, base - m, mq_cp);
1103
1104                put(ddz, base + m, dq * c);
1105                put(ddz, base - m, dq * s);
1106            }
1107
1108            l += 1;
1109        }
1110
1111        if m < L {
1112            let c_next = x.difference_of_products(c, y, s);
1113            let s_next = x.sum_of_products(s, y, c);
1114            cp = c;
1115            sp = s;
1116            c = c_next;
1117            s = s_next;
1118        }
1119
1120        m += 1;
1121    }
1122}
Last built: 2026-09-08 21:35:55 UTC