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 let nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
90 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 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 if let Some(v) = radial_reparam {
170 if v.nrows() == z.ncols() {
171 z = fast_ab(&z, &v);
172 }
173 }
174 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 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 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
427const 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
478const 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#[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
580pub(crate) const RADIAL_PROFILE_MIN_PAIRS: usize = 16_384;
587
588#[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 DuchonNullspaceOrder::Zero => 1,
609 DuchonNullspaceOrder::Linear => 2,
610 DuchonNullspaceOrder::Degree(degree) => degree + 1,
611 }
612}
613
614pub 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 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 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
685pub 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 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
733pub(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 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 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 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 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#[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#[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#[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 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 return Ok(c * 2.0_f64.powf(nu - 1.0) * gamma_lanczos(nu) * kappa.powf(-nu));
1079 }
1080 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
1128pub(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 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
1298pub(crate) struct BesselKLadder {
1316 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 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 #[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
1355pub(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
1444pub(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 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 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 validate_duchon_kernel_orders(length_scale, p_order, s_order, k_dim)?;
1579 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 let mut a = vec![0.0_f64; p_order + 1]; let mut b = vec![0.0_f64; s_order + 1]; if s_order == 0 {
1621 if p_order > 0 {
1622 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 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
1653fn 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 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 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 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 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 let w = 2.0 / ((1.0 - x * x) * dp * dp);
1699 nodes.push((x, w));
1701 if x.abs() > 1e-300 {
1702 nodes.push((-x, w));
1703 }
1704 }
1705 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
1714pub(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 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 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
1789pub(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 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 let a0 = 2.0 * (2.0 * c).powf(-b);
1855 let mut terms: Vec<(f64, f64, i32)> = vec![(a0, b, 0)];
1856 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 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 let inv_r = 1.0 / r;
1910 let q = phi1 * inv_r;
1911 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#[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 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 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 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 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#[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 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#[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 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
2329pub(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
2348pub 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 let sigma = if sigma < 1e-12 { 1.0 } else { sigma };
2367 scales.push(sigma.clamp(1e-6, 1e6));
2368 }
2369 scales
2370}
2371
2372pub 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 scales
2390 .iter()
2391 .map(|&scale| -scale.ln() - mean_neg_log)
2392 .collect()
2393}
2394
2395pub(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
2425pub(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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2483pub enum AnisoSeedMode {
2484 AutoSeedFromGeometry,
2494 Literal,
2501}
2502
2503pub(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
2540pub(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 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 #[test]
2623 fn bessel_k_matches_independent_high_precision_reference() {
2624 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 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 #[test]
2670 fn bessel_k_branch_crossover_has_no_step() {
2671 let delta = 1.0e-13;
2674 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 #[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 #[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 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 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; let q = powf_family_value(r, c, exponent, log, pure);
2810 differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2811 exponent -= 1.0; 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 #[test]
2917 fn pure_duchon_cpd_guard_is_nullspace_degree_independent_issue_2278() {
2918 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 assert!(validate_duchon_kernel_orders(None, 1, 1.0, 2).is_err());
2931 assert!(validate_duchon_kernel_orders(None, 2, 0.5, 2).is_ok());
2935 assert!(validate_duchon_kernel_orders(Some(1.0), 2, 1.0, 2).is_ok());
2938 }
2939
2940 #[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 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 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 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 assert!(
2985 r_min.is_finite() && r_min > 0.0,
2986 "r_min must be positive at n={n}"
2987 );
2988 }
2989 }
2990
2991 #[test]
2996 fn sampled_indices_span_full_range() {
2997 const K_CAP: usize = 1024;
2998 let n = 2000usize; 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 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 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 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 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 #[test]
3067 fn high_dim_hybrid_penalty_is_numerically_psd_1424() {
3068 let d = 16usize;
3069 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 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 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 #[test]
3145 fn hybrid_collision_diagonal_matches_closed_form_1604() {
3146 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 #[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 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 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 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 #[test]
3233 fn d1_hybrid_penalty_is_psd_1604() {
3234 let d = 1usize;
3235 let nullspace_order = DuchonNullspaceOrder::Linear; 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 #[test]
3271 fn low_dim_hybrid_kernel_values_unchanged_1424() {
3272 let d = 2usize;
3273 let p_order = 2usize; let s_order = 2usize;
3275 let kappa = 1.0_f64;
3276 let length_scale = Some(1.0_f64);
3277 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 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 #[test]
3320 fn operator_penalties_auto_raise_order_issue_1817() {
3321 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 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 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 let penalties = build_duchon_operator_penalty_matrices(
3362 centers.view(),
3363 None,
3364 None, 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}