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