Skip to main content

gam_terms/basis/
duchon_psi_derivatives.rs

1use super::*;
2
3/// Process-wide counter of full `n×k` Duchon kernel-design materializations
4/// performed by [`build_duchon_basis_designwithworkspace`]. Each increment is a
5/// full kernel evaluation over every (data-row, center) pair — the dominant
6/// cold-build cost. It exists so regression tests can pin STRUCTURALLY that the
7/// default `duchon(x, z)` cold build materializes the design ONCE, not twice
8/// (the #1718 redundant second kernel pass); it never affects the numeric
9/// result.
10pub(crate) static DUCHON_DESIGN_BUILD_COUNT: std::sync::atomic::AtomicUsize =
11    std::sync::atomic::AtomicUsize::new(0);
12
13pub(crate) fn duchon_coeff_exponents(p_order: usize, s_order: usize, m_or_n: usize) -> f64 {
14    // In the partial fractions
15    //   1 / (z^p (z + kappa^2)^s)
16    // = Σ a_m(kappa) / z^m + Σ b_n(kappa) / (z + kappa^2)^n,
17    // both a_m and b_n are pure powers of kappa:
18    //   c(kappa) = C * kappa^{-2(p+s-index)}.
19    // With psi = log(kappa), that gives c_psi = alpha c and
20    // c_psipsi = alpha^2 c with alpha below. This is the exact coefficient
21    // derivative rule from the Duchon spectral factorization.
22    -2.0 * (p_order + s_order - m_or_n) as f64
23}
24
25#[inline(always)]
26pub(crate) fn duchon_scaling_exponent(p_order: usize, s_order: usize, k_dim: usize) -> f64 {
27    k_dim as f64 - 2.0 * (p_order + s_order) as f64
28}
29
30#[derive(Clone, Copy)]
31pub(crate) struct DuchonMaternDerivativeTerm {
32    pub(crate) coeff: f64,
33    pub(crate) kappa_power: usize,
34    pub(crate) r_power: f64,
35    pub(crate) bessel_order: f64,
36}
37
38#[derive(Clone, Copy, Debug, Default)]
39pub(crate) struct DuchonRadialJets {
40    pub(crate) phi: f64,
41    pub(crate) phi_r: f64,
42    pub(crate) phi_rr: f64,
43    pub(crate) phi_rrr: f64,
44    pub(crate) q: f64,
45    pub(crate) q_r: f64,
46    pub(crate) q_rr: f64,
47    pub(crate) lap: f64,
48    pub(crate) lap_r: f64,
49    pub(crate) lap_rr: f64,
50    /// R-operator radial scalar: t = R²φ = (φ'' - q) / r² = q' / r.
51    /// At collision (r = 0): t = φ''''(0) / 3, computed via assembled
52    /// fourth-derivative collision limits of the partial-fraction blocks.
53    pub(crate) t: f64,
54    /// First radial derivative of t:
55    ///   t_r = dt/dr = (q_rr - t) / r  for r > 0.
56    /// At collision, the exact radial limit is t_r(0) = 0.
57    pub(crate) t_r: f64,
58    /// Second radial derivative of t:
59    ///   t_rr = d²t/dr² = [lap_rr + 2 t - (d + 4) q_rr] / r²  for r > 0,
60    /// using Delta phi = d q + r² t.
61    ///
62    /// At collision, the exact radial limit is
63    ///   t_rr(0) = φ⁽⁶⁾(0) / 15.
64    pub(crate) t_rr: f64,
65}
66
67#[derive(Clone, Copy, Debug, Default)]
68pub(crate) struct DuchonRegularizedOperatorCore {
69    pub(crate) q: f64,
70    pub(crate) t: f64,
71    pub(crate) t_r: f64,
72    pub(crate) t_rr: f64,
73}
74
75#[inline(always)]
76pub(crate) fn duchon_operator_jets_from_primary_core(
77    core: DuchonRegularizedOperatorCore,
78    r: f64,
79    d: f64,
80) -> DuchonRadialJets {
81    let r2 = r * r;
82    let mut out = DuchonRadialJets {
83        q: core.q,
84        t: core.t,
85        t_r: core.t_r,
86        t_rr: core.t_rr,
87        ..DuchonRadialJets::default()
88    };
89    out.q_r = r * out.t;
90    out.q_rr = out.t + r * out.t_r;
91    out.lap = d * out.q + r2 * out.t;
92    out.lap_r = (d + 2.0) * r * out.t + r2 * out.t_r;
93    out.lap_rr = (d + 2.0) * out.t + (d + 4.0) * r * out.t_r + r2 * out.t_rr;
94    out.phi_r = r * out.q;
95    out.phi_rr = out.q + r2 * out.t;
96    out.phi_rrr = 3.0 * r * out.t + r2 * out.t_r;
97
98    assert!(
99        ((out.phi_rr - (out.q + r * out.q_r)).abs()) <= 1e-10 * out.phi_rr.abs().max(1.0),
100        "radial scalar identity failed: phi_rr != q + r*q_r, phi_rr={}, q={}, r={}, q_r={}",
101        out.phi_rr,
102        out.q,
103        r,
104        out.q_r
105    );
106    assert!(
107        ((out.phi_rr - (out.q + r2 * out.t)).abs()) <= 1e-10 * out.phi_rr.abs().max(1.0),
108        "radial scalar identity failed: phi_rr != q + r2*t, phi_rr={}, q={}, r2={}, t={}",
109        out.phi_rr,
110        out.q,
111        r2,
112        out.t
113    );
114    assert!(
115        ((out.lap - (d * out.q + r2 * out.t)).abs()) <= 1e-10 * out.lap.abs().max(1.0),
116        "radial scalar identity failed: lap != d*q + r2*t, lap={}, d={}, q={}, r2={}, t={}",
117        out.lap,
118        d,
119        out.q,
120        r2,
121        out.t
122    );
123
124    out
125}
126
127#[inline(always)]
128pub(crate) fn scaled_log_kappa_derivatives(
129    value: f64,
130    radial_first: f64,
131    radialsecond: f64,
132    exponent: f64,
133    r: f64,
134) -> (f64, f64) {
135    // Scaling-law differentiation template
136    // For any radial quantity of the form
137    //   F(r; kappa) = kappa^a G(kappa r),
138    // with psi = log(kappa), one has d/dpsi = kappa d/dkappa.
139    //
140    // Writing t = kappa r,
141    //   F_psi
142    //   = kappa d/dkappa [kappa^a G(t)]
143    //   = a kappa^a G(t) + kappa^a (kappa r) G'(t)
144    //   = a F + r F_r.
145    //
146    // Differentiating again,
147    //   F_psipsi
148    //   = d/dpsi [a F + r F_r]
149    //   = a F_psi + r (F_r)_psi
150    //   = a (a F + r F_r) + r d/dr(F_psi)
151    //   = a^2 F + (2a + 1) r F_r + r^2 F_rr.
152    //
153    // This helper is the common exact formula used for:
154    //   - phi            with exponent delta
155    //   - q = phi_r / r  with exponent delta + 2
156    //   - Delta phi      with exponent delta + 2.
157    let first = exponent * value + r * radial_first;
158    let second = exponent * exponent * value
159        + (2.0 * exponent + 1.0) * r * radial_first
160        + r * r * radialsecond;
161    (first, second)
162}
163
164/// Exact `psi = log(kappa)` value jet of the low-dimensional hybrid Duchon
165/// representative that [`duchon_matern_kernel_general_from_distance`] ships.
166///
167/// The usual scaling identity
168///
169/// `K_psi = delta K + r K_r`, `delta = d - 2(p+s)`,
170///
171/// is exact for the cancellation-free integral representation (`2p < d`).
172/// On the complementary partial-fraction route, however, the Riesz blocks in
173/// even dimension contain `r^(2m-d) log(r)`.  Rescaling such a block adds a
174/// polynomial in `r`; the conditionally-positive-definite kernel is equivalent
175/// as a function space, but its *shipped coefficient representative* is not
176/// strictly homogeneous.  Differentiating the scaling identity there therefore
177/// differentiates a different design (gam#979's 2-D identity-chart control).
178///
179/// This routine differentiates the actual partial-fraction sum instead.  Riesz
180/// blocks move only through their coefficient powers.  For a Matérn block
181/// `M_n = F^-1[(kappa^2 + |omega|^2)^-n]`, differentiation under the spectrum
182/// gives the exact recurrence
183///
184/// `M_n,psi = -2 n kappa^2 M_(n+1)`.
185///
186/// The second derivative follows by applying the same identity again.  At the
187/// collision we use the same analytic Taylor coefficients as the forward value,
188/// so divergent partial-fraction terms are never evaluated separately.
189pub(crate) fn duchon_partial_fraction_kernel_psi_triplet(
190    r: f64,
191    length_scale: f64,
192    p_order: usize,
193    s_order: usize,
194    k_dim: usize,
195    coeffs: &DuchonPartialFractionCoeffs,
196) -> Result<(f64, f64, f64), BasisError> {
197    assert!(
198        !duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
199        "partial-fraction psi jet called for the stable-integral Duchon route"
200    );
201    let smoothness_order = 2 * (p_order + s_order);
202    let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale.max(1e-8);
203    if r <= collision_taylor_radius && smoothness_order > k_dim {
204        let r2 = r * r;
205        let mut value = 0.0_f64;
206        let mut first = 0.0_f64;
207        let mut second = 0.0_f64;
208        let mut r_power = 1.0_f64;
209        for j in 0..=3 {
210            if smoothness_order <= k_dim + 2 * j {
211                break;
212            }
213            let (derivative, derivative_first, derivative_second) =
214                duchon_phi_even_derivative_collision_psi_triplet(
215                    length_scale,
216                    p_order,
217                    s_order,
218                    k_dim,
219                    coeffs,
220                    j,
221                )?;
222            let factorial = gamma_lanczos((2 * j + 1) as f64);
223            let weight = r_power / factorial;
224            value += weight * derivative;
225            first += weight * derivative_first;
226            second += weight * derivative_second;
227            r_power *= r2;
228        }
229        return Ok((value, first, second));
230    }
231
232    let kappa = duchon_inverse_length_scale(length_scale, "Duchon partial-fraction ψ-triplet")?;
233    let kappa2 = kappa * kappa;
234    let mut value = KahanSum::default();
235    let mut first = KahanSum::default();
236    let mut second = KahanSum::default();
237    for (m, &coefficient) in coeffs.a.iter().enumerate().skip(1) {
238        if coefficient == 0.0 {
239            continue;
240        }
241        let block = polyharmonic_kernel(r, m as f64, k_dim);
242        let exponent = duchon_coeff_exponents(p_order, s_order, m);
243        value.add(coefficient * block);
244        first.add(exponent * coefficient * block);
245        second.add(exponent * exponent * coefficient * block);
246    }
247    for (n, &coefficient) in coeffs.b.iter().enumerate().skip(1) {
248        if coefficient == 0.0 {
249            continue;
250        }
251        let block = duchon_matern_block(r, kappa, n, k_dim)?;
252        let next = duchon_matern_block(r, kappa, n + 1, k_dim)?;
253        let next_next = duchon_matern_block(r, kappa, n + 2, k_dim)?;
254        let block_first = -2.0 * n as f64 * kappa2 * next;
255        let block_second = -4.0 * n as f64 * kappa2 * next
256            + 4.0 * n as f64 * (n + 1) as f64 * kappa2 * kappa2 * next_next;
257        let exponent = duchon_coeff_exponents(p_order, s_order, n);
258        value.add(coefficient * block);
259        first.add(coefficient * (exponent * block + block_first));
260        second.add(
261            coefficient
262                * (exponent * exponent * block
263                    + 2.0 * exponent * block_first
264                    + block_second),
265        );
266    }
267    let triplet = (value.sum(), first.sum(), second.sum());
268    if !(triplet.0.is_finite() && triplet.1.is_finite() && triplet.2.is_finite()) {
269        crate::bail_invalid_basis!(
270            "non-finite Duchon partial-fraction psi jet at r={r}, length_scale={length_scale}, p={p_order}, s={s_order}, dim={k_dim}"
271        );
272    }
273    Ok(triplet)
274}
275
276/// The outer coordinate a Duchon ψ-derivative differentiates (gam#2735).
277///
278/// The anisotropic Duchon metric is `u² = Σ_a exp(2 ψ_a) h_a²`, and the ψ
279/// coordinates the outer REML solve owns are the **raw** `ψ_a`: each one
280/// decodes simultaneously into the global scale `κ = exp(mean ψ)` and the
281/// centered contrast `η_a = ψ_a − mean ψ`, so
282///
283/// ```text
284///     ∂ log κ / ∂ψ_a = 1/d           ∂η_b / ∂ψ_a = δ_ab − 1/d
285/// ```
286///
287/// `Global` is the all-ones direction of that frame — moving every `ψ_a` by the
288/// same amount leaves every contrast fixed and multiplies `κ`. That is not a
289/// convention, it is an identity, and it is what
290/// [`duchon_axis_log_kappa_derivatives`] reproduces by construction:
291/// summing its first derivative over `a`, and its second over `(a, b)`, gives
292/// `scaled_log_kappa_derivatives` exactly. The isotropic route is therefore a
293/// contraction of the anisotropic one rather than a parallel derivation that
294/// could drift from it.
295#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub enum DuchonPsiDirection {
297    /// `ψ = log κ`. Every contrast — and therefore every metric weight
298    /// `w_a = exp(2η_a)` appearing explicitly in the operator-penalty blocks —
299    /// is constant along this direction.
300    Global,
301    /// The raw per-axis coordinate `ψ_a`.
302    Axis(usize),
303}
304
305/// Per-axis ψ derivatives of a radial scalar `F(r; κ) = κ^E G(κ r)`.
306///
307/// `axis_share[a] = s_a / r²` where `s_a = exp(2 η_a) h_a²` is the per-axis
308/// weighted squared displacement produced by `aniso_distance_and_components`;
309/// the shares are non-negative and sum to one. Writing
310///
311/// ```text
312///     A = r F_r                 B = r² F_rr − r F_r                 c = E/d
313/// ```
314///
315/// the exact chain rule through `(κ, η)` collapses to
316///
317/// ```text
318///     ∂F/∂ψ_a        = c F + A σ_a
319///     ∂²F/∂ψ_a∂ψ_b   = B σ_a σ_b + c A (σ_a + σ_b) + 2 A σ_a δ_ab + c² F
320/// ```
321///
322/// `A` and `B` are exactly the two combinations `scaled_log_kappa_derivatives`
323/// already forms, so the per-axis jet needs no radial quantity the isotropic
324/// jet does not, and — crucially — it is finite at collision: `σ` is bounded by
325/// 1 and both `A` and `B` vanish with `r`, so no `1/r` ever appears.
326///
327/// Contracting over the all-ones direction returns the isotropic jet:
328/// `Σ_a first = E F + r F_r` and `Σ_{a,b} second = E² F + (2E+1) r F_r + r² F_rr`.
329#[inline(always)]
330pub fn duchon_axis_log_kappa_derivatives(
331    value: f64,
332    radial_first: f64,
333    radialsecond: f64,
334    exponent: f64,
335    r: f64,
336    dim: usize,
337    axis_share: f64,
338    second_axis_share: f64,
339    same_axis: bool,
340) -> (f64, f64) {
341    let a_term = r * radial_first;
342    let b_term = r * r * radialsecond - a_term;
343    let c = exponent / dim.max(1) as f64;
344    let first = c * value + a_term * axis_share;
345    let second = b_term * axis_share * second_axis_share
346        + c * a_term * (axis_share + second_axis_share)
347        + if same_axis {
348            2.0 * a_term * axis_share
349        } else {
350            0.0
351        }
352        + c * c * value;
353    (first, second)
354}
355
356/// First and second ψ derivatives of a radial scalar along `direction`.
357///
358/// The single dispatch point between the isotropic and per-axis routes: every
359/// Duchon penalty assembly consumes this and nothing else, so a route can only
360/// differ from another by its `direction`.
361#[inline(always)]
362pub fn duchon_direction_derivatives(
363    direction: DuchonPsiDirection,
364    value: f64,
365    radial_first: f64,
366    radialsecond: f64,
367    exponent: f64,
368    r: f64,
369    dim: usize,
370    axis_shares: &[f64],
371) -> (f64, f64) {
372    match direction {
373        DuchonPsiDirection::Global => {
374            scaled_log_kappa_derivatives(value, radial_first, radialsecond, exponent, r)
375        }
376        DuchonPsiDirection::Axis(a) => {
377            let share = axis_shares.get(a).copied().unwrap_or(0.0);
378            duchon_axis_log_kappa_derivatives(
379                value,
380                radial_first,
381                radialsecond,
382                exponent,
383                r,
384                dim,
385                share,
386                share,
387                true,
388            )
389        }
390    }
391}
392
393/// Normalized per-axis shares `σ_a = s_a / r²` of the anisotropic squared
394/// distance, with the symmetric convention `σ_a = 1/d` at collision.
395///
396/// At `r = 0` both `A = r F_r` and `B = r² F_rr − r F_r` vanish for every
397/// radial scalar the Duchon jets produce, so the share is multiplied by zero
398/// and the convention only has to keep `Σ_a σ_a = 1` — which is what makes the
399/// isotropic contraction identity hold at collision too.
400#[inline(always)]
401pub fn duchon_axis_shares(components: &[f64], r: f64) -> Vec<f64> {
402    let d = components.len().max(1);
403    let r2 = r * r;
404    if !(r2 > 0.0) || !r2.is_finite() {
405        return vec![1.0 / d as f64; components.len()];
406    }
407    components.iter().map(|&s| s / r2).collect()
408}
409
410#[inline(always)]
411pub(crate) fn duchon_q_psi_triplet_from_jets(
412    jets: &DuchonRadialJets,
413    p_order: usize,
414    s_order: usize,
415    k_dim: usize,
416    r: f64,
417) -> (f64, f64) {
418    scaled_log_kappa_derivatives(
419        jets.q,
420        jets.q_r,
421        jets.q_rr,
422        duchon_operator_scaling_exponent(p_order, s_order, k_dim),
423        r,
424    )
425}
426
427#[inline(always)]
428pub(crate) fn duchon_operator_scaling_exponent(
429    p_order: usize,
430    s_order: usize,
431    k_dim: usize,
432) -> f64 {
433    // For the hybrid Duchon spectrum
434    //   1 / (|w|^(2p) (kappa^2 + |w|^2)^s),
435    // the spatial kernel scales as
436    //   phi(r; kappa) = kappa^delta H(kappa r),
437    // where
438    //   delta = d - 2p - 2s.
439    //
440    // A first spatial derivative contributes one extra factor of kappa, so
441    // phi_r scales like kappa^(delta + 1). Dividing by r gives
442    //   q(r; kappa) = phi_r / r = kappa^(delta + 2) Q(kappa r).
443    //
444    // The Laplacian also contributes two spatial derivatives, so
445    //   Delta phi(r; kappa) = kappa^(delta + 2) L(kappa r).
446    //
447    // Thus both Duchon operator scalars use exponent delta + 2.
448    duchon_scaling_exponent(p_order, s_order, k_dim) + 2.0
449}
450
451pub(crate) fn duchon_regularized_operator_core(
452    r_eval: f64,
453    kappa: f64,
454    k_dim: usize,
455    coeffs: &DuchonPartialFractionCoeffs,
456) -> Result<DuchonRegularizedOperatorCore, BasisError> {
457    // Assemble the operator scalars with compensated summation because the
458    // partial-fraction coefficients can alternate in sign and span many orders
459    // of magnitude in higher dimensions.
460    let mut q_sum = KahanSum::default();
461    let mut t_sum = KahanSum::default();
462    let mut t_r_sum = KahanSum::default();
463    let mut t_rr_sum = KahanSum::default();
464
465    for (m, coeff) in coeffs.a.iter().enumerate().skip(1) {
466        if *coeff == 0.0 {
467            continue;
468        }
469        let (q, t, t_r, t_rr) = duchon_polyharmonic_operator_block_jets(r_eval, m, k_dim)?;
470        q_sum.add(coeff * q);
471        t_sum.add(coeff * t);
472        t_r_sum.add(coeff * t_r);
473        t_rr_sum.add(coeff * t_rr);
474    }
475    // One Bessel-K ladder at z = κ·r serves every Matérn block and every
476    // term of their derivative lattices (see [`BesselKLadder`]); the old
477    // per-term Bessel calls restarted the seed+recurrence hundreds of times
478    // per evaluation point.
479    let max_ladder_steps = coeffs
480        .b
481        .iter()
482        .enumerate()
483        .skip(1)
484        .filter(|(_, coeff)| **coeff != 0.0)
485        .map(|(n, _)| duchon_matern_block_max_ladder_steps(n, k_dim))
486        .max();
487    if let Some(max_ladder_steps) = max_ladder_steps {
488        let ladder =
489            BesselKLadder::build(kappa * r_eval, !k_dim.is_multiple_of(2), max_ladder_steps);
490        for (n, coeff) in coeffs.b.iter().enumerate().skip(1) {
491            if *coeff == 0.0 {
492                continue;
493            }
494            let (q, t, t_r, t_rr) =
495                duchon_matern_operator_block_jets_with_ladder(r_eval, kappa, n, k_dim, &ladder)?;
496            q_sum.add(coeff * q);
497            t_sum.add(coeff * t);
498            t_r_sum.add(coeff * t_r);
499            t_rr_sum.add(coeff * t_rr);
500        }
501    }
502    Ok(DuchonRegularizedOperatorCore {
503        q: q_sum.sum(),
504        t: t_sum.sum(),
505        t_r: t_r_sum.sum(),
506        t_rr: t_rr_sum.sum(),
507    })
508}
509
510#[inline(always)]
511pub(crate) fn duchon_collision_taylor_operator_core(
512    r: f64,
513    phi_rr_collision: f64,
514    t_collision: f64,
515    t_rr_collision: f64,
516) -> DuchonRegularizedOperatorCore {
517    let r2 = r * r;
518    let r4 = r2 * r2;
519    DuchonRegularizedOperatorCore {
520        q: phi_rr_collision + 0.5 * t_collision * r2 + 0.125 * t_rr_collision * r4,
521        t: t_collision + 0.5 * t_rr_collision * r2,
522        t_r: t_rr_collision * r,
523        t_rr: t_rr_collision,
524    }
525}
526
527pub(crate) fn duchon_radial_jets(
528    r: f64,
529    length_scale: f64,
530    p_order: usize,
531    s_order: usize,
532    k_dim: usize,
533    coeffs: &DuchonPartialFractionCoeffs,
534) -> Result<DuchonRadialJets, BasisError> {
535    let kappa = duchon_inverse_length_scale(length_scale, "Duchon radial jets")?;
536    // `length_scale` is finite and positive (the owner above refused otherwise),
537    // so the collision radii are fractions of the real scale, not of a floored
538    // one that would have substituted `1e-8` for a genuinely small length scale.
539    let r_floor = DUCHON_DERIVATIVE_R_FLOOR_REL * length_scale;
540    let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale;
541    let r_eval = r.max(r_floor);
542    let d = k_dim as f64;
543
544    // The value and the operator read the same shape at the same κ, so the
545    // certified profile and the κ-fixed prefactor are bound once for both
546    // rather than resolved twice at every pair of every ψ sweep.
547    let hybrid = duchon_hybrid_evaluator(Some(length_scale), p_order, s_order, k_dim)?;
548    // Value path keeps the intrinsic diagonal convention used by the actual basis.
549    let phi = match hybrid.as_ref() {
550        Some(hybrid) => hybrid.value(r)?,
551        None => duchon_matern_kernel_general_from_distance(
552            r,
553            Some(length_scale),
554            p_order,
555            s_order,
556            k_dim,
557            Some(coeffs),
558        )?,
559    };
560    if !phi.is_finite() {
561        crate::bail_invalid_basis!(
562            "non-finite Duchon radial kernel value at r={r}, length_scale={length_scale}, p={p_order}, s={s_order}, dim={k_dim}"
563        );
564    }
565
566    // Assemble the operator scalars. The partial-fraction operator core
567    //   q = Σ a_m q_m + Σ b_n q_n,  t = Σ … (`duchon_regularized_operator_core`)
568    // is a sign-alternating sum whose blocks individually scale like
569    // r^{2m-d}; in high dimensions each block is ~1e3 while the true operator
570    // scalar is ~1e-13, so f64 loses every digit (gam#1424 / gam#1453). For the
571    // genuine Matérn-blend orders, evaluate `(q, t, t_r, t_rr)` via the same
572    // cancellation-free single integral as the kernel value, differentiated
573    // under the integral sign — each w-slice is one well-conditioned
574    // r^a K_ν(c r) term with no cross-block cancellation. The complementary
575    // orders (s = 0 pure polyharmonic, or 2p ≥ d at low d) keep the direct
576    // partial-fraction core, which has no meaningful cancellation there.
577    let operator_core = match hybrid.as_ref() {
578        Some(hybrid) => hybrid.operator_core(r_eval)?,
579        None => duchon_regularized_operator_core(r_eval, kappa, k_dim, coeffs)?,
580    };
581    let generic_jets = duchon_operator_jets_from_primary_core(operator_core, r_eval, d);
582    let mut out = DuchonRadialJets {
583        phi,
584        ..generic_jets
585    };
586
587    // Smoothness check: the collision Taylor expansion requires analytic
588    // collision limits (t(0) = φ''''(0)/3, etc.) which only exist when the
589    // kernel is sufficiently smooth at the origin: 2(p+s) > d + 2j.
590    // For the borderline case (2(p+s) == d+4), φ''''(0) diverges
591    // logarithmically and the Taylor carrier cannot represent t(r) accurately.
592    // In that regime, keep the generic-path values at r_eval = r_floor.
593    let smoothness_order = 2 * (p_order + s_order);
594    let collision_q_exists = smoothness_order > k_dim + 2;
595    let collision_t_exists = smoothness_order > k_dim + 4;
596    let collision_t_rr_exists = smoothness_order > k_dim + 6;
597
598    if r <= collision_taylor_radius.max(r_floor) && collision_t_exists {
599        // Tier 2+: full collision Taylor expansion using φ''(0), φ''''(0)/3,
600        // and optionally φ⁽⁶⁾(0)/15.  Replaces the generic r_floor path for
601        // all radial scalars in the near-origin region.
602        let (analytic_phi_rr, _, _) =
603            duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
604        let analytic_t_collision =
605            duchon_phi_rrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)? / 3.0;
606        let analytic_t_rr_collision = if collision_t_rr_exists {
607            duchon_phi_rrrrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)? / 15.0
608        } else {
609            // t_rr(0) does not exist as a finite limit for this smoothness
610            // order, so the smooth-origin carrier must stop at the quadratic
611            // term in t(r) and the quartic term in q(r), phi_r(r), phi_rr(r).
612            0.0
613        };
614        let collision_jets = duchon_operator_jets_from_primary_core(
615            duchon_collision_taylor_operator_core(
616                r,
617                analytic_phi_rr,
618                analytic_t_collision,
619                analytic_t_rr_collision,
620            ),
621            r,
622            d,
623        );
624        out = DuchonRadialJets {
625            phi: out.phi,
626            ..collision_jets
627        };
628    } else if r < r_floor && collision_q_exists {
629        // Tier 1: only lower-order collision identities exist.  φ''(0) is
630        // finite but φ''''(0) diverges logarithmically at this smoothness
631        // order.  Override phi_r, phi_rr, q, q_r, lap, lap_r with exact
632        // values; leave t, t_r, t_rr, q_rr, lap_rr at their generic-path
633        // values from r_eval = r_floor (best available for the divergent tier).
634        let (analytic_phi_rr, _, _) =
635            duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
636        out.phi_r = analytic_phi_rr * r;
637        out.phi_rr = analytic_phi_rr;
638        out.q = analytic_phi_rr;
639        out.q_r = 0.0;
640        out.lap = d * analytic_phi_rr;
641        out.lap_r = 0.0;
642    }
643    if !out.phi_r.is_finite()
644        || !out.phi_rr.is_finite()
645        || !out.phi_rrr.is_finite()
646        || !out.q.is_finite()
647        || !out.q_r.is_finite()
648        || !out.q_rr.is_finite()
649        || !out.lap.is_finite()
650        || !out.lap_r.is_finite()
651        || !out.lap_rr.is_finite()
652        || !out.t.is_finite()
653        || !out.t_r.is_finite()
654        || !out.t_rr.is_finite()
655    {
656        crate::bail_invalid_basis!(
657            "non-finite Duchon radial jets at r={r}, length_scale={length_scale}, p={p_order}, s={s_order}, dim={k_dim}"
658        );
659    }
660    Ok(out)
661}
662
663/// The scalar core's radial jet, before any ψ direction has been chosen.
664///
665/// **Duchon spectral derivation.** Start from the isotropic spectrum
666/// `K^(ω; κ) ∝ 1 / (|ω|^{2p} (κ² + |ω|²)^s)`, with fixed integer orders `p, s`
667/// and continuous scale `ψ = log κ`, `κ = 1/length_scale`. Rescaling frequency
668/// by `ω = κ ξ` gives the full spatial kernel scaling law
669///
670/// ```text
671///     φ(r; κ) = κ^δ H(κ r),      δ = d − 2p − 2s
672/// ```
673///
674/// so every radial scalar this file forms is `κ^E G(κ r)` for some exponent
675/// `E`: `φ` at `δ`, `q = φ_r/r` and `Δφ` at `δ + 2`, `t = q_r/r` at `δ + 4`.
676/// `scaled_log_kappa_derivatives` contracts that along the isotropic
677/// direction; [`duchon_axis_log_kappa_derivatives`] contracts it per axis, and
678/// summing the latter reproduces the former exactly. Splitting the value jet
679/// from the contraction makes the DIRECTION the only thing that differs
680/// between the two routes, so a global and a per-axis derivative can never be
681/// taken of two different kernels.
682///
683/// Once `{φ, q, Δφ}` and their ψ derivatives are known the collocation
684/// operators follow exactly — `D0[k,j] = φ(r)`, `D1[(k,a),j] = q(r)·h_a`,
685/// `D2[k,j] = Δφ(r)` — and the penalty Hessians come from the Gram identities
686/// `S_ψ = D_ψᵀD + DᵀD_ψ` and `S_ψψ = D_ψψᵀD + 2D_ψᵀD_ψ + DᵀD_ψψ`.
687///
688/// **Representation note.** When `p > 0` the Duchon kernel is only
689/// conditionally positive definite, so the spatial kernel is canonical only up
690/// to polynomial additions. These formulas are tied to the specific
691/// representative encoded by the partial-fraction construction and the
692/// collision rules; the operator penalties, exact ψ derivatives, and
693/// center-collision limits all have to use that same representative or the
694/// resulting penalty geometry drifts across code paths. In particular the
695/// `r = 0` limit is NOT the naive `(δ+2)·φ_rr` scaling shortcut — in even
696/// dimensions the log-Riesz representative carries κ-dependent finite parts at
697/// the origin, which is what [`duchonphi_rr_collision_psi_triplet`] exists for.
698pub(crate) struct DuchonRadialCoreValueJet {
699    pub(crate) value: f64,
700    pub(crate) first: f64,
701    pub(crate) second: f64,
702    pub(crate) exponent: f64,
703}
704
705pub(crate) fn duchon_radial_core_value_jet(
706    r: f64,
707    length_scale: f64,
708    p_order: usize,
709    s_order: usize,
710    k_dim: usize,
711    coeffs: &DuchonPartialFractionCoeffs,
712) -> Result<DuchonRadialCoreValueJet, BasisError> {
713    let jets = duchon_radial_jets(r, length_scale, p_order, s_order, k_dim, coeffs)?;
714    Ok(DuchonRadialCoreValueJet {
715        value: jets.phi,
716        first: jets.phi_r,
717        second: jets.phi_rr,
718        exponent: duchon_scaling_exponent(p_order, s_order, k_dim),
719    })
720}
721
722pub(crate) fn duchonphi_rr_collision_psi_triplet(
723    length_scale: f64,
724    p_order: usize,
725    s_order: usize,
726    k_dim: usize,
727    coeffs: &DuchonPartialFractionCoeffs,
728) -> Result<(f64, f64, f64), BasisError> {
729    // Center-collision rule
730    // For a C^2 radial kernel one has
731    //   lim_{r->0} phi_r(r)/r = phi_rr(0),
732    //   lim_{r->0} Δphi(r)    = d * phi_rr(0).
733    //
734    // Assemble phi_rr and its psi derivatives by summing the partial-fraction
735    // blocks directly.  Do not replace this with the tempting scaling shortcut
736    // `phi_rr_psi = (delta + 2) phi_rr`: in even dimensions the log-Riesz
737    // representative carries kappa-dependent finite-part constants at the
738    // origin, so the shortcut gives the wrong center-collision derivative even
739    // when the classical C^2 limit exists.
740    duchon_phi_even_derivative_collision_psi_triplet(
741        length_scale,
742        p_order,
743        s_order,
744        k_dim,
745        coeffs,
746        1,
747    )
748}
749
750/// Euler-Mascheroni constant γ ≈ 0.5772.
751pub(crate) const EULER_MASCHERONI: f64 = 0.577_215_664_901_532_9;
752
753/// Digamma function ψ(n) for positive integer n.
754///
755/// ψ(1) = −γ, ψ(n+1) = −γ + H_n where H_n = Σ_{j=1}^{n} 1/j.
756#[inline(always)]
757pub(crate) fn digamma_pos_int(n: usize) -> f64 {
758    assert!(n >= 1, "digamma_pos_int requires n >= 1: n={n}");
759    let mut h = 0.0_f64;
760    for j in 1..n {
761        h += 1.0 / j as f64;
762    }
763    -EULER_MASCHERONI + h
764}
765
766/// Extract the coefficient of r^{2j} (pure and log-r parts) from a single
767/// Matérn partial-fraction block g_n(r) = c · r^ν · K_{|ν|}(κr), where
768/// ν = n − d/2.
769///
770/// Returns `(pure_coeff, log_coeff)` such that the r^{2j} piece of g_n is
771///   pure_coeff · r^{2j}  +  log_coeff · r^{2j} · ln(r).
772///
773/// For even d (integer ν) the expansion uses the DLMF 10.31.1 series for
774/// K_n(z) at the origin, which involves digamma / harmonic-number terms.
775///
776/// For odd d (half-integer ν) the Bessel function is elementary; the Taylor
777/// coefficients come from convolving a finite polynomial in 1/r with e^{−κr},
778/// and there is no log-r contribution.
779pub(crate) fn duchon_matern_block_taylor_r2j(
780    kappa: f64,
781    n_order: usize,
782    k_dim: usize,
783    j: usize,
784) -> (f64, f64) {
785    let n = n_order as f64;
786    let k_half = 0.5 * k_dim as f64;
787    let nu = n - k_half;
788    // Normalization constant for the Matérn block.
789    let c = kappa.powf(k_half - n)
790        / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
791
792    if k_dim.is_multiple_of(2) {
793        // Integer ν.
794        let nu_int = n_order as i64 - (k_dim as i64) / 2;
795        duchon_matern_block_taylor_r2j_integer_nu(kappa, c, nu_int, j)
796    } else {
797        // Half-integer ν.
798        duchon_matern_block_taylor_r2j_half_integer_nu(kappa, c, nu, j)
799    }
800}
801
802#[inline(always)]
803pub(crate) fn psi_power_triplet(value: f64, exponent: f64) -> (f64, f64, f64) {
804    (value, exponent * value, exponent * exponent * value)
805}
806
807#[inline(always)]
808pub(crate) fn psi_power_log_triplet(
809    base: f64,
810    exponent: f64,
811    log_kappa_half: f64,
812) -> (f64, f64, f64) {
813    (
814        base * log_kappa_half,
815        base * (exponent * log_kappa_half + 1.0),
816        base * (exponent * exponent * log_kappa_half + 2.0 * exponent),
817    )
818}
819
820#[inline(always)]
821pub(crate) fn add_triplet(dst: &mut (f64, f64, f64), inc: (f64, f64, f64)) {
822    dst.0 += inc.0;
823    dst.1 += inc.1;
824    dst.2 += inc.2;
825}
826
827/// Like [`duchon_matern_block_taylor_r2j`], but also returns exact
828/// derivatives of the pure/log Taylor coefficients with respect to
829/// `psi = log(kappa)`.
830pub(crate) fn duchon_matern_block_taylor_r2j_triplet(
831    kappa: f64,
832    n_order: usize,
833    k_dim: usize,
834    j: usize,
835) -> ((f64, f64, f64), (f64, f64, f64)) {
836    let n = n_order as f64;
837    let k_half = 0.5 * k_dim as f64;
838    let nu = n - k_half;
839    let c_const = 1.0
840        / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
841    let c_exp = k_half - n;
842
843    let mut pure = (0.0, 0.0, 0.0);
844    let mut log_part = (0.0, 0.0, 0.0);
845    let log_kappa_half = (0.5 * kappa).ln();
846
847    if k_dim.is_multiple_of(2) {
848        let nu_int = n_order as i64 - (k_dim as i64) / 2;
849        let mu = nu_int.unsigned_abs() as usize;
850        let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 };
851
852        if nu_int >= 0 {
853            let nu_usize = nu_int as usize;
854
855            if j < nu_usize {
856                let sign = if j.is_multiple_of(2) { 1.0 } else { -1.0 };
857                let power = 2 * j as i32 - nu_usize as i32;
858                let coeff = 0.5 * sign * gamma_lanczos((nu_usize - j) as f64)
859                    / gamma_lanczos((j + 1) as f64)
860                    * 2.0_f64.powi(-power);
861                let exponent = c_exp + power as f64;
862                let value = c_const * coeff * kappa.powf(exponent);
863                add_triplet(&mut pure, psi_power_triplet(value, exponent));
864            }
865
866            if j >= nu_usize {
867                let k = j - nu_usize;
868                let inv_fac = 1.0
869                    / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((nu_usize + k + 1) as f64));
870                let power = (2 * k + nu_usize) as i32;
871                let exponent = c_exp + power as f64;
872                let kp_base = c_const * kappa.powf(exponent) * 2.0_f64.powi(-power);
873
874                let log_base = -sign_mu * kp_base * inv_fac;
875                add_triplet(&mut log_part, psi_power_triplet(log_base, exponent));
876                add_triplet(
877                    &mut pure,
878                    psi_power_log_triplet(log_base, exponent, log_kappa_half),
879                );
880
881                let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(nu_usize + k + 1);
882                let digamma_base = sign_mu * 0.5 * kp_base * inv_fac * psi_sum;
883                add_triplet(&mut pure, psi_power_triplet(digamma_base, exponent));
884            }
885        } else {
886            let k = j;
887            let inv_fac =
888                1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((mu + k + 1) as f64));
889            let power = (mu + 2 * k) as i32;
890            let exponent = c_exp + power as f64;
891            let kp_base = c_const * kappa.powf(exponent) * 2.0_f64.powi(-power);
892
893            let log_base = -sign_mu * kp_base * inv_fac;
894            add_triplet(&mut log_part, psi_power_triplet(log_base, exponent));
895            add_triplet(
896                &mut pure,
897                psi_power_log_triplet(log_base, exponent, log_kappa_half),
898            );
899
900            let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(mu + k + 1);
901            let digamma_base = sign_mu * 0.5 * kp_base * inv_fac * psi_sum;
902            add_triplet(&mut pure, psi_power_triplet(digamma_base, exponent));
903        }
904    } else {
905        let nu_abs = nu.abs();
906        // |ν| = l + ½ ⇒ l = |ν| − ½. (The earlier `2|ν| − 1` form computed `2l`,
907        // not `l`: it is correct only at ν = ½, and for |ν| ≥ 3/2 it selected the
908        // K_{2|ν|−½} polynomial instead of K_{|ν|}, collapsing the Taylor
909        // coefficients — e.g. the r⁰ diagonal term of the ν = 3/2 block to 0,
910        // which broke the d=1 / power≥2 Duchon penalty diagonal — gam#1604.)
911        let l = (nu_abs - 0.5).round().max(0.0) as usize;
912        let prefactor_const = (std::f64::consts::PI / 2.0).sqrt();
913        let prefactor_exp = -0.5;
914        let target = 2 * j;
915
916        for i in 0..=l {
917            let c_i = gamma_lanczos((l + i + 1) as f64)
918                / (gamma_lanczos((i + 1) as f64) * gamma_lanczos((l - i + 1) as f64));
919            let p_f64 = nu - 0.5 - i as f64;
920            let p_round = p_f64.round() as i64;
921            if (p_f64 - p_round as f64).abs() > 1e-12 {
922                continue;
923            }
924            let q_needed = target as i64 - p_round;
925            if q_needed < 0 {
926                continue;
927            }
928            let q = q_needed as usize;
929            let sign = if q.is_multiple_of(2) { 1.0 } else { -1.0 };
930            let exponent = c_exp + prefactor_exp - i as f64 + q as f64;
931            let value = c_const * prefactor_const * c_i * 2.0_f64.powi(-(i as i32)) * sign
932                / gamma_lanczos((q + 1) as f64)
933                * kappa.powf(exponent);
934            add_triplet(&mut pure, psi_power_triplet(value, exponent));
935        }
936    }
937
938    (pure, log_part)
939}
940
941/// Taylor r^{2j} coefficients for integer-ν Matérn block.
942///
943/// Uses the K_μ(z) expansion for integer μ = |ν| ≥ 0 (A&S 9.6.11 / DLMF 10.31.1):
944///
945///   K_μ(z) = (−1)^{μ+1} I_μ(z) ln(z/2)
946///          + ½ Σ_{k=0}^{μ−1} (−1)^k (μ−k−1)!/k! · (z/2)^{2k−μ}   [singular]
947///          + (−1)^μ · ½ Σ_{k≥0} (z/2)^{μ+2k}/(k!(μ+k)!)
948///                              · [ψ(k+1)+ψ(μ+k+1)]                  [regular]
949///
950/// Multiplied by r^ν, the r^{2j} coefficient is assembled from the singular
951/// and/or regular+log series depending on the sign and magnitude of ν.
952pub(crate) fn duchon_matern_block_taylor_r2j_integer_nu(
953    kappa: f64,
954    c: f64,
955    nu_int: i64,
956    j: usize,
957) -> (f64, f64) {
958    let mu = nu_int.unsigned_abs() as usize; // |ν|
959
960    // Helper: compute (κ/2)^p for integer p.
961    let kappa_half = 0.5 * kappa;
962
963    if nu_int >= 0 {
964        let nu = nu_int as usize;
965        // Two potential sources for the r^{2j} coefficient:
966        //
967        // 1) Singular sum:  contributes when j ≤ ν−1 (the k=j term gives r^{2j}).
968        // 2) Regular+log sum: contributes when 2ν+2k = 2j, i.e. k = j−ν ≥ 0.
969        let mut pure = 0.0;
970        let mut log_part = 0.0;
971
972        // Source 1: singular sum at k = j.
973        if j < nu {
974            // (1/2) · (−1)^j · (ν−j−1)!/j! · (κ/2)^{2j−ν}
975            let sign = if j.is_multiple_of(2) { 1.0 } else { -1.0 };
976            let coeff = sign * gamma_lanczos((nu - j) as f64) / gamma_lanczos((j + 1) as f64)
977                * kappa_half.powi(2 * j as i32 - nu as i32)
978                * 0.5;
979            pure += coeff;
980        }
981
982        // Source 2: regular+log sum at k = j − ν.
983        if j >= nu {
984            let k = j - nu;
985            let inv_fac =
986                1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((nu + k + 1) as f64));
987            let kp = kappa_half.powi(2 * k as i32 + nu as i32);
988            let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 }; // (−1)^μ
989
990            // Log coefficient: (−1)^{μ+1} · (κ/2)^{ν+2k} / (k!(ν+k)!)
991            log_part += -sign_mu * kp * inv_fac;
992
993            // Pure coefficient from the log series (ln(κ/2) piece):
994            //   (−1)^{μ+1} · (κ/2)^{ν+2k} / (k!(ν+k)!) · ln(κ/2)
995            // Plus the digamma series:
996            //   (−1)^μ · ½ · (κ/2)^{ν+2k} / (k!(ν+k)!) · [ψ(k+1)+ψ(ν+k+1)]
997            let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(nu + k + 1);
998            pure += -sign_mu * kp * inv_fac * kappa_half.ln();
999            pure += sign_mu * 0.5 * kp * inv_fac * psi_sum;
1000        }
1001
1002        (c * pure, c * log_part)
1003    } else {
1004        // ν < 0: mu = |ν| > 0.
1005        // Singular sum gives powers r^{2ν}, ..., r^{−2} (all negative).
1006        // Regular+log sum gives r^0, r^2, r^4, ... at k = j.
1007        let k = j;
1008        let inv_fac = 1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((mu + k + 1) as f64));
1009        let kp = kappa_half.powi(mu as i32 + 2 * k as i32);
1010        let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 };
1011
1012        // Log coefficient: (−1)^{μ+1} · (κ/2)^{μ+2k} / (k!(μ+k)!)
1013        let log_part = -sign_mu * kp * inv_fac;
1014
1015        // Pure coefficient: log-series ln(κ/2) piece + digamma piece.
1016        let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(mu + k + 1);
1017        let pure =
1018            -sign_mu * kp * inv_fac * kappa_half.ln() + sign_mu * 0.5 * kp * inv_fac * psi_sum;
1019
1020        (c * pure, c * log_part)
1021    }
1022}
1023
1024/// Taylor r^{2j} coefficients for half-integer-ν Matérn block.
1025///
1026/// For half-integer |ν| = l + ½, K_{l+½}(z) is elementary:
1027///   K_{l+½}(z) = √(π/(2z)) · e^{−z} · Σ_{i=0}^{l} C_i · (2z)^{−i}
1028/// where C_i = (l+i)! / (i! · (l−i)!).
1029///
1030/// The product r^ν · K_{|ν|}(κr) expands as an explicit polynomial in r
1031/// (including possible negative powers) times e^{−κr}.  The r^{2j} Taylor
1032/// coefficient is obtained by convolving with the exponential series
1033/// e^{−κr} = Σ_q (−κ)^q r^q / q!.  There is never a log-r contribution.
1034pub(crate) fn duchon_matern_block_taylor_r2j_half_integer_nu(
1035    kappa: f64,
1036    c: f64,
1037    nu: f64,
1038    j: usize,
1039) -> (f64, f64) {
1040    let nu_abs = nu.abs();
1041    // |ν| = l + ½ ⇒ l = |ν| − ½. (The earlier `2|ν| − 1` form computed `2l`,
1042    // not `l` — see the matching note in `duchon_matern_block_taylor_r2j_triplet`;
1043    // gam#1604.)
1044    let l = (nu_abs - 0.5).round().max(0.0) as usize;
1045    // Compute the polynomial coefficients C_i / (2κ)^i for each r-power.
1046    //
1047    // r^ν · K_{l+½}(κr) = √(π/(2κ)) · e^{−κr} · Σ_{i=0}^{l} C_i (2κ)^{−i} r^{ν−½−i}
1048    //
1049    // (since K_{l+½}(z) = √(π/(2z)) e^{−z} Σ C_i (2z)^{−i}, multiplying by
1050    // r^ν gives r^{ν−½} from the √(π/(2κr)) factor, then each (2κr)^{−i}
1051    // contributes r^{−i}.)
1052    let prefactor = (std::f64::consts::PI / (2.0 * kappa)).sqrt();
1053
1054    // Polynomial term i has r-power = ν − 0.5 − i.  We need to convolve
1055    // each monomial with e^{−κr} = Σ_q (−κ)^q r^q / q! and extract the
1056    // r^{2j} coefficient.
1057    //
1058    // For monomial r^p (p = ν−½−i) times e^{−κr}: the r^{2j} coefficient is
1059    //   (−κ)^{2j−p} / (2j−p)!   when 2j−p is a non-negative integer.
1060    let target = 2 * j;
1061    let mut pure = 0.0;
1062
1063    for i in 0..=l {
1064        let c_i = gamma_lanczos((l + i + 1) as f64)
1065            / (gamma_lanczos((i + 1) as f64) * gamma_lanczos((l - i + 1) as f64));
1066        let inv_2kappa_i = (2.0 * kappa).powi(-(i as i32));
1067
1068        // r-power of this polynomial term.
1069        let p_f64 = nu - 0.5 - i as f64;
1070        let p_round = p_f64.round() as i64;
1071        if (p_f64 - p_round as f64).abs() > 1e-12 {
1072            // Not integer/half-integer aligned — should not happen for half-integer ν.
1073            continue;
1074        }
1075        let q_needed = target as i64 - p_round;
1076        if q_needed < 0 {
1077            continue;
1078        }
1079        let q = q_needed as usize;
1080        let exp_coeff = (-kappa).powi(q as i32) / gamma_lanczos((q + 1) as f64);
1081        pure += c_i * inv_2kappa_i * exp_coeff;
1082    }
1083
1084    (c * prefactor * pure, 0.0) // No log contribution for half-integer ν.
1085}
1086
1087/// Extract the r^{2j} Taylor coefficient from a polyharmonic block Φ_m(r).
1088///
1089/// Non-log case (d odd, or d even with m < d/2): Φ_m = c · r^α with α = 2m − d.
1090///   Only contributes when α = 2j exactly: pure_coeff = c, log_coeff = 0.
1091///
1092/// Log case (d even, m ≥ d/2): Φ_m = c · r^α · ln(r).
1093///   Only contributes when α = 2j: pure_coeff = 0, log_coeff = c.
1094pub(crate) fn duchon_polyharmonic_block_taylor_r2j(m: usize, k_dim: usize, j: usize) -> (f64, f64) {
1095    let k_half = 0.5 * k_dim as f64;
1096    let alpha = 2 * m as i64 - k_dim as i64;
1097
1098    if alpha != 2 * j as i64 {
1099        return (0.0, 0.0);
1100    }
1101
1102    // α = 2j: compute the normalization constant.
1103    if k_dim.is_multiple_of(2) && m >= k_dim / 2 {
1104        // Log case: Φ_m = c · r^α · ln(r).
1105        let c = polyharmonic_log_sign(m, k_dim)
1106            / (2.0_f64.powi((2 * m - 1) as i32)
1107                * std::f64::consts::PI.powf(k_half)
1108                * gamma_lanczos(m as f64)
1109                * gamma_lanczos((m - k_dim / 2 + 1) as f64));
1110        (0.0, c)
1111    } else {
1112        // Non-log case: Φ_m = c · r^α.
1113        let c = gamma_lanczos(k_half - m as f64)
1114            / (4.0_f64.powi(m as i32)
1115                * std::f64::consts::PI.powf(k_half)
1116                * gamma_lanczos(m as f64));
1117        (c, 0.0)
1118    }
1119}
1120
1121/// Compute the even-order radial derivative φ^{(2j)}(0) from analytic Taylor
1122/// coefficients of the partial-fraction blocks.
1123///
1124/// For a C^{2j} radial kernel with Taylor expansion φ(r) = Σ_k a_{2k} r^{2k},
1125/// φ^{(2j)}(0) = (2j)! · a_{2j}.  Each partial-fraction block (polyharmonic
1126/// and Matérn) has a computable r^{2j} Taylor coefficient (both pure and
1127/// ln(r) parts).  The ln(r) contributions cancel across blocks whenever the
1128/// kernel is sufficiently smooth; the pure coefficients sum to give a_{2j}.
1129///
1130/// Existence condition (kernel is C^{2j} at the origin):
1131///   2(p + s) > d + 2j.
1132///
1133/// When this condition fails (borderline or insufficient smoothness), the
1134/// derivative is not a finite collision limit. Callers must reject that model
1135/// upstream rather than regularize it at an arbitrary floor radius.
1136pub(crate) fn duchon_phi_even_derivative_collision(
1137    length_scale: f64,
1138    p_order: usize,
1139    s_order: usize,
1140    k_dim: usize,
1141    coeffs: &DuchonPartialFractionCoeffs,
1142    j: usize,
1143) -> Result<f64, BasisError> {
1144    let smoothness_order = 2 * (p_order + s_order);
1145    let required = k_dim + 2 * j;
1146
1147    if smoothness_order <= required {
1148        // This path needs the (2j)-order radial-kernel derivative at the
1149        // origin, finite only when 2(p+s) > k_dim + 2j.
1150        return Err(BasisError::duchon_smoothness_insufficient(
1151            format!("collision derivative phi^({})", 2 * j),
1152            2 * j,
1153            k_dim,
1154            p_order,
1155            s_order as f64,
1156        ));
1157    }
1158
1159    // Analytic path: extract per-block Taylor r^{2j} coefficients and sum.
1160    let kappa = duchon_inverse_length_scale(length_scale, "Duchon even-derivative collision")?;
1161    let mut total_pure = KahanSum::default();
1162    let mut total_log = KahanSum::default();
1163    let mut total_log_abs_scale = KahanSum::default();
1164
1165    // Polyharmonic blocks.
1166    for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1167        if a_m == 0.0 {
1168            continue;
1169        }
1170        let (pure, log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, j);
1171        total_pure.add(a_m * pure);
1172        total_log.add(a_m * log);
1173        total_log_abs_scale.add((a_m * log).abs());
1174    }
1175
1176    // Matérn blocks.
1177    for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1178        if b_n == 0.0 {
1179            continue;
1180        }
1181        let (pure, log) = duchon_matern_block_taylor_r2j(kappa, n, k_dim, j);
1182        total_pure.add(b_n * pure);
1183        total_log.add(b_n * log);
1184        total_log_abs_scale.add((b_n * log).abs());
1185    }
1186    let total_pure = total_pure.sum();
1187    let total_log = total_log.sum();
1188    let total_log_abs_scale = total_log_abs_scale.sum();
1189
1190    // The ln(r) coefficients should cancel to zero (guaranteed by the PFD
1191    // identity when 2(p+s) > d+2j).  Check this as a sanity guard.
1192    let log_cancel_tol = 1e-10 * total_log_abs_scale.max(total_pure.abs()).max(1e-30);
1193    if total_log.abs() > log_cancel_tol {
1194        crate::bail_invalid_basis!(
1195            "Duchon Taylor a_{} log-coefficient did not cancel: log={total_log:.6e}, pure={total_pure:.6e}; \
1196             log_abs_scale={total_log_abs_scale:.6e}, tol={log_cancel_tol:.6e}; p={p_order}, s={s_order}, d={k_dim}",
1197            2 * j
1198        );
1199    }
1200
1201    // φ^{(2j)}(0) = (2j)! · a_{2j}
1202    let factorial_2j = gamma_lanczos((2 * j + 1) as f64);
1203    Ok(factorial_2j * total_pure)
1204}
1205
1206pub(crate) fn duchon_phi_even_derivative_collision_psi_triplet(
1207    length_scale: f64,
1208    p_order: usize,
1209    s_order: usize,
1210    k_dim: usize,
1211    coeffs: &DuchonPartialFractionCoeffs,
1212    j: usize,
1213) -> Result<(f64, f64, f64), BasisError> {
1214    let smoothness_order = 2 * (p_order + s_order);
1215    let required = k_dim + 2 * j;
1216
1217    if smoothness_order <= required {
1218        // The exact two-block / transformation-normal path needs analytic
1219        // length-scale derivatives of the kernel, finite only when
1220        // 2(p+s) > k_dim + 2j.
1221        return Err(BasisError::duchon_smoothness_insufficient(
1222            format!("collision derivative phi^({}) psi triplet", 2 * j),
1223            2 * j,
1224            k_dim,
1225            p_order,
1226            s_order as f64,
1227        ));
1228    }
1229
1230    let kappa = duchon_inverse_length_scale(length_scale, "Duchon even-derivative collision ψ-triplet")?;
1231    let mut value = KahanSum::default();
1232    let mut psi = KahanSum::default();
1233    let mut psi_psi = KahanSum::default();
1234    let mut log_value = KahanSum::default();
1235    let mut log_psi = KahanSum::default();
1236    let mut log_psi_psi = KahanSum::default();
1237    let mut log_abs_scale = KahanSum::default();
1238
1239    for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1240        if a_m == 0.0 {
1241            continue;
1242        }
1243        let alpha_m = duchon_coeff_exponents(p_order, s_order, m);
1244        let (pure, log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, j);
1245        value.add(a_m * pure);
1246        psi.add(alpha_m * a_m * pure);
1247        psi_psi.add(alpha_m * alpha_m * a_m * pure);
1248        log_value.add(a_m * log);
1249        log_psi.add(alpha_m * a_m * log);
1250        log_psi_psi.add(alpha_m * alpha_m * a_m * log);
1251        log_abs_scale.add((a_m * log).abs());
1252        log_abs_scale.add((alpha_m * a_m * log).abs());
1253        log_abs_scale.add((alpha_m * alpha_m * a_m * log).abs());
1254    }
1255
1256    for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1257        if b_n == 0.0 {
1258            continue;
1259        }
1260        let beta_n = duchon_coeff_exponents(p_order, s_order, n);
1261        let (pure, log) = duchon_matern_block_taylor_r2j_triplet(kappa, n, k_dim, j);
1262        value.add(b_n * pure.0);
1263        psi.add(beta_n * b_n * pure.0 + b_n * pure.1);
1264        psi_psi.add(beta_n * beta_n * b_n * pure.0 + 2.0 * beta_n * b_n * pure.1 + b_n * pure.2);
1265        log_value.add(b_n * log.0);
1266        log_psi.add(beta_n * b_n * log.0 + b_n * log.1);
1267        log_psi_psi.add(beta_n * beta_n * b_n * log.0 + 2.0 * beta_n * b_n * log.1 + b_n * log.2);
1268        let log_v = b_n * log.0;
1269        let log_p = beta_n * b_n * log.0 + b_n * log.1;
1270        let log_pp = beta_n * beta_n * b_n * log.0 + 2.0 * beta_n * b_n * log.1 + b_n * log.2;
1271        log_abs_scale.add(log_v.abs());
1272        log_abs_scale.add(log_p.abs());
1273        log_abs_scale.add(log_pp.abs());
1274    }
1275
1276    let value = value.sum();
1277    let psi = psi.sum();
1278    let psi_psi = psi_psi.sum();
1279    let log_value = log_value.sum();
1280    let log_psi = log_psi.sum();
1281    let log_psi_psi = log_psi_psi.sum();
1282    let log_abs_scale = log_abs_scale.sum();
1283    let scale = value.abs().max(psi.abs()).max(psi_psi.abs()).max(1e-30);
1284    let log_cancel_tol = 1e-10 * log_abs_scale.max(scale);
1285    if log_value.abs().max(log_psi.abs()).max(log_psi_psi.abs()) > log_cancel_tol {
1286        crate::bail_invalid_basis!(
1287            "Duchon Taylor a_{} log-coefficient derivative did not cancel: \
1288             log=({log_value:.6e}, {log_psi:.6e}, {log_psi_psi:.6e}), \
1289             value=({value:.6e}, {psi:.6e}, {psi_psi:.6e}), log_abs_scale={log_abs_scale:.6e}, tol={log_cancel_tol:.6e}; \
1290             p={p_order}, s={s_order}, d={k_dim}",
1291            2 * j
1292        );
1293    }
1294
1295    let factorial_2j = gamma_lanczos((2 * j + 1) as f64);
1296    Ok((
1297        factorial_2j * value,
1298        factorial_2j * psi,
1299        factorial_2j * psi_psi,
1300    ))
1301}
1302
1303/// Assemble φ''''(0) from the partial-fraction blocks using analytic Taylor
1304/// coefficients.
1305///
1306/// For a radial kernel with Taylor expansion φ(r) = a₀ + a₂r² + a₄r⁴ + ...,
1307/// we have φ''''(0) = 24 a₄.  This is used to compute the collision limit
1308/// t(0) = φ''''(0) / 3, where t = R²φ = (φ'' - q) / r².
1309///
1310/// Each partial-fraction block (polyharmonic and Matérn) has a known Taylor
1311/// expansion around r = 0; the r⁴ coefficient a₄ is extracted from the series
1312/// and summed.  This avoids the catastrophic cancellation that occurs when
1313/// evaluating divergent block derivatives at a small floor radius.
1314pub(crate) fn duchon_phi_rrrr_collision(
1315    length_scale: f64,
1316    p_order: usize,
1317    s_order: usize,
1318    k_dim: usize,
1319    coeffs: &DuchonPartialFractionCoeffs,
1320) -> Result<f64, BasisError> {
1321    duchon_phi_even_derivative_collision(length_scale, p_order, s_order, k_dim, coeffs, 2)
1322}
1323
1324/// Assemble φ⁽⁶⁾(0) from the partial-fraction blocks using analytic Taylor
1325/// coefficients.
1326///
1327/// For a radial kernel with Taylor expansion φ(r) = a₀ + a₂r² + a₄r⁴ + a₆r⁶ + ...,
1328/// we have φ⁽⁶⁾(0) = 720 a₆. This gives the collision limit
1329///   t_rr(0) = φ⁽⁶⁾(0) / 15
1330/// for t = R²φ.
1331///
1332/// Like [`duchon_phi_rrrr_collision`], this extracts per-block Taylor
1333/// coefficients analytically rather than evaluating divergent derivatives at
1334/// a small floor radius.
1335pub(crate) fn duchon_phi_rrrrrr_collision(
1336    length_scale: f64,
1337    p_order: usize,
1338    s_order: usize,
1339    k_dim: usize,
1340    coeffs: &DuchonPartialFractionCoeffs,
1341) -> Result<f64, BasisError> {
1342    duchon_phi_even_derivative_collision(length_scale, p_order, s_order, k_dim, coeffs, 3)
1343}
1344
1345/// Resolve the FROZEN radial chart that every Duchon ψ-derivative is taken in.
1346///
1347/// `build_duchon_basis` ADOPTS a data-metric radial reparameterization `V`
1348/// whenever the constrained kernel block has columns and the spec carries no
1349/// frozen one (#1355), then freezes it into
1350/// `BasisMetadata::Duchon::radial_reparam`. The design, the native penalties
1351/// and the operator penalties are all assembled in `Z·V`, and every
1352/// ψ-derivative on this path is a FROZEN-chart derivative: `V` is held at the
1353/// cold build and replayed onto the spec at each trial κ, which is exactly what
1354/// the κ-optimizer does before asking for one.
1355///
1356/// A spec that reaches a derivative builder WITHOUT a frozen `V` therefore does
1357/// not describe the penalty its own forward build would ship — that build would
1358/// compute a fresh `V(ψ)` — so differentiating in the raw `Z` chart returns the
1359/// exact derivative of a DIFFERENT matrix. Nothing downstream can notice: the
1360/// shapes agree and the numbers are finite. That is how three finite-difference
1361/// gates came to report the chart mismatch as a 10×–290× error in the analytic
1362/// derivative itself, when the derivative is exact to 1.1e-7 against a
1363/// same-chart difference (#2638). Refuse instead of returning it.
1364///
1365/// This is the ONE place the three fold sites decide the chart, so a missing
1366/// `V` cannot be absorbed silently at any of them.
1367pub(crate) fn duchon_frozen_radial_chart(
1368    z_kernel: Array2<f64>,
1369    spec: &DuchonBasisSpec,
1370    site: &str,
1371) -> Result<Array2<f64>, BasisError> {
1372    let Some(v) = spec.radial_reparam.as_ref() else {
1373        if z_kernel.ncols() == 0 {
1374            // No constrained radial columns ⇒ the forward build has nothing to
1375            // rotate and adopts no `V` either, so the raw chart IS its chart.
1376            return Ok(z_kernel);
1377        }
1378        crate::bail_invalid_basis!(
1379            "Duchon {site} ψ-derivative requires the frozen data-metric radial reparam V, but              the spec carries none while the constrained kernel block has {} columns. The              forward build adopts a fresh V(ψ) for this spec, so a derivative taken in the raw              Z chart is the exact derivative of a different penalty (#2638). Replay              BasisMetadata::Duchon::radial_reparam onto the spec first, as the κ-optimizer does.",
1380            z_kernel.ncols()
1381        );
1382    };
1383    if v.nrows() != z_kernel.ncols() {
1384        crate::bail_dim_basis!(
1385            "Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
1386            v.dim(),
1387            z_kernel.ncols()
1388        );
1389    }
1390    Ok(fast_ab(&z_kernel, v))
1391}
1392
1393pub(crate) fn build_duchon_design_psi_derivativeswithworkspace(
1394    data: ArrayView2<'_, f64>,
1395    centers: ArrayView2<'_, f64>,
1396    spec: &DuchonBasisSpec,
1397    identifiability_transform: Option<&Array2<f64>>,
1398    workspace: &mut BasisWorkspace,
1399) -> Result<ScalarDesignPsiDerivatives, BasisError> {
1400    let length_scale = spec.length_scale.ok_or_else(|| {
1401        BasisError::InvalidInput(
1402            "exact Duchon log-kappa derivatives require hybrid Duchon with length_scale"
1403                .to_string(),
1404        )
1405    })?;
1406    // Exact Duchon design derivatives:
1407    // 1. evaluate phi_psi and phi_psipsi at each data/center distance
1408    // 2. project the kernel block with the same nullspace constraint used by the basis
1409    // 3. append polynomial columns; their psi derivatives are zero because p and s are fixed
1410    // 4. apply any frozen identifiability transform
1411    let effective_nullspace_order = duchon_effective_nullspace_order(centers, spec.nullspace_order);
1412    let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
1413    let s_order = spec.power_as_usize();
1414    let kappa = 1.0 / length_scale;
1415    let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
1416    // #1355/#2638: the design ψ-derivatives assemble in the SAME frozen radial
1417    // chart `Z·V` as the forward design and penalty.
1418    let z_kernel = duchon_frozen_radial_chart(
1419        kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?,
1420        spec,
1421        "design",
1422    )?;
1423    let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
1424    let p_padded = z_kernel.ncols() + poly_cols;
1425    if let Some(zf) = identifiability_transform
1426        && p_padded != zf.nrows()
1427    {
1428        crate::bail_dim_basis!(
1429            "Duchon identifiability transform mismatch in design derivatives: local cols={}, transform rows={}",
1430            p_padded,
1431            zf.nrows()
1432        );
1433    }
1434    let p_final = identifiability_transform
1435        .map(|zf| zf.ncols())
1436        .unwrap_or(p_padded);
1437    // gam#979: the forward design ships `α·K` (see `duchon_kernel_chart`), so
1438    // the design ψ-derivative is formed under that same chart.
1439    let chart = duchon_kernel_chart(
1440        centers,
1441        Some(length_scale),
1442        p_order,
1443        s_order,
1444        data.ncols(),
1445        spec.aniso_log_scales.as_deref(),
1446        Some(&coeffs),
1447        None,
1448    )
1449    .design_chart();
1450    build_scalar_design_psi_derivatives_shared(
1451        data,
1452        centers,
1453        spec.aniso_log_scales.as_deref(),
1454        p_final,
1455        Some(z_kernel),
1456        identifiability_transform.cloned(),
1457        poly_cols,
1458        RadialScalarKind::Duchon {
1459            length_scale,
1460            p_order,
1461            s_order,
1462            dim: data.ncols(),
1463            coeffs,
1464        },
1465        duchon_scaling_exponent(p_order, s_order, data.ncols()),
1466        chart,
1467    )
1468}
1469
1470pub(crate) fn duchon_operator_penalties_requested(spec: &DuchonOperatorPenaltySpec) -> bool {
1471    matches!(spec.mass, OperatorPenaltySpec::Active { .. })
1472        || matches!(spec.tension, OperatorPenaltySpec::Active { .. })
1473        || matches!(spec.stiffness, OperatorPenaltySpec::Active { .. })
1474}
1475
1476/// Per-axis ψ derivatives of a hybrid Duchon basis — the anisotropic sibling of
1477/// [`build_duchon_basis_log_kappa_derivativeswith_collocationwithworkspace`]
1478/// (gam#2735).
1479///
1480/// The design half is the family-agnostic
1481/// `build_aniso_design_psi_derivatives_shared`, which already handles
1482/// `RadialScalarKind::Duchon` including its `δ/d` prefactor share; the penalty
1483/// half is the `_in_directions` entries, called once with `[Axis(0) … Axis(d−1)]`
1484/// so the whole per-axis surface costs one pass over the pairs.
1485///
1486/// Callers must have cleared [`crate::basis::duchon_spec_supports_axis_psi`]
1487/// first: this refuses rather than silently degrading, because a per-axis
1488/// coordinate whose derivative came from the isotropic route would be a
1489/// value/gradient desync rather than an approximation.
1490pub fn build_duchon_basis_log_kappa_aniso_derivativeswith_collocationwithworkspace(
1491    data: ArrayView2<'_, f64>,
1492    spec: &DuchonBasisSpec,
1493    centers: ArrayView2<'_, f64>,
1494    identifiability_transform: Option<&Array2<f64>>,
1495    operator_collocation_points: Option<ArrayView2<'_, f64>>,
1496    workspace: &mut BasisWorkspace,
1497) -> Result<AnisoBasisPsiDerivatives, BasisError> {
1498    let dim = data.ncols();
1499    if !crate::basis::duchon_spec_supports_axis_psi(spec, dim) {
1500        crate::bail_invalid_basis!(
1501            "Duchon per-axis ψ derivatives requested for a spec whose per-axis surface is not \
1502             derived (dim={dim}, length_scale={:?}, periodic={}, power={})",
1503            spec.length_scale,
1504            spec.periodic.is_some(),
1505            spec.power
1506        );
1507    }
1508    let length_scale = spec.length_scale.expect("capability check requires a hybrid scale");
1509    let eta = spec
1510        .aniso_log_scales
1511        .clone()
1512        .expect("capability check requires resolved anisotropy");
1513    let effective_nullspace_order = duchon_effective_nullspace_order(centers, spec.nullspace_order);
1514    let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
1515    let s_order = spec.power_as_usize();
1516    let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, 1.0 / length_scale);
1517    let z_kernel = duchon_frozen_radial_chart(
1518        kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?,
1519        spec,
1520        "aniso design",
1521    )?;
1522    let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
1523    let p_padded = z_kernel.ncols() + poly_cols;
1524    if let Some(zf) = identifiability_transform
1525        && p_padded != zf.nrows()
1526    {
1527        crate::bail_dim_basis!(
1528            "Duchon identifiability transform mismatch in aniso design derivatives: local cols={}, transform rows={}",
1529            p_padded,
1530            zf.nrows()
1531        );
1532    }
1533    let p_final = identifiability_transform
1534        .map(|zf| zf.ncols())
1535        .unwrap_or(p_padded);
1536    // gam#979: same chart as the isotropic sibling — the forward design is
1537    // `α·K` in the anisotropic metric too.
1538    let chart = duchon_kernel_chart(
1539        centers,
1540        Some(length_scale),
1541        p_order,
1542        s_order,
1543        dim,
1544        Some(eta.as_slice()),
1545        Some(&coeffs),
1546        None,
1547    )
1548    .design_chart();
1549    let mut result = build_aniso_design_psi_derivatives_shared(
1550        data,
1551        centers,
1552        &eta,
1553        p_final,
1554        Some(z_kernel),
1555        identifiability_transform.cloned(),
1556        poly_cols,
1557        RadialScalarKind::Duchon {
1558            length_scale,
1559            p_order,
1560            s_order,
1561            dim,
1562            coeffs,
1563        },
1564        chart,
1565    )?;
1566
1567    let directions: Vec<DuchonPsiDirection> = (0..dim).map(DuchonPsiDirection::Axis).collect();
1568    let native = crate::basis::build_duchon_native_penalty_psi_derivatives_in_directions(
1569        centers,
1570        spec,
1571        identifiability_transform,
1572        workspace,
1573        &directions,
1574    )?;
1575    let operator = if duchon_operator_penalties_requested(&spec.operator_penalties) {
1576        let Some(collocation_points) = operator_collocation_points else {
1577            crate::bail_invalid_basis!(
1578                "Duchon per-axis operator penalty derivatives require realized collocation points"
1579            );
1580        };
1581        crate::basis::build_duchon_operator_penalty_psi_derivatives_in_directions(
1582            collocation_points,
1583            centers,
1584            spec,
1585            identifiability_transform,
1586            workspace,
1587            &directions,
1588        )?
1589    } else {
1590        vec![(Vec::new(), Vec::new(), Vec::new()); dim]
1591    };
1592
1593    // Same order the isotropic bundle ships: native candidates then operator
1594    // candidates, per axis. A mismatch here would misalign the ψ blocks against
1595    // the realized penalty list, so it is asserted rather than assumed.
1596    let mut penalties_first = Vec::with_capacity(dim);
1597    let mut penalties_second_diag = Vec::with_capacity(dim);
1598    let expected = native[0].0.len() + operator[0].0.len();
1599    for axis in 0..dim {
1600        if native[axis].0.len() != native[0].0.len()
1601            || operator[axis].0.len() != operator[0].0.len()
1602        {
1603            crate::bail_invalid_basis!(
1604                "Duchon per-axis penalty source counts disagree across axes: axis {axis} has \
1605                 {}+{} blocks, axis 0 has {}+{}",
1606                native[axis].0.len(),
1607                operator[axis].0.len(),
1608                native[0].0.len(),
1609                operator[0].0.len()
1610            );
1611        }
1612        let mut first = Vec::with_capacity(expected);
1613        let mut second = Vec::with_capacity(expected);
1614        first.extend(native[axis].1.iter().cloned());
1615        first.extend(operator[axis].1.iter().cloned());
1616        second.extend(native[axis].2.iter().cloned());
1617        second.extend(operator[axis].2.iter().cloned());
1618        if first.len() != expected || second.len() != expected {
1619            crate::bail_invalid_basis!(
1620                "Duchon per-axis penalty derivative count mismatch on axis {axis}: assembled \
1621                 {}/{} against {expected} active sources",
1622                first.len(),
1623                second.len()
1624            );
1625        }
1626        penalties_first.push(first);
1627        penalties_second_diag.push(second);
1628    }
1629    result.penalties_first = penalties_first;
1630    result.penalties_second_diag = penalties_second_diag;
1631    // Cross-axis PENALTY seconds are not provided: the outer solve consumes the
1632    // per-axis diagonal seconds plus the operator's exact cross-axis DESIGN
1633    // seconds, and an absent provider is the shape the anisotropic Matérn's
1634    // operator-triplet path already ships.
1635    result.penalties_cross_pairs = Vec::new();
1636    result.penalties_cross_provider = None;
1637    Ok(result)
1638}
1639
1640pub fn build_duchon_basis_log_kappa_derivativeswith_collocationwithworkspace(
1641    data: ArrayView2<'_, f64>,
1642    spec: &DuchonBasisSpec,
1643    centers: ArrayView2<'_, f64>,
1644    identifiability_transform: Option<&Array2<f64>>,
1645    operator_collocation_points: Option<ArrayView2<'_, f64>>,
1646    workspace: &mut BasisWorkspace,
1647) -> Result<BasisPsiDerivativeBundle, BasisError> {
1648    let design_derivatives = build_duchon_design_psi_derivativeswithworkspace(
1649        data,
1650        centers,
1651        spec,
1652        identifiability_transform,
1653        workspace,
1654    )?;
1655    let (native_sources, native_first, native_second) =
1656        build_duchon_native_penalty_psi_derivatives(
1657            centers,
1658            spec,
1659            identifiability_transform,
1660            workspace,
1661        )?;
1662    let (operator_sources, operator_first, operator_second) = if duchon_operator_penalties_requested(
1663        &spec.operator_penalties,
1664    ) {
1665        let Some(collocation_points) = operator_collocation_points else {
1666            crate::bail_invalid_basis!(
1667                "Duchon log-kappa operator penalty derivatives require realized collocation points"
1668            );
1669        };
1670        build_duchon_operator_penalty_psi_derivatives(
1671            collocation_points,
1672            centers,
1673            spec,
1674            identifiability_transform,
1675            workspace,
1676        )?
1677    } else {
1678        (Vec::new(), Vec::new(), Vec::new())
1679    };
1680    let mut penalties_derivative = Vec::with_capacity(native_first.len() + operator_first.len());
1681    penalties_derivative.extend(native_first);
1682    penalties_derivative.extend(operator_first);
1683    let mut penaltiessecond_derivative =
1684        Vec::with_capacity(native_second.len() + operator_second.len());
1685    penaltiessecond_derivative.extend(native_second);
1686    penaltiessecond_derivative.extend(operator_second);
1687    let expected_derivative_count = native_sources.len() + operator_sources.len();
1688    if penalties_derivative.len() != expected_derivative_count {
1689        crate::bail_invalid_basis!(
1690            "Duchon penalty derivative count mismatch: assembled {}, expected {} from active penalty sources",
1691            penalties_derivative.len(),
1692            expected_derivative_count
1693        );
1694    }
1695    Ok(BasisPsiDerivativeBundle {
1696        first: BasisPsiDerivativeResult {
1697            design_derivative: design_derivatives.design_first,
1698            penalties_derivative,
1699            implicit_operator: None,
1700        },
1701        second: BasisPsiSecondDerivativeResult {
1702            designsecond_derivative: design_derivatives.design_second_diag,
1703            penaltiessecond_derivative,
1704            implicit_operator: None,
1705        },
1706        implicit_operator: design_derivatives.implicit_operator,
1707    })
1708}
1709
1710/// Multiplicative amplification factor that lifts an underflowing Duchon
1711/// kernel back into a representable range. Probes max|K_CC| (the kernel at
1712/// every center pair) and returns `1/max` when the kernel collapses to the
1713/// double-precision noise floor; otherwise returns `1.0`.
1714///
1715/// **Why**: in high d with a small length scale the spectral normalization
1716/// `c = κ^{d/2-n} / ((2π)^{d/2}·2^{n-1}·Γ(n))` of the Matérn block is `~1e-14`,
1717/// driving every `K(r) = c · r^ν · K_ν(κr)` to `~1e-16`. Downstream
1718/// `B^T B` is then at `~1e-32` — below `eps²` — and the spectral whitener
1719/// truncates everything as noise, even though the basis is mathematically
1720/// well-defined.
1721///
1722/// Rescaling the basis by α = 1/max|K_CC| produces the same predictions
1723/// (β rescales by α, REML's λ adapts). Since the probe is computed from
1724/// `centers + kernel parameters` which are stored verbatim in
1725/// `BasisMetadata::Duchon`, prediction recomputes an identical α — so
1726/// fit-time and predict-time bases share a single coefficient frame.
1727pub(crate) fn duchon_kernel_amplification(
1728    centers: ArrayView2<'_, f64>,
1729    length_scale: Option<f64>,
1730    p_order: usize,
1731    s_order: usize,
1732    d: usize,
1733    aniso_log_scales: Option<&[f64]>,
1734    coeffs: Option<&DuchonPartialFractionCoeffs>,
1735    pure_poly_coeff: Option<&PolyharmonicBlockCoeff>,
1736) -> f64 {
1737    duchon_kernel_chart(
1738        centers,
1739        length_scale,
1740        p_order,
1741        s_order,
1742        d,
1743        aniso_log_scales,
1744        coeffs,
1745        pure_poly_coeff,
1746    )
1747    .amplification
1748}
1749
1750/// The numerical chart of one realized Duchon kernel block (gam#979).
1751///
1752/// [`duchon_kernel_amplification`] is the amplitude `α` the forward basis
1753/// multiplies into every kernel value; `α` is `1/|K(r*)|` at the center pair
1754/// `(i*, j*)` carrying the largest kernel magnitude whenever that magnitude
1755/// has underflowed, and `1` otherwise. Because `K` moves with the length
1756/// scale and the anisotropy, so does `α` — the design the criterion is built
1757/// on is `α(ψ)·K(ψ)`, and a ψ-derivative of the design that differentiates
1758/// `K` alone is a derivative of something the fit never evaluates. The
1759/// derivative builders read the reference pair from here and form the exact
1760/// ψ-jets of `ln α` from the SAME radial jets they use for every other pair,
1761/// so the charted derivative is the derivative of the charted kernel.
1762#[derive(Clone, Copy, Debug)]
1763pub(crate) struct DuchonKernelChart {
1764    /// `α`: `1/|K(r*)|` when amplified, `1.0` otherwise.
1765    pub(crate) amplification: f64,
1766    /// The `(i*, j*)` center pair whose kernel magnitude defines `α`; `None`
1767    /// when the chart is not amplified (`amplification == 1.0`).
1768    pub(crate) reference_pair: Option<(usize, usize)>,
1769}
1770
1771impl DuchonKernelChart {
1772    pub(crate) const IDENTITY: Self = Self {
1773        amplification: 1.0,
1774        reference_pair: None,
1775    };
1776
1777    pub(crate) fn design_chart(&self) -> crate::basis::DesignKernelChart {
1778        crate::basis::DesignKernelChart {
1779            scale: self.amplification,
1780            reference_pair: self.reference_pair,
1781        }
1782    }
1783}
1784
1785pub(crate) fn duchon_kernel_chart(
1786    centers: ArrayView2<'_, f64>,
1787    length_scale: Option<f64>,
1788    p_order: usize,
1789    s_order: usize,
1790    d: usize,
1791    aniso_log_scales: Option<&[f64]>,
1792    coeffs: Option<&DuchonPartialFractionCoeffs>,
1793    pure_poly_coeff: Option<&PolyharmonicBlockCoeff>,
1794) -> DuchonKernelChart {
1795    let k = centers.nrows();
1796    if k == 0 {
1797        return DuchonKernelChart::IDENTITY;
1798    }
1799    let axis_scales = aniso_log_scales.map(aniso_axis_scales);
1800    // One bound evaluator for the whole k² sweep (the chart is rebuilt for
1801    // every κ trial).
1802    let hybrid = duchon_hybrid_evaluator(length_scale, p_order, s_order, d)
1803        .ok()
1804        .flatten();
1805    let mut max_abs = 0.0_f64;
1806    let mut reference_pair = None;
1807    for i in 0..k {
1808        for j in i..k {
1809            let r = if let Some(scales) = axis_scales.as_deref() {
1810                aniso_distance_rows_with_scales(centers, i, centers, j, scales)
1811            } else {
1812                euclidean_distance_rows(centers, i, centers, j)
1813            };
1814            let val = if let Some(ppc) = pure_poly_coeff {
1815                ppc.eval(r)
1816            } else if let Some(hybrid) = hybrid.as_ref() {
1817                match hybrid.value(r) {
1818                    Ok(v) => v,
1819                    Err(_) => continue,
1820                }
1821            } else {
1822                match duchon_matern_kernel_general_from_distance(
1823                    r,
1824                    length_scale,
1825                    p_order,
1826                    s_order,
1827                    d,
1828                    coeffs,
1829                ) {
1830                    Ok(v) => v,
1831                    Err(_) => continue,
1832                }
1833            };
1834            if val.abs() > max_abs {
1835                max_abs = val.abs();
1836                reference_pair = Some((i, j));
1837            }
1838        }
1839    }
1840    // Only amplify when the kernel has underflowed. The 1e-10 threshold is
1841    // well above any meaningful smoothing-relevant kernel scale yet far from
1842    // 1.0, so well-conditioned kernels pass through unchanged (α = 1).
1843    if max_abs > 0.0 && max_abs < 1e-10 {
1844        DuchonKernelChart {
1845            amplification: 1.0 / max_abs,
1846            reference_pair,
1847        }
1848    } else {
1849        DuchonKernelChart::IDENTITY
1850    }
1851}
1852
1853/// Scalar kernel amplification `α` that [`build_duchon_basis`] applies to the
1854/// pure scale-free polyharmonic Duchon kernel block (`length_scale = None`,
1855/// `power = 0`, no anisotropy) for the given requested null-space `order`.
1856///
1857/// This is the exact factor the forward design multiplies into `K(t,C)` before
1858/// the null-space projection `Z`, so any derivative path that differentiates
1859/// that forward design (e.g. the `duchon_basis_with_jet` FFI, which builds its
1860/// forward via [`build_duchon_basis`] with these same parameters) must scale
1861/// its raw radial jet by the identical `α`. Returning it from the Rust core —
1862/// rather than recomputing the amplification probe in a wrapper — keeps the
1863/// derivative bit-for-bit consistent with the forward and avoids duplicating
1864/// the spectral-normalization math outside this module.
1865///
1866/// The requested `order` is internally degraded via
1867/// [`duchon_effective_nullspace_order`] exactly as the forward builder does, so
1868/// the polyharmonic order `p` used by the amplification probe matches.
1869pub fn duchon_pure_kernel_amplification(
1870    centers: ArrayView2<'_, f64>,
1871    order: DuchonNullspaceOrder,
1872    power: f64,
1873) -> f64 {
1874    let dim = centers.ncols();
1875    if dim == 0 || centers.nrows() == 0 {
1876        return 1.0;
1877    }
1878    let effective_order = duchon_effective_nullspace_order(centers, order);
1879    let p_order = duchon_p_from_nullspace_order(effective_order);
1880    let s_order: f64 = power;
1881    let pure_poly_coeff =
1882        PolyharmonicBlockCoeff::new(pure_duchon_block_order(p_order, s_order), dim);
1883    duchon_kernel_amplification(
1884        centers,
1885        None,
1886        p_order,
1887        duchon_power_to_usize(s_order),
1888        dim,
1889        None,
1890        None,
1891        Some(&pure_poly_coeff),
1892    )
1893}
1894
1895pub(crate) fn build_duchon_basis_designwithworkspace(
1896    data: ArrayView2<'_, f64>,
1897    centers: ArrayView2<'_, f64>,
1898    length_scale: Option<f64>,
1899    power: f64,
1900    nullspace_order: DuchonNullspaceOrder,
1901    aniso_log_scales: Option<&[f64]>,
1902    radial_reparam: Option<&Array2<f64>>,
1903    spectral_kernel_transform: Option<&Array2<f64>>,
1904    workspace: &mut BasisWorkspace,
1905) -> Result<DuchonBasisDesign, BasisError> {
1906    DUCHON_DESIGN_BUILD_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1907    let n = data.nrows();
1908    let d = data.ncols();
1909    let k = centers.nrows();
1910
1911    if d == 0 {
1912        crate::bail_invalid_basis!("Duchon basis requires at least one covariate dimension");
1913    }
1914    if k == 0 {
1915        crate::bail_invalid_basis!("Duchon basis requires at least one center");
1916    }
1917    if centers.ncols() != d {
1918        crate::bail_dim_basis!(
1919            "Duchon basis dimension mismatch: data has {d} columns, centers have {}",
1920            centers.ncols()
1921        );
1922    }
1923    if data.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
1924        crate::bail_invalid_basis!("Duchon basis requires finite data and center values");
1925    }
1926    // Auto-degrade the null-space order to Zero when centers are insufficient
1927    // to span the requested polynomial block; emits a warning inside the helper.
1928    let nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
1929    let p_order = duchon_p_from_nullspace_order(nullspace_order);
1930    let s_order: f64 = power;
1931    // Gate on the spectral power the kernel actually evaluates: the scale-free
1932    // native Gram uses the literal fractional `power`, but the hybrid
1933    // (`length_scale=Some`) partial-fraction kernel reads `s` back through
1934    // `duchon_power_to_usize` (truncating a fractional `power`). Validating the
1935    // raw fractional power on the hybrid path would desync the `2(p+s) > d`
1936    // gate from the realized kernel and let the non-finite-at-origin case
1937    // through (gh#750).
1938    let validation_power = if length_scale.is_some() {
1939        duchon_power_to_usize(s_order) as f64
1940    } else {
1941        s_order
1942    };
1943    validate_duchon_kernel_orders(length_scale, p_order, validation_power, d)?;
1944
1945    // Translation-invariant polynomial frame (#1375, mirroring the #1269 tp fix).
1946    // The Duchon kernel reads only coordinate *differences* `data − centers`, so
1947    // the `K·Z` block is already invariant to a covariate translation `x → x + b`.
1948    // The polynomial null-space block `P = {1, x, x², …}` (appended as explicit
1949    // unpenalized design columns) and the side-condition `P(centers)ᵀα = 0` that
1950    // defines `Z`, however, are assembled at the *absolute* coordinate. With a
1951    // large covariate mean the `{1, x}` columns become near-collinear, the design
1952    // ill-conditions, and REML λ-selection lands in a slightly different basin —
1953    // moving the fit even though `{1, x − x̄}` spans the same model space. Subtract
1954    // the CENTER-CLOUD per-axis mean from both `data` and `centers` before every
1955    // polynomial / side-condition assembly so the polynomial frame is
1956    // location-standardized. The mean is a fixed property of the frozen
1957    // (`UserProvided`) centers — recomputed identically at predict — and under
1958    // `x → x + b` the centers (selected from the data) shift by the same `b`, so
1959    // the centred coordinate, hence the whole basis, is invariant.
1960    let center_mean: Vec<f64> = (0..d)
1961        .map(|c| centers.column(c).sum() / (k.max(1) as f64))
1962        .collect();
1963    let mut data_centered = data.to_owned();
1964    for c in 0..d {
1965        let mu = center_mean[c];
1966        data_centered.column_mut(c).mapv_inplace(|v| v - mu);
1967    }
1968
1969    let poly_block = polynomial_block_from_order(data_centered.view(), nullspace_order);
1970    // Z spans null(Q^T), where Q contains polynomial side conditions at centers.
1971    // Reparameterizing alpha = Z gamma enforces conditional-PD constraints once
1972    // and yields free-parameter penalty gamma^T (Z^T K_CC Z) gamma.
1973    // `kernel_constraint_nullspace` centers `centers` by the same center-cloud
1974    // mean internally (#1375), so the side-condition factorisation matches the
1975    // centered polynomial design columns above and is translation-stable; this is
1976    // the SAME `Z` the penalty path assembles, keeping design and penalty
1977    // consistent.
1978    if radial_reparam.is_some() && spectral_kernel_transform.is_some() {
1979        crate::bail_invalid_basis!(
1980            "Duchon design cannot combine landmark radial reparameterization with a direct \
1981             spectral kernel transform"
1982        );
1983    }
1984    let z_raw = if let Some(spectral) = spectral_kernel_transform {
1985        if spectral.nrows() != centers.nrows() {
1986            crate::bail_dim_basis!(
1987                "Duchon spectral kernel transform shape {:?} does not match {} centers",
1988                spectral.dim(),
1989                centers.nrows()
1990            );
1991        }
1992        spectral.clone()
1993    } else {
1994        kernel_constraint_nullspace(centers, nullspace_order, &mut workspace.cache)?
1995    };
1996    // #1355: fold the frozen data-metric radial reparameterization `V` into the
1997    // constrained kernel transform (`Z' = Z·V`) so the realized design columns
1998    // `K·Z·V` rotate into the `G_c`-orthonormal generalized eigenbasis. Applied
1999    // here identically to the penalty assembly keeps design and penalty
2000    // bit-consistent at fit, predict, and κ-trial time.
2001    let z = if let Some(v) = radial_reparam {
2002        if v.nrows() != z_raw.ncols() {
2003            crate::bail_dim_basis!(
2004                "Duchon radial reparam shape {:?} does not match constrained kernel dimension {}",
2005                v.dim(),
2006                z_raw.ncols()
2007            );
2008        }
2009        fast_ab(&z_raw, v)
2010    } else {
2011        z_raw
2012    };
2013
2014    let coeffs = length_scale
2015        .map(|ls| {
2016            duchon_inverse_length_scale(ls, "Duchon basis design").map(|kappa| {
2017                duchon_partial_fraction_coeffs(p_order, duchon_power_to_usize(s_order), kappa)
2018            })
2019        })
2020        .transpose()?;
2021
2022    // Practical safe operating range (document Eq. D.2):
2023    //   κ in [1e-2 / r_max, 1e2 / r_min]
2024    // where r_min/r_max are pairwise center distance extrema. Under
2025    // anisotropy the kernel metric is y-space (y_a = exp(η_a) x_a), so
2026    // the relevant r_min/r_max are y-space pairwise distances, not raw.
2027    // We keep user-provided κ but emit a warning outside this regime.
2028    let warn_bounds = match (length_scale, aniso_log_scales) {
2029        (Some(_), Some(eta)) => {
2030            let y_centers = points_in_aniso_y_space(centers, eta);
2031            pairwise_distance_bounds(y_centers.view())
2032        }
2033        (Some(_), None) => pairwise_distance_bounds(centers),
2034        (None, _) => None,
2035    };
2036    if let (Some(length_scale), Some((r_min, r_max))) = (length_scale, warn_bounds) {
2037        let kappa = duchon_inverse_length_scale(length_scale, "Duchon basis operating range")?;
2038        let kappa_lo = 1e-2 / r_max;
2039        let kappa_hi = 1e2 / r_min;
2040        if kappa < kappa_lo || kappa > kappa_hi {
2041            log::debug!(
2042                "Duchon κ={} is outside recommended range [{}, {}] derived from centers (r_min={}, r_max={}); numerical conditioning may degrade",
2043                kappa,
2044                kappa_lo,
2045                kappa_hi,
2046                r_min,
2047                r_max
2048            );
2049        }
2050    }
2051
2052    let kernel_cols = z.ncols();
2053    let poly_cols = poly_block.ncols();
2054    let total_cols = kernel_cols + poly_cols;
2055
2056    // Pre-compute polyharmonic coefficient for the pure Duchon case (no length_scale).
2057    // This avoids 2 gamma_lanczos calls per kernel evaluation (n × k total).
2058    let pure_poly_coeff = if length_scale.is_none() {
2059        Some(PolyharmonicBlockCoeff::new(
2060            (pure_duchon_block_order(p_order, s_order)) as f64,
2061            d,
2062        ))
2063    } else {
2064        None
2065    };
2066
2067    let axis_scales = aniso_log_scales.map(aniso_axis_scales);
2068    let kernel_amp = duchon_kernel_amplification(
2069        centers,
2070        length_scale,
2071        p_order,
2072        duchon_power_to_usize(s_order),
2073        d,
2074        aniso_log_scales,
2075        coeffs.as_ref(),
2076        pure_poly_coeff.as_ref(),
2077    );
2078    // Certified radial value profile for the hybrid path (#979): one exact
2079    // hybrid-Duchon kernel value costs microseconds across its
2080    // partial-fraction blocks, and this n·k materialization loop runs on
2081    // every design rebuild of every κ-trial. For large sweeps, profile φ
2082    // once over the observed radius range (distance-only pre-pass) and
2083    // answer per-pair queries by Clenshaw; out-of-range radii and
2084    // uncertified builds fall back to the exact evaluator (the profile's
2085    // exact fallback IS `duchon_radial_jets`, whose value channel is the
2086    // same `duchon_matern_kernel_general_from_distance` evaluated below).
2087    // The hybrid orders are answered from the shape's certified universal
2088    // profile, bound here for the whole n·k sweep.
2089    let hybrid_eval = if pure_poly_coeff.is_some() {
2090        None
2091    } else {
2092        duchon_hybrid_evaluator(length_scale, p_order, duchon_power_to_usize(s_order), d)?
2093    };
2094    let hybrid_kind = match (length_scale, coeffs.as_ref()) {
2095        (Some(ls), Some(c)) if pure_poly_coeff.is_none() => Some(RadialScalarKind::Duchon {
2096            length_scale: ls,
2097            p_order,
2098            s_order: duchon_power_to_usize(s_order),
2099            dim: d,
2100            coeffs: c.clone(),
2101        }),
2102        _ => None,
2103    };
2104    // A bound evaluator already answers those orders exactly; the per-build
2105    // Chebyshev profile below would approximate the same universal G a second
2106    // time, and the n·k distance pre-pass that sizes it would walk every pair
2107    // before the loop that walks them again.
2108    let value_profile = hybrid_kind.as_ref().filter(|_| hybrid_eval.is_none()).and_then(|kind| {
2109        if n.saturating_mul(k) < RADIAL_PROFILE_MIN_PAIRS {
2110            return None;
2111        }
2112        let (r_lo, r_hi) = (0..n)
2113            .into_par_iter()
2114            .map(|i| {
2115                let mut lo = f64::INFINITY;
2116                let mut hi = 0.0_f64;
2117                for j in 0..k {
2118                    let r = if let Some(scales) = axis_scales.as_deref() {
2119                        aniso_distance_rows_with_scales(data, i, centers, j, scales)
2120                    } else {
2121                        euclidean_distance_rows(data, i, centers, j)
2122                    };
2123                    if r > 0.0 {
2124                        lo = lo.min(r);
2125                        hi = hi.max(r);
2126                    }
2127                }
2128                (lo, hi)
2129            })
2130            .reduce(
2131                || (f64::INFINITY, 0.0_f64),
2132                |a, b| (a.0.min(b.0), a.1.max(b.1)),
2133            );
2134        if r_lo.is_finite() && r_hi > r_lo {
2135            radial_profile::RadialProfile::build(kind, r_lo, r_hi)
2136        } else {
2137            None
2138        }
2139    });
2140    let mut basis = Array2::<f64>::zeros((n, total_cols));
2141    // Process rows in chunks to amortize thread-local allocation across many rows.
2142    // Use larger chunks (1024) for better cache utilization at large scale.
2143    let chunk_size = 1024.min(n);
2144    let basis_result: Result<(), BasisError> = basis
2145        .axis_chunks_iter_mut(Axis(0), chunk_size)
2146        .into_par_iter()
2147        .enumerate()
2148        .try_for_each(|(ci, mut chunk)| {
2149            let rows = chunk.nrows();
2150            let chunk_start = ci * chunk_size;
2151            // The chunk's kernel block `K` (rows × k), filled row by row and
2152            // then multiplied by `Z` in ONE call.
2153            let mut kernel_block = Array2::<f64>::zeros((rows, k));
2154            for local_i in 0..rows {
2155                let i = chunk_start + local_i;
2156                let mut kernel_row = kernel_block.row_mut(local_i);
2157                for j in 0..k {
2158                    let r = if let Some(scales) = axis_scales.as_deref() {
2159                        aniso_distance_rows_with_scales(data, i, centers, j, scales)
2160                    } else {
2161                        euclidean_distance_rows(data, i, centers, j)
2162                    };
2163                    let raw = if let Some(ref ppc) = pure_poly_coeff {
2164                        // Pure Duchon: use precomputed coefficient, skip gamma calls.
2165                        ppc.eval(r)
2166                    } else if let Some(hybrid) = hybrid_eval.as_ref() {
2167                        hybrid.value(r)?
2168                    } else if let (Some(profile), Some(kind)) =
2169                        (value_profile.as_ref(), hybrid_kind.as_ref())
2170                    {
2171                        profile.eval_or_exact(kind, r)?.0
2172                    } else {
2173                        duchon_matern_kernel_general_from_distance(
2174                            r,
2175                            length_scale,
2176                            p_order,
2177                            duchon_power_to_usize(s_order),
2178                            d,
2179                            coeffs.as_ref(),
2180                        )?
2181                    };
2182                    kernel_row[j] = raw * kernel_amp;
2183                }
2184            }
2185            // The kernel block times the identifiability transform,
2186            // `basis[chunk] = K · Z`, is a matrix product: one GEMM per chunk
2187            // instead of a scatter-accumulate over `rows · k` rows of `Z`.
2188            //
2189            // Written out by hand this is the dominant cost of a wide design:
2190            // `n · k · kernel_cols` scalar updates through bounds-checked
2191            // indexing measured ≈ 1 GFLOP/s across the pool on the 6-D
2192            // isotropic Duchon fit (n = 50 000, k = 100, 8 threads: 19 % of
2193            // the whole fit's samples sat in this closure's own frame, and the
2194            // per-κ-trial rebuild grows with `k²`). faer's kernel does the
2195            // same arithmetic blocked and vectorised. It runs sequentially
2196            // here because the chunk loop is already the parallel region — the
2197            // pool is saturated by chunks, not by one product (#2735).
2198            let mut product = Array2::<f64>::zeros((rows, kernel_cols));
2199            gam_linalg::faer_ndarray::with_faer_sequential(|| {
2200                gam_linalg::faer_ndarray::fast_ab_into(&kernel_block, &z, &mut product)
2201            });
2202            chunk.slice_mut(s![.., ..kernel_cols]).assign(&product);
2203            Ok(())
2204        });
2205    basis_result?;
2206    if poly_cols > 0 {
2207        basis.slice_mut(s![.., kernel_cols..]).assign(&poly_block);
2208    }
2209
2210    Ok(DuchonBasisDesign { basis })
2211}
2212
2213/// Generic Duchon builder returning design + penalty list.
2214pub fn build_duchon_basis(
2215    data: ArrayView2<'_, f64>,
2216    spec: &DuchonBasisSpec,
2217) -> Result<BasisBuildResult, BasisError> {
2218    let mut workspace = BasisWorkspace::default();
2219    build_duchon_basiswithworkspace(data, spec, &mut workspace)
2220}
2221
2222pub fn create_duchon_basis_1d_derivative_dense(
2223    t: ArrayView1<'_, f64>,
2224    centers: ArrayView1<'_, f64>,
2225    power: f64,
2226    nullspace_order: DuchonNullspaceOrder,
2227    periodic: bool,
2228    period: Option<f64>,
2229    order: usize,
2230) -> Result<Array2<f64>, BasisError> {
2231    create_duchon_basis_1d_derivative_dense_with_radial_reparam(
2232        t,
2233        centers,
2234        power,
2235        nullspace_order,
2236        periodic,
2237        period,
2238        None,
2239        order,
2240    )
2241}
2242
2243/// Evaluate a 1-D Duchon design derivative in an already-frozen radial chart.
2244/// Position-batched consumers compute the data-metric chart once from the
2245/// complete ragged batch and reuse it for every segment; without this argument
2246/// each segment differentiates a different coefficient basis.
2247pub fn create_duchon_basis_1d_derivative_dense_with_radial_reparam(
2248    t: ArrayView1<'_, f64>,
2249    centers: ArrayView1<'_, f64>,
2250    power: f64,
2251    nullspace_order: DuchonNullspaceOrder,
2252    periodic: bool,
2253    period: Option<f64>,
2254    radial_reparam: Option<ArrayView2<'_, f64>>,
2255    order: usize,
2256) -> Result<Array2<f64>, BasisError> {
2257    if order > 2 {
2258        crate::bail_invalid_basis!(
2259            "Duchon basis derivative supports orders 0, 1, and 2; got order={order}"
2260        );
2261    }
2262    if t.is_empty() || centers.is_empty() {
2263        crate::bail_invalid_basis!("Duchon basis derivative requires non-empty t and centers");
2264    }
2265    if t.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
2266        crate::bail_invalid_basis!("Duchon basis derivative requires finite t and center values");
2267    }
2268    if !periodic && period.is_some() {
2269        crate::bail_invalid_basis!(
2270            "Duchon basis derivative period is only valid when periodic=true"
2271        );
2272    }
2273    if periodic && radial_reparam.is_some() {
2274        crate::bail_invalid_basis!(
2275            "periodic 1-D Duchon derivatives do not admit an open-domain radial reparameterization"
2276        );
2277    }
2278
2279    let data = t.to_owned().insert_axis(Axis(1));
2280    let center_matrix = centers.to_owned().insert_axis(Axis(1));
2281    let mut workspace = BasisWorkspace::default();
2282    // The user-requested Duchon order ``m`` is encoded in ``nullspace_order``;
2283    // the PERIODIC kernel is the Bernoulli Green's function of ``(d²/dx²)^m``
2284    // (PSD on the circle, gam#580) so it needs the original ``m`` even though
2285    // the periodic *constraint* nullspace is forced to constants only.
2286    let user_m = duchon_p_from_nullspace_order(nullspace_order);
2287    let effective_order = if periodic {
2288        DuchonNullspaceOrder::Zero
2289    } else {
2290        duchon_effective_nullspace_order(center_matrix.view(), nullspace_order)
2291    };
2292    let p_order = duchon_p_from_nullspace_order(effective_order);
2293    let s_order = duchon_power_to_usize(power);
2294    validate_duchon_kernel_orders(None, p_order, s_order as f64, 1)?;
2295
2296    if periodic {
2297        // Periodic case: mirror the forward Bernoulli Green's-function design
2298        // (`build_periodic_duchon_basis_1d`) EXACTLY — same collapsed centers,
2299        // same domain-wrap period, same constant-only constraint nullspace —
2300        // so the analytic derivative is the true ∂/∂t of the forward design
2301        // (gam#580). Using the polyharmonic triangle-wave kernel here (the old
2302        // path) was inconsistent with the Bernoulli forward and silently wrong.
2303        let (collapsed_centers, left, resolved_period) =
2304            prepare_periodic_duchon_centers_1d_with_period(center_matrix, period)?;
2305        let z = kernel_constraint_nullspace(
2306            collapsed_centers.view(),
2307            effective_order,
2308            &mut workspace.cache,
2309        )?;
2310        let kernel_cols = z.ncols();
2311        let k_centers = collapsed_centers.nrows();
2312        let centers_col0: Vec<f64> = collapsed_centers.column(0).to_vec();
2313        let mut raw_kernel = Array2::<f64>::zeros((t.len(), k_centers));
2314        for i in 0..t.len() {
2315            let x = wrap_to_period(t[i], left, resolved_period);
2316            for j in 0..k_centers {
2317                // Signed offset reduced to [−period/2, period/2]; r = |offset|.
2318                let mut delta = (x - centers_col0[j]).rem_euclid(resolved_period);
2319                if delta > 0.5 * resolved_period {
2320                    delta -= resolved_period;
2321                }
2322                let r = delta.abs();
2323                let sign = if delta > 0.0 {
2324                    1.0
2325                } else if delta < 0.0 {
2326                    -1.0
2327                } else {
2328                    0.0
2329                };
2330                let (phi, phi_r, phi_rr) =
2331                    periodic_duchon_kernel_bernoulli_triplet(r, user_m, resolved_period)?;
2332                raw_kernel[[i, j]] = match order {
2333                    0 => phi,
2334                    1 => phi_r * sign,
2335                    2 => phi_rr,
2336                    other => {
2337                        crate::bail_invalid_basis!(
2338                            "Duchon basis derivative supports orders 0, 1, and 2; got order={other}"
2339                        );
2340                    }
2341                };
2342            }
2343        }
2344        // Forward design appends a single constant column; its t-derivative is
2345        // zero (order ≥ 1) or one (order 0). Match that layout exactly.
2346        let mut basis = Array2::<f64>::zeros((t.len(), kernel_cols + 1));
2347        let design_kernel = fast_ab(&raw_kernel, &z);
2348        basis
2349            .slice_mut(s![.., 0..kernel_cols])
2350            .assign(&design_kernel);
2351        if order == 0 {
2352            basis.column_mut(kernel_cols).fill(1.0);
2353        }
2354        return Ok(basis);
2355    }
2356
2357    let mut z =
2358        kernel_constraint_nullspace(center_matrix.view(), effective_order, &mut workspace.cache)?;
2359    if let Some(radial_reparam) = radial_reparam {
2360        if radial_reparam.nrows() != z.ncols() {
2361            crate::bail_dim_basis!(
2362                "Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
2363                radial_reparam.dim(),
2364                z.ncols()
2365            );
2366        }
2367        z = fast_ab(&z, &radial_reparam.to_owned());
2368    }
2369    let kernel_cols = z.ncols();
2370    let poly_cols = polynomial_block_from_order(data.view(), effective_order).ncols();
2371
2372    let pure_coeff =
2373        PolyharmonicBlockCoeff::new((pure_duchon_block_order(p_order, s_order as f64)) as f64, 1);
2374    let kernel_amp = duchon_kernel_amplification(
2375        center_matrix.view(),
2376        None,
2377        p_order,
2378        s_order,
2379        1,
2380        None,
2381        None,
2382        Some(&pure_coeff),
2383    );
2384
2385    let mut raw_kernel = Array2::<f64>::zeros((t.len(), centers.len()));
2386    for i in 0..t.len() {
2387        let x = t[i];
2388        for j in 0..centers.len() {
2389            let delta = x - centers[j];
2390            let r = delta.abs();
2391            let sign = if delta > 0.0 {
2392                1.0
2393            } else if delta < 0.0 {
2394                -1.0
2395            } else {
2396                0.0
2397            };
2398            let (phi, phi_r, phi_rr) =
2399                duchon_kernel_radial_triplet(r, None, p_order, s_order as f64, 1, None)?;
2400            raw_kernel[[i, j]] = match order {
2401                0 => phi,
2402                1 => phi_r * sign,
2403                2 => phi_rr,
2404                other => {
2405                    crate::bail_invalid_basis!(
2406                        "Duchon basis derivative supports orders 0, 1, and 2; got order={other}"
2407                    );
2408                }
2409            } * kernel_amp;
2410        }
2411    }
2412
2413    let mut basis = Array2::<f64>::zeros((t.len(), kernel_cols + poly_cols));
2414    let design_kernel = fast_ab(&raw_kernel, &z);
2415    basis
2416        .slice_mut(s![.., 0..kernel_cols])
2417        .assign(&design_kernel);
2418    fill_duchon_1d_polynomial_derivative(&mut basis, kernel_cols, t, effective_order, order);
2419    Ok(basis)
2420}
2421
2422#[cfg(test)]
2423mod taylor_degree_tests {
2424    use super::*;
2425
2426    /// gam#1604 — the half-integer-ν Matérn block Taylor coefficients. For
2427    /// |ν| = l + ½ the block has the elementary closed form
2428    /// `c · r^ν K_ν(κr) = c · √(π/2κ) · e^{−κr} · P(κ,r)` with P a finite
2429    /// Laurent polynomial, so the exact `r^{2j}` coefficients are clean rationals
2430    /// (no log term). At κ = 1, d = 1:
2431    ///   • n = 2 (ν = 3/2): block = ¼ (r + 1) e^{−r}        → [0.25, −0.125, −0.03125]
2432    ///   • n = 3 (ν = 5/2): block = 1/16 (r² + 3r + 3) e^{−r} → [0.1875, −0.03125, 0.0078125]
2433    /// The earlier `l = round(2|ν| − 1)` miscount used the K_{5/2} / K_{9/2}
2434    /// polynomials for these (degree 2|ν|−½, not |ν|), collapsing the j = 0 term
2435    /// to exactly 0. These references would all fail under that bug.
2436    #[test]
2437    fn half_integer_matern_taylor_coeffs_1604() {
2438        let want_nu_3_2 = [0.25_f64, -0.125, -0.03125];
2439        let want_nu_5_2 = [0.1875_f64, -0.03125, 0.0078125];
2440        for (j, &want) in want_nu_3_2.iter().enumerate() {
2441            let (pure, log) = duchon_matern_block_taylor_r2j(1.0, 2, 1, j);
2442            assert!(log == 0.0, "no log term for half-integer ν (j={j}): {log}");
2443            assert!(
2444                (pure - want).abs() < 1e-13,
2445                "ν=3/2 r^{{{}}} coeff: got {pure:.15}, want {want}",
2446                2 * j
2447            );
2448        }
2449        for (j, &want) in want_nu_5_2.iter().enumerate() {
2450            let (pure, log) = duchon_matern_block_taylor_r2j(1.0, 3, 1, j);
2451            assert!(log == 0.0, "no log term for half-integer ν (j={j}): {log}");
2452            assert!(
2453                (pure - want).abs() < 1e-13,
2454                "ν=5/2 r^{{{}}} coeff: got {pure:.15}, want {want}",
2455                2 * j
2456            );
2457        }
2458    }
2459
2460    /// gam#1604 — the j = 0 Taylor coefficient must equal the r → 0⁺ limit of the
2461    /// block computed independently via the real Bessel-K value path
2462    /// (`r^ν K_ν(κr) → 2^{ν−1} Γ(ν) κ^{−ν}` for ν > 0). Sweeps half-integer ν up
2463    /// to 7/2 and several κ; the regressed code returned 0 for ν ≥ 3/2.
2464    #[test]
2465    fn half_integer_matern_taylor_j0_matches_value_limit_1604() {
2466        let d = 1usize;
2467        for n in 1..=4usize {
2468            let nu = n as f64 - 0.5 * d as f64; // ν = n − ½ ∈ {0.5, 1.5, 2.5, 3.5}
2469            for &kappa in &[0.3_f64, 1.0, 2.0, 7.5] {
2470                let (pure, _log) = duchon_matern_block_taylor_r2j(kappa, n, d, 0);
2471                // Independent r→0⁺ limit through the value path.
2472                let want = duchon_matern_block(0.0, kappa, n, d).expect("r→0 limit");
2473                let rel = (pure - want).abs() / want.abs().max(1e-300);
2474                assert!(
2475                    rel < 1e-12,
2476                    "ν={nu} κ={kappa}: Taylor j=0 {pure:.15e} vs value limit {want:.15e} (rel {rel:.2e})"
2477                );
2478            }
2479        }
2480    }
2481}
2482
2483#[cfg(test)]
2484mod end_to_end_1604_tests {
2485    use super::*;
2486    use gam_linalg::faer_ndarray::FaerEigh;
2487
2488    /// gam#1604 — end-to-end: a 1-D hybrid Duchon smooth with power ≥ 2 must
2489    /// build successfully through the public `build_duchon_basis` path and emit
2490    /// numerically-PSD penalties. Before the half-integer-ν Taylor-degree fix the
2491    /// corrupted collision diagonal made the constrained native penalty
2492    /// indefinite, so the build's PSD guard rejected it outright — the issue's
2493    /// "any d=1 Duchon smooth with power ≥ 2 currently cannot be fitted".
2494    #[test]
2495    fn d1_hybrid_duchon_power_ge_2_builds_psd() {
2496        // A clustered + spread 1-D sample so center spacing is non-trivial.
2497        let n = 40usize;
2498        let mut data = Array2::<f64>::zeros((n, 1));
2499        for i in 0..n {
2500            data[[i, 0]] = -1.0 + 2.0 * (i as f64) / (n as f64 - 1.0);
2501        }
2502        for &power in &[2.0f64, 3.0] {
2503            let spec = DuchonBasisSpec {
2504                center_strategy: CenterStrategy::FarthestPoint { num_centers: 12 },
2505                periodic: None,
2506                length_scale: Some(0.5),
2507                power,
2508                nullspace_order: DuchonNullspaceOrder::Linear,
2509                identifiability: SpatialIdentifiability::None,
2510                aniso_log_scales: None,
2511                operator_penalties: DuchonOperatorPenaltySpec::default(),
2512                boundary: OneDimensionalBoundary::Open,
2513                radial_reparam: None,
2514            };
2515            let result = build_duchon_basis(data.view(), &spec).unwrap_or_else(|e| {
2516                panic!("d=1 hybrid Duchon power={power} build rejected (gam#1604): {e}")
2517            });
2518            assert!(
2519                !result.active_penalties.is_empty(),
2520                "d=1 hybrid Duchon power={power} produced no penalty"
2521            );
2522            for (k, penalty) in result.active_penalties.iter().enumerate() {
2523                let sym = symmetrize_penalty(&penalty.matrix);
2524                let (evals, _) =
2525                    FaerEigh::eigh(&sym, faer::Side::Lower).expect("symmetric eigendecomposition");
2526                let lam_min = evals.iter().copied().fold(f64::INFINITY, f64::min);
2527                let lam_max = evals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2528                let tol = 1e-9 * lam_max.abs().max(1.0);
2529                assert!(
2530                    lam_min >= -tol,
2531                    "d=1 hybrid Duchon power={power} penalty[{k}] not PSD: λ_min={lam_min:.6e} (tol={tol:.3e})"
2532                );
2533            }
2534        }
2535    }
2536}