Skip to main content

gam_terms/basis/
duchon_kernel_math.rs

1use super::*;
2
3pub fn build_duchon_collocation_operator_matrices(
4    centers: ArrayView2<'_, f64>,
5    collocationweights: Option<ArrayView1<'_, f64>>,
6    length_scale: Option<f64>,
7    power: f64,
8    nullspace_order: DuchonNullspaceOrder,
9    aniso_log_scales: Option<&[f64]>,
10    identifiability_transform: Option<ArrayView2<'_, f64>>,
11    max_operator_derivative_order: usize,
12) -> Result<CollocationOperatorMatrices, BasisError> {
13    let mut workspace = BasisWorkspace::default();
14    build_duchon_collocation_operator_matriceswithworkspace(
15        centers,
16        centers,
17        collocationweights,
18        length_scale,
19        power,
20        nullspace_order,
21        aniso_log_scales,
22        identifiability_transform,
23        max_operator_derivative_order,
24        None,
25        &mut workspace,
26    )
27}
28
29pub fn build_duchon_operator_penalty_matrices(
30    centers: ArrayView2<'_, f64>,
31    collocationweights: Option<ArrayView1<'_, f64>>,
32    length_scale: Option<f64>,
33    power: f64,
34    nullspace_order: DuchonNullspaceOrder,
35    aniso_log_scales: Option<&[f64]>,
36    identifiability_transform: Option<ArrayView2<'_, f64>>,
37) -> Result<DuchonOperatorPenaltyMatrices, BasisError> {
38    let ops = build_duchon_collocation_operator_matrices(
39        centers,
40        collocationweights,
41        length_scale,
42        power,
43        nullspace_order,
44        aniso_log_scales,
45        identifiability_transform,
46        2,
47    )?;
48    let (mass, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d0)));
49    let (tension, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d1)));
50    let (stiffness, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d2)));
51    Ok(DuchonOperatorPenaltyMatrices {
52        mass,
53        tension,
54        stiffness,
55    })
56}
57
58pub fn build_thin_plate_penalty_matrix(
59    centers: ArrayView2<'_, f64>,
60    length_scale: f64,
61) -> Result<ThinPlatePenaltyMatrix, BasisError> {
62    let mut workspace = BasisWorkspace::default();
63    let kernel_transform = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
64    let (penalty, _) =
65        build_thin_plate_penalty_matrices(centers, length_scale, &kernel_transform, false)?;
66    let (penalty, _) = normalize_penalty(&penalty);
67    Ok(ThinPlatePenaltyMatrix { penalty })
68}
69
70pub fn build_duchon_collocation_operator_matriceswithworkspace(
71    centers: ArrayView2<'_, f64>,
72    collocation_points: ArrayView2<'_, f64>,
73    collocationweights: Option<ArrayView1<'_, f64>>,
74    length_scale: Option<f64>,
75    power: f64,
76    nullspace_order: DuchonNullspaceOrder,
77    aniso_log_scales: Option<&[f64]>,
78    identifiability_transform: Option<ArrayView2<'_, f64>>,
79    max_operator_derivative_order: usize,
80    radial_reparam: Option<ArrayView2<'_, f64>>,
81    workspace: &mut BasisWorkspace,
82) -> Result<CollocationOperatorMatrices, BasisError> {
83    // The operator design rows are the COLLOCATION points (a density-blind,
84    // space-filling sample of the data support); the columns are the `k` basis
85    // CENTERS. Decoupling them is what makes the operator penalty a faithful
86    // quadrature of `∫‖Dᵠf‖²` (collocating at the `k` centers themselves — the
87    // old `collocation_points == centers` special case — under-samples a
88    // `k`-bump basis and is what made these penalties explode).
89    let nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
90    // Auto-raise the null-space order (p) so the pointwise kernel and every
91    // active derivative-collocation operator clear their well-posedness margin
92    // `2(p + s) > d + max_op` BEFORE the guard in
93    // `validate_duchon_collocation_orders` can fire. Mirrors the auto-degrade
94    // above; only `p` is lifted, so the spectral power and CPD condition are
95    // untouched. See `duchon_order_for_operator_margin`.
96    let nullspace_order = duchon_order_for_operator_margin(
97        centers.ncols(),
98        power,
99        nullspace_order,
100        max_operator_derivative_order,
101    );
102    let p_order = duchon_p_from_nullspace_order(nullspace_order);
103    let s_order: f64 = power;
104    let p_colloc = collocation_points.nrows();
105    let n_basis = centers.nrows();
106    let dim = centers.ncols();
107    if collocation_points.ncols() != dim {
108        crate::bail_dim_basis!(
109            "collocation points dim {} != centers dim {dim}",
110            collocation_points.ncols()
111        );
112    }
113    validate_duchon_collocation_orders(
114        length_scale,
115        p_order,
116        s_order,
117        dim,
118        max_operator_derivative_order,
119    )?;
120    if let Some(eta) = aniso_log_scales
121        && eta.len() != dim
122    {
123        crate::bail_dim_basis!(
124            "Duchon anisotropy dimension mismatch: got {}, expected {dim}",
125            eta.len()
126        );
127    }
128    // Partial-fraction expansion only runs in the hybrid Matérn branch
129    // (`length_scale = Some`). The scale-free path (`length_scale = None`)
130    // skips it entirely and is fractional-clean down to the Riesz kernel.
131    let coeffs = length_scale.map(|scale| {
132        let s_int = duchon_power_to_usize(s_order);
133        duchon_partial_fraction_coeffs(p_order, s_int, 1.0 / scale.max(1e-300))
134    });
135    let metric_weights: Option<Vec<f64>> = aniso_log_scales.map(centered_aniso_metric_weights);
136    let row_scales = if let Some(w) = collocationweights {
137        if w.len() != p_colloc {
138            crate::bail_dim_basis!(
139                "collocation weight length mismatch: got {}, expected {p_colloc}",
140                w.len()
141            );
142        }
143        let mut out = Vec::with_capacity(p_colloc);
144        for &wk in w {
145            if !wk.is_finite() || wk < 0.0 {
146                crate::bail_invalid_basis!(
147                    "collocation weights must be finite and non-negative; got {wk}"
148                );
149            }
150            out.push(wk.sqrt());
151        }
152        out
153    } else {
154        vec![1.0; p_colloc]
155    };
156    let mut z = kernel_constraint_nullspace(centers, nullspace_order, &mut workspace.cache)?;
157    // #1355 cliff reparam consistency: when the design's constrained kernel
158    // columns are rotated into the data-metric generalized eigenbasis
159    // (`K·Z·V`), the operator collocation designs D0/D1/D2 must live in the SAME
160    // `Z·V` frame or their emitted penalties would penalize the wrong
161    // coefficients (a design↔penalty basis desync). Fold the frozen `V` into `Z`
162    // here so every operator block is assembled directly in the fit-time
163    // `K·Z·V` basis — exactly as the native `Primary` penalty already is. Guard
164    // on the column count: `V` was solved against the design's constrained
165    // kernel dimension (`Z.ncols()` at the design's null-space order); if the
166    // operator margin auto-raised the order the dims differ and `V` does not
167    // apply, so the block is left in the raw `Z` frame (the pre-reparam
168    // behavior, no regression for those configs).
169    if let Some(v) = radial_reparam {
170        if v.nrows() == z.ncols() {
171            z = fast_ab(&z, &v);
172        }
173    }
174    // D0/D1/D2 rows = collocation points (`p_colloc`), columns = basis centers
175    // (`n_basis`). Gradients/Hessians are taken w.r.t. the EVALUATION point
176    // (the collocation row), so `delta = collocation - center`. No symmetry: the
177    // two point sets differ in general.
178    // Skip the costly higher-derivative designs the caller doesn't need: mass
179    // (D0) + tension (D1) build with `max_op = 1`, so the `O(d²)`-row Hessian
180    // (D2) is never allocated or filled — decisive in high `d`.
181    let build_d1 = max_operator_derivative_order >= 1;
182    let build_d2 = max_operator_derivative_order >= 2;
183    let mut d0_raw = Array2::<f64>::zeros((p_colloc, n_basis));
184    let mut d1_raw = Array2::<f64>::zeros((if build_d1 { p_colloc * dim } else { 0 }, n_basis));
185    let mut d2_raw =
186        Array2::<f64>::zeros((if build_d2 { p_colloc * dim * dim } else { 0 }, n_basis));
187    const R_EPS: f64 = 1e-10;
188    for i in 0..p_colloc {
189        let scale_i = row_scales[i];
190        for j in 0..n_basis {
191            let r = if let Some(eta) = aniso_log_scales {
192                let row_i: Vec<f64> = (0..dim).map(|a| collocation_points[[i, a]]).collect();
193                let row_j: Vec<f64> = (0..dim).map(|a| centers[[j, a]]).collect();
194                aniso_distance(&row_i, &row_j, eta)
195            } else {
196                stable_euclidean_norm(
197                    (0..dim).map(|axis| collocation_points[[i, axis]] - centers[[j, axis]]),
198                )
199            };
200            // Floor coincident collocation/center pairs off the kernel's origin
201            // singularity: a farthest-point sample can land exactly on a center.
202            // The gradient/Hessian limits at r→0 are the zeros the `r > R_EPS`
203            // guards below already produce, so flooring only avoids the log-case
204            // `r²·log r` second-derivative blow-up at exact r=0.
205            let r = r.max(R_EPS);
206            let (phi, q, t) = if let (Some(length_scale), Some(coeffs)) =
207                (length_scale, coeffs.as_ref())
208            {
209                let jets =
210                    duchon_radial_jets(r, length_scale, p_order, s_order as usize, dim, coeffs)?;
211                (jets.phi, jets.q, jets.t)
212            } else {
213                let (phi, phi_r, phi_rr) = duchon_kernel_radial_triplet(
214                    r,
215                    length_scale,
216                    p_order,
217                    s_order,
218                    dim,
219                    coeffs.as_ref(),
220                )?;
221                let q = if r > R_EPS { phi_r / r } else { phi_rr };
222                let t = if r > R_EPS {
223                    (phi_rr - q) / (r * r)
224                } else {
225                    0.0
226                };
227                (phi, q, t)
228            };
229            if !phi.is_finite() || !q.is_finite() || !t.is_finite() {
230                crate::bail_invalid_basis!(
231                    "non-finite Duchon collocation operator derivative at (colloc {i}, center {j}), r={r}"
232                );
233            }
234            d0_raw[[i, j]] = scale_i * phi;
235            if build_d2 {
236                for axis_a in 0..dim {
237                    let h_a = collocation_points[[i, axis_a]] - centers[[j, axis_a]];
238                    let w_a = metric_weights
239                        .as_ref()
240                        .map(|weights| weights[axis_a])
241                        .unwrap_or(1.0);
242                    for axis_b in 0..dim {
243                        let h_b = collocation_points[[i, axis_b]] - centers[[j, axis_b]];
244                        let w_b = metric_weights
245                            .as_ref()
246                            .map(|weights| weights[axis_b])
247                            .unwrap_or(1.0);
248                        let diagonal = if axis_a == axis_b { q * w_a } else { 0.0 };
249                        let mixed = if r > R_EPS {
250                            t * w_a * h_a * w_b * h_b
251                        } else {
252                            0.0
253                        };
254                        let value = diagonal + mixed;
255                        let row_i = (i * dim + axis_a) * dim + axis_b;
256                        d2_raw[[row_i, j]] = scale_i * value;
257                    }
258                }
259            }
260            if build_d1 && r > R_EPS {
261                for axis in 0..dim {
262                    let delta = collocation_points[[i, axis]] - centers[[j, axis]];
263                    let axis_scale = metric_weights
264                        .as_ref()
265                        .map(|weights| weights[axis])
266                        .unwrap_or(1.0);
267                    d1_raw[[i * dim + axis, j]] = scale_i * q * axis_scale * delta;
268                }
269            }
270        }
271    }
272    let d0_kernel = fast_ab(&d0_raw, &z);
273    let poly = polynomial_block_from_order(centers, nullspace_order);
274    let poly_collocation = polynomial_block_from_order(collocation_points, nullspace_order);
275    let poly_d1 = if build_d1 {
276        polynomial_derivative_block(collocation_points, nullspace_order, 1)
277    } else {
278        Array2::<f64>::zeros((0, poly.ncols()))
279    };
280    let poly_d2 = if build_d2 {
281        polynomial_derivative_block(collocation_points, nullspace_order, 2)
282    } else {
283        Array2::<f64>::zeros((0, poly.ncols()))
284    };
285    let kernel_cols = d0_kernel.ncols();
286    let poly_cols = poly.ncols();
287    let total_cols = kernel_cols + poly_cols;
288    // The operator matrices act on the SAME coefficient basis as the emitted
289    // design: constrained radial columns followed by explicit polynomial
290    // null-space columns.  The lower-order Hilbert-scale penalties are function
291    // penalties, not just radial-kernel penalties, so the polynomial block must
292    // be evaluated/differentiated at the collocation sites too: D0 sees the
293    // polynomial value, D1 its gradient, and D2 its Hessian. Orders the caller
294    // skipped stay empty (0 rows).
295    let mut d0 = Array2::<f64>::zeros((p_colloc, total_cols));
296    d0.slice_mut(s![.., 0..kernel_cols]).assign(&d0_kernel);
297    d0.slice_mut(s![.., kernel_cols..total_cols])
298        .assign(&poly_collocation);
299    let mut d1 = Array2::<f64>::zeros((if build_d1 { p_colloc * dim } else { 0 }, total_cols));
300    if build_d1 {
301        d1.slice_mut(s![.., 0..kernel_cols])
302            .assign(&fast_ab(&d1_raw, &z));
303        d1.slice_mut(s![.., kernel_cols..total_cols])
304            .assign(&poly_d1);
305    }
306    let mut d2 =
307        Array2::<f64>::zeros((if build_d2 { p_colloc * dim * dim } else { 0 }, total_cols));
308    if build_d2 {
309        d2.slice_mut(s![.., 0..kernel_cols])
310            .assign(&fast_ab(&d2_raw, &z));
311        d2.slice_mut(s![.., kernel_cols..total_cols])
312            .assign(&poly_d2);
313    }
314    if let Some(z) = identifiability_transform {
315        let z = z.to_owned();
316        d0 = fast_ab(&d0, &z);
317        d1 = fast_ab(&d1, &z);
318        d2 = fast_ab(&d2, &z);
319    }
320    Ok(CollocationOperatorMatrices {
321        d0,
322        d1,
323        d2,
324        collocation_points: collocation_points.to_owned(),
325        kernel_nullspace_transform: Some(z),
326        polynomial_block_cols: poly_cols,
327    })
328}
329
330pub(crate) fn polynomial_derivative_block(
331    points: ArrayView2<'_, f64>,
332    order: DuchonNullspaceOrder,
333    derivative_order: usize,
334) -> Array2<f64> {
335    let n = points.nrows();
336    let d = points.ncols();
337    let degree = match order {
338        DuchonNullspaceOrder::Zero => 0,
339        DuchonNullspaceOrder::Linear => 1,
340        DuchonNullspaceOrder::Degree(degree) => degree,
341    };
342    let exponents = monomial_exponents(d, degree);
343    match derivative_order {
344        1 => {
345            let mut block = Array2::<f64>::zeros((n * d, exponents.len()));
346            for row in 0..n {
347                for axis in 0..d {
348                    let out_row = row * d + axis;
349                    for (col, exps) in exponents.iter().enumerate() {
350                        block[[out_row, col]] = monomial_derivative_value(points, row, exps, axis);
351                    }
352                }
353            }
354            block
355        }
356        2 => {
357            let mut block = Array2::<f64>::zeros((n * d * d, exponents.len()));
358            for row in 0..n {
359                for axis_a in 0..d {
360                    for axis_b in 0..d {
361                        let out_row = (row * d + axis_a) * d + axis_b;
362                        for (col, exps) in exponents.iter().enumerate() {
363                            block[[out_row, col]] =
364                                monomial_second_derivative_value(points, row, exps, axis_a, axis_b);
365                        }
366                    }
367                }
368            }
369            block
370        }
371        _ => Array2::<f64>::zeros((0, exponents.len())),
372    }
373}
374
375fn monomial_derivative_value(
376    points: ArrayView2<'_, f64>,
377    row: usize,
378    exponents: &[usize],
379    axis: usize,
380) -> f64 {
381    let exponent = exponents[axis];
382    if exponent == 0 {
383        return 0.0;
384    }
385    let mut value = exponent as f64;
386    for a in 0..points.ncols() {
387        let power = exponents[a] - usize::from(a == axis);
388        if power != 0 {
389            value *= points[[row, a]].powi(power as i32);
390        }
391    }
392    value
393}
394
395fn monomial_second_derivative_value(
396    points: ArrayView2<'_, f64>,
397    row: usize,
398    exponents: &[usize],
399    axis_a: usize,
400    axis_b: usize,
401) -> f64 {
402    let coeff = if axis_a == axis_b {
403        let exponent = exponents[axis_a];
404        if exponent < 2 {
405            return 0.0;
406        }
407        (exponent * (exponent - 1)) as f64
408    } else {
409        let exponent_a = exponents[axis_a];
410        let exponent_b = exponents[axis_b];
411        if exponent_a == 0 || exponent_b == 0 {
412            return 0.0;
413        }
414        (exponent_a * exponent_b) as f64
415    };
416    let mut value = coeff;
417    for axis in 0..points.ncols() {
418        let consumed = usize::from(axis == axis_a) + usize::from(axis == axis_b);
419        let power = exponents[axis] - consumed;
420        if power != 0 {
421            value *= points[[row, axis]].powi(power as i32);
422        }
423    }
424    value
425}
426
427/// Chebyshev coefficients for `√x·e^x·K₀(x)` on `x ≥ 2`, in ASCENDING powers of
428/// `y = 2/x ∈ (0, 1]`, and likewise for `K₁` below.
429///
430/// The scaled function is analytic on the closed interval (its only branch
431/// point, `x = 0`, sits at `y = ∞`), so a Chebyshev projection converges
432/// geometrically: 12 terms reach `2.5e−11`, 16 reach `1.1e−13`, 20 reach
433/// `8.3e−16`, and the 24 kept here reach `8.4e−18` — comfortably under `f64`,
434/// so the shipped error is the Horner evaluation's own `2.8e−16` and not the
435/// truncation. Every coefficient is `O(1)` (largest `1.33`, smallest `5.2e−4`),
436/// so the monomial-basis Horner is well conditioned despite the degree.
437///
438/// Regenerate with, at 40 digits:
439///
440/// ```text
441/// f = lambda y: sqrt(2/y) * e**(2/y) * besselk(nu, 2/y)   # f(0) := sqrt(pi/2)
442/// chebyfit(f, [0, 1], 24, error=False)                    # descending; reverse
443/// ```
444///
445/// These replace the Abramowitz & Stegun 9.8.6 / 9.8.8 seven-term polynomials,
446/// whose stated accuracy is `|ε| < 2e−7` and which measured `1.6e−7` relative
447/// here — against a small-`x` branch that is already accurate to `1e−15`, so
448/// the pair also had a `2.9e−9` STEP at their `x = 2` crossover. That step was
449/// a jump discontinuity in the Matérn/Duchon radial kernel, and therefore in
450/// every length-scale derivative taken through it.
451const SCALED_BESSEL_K0_CHEBYSHEV: [f64; 24] = [
452    1.2533141373155003,
453    -0.07833213358220663,
454    0.02203091256764185,
455    -0.011474433446088833,
456    0.008785105521551262,
457    -0.008894725254872692,
458    0.011207719358405647,
459    -0.01687069932280815,
460    0.0292835658778568,
461    -0.05619383717311583,
462    0.11288530340577708,
463    -0.22351971988707764,
464    0.4132521698842443,
465    -0.6841363697170683,
466    0.9837481719941347,
467    -1.2010795750851755,
468    1.2217566634595598,
469    -1.0164713197726316,
470    0.6772223712126879,
471    -0.3515643114800813,
472    0.13672561929586632,
473    -0.03741713905236645,
474    0.00641841764170551,
475    -0.0005187103462358833,
476];
477
478/// Chebyshev coefficients for `√x·e^x·K₁(x)` on `x ≥ 2`; see
479/// [`SCALED_BESSEL_K0_CHEBYSHEV`] for the derivation and the regeneration
480/// recipe (same call with `nu = 1`).
481const SCALED_BESSEL_K1_CHEBYSHEV: [f64; 24] = [
482    1.2533141373155003,
483    0.23499640074664313,
484    -0.03671818761410955,
485    0.01606420688270234,
486    -0.011295137230303733,
487    0.010871358854537931,
488    -0.01324584075315714,
489    0.019469483748136365,
490    -0.03321119404885756,
491    0.06293088720301371,
492    -0.12530769564341018,
493    0.24664898892370152,
494    -0.4542359642973804,
495    0.7500446332432126,
496    -1.0766417854892274,
497    1.3129047408768328,
498    -1.3343491820484357,
499    1.1094354121788925,
500    -0.7388012080869869,
501    0.38338722849862483,
502    -0.14905729821792707,
503    0.0407820842387012,
504    -0.006994249846147938,
505    0.000565154088568589,
506];
507
508/// `e^{−x}/√x · Σ_k c_k y^k` with `y = 2/x`, the common envelope of both
509/// large-argument branches. Kept in one place so `K₀` and `K₁` cannot drift
510/// apart in how they form it.
511#[inline(always)]
512fn scaled_bessel_k_large(x: f64, coefficients: &[f64; 24]) -> f64 {
513    let y = 2.0 / x;
514    let series = coefficients.iter().rev().fold(0.0, |acc, &c| acc * y + c);
515    (-x).exp() / x.sqrt() * series
516}
517
518#[inline(always)]
519pub(crate) fn bessel_k0_stable(x: f64) -> f64 {
520    let x_pos = x.max(1e-300);
521    if x_pos <= 2.0 {
522        return bessel_k0_small_series(x_pos);
523    }
524    scaled_bessel_k_large(x_pos, &SCALED_BESSEL_K0_CHEBYSHEV)
525}
526
527#[inline(always)]
528pub(crate) fn bessel_k1_stable(x: f64) -> f64 {
529    let x_pos = x.max(1e-300);
530    if x_pos <= 2.0 {
531        return bessel_k1_small_series(x_pos);
532    }
533    scaled_bessel_k_large(x_pos, &SCALED_BESSEL_K1_CHEBYSHEV)
534}
535
536#[inline(always)]
537pub(crate) fn bessel_k0_k1_small_series(x: f64) -> (f64, f64) {
538    const EULER_GAMMA: f64 = 0.577_215_664_901_532_9;
539    let y = 0.25 * x * x;
540    let log_half_plus_gamma = 0.5 * y.ln() + EULER_GAMMA;
541    let mut i0 = 1.0;
542    let mut i1 = 0.5 * x;
543    let mut harmonic = 0.0;
544    let mut y_power_over_fact_sq = 1.0;
545    let mut k0_series = 0.0;
546    let mut k0_series_y_derivative_times_y = 0.0;
547    for k in 1..=256 {
548        let kf = k as f64;
549        harmonic += 1.0 / kf;
550        y_power_over_fact_sq *= y / (kf * kf);
551        let k0_term = harmonic * y_power_over_fact_sq;
552        k0_series += k0_term;
553        k0_series_y_derivative_times_y += kf * k0_term;
554        i0 += y_power_over_fact_sq;
555        i1 += 0.5 * x * y_power_over_fact_sq / (kf + 1.0);
556        if k0_term.abs() <= f64::EPSILON * i0.abs().max(k0_series.abs()).max(1.0) {
557            break;
558        }
559    }
560
561    let k0 = -log_half_plus_gamma * i0 + k0_series;
562    let k1 = i0 / x + log_half_plus_gamma * i1 - (2.0 / x) * k0_series_y_derivative_times_y;
563    (k0, k1)
564}
565
566#[inline(always)]
567pub(crate) fn bessel_k0_small_series(x: f64) -> f64 {
568    bessel_k0_k1_small_series(x).0
569}
570
571#[inline(always)]
572pub(crate) fn bessel_k1_small_series(x: f64) -> f64 {
573    bessel_k0_k1_small_series(x).1
574}
575
576pub(crate) const DUCHON_DERIVATIVE_R_FLOOR_REL: f64 = 1e-5;
577
578pub(crate) const DUCHON_COLLISION_TAYLOR_REL: f64 = 1e-4;
579
580/// Minimum `(row, center)` pair count before a radial design sweep builds a
581/// certified [`radial_profile::RadialProfile`] instead of evaluating every
582/// pair exactly. The profile build costs a few hundred exact jet
583/// evaluations, so it only pays for itself when the sweep reuses it well
584/// beyond that; below the threshold the exact path keeps small fits
585/// bit-identical to the pre-profile behavior.
586pub(crate) const RADIAL_PROFILE_MIN_PAIRS: usize = 16_384;
587
588/// The one m→order mapping: `m` is the spline ORDER knob (mgcv's `m`), and it
589/// selects the polynomial null space the smoother leaves unpenalized
590/// (1 → mean only, 2 → mean + linear, k → total degree ≤ k−1). It is NOT the
591/// spectral power. Inverse of `duchon_p_from_nullspace_order`.
592#[inline(always)]
593pub fn duchon_nullspace_order_from_m(m: usize) -> DuchonNullspaceOrder {
594    match m {
595        1 => DuchonNullspaceOrder::Zero,
596        2 => DuchonNullspaceOrder::Linear,
597        other => DuchonNullspaceOrder::Degree(other - 1),
598    }
599}
600
601#[inline(always)]
602pub(crate) fn duchon_p_from_nullspace_order(order: DuchonNullspaceOrder) -> usize {
603    match order {
604        // Duchon null spaces contain all polynomials of degree < m.
605        // The public `order` knob chooses that polynomial degree cutoff:
606        //   order=0 -> constants only  -> m=1
607        //   order=1 -> constants+linear -> m=2
608        DuchonNullspaceOrder::Zero => 1,
609        DuchonNullspaceOrder::Linear => 2,
610        DuchonNullspaceOrder::Degree(degree) => degree + 1,
611    }
612}
613
614/// Whether a Duchon spec's **per-axis** ψ derivative surface is complete, so
615/// its `aniso_log_scales` may be enrolled as outer REML coordinates (gam#2735).
616///
617/// This is a capability question, not a policy one, and it is asked of the spec
618/// alone so the answer cannot depend on which call site is asking. It returns
619/// `false` — leaving the term on its single isotropic ψ axis, exactly as
620/// before — for every configuration whose per-axis derivative is not derived:
621///
622/// * a **scale-free** (`length_scale = None`) Duchon: every ψ-derivative
623///   builder in the family refuses it, isotropic included;
624/// * a **periodic** Duchon: the periodic path is a different builder with its
625///   own chart, and no per-axis route through it exists;
626/// * a term with no per-axis contrasts to learn (`d ≤ 1`, or η absent / the
627///   wrong length);
628/// * a spec whose ACTIVE operator penalty routes through the **closed-form
629///   Lebesgue block**, which replaces the collocation Gram on the value side
630///   and whose ψ-derivative is derived only for the isotropic direction.
631///   Enrolling one of those would ship a block whose value and gradient came
632///   from two different constructions.
633///
634/// The closed-form check sweeps every null-space order the realized build could
635/// degrade to (`duchon_effective_nullspace_order` only ever reduces), because
636/// the predicate is asked here — before centers exist — and a spec that becomes
637/// unsupported only after degradation must not be enrolled.
638pub fn duchon_spec_supports_axis_psi(spec: &DuchonBasisSpec, dim: usize) -> bool {
639    if dim <= 1 || spec.length_scale.is_none() || spec.periodic.is_some() {
640        return false;
641    }
642    match spec.aniso_log_scales.as_deref() {
643        Some(eta) if eta.len() == dim => {}
644        _ => return false,
645    }
646    let s_order = spec.power_as_usize() as f64;
647    if s_order != spec.power {
648        // The partial-fraction jets require an integer spectral power; the
649        // fractional path never reaches the ψ-derivative surface at all.
650        return false;
651    }
652    let requested_p = duchon_p_from_nullspace_order(spec.nullspace_order);
653    let tension_requested = matches!(
654        spec.operator_penalties.tension,
655        OperatorPenaltySpec::Active { .. }
656    );
657    let stiffness_requested = matches!(
658        spec.operator_penalties.stiffness,
659        OperatorPenaltySpec::Active { .. }
660    );
661    for p_order in 1..=requested_p.max(1) {
662        let two_pps = 2.0 * (p_order as f64 + spec.power);
663        // Mirror the builder's auto-disable: a penalty the kernel is too rough
664        // to admit is never assembled, so it cannot reach the closed form.
665        let tension_active = tension_requested && two_pps > dim as f64 + 1.0;
666        let stiffness_active = stiffness_requested && two_pps > dim as f64 + 2.0;
667        if tension_active
668            && crate::basis::duchon_closed_form_operator_penalty_converges(
669                1, p_order, s_order, dim,
670            )
671        {
672            return false;
673        }
674        if stiffness_active
675            && crate::basis::duchon_closed_form_operator_penalty_converges(
676                2, p_order, s_order, dim,
677            )
678        {
679            return false;
680        }
681    }
682    true
683}
684
685/// Returns the effective Duchon null-space order, auto-degrading when the
686/// requested order leaves no radial kernel degrees of freedom.
687///
688/// The constrained kernel block has `centers.nrows() - rank(P)` columns, where
689/// `P` is the polynomial null-space block. A valid polynomial block with
690/// exactly as many centers as columns is still useless for smoothing: every
691/// center is consumed by the side constraints and the design collapses to the
692/// polynomial tail. Degrade to the highest lower null-space order with at
693/// least one constrained kernel column.
694pub fn duchon_effective_nullspace_order(
695    centers: ArrayView2<'_, f64>,
696    order: DuchonNullspaceOrder,
697) -> DuchonNullspaceOrder {
698    if order == DuchonNullspaceOrder::Zero {
699        return order;
700    }
701    let mut effective = order;
702    while effective != DuchonNullspaceOrder::Zero
703        && centers.nrows() <= polynomial_block_from_order(centers, effective).ncols()
704    {
705        effective = duchon_previous_nullspace_order(effective);
706    }
707    if effective != order {
708        // Dedup: warn only once per (rows, cols, requested_order) per process.
709        // BFGS × P-IRLS × derivative callsites hit this path many times.
710        static SEEN: std::sync::OnceLock<
711            std::sync::Mutex<std::collections::HashSet<(usize, usize, DuchonNullspaceOrder)>>,
712        > = std::sync::OnceLock::new();
713        let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
714        let key = (centers.nrows(), centers.ncols(), order);
715        let fresh = seen.lock().map(|mut s| s.insert(key)).unwrap_or(true);
716        if fresh {
717            let requested_cols = polynomial_block_from_order(centers, order).ncols();
718            let effective_cols = polynomial_block_from_order(centers, effective).ncols();
719            log::warn!(
720                "Duchon nullspace order={:?} in dim={} with {} centers leaves no radial kernel columns (polynomial_cols={}); degrading to {:?} (polynomial_cols={})",
721                order,
722                centers.ncols(),
723                centers.nrows(),
724                requested_cols,
725                effective,
726                effective_cols
727            );
728        }
729    }
730    effective
731}
732
733/// Auto-*raise* the Duchon null-space order so the polyharmonic kernel — and
734/// any active derivative-collocation operators — clear their pointwise
735/// well-posedness margin `2(p + s) > dimension + max_operator_derivative_order`
736/// *before* the hard guard in [`validate_duchon_collocation_orders`] can fire.
737///
738/// This is the escalating twin of [`duchon_effective_nullspace_order`], which
739/// auto-*degrades* the order when too few centers remain to leave any radial
740/// kernel columns. Here the failure mode is the opposite end: a low
741/// order/power pair in dimension `dim` (e.g. `d=2`, `Linear` ⇒ `p=2`, `s=0`
742/// with stiffness/D2 active) leaves the kernel value — or its `k`-th
743/// derivative collocation — divergent at the origin, so `2(p + s) ≤ d + k`
744/// trips the guard mid-fit. Lifting `p` (the null-space order) by the smallest
745/// amount that restores the strict margin makes the guard unreachable for
746/// otherwise valid-intent configs.
747///
748/// Only `p` is lifted; the spectral power `s` and the CPD condition `2s < d`
749/// (which involves `s` and `d` alone) are untouched, so raising `p` can never
750/// invalidate a config that the requested power already satisfied.
751///
752/// `max_operator_derivative_order` is the max derivative order among the
753/// *active* operators (0 = mass/pointwise, 1 = tension/D1, 2 = stiffness/D2),
754/// exactly the value threaded into [`validate_duchon_collocation_orders`].
755pub(crate) fn duchon_order_for_operator_margin(
756    dim: usize,
757    power: f64,
758    order: DuchonNullspaceOrder,
759    max_operator_derivative_order: usize,
760) -> DuchonNullspaceOrder {
761    let margin = dim as f64 + max_operator_derivative_order as f64;
762    let mut effective = order;
763    // 2(p + s) > margin  ⇔  p > margin/2 − s. Each escalation lifts `p` by 1, so
764    // `2(p + s)` grows by 2 per step and the loop is bounded by ⌈margin/2⌉.
765    while 2.0 * (duchon_p_from_nullspace_order(effective) as f64 + power) <= margin {
766        effective = duchon_next_nullspace_order(effective);
767    }
768    if effective != order {
769        // Dedup: warn only once per (dim, power, requested_order, max_op) per
770        // process — the escalation is hit from many rebuild callsites.
771        static SEEN: std::sync::OnceLock<
772            std::sync::Mutex<std::collections::HashSet<(usize, u64, DuchonNullspaceOrder, usize)>>,
773        > = std::sync::OnceLock::new();
774        let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
775        let key = (dim, power.to_bits(), order, max_operator_derivative_order);
776        let fresh = seen.lock().map(|mut s| s.insert(key)).unwrap_or(true);
777        if fresh {
778            log::warn!(
779                "Duchon nullspace order={:?} with power={} in dim={} leaves 2*(p+s)={} \
780                 below the pointwise/collocation margin dimension+{}={} required by the \
781                 active operators; auto-raising to {:?} so the kernel stays well-posed",
782                order,
783                power,
784                dim,
785                2.0 * (duchon_p_from_nullspace_order(order) as f64 + power),
786                max_operator_derivative_order,
787                margin,
788                effective,
789            );
790        }
791    }
792    effective
793}
794
795#[inline(always)]
796pub(crate) fn gamma_lanczos(x: f64) -> f64 {
797    // Numerical Recipes / Lanczos approximation with reflection formula.
798    const G: f64 = 7.0;
799    const P: [f64; 9] = [
800        0.999_999_999_999_809_9,
801        676.520_368_121_885_1,
802        -1_259.139_216_722_402_8,
803        771.323_428_777_653_1,
804        -176.615_029_162_140_6,
805        12.507_343_278_686_905,
806        -0.138_571_095_265_720_12,
807        9.984_369_578_019_571e-6,
808        1.505_632_735_149_311_6e-7,
809    ];
810    if x < 0.5 {
811        let pix = std::f64::consts::PI * x;
812        return std::f64::consts::PI / (pix.sin() * gamma_lanczos(1.0 - x));
813    }
814    let z = x - 1.0;
815    let mut a = P[0];
816    for (i, coeff) in P.iter().enumerate().skip(1) {
817        a += coeff / (z + i as f64);
818    }
819    let t = z + G + 0.5;
820    (2.0 * std::f64::consts::PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * a
821}
822
823#[inline(always)]
824pub(crate) fn bessel_k_integer_order(n: usize, z: f64) -> f64 {
825    let zz = z.max(1e-300);
826    if n == 0 {
827        return bessel_k0_stable(zz);
828    }
829    if n == 1 {
830        return bessel_k1_stable(zz);
831    }
832    let mut km1 = bessel_k0_stable(zz);
833    let mut k = bessel_k1_stable(zz);
834    for m in 1..n {
835        let kp1 = km1 + 2.0 * (m as f64) * k / zz;
836        km1 = k;
837        k = kp1;
838    }
839    k
840}
841
842#[inline(always)]
843pub(crate) fn bessel_k_half_integer_order(l: usize, z: f64) -> f64 {
844    // Exact closed-form seeds and the stable upward recurrence
845    //   K_{1/2}(z) = sqrt(π/(2z))·e^{−z},
846    //   K_{3/2}(z) = K_{1/2}(z)·(1 + 1/z),
847    //   K_{ν+1}(z) = K_{ν−1}(z) + (2ν/z)·K_ν(z)   (ν = 1/2 + m, m ≥ 1).
848    // Equivalent to the closed-form polynomial sum, but uses EXACT integer
849    // coefficients via the recurrence instead of approximate Lanczos-gamma
850    // values for `c_j = (l+j)!/(j!(l−j)!)`. The Lanczos approximation is
851    // accurate to ~1 ULP at integer arguments; that error gets amplified
852    // through catastrophic cancellation in derivative lattices of the
853    // r^μ·K_μ(κr) family. Matching the [`BesselKLadder`] arithmetic byte-
854    // for-byte also ensures the ladder/per-call paths agree exactly.
855    let zz = z.max(1e-300);
856    let k_half = (std::f64::consts::PI / (2.0 * zz)).sqrt() * (-zz).exp();
857    if l == 0 {
858        return k_half;
859    }
860    let mut km1 = k_half;
861    let mut k = k_half * (1.0 + 1.0 / zz);
862    for m in 1..l {
863        let nu = m as f64 + 0.5;
864        let kp1 = km1 + 2.0 * nu * k / zz;
865        km1 = k;
866        k = kp1;
867    }
868    k
869}
870
871#[inline(always)]
872pub(crate) fn bessel_k_real_half_integer_or_integer(
873    nu_abs: f64,
874    z: f64,
875) -> Result<f64, BasisError> {
876    let two_nu = (2.0 * nu_abs).round();
877    if (two_nu - 2.0 * nu_abs).abs() > 1e-12 {
878        crate::bail_invalid_basis!(
879            "unsupported Bessel-K order ν={nu_abs}; only integer/half-integer orders are supported"
880        );
881    }
882    let two_nu_i = two_nu as i64;
883    if two_nu_i % 2 == 0 {
884        let n = (two_nu_i / 2).max(0) as usize;
885        Ok(bessel_k_integer_order(n, z))
886    } else {
887        let l = ((two_nu_i - 1) / 2).max(0) as usize;
888        Ok(bessel_k_half_integer_order(l, z))
889    }
890}
891
892/// Exact `i32` representation of a floating-point exponent, when one exists.
893///
894/// Duchon block orders are allowed to be fractional in the public pure-kernel
895/// API, but the auto-topology/operator path carries integer `m` and therefore
896/// integer exponents `2m-d-j`. Keeping the distinction explicit lets that hot
897/// path use LLVM's integral-power primitive while fractional callers retain
898/// the existing `powf` domain and rounding semantics.
899#[inline(always)]
900fn exact_i32_exponent(exponent: f64) -> Option<i32> {
901    if !exponent.is_finite() {
902        return None;
903    }
904    let integral = exponent as i32;
905    (integral as f64 == exponent).then_some(integral)
906}
907
908/// Power for a positive base and an integer/half-integer exponent represented
909/// as `2 * exponent`. This uses one `sqrt` only for the half-integer case and
910/// otherwise routes directly to `powi`; it is never used for a non-positive
911/// base, so it does not alter `powf`'s signed-zero/negative-base domain rules.
912#[inline(always)]
913fn positive_base_half_integer_power(base: f64, twice_exponent: i32) -> f64 {
914    if twice_exponent % 2 == 0 {
915        return base.powi(twice_exponent / 2);
916    }
917    let integer_part = twice_exponent / 2;
918    if twice_exponent > 0 {
919        base.powi(integer_part) * base.sqrt()
920    } else {
921        base.powi(integer_part) / base.sqrt()
922    }
923}
924
925#[inline(always)]
926fn positive_base_power_integral_or_half(base: f64, exponent: f64) -> f64 {
927    exact_i32_exponent(2.0 * exponent).map_or_else(
928        || base.powf(exponent),
929        |twice| positive_base_half_integer_power(base, twice),
930    )
931}
932
933/// Precomputed coefficient for `polyharmonic_kernel` that depends only on
934/// `m` and `k_dim`, not on `r`.  Avoids repeated gamma_lanczos calls in the
935/// hot kernel evaluation loop (called n × k times per basis build).
936#[derive(Clone, Copy)]
937pub(crate) struct PolyharmonicBlockCoeff {
938    pub(crate) c: f64,
939    pub(crate) power: f64,
940    power_i32: Option<i32>,
941    pub(crate) is_log_case: bool,
942}
943
944impl PolyharmonicBlockCoeff {
945    pub(crate) fn new(m: f64, k_dim: usize) -> Self {
946        assert!(
947            m.is_finite() && m > 0.0,
948            "PolyharmonicBlockCoeff::new: m must be finite and > 0, got {m}"
949        );
950        let k_half = 0.5 * k_dim as f64;
951        let power = 2.0 * m - k_dim as f64;
952        // Log case: k_dim is even and `2m − k_dim` is a non-negative even
953        // integer (within ε). For fractional `m` this never fires; for
954        // integer `m` it matches the original integer modulo check exactly.
955        const LOG_EPS: f64 = 1e-12;
956        let two_m = 2.0 * m;
957        let is_log_case = k_dim.is_multiple_of(2) && {
958            let n_f = (power / 2.0).round();
959            n_f >= 0.0 && (n_f * 2.0 - power).abs() < LOG_EPS
960        };
961        if is_log_case {
962            let m_int = m.round() as i64;
963            let m_minus_half_d_plus_one = (m - k_half + 1.0).round() as i64;
964            let c = polyharmonic_log_sign(m_int as usize, k_dim)
965                / (2.0_f64.powi((two_m.round() as i32) - 1)
966                    * positive_base_power_integral_or_half(std::f64::consts::PI, k_half)
967                    * gamma_lanczos(m)
968                    * gamma_lanczos(m_minus_half_d_plus_one as f64));
969            Self {
970                c,
971                power,
972                power_i32: exact_i32_exponent(power),
973                is_log_case: true,
974            }
975        } else {
976            let c = gamma_lanczos(k_half - m)
977                / (positive_base_power_integral_or_half(4.0, m)
978                    * positive_base_power_integral_or_half(std::f64::consts::PI, k_half)
979                    * gamma_lanczos(m));
980            Self {
981                c,
982                power,
983                power_i32: exact_i32_exponent(power),
984                is_log_case: false,
985            }
986        }
987    }
988
989    #[inline(always)]
990    pub(crate) fn eval(&self, r: f64) -> f64 {
991        if r <= 0.0 {
992            return self.origin_limit();
993        }
994        let radial_power = self
995            .power_i32
996            .map_or_else(|| r.powf(self.power), |power| r.powi(power));
997        if self.is_log_case {
998            self.c * radial_power * r.max(1e-300).ln()
999        } else {
1000            self.c * radial_power
1001        }
1002    }
1003
1004    #[inline(always)]
1005    pub(crate) fn origin_limit(&self) -> f64 {
1006        if self.is_log_case {
1007            log_power_origin_limit(self.c, self.power, 1.0, 0.0)
1008        } else {
1009            log_power_origin_limit(self.c, self.power, 0.0, 1.0)
1010        }
1011    }
1012}
1013
1014pub(crate) fn polyharmonic_kernel(r: f64, m: f64, k_dim: usize) -> f64 {
1015    PolyharmonicBlockCoeff::new(m, k_dim).eval(r)
1016}
1017
1018#[inline(always)]
1019pub(crate) fn signed_infinity(sign: f64) -> f64 {
1020    if sign.is_sign_negative() {
1021        f64::NEG_INFINITY
1022    } else {
1023        f64::INFINITY
1024    }
1025}
1026
1027#[inline(always)]
1028pub(crate) fn log_power_origin_limit(
1029    coeff: f64,
1030    exponent: f64,
1031    log_coeff: f64,
1032    pure_coeff: f64,
1033) -> f64 {
1034    if log_coeff == 0.0 && pure_coeff == 0.0 {
1035        return 0.0;
1036    }
1037    if exponent > 0.0 {
1038        return 0.0;
1039    }
1040    if exponent == 0.0 {
1041        if log_coeff != 0.0 {
1042            signed_infinity(-coeff * log_coeff)
1043        } else {
1044            coeff * pure_coeff
1045        }
1046    } else if log_coeff != 0.0 {
1047        signed_infinity(-coeff * log_coeff)
1048    } else {
1049        signed_infinity(coeff * pure_coeff)
1050    }
1051}
1052
1053#[inline(always)]
1054pub(crate) fn polyharmonic_log_sign(m: usize, k_dim: usize) -> f64 {
1055    assert!(
1056        k_dim.is_multiple_of(2),
1057        "polyharmonic_log_sign requires even kernel dimension: k_dim={k_dim}, m={m}"
1058    );
1059    (-1.0_f64).powi(m as i32 - (k_dim as i32 / 2) + 1)
1060}
1061
1062#[inline(always)]
1063pub(crate) fn duchon_matern_block(
1064    r: f64,
1065    kappa: f64,
1066    n_order: usize,
1067    k_dim: usize,
1068) -> Result<f64, BasisError> {
1069    let n = n_order as f64;
1070    let k_half = 0.5 * k_dim as f64;
1071    let nu = n - k_half;
1072    let nu_abs = nu.abs();
1073    let c = kappa.powf(k_half - n)
1074        / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
1075    if r <= 0.0 {
1076        if nu > 0.0 {
1077            // r^ν K_ν(κr) → 2^(ν−1) Γ(ν) κ^(−ν) as r→0+.
1078            return Ok(c * 2.0_f64.powf(nu - 1.0) * gamma_lanczos(nu) * kappa.powf(-nu));
1079        }
1080        // ν ≤ 0: c·r^ν·K_|ν|(κr) is divergent at r=0 (logarithmically for ν=0,
1081        // power-law for ν<0). The hybrid-kernel diagonal must be evaluated via
1082        // duchon_hybrid_kernel_collision_value, which sums the divergent
1083        // Matérn and polyharmonic blocks so the singularities cancel exactly
1084        // (guaranteed by the PFD identity when 2(p+s) > d).
1085        crate::bail_invalid_basis!(
1086            "Duchon Matérn block at r=0 with ν={nu} ≤ 0 is divergent; \
1087             evaluate the hybrid kernel diagonal via the collision routine"
1088        );
1089    }
1090    let z = (kappa * r).max(1e-300);
1091    let k_nu = bessel_k_real_half_integer_or_integer(nu_abs, z)?;
1092    Ok(c * r.powf(nu) * k_nu)
1093}
1094
1095#[inline(always)]
1096pub(crate) fn polyharmonic_kernel_triplet(
1097    r: f64,
1098    m: f64,
1099    k_dim: usize,
1100) -> Result<(f64, f64, f64), BasisError> {
1101    let (value, first, second, _, _) = polyharmonic_block_jet4(r, m, k_dim)?;
1102    Ok((value, first, second))
1103}
1104
1105#[inline(always)]
1106pub(crate) fn falling_factorial(alpha: f64, order: usize) -> f64 {
1107    (0..order).fold(1.0, |acc, idx| acc * (alpha - idx as f64))
1108}
1109
1110#[inline(always)]
1111pub(crate) fn falling_factorial_derivative(alpha: f64, order: usize) -> f64 {
1112    if order == 0 {
1113        return 0.0;
1114    }
1115    let mut total = 0.0;
1116    for omit in 0..order {
1117        let mut term = 1.0;
1118        for idx in 0..order {
1119            if idx != omit {
1120                term *= alpha - idx as f64;
1121            }
1122        }
1123        total += term;
1124    }
1125    total
1126}
1127
1128/// Unified radial jet for one polyharmonic partial-fraction block.
1129///
1130/// Returns (φ, φ', φ'', φ''', φ'''') from a single consistent evaluation,
1131/// sharing normalization constant, r_safe, and log_r. This eliminates the
1132/// possibility of numerical drift between the triplet and higher-order
1133/// derivative paths.
1134pub(crate) fn polyharmonic_block_jet4(
1135    r: f64,
1136    m: f64,
1137    k_dim: usize,
1138) -> Result<(f64, f64, f64, f64, f64), BasisError> {
1139    if !r.is_finite() || r < 0.0 {
1140        crate::bail_invalid_basis!("polyharmonic distance must be finite and non-negative");
1141    }
1142    assert!(
1143        m.is_finite() && m > 0.0,
1144        "polyharmonic_block_jet4: m must be finite and > 0, got {m}"
1145    );
1146
1147    let k_half = 0.5 * k_dim as f64;
1148    let alpha = 2.0 * m - k_dim as f64;
1149    let alpha_i32 = exact_i32_exponent(alpha);
1150    // Log case: k_dim even and `2m − k_dim` is a non-negative even integer
1151    // (within ε). For fractional `m` this never fires.
1152    const LOG_EPS: f64 = 1e-12;
1153    let is_log_case = k_dim.is_multiple_of(2) && {
1154        let n_f = (alpha / 2.0).round();
1155        n_f >= 0.0 && (n_f * 2.0 - alpha).abs() < LOG_EPS
1156    };
1157    if is_log_case {
1158        let m_int = m.round() as usize;
1159        let c = polyharmonic_log_sign(m_int, k_dim)
1160            / (2.0_f64.powi((2 * m_int - 1) as i32)
1161                * positive_base_power_integral_or_half(std::f64::consts::PI, k_half)
1162                * gamma_lanczos(m)
1163                * gamma_lanczos((m_int - k_dim / 2 + 1) as f64));
1164        let mut out = [0.0; 5];
1165        let log_r = (r > 0.0).then(|| r.ln());
1166        for d in 0..5 {
1167            let e = alpha - d as f64;
1168            let ff = falling_factorial(alpha, d);
1169            let ff_d = falling_factorial_derivative(alpha, d);
1170            out[d] = if r <= 0.0 {
1171                log_power_origin_limit(c, e, ff, ff_d)
1172            } else {
1173                let radial_power =
1174                    alpha_i32.map_or_else(|| r.powf(e), |integral| r.powi(integral - d as i32));
1175                c * radial_power * (ff * log_r.expect("positive radius has a logarithm") + ff_d)
1176            };
1177        }
1178        return Ok((out[0], out[1], out[2], out[3], out[4]));
1179    }
1180
1181    let c = gamma_lanczos(k_half - m)
1182        / (positive_base_power_integral_or_half(4.0, m)
1183            * positive_base_power_integral_or_half(std::f64::consts::PI, k_half)
1184            * gamma_lanczos(m));
1185    let mut out = [0.0; 5];
1186    for d in 0..5 {
1187        let e = alpha - d as f64;
1188        let ff = falling_factorial(alpha, d);
1189        out[d] = if r <= 0.0 {
1190            log_power_origin_limit(c, e, 0.0, ff)
1191        } else {
1192            let radial_power =
1193                alpha_i32.map_or_else(|| r.powf(e), |integral| r.powi(integral - d as i32));
1194            c * ff * radial_power
1195        };
1196    }
1197    Ok((out[0], out[1], out[2], out[3], out[4]))
1198}
1199
1200#[inline(always)]
1201pub(crate) fn log_power_family_derivative(
1202    exponent: i32,
1203    log_coeff: f64,
1204    pure_coeff: f64,
1205) -> (i32, f64, f64) {
1206    let exponent_f64 = exponent as f64;
1207    (
1208        exponent - 1,
1209        exponent_f64 * log_coeff,
1210        exponent_f64 * pure_coeff + log_coeff,
1211    )
1212}
1213
1214#[inline(always)]
1215pub(crate) fn log_power_family_value(
1216    r: f64,
1217    coeff: f64,
1218    exponent: i32,
1219    log_coeff: f64,
1220    pure_coeff: f64,
1221) -> f64 {
1222    if r <= 0.0 {
1223        log_power_origin_limit(coeff, exponent as f64, log_coeff, pure_coeff)
1224    } else {
1225        coeff * r.powi(exponent) * (log_coeff * r.ln() + pure_coeff)
1226    }
1227}
1228
1229#[inline(always)]
1230pub(crate) fn duchon_polyharmonic_operator_block_jets(
1231    r: f64,
1232    m: usize,
1233    k_dim: usize,
1234) -> Result<(f64, f64, f64, f64), BasisError> {
1235    if !r.is_finite() || r < 0.0 {
1236        crate::bail_invalid_basis!("polyharmonic distance must be finite and non-negative");
1237    }
1238    assert!(
1239        m > 0,
1240        "duchon_polyharmonic_operator_block_jets: m must be > 0, got {m}"
1241    );
1242
1243    let Ok(m_i32) = i32::try_from(m) else {
1244        crate::bail_invalid_basis!("polyharmonic order {m} exceeds the supported i32 range");
1245    };
1246    let Ok(k_dim_i32) = i32::try_from(k_dim) else {
1247        crate::bail_invalid_basis!("Duchon dimension {k_dim} exceeds the supported i32 range");
1248    };
1249    let Some(alpha) = m_i32
1250        .checked_mul(2)
1251        .and_then(|twice_m| twice_m.checked_sub(k_dim_i32))
1252    else {
1253        crate::bail_invalid_basis!("Duchon exponent 2*{m}-{k_dim} exceeds the supported i32 range");
1254    };
1255    let m_f64 = m as f64;
1256    let k_half = 0.5 * k_dim as f64;
1257    let is_log_case = k_dim.is_multiple_of(2) && alpha >= 0;
1258    let (c, phi_log_coeff, phi_pure_coeff) = if is_log_case {
1259        (
1260            polyharmonic_log_sign(m, k_dim)
1261                / (2.0_f64.powi(2 * m_i32 - 1)
1262                    * positive_base_half_integer_power(std::f64::consts::PI, k_dim_i32)
1263                    * gamma_lanczos(m_f64)
1264                    * gamma_lanczos((m - k_dim / 2 + 1) as f64)),
1265            1.0,
1266            0.0,
1267        )
1268    } else {
1269        (
1270            gamma_lanczos(k_half - m_f64)
1271                / (4.0_f64.powi(m_i32)
1272                    * positive_base_half_integer_power(std::f64::consts::PI, k_dim_i32)
1273                    * gamma_lanczos(m_f64)),
1274            0.0,
1275            1.0,
1276        )
1277    };
1278
1279    let (phi_r_exp, phi_r_log, phi_r_pure) =
1280        log_power_family_derivative(alpha, phi_log_coeff, phi_pure_coeff);
1281    let q_exp = phi_r_exp - 1;
1282    let q = log_power_family_value(r, c, q_exp, phi_r_log, phi_r_pure);
1283
1284    let (q_r_exp_raw, q_r_log, q_r_pure) =
1285        log_power_family_derivative(q_exp, phi_r_log, phi_r_pure);
1286    let t_exp = q_r_exp_raw - 1;
1287    let t = log_power_family_value(r, c, t_exp, q_r_log, q_r_pure);
1288
1289    let (t_r_exp, t_r_log, t_r_pure) = log_power_family_derivative(t_exp, q_r_log, q_r_pure);
1290    let t_r = log_power_family_value(r, c, t_r_exp, t_r_log, t_r_pure);
1291
1292    let (t_rr_exp, t_rr_log, t_rr_pure) = log_power_family_derivative(t_r_exp, t_r_log, t_r_pure);
1293    let t_rr = log_power_family_value(r, c, t_rr_exp, t_rr_log, t_rr_pure);
1294
1295    Ok((q, t, t_r, t_rr))
1296}
1297
1298/// Shared Bessel-K ladder for one evaluation point `z = κ·r`.
1299///
1300/// Every Matérn partial-fraction block and every term of its radial
1301/// derivative lattice consumes `K_ν(z)` at orders from ONE parity class
1302/// (integer when the covariate dimension is even, half-integer when odd),
1303/// differing by integers — and all at the SAME `z`. The previous code
1304/// restarted the `K₀/K₁` (or closed-form half-integer) seed evaluation and
1305/// the upward recurrence inside every per-term Bessel call: hundreds of
1306/// redundant seed+recurrence runs per `(row, center)` pair, which the #979
1307/// CTN stage-1 stack profile showed to be the dominant cost of every Duchon
1308/// κ-trial at scale. One ladder per point replaces all of them: two seed
1309/// evaluations plus the standard upward recurrence
1310/// `K_{ν+1}(z) = K_{ν−1}(z) + (2ν/z)·K_ν(z)`, which is the numerically
1311/// STABLE direction for `K` (it grows with ν). For integer orders this is
1312/// arithmetic-identical to the old per-call `bessel_k_integer_order`, which
1313/// ran the same seeds and recurrence internally; for half-integer orders the
1314/// recurrence is exact and replaces the per-order closed-form sum.
1315pub(crate) struct BesselKLadder {
1316    /// `values[i] = K_{base + i}(z)` with `base ∈ {0, ½}`.
1317    pub(crate) values: SmallVec<[f64; 16]>,
1318    pub(crate) half_integer: bool,
1319}
1320
1321impl BesselKLadder {
1322    pub(crate) fn build(z: f64, half_integer: bool, max_order_steps: usize) -> Self {
1323        let zz = z.max(1e-300);
1324        let mut values: SmallVec<[f64; 16]> = SmallVec::with_capacity(max_order_steps + 2);
1325        if half_integer {
1326            // K_{1/2}(z) = √(π/(2z))·e^{−z};  K_{3/2}(z) = K_{1/2}(z)·(1 + 1/z).
1327            let k_half = (std::f64::consts::PI / (2.0 * zz)).sqrt() * (-zz).exp();
1328            values.push(k_half);
1329            values.push(k_half * (1.0 + 1.0 / zz));
1330        } else {
1331            values.push(bessel_k0_stable(zz));
1332            values.push(bessel_k1_stable(zz));
1333        }
1334        let base = if half_integer { 0.5 } else { 0.0 };
1335        for i in 1..max_order_steps {
1336            let nu = base + i as f64;
1337            let next = values[i - 1] + 2.0 * nu * values[i] / zz;
1338            values.push(next);
1339        }
1340        Self {
1341            values,
1342            half_integer,
1343        }
1344    }
1345
1346    /// `K_{|order|}(z)` from the ladder (`K_{−ν} = K_ν`).
1347    #[inline]
1348    pub(crate) fn k_abs(&self, order_abs: f64) -> f64 {
1349        let base = if self.half_integer { 0.5 } else { 0.0 };
1350        let idx = (order_abs - base).round() as usize;
1351        self.values[idx]
1352    }
1353}
1354
1355/// Radial-derivative jets of the Matérn family `coeff·r^μ·K_μ(κr)` up to
1356/// order `max_j ≤ 4`, evaluated against a shared [`BesselKLadder`].
1357///
1358/// Exact recurrence derived from `d/dr[r^ν K_ν(κr)]` and the Bessel identity
1359/// `dK_ν/dz = −K_{ν−1}(z) − (ν/z)K_ν(z)`:
1360///
1361///   g⁽⁰⁾ = c · r^ν · K_ν(z)
1362///   g⁽¹⁾ = −c · κ · r^ν · K_{ν−1}(z)
1363///   g⁽²⁾ = c·κ² r^ν K_{ν−2} − c·κ r^{ν−1} K_{ν−1}, ...
1364///
1365/// Same derivative lattice as the per-order reference implementation
1366/// `duchon_matern_family_radial_derivative_reference` (kept in the test
1367/// module as the equivalence oracle)
1368/// (term-for-term, in the same order), but: (a) the lattice is expanded
1369/// incrementally once instead of rebuilt from scratch per derivative order,
1370/// (b) terms live in a fixed-capacity stack buffer instead of per-call heap
1371/// `Vec`s (≤ 2^max_j ≤ 16 terms), and (c) every Bessel factor is an indexed
1372/// ladder read instead of a fresh seed+recurrence evaluation. Only orders
1373/// `0..=max_j` are computed — the q-family consumes order 0 only and the
1374/// t-family orders ≤ 2, where the old path always expanded to order 4 and
1375/// discarded the tail.
1376pub(crate) fn duchon_matern_family_jets_with_ladder(
1377    r: f64,
1378    kappa: f64,
1379    coeff: f64,
1380    mu: f64,
1381    max_j: usize,
1382    ladder: &BesselKLadder,
1383    out: &mut [f64],
1384) -> Result<(), BasisError> {
1385    if max_j > 4 || out.len() <= max_j {
1386        crate::bail_invalid_basis!(
1387            "Duchon Matérn-family ladder jets support derivative orders 0..=4 with an output slot per order"
1388        );
1389    }
1390    if r <= 0.0 {
1391        out[..=max_j].fill(0.0);
1392        if mu > 0.0 {
1393            out[0] = coeff * 2.0_f64.powf(mu - 1.0) * gamma_lanczos(mu) * kappa.powf(-mu);
1394        }
1395        return Ok(());
1396    }
1397    let mut terms: SmallVec<[DuchonMaternDerivativeTerm; 16]> =
1398        smallvec![DuchonMaternDerivativeTerm {
1399            coeff,
1400            kappa_power: 0,
1401            r_power: mu,
1402            bessel_order: mu,
1403        }];
1404    for (j, slot) in out.iter_mut().enumerate().take(max_j + 1) {
1405        if j > 0 {
1406            let mut next: SmallVec<[DuchonMaternDerivativeTerm; 16]> =
1407                SmallVec::with_capacity(terms.len() * 2);
1408            for term in &terms {
1409                let stay_coeff = term.coeff * (term.r_power - term.bessel_order);
1410                if stay_coeff != 0.0 {
1411                    next.push(DuchonMaternDerivativeTerm {
1412                        coeff: stay_coeff,
1413                        kappa_power: term.kappa_power,
1414                        r_power: term.r_power - 1.0,
1415                        bessel_order: term.bessel_order,
1416                    });
1417                }
1418                next.push(DuchonMaternDerivativeTerm {
1419                    coeff: -term.coeff,
1420                    kappa_power: term.kappa_power + 1,
1421                    r_power: term.r_power,
1422                    bessel_order: term.bessel_order - 1.0,
1423                });
1424            }
1425            terms = next;
1426        }
1427        let mut value = KahanSum::default();
1428        for term in &terms {
1429            if term.coeff == 0.0 {
1430                continue;
1431            }
1432            value.add(
1433                term.coeff
1434                    * kappa.powi(term.kappa_power as i32)
1435                    * r.powf(term.r_power)
1436                    * ladder.k_abs(term.bessel_order.abs()),
1437            );
1438        }
1439        *slot = value.sum();
1440    }
1441    Ok(())
1442}
1443
1444/// Maximum ladder steps (`K_base ..= K_{base+steps}`) needed by the q/t
1445/// operator families of Matérn block `n` in dimension `k_dim`: the q-family
1446/// reads `K_{|ν−1|}` and the t-family `K_{|ν−2−j|}` for `j ≤ 2`, ν = n − d/2.
1447pub(crate) fn duchon_matern_block_max_ladder_steps(n_order: usize, k_dim: usize) -> usize {
1448    let nu = n_order as f64 - 0.5 * k_dim as f64;
1449    let candidates = [
1450        (nu - 1.0).abs(),
1451        (nu - 2.0).abs(),
1452        (nu - 3.0).abs(),
1453        (nu - 4.0).abs(),
1454    ];
1455    let max_abs = candidates.iter().copied().fold(0.0_f64, f64::max);
1456    max_abs.floor() as usize + 1
1457}
1458
1459pub(crate) fn duchon_matern_operator_block_jets_with_ladder(
1460    r: f64,
1461    kappa: f64,
1462    n_order: usize,
1463    k_dim: usize,
1464    ladder: &BesselKLadder,
1465) -> Result<(f64, f64, f64, f64), BasisError> {
1466    if r <= 0.0 {
1467        return Ok((0.0, 0.0, 0.0, 0.0));
1468    }
1469    let n = n_order as f64;
1470    let k_half = 0.5 * k_dim as f64;
1471    let nu = n - k_half;
1472    let c = kappa.powf(k_half - n)
1473        / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
1474
1475    let mut q_out = [0.0_f64; 1];
1476    duchon_matern_family_jets_with_ladder(r, kappa, -c * kappa, nu - 1.0, 0, ladder, &mut q_out)?;
1477    let mut t_out = [0.0_f64; 3];
1478    duchon_matern_family_jets_with_ladder(
1479        r,
1480        kappa,
1481        c * kappa * kappa,
1482        nu - 2.0,
1483        2,
1484        ladder,
1485        &mut t_out,
1486    )?;
1487    Ok((q_out[0], t_out[0], t_out[1], t_out[2]))
1488}
1489
1490#[inline(always)]
1491pub(crate) fn pure_duchon_block_order(p_order: usize, s_order: f64) -> f64 {
1492    p_order as f64 + s_order
1493}
1494
1495pub(crate) fn validate_duchon_kernel_orders(
1496    length_scale: Option<f64>,
1497    p_order: usize,
1498    s_order: f64,
1499    k_dim: usize,
1500) -> Result<(), BasisError> {
1501    if k_dim == 0 {
1502        crate::bail_invalid_basis!("Duchon basis requires at least one covariate dimension");
1503    }
1504    if let Some(scale) = length_scale
1505        && (!scale.is_finite() || scale <= 0.0)
1506    {
1507        crate::bail_invalid_basis!("Duchon hybrid length_scale must be finite and positive");
1508    }
1509    // Two independent well-posedness conditions on (p, s, d) for pure Duchon.
1510    //
1511    // (1) CPD-vs-nullspace adequacy — gated below on `length_scale.is_none()`.
1512    //     The pure-polyharmonic kernel of effective order m = p+s in R^d is
1513    //     phi(r) = r^{2m-d}, or r^{2m-d}·log r when 2m-d is a non-negative
1514    //     even integer (the "log case", reached precisely when d is even
1515    //     and m >= d/2). Wendland's Theorem 8.17 / 8.18 give its
1516    //     conditional-positive-definiteness order:
1517    //
1518    //         d odd,  exponent half-integer:  ceil((2m-d)/2) = m - (d-1)/2
1519    //         d even, log case:               (2m-d)/2 + 1   = m - d/2 + 1
1520    //
1521    //     Duchon interpolation with polynomial nullspace P_p (polynomials
1522    //     of degree < p) is uniquely solvable iff the kernel's CPD order
1523    //     does not exceed p. Substituting m = p + s:
1524    //
1525    //         d odd:  s <= (d-1)/2     <=>  2s <= d - 1
1526    //         d even: s <= d/2 - 1     <=>  2s <= d - 2
1527    //
1528    //     Both branches collapse to `2s < d` once we use that s and d are
1529    //     integers and 2s is therefore even (so `2s = d - 1` is impossible
1530    //     for even d, and `2s <= d - 2` is just `2s < d`).
1531    //
1532    //     Counter-example admitted if this guard is dropped: d=2, p=1, s=1
1533    //     passes the spectral check (2(1+1)=4 > 2) and builds the TPS
1534    //     kernel c·r²·log r against a constants-only nullspace P_1; the
1535    //     interpolation form is not PD on lambda perp P_1 and the fitted
1536    //     penalty is meaningless.
1537    //
1538    //     The hybrid (Matérn-blended) Duchon kernel sidesteps this entirely:
1539    //     the Matérn remainder is strictly positive definite (CPD order 0),
1540    //     so any P_p suffices — hence the `length_scale.is_none()` gate.
1541    //
1542    // (2) Spectral kernel-existence — universal, gated below on the sum.
1543    //     The pointwise kernel comes from the inverse Fourier of
1544    //     1/|xi|^{2(p+s)}, which is a finite distribution at the origin
1545    //     iff `2(p+s) > d`. Below that threshold the radial kernel value
1546    //     diverges and there is nothing to evaluate.
1547    if !s_order.is_finite() || s_order < 0.0 {
1548        crate::bail_invalid_basis!("Duchon spectral power must be finite and ≥ 0; got s={s_order}");
1549    }
1550    if length_scale.is_none() && 2.0 * s_order >= k_dim as f64 {
1551        // The `2s >= d` boundary is INDEPENDENT of the nullspace degree p (it
1552        // cancels in the CPD-order-vs-p derivation above, #2278): a former
1553        // `p_order < 2` conjunct here wrongly let `p >= 2` configs (e.g. d=2,
1554        // Linear nullspace p=2, explicit power s=1) bypass the check and build a
1555        // penalty from a kernel that is not CPD on the nullspace complement.
1556        crate::bail_invalid_basis!(
1557            "pure Duchon requires spectral power < dimension/2 (2s < d), independent of nullspace degree; got power={s_order}, dimension={k_dim}"
1558        );
1559    }
1560    let spectral_order = 2.0 * (p_order as f64 + s_order);
1561    if spectral_order <= k_dim as f64 {
1562        crate::bail_invalid_basis!(
1563            "Duchon pointwise kernel values require 2*(p+s) > dimension; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1564        );
1565    }
1566    Ok(())
1567}
1568
1569pub(crate) fn validate_duchon_collocation_orders(
1570    length_scale: Option<f64>,
1571    p_order: usize,
1572    s_order: f64,
1573    k_dim: usize,
1574    max_operator_derivative_order: usize,
1575) -> Result<(), BasisError> {
1576    // Kernel-level conditions (existence + CPD/nullspace adequacy) come first;
1577    // the operator-level conditions below build on a pointwise-valid kernel.
1578    validate_duchon_kernel_orders(length_scale, p_order, s_order, k_dim)?;
1579    // The spectral_order > k_dim + k checks below are C^k-at-origin
1580    // conditions: for the polyharmonic kernel r^{2(p+s)-d} (or the log
1581    // variant) to admit k-th radial derivatives in the distributional sense
1582    // — and therefore for k-th-order derivative *collocation* of the
1583    // kernel against centers to produce a finite operator — we need its
1584    // exponent to clear the next k orders of differentiation at the
1585    // origin. Equivalently: 2(p+s) - d > k.
1586    //
1587    // Note these are independent of the CPD/nullspace check. The penalty
1588    // matrices ultimately built from these collocation operators are of
1589    // the form S = D_k^T D_k and are PSD by construction; the discipline
1590    // here is purely about *existence* of D_k itself.
1591    let spectral_order = 2.0 * (p_order as f64 + s_order);
1592    if max_operator_derivative_order >= 1 && spectral_order <= k_dim as f64 + 1.0 {
1593        crate::bail_invalid_basis!(
1594            "Duchon D1 collocation requires 2*(p+s) > dimension+1; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1595        );
1596    }
1597    if max_operator_derivative_order >= 2 && spectral_order <= k_dim as f64 + 2.0 {
1598        crate::bail_invalid_basis!(
1599            "Duchon D2 collocation requires 2*(p+s) > dimension+2; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1600        );
1601    }
1602    Ok(())
1603}
1604
1605#[derive(Debug, Clone)]
1606pub struct DuchonPartialFractionCoeffs {
1607    pub(crate) a: Vec<f64>,
1608    pub(crate) b: Vec<f64>,
1609}
1610
1611#[inline(always)]
1612pub(crate) fn duchon_partial_fraction_coeffs(
1613    p_order: usize,
1614    s_order: usize,
1615    kappa: f64,
1616) -> DuchonPartialFractionCoeffs {
1617    // 1/(ρ^{2p}(κ²+ρ²)^s) = Σ a_m/ρ^{2m} + Σ b_n/(κ²+ρ²)^n
1618    let mut a = vec![0.0_f64; p_order + 1]; // 1-based m
1619    let mut b = vec![0.0_f64; s_order + 1]; // 1-based n
1620    if s_order == 0 {
1621        if p_order > 0 {
1622            // Pure intrinsic polyharmonic case: no Matérn tail remains, so the
1623            // spectrum is exactly 1 / ρ^(2p).
1624            a[p_order] = 1.0;
1625        }
1626        return DuchonPartialFractionCoeffs { a, b };
1627    }
1628    for m in 1..=p_order {
1629        let sign = if (p_order - m).is_multiple_of(2) {
1630            1.0
1631        } else {
1632            -1.0
1633        };
1634        let expo = -2.0 * (s_order + p_order - m) as f64;
1635        let comb = binomial_f64(s_order + p_order - m - 1, p_order - m);
1636        a[m] = sign * kappa.powf(expo) * comb;
1637    }
1638    for n in 1..=s_order {
1639        let sign = if p_order.is_multiple_of(2) { 1.0 } else { -1.0 };
1640        let expo = -2.0 * (p_order + s_order - n) as f64;
1641        let comb = if p_order == 0 && n == s_order {
1642            // p=0 reduces to the pure Matérn block 1/(κ²+ρ²)^s.
1643            1.0
1644        } else {
1645            let top = p_order + s_order - n - 1;
1646            binomial_f64(top, s_order - n)
1647        };
1648        b[n] = sign * kappa.powf(expo) * comb;
1649    }
1650    DuchonPartialFractionCoeffs { a, b }
1651}
1652
1653/// 64-node Gauss–Legendre rule on `[0, 1]` (nodes already mapped from the
1654/// canonical `[-1, 1]` interval, weights scaled by the `1/2` Jacobian).
1655///
1656/// Used by [`duchon_hybrid_kernel_stable_integral`] to evaluate the hybrid
1657/// Duchon–Matérn kernel without the catastrophically-cancelling
1658/// partial-fraction sum (gam#1424). The integrand is smooth and strictly
1659/// positive on `(0, 1)`, so a fixed high-order rule reproduces the kernel to
1660/// ~1e-15 relative accuracy across all reachable high-dimensional orders.
1661fn gauss_legendre_01_64() -> &'static [(f64, f64)] {
1662    use std::sync::OnceLock;
1663    static NODES: OnceLock<Vec<(f64, f64)>> = OnceLock::new();
1664    NODES.get_or_init(|| {
1665        // Newton iteration on the Legendre polynomial roots (the classic
1666        // `gauleg` recipe). The N-point rule is symmetric about the midpoint, so
1667        // only the lower half of the roots is solved for and the rule is
1668        // mirrored. Computed once; converges to full f64 precision in a handful
1669        // of Newton steps per root.
1670        const N: usize = 64;
1671        let nf = N as f64;
1672        let mut nodes: Vec<(f64, f64)> = Vec::with_capacity(N);
1673        let half = N.div_ceil(2);
1674        for i in 0..half {
1675            // Initial guess for the i-th root on [-1, 1] (Chebyshev-like).
1676            let mut x = (std::f64::consts::PI * (i as f64 + 0.75) / (nf + 0.5)).cos();
1677            let mut dp = 0.0_f64;
1678            for _ in 0..100 {
1679                // Evaluate the Legendre polynomial P_N(x) and derivative P_N'(x)
1680                // via the three-term recurrence.
1681                let mut p0 = 1.0_f64;
1682                let mut p1 = x;
1683                for k in 2..=N {
1684                    let kf = k as f64;
1685                    let p2 = ((2.0 * kf - 1.0) * x * p1 - (kf - 1.0) * p0) / kf;
1686                    p0 = p1;
1687                    p1 = p2;
1688                }
1689                // P_N'(x) = N (x P_N(x) − P_{N−1}(x)) / (x² − 1).
1690                dp = nf * (x * p1 - p0) / (x * x - 1.0);
1691                let dx = p1 / dp;
1692                x -= dx;
1693                if dx.abs() <= 1e-16 * x.abs().max(1.0) {
1694                    break;
1695                }
1696            }
1697            // Gauss–Legendre weight: 2 / ((1 − x²) P_N'(x)²).
1698            let w = 2.0 / ((1.0 - x * x) * dp * dp);
1699            // x is the i-th root counting inward from +1; mirror to −x.
1700            nodes.push((x, w));
1701            if x.abs() > 1e-300 {
1702                nodes.push((-x, w));
1703            }
1704        }
1705        // Sort by node, then map [-1, 1] -> [0, 1] with the 1/2 Jacobian.
1706        nodes.sort_by(|a, b| a.0.total_cmp(&b.0));
1707        nodes
1708            .into_iter()
1709            .map(|(x, w)| (0.5 * (x + 1.0), 0.5 * w))
1710            .collect()
1711    })
1712}
1713
1714/// Evaluate the hybrid Duchon–Matérn kernel
1715/// `φ(r) = F^{-1}[ ρ^{-2p} (κ²+ρ²)^{-s} ](r)` via a single, cancellation-free
1716/// 1-D integral (gam#1424).
1717///
1718/// The partial-fraction expansion `Σ a_m/ρ^{2m} + Σ b_n/(κ²+ρ²)^n` evaluates
1719/// the radial kernel as an alternating sum of individually huge polyharmonic
1720/// (`r^{2m-d}`) and Matérn blocks whose leading singular parts cancel. For
1721/// high `d` (e.g. d=16, p=2, s=7) the largest block is ~1e3 while the true
1722/// value is ~1e-13, so f64 loses *every* significant digit and the resulting
1723/// Gram matrix is no longer PSD (λ_min ≈ −0.26 after normalization).
1724///
1725/// Using the Schwinger / Feynman parametrization of both rational factors and
1726/// performing the Gaussian (radial inverse-FT) integral analytically reduces
1727/// the kernel to
1728///
1729/// ```text
1730///   φ(r) = (4π)^{-d/2} / (Γ(p)Γ(s))
1731///          · ∫₀¹ (1-w)^{p-1} w^{s-1} · 2(B/A)^{b/2} K_b(2√(AB)) dw,
1732///   with  b = p + s − d/2,  A = w κ²,  B = r²/4.
1733/// ```
1734///
1735/// The integrand is smooth and strictly positive on `(0, 1)` (no cancellation),
1736/// so a fixed 64-point Gauss–Legendre rule is accurate to ~1e-15 relative.
1737/// The `r = 0` diagonal has the closed form
1738/// `φ(0) = (4π)^{-d/2} Γ(b)/(Γ(p)Γ(s)) κ^{-2b} B(s−b, p)`.
1739///
1740/// Requires `b = p + s − d/2 > 0` (kernel existence, `2(p+s) > d`) and
1741/// `s − b = d/2 − p > 0` (integrable `w → 0` endpoint), i.e. `2p < d`. Callers
1742/// must check [`duchon_hybrid_stable_integral_applies`] before invoking.
1743pub(crate) fn duchon_hybrid_kernel_stable_integral(
1744    r: f64,
1745    kappa: f64,
1746    p_order: usize,
1747    s_order: usize,
1748    k_dim: usize,
1749) -> Result<f64, BasisError> {
1750    assert!(
1751        duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
1752        "duchon_hybrid_kernel_stable_integral precondition violated: 2(p+s) > d and 2p < d required (p={p_order}, s={s_order}, d={k_dim})"
1753    );
1754    let p = p_order as f64;
1755    let s = s_order as f64;
1756    let half_d = 0.5 * k_dim as f64;
1757    let b = p + s - half_d;
1758    let pref = (4.0 * std::f64::consts::PI).powf(-half_d) / (gamma_lanczos(p) * gamma_lanczos(s));
1759    if r == 0.0 {
1760        // φ(0) = pref · Γ(b) · κ^{-2b} · B(s−b, p),  B(x,y)=Γ(x)Γ(y)/Γ(x+y).
1761        let beta = gamma_lanczos(s - b) * gamma_lanczos(p) / gamma_lanczos(s - b + p);
1762        let value = pref * gamma_lanczos(b) * kappa.powf(-2.0 * b) * beta;
1763        if !value.is_finite() {
1764            crate::bail_invalid_basis!(
1765                "non-finite Duchon hybrid diagonal (stable form) for p={p_order}, s={s_order}, d={k_dim}"
1766            );
1767        }
1768        return Ok(value);
1769    }
1770    let mut acc = KahanSum::default();
1771    for &(w, weight) in gauss_legendre_01_64() {
1772        // Smooth term  2(B/A)^{b/2} K_b(2√(AB)) = 2 (r/(2κ√w))^b K_b(κ r √w).
1773        let sqrt_w = w.sqrt();
1774        let z = (kappa * r * sqrt_w).max(1e-300);
1775        let k_b = bessel_k_real_half_integer_or_integer(b.abs(), z)?;
1776        let smooth = 2.0 * (r / (2.0 * kappa * sqrt_w)).powf(b) * k_b;
1777        let factor = (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * smooth;
1778        acc.add(weight * factor);
1779    }
1780    let value = pref * acc.sum();
1781    if !value.is_finite() {
1782        crate::bail_invalid_basis!(
1783            "non-finite Duchon hybrid value (stable form) at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1784        );
1785    }
1786    Ok(value)
1787}
1788
1789/// Radial operator scalars `(q, t, t_r, t_rr)` of the hybrid Duchon–Matérn
1790/// kernel via the same cancellation-free single integral as
1791/// [`duchon_hybrid_kernel_stable_integral`], differentiated under the integral
1792/// sign (gam#1424 / gam#1453).
1793///
1794/// The partial-fraction operator core (`duchon_regularized_operator_core`)
1795/// assembles `q, t` as a sign-alternating sum of polyharmonic and Matérn
1796/// *operator* blocks. In high dimensions (e.g. d=16, p=1, s=9) each block is
1797/// ~1e3 while the true operator scalar is ~1e-13, so f64 loses every
1798/// significant digit — Kahan summation fixes accumulation, not the
1799/// cancellation between huge opposing terms, leaving `q, t` with ~1e-2 relative
1800/// noise. That floor sits above the Chebyshev profile certificate, so the
1801/// production profile cannot certify (gam#1453).
1802///
1803/// This routine instead differentiates the smooth per-`w` integrand
1804/// `g(r,w) = 2 (r/(2c))^b K_b(c r)`, `c = κ√w`, in `r`. Each `w`-slice is a
1805/// single well-conditioned `r^a K_ν(c r)` term whose `r`-derivatives are exact
1806/// (`d/dr[r^a K_ν(c r)] = a r^{a-1} K_ν(c r) − (c/2) r^a (K_{ν-1}+K_{ν+1})`),
1807/// so there is no cross-block cancellation. The radial derivatives `φ′…φ⁗`
1808/// are integrated against the same `(1-w)^{p-1} w^{s-1}` weight and the
1809/// 64-node Gauss–Legendre rule, then the operator scalars are assembled from
1810/// the standard radial relations
1811/// `q = φ′/r`, `t = q′/r`, `t_r = (q″−t)/r`, `t_rr = q‴/r − 2q″/r² + 2q′/r³`.
1812///
1813/// Requires the same precondition as the kernel form
1814/// ([`duchon_hybrid_stable_integral_applies`]) and `r > 0`.
1815pub(crate) fn duchon_hybrid_operator_stable_integral(
1816    r: f64,
1817    kappa: f64,
1818    p_order: usize,
1819    s_order: usize,
1820    k_dim: usize,
1821) -> Result<DuchonRegularizedOperatorCore, BasisError> {
1822    assert!(
1823        duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
1824        "duchon_hybrid_operator_stable_integral precondition violated: 2(p+s) > d and 2p < d required (p={p_order}, s={s_order}, d={k_dim})"
1825    );
1826    assert!(
1827        r > 0.0 && r.is_finite(),
1828        "duchon_hybrid_operator_stable_integral requires r > 0, got r={r}"
1829    );
1830    let p = p_order as f64;
1831    let s = s_order as f64;
1832    let half_d = 0.5 * k_dim as f64;
1833    let b = p + s - half_d;
1834    let pref = (4.0 * std::f64::consts::PI).powf(-half_d) / (gamma_lanczos(p) * gamma_lanczos(s));
1835
1836    // Accumulate φ′, φ″, φ‴, φ⁗ across the Gauss–Legendre nodes. (φ itself is
1837    // not needed for the operator scalars.)
1838    let mut d1 = KahanSum::default();
1839    let mut d2 = KahanSum::default();
1840    let mut d3 = KahanSum::default();
1841    let mut d4 = KahanSum::default();
1842
1843    for &(w, weight) in gauss_legendre_01_64() {
1844        let sqrt_w = w.sqrt();
1845        let c = (kappa * sqrt_w).max(1e-300);
1846        let z = (c * r).max(1e-300);
1847
1848        // Smooth integrand g(r) = A · r^b · K_b(c r),  A = 2 (2c)^{-b}.
1849        // Differentiate the symbolic term list (coef, a, ν-offset) in r:
1850        //   d/dr[c0 r^a K_{b+j}(c r)]
1851        //     = c0·a · r^{a-1} K_{b+j}(c r)
1852        //       − c0·(c/2) · r^a (K_{b+j-1}(c r) + K_{b+j+1}(c r)).
1853        // Four derivatives need ν-offsets in [-4, 4] around b.
1854        let a0 = 2.0 * (2.0 * c).powf(-b);
1855        let mut terms: Vec<(f64, f64, i32)> = vec![(a0, b, 0)];
1856        // Cache K_{b+j}(z) for j ∈ [-4, 4] (K is even in order → use |·|).
1857        let bessel = |j: i32| -> Result<f64, BasisError> {
1858            bessel_k_real_half_integer_or_integer((b + j as f64).abs(), z)
1859        };
1860        let evaluate = |terms: &Vec<(f64, f64, i32)>| -> Result<f64, BasisError> {
1861            let mut acc = KahanSum::default();
1862            for &(c0, a, j) in terms {
1863                if c0 == 0.0 {
1864                    continue;
1865                }
1866                acc.add(c0 * r.powf(a) * bessel(j)?);
1867            }
1868            Ok(acc.sum())
1869        };
1870
1871        let mut slice_derivs = [0.0_f64; 4];
1872        for slot in slice_derivs.iter_mut() {
1873            // Differentiate the current term list once.
1874            let mut next: Vec<(f64, f64, i32)> = Vec::with_capacity(terms.len() * 3);
1875            for &(c0, a, j) in &terms {
1876                if c0 == 0.0 {
1877                    continue;
1878                }
1879                if a != 0.0 {
1880                    next.push((c0 * a, a - 1.0, j));
1881                }
1882                let half = -c0 * c * 0.5;
1883                next.push((half, a, j - 1));
1884                next.push((half, a, j + 1));
1885            }
1886            terms = next;
1887            *slot = evaluate(&terms)?;
1888        }
1889
1890        d1.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[0]);
1891        d2.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[1]);
1892        d3.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[2]);
1893        d4.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[3]);
1894    }
1895
1896    let phi1 = pref * d1.sum();
1897    let phi2 = pref * d2.sum();
1898    let phi3 = pref * d3.sum();
1899    let phi4 = pref * d4.sum();
1900    if !(phi1.is_finite() && phi2.is_finite() && phi3.is_finite() && phi4.is_finite()) {
1901        crate::bail_invalid_basis!(
1902            "non-finite Duchon hybrid operator (stable form) at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1903        );
1904    }
1905
1906    // Assemble the operator scalars from the radial derivatives. For r > 0
1907    // these divisions are removable-singularity quotients of moderate
1908    // quantities (no cancellation between blocks remains).
1909    let inv_r = 1.0 / r;
1910    let q = phi1 * inv_r;
1911    // q′ = φ″/r − φ′/r²; q″ = φ‴/r − 2φ″/r² + 2φ′/r³;
1912    // q‴ = φ⁗/r − 3φ‴/r² + 6φ″/r³ − 6φ′/r⁴.
1913    let q_r = phi2 * inv_r - phi1 * inv_r * inv_r;
1914    let q_rr = phi3 * inv_r - 2.0 * phi2 * inv_r * inv_r + 2.0 * phi1 * inv_r * inv_r * inv_r;
1915    let q_rrr = phi4 * inv_r - 3.0 * phi3 * inv_r * inv_r + 6.0 * phi2 * inv_r * inv_r * inv_r
1916        - 6.0 * phi1 * inv_r * inv_r * inv_r * inv_r;
1917    let t = q_r * inv_r;
1918    let t_r = q_rr * inv_r - q_r * inv_r * inv_r;
1919    let t_rr = q_rrr * inv_r - 2.0 * q_rr * inv_r * inv_r + 2.0 * q_r * inv_r * inv_r * inv_r;
1920
1921    Ok(DuchonRegularizedOperatorCore { q, t, t_r, t_rr })
1922}
1923
1924/// Whether the cancellation-free [`duchon_hybrid_kernel_stable_integral`] is
1925/// applicable for these orders: a genuine Matérn blend (`s ≥ 1`) whose
1926/// single-integral reduction has an integrable `w → 0` endpoint (`2p < d`).
1927///
1928/// The complementary cases — `s = 0` (pure polyharmonic, already evaluated
1929/// directly with no cancellation) and `2p ≥ d` (only reachable at low `d`,
1930/// where the partial-fraction sum has no meaningful cancellation) — retain the
1931/// existing partial-fraction path.
1932#[inline]
1933pub(crate) fn duchon_hybrid_stable_integral_applies(
1934    p_order: usize,
1935    s_order: usize,
1936    k_dim: usize,
1937) -> bool {
1938    s_order >= 1 && 2 * p_order < k_dim
1939}
1940
1941pub(crate) fn duchon_matern_kernel_general_from_distance(
1942    r: f64,
1943    length_scale: Option<f64>,
1944    p_order: usize,
1945    s_order: usize,
1946    k_dim: usize,
1947    coeffs: Option<&DuchonPartialFractionCoeffs>,
1948) -> Result<f64, BasisError> {
1949    if !r.is_finite() || r < 0.0 {
1950        crate::bail_invalid_basis!("Duchon kernel distance must be finite and non-negative");
1951    }
1952    let Some(length_scale) = length_scale else {
1953        return Ok(polyharmonic_kernel(
1954            r,
1955            pure_duchon_block_order(p_order, s_order as f64),
1956            k_dim,
1957        ));
1958    };
1959    if !length_scale.is_finite() || length_scale <= 0.0 {
1960        crate::bail_invalid_basis!("Duchon hybrid length_scale must be finite and positive");
1961    }
1962    let kappa = 1.0 / length_scale;
1963
1964    // gam#1424: for genuine high-dimensional Matérn blends the partial-fraction
1965    // sum below cancels catastrophically (the largest block dwarfs the true
1966    // ~1e-13 kernel value, destroying every significant digit and the PSD
1967    // property of the Gram matrix). Evaluate those orders with the
1968    // cancellation-free single-integral form instead — it also handles the
1969    // `r = 0` diagonal in closed form, so it short-circuits before the
1970    // near-collision Taylor branch.
1971    if duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim) {
1972        return duchon_hybrid_kernel_stable_integral(r, kappa, p_order, s_order, k_dim);
1973    }
1974
1975    let coeffs_local;
1976    let coeffs_ref = if let Some(c) = coeffs {
1977        c
1978    } else {
1979        coeffs_local = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
1980        &coeffs_local
1981    };
1982    let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale.max(1e-8);
1983    // The near-collision Taylor expansion uses phi(0) plus even-order
1984    // derivative collision limits. Those limits only exist when the kernel
1985    // is finite at the origin, i.e. when 2(p+s) > d. Below that threshold
1986    // the partial-fraction blocks individually diverge at r=0 but their
1987    // sum is still a well-defined function for any r > 0 (each Bessel-K
1988    // and r^{2m-d}-type block is finite away from origin). Fall through
1989    // to the direct sum in that regime; r=0 itself remains an error.
1990    let kernel_finite_at_origin = 2 * (p_order + s_order) > k_dim;
1991    if r <= collision_taylor_radius && kernel_finite_at_origin {
1992        return duchon_hybrid_kernel_near_collision_value(
1993            r,
1994            length_scale,
1995            p_order,
1996            s_order,
1997            k_dim,
1998            coeffs_ref,
1999        );
2000    }
2001    let mut val = KahanSum::default();
2002    for (m, coeff) in coeffs_ref.a.iter().enumerate().skip(1) {
2003        if *coeff == 0.0 {
2004            continue;
2005        }
2006        val.add(coeff * polyharmonic_kernel(r, (m) as f64, k_dim));
2007    }
2008    for (n, coeff) in coeffs_ref.b.iter().enumerate().skip(1) {
2009        if *coeff == 0.0 {
2010            continue;
2011        }
2012        val.add(coeff * duchon_matern_block(r, kappa, n, k_dim)?);
2013    }
2014    Ok(val.sum())
2015}
2016
2017pub(crate) fn duchon_hybrid_kernel_collision_value(
2018    length_scale: f64,
2019    p_order: usize,
2020    s_order: usize,
2021    k_dim: usize,
2022    coeffs: &DuchonPartialFractionCoeffs,
2023) -> Result<f64, BasisError> {
2024    let spectral_order = 2 * (p_order + s_order);
2025    if spectral_order <= k_dim {
2026        crate::bail_invalid_basis!(
2027            "Duchon hybrid diagonal is not finite when 2*(p+s) <= dimension; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
2028        );
2029    }
2030
2031    let kappa = 1.0 / length_scale.max(1e-300);
2032    let mut pure = KahanSum::default();
2033    let mut log_part = KahanSum::default();
2034    for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
2035        if a_m == 0.0 {
2036            continue;
2037        }
2038        let (block_pure, block_log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, 0);
2039        pure.add(a_m * block_pure);
2040        log_part.add(a_m * block_log);
2041    }
2042    for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
2043        if b_n == 0.0 {
2044            continue;
2045        }
2046        let (block_pure, block_log) = duchon_matern_block_taylor_r2j(kappa, n, k_dim, 0);
2047        pure.add(b_n * block_pure);
2048        log_part.add(b_n * block_log);
2049    }
2050    let value = pure.sum();
2051    let log_value = log_part.sum();
2052    if log_value.abs() > 1e-8 * value.abs().max(1e-30) {
2053        crate::bail_invalid_basis!(
2054            "Duchon hybrid diagonal log terms did not cancel: log={log_value:.6e}, value={value:.6e}; p={p_order}, s={s_order}, d={k_dim}"
2055        );
2056    }
2057    if !value.is_finite() {
2058        crate::bail_invalid_basis!(
2059            "non-finite Duchon hybrid diagonal value for p={p_order}, s={s_order}, d={k_dim}"
2060        );
2061    }
2062    Ok(value)
2063}
2064
2065pub(crate) fn duchon_hybrid_kernel_near_collision_value(
2066    r: f64,
2067    length_scale: f64,
2068    p_order: usize,
2069    s_order: usize,
2070    k_dim: usize,
2071    coeffs: &DuchonPartialFractionCoeffs,
2072) -> Result<f64, BasisError> {
2073    let mut value =
2074        duchon_hybrid_kernel_collision_value(length_scale, p_order, s_order, k_dim, coeffs)?;
2075    if r == 0.0 {
2076        return Ok(value);
2077    }
2078
2079    // Radial Taylor expansion about the center collision:
2080    //
2081    //   phi(r) = phi(0)
2082    //          + phi''(0) r^2 / 2
2083    //          + phi''''(0) r^4 / 24
2084    //          + phi''''''(0) r^6 / 720 + ...
2085    //
2086    // Odd terms vanish for an isotropic radial kernel. A finite 2q-th
2087    // derivative at zero requires spectral smoothness 2(p+s) > d + 2q.
2088    // Terms whose collision derivative does not exist are omitted; this is
2089    // still strictly better than evaluating the raw partial-fraction sum at a
2090    // tiny nonzero radius, where large singular components cancel only after
2091    // losing many digits.
2092    let smoothness_order = 2 * (p_order + s_order);
2093    let r2 = r * r;
2094    if smoothness_order > k_dim + 2 {
2095        let (phi_rr, _, _) =
2096            duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
2097        value += 0.5 * phi_rr * r2;
2098    }
2099    if smoothness_order > k_dim + 4 {
2100        let phi_rrrr = duchon_phi_rrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)?;
2101        value += (1.0 / 24.0) * phi_rrrr * r2 * r2;
2102    }
2103    if smoothness_order > k_dim + 6 {
2104        let phi_rrrrrr =
2105            duchon_phi_rrrrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)?;
2106        value += (1.0 / 720.0) * phi_rrrrrr * r2 * r2 * r2;
2107    }
2108    if !value.is_finite() {
2109        crate::bail_invalid_basis!(
2110            "non-finite Duchon hybrid near-collision value at r={r}, p={p_order}, s={s_order}, d={k_dim}"
2111        );
2112    }
2113    Ok(value)
2114}
2115
2116#[inline(always)]
2117pub(crate) fn stable_euclidean_norm<I>(components: I) -> f64
2118where
2119    I: IntoIterator<Item = f64>,
2120{
2121    let mut scale = 0.0_f64;
2122    let mut sumsq = 1.0_f64;
2123    let mut has_nonzero = false;
2124    for component in components {
2125        let abs = component.abs();
2126        if abs == 0.0 {
2127            continue;
2128        }
2129        if !abs.is_finite() {
2130            return f64::INFINITY;
2131        }
2132        if !has_nonzero {
2133            scale = abs;
2134            has_nonzero = true;
2135            continue;
2136        }
2137        if scale < abs {
2138            let ratio = scale / abs;
2139            sumsq = 1.0 + sumsq * ratio * ratio;
2140            scale = abs;
2141        } else {
2142            let ratio = abs / scale;
2143            sumsq += ratio * ratio;
2144        }
2145    }
2146    if has_nonzero {
2147        scale * sumsq.sqrt()
2148    } else {
2149        0.0
2150    }
2151}
2152
2153#[inline]
2154pub(crate) fn centered_aniso_log_scale_mean(eta: &[f64]) -> f64 {
2155    if eta.len() <= 1 {
2156        0.0
2157    } else {
2158        eta.iter().sum::<f64>() / eta.len() as f64
2159    }
2160}
2161
2162#[inline]
2163pub(crate) fn centered_aniso_log_scale(value: f64, mean: f64) -> f64 {
2164    // This bound exists solely to keep the downstream `.exp()` (axis scale and
2165    // metric weight) finite. `f64::clamp` leaves NaN as NaN, so a non-finite
2166    // contrast (e.g. an `inf − inf` from a degenerate anisotropy `eta`) would
2167    // slip through and poison the Gram matrix. Map any non-finite difference to
2168    // the saturating bound explicitly; finite inputs take the identical clamp.
2169    let centered = value - mean;
2170    if centered.is_finite() {
2171        centered.clamp(-50.0, 50.0)
2172    } else if centered > 0.0 {
2173        50.0
2174    } else {
2175        -50.0
2176    }
2177}
2178
2179#[inline]
2180pub(crate) fn aniso_axis_scale(value: f64, mean: f64) -> f64 {
2181    centered_aniso_log_scale(value, mean).exp()
2182}
2183
2184#[inline]
2185pub(crate) fn aniso_metric_weight(value: f64, mean: f64) -> f64 {
2186    (2.0 * centered_aniso_log_scale(value, mean)).exp()
2187}
2188
2189pub(crate) fn centered_aniso_metric_weights(eta: &[f64]) -> Vec<f64> {
2190    let mean = centered_aniso_log_scale_mean(eta);
2191    eta.iter()
2192        .map(|&value| aniso_metric_weight(value, mean))
2193        .collect()
2194}
2195
2196/// Compute anisotropic squared distance components and total distance.
2197///
2198/// This is the core of **geometric anisotropy**: a linear warp Λ = diag(κ_a)
2199/// turns ellipsoidal correlation contours into isotropic ones. Writing h = x − c,
2200/// z = Λh, the anisotropic distance is r = |z| = |Λh|.
2201///
2202/// We decompose Λ = κ · A where det(A) = 1, parameterized as
2203///   ψ_a = ψ̄ + η_a,   Σ η_a = 0
2204/// where ψ̄ is the global scale (existing scalar κ) and η_a are d−1 anisotropy
2205/// contrasts. This separates scale from shape and preserves the Duchon scaling
2206/// law φ(r;κ) = κ^δ H(κr) for the global part.
2207///
2208/// Given per-axis log-scales `eta`, the identifiable centered contrasts are
2209/// ψ_a = eta_a - mean(eta). The metric uses those contrasts so Σ_a ψ_a = 0
2210/// even when a caller passes an uncentered vector:
2211///
2212///   r = √( Σ_a exp(2·ψ_a) · (x_a - c_a)² )
2213///
2214/// Returns `(r, s_vec)` where `s_vec[a] = exp(2·ψ_a) · h_a²` is the
2215/// per-axis weighted squared displacement. These components are needed for
2216/// per-axis derivatives: `∂φ/∂ψ_a = q · s_a`.
2217///
2218/// The derivative chain through r gives:
2219///   ∇_ψ r      = s / r
2220///   ∇²_ψ r     = (2/r) Diag(s) − (1/r³) ss'
2221/// which is diagonal + rank-1, so Hessian-vector products are O(d).
2222#[inline]
2223pub(crate) fn aniso_distance_and_components(
2224    data_row: &[f64],
2225    center: &[f64],
2226    eta: &[f64],
2227) -> (f64, Vec<f64>) {
2228    assert_eq!(data_row.len(), center.len());
2229    assert_eq!(data_row.len(), eta.len());
2230    let d = data_row.len();
2231    let eta_mean = centered_aniso_log_scale_mean(eta);
2232    let mut s_vec = Vec::with_capacity(d);
2233    let mut scaled_components = Vec::with_capacity(d);
2234    for a in 0..d {
2235        let h_a = data_row[a] - center[a];
2236        // Clamp exp(2ψ) to avoid overflow/underflow: ψ in [-50, 50].
2237        let scale_a = aniso_axis_scale(eta[a], eta_mean);
2238        let scaled_h_a = scale_a * h_a;
2239        let s_a = scaled_h_a * scaled_h_a;
2240        scaled_components.push(scaled_h_a);
2241        s_vec.push(s_a);
2242    }
2243    (stable_euclidean_norm(scaled_components), s_vec)
2244}
2245
2246/// Compute anisotropic distance without returning per-axis components.
2247///
2248/// This is the lightweight version of [`aniso_distance_and_components`] for
2249/// call sites that only need the scalar distance `r`.
2250#[inline]
2251pub(crate) fn aniso_distance(data_row: &[f64], center: &[f64], eta: &[f64]) -> f64 {
2252    assert_eq!(data_row.len(), center.len());
2253    assert_eq!(data_row.len(), eta.len());
2254    let eta_mean = centered_aniso_log_scale_mean(eta);
2255    stable_euclidean_norm(
2256        (0..data_row.len()).map(|a| aniso_axis_scale(eta[a], eta_mean) * (data_row[a] - center[a])),
2257    )
2258}
2259
2260#[inline(always)]
2261pub(crate) fn euclidean_distance_rows(
2262    lhs: ArrayView2<'_, f64>,
2263    lhs_row: usize,
2264    rhs: ArrayView2<'_, f64>,
2265    rhs_row: usize,
2266) -> f64 {
2267    assert_eq!(lhs.ncols(), rhs.ncols());
2268    stable_euclidean_norm((0..lhs.ncols()).map(|axis| lhs[[lhs_row, axis]] - rhs[[rhs_row, axis]]))
2269}
2270
2271#[inline(always)]
2272pub(crate) fn aniso_axis_scales(eta: &[f64]) -> Vec<f64> {
2273    let eta_mean = centered_aniso_log_scale_mean(eta);
2274    eta.iter()
2275        .map(|&value| aniso_axis_scale(value, eta_mean))
2276        .collect()
2277}
2278
2279#[inline(always)]
2280pub(crate) fn aniso_distance_rows_with_scales(
2281    lhs: ArrayView2<'_, f64>,
2282    lhs_row: usize,
2283    rhs: ArrayView2<'_, f64>,
2284    rhs_row: usize,
2285    axis_scales: &[f64],
2286) -> f64 {
2287    assert_eq!(lhs.ncols(), rhs.ncols());
2288    assert_eq!(lhs.ncols(), axis_scales.len());
2289    stable_euclidean_norm(
2290        (0..lhs.ncols())
2291            .map(|axis| axis_scales[axis] * (lhs[[lhs_row, axis]] - rhs[[rhs_row, axis]])),
2292    )
2293}
2294
2295pub(crate) fn fill_symmetric_from_row_kernel<F>(
2296    matrix: &mut Array2<f64>,
2297    kernel: F,
2298) -> Result<(), BasisError>
2299where
2300    F: Fn(usize, usize) -> Result<f64, BasisError> + Sync,
2301{
2302    assert_eq!(matrix.nrows(), matrix.ncols());
2303    let k = matrix.nrows();
2304    // The kernels passed here are pure functions of the (symmetric) pairwise
2305    // center distance, so `kernel(i, j) == kernel(j, i)`. Evaluate only the
2306    // upper triangle (including the diagonal) in parallel — each row task
2307    // touches only its own `j >= i` cells, so the borrows stay disjoint — then
2308    // mirror into the lower triangle. This halves the (sqrt + special-function)
2309    // kernel evaluations relative to filling every cell independently, with no
2310    // change to the resulting matrix (still exactly symmetric).
2311    matrix
2312        .axis_iter_mut(Axis(0))
2313        .into_par_iter()
2314        .enumerate()
2315        .try_for_each(|(i, mut row)| {
2316            for j in i..k {
2317                row[j] = kernel(i, j)?;
2318            }
2319            Ok::<(), BasisError>(())
2320        })?;
2321    for i in 1..k {
2322        for j in 0..i {
2323            matrix[[i, j]] = matrix[[j, i]];
2324        }
2325    }
2326    Ok(())
2327}
2328
2329/// Return y-space points `y_{i,a} = exp(ψ_a) x_{i,a}` with
2330/// `ψ_a = η_a - mean(η)` so Euclidean pairwise
2331/// distances in y equal anisotropic kernel distances in x:
2332///   |y_i - y_j|² = Σ_a exp(2 ψ_a) (x_{i,a} - x_{j,a})² = aniso_distance²(x_i, x_j, η).
2333/// Use this before `pairwise_distance_bounds` whenever κ conditioning
2334/// bounds must match the kernel's actual metric (anisotropic case). For
2335/// isotropic terms, pass `None` and keep using the raw centers.
2336pub(crate) fn points_in_aniso_y_space(points: ArrayView2<'_, f64>, eta: &[f64]) -> Array2<f64> {
2337    assert_eq!(points.ncols(), eta.len());
2338    let mut y = points.to_owned();
2339    let eta_mean = centered_aniso_log_scale_mean(eta);
2340    let weights: Vec<f64> = eta.iter().map(|&e| aniso_axis_scale(e, eta_mean)).collect();
2341    for a in 0..eta.len() {
2342        let w_a = weights[a];
2343        y.column_mut(a).mapv_inplace(|v| v * w_a);
2344    }
2345    y
2346}
2347
2348/// Compute per-axis standard deviations of knot center coordinates.
2349///
2350/// Returns σ_a for each axis column of `centers`. Axes with zero variance
2351/// (constant column) get σ_a = 1.0. All values are clamped to [1e-6, 1e6].
2352pub fn knot_cloud_axis_scales(centers: ArrayView2<'_, f64>) -> Vec<f64> {
2353    let k = centers.nrows();
2354    let d = centers.ncols();
2355    if k < 2 || d == 0 {
2356        return vec![1.0; d];
2357    }
2358    let n = k as f64;
2359    let mut scales = Vec::with_capacity(d);
2360    for a in 0..d {
2361        let col = centers.column(a);
2362        let mean = col.sum() / n;
2363        let var = col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2364        let sigma = var.sqrt();
2365        // If variance is zero (constant column), use 1.0 (no scaling).
2366        let sigma = if sigma < 1e-12 { 1.0 } else { sigma };
2367        scales.push(sigma.clamp(1e-6, 1e6));
2368    }
2369    scales
2370}
2371
2372/// Compute initial anisotropy contrasts η_a from knot center geometry.
2373///
2374/// Returns η_a = −ln(σ_a) + (1/d) Σ_b ln(σ_b), which satisfies Ση_a = 0
2375/// by construction. Axes with more spread get negative η_a (smaller κ_a,
2376/// longer correlation range), axes with less spread get positive η_a.
2377///
2378/// If d ≤ 1, returns an empty vector (anisotropy is meaningless for 1-D).
2379pub fn initial_aniso_contrasts(centers: ArrayView2<'_, f64>) -> Vec<f64> {
2380    let d = centers.ncols();
2381    if d <= 1 {
2382        return Vec::new();
2383    }
2384    let scales = knot_cloud_axis_scales(centers);
2385    let mean_neg_log: f64 = scales.iter().map(|&s| -s.ln()).sum::<f64>() / d as f64;
2386    // η_a = −ln(σ_a) + (1/d) Σ_b ln(σ_b)
2387    //     = −ln(σ_a) − mean(−ln(σ_b))
2388    //     = neg_log_scales[a] − mean(neg_log_scales)
2389    scales
2390        .iter()
2391        .map(|&scale| -scale.ln() - mean_neg_log)
2392        .collect()
2393}
2394
2395/// Pure forward transform of the supplied anisotropy log-scales: subtract the
2396/// mean (so Σ η = 0) and zero tiny residuals. `None` (or a 1-D problem, where
2397/// centering is a no-op) means *no* anisotropy.
2398///
2399/// This is a **continuous function of η with no hidden data dependence**: an
2400/// explicit all-zero vector centers to all-zero, i.e. the isotropic metric
2401/// (weights `exp(2·0) = 1`, Euclidean radius). It is therefore identical, as a
2402/// design, to the `None` path through `η = 0`, and is continuous across it —
2403/// `[1e-9, -1e-9]` and `[0, 0]` map to neighboring designs, not a jump.
2404///
2405/// The Matérn input-location jet/Hessian (`matern_metric_weights`, the public
2406/// `matern_input_location_first_jet`/`_hessian` FFI) and the `UserProvided`-center
2407/// forward design both apply *this* transform, so the jet differentiates exactly
2408/// the function the public design evaluates (#437), and an explicit isotropic
2409/// request reduces to the closed-form isotropic Matérn kernel rather than a
2410/// data-driven anisotropic one (#1042).
2411///
2412/// Auto-initialization of `η` from knot-cloud geometry is a *separate* concern
2413/// handled by [`auto_seed_aniso_contrasts`]; it is reserved for callers that
2414/// opt into data-derived geometry (the κ-optimizer's data-driven center
2415/// strategies and the pure-Duchon `scale_dims` path), selected by
2416/// [`resolve_matern_forward_aniso`].
2417pub(crate) fn centered_aniso_contrasts(aniso: Option<&[f64]>) -> Option<Vec<f64>> {
2418    match aniso {
2419        Some(v) if v.len() > 1 => Some(center_aniso_log_scales(v)),
2420        Some(v) => Some(v.to_vec()),
2421        None => None,
2422    }
2423}
2424
2425/// Auto-seed anisotropy contrasts from knot-cloud geometry for callers that use
2426/// an all-zero vector as the "initialize me" sentinel.
2427///
2428/// Used by (a) the pure-Duchon `scale_dims` path, where `η` is a FIXED,
2429/// geometry-derived basis parameter that is never enrolled as a REML hyper-axis
2430/// (see `spatial_term_supports_hyper_optimization`): "standardize the geometry,
2431/// then learn the smoothness"; and (b) the Matérn forward design when the term
2432/// uses a **data-driven** center strategy, i.e. the κ-optimizer's seeding
2433/// sentinel (the optimizer's analytic ψ-gradient is computed against the same
2434/// auto-seeded design, so the pair stays consistent). A non-zero (or absent)
2435/// vector is honored verbatim (centered, exactly like [`centered_aniso_contrasts`]);
2436/// only an *exactly* all-zero vector is replaced by `initial_aniso_contrasts(centers)`.
2437///
2438/// A `UserProvided`-center Matérn term does NOT use this — its geometry is fully
2439/// caller-specified, so an explicit all-zero η must be honored literally; folding
2440/// the geometry seed into that path made the public design discontinuous at
2441/// `η = 0` and hijacked explicit isotropic requests (#1042).
2442pub(crate) fn auto_seed_aniso_contrasts(
2443    centers: ArrayView2<'_, f64>,
2444    aniso: Option<&[f64]>,
2445) -> Option<Vec<f64>> {
2446    let eta = match aniso {
2447        Some(v) if v.len() > 1 => v,
2448        Some(v) => return Some(v.to_vec()),
2449        None => return None,
2450    };
2451    let all_zero = eta.iter().all(|&e| e == 0.0);
2452    if !all_zero {
2453        return Some(center_aniso_log_scales(eta));
2454    }
2455    let contrasts = initial_aniso_contrasts(centers);
2456    if contrasts.is_empty() {
2457        Some(center_aniso_log_scales(eta))
2458    } else {
2459        Some(center_aniso_log_scales(&contrasts))
2460    }
2461}
2462
2463fn center_aniso_log_scales(eta: &[f64]) -> Vec<f64> {
2464    if eta.len() <= 1 {
2465        return eta.to_vec();
2466    }
2467    let mean = eta.iter().sum::<f64>() / eta.len() as f64;
2468    eta.iter()
2469        .map(|&v| {
2470            let centered = v - mean;
2471            if centered.abs() <= 1e-15 {
2472                0.0
2473            } else {
2474                centered
2475            }
2476        })
2477        .collect()
2478}
2479
2480/// How the Matérn forward design build interprets an *exactly all-zero*
2481/// `aniso_log_scales` vector.
2482#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2483pub enum AnisoSeedMode {
2484    /// All-zero `η` is the κ-optimizer / `scale_dims` seeding sentinel: replace
2485    /// it with geometry-derived contrasts from the knot cloud
2486    /// (`auto_seed_aniso_contrasts`). This is the default for every internal
2487    /// build entry; the optimizer's analytic ψ-gradient is computed against the
2488    /// same auto-seeded design, so value/gradient stay consistent. Note that by
2489    /// the time the κ-optimizer rebuilds a frozen design the center strategy has
2490    /// usually been resolved to `UserProvided`, so center provenance cannot be
2491    /// used to distinguish this from a genuine literal request — the mode must
2492    /// be carried explicitly.
2493    AutoSeedFromGeometry,
2494    /// All-zero `η` is an explicit isotropic request and is honored literally
2495    /// (`centered_aniso_contrasts`): the design reduces to the closed-form
2496    /// isotropic Matérn and varies continuously through `η = 0`. The public
2497    /// `matern_basis` FFI (and its input-location jet/Hessian) selects this so a
2498    /// caller's explicit isotropic request is not hijacked into a data-driven
2499    /// anisotropic kernel (#1042).
2500    Literal,
2501}
2502
2503/// Resolve the anisotropy contrasts the Matérn forward design build applies,
2504/// dispatching on the explicit [`AnisoSeedMode`].
2505pub(crate) fn resolve_matern_forward_aniso(
2506    mode: AnisoSeedMode,
2507    centers: ArrayView2<'_, f64>,
2508    aniso: Option<&[f64]>,
2509) -> Option<Vec<f64>> {
2510    match mode {
2511        AnisoSeedMode::Literal => centered_aniso_contrasts(aniso),
2512        AnisoSeedMode::AutoSeedFromGeometry => auto_seed_aniso_contrasts(centers, aniso),
2513    }
2514}
2515
2516pub(crate) fn pairwise_distance_bounds(points: ArrayView2<'_, f64>) -> Option<(f64, f64)> {
2517    let n = points.nrows();
2518    let d = points.ncols();
2519    if n < 2 || d == 0 {
2520        return None;
2521    }
2522    let mut r_min = f64::INFINITY;
2523    let mut r_max = 0.0_f64;
2524    for i in 0..n {
2525        for j in (i + 1)..n {
2526            let r = stable_euclidean_norm((0..d).map(|c| points[[i, c]] - points[[j, c]]));
2527            if r.is_finite() && r > 0.0 {
2528                r_min = r_min.min(r);
2529                r_max = r_max.max(r);
2530            }
2531        }
2532    }
2533    if r_min.is_finite() && r_max.is_finite() && r_min > 0.0 && r_max > 0.0 {
2534        Some((r_min, r_max))
2535    } else {
2536        None
2537    }
2538}
2539
2540/// Capped-sample pairwise distance bounds for large point clouds.
2541///
2542/// Returns `(r_min_hat, r_max_hat)` such that:
2543/// - `r_max_hat <= true r_max`  (pairwise max over a sub-sample is monotone
2544///    in the sample, so the sampled max underestimates the true max).
2545/// - `r_min_hat >= true r_min`  (pairwise min over a sub-sample can only
2546///    exclude some pairs, so the sampled min overestimates the true min).
2547///
2548/// Both approximations are conservative for κ-bound derivation:
2549///   kappa_lo = 1e-2 / r_max_hat  >=  1e-2 / true r_max  (wider window, low κ)
2550///   kappa_hi = 1e2  / r_min_hat  <=  1e2  / true r_min  (tighter window, high κ)
2551/// so no feasible κ that the exact bound would include is excluded by the
2552/// approximation — it can only slightly shrink the high-κ tail, which is
2553/// exactly the regime (κ → ∞ ⇒ degenerate kernel) that we want the outer
2554/// optimizer to avoid anyway.
2555///
2556/// Sampling picks `K = 1024` indices spaced evenly across the FULL index range
2557/// `[0, n-1]` (endpoints included): `idx(s) = round(s·(n-1)/(K-1))`. For a cap
2558/// of `K = 1024` and n up to ~10⁹ this yields O(K²·d) work per call — a few
2559/// hundred μs. For n ≤ K the exact pairwise is used.
2560///
2561/// #1033: spanning the full range (rather than the contiguous prefix `0,
2562/// stride, …, (K-1)·stride` that an integer `stride = n/K` produces — which
2563/// floors to `1` for every n in `(K, 2K]` and so visits only indices `0..K`,
2564/// dropping the entire tail of the cloud) is what makes the diameter estimate
2565/// `r_max_hat` n-STABLE. The κ/ψ window is derived once from `r_max_hat`
2566/// (`psi_lo = ln(diameter_fraction / r_max_hat)`); a prefix-sampled `r_max_hat`
2567/// shrinks with the prefix's spatial extent as n crosses `K`, which silently
2568/// makes the outer optimizer's box — and therefore its whole trajectory —
2569/// n-dependent. Even spacing keeps `r_max_hat` ≈ the true cloud diameter at
2570/// every n, so the sufficient-statistic outer loop touches the same ψ window
2571/// regardless of n.
2572pub(crate) fn pairwise_distance_bounds_sampled(points: ArrayView2<'_, f64>) -> Option<(f64, f64)> {
2573    const K_CAP: usize = 1024;
2574    let n = points.nrows();
2575    let d = points.ncols();
2576    if n < 2 || d == 0 {
2577        return None;
2578    }
2579    if n <= K_CAP {
2580        return pairwise_distance_bounds(points);
2581    }
2582    // Evenly spaced indices spanning `[0, n-1]` inclusive (n > K_CAP ⇒ k = K_CAP
2583    // ≥ 2, so the denominator is positive and `idx(0)=0`, `idx(k-1)=n-1`). The
2584    // spacing `(n-1)/(k-1) > 1`, so distinct `s` map to distinct rows; any rare
2585    // rounding collision is harmless (the `r > 0.0` guard drops a zero pair).
2586    let k = K_CAP;
2587    let denom = (k - 1) as f64;
2588    let span = (n - 1) as f64;
2589    let sample_index = |s: usize| -> usize { ((s as f64) * span / denom).round() as usize };
2590    let mut r_min = f64::INFINITY;
2591    let mut r_max = 0.0_f64;
2592    for i_idx in 0..k {
2593        let i = sample_index(i_idx);
2594        for j_idx in (i_idx + 1)..k {
2595            let j = sample_index(j_idx);
2596            let r = stable_euclidean_norm((0..d).map(|c| points[[i, c]] - points[[j, c]]));
2597            if r.is_finite() && r > 0.0 {
2598                r_min = r_min.min(r);
2599                r_max = r_max.max(r);
2600            }
2601        }
2602    }
2603    if r_min.is_finite() && r_max.is_finite() && r_min > 0.0 && r_max > 0.0 {
2604        Some((r_min, r_max))
2605    } else {
2606        None
2607    }
2608}
2609
2610#[cfg(test)]
2611mod bessel_k_accuracy_tests {
2612    use super::*;
2613
2614    /// `K₀` and `K₁` against a 50-digit `mpmath.besselk`, across both branches.
2615    ///
2616    /// The small-argument branch (`x ≤ 2`) was always accurate — it sums a
2617    /// convergent series to an `f64::EPSILON` break. The large-argument branch
2618    /// was the A&S 9.8.6 / 9.8.8 minimax polynomials at `1.6e−7`, so the pair
2619    /// disagreed by `2.9e−9` across their own crossover: a jump discontinuity
2620    /// in a radial kernel that `duchon_matern_block_jet4` differentiates with
2621    /// respect to the length scale.
2622    #[test]
2623    fn bessel_k_matches_independent_high_precision_reference() {
2624        // (x, K₀(x), K₁(x))
2625        const BESSEL_K_REFERENCE: [[f64; 3]; 20] = [
2626            [1e-08, 18.536612259610777, 99999999.9999999],
2627            [0.0001, 9.326271913450276, 9999.999508686404],
2628            [0.01, 4.721244730161095, 99.97389411829624],
2629            [0.1, 2.4270690247020164, 9.853844780870606],
2630            [0.5, 0.9244190712276659, 1.656441120003301],
2631            [1.0, 0.42102443824070834, 0.6019072301972346],
2632            [1.5, 0.21380556264752573, 0.2773878004568438],
2633            [1.99, 0.1153017675517768, 0.14171756162240132],
2634            [2.0, 0.11389387274953344, 0.13986588181652243],
2635            [2.01, 0.11250436099872804, 0.1380408773192077],
2636            [2.5, 0.06234755320036619, 0.07389081634774707],
2637            [3.0, 0.03473950438627925, 0.040156431128194184],
2638            [5.0, 0.0036910983340425942, 0.004044613445452165],
2639            [8.0, 0.0001464707052228154, 0.00015536921180500115],
2640            [12.0, 2.2008253973114916e-06, 2.290757464767188e-06],
2641            [20.0, 5.741237815336525e-10, 5.883057969557038e-10],
2642            [50.0, 3.4101677497894956e-23, 3.4441022267175555e-23],
2643            [150.0, 7.336371406107646e-67, 7.36078548876807e-67],
2644            [400.0, 1.199780043200976e-175, 1.2012788332610325e-175],
2645            [700.0, 4.669776431685377e-306, 4.6731107967079664e-306],
2646        ];
2647
2648        // The ascending series carries `−[ln(x/2)+γ]·I₀(x)` against a positive
2649        // sum, and both grow like `e^x` while `K` decays like `e^{−x}`, so it
2650        // loses `~e^{2x}` — about 1.7 digits at the `x = 2` crossover and less
2651        // below. The Chebyshev branch is limited only by its own Horner.
2652        const TOLERANCE: f64 = 8e-15;
2653        for [x, want_k0, want_k1] in BESSEL_K_REFERENCE {
2654            for (order, got, want) in [
2655                (0, bessel_k0_stable(x), want_k0),
2656                (1, bessel_k1_stable(x), want_k1),
2657            ] {
2658                let error = (got - want).abs() / want.abs();
2659                assert!(
2660                    error < TOLERANCE,
2661                    "K{order}({x}): got {got:.17e}, want {want:.17e} (rel {error:.3e})"
2662                );
2663            }
2664        }
2665    }
2666
2667    /// The two branches must not be distinguishable at their crossover. Before
2668    /// the Chebyshev fit they stepped by `2.9e−9` (`K₀`) and `2.4e−9` (`K₁`).
2669    #[test]
2670    fn bessel_k_branch_crossover_has_no_step() {
2671        // 1e-13 is ~230 ulp of 2.0, so the two sides land in different branches
2672        // while the true functions have barely moved.
2673        let delta = 1.0e-13;
2674        // `dK₀/dx = −K₁` and `dK₁/dx = −K₀ − K₁/x`, so `K₀(2) + K₁(2)` bounds
2675        // both slopes at the crossover.
2676        let slope = bessel_k0_stable(2.0) + bessel_k1_stable(2.0);
2677        for (order, f) in [
2678            (0, bessel_k0_stable as fn(f64) -> f64),
2679            (1, bessel_k1_stable),
2680        ] {
2681            let below = f(2.0 - delta);
2682            let above = f(2.0 + delta);
2683            let budget = 2.0 * delta * slope + 8.0 * f64::EPSILON * below.abs();
2684            assert!(
2685                (above - below).abs() < budget,
2686                "K{order} steps at the x=2 crossover: {below:.17e} -> {above:.17e} \
2687                 (change {:.3e} > budget {budget:.3e})",
2688                (above - below).abs()
2689            );
2690        }
2691    }
2692
2693    /// The crate carries a SECOND `K` — `closed_form_penalty::bessel_k`, a
2694    /// Temme-series/Steed-continued-fraction evaluator for arbitrary real order.
2695    /// It is full precision and always was, so before the Chebyshev fit the two
2696    /// implementations of `K₀`/`K₁` in this crate disagreed by up to `1.6e−7`
2697    /// depending on which one a caller happened to reach. They must not.
2698    #[test]
2699    fn the_two_bessel_k_implementations_in_this_crate_agree() {
2700        use crate::basis::closed_form_penalty::bessel_k;
2701        for x in [
2702            0.01_f64, 0.1, 0.5, 1.0, 1.99, 2.0, 2.01, 2.5, 4.0, 7.0, 15.0, 40.0, 120.0,
2703        ] {
2704            for (order, fast) in [(0.0_f64, bessel_k0_stable(x)), (1.0, bessel_k1_stable(x))] {
2705                let general = bessel_k(order, x);
2706                let error = (fast - general).abs() / general.abs();
2707                assert!(
2708                    error < 1e-13,
2709                    "K{order}({x}): fast path {fast:.17e} vs Temme/Steed {general:.17e} \
2710                     (rel {error:.3e})"
2711                );
2712            }
2713        }
2714    }
2715
2716    /// `K` obeys `K_{ν−1}(x) − K_{ν+1}(x) = −(2ν/x)·K_ν(x)` and the Wronskian
2717    /// `I₀(x)K₁(x) + I₁(x)K₀(x) = 1/x`. The Wronskian ties `K` to an evaluator
2718    /// it shares no code with (`gam_math`'s modified Bessel `I`), so it is a
2719    /// genuine cross-check rather than a restatement of either one.
2720    #[test]
2721    fn bessel_k_satisfies_the_wronskian_against_bessel_i() {
2722        for x in [
2723            0.05_f64, 0.5, 1.0, 1.99, 2.0, 2.01, 3.0, 6.0, 12.0, 30.0, 80.0,
2724        ] {
2725            let (centered_log_i0, ratio, _) = gam_math::special::bessel_i0_centered_terms(x);
2726            // I₀ = exp(centered + x); I₁ = ratio·I₀. Both are formed here rather
2727            // than cancelled against K, so the identity is checked on the values
2728            // themselves.
2729            let i0 = (centered_log_i0 + x).exp();
2730            let i1 = ratio * i0;
2731            let wronskian = i0 * bessel_k1_stable(x) + i1 * bessel_k0_stable(x);
2732            let want = 1.0 / x;
2733            let error = (wronskian - want).abs() / want;
2734            // `I₀` reaches 1e34 by x = 80 while `K₀` is 1e-36, so the product is
2735            // formed from numbers whose exponents differ by 70; the tolerance
2736            // tracks that reconstruction, not the evaluators.
2737            assert!(
2738                error < 1e-13,
2739                "Wronskian at x={x}: got {wronskian:.17e}, want {want:.17e} (rel {error:.3e})"
2740            );
2741        }
2742    }
2743}
2744
2745#[cfg(test)]
2746mod duchon_hybrid_psd_tests {
2747    use super::*;
2748    use faer::Side;
2749    use gam_linalg::faer_ndarray::FaerEigh;
2750
2751    fn assert_pow_parity(label: &str, got: f64, reference: f64) {
2752        if got.to_bits() == reference.to_bits() || (got.is_nan() && reference.is_nan()) {
2753            return;
2754        }
2755        if got.is_infinite() || reference.is_infinite() {
2756            assert_eq!(got, reference, "{label}: infinity/sign mismatch");
2757            return;
2758        }
2759        let scale = got.abs().max(reference.abs()).max(f64::MIN_POSITIVE);
2760        let relative = (got - reference).abs() / scale;
2761        assert!(
2762            relative <= 2.0e-12 || (got - reference).abs() <= 1.0e-300,
2763            "{label}: got {got:.17e}, powf reference {reference:.17e}, relative error {relative:.3e}"
2764        );
2765    }
2766
2767    fn powf_polyharmonic_constants(m: f64, d: usize) -> (f64, f64, bool) {
2768        let half_d = 0.5 * d as f64;
2769        let alpha = 2.0 * m - d as f64;
2770        let log_case = d.is_multiple_of(2) && alpha >= 0.0 && (alpha % 2.0).abs() < 1.0e-12;
2771        let c = if log_case {
2772            let m_int = m.round() as usize;
2773            polyharmonic_log_sign(m_int, d)
2774                / (2.0_f64.powi((2 * m_int - 1) as i32)
2775                    * std::f64::consts::PI.powf(half_d)
2776                    * gamma_lanczos(m)
2777                    * gamma_lanczos((m_int - d / 2 + 1) as f64))
2778        } else {
2779            gamma_lanczos(half_d - m)
2780                / (4.0_f64.powf(m) * std::f64::consts::PI.powf(half_d) * gamma_lanczos(m))
2781        };
2782        (c, alpha, log_case)
2783    }
2784
2785    fn powf_family_value(r: f64, c: f64, exponent: f64, log: f64, pure: f64) -> f64 {
2786        if r <= 0.0 {
2787            log_power_origin_limit(c, exponent, log, pure)
2788        } else {
2789            c * r.powf(exponent) * (log * r.ln() + pure)
2790        }
2791    }
2792
2793    fn differentiate_powf_family(exponent: &mut f64, log: &mut f64, pure: &mut f64) {
2794        let old_exponent = *exponent;
2795        *exponent -= 1.0;
2796        *pure = old_exponent * *pure + *log;
2797        *log *= old_exponent;
2798    }
2799
2800    fn powf_operator_reference(r: f64, m: usize, d: usize) -> [f64; 4] {
2801        let (c, alpha, log_case) = powf_polyharmonic_constants(m as f64, d);
2802        let (mut exponent, mut log, mut pure) = if log_case {
2803            (alpha, 1.0, 0.0)
2804        } else {
2805            (alpha, 0.0, 1.0)
2806        };
2807        differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2808        exponent -= 1.0; // q = phi'/r
2809        let q = powf_family_value(r, c, exponent, log, pure);
2810        differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2811        exponent -= 1.0; // t = q'/r
2812        let t = powf_family_value(r, c, exponent, log, pure);
2813        differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2814        let t_r = powf_family_value(r, c, exponent, log, pure);
2815        differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2816        let t_rr = powf_family_value(r, c, exponent, log, pure);
2817        [q, t, t_r, t_rr]
2818    }
2819
2820    #[test]
2821    fn pure_polyharmonic_integer_powers_match_powf_at_zero_tiny_and_large_radius() {
2822        let radii = [0.0_f64, 1.0e-40, 1.0e-12, 0.2, 1.0, 12.0, 1.0e40];
2823        for &(m, d) in &[(2usize, 1usize), (3, 2), (4, 5), (5, 6), (7, 9)] {
2824            let block = PolyharmonicBlockCoeff::new(m as f64, d);
2825            assert!(block.power_i32.is_some());
2826            let (reference_c, alpha, log_case) = powf_polyharmonic_constants(m as f64, d);
2827            assert_pow_parity("coefficient", block.c, reference_c);
2828            for &r in &radii {
2829                let reference_value = if r <= 0.0 {
2830                    block.origin_limit()
2831                } else if log_case {
2832                    reference_c * r.powf(alpha) * r.ln()
2833                } else {
2834                    reference_c * r.powf(alpha)
2835                };
2836                assert_pow_parity("block value", block.eval(r), reference_value);
2837
2838                let got = polyharmonic_block_jet4(r, m as f64, d)
2839                    .expect("the polyharmonic block jet is defined at this fixture radius");
2840                let got = [got.0, got.1, got.2, got.3, got.4];
2841                for derivative in 0..5 {
2842                    let exponent = alpha - derivative as f64;
2843                    let falling = falling_factorial(alpha, derivative);
2844                    let reference = if log_case {
2845                        powf_family_value(
2846                            r,
2847                            reference_c,
2848                            exponent,
2849                            falling,
2850                            falling_factorial_derivative(alpha, derivative),
2851                        )
2852                    } else {
2853                        powf_family_value(r, reference_c, exponent, 0.0, falling)
2854                    };
2855                    assert_pow_parity("jet channel", got[derivative], reference);
2856                }
2857
2858                let got = duchon_polyharmonic_operator_block_jets(r, m, d)
2859                    .expect("the operator block jets are defined at this fixture radius");
2860                let got = [got.0, got.1, got.2, got.3];
2861                let reference = powf_operator_reference(r, m, d);
2862                for channel in 0..4 {
2863                    assert_pow_parity("operator channel", got[channel], reference[channel]);
2864                }
2865            }
2866        }
2867    }
2868
2869    #[test]
2870    fn fractional_polyharmonic_power_retains_powf_path() {
2871        let (m, d) = (2.125_f64, 3usize);
2872        let block = PolyharmonicBlockCoeff::new(m, d);
2873        assert_eq!(block.power, 1.25);
2874        assert!(block.power_i32.is_none());
2875        let (c, alpha, log_case) = powf_polyharmonic_constants(m, d);
2876        assert!(!log_case);
2877        for &r in &[0.0_f64, 1.0e-40, 0.25, 3.0, 1.0e40] {
2878            let reference = if r <= 0.0 {
2879                log_power_origin_limit(c, alpha, 0.0, 1.0)
2880            } else {
2881                c * r.powf(alpha)
2882            };
2883            assert_pow_parity("fractional block", block.eval(r), reference);
2884        }
2885    }
2886
2887    #[test]
2888    fn pure_polyharmonic_powi_microbenchmark() {
2889        const N: usize = 20_000;
2890        let (m, d, r) = (7usize, 9usize, 0.731_f64);
2891        let start = std::time::Instant::now();
2892        let powf_sum = (0..N).fold(0.0, |sum, i| {
2893            let radius = std::hint::black_box(r + (i % 17) as f64 * 1.0e-6);
2894            sum + powf_operator_reference(radius, m, d)[0]
2895        });
2896        let powf_time = start.elapsed();
2897        let start = std::time::Instant::now();
2898        let powi_sum = (0..N).fold(0.0, |sum, i| {
2899            let radius = std::hint::black_box(r + (i % 17) as f64 * 1.0e-6);
2900            sum + duchon_polyharmonic_operator_block_jets(radius, m, d)
2901                .expect("the operator block jets are defined at this benchmark radius")
2902                .0
2903        });
2904        let powi_time = start.elapsed();
2905        assert_pow_parity("benchmark accumulator", powi_sum, powf_sum);
2906        eprintln!(
2907            "pure operator {N} calls: powf={powf_time:?}, powi={powi_time:?}, speedup={:.2}x",
2908            powf_time.as_secs_f64() / powi_time.as_secs_f64().max(f64::MIN_POSITIVE)
2909        );
2910    }
2911
2912    /// #2278: the pure-Duchon CPD-adequacy boundary `2s >= d` is INDEPENDENT of
2913    /// the nullspace degree `p` (it cancels in the derivation above), so it must
2914    /// reject for all `p` — not only `p < 2`. Regression for the former spurious
2915    /// `p_order < 2` conjunct.
2916    #[test]
2917    fn pure_duchon_cpd_guard_is_nullspace_degree_independent_issue_2278() {
2918        // d = 2, Linear nullspace (p = 2), explicit integer power s = 1:
2919        // 2s = 2 >= d = 2 is ill-posed and previously slipped through.
2920        let err = validate_duchon_kernel_orders(None, 2, 1.0, 2)
2921            .expect_err("pure Duchon d=2, p=2, s=1 (2s>=d) must be rejected as ill-posed");
2922        let BasisError::InvalidInput(msg) = err else {
2923            panic!("expected an InvalidInput well-posedness error, got {err}");
2924        };
2925        assert!(
2926            msg.contains("dimension/2") || msg.contains("2s < d"),
2927            "message must name the CPD/well-posedness cause: {msg}"
2928        );
2929        // The p < 2 sibling that was already rejected still is (no regression).
2930        assert!(validate_duchon_kernel_orders(None, 1, 1.0, 2).is_err());
2931        // Control: a well-posed pure config (default fractional power
2932        // s = (d-1)/2 = 0.5 at p = 2, giving 2s = 1 < d = 2) must still build —
2933        // the guard must not over-reject.
2934        assert!(validate_duchon_kernel_orders(None, 2, 0.5, 2).is_ok());
2935        // Control: the hybrid (Matérn-blended) path is exempt (CPD order 0), so
2936        // even 2s >= d builds — the `length_scale.is_none()` gate is preserved.
2937        assert!(validate_duchon_kernel_orders(Some(1.0), 2, 1.0, 2).is_ok());
2938    }
2939
2940    /// #1033: the capped-sample diameter estimate must be n-STABLE on a fixed
2941    /// point cloud. A uniform grid on `[-3, 3]` has a fixed true diameter (6.0)
2942    /// and minimum spacing that shrinks like `6/(n-1)` regardless of how finely
2943    /// it is sampled. The κ/ψ window is derived ONCE from `r_max_hat`, so if the
2944    /// sampler underestimates the diameter as n crosses the `K_CAP = 1024`
2945    /// threshold (the old `stride = n/K` prefix bug visited only indices
2946    /// `0..K`, i.e. the LEFT HALF of the domain for n in `(1024, 2048]`,
2947    /// halving `r_max_hat`), the outer optimizer's box — and hence its whole
2948    /// trajectory — becomes n-dependent, which is exactly the invariant #1033
2949    /// forbids. This pins `r_max_hat ≈ 6.0` across the threshold.
2950    #[test]
2951    fn sampled_diameter_is_n_stable_across_cap_threshold() {
2952        let grid = |n: usize| -> Array2<f64> {
2953            let mut x = Array2::<f64>::zeros((n, 1));
2954            for i in 0..n {
2955                x[[i, 0]] = (i as f64) / (n as f64 - 1.0) * 6.0 - 3.0;
2956            }
2957            x
2958        };
2959        // Below the cap (exact), straddling it, and well above it.
2960        let exact_diam = 6.0_f64;
2961        let mut last_rmax: Option<f64> = None;
2962        for &n in &[1000usize, 1025, 1500, 2000, 4000, 50_000] {
2963            let x = grid(n);
2964            let (r_min, r_max) =
2965                pairwise_distance_bounds_sampled(x.view()).expect("bounds for dense grid");
2966            // The sampled diameter must stay within 1% of the true 6.0 at every
2967            // n — NOT collapse to ~3.0 as the prefix bug did for n in (1024,2048].
2968            assert!(
2969                (r_max - exact_diam).abs() <= 0.01 * exact_diam,
2970                "sampled r_max at n={n} = {r_max:.6} drifted from the true diameter \
2971                 {exact_diam:.6}: the diameter estimate is n-dependent (#1033)"
2972            );
2973            // Cross-n stability: consecutive n's must agree on r_max to <2%.
2974            if let Some(prev) = last_rmax {
2975                let rel = (r_max - prev).abs() / exact_diam;
2976                assert!(
2977                    rel <= 0.02,
2978                    "sampled r_max jumped {rel:.4} (rel) between n steps near n={n}: \
2979                     {prev:.6} -> {r_max:.6}; outer-loop box is not n-stable (#1033)"
2980                );
2981            }
2982            last_rmax = Some(r_max);
2983            // r_min is positive and finite (the floor used for the high-κ ceiling).
2984            assert!(
2985                r_min.is_finite() && r_min > 0.0,
2986                "r_min must be positive at n={n}"
2987            );
2988        }
2989    }
2990
2991    /// The sampler's chosen indices must span the FULL range `[0, n-1]`
2992    /// (endpoints included) — the property that makes the diameter estimate
2993    /// stable. Reconstruct the index set the implementation uses and assert it
2994    /// reaches both ends with no contiguous-prefix clustering.
2995    #[test]
2996    fn sampled_indices_span_full_range() {
2997        const K_CAP: usize = 1024;
2998        let n = 2000usize; // stride = n/K_CAP would floor to 1 → prefix bug regime
2999        let k = K_CAP;
3000        let denom = (k - 1) as f64;
3001        let span = (n - 1) as f64;
3002        let idx = |s: usize| -> usize { ((s as f64) * span / denom).round() as usize };
3003        assert_eq!(idx(0), 0, "first sample must be index 0");
3004        assert_eq!(idx(k - 1), n - 1, "last sample must be the final index n-1");
3005        // The largest gap between consecutive samples must be ≈ (n-1)/(k-1),
3006        // i.e. roughly 2 here — NOT a single dense prefix followed by a void.
3007        let mut max_gap = 0usize;
3008        for s in 1..k {
3009            max_gap = max_gap.max(idx(s) - idx(s - 1));
3010        }
3011        assert!(
3012            max_gap <= 2,
3013            "evenly-spaced samples should step by ~{:.2}; saw a gap of {max_gap} \
3014             (prefix clustering would leave one huge gap)",
3015            span / denom
3016        );
3017    }
3018
3019    /// Deterministic, well-separated centers on `[-1, 1]^d` (a Halton-style
3020    /// low-discrepancy lattice over the radical-inverse base sequence). Mirrors
3021    /// the `4*d` random centers the Python fixture
3022    /// (`tests/test_python_api.py`'s high-dimensional hybrid Duchon penalty PSD
3023    /// check) draws, but without an RNG so the regression is byte-stable.
3024    fn fixture_centers(d: usize, n: usize) -> Array2<f64> {
3025        const BASES: [u64; 24] = [
3026            2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83,
3027            89,
3028        ];
3029        let mut centers = Array2::<f64>::zeros((n, d));
3030        for i in 0..n {
3031            for axis in 0..d {
3032                let base = BASES[axis % BASES.len()];
3033                // Van der Corput radical inverse of (i + 1) in `base`, mapped to
3034                // [-1, 1]. Different axes use different primes, so the cloud is
3035                // affinely full-rank and spans the linear null space.
3036                let mut f = 1.0_f64;
3037                let mut idx = (i + 1) as u64;
3038                let mut value = 0.0_f64;
3039                while idx > 0 {
3040                    f /= base as f64;
3041                    value += f * (idx % base) as f64;
3042                    idx /= base;
3043                }
3044                centers[[i, axis]] = 2.0 * value - 1.0;
3045            }
3046        }
3047        centers
3048    }
3049
3050    /// Smallest symmetric eigenvalue of `matrix` (the matrix is symmetrized
3051    /// first; the constrained Duchon penalty is symmetric by construction).
3052    fn lambda_min(matrix: &Array2<f64>) -> f64 {
3053        let sym = symmetrize_penalty(matrix);
3054        let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).expect("symmetric eigendecomposition");
3055        evals.iter().copied().fold(f64::INFINITY, f64::min)
3056    }
3057
3058    /// gam#1424: the (d=16, m=2, s=7) hybrid Duchon–Matérn fixture used to lose
3059    /// positive definiteness through catastrophic cancellation in the
3060    /// partial-fraction kernel expansion — the constrained, post-normalization
3061    /// penalty had λ_min ≈ −0.26442 even though the kernel's spectral density
3062    /// `ρ^{-2p}(κ²+ρ²)^{-s}` is nonnegative (so the true penalty is PSD). The
3063    /// kernel now routes through the cancellation-free single-integral form, so
3064    /// the spectrum is numerically PSD. This mirrors the production penalty path
3065    /// `duchon_constrained_bending_penalty` → `normalize_penalty`.
3066    #[test]
3067    fn high_dim_hybrid_penalty_is_numerically_psd_1424() {
3068        let d = 16usize;
3069        // m=2 ⇒ Linear null space. The cubic default spectral power is the
3070        // fractional (d-1)/2 = 7.5; the production hybrid config resolves it to
3071        // the integer spectral order the closed-form kernel consumes, s = 7
3072        // (`duchon_constrained_bending_penalty` itself takes the integer view via
3073        // `duchon_power_to_usize`, and the reroute predicate needs s ≥ 1). This is
3074        // the (d=16, m=2, s=7) fixture from the issue and the Python
3075        // `duchon_function_norm_penalty` PSD test.
3076        let (nullspace_order, default_power) = duchon_cubic_default(d);
3077        assert!(matches!(nullspace_order, DuchonNullspaceOrder::Linear));
3078        assert!(
3079            (default_power - 7.5).abs() < 1e-12,
3080            "cubic-default power for d=16 is 7.5"
3081        );
3082        let power = 7.0_f64;
3083        assert_eq!(duchon_power_to_usize(power), 7);
3084        // The reroute must engage for this fixture (s = 7 ≥ 1, 2p = 4 < d = 16).
3085        assert!(duchon_hybrid_stable_integral_applies(
3086            duchon_p_from_nullspace_order(nullspace_order),
3087            duchon_power_to_usize(power),
3088            d,
3089        ));
3090        let length_scale = Some(1.0_f64);
3091        let centers = fixture_centers(d, 4 * d);
3092
3093        let mut cache = BasisCacheContext::default();
3094        let z = kernel_constraint_nullspace(centers.view(), nullspace_order, &mut cache)
3095            .expect("constraint null space");
3096
3097        let omega = duchon_constrained_bending_penalty(
3098            centers.view(),
3099            length_scale,
3100            power,
3101            nullspace_order,
3102            None,
3103            &z,
3104        )
3105        .expect("constrained bending penalty assembles for the hybrid fixture");
3106        let (penalty, _scale) = normalize_penalty(&omega);
3107
3108        let lam_min = lambda_min(&penalty);
3109        assert!(
3110            lam_min >= -1e-10,
3111            "gam#1424: (d=16, m=2, s=7) hybrid penalty is not numerically PSD: \
3112             λ_min={lam_min:.6e} (was ≈ −0.26442 with the cancellation-prone \
3113             partial-fraction kernel)"
3114        );
3115    }
3116
3117    /// gam#1604: independent closed form for the hybrid-kernel origin value
3118    /// `φ(0) = F⁻¹[ρ^{-2p}(κ²+ρ²)^{-s}](0)` in `d` dimensions, derived by
3119    /// Schwinger-parametrizing both rational factors and evaluating the radial
3120    /// inverse-FT integral at `r = 0`:
3121    ///
3122    ///   φ(0) = (4π)^{-d/2} / Γ(s) · Γ(b) · κ^{-2b} · Γ(d/2 − p) / Γ(d/2),
3123    ///   b = p + s − d/2.
3124    ///
3125    /// Finite whenever `2(p+s) > d` and (for the Γ(d/2 − p) factor to avoid a
3126    /// pole) `d` is odd or `2p < d`. This reuses none of the Taylor-coefficient
3127    /// machinery under test, so it is a true oracle for the collision diagonal.
3128    fn phi0_closed_form(p: usize, s: usize, d: usize, kappa: f64) -> f64 {
3129        let half_d = 0.5 * d as f64;
3130        let b = p as f64 + s as f64 - half_d;
3131        (4.0 * std::f64::consts::PI).powf(-half_d) / gamma_lanczos(s as f64)
3132            * gamma_lanczos(b)
3133            * kappa.powf(-2.0 * b)
3134            * gamma_lanczos(half_d - p as f64)
3135            / gamma_lanczos(half_d)
3136    }
3137
3138    /// gam#1604 — the collision (r = 0) diagonal of the hybrid Duchon–Matérn
3139    /// kernel must equal the independent closed form above. The half-integer-ν
3140    /// Taylor coefficients that feed `duchon_hybrid_kernel_collision_value`
3141    /// previously miscounted the K_{l+½} polynomial degree (`l = 2|ν| − 1`
3142    /// instead of `|ν| − ½`), zeroing the r⁰ term of every |ν| ≥ 3/2 block and
3143    /// silently dropping their contribution to φ(0).
3144    #[test]
3145    fn hybrid_collision_diagonal_matches_closed_form_1604() {
3146        // Odd dimensions exercise the half-integer-ν path. For each, sweep p, s
3147        // (with 2(p+s) > d) and κ. d = 1, n ≥ 2 ⇒ ν ≥ 3/2 is the regressed case.
3148        for &d in &[1usize, 3, 5] {
3149            for &p in &[1usize, 2, 3] {
3150                for &s in &[1usize, 2, 3, 4] {
3151                    if 2 * (p + s) <= d {
3152                        continue;
3153                    }
3154                    for &kappa in &[0.5f64, 1.0, 2.5] {
3155                        let coeffs = duchon_partial_fraction_coeffs(p, s, kappa);
3156                        let got =
3157                            duchon_hybrid_kernel_collision_value(1.0 / kappa, p, s, d, &coeffs)
3158                                .expect("collision diagonal");
3159                        let want = phi0_closed_form(p, s, d, kappa);
3160                        let rel = (got - want).abs() / want.abs().max(1e-300);
3161                        assert!(
3162                            rel < 1e-10,
3163                            "φ(0) mismatch d={d} p={p} s={s} κ={kappa}: got {got:.12e}, want {want:.12e} (rel {rel:.2e})"
3164                        );
3165                    }
3166                }
3167            }
3168        }
3169    }
3170
3171    /// gam#1604 — the near-collision Taylor branch must be continuous with the
3172    /// direct partial-fraction sum: assembling φ(r) from φ(0), φ″(0), φ⁗(0),
3173    /// φ⁽⁶⁾(0) (all built from the same half-integer-ν Taylor coefficients) must
3174    /// match the cancellation-free direct block sum at a small radius where both
3175    /// are individually accurate. This exercises the j ≥ 1 coefficients (the
3176    /// diagonal test only pins j = 0).
3177    #[test]
3178    fn hybrid_near_collision_continuous_with_direct_1604() {
3179        for &d in &[1usize, 3] {
3180            for &p in &[1usize, 2] {
3181                for &s in &[2usize, 3] {
3182                    if 2 * (p + s) <= d + 6 {
3183                        // Need φ⁽⁶⁾(0) to exist for the full 6th-order Taylor.
3184                        continue;
3185                    }
3186                    for &kappa in &[0.5f64, 1.0, 2.0] {
3187                        let length_scale = 1.0 / kappa;
3188                        let coeffs = duchon_partial_fraction_coeffs(p, s, kappa);
3189                        // r small enough that the truncated 6th-order Taylor is
3190                        // accurate to ~r⁸, yet large enough that the direct block
3191                        // sum has not lost precision (d = 1/3, moderate κ).
3192                        let r = 0.02 * length_scale;
3193                        let taylor = duchon_hybrid_kernel_near_collision_value(
3194                            r,
3195                            length_scale,
3196                            p,
3197                            s,
3198                            d,
3199                            &coeffs,
3200                        )
3201                        .expect("near-collision value");
3202                        // Direct partial-fraction sum (real Bessel-K, no Taylor).
3203                        let mut direct = 0.0f64;
3204                        for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
3205                            if a_m != 0.0 {
3206                                direct += a_m * polyharmonic_kernel(r, m as f64, d);
3207                            }
3208                        }
3209                        for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
3210                            if b_n != 0.0 {
3211                                direct += b_n
3212                                    * duchon_matern_block(r, kappa, n, d).expect("matern block");
3213                            }
3214                        }
3215                        let rel = (taylor - direct).abs() / direct.abs().max(1e-300);
3216                        assert!(
3217                            rel < 1e-9,
3218                            "near-collision vs direct mismatch d={d} p={p} s={s} κ={kappa} r={r}: \
3219                             taylor {taylor:.12e}, direct {direct:.12e} (rel {rel:.2e})"
3220                        );
3221                    }
3222                }
3223            }
3224        }
3225    }
3226
3227    /// gam#1604 — the production constrained Duchon penalty `Ω_c = α²·ZᵀK_CC Z`
3228    /// for a `d = 1` hybrid smooth with power ≥ 2 must be numerically PSD across
3229    /// realistic length scales. Before the Taylor-degree fix the corrupted
3230    /// diagonal made `Ω_c ≈ Ω_true − δ·I` (δ = the dropped diagonal mass),
3231    /// giving λ_min ≈ −δ < 0 at *every* length scale — the issue's report.
3232    #[test]
3233    fn d1_hybrid_penalty_is_psd_1604() {
3234        let d = 1usize;
3235        let nullspace_order = DuchonNullspaceOrder::Linear; // p = 2
3236        let centers = fixture_centers(d, 12);
3237        let mut cache = BasisCacheContext::default();
3238        let z = kernel_constraint_nullspace(centers.view(), nullspace_order, &mut cache)
3239            .expect("constraint null space");
3240        for &power in &[2.0f64, 3.0] {
3241            for &length_scale in &[0.5f64, 1.0, 10.0, 100.0] {
3242                let omega = duchon_constrained_bending_penalty(
3243                    centers.view(),
3244                    Some(length_scale),
3245                    power,
3246                    nullspace_order,
3247                    None,
3248                    &z,
3249                )
3250                .unwrap_or_else(|e| {
3251                    panic!("d=1 p=2 s={power} ls={length_scale} penalty rejected: {e}")
3252                });
3253                let (penalty, _scale) = normalize_penalty(&omega);
3254                let lam_min = lambda_min(&penalty);
3255                assert!(
3256                    lam_min >= -1e-9,
3257                    "d=1 p=2 s={power} ls={length_scale}: λ_min={lam_min:.6e} (not PSD)"
3258                );
3259            }
3260        }
3261    }
3262
3263    /// No-regression guard: a well-conditioned low-dimensional fixture must keep
3264    /// the exact kernel VALUES the partial-fraction path produced before the
3265    /// gam#1424 fix. For d=2 the stable-integral reroute does not apply
3266    /// (`2p=4 ≥ d=2`), so `duchon_matern_kernel_general_from_distance` still runs
3267    /// the original sum verbatim; pinning it against an independent direct
3268    /// evaluation of the same partial-fraction blocks proves the production
3269    /// routing is unchanged for low `d`.
3270    #[test]
3271    fn low_dim_hybrid_kernel_values_unchanged_1424() {
3272        let d = 2usize;
3273        let p_order = 2usize; // Linear null space (m=2)
3274        let s_order = 2usize;
3275        let kappa = 1.0_f64;
3276        let length_scale = Some(1.0_f64);
3277        // The d=2 case is NOT rerouted to the stable integral.
3278        assert!(!duchon_hybrid_stable_integral_applies(p_order, s_order, d));
3279        let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
3280
3281        for &r in &[0.25_f64, 0.75, 1.5] {
3282            // Independent reference: the raw partial-fraction sum
3283            // Σ a_m·r^{2m-d}(·log) + Σ b_n·matern_block, identical in form to the
3284            // production direct-sum branch but assembled here from scratch.
3285            let mut reference = 0.0_f64;
3286            for (m, &coeff) in coeffs.a.iter().enumerate().skip(1) {
3287                if coeff != 0.0 {
3288                    reference += coeff * polyharmonic_kernel(r, m as f64, d);
3289                }
3290            }
3291            for (n, &coeff) in coeffs.b.iter().enumerate().skip(1) {
3292                if coeff != 0.0 {
3293                    reference += coeff * duchon_matern_block(r, kappa, n, d).expect("matern block");
3294                }
3295            }
3296
3297            let got = duchon_matern_kernel_general_from_distance(
3298                r,
3299                length_scale,
3300                p_order,
3301                s_order,
3302                d,
3303                Some(&coeffs),
3304            )
3305            .expect("low-d hybrid kernel value");
3306            assert!(
3307                (got - reference).abs() <= 1e-10,
3308                "low-d hybrid kernel value regressed at r={r}: got {got:.15e}, reference {reference:.15e}"
3309            );
3310        }
3311    }
3312
3313    /// #1817: a low-order/low-power Duchon config with the stiffness (D2)
3314    /// operator active — d=2, `Linear` null space (p=2), power s=0 — has
3315    /// `2(p+s)=4`, which clears the pointwise margin (>d=2) and D1 (>d+1=3) but
3316    /// NOT D2 (>d+2=4), so the collocation guard used to fire mid-fit. The order
3317    /// must now auto-raise so the mass+tension+stiffness penalty matrices build
3318    /// cleanly, and the effective order must satisfy the strict D2 margin.
3319    #[test]
3320    fn operator_penalties_auto_raise_order_issue_1817() {
3321        // 4×3 grid on [0,1]² — 12 centers, comfortably above the 6 polynomial
3322        // columns of the auto-raised Degree(2) null space, so the auto-DEGRADE
3323        // path does not interfere with the auto-RAISE under test.
3324        let mut centers = Array2::<f64>::zeros((12, 2));
3325        let mut row = 0;
3326        for i in 0..4 {
3327            for j in 0..3 {
3328                centers[[row, 0]] = i as f64 / 3.0;
3329                centers[[row, 1]] = j as f64 / 2.0;
3330                row += 1;
3331            }
3332        }
3333
3334        let dim = 2usize;
3335        let power = 0.0_f64;
3336        let requested = DuchonNullspaceOrder::Linear;
3337
3338        // The unraised config is exactly the one that trips the D2 guard.
3339        let requested_p = duchon_p_from_nullspace_order(requested);
3340        assert!(
3341            2.0 * (requested_p as f64 + power) <= dim as f64 + 2.0,
3342            "precondition: requested (p,s) must be on the failing side of the D2 margin"
3343        );
3344
3345        // Auto-raise (max_op = 2 ⇒ stiffness/D2 active) must clear the strict D2
3346        // margin 2(p+s) > d+2.
3347        let effective = duchon_order_for_operator_margin(dim, power, requested, 2);
3348        let effective_p = duchon_p_from_nullspace_order(effective);
3349        assert!(
3350            2.0 * (effective_p as f64 + power) > dim as f64 + 2.0,
3351            "auto-raised order must satisfy 2(p+s) > d+2: got 2*({}+{})={} vs d+2={}",
3352            effective_p,
3353            power,
3354            2.0 * (effective_p as f64 + power),
3355            dim as f64 + 2.0
3356        );
3357
3358        // End-to-end: the mass+tension+stiffness penalty matrices (max_op=2)
3359        // must now build without an InvalidInput from the pointwise/collocation
3360        // guard, because the order was raised before the guard could fire.
3361        let penalties = build_duchon_operator_penalty_matrices(
3362            centers.view(),
3363            None,
3364            None, // pure (scale-free) Duchon — the guarded branch
3365            power,
3366            requested,
3367            None,
3368            None,
3369        )
3370        .expect("Duchon mass+tension+stiffness penalties must build after auto-raise (#1817)");
3371        for m in [&penalties.mass, &penalties.tension, &penalties.stiffness] {
3372            assert!(
3373                m.iter().all(|v| v.is_finite()),
3374                "auto-raised operator penalty matrices must be finite"
3375            );
3376        }
3377    }
3378}