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 &mut workspace,
25 )
26}
27
28pub fn build_duchon_operator_penalty_matrices(
29 centers: ArrayView2<'_, f64>,
30 collocationweights: Option<ArrayView1<'_, f64>>,
31 length_scale: Option<f64>,
32 power: f64,
33 nullspace_order: DuchonNullspaceOrder,
34 aniso_log_scales: Option<&[f64]>,
35 identifiability_transform: Option<ArrayView2<'_, f64>>,
36) -> Result<DuchonOperatorPenaltyMatrices, BasisError> {
37 let ops = build_duchon_collocation_operator_matrices(
38 centers,
39 collocationweights,
40 length_scale,
41 power,
42 nullspace_order,
43 aniso_log_scales,
44 identifiability_transform,
45 2,
46 )?;
47 let (mass, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d0)));
48 let (tension, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d1)));
49 let (stiffness, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d2)));
50 Ok(DuchonOperatorPenaltyMatrices {
51 mass,
52 tension,
53 stiffness,
54 })
55}
56
57pub fn build_thin_plate_penalty_matrix(
58 centers: ArrayView2<'_, f64>,
59 length_scale: f64,
60) -> Result<ThinPlatePenaltyMatrix, BasisError> {
61 let mut workspace = BasisWorkspace::default();
62 let kernel_transform = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
63 let (penalty, _) =
64 build_thin_plate_penalty_matrices(centers, length_scale, &kernel_transform, false)?;
65 let (penalty, _) = normalize_penalty(&penalty);
66 Ok(ThinPlatePenaltyMatrix { penalty })
67}
68
69pub fn build_duchon_collocation_operator_matriceswithworkspace(
70 centers: ArrayView2<'_, f64>,
71 collocation_points: ArrayView2<'_, f64>,
72 collocationweights: Option<ArrayView1<'_, f64>>,
73 length_scale: Option<f64>,
74 power: f64,
75 nullspace_order: DuchonNullspaceOrder,
76 aniso_log_scales: Option<&[f64]>,
77 identifiability_transform: Option<ArrayView2<'_, f64>>,
78 max_operator_derivative_order: usize,
79 workspace: &mut BasisWorkspace,
80) -> Result<CollocationOperatorMatrices, BasisError> {
81 let nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
88 let p_order = duchon_p_from_nullspace_order(nullspace_order);
89 let s_order: f64 = power;
90 let p_colloc = collocation_points.nrows();
91 let n_basis = centers.nrows();
92 let dim = centers.ncols();
93 if collocation_points.ncols() != dim {
94 crate::bail_dim_basis!(
95 "collocation points dim {} != centers dim {dim}",
96 collocation_points.ncols()
97 );
98 }
99 validate_duchon_collocation_orders(
100 length_scale,
101 p_order,
102 s_order,
103 dim,
104 max_operator_derivative_order,
105 )?;
106 if let Some(eta) = aniso_log_scales
107 && eta.len() != dim
108 {
109 crate::bail_dim_basis!(
110 "Duchon anisotropy dimension mismatch: got {}, expected {dim}",
111 eta.len()
112 );
113 }
114 let coeffs = length_scale.map(|scale| {
118 let s_int = duchon_power_to_usize(s_order);
119 duchon_partial_fraction_coeffs(p_order, s_int, 1.0 / scale.max(1e-300))
120 });
121 let metric_weights: Option<Vec<f64>> = aniso_log_scales.map(centered_aniso_metric_weights);
122 let row_scales = if let Some(w) = collocationweights {
123 if w.len() != p_colloc {
124 crate::bail_dim_basis!(
125 "collocation weight length mismatch: got {}, expected {p_colloc}",
126 w.len()
127 );
128 }
129 let mut out = Vec::with_capacity(p_colloc);
130 for &wk in w {
131 if !wk.is_finite() || wk < 0.0 {
132 crate::bail_invalid_basis!(
133 "collocation weights must be finite and non-negative; got {wk}"
134 );
135 }
136 out.push(wk.sqrt());
137 }
138 out
139 } else {
140 vec![1.0; p_colloc]
141 };
142 let z = kernel_constraint_nullspace(centers, nullspace_order, &mut workspace.cache)?;
143 let build_d1 = max_operator_derivative_order >= 1;
151 let build_d2 = max_operator_derivative_order >= 2;
152 let mut d0_raw = Array2::<f64>::zeros((p_colloc, n_basis));
153 let mut d1_raw = Array2::<f64>::zeros((if build_d1 { p_colloc * dim } else { 0 }, n_basis));
154 let mut d2_raw =
155 Array2::<f64>::zeros((if build_d2 { p_colloc * dim * dim } else { 0 }, n_basis));
156 const R_EPS: f64 = 1e-10;
157 for i in 0..p_colloc {
158 let scale_i = row_scales[i];
159 for j in 0..n_basis {
160 let r = if let Some(eta) = aniso_log_scales {
161 let row_i: Vec<f64> = (0..dim).map(|a| collocation_points[[i, a]]).collect();
162 let row_j: Vec<f64> = (0..dim).map(|a| centers[[j, a]]).collect();
163 aniso_distance(&row_i, &row_j, eta)
164 } else {
165 stable_euclidean_norm(
166 (0..dim).map(|axis| collocation_points[[i, axis]] - centers[[j, axis]]),
167 )
168 };
169 let r = r.max(R_EPS);
175 let (phi, q, t) = if let (Some(length_scale), Some(coeffs)) =
176 (length_scale, coeffs.as_ref())
177 {
178 let jets =
179 duchon_radial_jets(r, length_scale, p_order, s_order as usize, dim, coeffs)?;
180 (jets.phi, jets.q, jets.t)
181 } else {
182 let (phi, phi_r, phi_rr) = duchon_kernel_radial_triplet(
183 r,
184 length_scale,
185 p_order,
186 s_order,
187 dim,
188 coeffs.as_ref(),
189 )?;
190 let q = if r > R_EPS { phi_r / r } else { phi_rr };
191 let t = if r > R_EPS {
192 (phi_rr - q) / (r * r)
193 } else {
194 0.0
195 };
196 (phi, q, t)
197 };
198 if !phi.is_finite() || !q.is_finite() || !t.is_finite() {
199 crate::bail_invalid_basis!(
200 "non-finite Duchon collocation operator derivative at (colloc {i}, center {j}), r={r}"
201 );
202 }
203 d0_raw[[i, j]] = scale_i * phi;
204 if build_d2 {
205 for axis_a in 0..dim {
206 let h_a = collocation_points[[i, axis_a]] - centers[[j, axis_a]];
207 let w_a = metric_weights
208 .as_ref()
209 .map(|weights| weights[axis_a])
210 .unwrap_or(1.0);
211 for axis_b in 0..dim {
212 let h_b = collocation_points[[i, axis_b]] - centers[[j, axis_b]];
213 let w_b = metric_weights
214 .as_ref()
215 .map(|weights| weights[axis_b])
216 .unwrap_or(1.0);
217 let diagonal = if axis_a == axis_b { q * w_a } else { 0.0 };
218 let mixed = if r > R_EPS {
219 t * w_a * h_a * w_b * h_b
220 } else {
221 0.0
222 };
223 let value = diagonal + mixed;
224 let row_i = (i * dim + axis_a) * dim + axis_b;
225 d2_raw[[row_i, j]] = scale_i * value;
226 }
227 }
228 }
229 if build_d1 && r > R_EPS {
230 for axis in 0..dim {
231 let delta = collocation_points[[i, axis]] - centers[[j, axis]];
232 let axis_scale = metric_weights
233 .as_ref()
234 .map(|weights| weights[axis])
235 .unwrap_or(1.0);
236 d1_raw[[i * dim + axis, j]] = scale_i * q * axis_scale * delta;
237 }
238 }
239 }
240 }
241 let d0_kernel = fast_ab(&d0_raw, &z);
242 let poly = polynomial_block_from_order(centers, nullspace_order);
243 let kernel_cols = d0_kernel.ncols();
244 let poly_cols = poly.ncols();
245 let total_cols = kernel_cols + poly_cols;
246 let mut d0 = Array2::<f64>::zeros((p_colloc, total_cols));
250 d0.slice_mut(s![.., 0..kernel_cols]).assign(&d0_kernel);
251 let mut d1 = Array2::<f64>::zeros((if build_d1 { p_colloc * dim } else { 0 }, total_cols));
252 if build_d1 {
253 d1.slice_mut(s![.., 0..kernel_cols])
254 .assign(&fast_ab(&d1_raw, &z));
255 }
256 let mut d2 =
257 Array2::<f64>::zeros((if build_d2 { p_colloc * dim * dim } else { 0 }, total_cols));
258 if build_d2 {
259 d2.slice_mut(s![.., 0..kernel_cols])
260 .assign(&fast_ab(&d2_raw, &z));
261 }
262 if let Some(z) = identifiability_transform {
263 let z = z.to_owned();
264 d0 = fast_ab(&d0, &z);
265 d1 = fast_ab(&d1, &z);
266 d2 = fast_ab(&d2, &z);
267 }
268 Ok(CollocationOperatorMatrices {
269 d0,
270 d1,
271 d2,
272 collocation_points: collocation_points.to_owned(),
273 kernel_nullspace_transform: Some(z),
274 polynomial_block_cols: poly_cols,
275 })
276}
277
278#[inline(always)]
279pub(crate) fn bessel_k0_stable(x: f64) -> f64 {
280 let x_pos = x.max(1e-300);
281 if x_pos <= 2.0 {
282 return bessel_k0_small_series(x_pos);
283 }
284 let y = 2.0 / x_pos;
285 (-x_pos).exp() / x_pos.sqrt()
286 * (1.253_314_14
287 + y * (-0.078_323_58
288 + y * (0.021_895_68
289 + y * (-0.010_624_46
290 + y * (0.005_878_72 + y * (-0.002_515_40 + y * 0.000_532_08))))))
291}
292
293#[inline(always)]
294pub(crate) fn bessel_k1_stable(x: f64) -> f64 {
295 let x_pos = x.max(1e-300);
296 if x_pos <= 2.0 {
297 return bessel_k1_small_series(x_pos);
298 }
299 let y = 2.0 / x_pos;
300 (-x_pos).exp() / x_pos.sqrt()
301 * (1.253_314_14
302 + y * (0.234_986_19
303 + y * (-0.036_556_20
304 + y * (0.015_042_68
305 + y * (-0.007_803_53 + y * (0.003_256_14 + y * -0.000_682_45))))))
306}
307
308#[inline(always)]
309pub(crate) fn bessel_k0_k1_small_series(x: f64) -> (f64, f64) {
310 const EULER_GAMMA: f64 = 0.577_215_664_901_532_9;
311 let y = 0.25 * x * x;
312 let log_half_plus_gamma = 0.5 * y.ln() + EULER_GAMMA;
313 let mut i0 = 1.0;
314 let mut i1 = 0.5 * x;
315 let mut harmonic = 0.0;
316 let mut y_power_over_fact_sq = 1.0;
317 let mut k0_series = 0.0;
318 let mut k0_series_y_derivative_times_y = 0.0;
319 for k in 1..=256 {
320 let kf = k as f64;
321 harmonic += 1.0 / kf;
322 y_power_over_fact_sq *= y / (kf * kf);
323 let k0_term = harmonic * y_power_over_fact_sq;
324 k0_series += k0_term;
325 k0_series_y_derivative_times_y += kf * k0_term;
326 i0 += y_power_over_fact_sq;
327 i1 += 0.5 * x * y_power_over_fact_sq / (kf + 1.0);
328 if k0_term.abs() <= f64::EPSILON * i0.abs().max(k0_series.abs()).max(1.0) {
329 break;
330 }
331 }
332
333 let k0 = -log_half_plus_gamma * i0 + k0_series;
334 let k1 = i0 / x + log_half_plus_gamma * i1 - (2.0 / x) * k0_series_y_derivative_times_y;
335 (k0, k1)
336}
337
338#[inline(always)]
339pub(crate) fn bessel_k0_small_series(x: f64) -> f64 {
340 bessel_k0_k1_small_series(x).0
341}
342
343#[inline(always)]
344pub(crate) fn bessel_k1_small_series(x: f64) -> f64 {
345 bessel_k0_k1_small_series(x).1
346}
347
348pub(crate) const DUCHON_DERIVATIVE_R_FLOOR_REL: f64 = 1e-5;
349
350pub(crate) const DUCHON_COLLISION_TAYLOR_REL: f64 = 1e-4;
351
352pub(crate) const RADIAL_PROFILE_MIN_PAIRS: usize = 16_384;
359
360#[inline(always)]
361pub(crate) fn duchon_p_from_nullspace_order(order: DuchonNullspaceOrder) -> usize {
362 match order {
363 DuchonNullspaceOrder::Zero => 1,
368 DuchonNullspaceOrder::Linear => 2,
369 DuchonNullspaceOrder::Degree(degree) => degree + 1,
370 }
371}
372
373pub(crate) fn duchon_effective_nullspace_order(
383 centers: ArrayView2<'_, f64>,
384 order: DuchonNullspaceOrder,
385) -> DuchonNullspaceOrder {
386 if order == DuchonNullspaceOrder::Zero {
387 return order;
388 }
389 let mut effective = order;
390 while effective != DuchonNullspaceOrder::Zero
391 && centers.nrows() <= polynomial_block_from_order(centers, effective).ncols()
392 {
393 effective = duchon_previous_nullspace_order(effective);
394 }
395 if effective != order {
396 static SEEN: std::sync::OnceLock<
399 std::sync::Mutex<std::collections::HashSet<(usize, usize, DuchonNullspaceOrder)>>,
400 > = std::sync::OnceLock::new();
401 let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
402 let key = (centers.nrows(), centers.ncols(), order);
403 let fresh = seen.lock().map(|mut s| s.insert(key)).unwrap_or(true);
404 if fresh {
405 let requested_cols = polynomial_block_from_order(centers, order).ncols();
406 let effective_cols = polynomial_block_from_order(centers, effective).ncols();
407 log::warn!(
408 "Duchon nullspace order={:?} in dim={} with {} centers leaves no radial kernel columns (polynomial_cols={}); degrading to {:?} (polynomial_cols={})",
409 order,
410 centers.ncols(),
411 centers.nrows(),
412 requested_cols,
413 effective,
414 effective_cols
415 );
416 }
417 }
418 effective
419}
420
421#[inline(always)]
422pub(crate) fn gamma_lanczos(x: f64) -> f64 {
423 const G: f64 = 7.0;
425 const P: [f64; 9] = [
426 0.999_999_999_999_809_9,
427 676.520_368_121_885_1,
428 -1_259.139_216_722_402_8,
429 771.323_428_777_653_1,
430 -176.615_029_162_140_6,
431 12.507_343_278_686_905,
432 -0.138_571_095_265_720_12,
433 9.984_369_578_019_571e-6,
434 1.505_632_735_149_311_6e-7,
435 ];
436 if x < 0.5 {
437 let pix = std::f64::consts::PI * x;
438 return std::f64::consts::PI / (pix.sin() * gamma_lanczos(1.0 - x));
439 }
440 let z = x - 1.0;
441 let mut a = P[0];
442 for (i, coeff) in P.iter().enumerate().skip(1) {
443 a += coeff / (z + i as f64);
444 }
445 let t = z + G + 0.5;
446 (2.0 * std::f64::consts::PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * a
447}
448
449#[inline(always)]
450pub(crate) fn bessel_k_integer_order(n: usize, z: f64) -> f64 {
451 let zz = z.max(1e-300);
452 if n == 0 {
453 return bessel_k0_stable(zz);
454 }
455 if n == 1 {
456 return bessel_k1_stable(zz);
457 }
458 let mut km1 = bessel_k0_stable(zz);
459 let mut k = bessel_k1_stable(zz);
460 for m in 1..n {
461 let kp1 = km1 + 2.0 * (m as f64) * k / zz;
462 km1 = k;
463 k = kp1;
464 }
465 k
466}
467
468#[inline(always)]
469pub(crate) fn bessel_k_half_integer_order(l: usize, z: f64) -> f64 {
470 let zz = z.max(1e-300);
482 let k_half = (std::f64::consts::PI / (2.0 * zz)).sqrt() * (-zz).exp();
483 if l == 0 {
484 return k_half;
485 }
486 let mut km1 = k_half;
487 let mut k = k_half * (1.0 + 1.0 / zz);
488 for m in 1..l {
489 let nu = m as f64 + 0.5;
490 let kp1 = km1 + 2.0 * nu * k / zz;
491 km1 = k;
492 k = kp1;
493 }
494 k
495}
496
497#[inline(always)]
498pub(crate) fn bessel_k_real_half_integer_or_integer(
499 nu_abs: f64,
500 z: f64,
501) -> Result<f64, BasisError> {
502 let two_nu = (2.0 * nu_abs).round();
503 if (two_nu - 2.0 * nu_abs).abs() > 1e-12 {
504 crate::bail_invalid_basis!(
505 "unsupported Bessel-K order ν={nu_abs}; only integer/half-integer orders are supported"
506 );
507 }
508 let two_nu_i = two_nu as i64;
509 if two_nu_i % 2 == 0 {
510 let n = (two_nu_i / 2).max(0) as usize;
511 Ok(bessel_k_integer_order(n, z))
512 } else {
513 let l = ((two_nu_i - 1) / 2).max(0) as usize;
514 Ok(bessel_k_half_integer_order(l, z))
515 }
516}
517
518#[derive(Clone, Copy)]
522pub(crate) struct PolyharmonicBlockCoeff {
523 pub(crate) c: f64,
524 pub(crate) power: f64,
525 pub(crate) is_log_case: bool,
526}
527
528impl PolyharmonicBlockCoeff {
529 pub(crate) fn new(m: f64, k_dim: usize) -> Self {
530 assert!(
531 m.is_finite() && m > 0.0,
532 "PolyharmonicBlockCoeff::new: m must be finite and > 0, got {m}"
533 );
534 let k_half = 0.5 * k_dim as f64;
535 let power = 2.0 * m - k_dim as f64;
536 const LOG_EPS: f64 = 1e-12;
540 let two_m = 2.0 * m;
541 let is_log_case = k_dim.is_multiple_of(2) && {
542 let n_f = (power / 2.0).round();
543 n_f >= 0.0 && (n_f * 2.0 - power).abs() < LOG_EPS
544 };
545 if is_log_case {
546 let m_int = m.round() as i64;
547 let m_minus_half_d_plus_one = (m - k_half + 1.0).round() as i64;
548 let c = polyharmonic_log_sign(m_int as usize, k_dim)
549 / (2.0_f64.powi((two_m.round() as i32) - 1)
550 * std::f64::consts::PI.powf(k_half)
551 * gamma_lanczos(m)
552 * gamma_lanczos(m_minus_half_d_plus_one as f64));
553 Self {
554 c,
555 power,
556 is_log_case: true,
557 }
558 } else {
559 let c = gamma_lanczos(k_half - m)
560 / (4.0_f64.powf(m) * std::f64::consts::PI.powf(k_half) * gamma_lanczos(m));
561 Self {
562 c,
563 power,
564 is_log_case: false,
565 }
566 }
567 }
568
569 #[inline(always)]
570 pub(crate) fn eval(&self, r: f64) -> f64 {
571 if r <= 0.0 {
572 return self.origin_limit();
573 }
574 if self.is_log_case {
575 self.c * r.powf(self.power) * r.max(1e-300).ln()
576 } else {
577 self.c * r.powf(self.power)
578 }
579 }
580
581 #[inline(always)]
582 pub(crate) fn origin_limit(&self) -> f64 {
583 if self.is_log_case {
584 log_power_origin_limit(self.c, self.power, 1.0, 0.0)
585 } else {
586 log_power_origin_limit(self.c, self.power, 0.0, 1.0)
587 }
588 }
589}
590
591pub(crate) fn polyharmonic_kernel(r: f64, m: f64, k_dim: usize) -> f64 {
592 PolyharmonicBlockCoeff::new(m, k_dim).eval(r)
593}
594
595#[inline(always)]
596pub(crate) fn signed_infinity(sign: f64) -> f64 {
597 if sign.is_sign_negative() {
598 f64::NEG_INFINITY
599 } else {
600 f64::INFINITY
601 }
602}
603
604#[inline(always)]
605pub(crate) fn log_power_origin_limit(
606 coeff: f64,
607 exponent: f64,
608 log_coeff: f64,
609 pure_coeff: f64,
610) -> f64 {
611 if log_coeff == 0.0 && pure_coeff == 0.0 {
612 return 0.0;
613 }
614 if exponent > 0.0 {
615 return 0.0;
616 }
617 if exponent == 0.0 {
618 if log_coeff != 0.0 {
619 signed_infinity(-coeff * log_coeff)
620 } else {
621 coeff * pure_coeff
622 }
623 } else if log_coeff != 0.0 {
624 signed_infinity(-coeff * log_coeff)
625 } else {
626 signed_infinity(coeff * pure_coeff)
627 }
628}
629
630#[inline(always)]
631pub(crate) fn polyharmonic_log_sign(m: usize, k_dim: usize) -> f64 {
632 assert!(
633 k_dim.is_multiple_of(2),
634 "polyharmonic_log_sign requires even kernel dimension: k_dim={k_dim}, m={m}"
635 );
636 (-1.0_f64).powi(m as i32 - (k_dim as i32 / 2) + 1)
637}
638
639#[inline(always)]
640pub(crate) fn duchon_matern_block(
641 r: f64,
642 kappa: f64,
643 n_order: usize,
644 k_dim: usize,
645) -> Result<f64, BasisError> {
646 let n = n_order as f64;
647 let k_half = 0.5 * k_dim as f64;
648 let nu = n - k_half;
649 let nu_abs = nu.abs();
650 let c = kappa.powf(k_half - n)
651 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
652 if r <= 0.0 {
653 if nu > 0.0 {
654 return Ok(c * 2.0_f64.powf(nu - 1.0) * gamma_lanczos(nu) * kappa.powf(-nu));
656 }
657 crate::bail_invalid_basis!(
663 "Duchon Matérn block at r=0 with ν={nu} ≤ 0 is divergent; \
664 evaluate the hybrid kernel diagonal via the collision routine"
665 );
666 }
667 let z = (kappa * r).max(1e-300);
668 let k_nu = bessel_k_real_half_integer_or_integer(nu_abs, z)?;
669 Ok(c * r.powf(nu) * k_nu)
670}
671
672#[inline(always)]
673pub(crate) fn polyharmonic_kernel_triplet(
674 r: f64,
675 m: f64,
676 k_dim: usize,
677) -> Result<(f64, f64, f64), BasisError> {
678 let (value, first, second, _, _) = polyharmonic_block_jet4(r, m, k_dim)?;
679 Ok((value, first, second))
680}
681
682#[inline(always)]
683pub(crate) fn falling_factorial(alpha: f64, order: usize) -> f64 {
684 (0..order).fold(1.0, |acc, idx| acc * (alpha - idx as f64))
685}
686
687#[inline(always)]
688pub(crate) fn falling_factorial_derivative(alpha: f64, order: usize) -> f64 {
689 if order == 0 {
690 return 0.0;
691 }
692 let mut total = 0.0;
693 for omit in 0..order {
694 let mut term = 1.0;
695 for idx in 0..order {
696 if idx != omit {
697 term *= alpha - idx as f64;
698 }
699 }
700 total += term;
701 }
702 total
703}
704
705pub(crate) fn polyharmonic_block_jet4(
712 r: f64,
713 m: f64,
714 k_dim: usize,
715) -> Result<(f64, f64, f64, f64, f64), BasisError> {
716 if !r.is_finite() || r < 0.0 {
717 crate::bail_invalid_basis!("polyharmonic distance must be finite and non-negative");
718 }
719 assert!(
720 m.is_finite() && m > 0.0,
721 "polyharmonic_block_jet4: m must be finite and > 0, got {m}"
722 );
723
724 let k_half = 0.5 * k_dim as f64;
725 let alpha = 2.0 * m - k_dim as f64;
726 const LOG_EPS: f64 = 1e-12;
729 let is_log_case = k_dim.is_multiple_of(2) && {
730 let n_f = (alpha / 2.0).round();
731 n_f >= 0.0 && (n_f * 2.0 - alpha).abs() < LOG_EPS
732 };
733 if is_log_case {
734 let m_int = m.round() as usize;
735 let c = polyharmonic_log_sign(m_int, k_dim)
736 / (2.0_f64.powi((2 * m_int - 1) as i32)
737 * std::f64::consts::PI.powf(k_half)
738 * gamma_lanczos(m)
739 * gamma_lanczos((m_int - k_dim / 2 + 1) as f64));
740 let mut out = [0.0; 5];
741 for d in 0..5 {
742 let e = alpha - d as f64;
743 let ff = falling_factorial(alpha, d);
744 let ff_d = falling_factorial_derivative(alpha, d);
745 out[d] = if r <= 0.0 {
746 log_power_origin_limit(c, e, ff, ff_d)
747 } else {
748 c * r.powf(e) * (ff * r.ln() + ff_d)
749 };
750 }
751 return Ok((out[0], out[1], out[2], out[3], out[4]));
752 }
753
754 let c = gamma_lanczos(k_half - m)
755 / (4.0_f64.powf(m) * std::f64::consts::PI.powf(k_half) * gamma_lanczos(m));
756 let mut out = [0.0; 5];
757 for d in 0..5 {
758 let e = alpha - d as f64;
759 let ff = falling_factorial(alpha, d);
760 out[d] = if r <= 0.0 {
761 log_power_origin_limit(c, e, 0.0, ff)
762 } else {
763 c * ff * r.powf(e)
764 };
765 }
766 Ok((out[0], out[1], out[2], out[3], out[4]))
767}
768
769#[inline(always)]
770pub(crate) fn log_power_family_derivative(
771 exponent: f64,
772 log_coeff: f64,
773 pure_coeff: f64,
774) -> (f64, f64, f64) {
775 (
776 exponent - 1.0,
777 exponent * log_coeff,
778 exponent * pure_coeff + log_coeff,
779 )
780}
781
782#[inline(always)]
783pub(crate) fn log_power_family_value(
784 r: f64,
785 coeff: f64,
786 exponent: f64,
787 log_coeff: f64,
788 pure_coeff: f64,
789) -> f64 {
790 if r <= 0.0 {
791 log_power_origin_limit(coeff, exponent, log_coeff, pure_coeff)
792 } else {
793 coeff * r.powf(exponent) * (log_coeff * r.ln() + pure_coeff)
794 }
795}
796
797#[inline(always)]
798pub(crate) fn duchon_polyharmonic_operator_block_jets(
799 r: f64,
800 m: f64,
801 k_dim: usize,
802) -> Result<(f64, f64, f64, f64), BasisError> {
803 if !r.is_finite() || r < 0.0 {
804 crate::bail_invalid_basis!("polyharmonic distance must be finite and non-negative");
805 }
806 assert!(
807 m.is_finite() && m > 0.0,
808 "duchon_polyharmonic_operator_block_jets: m must be finite and > 0, got {m}"
809 );
810
811 let k_half = 0.5 * k_dim as f64;
812 let alpha = 2.0 * m - k_dim as f64;
813 const LOG_EPS: f64 = 1e-12;
817 let is_log_case = k_dim.is_multiple_of(2) && {
818 let n_f = (alpha / 2.0).round();
819 n_f >= 0.0 && (n_f * 2.0 - alpha).abs() < LOG_EPS
820 };
821 let (c, phi_log_coeff, phi_pure_coeff) = if is_log_case {
822 let m_int = m.round() as usize;
823 (
824 polyharmonic_log_sign(m_int, k_dim)
825 / (2.0_f64.powi((2 * m_int - 1) as i32)
826 * std::f64::consts::PI.powf(k_half)
827 * gamma_lanczos(m)
828 * gamma_lanczos((m_int - k_dim / 2 + 1) as f64)),
829 1.0,
830 0.0,
831 )
832 } else {
833 (
834 gamma_lanczos(k_half - m)
835 / (4.0_f64.powf(m) * std::f64::consts::PI.powf(k_half) * gamma_lanczos(m)),
836 0.0,
837 1.0,
838 )
839 };
840
841 let (phi_r_exp, phi_r_log, phi_r_pure) =
842 log_power_family_derivative(alpha, phi_log_coeff, phi_pure_coeff);
843 let q_exp = phi_r_exp - 1.0;
844 let q = log_power_family_value(r, c, q_exp, phi_r_log, phi_r_pure);
845
846 let (q_r_exp_raw, q_r_log, q_r_pure) =
847 log_power_family_derivative(q_exp, phi_r_log, phi_r_pure);
848 let t_exp = q_r_exp_raw - 1.0;
849 let t = log_power_family_value(r, c, t_exp, q_r_log, q_r_pure);
850
851 let (t_r_exp, t_r_log, t_r_pure) = log_power_family_derivative(t_exp, q_r_log, q_r_pure);
852 let t_r = log_power_family_value(r, c, t_r_exp, t_r_log, t_r_pure);
853
854 let (t_rr_exp, t_rr_log, t_rr_pure) = log_power_family_derivative(t_r_exp, t_r_log, t_r_pure);
855 let t_rr = log_power_family_value(r, c, t_rr_exp, t_rr_log, t_rr_pure);
856
857 Ok((q, t, t_r, t_rr))
858}
859
860pub(crate) struct BesselKLadder {
878 pub(crate) values: SmallVec<[f64; 16]>,
880 pub(crate) half_integer: bool,
881}
882
883impl BesselKLadder {
884 pub(crate) fn build(z: f64, half_integer: bool, max_order_steps: usize) -> Self {
885 let zz = z.max(1e-300);
886 let mut values: SmallVec<[f64; 16]> = SmallVec::with_capacity(max_order_steps + 2);
887 if half_integer {
888 let k_half = (std::f64::consts::PI / (2.0 * zz)).sqrt() * (-zz).exp();
890 values.push(k_half);
891 values.push(k_half * (1.0 + 1.0 / zz));
892 } else {
893 values.push(bessel_k0_stable(zz));
894 values.push(bessel_k1_stable(zz));
895 }
896 let base = if half_integer { 0.5 } else { 0.0 };
897 for i in 1..max_order_steps {
898 let nu = base + i as f64;
899 let next = values[i - 1] + 2.0 * nu * values[i] / zz;
900 values.push(next);
901 }
902 Self {
903 values,
904 half_integer,
905 }
906 }
907
908 #[inline]
910 pub(crate) fn k_abs(&self, order_abs: f64) -> f64 {
911 let base = if self.half_integer { 0.5 } else { 0.0 };
912 let idx = (order_abs - base).round() as usize;
913 self.values[idx]
914 }
915}
916
917pub(crate) fn duchon_matern_family_jets_with_ladder(
939 r: f64,
940 kappa: f64,
941 coeff: f64,
942 mu: f64,
943 max_j: usize,
944 ladder: &BesselKLadder,
945 out: &mut [f64],
946) -> Result<(), BasisError> {
947 if max_j > 4 || out.len() <= max_j {
948 crate::bail_invalid_basis!(
949 "Duchon Matérn-family ladder jets support derivative orders 0..=4 with an output slot per order"
950 );
951 }
952 if r <= 0.0 {
953 out[..=max_j].fill(0.0);
954 if mu > 0.0 {
955 out[0] = coeff * 2.0_f64.powf(mu - 1.0) * gamma_lanczos(mu) * kappa.powf(-mu);
956 }
957 return Ok(());
958 }
959 let mut terms: SmallVec<[DuchonMaternDerivativeTerm; 16]> =
960 smallvec![DuchonMaternDerivativeTerm {
961 coeff,
962 kappa_power: 0,
963 r_power: mu,
964 bessel_order: mu,
965 }];
966 for (j, slot) in out.iter_mut().enumerate().take(max_j + 1) {
967 if j > 0 {
968 let mut next: SmallVec<[DuchonMaternDerivativeTerm; 16]> =
969 SmallVec::with_capacity(terms.len() * 2);
970 for term in &terms {
971 let stay_coeff = term.coeff * (term.r_power - term.bessel_order);
972 if stay_coeff != 0.0 {
973 next.push(DuchonMaternDerivativeTerm {
974 coeff: stay_coeff,
975 kappa_power: term.kappa_power,
976 r_power: term.r_power - 1.0,
977 bessel_order: term.bessel_order,
978 });
979 }
980 next.push(DuchonMaternDerivativeTerm {
981 coeff: -term.coeff,
982 kappa_power: term.kappa_power + 1,
983 r_power: term.r_power,
984 bessel_order: term.bessel_order - 1.0,
985 });
986 }
987 terms = next;
988 }
989 let mut value = KahanSum::default();
990 for term in &terms {
991 if term.coeff == 0.0 {
992 continue;
993 }
994 value.add(
995 term.coeff
996 * kappa.powi(term.kappa_power as i32)
997 * r.powf(term.r_power)
998 * ladder.k_abs(term.bessel_order.abs()),
999 );
1000 }
1001 *slot = value.sum();
1002 }
1003 Ok(())
1004}
1005
1006pub(crate) fn duchon_matern_block_max_ladder_steps(n_order: usize, k_dim: usize) -> usize {
1010 let nu = n_order as f64 - 0.5 * k_dim as f64;
1011 let candidates = [
1012 (nu - 1.0).abs(),
1013 (nu - 2.0).abs(),
1014 (nu - 3.0).abs(),
1015 (nu - 4.0).abs(),
1016 ];
1017 let max_abs = candidates.iter().copied().fold(0.0_f64, f64::max);
1018 max_abs.floor() as usize + 1
1019}
1020
1021pub(crate) fn duchon_matern_operator_block_jets_with_ladder(
1022 r: f64,
1023 kappa: f64,
1024 n_order: usize,
1025 k_dim: usize,
1026 ladder: &BesselKLadder,
1027) -> Result<(f64, f64, f64, f64), BasisError> {
1028 if r <= 0.0 {
1029 return Ok((0.0, 0.0, 0.0, 0.0));
1030 }
1031 let n = n_order as f64;
1032 let k_half = 0.5 * k_dim as f64;
1033 let nu = n - k_half;
1034 let c = kappa.powf(k_half - n)
1035 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
1036
1037 let mut q_out = [0.0_f64; 1];
1038 duchon_matern_family_jets_with_ladder(r, kappa, -c * kappa, nu - 1.0, 0, ladder, &mut q_out)?;
1039 let mut t_out = [0.0_f64; 3];
1040 duchon_matern_family_jets_with_ladder(
1041 r,
1042 kappa,
1043 c * kappa * kappa,
1044 nu - 2.0,
1045 2,
1046 ladder,
1047 &mut t_out,
1048 )?;
1049 Ok((q_out[0], t_out[0], t_out[1], t_out[2]))
1050}
1051
1052#[inline(always)]
1053pub(crate) fn pure_duchon_block_order(p_order: usize, s_order: f64) -> f64 {
1054 p_order as f64 + s_order
1055}
1056
1057pub(crate) fn validate_duchon_kernel_orders(
1058 length_scale: Option<f64>,
1059 p_order: usize,
1060 s_order: f64,
1061 k_dim: usize,
1062) -> Result<(), BasisError> {
1063 if k_dim == 0 {
1064 crate::bail_invalid_basis!("Duchon basis requires at least one covariate dimension");
1065 }
1066 if let Some(scale) = length_scale
1067 && (!scale.is_finite() || scale <= 0.0)
1068 {
1069 crate::bail_invalid_basis!("Duchon hybrid length_scale must be finite and positive");
1070 }
1071 if !s_order.is_finite() || s_order < 0.0 {
1110 crate::bail_invalid_basis!("Duchon spectral power must be finite and ≥ 0; got s={s_order}");
1111 }
1112 if length_scale.is_none() && p_order < 2 && 2.0 * s_order >= k_dim as f64 {
1113 crate::bail_invalid_basis!(
1114 "pure Duchon requires power < dimension/2 for nullspace degree < {p_order}; got power={s_order}, dimension={k_dim}"
1115 );
1116 }
1117 let spectral_order = 2.0 * (p_order as f64 + s_order);
1118 if spectral_order <= k_dim as f64 {
1119 crate::bail_invalid_basis!(
1120 "Duchon pointwise kernel values require 2*(p+s) > dimension; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1121 );
1122 }
1123 Ok(())
1124}
1125
1126pub(crate) fn validate_duchon_collocation_orders(
1127 length_scale: Option<f64>,
1128 p_order: usize,
1129 s_order: f64,
1130 k_dim: usize,
1131 max_operator_derivative_order: usize,
1132) -> Result<(), BasisError> {
1133 validate_duchon_kernel_orders(length_scale, p_order, s_order, k_dim)?;
1136 let spectral_order = 2.0 * (p_order as f64 + s_order);
1149 if max_operator_derivative_order >= 1 && spectral_order <= k_dim as f64 + 1.0 {
1150 crate::bail_invalid_basis!(
1151 "Duchon D1 collocation requires 2*(p+s) > dimension+1; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1152 );
1153 }
1154 if max_operator_derivative_order >= 2 && spectral_order <= k_dim as f64 + 2.0 {
1155 crate::bail_invalid_basis!(
1156 "Duchon D2 collocation requires 2*(p+s) > dimension+2; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1157 );
1158 }
1159 Ok(())
1160}
1161
1162#[derive(Debug, Clone)]
1163pub struct DuchonPartialFractionCoeffs {
1164 pub(crate) a: Vec<f64>,
1165 pub(crate) b: Vec<f64>,
1166}
1167
1168#[inline(always)]
1169pub(crate) fn duchon_partial_fraction_coeffs(
1170 p_order: usize,
1171 s_order: usize,
1172 kappa: f64,
1173) -> DuchonPartialFractionCoeffs {
1174 let mut a = vec![0.0_f64; p_order + 1]; let mut b = vec![0.0_f64; s_order + 1]; if s_order == 0 {
1178 if p_order > 0 {
1179 a[p_order] = 1.0;
1182 }
1183 return DuchonPartialFractionCoeffs { a, b };
1184 }
1185 for m in 1..=p_order {
1186 let sign = if (p_order - m).is_multiple_of(2) {
1187 1.0
1188 } else {
1189 -1.0
1190 };
1191 let expo = -2.0 * (s_order + p_order - m) as f64;
1192 let comb = binomial_f64(s_order + p_order - m - 1, p_order - m);
1193 a[m] = sign * kappa.powf(expo) * comb;
1194 }
1195 for n in 1..=s_order {
1196 let sign = if p_order.is_multiple_of(2) { 1.0 } else { -1.0 };
1197 let expo = -2.0 * (p_order + s_order - n) as f64;
1198 let comb = if p_order == 0 && n == s_order {
1199 1.0
1201 } else {
1202 let top = p_order + s_order - n - 1;
1203 binomial_f64(top, s_order - n)
1204 };
1205 b[n] = sign * kappa.powf(expo) * comb;
1206 }
1207 DuchonPartialFractionCoeffs { a, b }
1208}
1209
1210fn gauss_legendre_01_64() -> &'static [(f64, f64)] {
1219 use std::sync::OnceLock;
1220 static NODES: OnceLock<Vec<(f64, f64)>> = OnceLock::new();
1221 NODES.get_or_init(|| {
1222 const N: usize = 64;
1228 let nf = N as f64;
1229 let mut nodes: Vec<(f64, f64)> = Vec::with_capacity(N);
1230 let half = N.div_ceil(2);
1231 for i in 0..half {
1232 let mut x = (std::f64::consts::PI * (i as f64 + 0.75) / (nf + 0.5)).cos();
1234 let mut dp = 0.0_f64;
1235 for _ in 0..100 {
1236 let mut p0 = 1.0_f64;
1239 let mut p1 = x;
1240 for k in 2..=N {
1241 let kf = k as f64;
1242 let p2 = ((2.0 * kf - 1.0) * x * p1 - (kf - 1.0) * p0) / kf;
1243 p0 = p1;
1244 p1 = p2;
1245 }
1246 dp = nf * (x * p1 - p0) / (x * x - 1.0);
1248 let dx = p1 / dp;
1249 x -= dx;
1250 if dx.abs() <= 1e-16 * x.abs().max(1.0) {
1251 break;
1252 }
1253 }
1254 let w = 2.0 / ((1.0 - x * x) * dp * dp);
1256 nodes.push((x, w));
1258 if x.abs() > 1e-300 {
1259 nodes.push((-x, w));
1260 }
1261 }
1262 nodes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
1264 nodes
1265 .into_iter()
1266 .map(|(x, w)| (0.5 * (x + 1.0), 0.5 * w))
1267 .collect()
1268 })
1269}
1270
1271pub(crate) fn duchon_hybrid_kernel_stable_integral(
1301 r: f64,
1302 kappa: f64,
1303 p_order: usize,
1304 s_order: usize,
1305 k_dim: usize,
1306) -> Result<f64, BasisError> {
1307 assert!(
1308 duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
1309 "duchon_hybrid_kernel_stable_integral precondition violated: 2(p+s) > d and 2p < d required (p={p_order}, s={s_order}, d={k_dim})"
1310 );
1311 let p = p_order as f64;
1312 let s = s_order as f64;
1313 let half_d = 0.5 * k_dim as f64;
1314 let b = p + s - half_d;
1315 let pref = (4.0 * std::f64::consts::PI).powf(-half_d) / (gamma_lanczos(p) * gamma_lanczos(s));
1316 if r == 0.0 {
1317 let beta = gamma_lanczos(s - b) * gamma_lanczos(p) / gamma_lanczos(s - b + p);
1319 let value = pref * gamma_lanczos(b) * kappa.powf(-2.0 * b) * beta;
1320 if !value.is_finite() {
1321 crate::bail_invalid_basis!(
1322 "non-finite Duchon hybrid diagonal (stable form) for p={p_order}, s={s_order}, d={k_dim}"
1323 );
1324 }
1325 return Ok(value);
1326 }
1327 let mut acc = KahanSum::default();
1328 for &(w, weight) in gauss_legendre_01_64() {
1329 let sqrt_w = w.sqrt();
1331 let z = (kappa * r * sqrt_w).max(1e-300);
1332 let k_b = bessel_k_real_half_integer_or_integer(b.abs(), z)?;
1333 let smooth = 2.0 * (r / (2.0 * kappa * sqrt_w)).powf(b) * k_b;
1334 let factor = (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * smooth;
1335 acc.add(weight * factor);
1336 }
1337 let value = pref * acc.sum();
1338 if !value.is_finite() {
1339 crate::bail_invalid_basis!(
1340 "non-finite Duchon hybrid value (stable form) at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1341 );
1342 }
1343 Ok(value)
1344}
1345
1346pub(crate) fn duchon_hybrid_operator_stable_integral(
1373 r: f64,
1374 kappa: f64,
1375 p_order: usize,
1376 s_order: usize,
1377 k_dim: usize,
1378) -> Result<DuchonRegularizedOperatorCore, BasisError> {
1379 assert!(
1380 duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
1381 "duchon_hybrid_operator_stable_integral precondition violated: 2(p+s) > d and 2p < d required (p={p_order}, s={s_order}, d={k_dim})"
1382 );
1383 assert!(
1384 r > 0.0 && r.is_finite(),
1385 "duchon_hybrid_operator_stable_integral requires r > 0, got r={r}"
1386 );
1387 let p = p_order as f64;
1388 let s = s_order as f64;
1389 let half_d = 0.5 * k_dim as f64;
1390 let b = p + s - half_d;
1391 let pref = (4.0 * std::f64::consts::PI).powf(-half_d) / (gamma_lanczos(p) * gamma_lanczos(s));
1392
1393 let mut d1 = KahanSum::default();
1396 let mut d2 = KahanSum::default();
1397 let mut d3 = KahanSum::default();
1398 let mut d4 = KahanSum::default();
1399
1400 for &(w, weight) in gauss_legendre_01_64() {
1401 let sqrt_w = w.sqrt();
1402 let c = (kappa * sqrt_w).max(1e-300);
1403 let z = (c * r).max(1e-300);
1404
1405 let a0 = 2.0 * (2.0 * c).powf(-b);
1412 let mut terms: Vec<(f64, f64, i32)> = vec![(a0, b, 0)];
1413 let bessel = |j: i32| -> Result<f64, BasisError> {
1415 bessel_k_real_half_integer_or_integer((b + j as f64).abs(), z)
1416 };
1417 let evaluate = |terms: &Vec<(f64, f64, i32)>| -> Result<f64, BasisError> {
1418 let mut acc = KahanSum::default();
1419 for &(c0, a, j) in terms {
1420 if c0 == 0.0 {
1421 continue;
1422 }
1423 acc.add(c0 * r.powf(a) * bessel(j)?);
1424 }
1425 Ok(acc.sum())
1426 };
1427
1428 let mut slice_derivs = [0.0_f64; 4];
1429 for slot in slice_derivs.iter_mut() {
1430 let mut next: Vec<(f64, f64, i32)> = Vec::with_capacity(terms.len() * 3);
1432 for &(c0, a, j) in &terms {
1433 if c0 == 0.0 {
1434 continue;
1435 }
1436 if a != 0.0 {
1437 next.push((c0 * a, a - 1.0, j));
1438 }
1439 let half = -c0 * c * 0.5;
1440 next.push((half, a, j - 1));
1441 next.push((half, a, j + 1));
1442 }
1443 terms = next;
1444 *slot = evaluate(&terms)?;
1445 }
1446
1447 d1.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[0]);
1448 d2.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[1]);
1449 d3.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[2]);
1450 d4.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[3]);
1451 }
1452
1453 let phi1 = pref * d1.sum();
1454 let phi2 = pref * d2.sum();
1455 let phi3 = pref * d3.sum();
1456 let phi4 = pref * d4.sum();
1457 if !(phi1.is_finite() && phi2.is_finite() && phi3.is_finite() && phi4.is_finite()) {
1458 crate::bail_invalid_basis!(
1459 "non-finite Duchon hybrid operator (stable form) at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1460 );
1461 }
1462
1463 let inv_r = 1.0 / r;
1467 let q = phi1 * inv_r;
1468 let q_r = phi2 * inv_r - phi1 * inv_r * inv_r;
1471 let q_rr = phi3 * inv_r - 2.0 * phi2 * inv_r * inv_r + 2.0 * phi1 * inv_r * inv_r * inv_r;
1472 let q_rrr = phi4 * inv_r - 3.0 * phi3 * inv_r * inv_r + 6.0 * phi2 * inv_r * inv_r * inv_r
1473 - 6.0 * phi1 * inv_r * inv_r * inv_r * inv_r;
1474 let t = q_r * inv_r;
1475 let t_r = q_rr * inv_r - q_r * inv_r * inv_r;
1476 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;
1477
1478 Ok(DuchonRegularizedOperatorCore { q, t, t_r, t_rr })
1479}
1480
1481#[inline]
1490pub(crate) fn duchon_hybrid_stable_integral_applies(
1491 p_order: usize,
1492 s_order: usize,
1493 k_dim: usize,
1494) -> bool {
1495 s_order >= 1 && 2 * p_order < k_dim
1496}
1497
1498pub(crate) fn duchon_matern_kernel_general_from_distance(
1499 r: f64,
1500 length_scale: Option<f64>,
1501 p_order: usize,
1502 s_order: usize,
1503 k_dim: usize,
1504 coeffs: Option<&DuchonPartialFractionCoeffs>,
1505) -> Result<f64, BasisError> {
1506 if !r.is_finite() || r < 0.0 {
1507 crate::bail_invalid_basis!("Duchon kernel distance must be finite and non-negative");
1508 }
1509 let Some(length_scale) = length_scale else {
1510 return Ok(polyharmonic_kernel(
1511 r,
1512 pure_duchon_block_order(p_order, s_order as f64),
1513 k_dim,
1514 ));
1515 };
1516 if !length_scale.is_finite() || length_scale <= 0.0 {
1517 crate::bail_invalid_basis!("Duchon hybrid length_scale must be finite and positive");
1518 }
1519 let kappa = 1.0 / length_scale;
1520
1521 if duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim) {
1529 return duchon_hybrid_kernel_stable_integral(r, kappa, p_order, s_order, k_dim);
1530 }
1531
1532 let coeffs_local;
1533 let coeffs_ref = if let Some(c) = coeffs {
1534 c
1535 } else {
1536 coeffs_local = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
1537 &coeffs_local
1538 };
1539 let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale.max(1e-8);
1540 let kernel_finite_at_origin = 2 * (p_order + s_order) > k_dim;
1548 if r <= collision_taylor_radius && kernel_finite_at_origin {
1549 return duchon_hybrid_kernel_near_collision_value(
1550 r,
1551 length_scale,
1552 p_order,
1553 s_order,
1554 k_dim,
1555 coeffs_ref,
1556 );
1557 }
1558 let mut val = KahanSum::default();
1559 for (m, coeff) in coeffs_ref.a.iter().enumerate().skip(1) {
1560 if *coeff == 0.0 {
1561 continue;
1562 }
1563 val.add(coeff * polyharmonic_kernel(r, (m) as f64, k_dim));
1564 }
1565 for (n, coeff) in coeffs_ref.b.iter().enumerate().skip(1) {
1566 if *coeff == 0.0 {
1567 continue;
1568 }
1569 val.add(coeff * duchon_matern_block(r, kappa, n, k_dim)?);
1570 }
1571 Ok(val.sum())
1572}
1573
1574pub(crate) fn duchon_hybrid_kernel_collision_value(
1575 length_scale: f64,
1576 p_order: usize,
1577 s_order: usize,
1578 k_dim: usize,
1579 coeffs: &DuchonPartialFractionCoeffs,
1580) -> Result<f64, BasisError> {
1581 let spectral_order = 2 * (p_order + s_order);
1582 if spectral_order <= k_dim {
1583 crate::bail_invalid_basis!(
1584 "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}"
1585 );
1586 }
1587
1588 let kappa = 1.0 / length_scale.max(1e-300);
1589 let mut pure = KahanSum::default();
1590 let mut log_part = KahanSum::default();
1591 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1592 if a_m == 0.0 {
1593 continue;
1594 }
1595 let (block_pure, block_log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, 0);
1596 pure.add(a_m * block_pure);
1597 log_part.add(a_m * block_log);
1598 }
1599 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1600 if b_n == 0.0 {
1601 continue;
1602 }
1603 let (block_pure, block_log) = duchon_matern_block_taylor_r2j(kappa, n, k_dim, 0);
1604 pure.add(b_n * block_pure);
1605 log_part.add(b_n * block_log);
1606 }
1607 let value = pure.sum();
1608 let log_value = log_part.sum();
1609 if log_value.abs() > 1e-8 * value.abs().max(1e-30) {
1610 crate::bail_invalid_basis!(
1611 "Duchon hybrid diagonal log terms did not cancel: log={log_value:.6e}, value={value:.6e}; p={p_order}, s={s_order}, d={k_dim}"
1612 );
1613 }
1614 if !value.is_finite() {
1615 crate::bail_invalid_basis!(
1616 "non-finite Duchon hybrid diagonal value for p={p_order}, s={s_order}, d={k_dim}"
1617 );
1618 }
1619 Ok(value)
1620}
1621
1622pub(crate) fn duchon_hybrid_kernel_near_collision_value(
1623 r: f64,
1624 length_scale: f64,
1625 p_order: usize,
1626 s_order: usize,
1627 k_dim: usize,
1628 coeffs: &DuchonPartialFractionCoeffs,
1629) -> Result<f64, BasisError> {
1630 let mut value =
1631 duchon_hybrid_kernel_collision_value(length_scale, p_order, s_order, k_dim, coeffs)?;
1632 if r == 0.0 {
1633 return Ok(value);
1634 }
1635
1636 let smoothness_order = 2 * (p_order + s_order);
1650 let r2 = r * r;
1651 if smoothness_order > k_dim + 2 {
1652 let (phi_rr, _, _) =
1653 duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
1654 value += 0.5 * phi_rr * r2;
1655 }
1656 if smoothness_order > k_dim + 4 {
1657 let phi_rrrr = duchon_phi_rrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)?;
1658 value += (1.0 / 24.0) * phi_rrrr * r2 * r2;
1659 }
1660 if smoothness_order > k_dim + 6 {
1661 let phi_rrrrrr =
1662 duchon_phi_rrrrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)?;
1663 value += (1.0 / 720.0) * phi_rrrrrr * r2 * r2 * r2;
1664 }
1665 if !value.is_finite() {
1666 crate::bail_invalid_basis!(
1667 "non-finite Duchon hybrid near-collision value at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1668 );
1669 }
1670 Ok(value)
1671}
1672
1673#[inline(always)]
1674pub(crate) fn stable_euclidean_norm<I>(components: I) -> f64
1675where
1676 I: IntoIterator<Item = f64>,
1677{
1678 let mut scale = 0.0_f64;
1679 let mut sumsq = 1.0_f64;
1680 let mut has_nonzero = false;
1681 for component in components {
1682 let abs = component.abs();
1683 if abs == 0.0 {
1684 continue;
1685 }
1686 if !abs.is_finite() {
1687 return f64::INFINITY;
1688 }
1689 if !has_nonzero {
1690 scale = abs;
1691 has_nonzero = true;
1692 continue;
1693 }
1694 if scale < abs {
1695 let ratio = scale / abs;
1696 sumsq = 1.0 + sumsq * ratio * ratio;
1697 scale = abs;
1698 } else {
1699 let ratio = abs / scale;
1700 sumsq += ratio * ratio;
1701 }
1702 }
1703 if has_nonzero {
1704 scale * sumsq.sqrt()
1705 } else {
1706 0.0
1707 }
1708}
1709
1710#[inline]
1711pub(crate) fn centered_aniso_log_scale_mean(eta: &[f64]) -> f64 {
1712 if eta.len() <= 1 {
1713 0.0
1714 } else {
1715 eta.iter().sum::<f64>() / eta.len() as f64
1716 }
1717}
1718
1719#[inline]
1720pub(crate) fn centered_aniso_log_scale(value: f64, mean: f64) -> f64 {
1721 let centered = value - mean;
1727 if centered.is_finite() {
1728 centered.clamp(-50.0, 50.0)
1729 } else if centered > 0.0 {
1730 50.0
1731 } else {
1732 -50.0
1733 }
1734}
1735
1736#[inline]
1737pub(crate) fn aniso_axis_scale(value: f64, mean: f64) -> f64 {
1738 centered_aniso_log_scale(value, mean).exp()
1739}
1740
1741#[inline]
1742pub(crate) fn aniso_metric_weight(value: f64, mean: f64) -> f64 {
1743 (2.0 * centered_aniso_log_scale(value, mean)).exp()
1744}
1745
1746pub(crate) fn centered_aniso_metric_weights(eta: &[f64]) -> Vec<f64> {
1747 let mean = centered_aniso_log_scale_mean(eta);
1748 eta.iter()
1749 .map(|&value| aniso_metric_weight(value, mean))
1750 .collect()
1751}
1752
1753#[inline]
1780pub(crate) fn aniso_distance_and_components(
1781 data_row: &[f64],
1782 center: &[f64],
1783 eta: &[f64],
1784) -> (f64, Vec<f64>) {
1785 assert_eq!(data_row.len(), center.len());
1786 assert_eq!(data_row.len(), eta.len());
1787 let d = data_row.len();
1788 let eta_mean = centered_aniso_log_scale_mean(eta);
1789 let mut s_vec = Vec::with_capacity(d);
1790 let mut scaled_components = Vec::with_capacity(d);
1791 for a in 0..d {
1792 let h_a = data_row[a] - center[a];
1793 let scale_a = aniso_axis_scale(eta[a], eta_mean);
1795 let scaled_h_a = scale_a * h_a;
1796 let s_a = scaled_h_a * scaled_h_a;
1797 scaled_components.push(scaled_h_a);
1798 s_vec.push(s_a);
1799 }
1800 (stable_euclidean_norm(scaled_components), s_vec)
1801}
1802
1803#[inline]
1808pub(crate) fn aniso_distance(data_row: &[f64], center: &[f64], eta: &[f64]) -> f64 {
1809 assert_eq!(data_row.len(), center.len());
1810 assert_eq!(data_row.len(), eta.len());
1811 let eta_mean = centered_aniso_log_scale_mean(eta);
1812 stable_euclidean_norm(
1813 (0..data_row.len()).map(|a| aniso_axis_scale(eta[a], eta_mean) * (data_row[a] - center[a])),
1814 )
1815}
1816
1817#[inline(always)]
1818pub(crate) fn euclidean_distance_rows(
1819 lhs: ArrayView2<'_, f64>,
1820 lhs_row: usize,
1821 rhs: ArrayView2<'_, f64>,
1822 rhs_row: usize,
1823) -> f64 {
1824 assert_eq!(lhs.ncols(), rhs.ncols());
1825 stable_euclidean_norm((0..lhs.ncols()).map(|axis| lhs[[lhs_row, axis]] - rhs[[rhs_row, axis]]))
1826}
1827
1828#[inline(always)]
1829pub(crate) fn aniso_axis_scales(eta: &[f64]) -> Vec<f64> {
1830 let eta_mean = centered_aniso_log_scale_mean(eta);
1831 eta.iter()
1832 .map(|&value| aniso_axis_scale(value, eta_mean))
1833 .collect()
1834}
1835
1836#[inline(always)]
1837pub(crate) fn aniso_distance_rows_with_scales(
1838 lhs: ArrayView2<'_, f64>,
1839 lhs_row: usize,
1840 rhs: ArrayView2<'_, f64>,
1841 rhs_row: usize,
1842 axis_scales: &[f64],
1843) -> f64 {
1844 assert_eq!(lhs.ncols(), rhs.ncols());
1845 assert_eq!(lhs.ncols(), axis_scales.len());
1846 stable_euclidean_norm(
1847 (0..lhs.ncols())
1848 .map(|axis| axis_scales[axis] * (lhs[[lhs_row, axis]] - rhs[[rhs_row, axis]])),
1849 )
1850}
1851
1852pub(crate) fn fill_symmetric_from_row_kernel<F>(
1853 matrix: &mut Array2<f64>,
1854 kernel: F,
1855) -> Result<(), BasisError>
1856where
1857 F: Fn(usize, usize) -> Result<f64, BasisError> + Sync,
1858{
1859 assert_eq!(matrix.nrows(), matrix.ncols());
1860 let k = matrix.nrows();
1861 matrix
1869 .axis_iter_mut(Axis(0))
1870 .into_par_iter()
1871 .enumerate()
1872 .try_for_each(|(i, mut row)| {
1873 for j in i..k {
1874 row[j] = kernel(i, j)?;
1875 }
1876 Ok::<(), BasisError>(())
1877 })?;
1878 for i in 1..k {
1879 for j in 0..i {
1880 matrix[[i, j]] = matrix[[j, i]];
1881 }
1882 }
1883 Ok(())
1884}
1885
1886pub(crate) fn points_in_aniso_y_space(points: ArrayView2<'_, f64>, eta: &[f64]) -> Array2<f64> {
1894 assert_eq!(points.ncols(), eta.len());
1895 let mut y = points.to_owned();
1896 let eta_mean = centered_aniso_log_scale_mean(eta);
1897 let weights: Vec<f64> = eta.iter().map(|&e| aniso_axis_scale(e, eta_mean)).collect();
1898 for a in 0..eta.len() {
1899 let w_a = weights[a];
1900 y.column_mut(a).mapv_inplace(|v| v * w_a);
1901 }
1902 y
1903}
1904
1905pub fn knot_cloud_axis_scales(centers: ArrayView2<'_, f64>) -> Vec<f64> {
1910 let k = centers.nrows();
1911 let d = centers.ncols();
1912 if k < 2 || d == 0 {
1913 return vec![1.0; d];
1914 }
1915 let n = k as f64;
1916 let mut scales = Vec::with_capacity(d);
1917 for a in 0..d {
1918 let col = centers.column(a);
1919 let mean = col.sum() / n;
1920 let var = col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
1921 let sigma = var.sqrt();
1922 let sigma = if sigma < 1e-12 { 1.0 } else { sigma };
1924 scales.push(sigma.clamp(1e-6, 1e6));
1925 }
1926 scales
1927}
1928
1929pub fn initial_aniso_contrasts(centers: ArrayView2<'_, f64>) -> Vec<f64> {
1937 let d = centers.ncols();
1938 if d <= 1 {
1939 return Vec::new();
1940 }
1941 let scales = knot_cloud_axis_scales(centers);
1942 let mean_neg_log: f64 = scales.iter().map(|&s| -s.ln()).sum::<f64>() / d as f64;
1943 scales
1947 .iter()
1948 .map(|&scale| -scale.ln() - mean_neg_log)
1949 .collect()
1950}
1951
1952pub(crate) fn centered_aniso_contrasts(aniso: Option<&[f64]>) -> Option<Vec<f64>> {
1975 match aniso {
1976 Some(v) if v.len() > 1 => Some(center_aniso_log_scales(v)),
1977 Some(v) => Some(v.to_vec()),
1978 None => None,
1979 }
1980}
1981
1982pub(crate) fn auto_seed_aniso_contrasts(
2000 centers: ArrayView2<'_, f64>,
2001 aniso: Option<&[f64]>,
2002) -> Option<Vec<f64>> {
2003 let eta = match aniso {
2004 Some(v) if v.len() > 1 => v,
2005 Some(v) => return Some(v.to_vec()),
2006 None => return None,
2007 };
2008 let all_zero = eta.iter().all(|&e| e == 0.0);
2009 if !all_zero {
2010 return Some(center_aniso_log_scales(eta));
2011 }
2012 let contrasts = initial_aniso_contrasts(centers);
2013 if contrasts.is_empty() {
2014 Some(center_aniso_log_scales(eta))
2015 } else {
2016 Some(center_aniso_log_scales(&contrasts))
2017 }
2018}
2019
2020fn center_aniso_log_scales(eta: &[f64]) -> Vec<f64> {
2021 if eta.len() <= 1 {
2022 return eta.to_vec();
2023 }
2024 let mean = eta.iter().sum::<f64>() / eta.len() as f64;
2025 eta.iter()
2026 .map(|&v| {
2027 let centered = v - mean;
2028 if centered.abs() <= 1e-15 {
2029 0.0
2030 } else {
2031 centered
2032 }
2033 })
2034 .collect()
2035}
2036
2037#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2040pub enum AnisoSeedMode {
2041 AutoSeedFromGeometry,
2051 Literal,
2058}
2059
2060pub(crate) fn resolve_matern_forward_aniso(
2063 mode: AnisoSeedMode,
2064 centers: ArrayView2<'_, f64>,
2065 aniso: Option<&[f64]>,
2066) -> Option<Vec<f64>> {
2067 match mode {
2068 AnisoSeedMode::Literal => centered_aniso_contrasts(aniso),
2069 AnisoSeedMode::AutoSeedFromGeometry => auto_seed_aniso_contrasts(centers, aniso),
2070 }
2071}
2072
2073pub(crate) fn pairwise_distance_bounds(points: ArrayView2<'_, f64>) -> Option<(f64, f64)> {
2074 let n = points.nrows();
2075 let d = points.ncols();
2076 if n < 2 || d == 0 {
2077 return None;
2078 }
2079 let mut r_min = f64::INFINITY;
2080 let mut r_max = 0.0_f64;
2081 for i in 0..n {
2082 for j in (i + 1)..n {
2083 let r = stable_euclidean_norm((0..d).map(|c| points[[i, c]] - points[[j, c]]));
2084 if r.is_finite() && r > 0.0 {
2085 r_min = r_min.min(r);
2086 r_max = r_max.max(r);
2087 }
2088 }
2089 }
2090 if r_min.is_finite() && r_max.is_finite() && r_min > 0.0 && r_max > 0.0 {
2091 Some((r_min, r_max))
2092 } else {
2093 None
2094 }
2095}
2096
2097pub(crate) fn pairwise_distance_bounds_sampled(points: ArrayView2<'_, f64>) -> Option<(f64, f64)> {
2117 const K_CAP: usize = 1024;
2118 let n = points.nrows();
2119 let d = points.ncols();
2120 if n < 2 || d == 0 {
2121 return None;
2122 }
2123 if n <= K_CAP {
2124 return pairwise_distance_bounds(points);
2125 }
2126 let stride = n / K_CAP;
2131 let k = K_CAP; let mut r_min = f64::INFINITY;
2133 let mut r_max = 0.0_f64;
2134 for i_idx in 0..k {
2135 let i = i_idx * stride;
2136 for j_idx in (i_idx + 1)..k {
2137 let j = j_idx * stride;
2138 let r = stable_euclidean_norm((0..d).map(|c| points[[i, c]] - points[[j, c]]));
2139 if r.is_finite() && r > 0.0 {
2140 r_min = r_min.min(r);
2141 r_max = r_max.max(r);
2142 }
2143 }
2144 }
2145 if r_min.is_finite() && r_max.is_finite() && r_min > 0.0 && r_max > 0.0 {
2146 Some((r_min, r_max))
2147 } else {
2148 None
2149 }
2150}
2151
2152#[cfg(test)]
2153mod duchon_hybrid_psd_tests {
2154 use super::*;
2155 use faer::Side;
2156 use gam_linalg::faer_ndarray::FaerEigh;
2157
2158 fn fixture_centers(d: usize, n: usize) -> Array2<f64> {
2164 const BASES: [u64; 24] = [
2165 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83,
2166 89,
2167 ];
2168 let mut centers = Array2::<f64>::zeros((n, d));
2169 for i in 0..n {
2170 for axis in 0..d {
2171 let base = BASES[axis % BASES.len()];
2172 let mut f = 1.0_f64;
2176 let mut idx = (i + 1) as u64;
2177 let mut value = 0.0_f64;
2178 while idx > 0 {
2179 f /= base as f64;
2180 value += f * (idx % base) as f64;
2181 idx /= base;
2182 }
2183 centers[[i, axis]] = 2.0 * value - 1.0;
2184 }
2185 }
2186 centers
2187 }
2188
2189 fn lambda_min(matrix: &Array2<f64>) -> f64 {
2192 let sym = symmetrize_penalty(matrix);
2193 let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).expect("symmetric eigendecomposition");
2194 evals.iter().copied().fold(f64::INFINITY, f64::min)
2195 }
2196
2197 #[test]
2206 fn high_dim_hybrid_penalty_is_numerically_psd_1424() {
2207 let d = 16usize;
2208 let (nullspace_order, default_power) = duchon_cubic_default(d);
2216 assert!(matches!(nullspace_order, DuchonNullspaceOrder::Linear));
2217 assert!(
2218 (default_power - 7.5).abs() < 1e-12,
2219 "cubic-default power for d=16 is 7.5"
2220 );
2221 let power = 7.0_f64;
2222 assert_eq!(duchon_power_to_usize(power), 7);
2223 assert!(duchon_hybrid_stable_integral_applies(
2225 duchon_p_from_nullspace_order(nullspace_order),
2226 duchon_power_to_usize(power),
2227 d,
2228 ));
2229 let length_scale = Some(1.0_f64);
2230 let centers = fixture_centers(d, 4 * d);
2231
2232 let mut cache = BasisCacheContext::default();
2233 let z = kernel_constraint_nullspace(centers.view(), nullspace_order, &mut cache)
2234 .expect("constraint null space");
2235
2236 let omega = duchon_constrained_bending_penalty(
2237 centers.view(),
2238 length_scale,
2239 power,
2240 nullspace_order,
2241 None,
2242 &z,
2243 )
2244 .expect("constrained bending penalty assembles for the hybrid fixture");
2245 let (penalty, _scale) = normalize_penalty(&omega);
2246
2247 let lam_min = lambda_min(&penalty);
2248 assert!(
2249 lam_min >= -1e-10,
2250 "gam#1424: (d=16, m=2, s=7) hybrid penalty is not numerically PSD: \
2251 λ_min={lam_min:.6e} (was ≈ −0.26442 with the cancellation-prone \
2252 partial-fraction kernel)"
2253 );
2254 }
2255
2256 fn phi0_closed_form(p: usize, s: usize, d: usize, kappa: f64) -> f64 {
2268 let half_d = 0.5 * d as f64;
2269 let b = p as f64 + s as f64 - half_d;
2270 (4.0 * std::f64::consts::PI).powf(-half_d) / gamma_lanczos(s as f64)
2271 * gamma_lanczos(b)
2272 * kappa.powf(-2.0 * b)
2273 * gamma_lanczos(half_d - p as f64)
2274 / gamma_lanczos(half_d)
2275 }
2276
2277 #[test]
2284 fn hybrid_collision_diagonal_matches_closed_form_1604() {
2285 for &d in &[1usize, 3, 5] {
2288 for &p in &[1usize, 2, 3] {
2289 for &s in &[1usize, 2, 3, 4] {
2290 if 2 * (p + s) <= d {
2291 continue;
2292 }
2293 for &kappa in &[0.5f64, 1.0, 2.5] {
2294 let coeffs = duchon_partial_fraction_coeffs(p, s, kappa);
2295 let got = duchon_hybrid_kernel_collision_value(
2296 1.0 / kappa,
2297 p,
2298 s,
2299 d,
2300 &coeffs,
2301 )
2302 .expect("collision diagonal");
2303 let want = phi0_closed_form(p, s, d, kappa);
2304 let rel = (got - want).abs() / want.abs().max(1e-300);
2305 assert!(
2306 rel < 1e-10,
2307 "φ(0) mismatch d={d} p={p} s={s} κ={kappa}: got {got:.12e}, want {want:.12e} (rel {rel:.2e})"
2308 );
2309 }
2310 }
2311 }
2312 }
2313 }
2314
2315 #[test]
2322 fn hybrid_near_collision_continuous_with_direct_1604() {
2323 for &d in &[1usize, 3] {
2324 for &p in &[1usize, 2] {
2325 for &s in &[2usize, 3] {
2326 if 2 * (p + s) <= d + 6 {
2327 continue;
2329 }
2330 for &kappa in &[0.5f64, 1.0, 2.0] {
2331 let length_scale = 1.0 / kappa;
2332 let coeffs = duchon_partial_fraction_coeffs(p, s, kappa);
2333 let r = 0.02 * length_scale;
2337 let taylor = duchon_hybrid_kernel_near_collision_value(
2338 r,
2339 length_scale,
2340 p,
2341 s,
2342 d,
2343 &coeffs,
2344 )
2345 .expect("near-collision value");
2346 let mut direct = 0.0f64;
2348 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
2349 if a_m != 0.0 {
2350 direct += a_m * polyharmonic_kernel(r, m as f64, d);
2351 }
2352 }
2353 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
2354 if b_n != 0.0 {
2355 direct +=
2356 b_n * duchon_matern_block(r, kappa, n, d).expect("matern block");
2357 }
2358 }
2359 let rel = (taylor - direct).abs() / direct.abs().max(1e-300);
2360 assert!(
2361 rel < 1e-9,
2362 "near-collision vs direct mismatch d={d} p={p} s={s} κ={kappa} r={r}: \
2363 taylor {taylor:.12e}, direct {direct:.12e} (rel {rel:.2e})"
2364 );
2365 }
2366 }
2367 }
2368 }
2369 }
2370
2371 #[test]
2377 fn d1_hybrid_penalty_is_psd_1604() {
2378 let d = 1usize;
2379 let nullspace_order = DuchonNullspaceOrder::Linear; let centers = fixture_centers(d, 12);
2381 let mut cache = BasisCacheContext::default();
2382 let z = kernel_constraint_nullspace(centers.view(), nullspace_order, &mut cache)
2383 .expect("constraint null space");
2384 for &power in &[2.0f64, 3.0] {
2385 for &length_scale in &[0.5f64, 1.0, 10.0, 100.0] {
2386 let omega = duchon_constrained_bending_penalty(
2387 centers.view(),
2388 Some(length_scale),
2389 power,
2390 nullspace_order,
2391 None,
2392 &z,
2393 )
2394 .unwrap_or_else(|e| {
2395 panic!("d=1 p=2 s={power} ls={length_scale} penalty rejected: {e}")
2396 });
2397 let (penalty, _scale) = normalize_penalty(&omega);
2398 let lam_min = lambda_min(&penalty);
2399 assert!(
2400 lam_min >= -1e-9,
2401 "d=1 p=2 s={power} ls={length_scale}: λ_min={lam_min:.6e} (not PSD)"
2402 );
2403 }
2404 }
2405 }
2406
2407 #[test]
2415 fn low_dim_hybrid_kernel_values_unchanged_1424() {
2416 let d = 2usize;
2417 let p_order = 2usize; let s_order = 2usize;
2419 let kappa = 1.0_f64;
2420 let length_scale = Some(1.0_f64);
2421 assert!(!duchon_hybrid_stable_integral_applies(p_order, s_order, d));
2423 let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
2424
2425 for &r in &[0.25_f64, 0.75, 1.5] {
2426 let mut reference = 0.0_f64;
2430 for (m, &coeff) in coeffs.a.iter().enumerate().skip(1) {
2431 if coeff != 0.0 {
2432 reference += coeff * polyharmonic_kernel(r, m as f64, d);
2433 }
2434 }
2435 for (n, &coeff) in coeffs.b.iter().enumerate().skip(1) {
2436 if coeff != 0.0 {
2437 reference += coeff * duchon_matern_block(r, kappa, n, d).expect("matern block");
2438 }
2439 }
2440
2441 let got = duchon_matern_kernel_general_from_distance(
2442 r,
2443 length_scale,
2444 p_order,
2445 s_order,
2446 d,
2447 Some(&coeffs),
2448 )
2449 .expect("low-d hybrid kernel value");
2450 assert!(
2451 (got - reference).abs() <= 1e-10,
2452 "low-d hybrid kernel value regressed at r={r}: got {got:.15e}, reference {reference:.15e}"
2453 );
2454 }
2455 }
2456}