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)]
589pub(crate) fn duchon_p_from_nullspace_order(order: DuchonNullspaceOrder) -> usize {
590 match order {
591 DuchonNullspaceOrder::Zero => 1,
596 DuchonNullspaceOrder::Linear => 2,
597 DuchonNullspaceOrder::Degree(degree) => degree + 1,
598 }
599}
600
601pub(crate) fn duchon_effective_nullspace_order(
611 centers: ArrayView2<'_, f64>,
612 order: DuchonNullspaceOrder,
613) -> DuchonNullspaceOrder {
614 if order == DuchonNullspaceOrder::Zero {
615 return order;
616 }
617 let mut effective = order;
618 while effective != DuchonNullspaceOrder::Zero
619 && centers.nrows() <= polynomial_block_from_order(centers, effective).ncols()
620 {
621 effective = duchon_previous_nullspace_order(effective);
622 }
623 if effective != order {
624 static SEEN: std::sync::OnceLock<
627 std::sync::Mutex<std::collections::HashSet<(usize, usize, DuchonNullspaceOrder)>>,
628 > = std::sync::OnceLock::new();
629 let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
630 let key = (centers.nrows(), centers.ncols(), order);
631 let fresh = seen.lock().map(|mut s| s.insert(key)).unwrap_or(true);
632 if fresh {
633 let requested_cols = polynomial_block_from_order(centers, order).ncols();
634 let effective_cols = polynomial_block_from_order(centers, effective).ncols();
635 log::warn!(
636 "Duchon nullspace order={:?} in dim={} with {} centers leaves no radial kernel columns (polynomial_cols={}); degrading to {:?} (polynomial_cols={})",
637 order,
638 centers.ncols(),
639 centers.nrows(),
640 requested_cols,
641 effective,
642 effective_cols
643 );
644 }
645 }
646 effective
647}
648
649pub(crate) fn duchon_order_for_operator_margin(
672 dim: usize,
673 power: f64,
674 order: DuchonNullspaceOrder,
675 max_operator_derivative_order: usize,
676) -> DuchonNullspaceOrder {
677 let margin = dim as f64 + max_operator_derivative_order as f64;
678 let mut effective = order;
679 while 2.0 * (duchon_p_from_nullspace_order(effective) as f64 + power) <= margin {
682 effective = duchon_next_nullspace_order(effective);
683 }
684 if effective != order {
685 static SEEN: std::sync::OnceLock<
688 std::sync::Mutex<std::collections::HashSet<(usize, u64, DuchonNullspaceOrder, usize)>>,
689 > = std::sync::OnceLock::new();
690 let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
691 let key = (dim, power.to_bits(), order, max_operator_derivative_order);
692 let fresh = seen.lock().map(|mut s| s.insert(key)).unwrap_or(true);
693 if fresh {
694 log::warn!(
695 "Duchon nullspace order={:?} with power={} in dim={} leaves 2*(p+s)={} \
696 below the pointwise/collocation margin dimension+{}={} required by the \
697 active operators; auto-raising to {:?} so the kernel stays well-posed",
698 order,
699 power,
700 dim,
701 2.0 * (duchon_p_from_nullspace_order(order) as f64 + power),
702 max_operator_derivative_order,
703 margin,
704 effective,
705 );
706 }
707 }
708 effective
709}
710
711#[inline(always)]
712pub(crate) fn gamma_lanczos(x: f64) -> f64 {
713 const G: f64 = 7.0;
715 const P: [f64; 9] = [
716 0.999_999_999_999_809_9,
717 676.520_368_121_885_1,
718 -1_259.139_216_722_402_8,
719 771.323_428_777_653_1,
720 -176.615_029_162_140_6,
721 12.507_343_278_686_905,
722 -0.138_571_095_265_720_12,
723 9.984_369_578_019_571e-6,
724 1.505_632_735_149_311_6e-7,
725 ];
726 if x < 0.5 {
727 let pix = std::f64::consts::PI * x;
728 return std::f64::consts::PI / (pix.sin() * gamma_lanczos(1.0 - x));
729 }
730 let z = x - 1.0;
731 let mut a = P[0];
732 for (i, coeff) in P.iter().enumerate().skip(1) {
733 a += coeff / (z + i as f64);
734 }
735 let t = z + G + 0.5;
736 (2.0 * std::f64::consts::PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * a
737}
738
739#[inline(always)]
740pub(crate) fn bessel_k_integer_order(n: usize, z: f64) -> f64 {
741 let zz = z.max(1e-300);
742 if n == 0 {
743 return bessel_k0_stable(zz);
744 }
745 if n == 1 {
746 return bessel_k1_stable(zz);
747 }
748 let mut km1 = bessel_k0_stable(zz);
749 let mut k = bessel_k1_stable(zz);
750 for m in 1..n {
751 let kp1 = km1 + 2.0 * (m as f64) * k / zz;
752 km1 = k;
753 k = kp1;
754 }
755 k
756}
757
758#[inline(always)]
759pub(crate) fn bessel_k_half_integer_order(l: usize, z: f64) -> f64 {
760 let zz = z.max(1e-300);
772 let k_half = (std::f64::consts::PI / (2.0 * zz)).sqrt() * (-zz).exp();
773 if l == 0 {
774 return k_half;
775 }
776 let mut km1 = k_half;
777 let mut k = k_half * (1.0 + 1.0 / zz);
778 for m in 1..l {
779 let nu = m as f64 + 0.5;
780 let kp1 = km1 + 2.0 * nu * k / zz;
781 km1 = k;
782 k = kp1;
783 }
784 k
785}
786
787#[inline(always)]
788pub(crate) fn bessel_k_real_half_integer_or_integer(
789 nu_abs: f64,
790 z: f64,
791) -> Result<f64, BasisError> {
792 let two_nu = (2.0 * nu_abs).round();
793 if (two_nu - 2.0 * nu_abs).abs() > 1e-12 {
794 crate::bail_invalid_basis!(
795 "unsupported Bessel-K order ν={nu_abs}; only integer/half-integer orders are supported"
796 );
797 }
798 let two_nu_i = two_nu as i64;
799 if two_nu_i % 2 == 0 {
800 let n = (two_nu_i / 2).max(0) as usize;
801 Ok(bessel_k_integer_order(n, z))
802 } else {
803 let l = ((two_nu_i - 1) / 2).max(0) as usize;
804 Ok(bessel_k_half_integer_order(l, z))
805 }
806}
807
808#[inline(always)]
816fn exact_i32_exponent(exponent: f64) -> Option<i32> {
817 if !exponent.is_finite() {
818 return None;
819 }
820 let integral = exponent as i32;
821 (integral as f64 == exponent).then_some(integral)
822}
823
824#[inline(always)]
829fn positive_base_half_integer_power(base: f64, twice_exponent: i32) -> f64 {
830 if twice_exponent % 2 == 0 {
831 return base.powi(twice_exponent / 2);
832 }
833 let integer_part = twice_exponent / 2;
834 if twice_exponent > 0 {
835 base.powi(integer_part) * base.sqrt()
836 } else {
837 base.powi(integer_part) / base.sqrt()
838 }
839}
840
841#[inline(always)]
842fn positive_base_power_integral_or_half(base: f64, exponent: f64) -> f64 {
843 exact_i32_exponent(2.0 * exponent).map_or_else(
844 || base.powf(exponent),
845 |twice| positive_base_half_integer_power(base, twice),
846 )
847}
848
849#[derive(Clone, Copy)]
853pub(crate) struct PolyharmonicBlockCoeff {
854 pub(crate) c: f64,
855 pub(crate) power: f64,
856 power_i32: Option<i32>,
857 pub(crate) is_log_case: bool,
858}
859
860impl PolyharmonicBlockCoeff {
861 pub(crate) fn new(m: f64, k_dim: usize) -> Self {
862 assert!(
863 m.is_finite() && m > 0.0,
864 "PolyharmonicBlockCoeff::new: m must be finite and > 0, got {m}"
865 );
866 let k_half = 0.5 * k_dim as f64;
867 let power = 2.0 * m - k_dim as f64;
868 const LOG_EPS: f64 = 1e-12;
872 let two_m = 2.0 * m;
873 let is_log_case = k_dim.is_multiple_of(2) && {
874 let n_f = (power / 2.0).round();
875 n_f >= 0.0 && (n_f * 2.0 - power).abs() < LOG_EPS
876 };
877 if is_log_case {
878 let m_int = m.round() as i64;
879 let m_minus_half_d_plus_one = (m - k_half + 1.0).round() as i64;
880 let c = polyharmonic_log_sign(m_int as usize, k_dim)
881 / (2.0_f64.powi((two_m.round() as i32) - 1)
882 * positive_base_power_integral_or_half(std::f64::consts::PI, k_half)
883 * gamma_lanczos(m)
884 * gamma_lanczos(m_minus_half_d_plus_one as f64));
885 Self {
886 c,
887 power,
888 power_i32: exact_i32_exponent(power),
889 is_log_case: true,
890 }
891 } else {
892 let c = gamma_lanczos(k_half - m)
893 / (positive_base_power_integral_or_half(4.0, m)
894 * positive_base_power_integral_or_half(std::f64::consts::PI, k_half)
895 * gamma_lanczos(m));
896 Self {
897 c,
898 power,
899 power_i32: exact_i32_exponent(power),
900 is_log_case: false,
901 }
902 }
903 }
904
905 #[inline(always)]
906 pub(crate) fn eval(&self, r: f64) -> f64 {
907 if r <= 0.0 {
908 return self.origin_limit();
909 }
910 let radial_power = self
911 .power_i32
912 .map_or_else(|| r.powf(self.power), |power| r.powi(power));
913 if self.is_log_case {
914 self.c * radial_power * r.max(1e-300).ln()
915 } else {
916 self.c * radial_power
917 }
918 }
919
920 #[inline(always)]
921 pub(crate) fn origin_limit(&self) -> f64 {
922 if self.is_log_case {
923 log_power_origin_limit(self.c, self.power, 1.0, 0.0)
924 } else {
925 log_power_origin_limit(self.c, self.power, 0.0, 1.0)
926 }
927 }
928}
929
930pub(crate) fn polyharmonic_kernel(r: f64, m: f64, k_dim: usize) -> f64 {
931 PolyharmonicBlockCoeff::new(m, k_dim).eval(r)
932}
933
934#[inline(always)]
935pub(crate) fn signed_infinity(sign: f64) -> f64 {
936 if sign.is_sign_negative() {
937 f64::NEG_INFINITY
938 } else {
939 f64::INFINITY
940 }
941}
942
943#[inline(always)]
944pub(crate) fn log_power_origin_limit(
945 coeff: f64,
946 exponent: f64,
947 log_coeff: f64,
948 pure_coeff: f64,
949) -> f64 {
950 if log_coeff == 0.0 && pure_coeff == 0.0 {
951 return 0.0;
952 }
953 if exponent > 0.0 {
954 return 0.0;
955 }
956 if exponent == 0.0 {
957 if log_coeff != 0.0 {
958 signed_infinity(-coeff * log_coeff)
959 } else {
960 coeff * pure_coeff
961 }
962 } else if log_coeff != 0.0 {
963 signed_infinity(-coeff * log_coeff)
964 } else {
965 signed_infinity(coeff * pure_coeff)
966 }
967}
968
969#[inline(always)]
970pub(crate) fn polyharmonic_log_sign(m: usize, k_dim: usize) -> f64 {
971 assert!(
972 k_dim.is_multiple_of(2),
973 "polyharmonic_log_sign requires even kernel dimension: k_dim={k_dim}, m={m}"
974 );
975 (-1.0_f64).powi(m as i32 - (k_dim as i32 / 2) + 1)
976}
977
978#[inline(always)]
979pub(crate) fn duchon_matern_block(
980 r: f64,
981 kappa: f64,
982 n_order: usize,
983 k_dim: usize,
984) -> Result<f64, BasisError> {
985 let n = n_order as f64;
986 let k_half = 0.5 * k_dim as f64;
987 let nu = n - k_half;
988 let nu_abs = nu.abs();
989 let c = kappa.powf(k_half - n)
990 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
991 if r <= 0.0 {
992 if nu > 0.0 {
993 return Ok(c * 2.0_f64.powf(nu - 1.0) * gamma_lanczos(nu) * kappa.powf(-nu));
995 }
996 crate::bail_invalid_basis!(
1002 "Duchon Matérn block at r=0 with ν={nu} ≤ 0 is divergent; \
1003 evaluate the hybrid kernel diagonal via the collision routine"
1004 );
1005 }
1006 let z = (kappa * r).max(1e-300);
1007 let k_nu = bessel_k_real_half_integer_or_integer(nu_abs, z)?;
1008 Ok(c * r.powf(nu) * k_nu)
1009}
1010
1011#[inline(always)]
1012pub(crate) fn polyharmonic_kernel_triplet(
1013 r: f64,
1014 m: f64,
1015 k_dim: usize,
1016) -> Result<(f64, f64, f64), BasisError> {
1017 let (value, first, second, _, _) = polyharmonic_block_jet4(r, m, k_dim)?;
1018 Ok((value, first, second))
1019}
1020
1021#[inline(always)]
1022pub(crate) fn falling_factorial(alpha: f64, order: usize) -> f64 {
1023 (0..order).fold(1.0, |acc, idx| acc * (alpha - idx as f64))
1024}
1025
1026#[inline(always)]
1027pub(crate) fn falling_factorial_derivative(alpha: f64, order: usize) -> f64 {
1028 if order == 0 {
1029 return 0.0;
1030 }
1031 let mut total = 0.0;
1032 for omit in 0..order {
1033 let mut term = 1.0;
1034 for idx in 0..order {
1035 if idx != omit {
1036 term *= alpha - idx as f64;
1037 }
1038 }
1039 total += term;
1040 }
1041 total
1042}
1043
1044pub(crate) fn polyharmonic_block_jet4(
1051 r: f64,
1052 m: f64,
1053 k_dim: usize,
1054) -> Result<(f64, f64, f64, f64, f64), BasisError> {
1055 if !r.is_finite() || r < 0.0 {
1056 crate::bail_invalid_basis!("polyharmonic distance must be finite and non-negative");
1057 }
1058 assert!(
1059 m.is_finite() && m > 0.0,
1060 "polyharmonic_block_jet4: m must be finite and > 0, got {m}"
1061 );
1062
1063 let k_half = 0.5 * k_dim as f64;
1064 let alpha = 2.0 * m - k_dim as f64;
1065 let alpha_i32 = exact_i32_exponent(alpha);
1066 const LOG_EPS: f64 = 1e-12;
1069 let is_log_case = k_dim.is_multiple_of(2) && {
1070 let n_f = (alpha / 2.0).round();
1071 n_f >= 0.0 && (n_f * 2.0 - alpha).abs() < LOG_EPS
1072 };
1073 if is_log_case {
1074 let m_int = m.round() as usize;
1075 let c = polyharmonic_log_sign(m_int, k_dim)
1076 / (2.0_f64.powi((2 * m_int - 1) as i32)
1077 * positive_base_power_integral_or_half(std::f64::consts::PI, k_half)
1078 * gamma_lanczos(m)
1079 * gamma_lanczos((m_int - k_dim / 2 + 1) as f64));
1080 let mut out = [0.0; 5];
1081 let log_r = (r > 0.0).then(|| r.ln());
1082 for d in 0..5 {
1083 let e = alpha - d as f64;
1084 let ff = falling_factorial(alpha, d);
1085 let ff_d = falling_factorial_derivative(alpha, d);
1086 out[d] = if r <= 0.0 {
1087 log_power_origin_limit(c, e, ff, ff_d)
1088 } else {
1089 let radial_power =
1090 alpha_i32.map_or_else(|| r.powf(e), |integral| r.powi(integral - d as i32));
1091 c * radial_power * (ff * log_r.expect("positive radius has a logarithm") + ff_d)
1092 };
1093 }
1094 return Ok((out[0], out[1], out[2], out[3], out[4]));
1095 }
1096
1097 let c = gamma_lanczos(k_half - m)
1098 / (positive_base_power_integral_or_half(4.0, m)
1099 * positive_base_power_integral_or_half(std::f64::consts::PI, k_half)
1100 * gamma_lanczos(m));
1101 let mut out = [0.0; 5];
1102 for d in 0..5 {
1103 let e = alpha - d as f64;
1104 let ff = falling_factorial(alpha, d);
1105 out[d] = if r <= 0.0 {
1106 log_power_origin_limit(c, e, 0.0, ff)
1107 } else {
1108 let radial_power =
1109 alpha_i32.map_or_else(|| r.powf(e), |integral| r.powi(integral - d as i32));
1110 c * ff * radial_power
1111 };
1112 }
1113 Ok((out[0], out[1], out[2], out[3], out[4]))
1114}
1115
1116#[inline(always)]
1117pub(crate) fn log_power_family_derivative(
1118 exponent: i32,
1119 log_coeff: f64,
1120 pure_coeff: f64,
1121) -> (i32, f64, f64) {
1122 let exponent_f64 = exponent as f64;
1123 (
1124 exponent - 1,
1125 exponent_f64 * log_coeff,
1126 exponent_f64 * pure_coeff + log_coeff,
1127 )
1128}
1129
1130#[inline(always)]
1131pub(crate) fn log_power_family_value(
1132 r: f64,
1133 coeff: f64,
1134 exponent: i32,
1135 log_coeff: f64,
1136 pure_coeff: f64,
1137) -> f64 {
1138 if r <= 0.0 {
1139 log_power_origin_limit(coeff, exponent as f64, log_coeff, pure_coeff)
1140 } else {
1141 coeff * r.powi(exponent) * (log_coeff * r.ln() + pure_coeff)
1142 }
1143}
1144
1145#[inline(always)]
1146pub(crate) fn duchon_polyharmonic_operator_block_jets(
1147 r: f64,
1148 m: usize,
1149 k_dim: usize,
1150) -> Result<(f64, f64, f64, f64), BasisError> {
1151 if !r.is_finite() || r < 0.0 {
1152 crate::bail_invalid_basis!("polyharmonic distance must be finite and non-negative");
1153 }
1154 assert!(
1155 m > 0,
1156 "duchon_polyharmonic_operator_block_jets: m must be > 0, got {m}"
1157 );
1158
1159 let Ok(m_i32) = i32::try_from(m) else {
1160 crate::bail_invalid_basis!("polyharmonic order {m} exceeds the supported i32 range");
1161 };
1162 let Ok(k_dim_i32) = i32::try_from(k_dim) else {
1163 crate::bail_invalid_basis!("Duchon dimension {k_dim} exceeds the supported i32 range");
1164 };
1165 let Some(alpha) = m_i32
1166 .checked_mul(2)
1167 .and_then(|twice_m| twice_m.checked_sub(k_dim_i32))
1168 else {
1169 crate::bail_invalid_basis!("Duchon exponent 2*{m}-{k_dim} exceeds the supported i32 range");
1170 };
1171 let m_f64 = m as f64;
1172 let k_half = 0.5 * k_dim as f64;
1173 let is_log_case = k_dim.is_multiple_of(2) && alpha >= 0;
1174 let (c, phi_log_coeff, phi_pure_coeff) = if is_log_case {
1175 (
1176 polyharmonic_log_sign(m, k_dim)
1177 / (2.0_f64.powi(2 * m_i32 - 1)
1178 * positive_base_half_integer_power(std::f64::consts::PI, k_dim_i32)
1179 * gamma_lanczos(m_f64)
1180 * gamma_lanczos((m - k_dim / 2 + 1) as f64)),
1181 1.0,
1182 0.0,
1183 )
1184 } else {
1185 (
1186 gamma_lanczos(k_half - m_f64)
1187 / (4.0_f64.powi(m_i32)
1188 * positive_base_half_integer_power(std::f64::consts::PI, k_dim_i32)
1189 * gamma_lanczos(m_f64)),
1190 0.0,
1191 1.0,
1192 )
1193 };
1194
1195 let (phi_r_exp, phi_r_log, phi_r_pure) =
1196 log_power_family_derivative(alpha, phi_log_coeff, phi_pure_coeff);
1197 let q_exp = phi_r_exp - 1;
1198 let q = log_power_family_value(r, c, q_exp, phi_r_log, phi_r_pure);
1199
1200 let (q_r_exp_raw, q_r_log, q_r_pure) =
1201 log_power_family_derivative(q_exp, phi_r_log, phi_r_pure);
1202 let t_exp = q_r_exp_raw - 1;
1203 let t = log_power_family_value(r, c, t_exp, q_r_log, q_r_pure);
1204
1205 let (t_r_exp, t_r_log, t_r_pure) = log_power_family_derivative(t_exp, q_r_log, q_r_pure);
1206 let t_r = log_power_family_value(r, c, t_r_exp, t_r_log, t_r_pure);
1207
1208 let (t_rr_exp, t_rr_log, t_rr_pure) = log_power_family_derivative(t_r_exp, t_r_log, t_r_pure);
1209 let t_rr = log_power_family_value(r, c, t_rr_exp, t_rr_log, t_rr_pure);
1210
1211 Ok((q, t, t_r, t_rr))
1212}
1213
1214pub(crate) struct BesselKLadder {
1232 pub(crate) values: SmallVec<[f64; 16]>,
1234 pub(crate) half_integer: bool,
1235}
1236
1237impl BesselKLadder {
1238 pub(crate) fn build(z: f64, half_integer: bool, max_order_steps: usize) -> Self {
1239 let zz = z.max(1e-300);
1240 let mut values: SmallVec<[f64; 16]> = SmallVec::with_capacity(max_order_steps + 2);
1241 if half_integer {
1242 let k_half = (std::f64::consts::PI / (2.0 * zz)).sqrt() * (-zz).exp();
1244 values.push(k_half);
1245 values.push(k_half * (1.0 + 1.0 / zz));
1246 } else {
1247 values.push(bessel_k0_stable(zz));
1248 values.push(bessel_k1_stable(zz));
1249 }
1250 let base = if half_integer { 0.5 } else { 0.0 };
1251 for i in 1..max_order_steps {
1252 let nu = base + i as f64;
1253 let next = values[i - 1] + 2.0 * nu * values[i] / zz;
1254 values.push(next);
1255 }
1256 Self {
1257 values,
1258 half_integer,
1259 }
1260 }
1261
1262 #[inline]
1264 pub(crate) fn k_abs(&self, order_abs: f64) -> f64 {
1265 let base = if self.half_integer { 0.5 } else { 0.0 };
1266 let idx = (order_abs - base).round() as usize;
1267 self.values[idx]
1268 }
1269}
1270
1271pub(crate) fn duchon_matern_family_jets_with_ladder(
1293 r: f64,
1294 kappa: f64,
1295 coeff: f64,
1296 mu: f64,
1297 max_j: usize,
1298 ladder: &BesselKLadder,
1299 out: &mut [f64],
1300) -> Result<(), BasisError> {
1301 if max_j > 4 || out.len() <= max_j {
1302 crate::bail_invalid_basis!(
1303 "Duchon Matérn-family ladder jets support derivative orders 0..=4 with an output slot per order"
1304 );
1305 }
1306 if r <= 0.0 {
1307 out[..=max_j].fill(0.0);
1308 if mu > 0.0 {
1309 out[0] = coeff * 2.0_f64.powf(mu - 1.0) * gamma_lanczos(mu) * kappa.powf(-mu);
1310 }
1311 return Ok(());
1312 }
1313 let mut terms: SmallVec<[DuchonMaternDerivativeTerm; 16]> =
1314 smallvec![DuchonMaternDerivativeTerm {
1315 coeff,
1316 kappa_power: 0,
1317 r_power: mu,
1318 bessel_order: mu,
1319 }];
1320 for (j, slot) in out.iter_mut().enumerate().take(max_j + 1) {
1321 if j > 0 {
1322 let mut next: SmallVec<[DuchonMaternDerivativeTerm; 16]> =
1323 SmallVec::with_capacity(terms.len() * 2);
1324 for term in &terms {
1325 let stay_coeff = term.coeff * (term.r_power - term.bessel_order);
1326 if stay_coeff != 0.0 {
1327 next.push(DuchonMaternDerivativeTerm {
1328 coeff: stay_coeff,
1329 kappa_power: term.kappa_power,
1330 r_power: term.r_power - 1.0,
1331 bessel_order: term.bessel_order,
1332 });
1333 }
1334 next.push(DuchonMaternDerivativeTerm {
1335 coeff: -term.coeff,
1336 kappa_power: term.kappa_power + 1,
1337 r_power: term.r_power,
1338 bessel_order: term.bessel_order - 1.0,
1339 });
1340 }
1341 terms = next;
1342 }
1343 let mut value = KahanSum::default();
1344 for term in &terms {
1345 if term.coeff == 0.0 {
1346 continue;
1347 }
1348 value.add(
1349 term.coeff
1350 * kappa.powi(term.kappa_power as i32)
1351 * r.powf(term.r_power)
1352 * ladder.k_abs(term.bessel_order.abs()),
1353 );
1354 }
1355 *slot = value.sum();
1356 }
1357 Ok(())
1358}
1359
1360pub(crate) fn duchon_matern_block_max_ladder_steps(n_order: usize, k_dim: usize) -> usize {
1364 let nu = n_order as f64 - 0.5 * k_dim as f64;
1365 let candidates = [
1366 (nu - 1.0).abs(),
1367 (nu - 2.0).abs(),
1368 (nu - 3.0).abs(),
1369 (nu - 4.0).abs(),
1370 ];
1371 let max_abs = candidates.iter().copied().fold(0.0_f64, f64::max);
1372 max_abs.floor() as usize + 1
1373}
1374
1375pub(crate) fn duchon_matern_operator_block_jets_with_ladder(
1376 r: f64,
1377 kappa: f64,
1378 n_order: usize,
1379 k_dim: usize,
1380 ladder: &BesselKLadder,
1381) -> Result<(f64, f64, f64, f64), BasisError> {
1382 if r <= 0.0 {
1383 return Ok((0.0, 0.0, 0.0, 0.0));
1384 }
1385 let n = n_order as f64;
1386 let k_half = 0.5 * k_dim as f64;
1387 let nu = n - k_half;
1388 let c = kappa.powf(k_half - n)
1389 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
1390
1391 let mut q_out = [0.0_f64; 1];
1392 duchon_matern_family_jets_with_ladder(r, kappa, -c * kappa, nu - 1.0, 0, ladder, &mut q_out)?;
1393 let mut t_out = [0.0_f64; 3];
1394 duchon_matern_family_jets_with_ladder(
1395 r,
1396 kappa,
1397 c * kappa * kappa,
1398 nu - 2.0,
1399 2,
1400 ladder,
1401 &mut t_out,
1402 )?;
1403 Ok((q_out[0], t_out[0], t_out[1], t_out[2]))
1404}
1405
1406#[inline(always)]
1407pub(crate) fn pure_duchon_block_order(p_order: usize, s_order: f64) -> f64 {
1408 p_order as f64 + s_order
1409}
1410
1411pub(crate) fn validate_duchon_kernel_orders(
1412 length_scale: Option<f64>,
1413 p_order: usize,
1414 s_order: f64,
1415 k_dim: usize,
1416) -> Result<(), BasisError> {
1417 if k_dim == 0 {
1418 crate::bail_invalid_basis!("Duchon basis requires at least one covariate dimension");
1419 }
1420 if let Some(scale) = length_scale
1421 && (!scale.is_finite() || scale <= 0.0)
1422 {
1423 crate::bail_invalid_basis!("Duchon hybrid length_scale must be finite and positive");
1424 }
1425 if !s_order.is_finite() || s_order < 0.0 {
1464 crate::bail_invalid_basis!("Duchon spectral power must be finite and ≥ 0; got s={s_order}");
1465 }
1466 if length_scale.is_none() && 2.0 * s_order >= k_dim as f64 {
1467 crate::bail_invalid_basis!(
1473 "pure Duchon requires spectral power < dimension/2 (2s < d), independent of nullspace degree; got power={s_order}, dimension={k_dim}"
1474 );
1475 }
1476 let spectral_order = 2.0 * (p_order as f64 + s_order);
1477 if spectral_order <= k_dim as f64 {
1478 crate::bail_invalid_basis!(
1479 "Duchon pointwise kernel values require 2*(p+s) > dimension; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1480 );
1481 }
1482 Ok(())
1483}
1484
1485pub(crate) fn validate_duchon_collocation_orders(
1486 length_scale: Option<f64>,
1487 p_order: usize,
1488 s_order: f64,
1489 k_dim: usize,
1490 max_operator_derivative_order: usize,
1491) -> Result<(), BasisError> {
1492 validate_duchon_kernel_orders(length_scale, p_order, s_order, k_dim)?;
1495 let spectral_order = 2.0 * (p_order as f64 + s_order);
1508 if max_operator_derivative_order >= 1 && spectral_order <= k_dim as f64 + 1.0 {
1509 crate::bail_invalid_basis!(
1510 "Duchon D1 collocation requires 2*(p+s) > dimension+1; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1511 );
1512 }
1513 if max_operator_derivative_order >= 2 && spectral_order <= k_dim as f64 + 2.0 {
1514 crate::bail_invalid_basis!(
1515 "Duchon D2 collocation requires 2*(p+s) > dimension+2; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1516 );
1517 }
1518 Ok(())
1519}
1520
1521#[derive(Debug, Clone)]
1522pub struct DuchonPartialFractionCoeffs {
1523 pub(crate) a: Vec<f64>,
1524 pub(crate) b: Vec<f64>,
1525}
1526
1527#[inline(always)]
1528pub(crate) fn duchon_partial_fraction_coeffs(
1529 p_order: usize,
1530 s_order: usize,
1531 kappa: f64,
1532) -> DuchonPartialFractionCoeffs {
1533 let mut a = vec![0.0_f64; p_order + 1]; let mut b = vec![0.0_f64; s_order + 1]; if s_order == 0 {
1537 if p_order > 0 {
1538 a[p_order] = 1.0;
1541 }
1542 return DuchonPartialFractionCoeffs { a, b };
1543 }
1544 for m in 1..=p_order {
1545 let sign = if (p_order - m).is_multiple_of(2) {
1546 1.0
1547 } else {
1548 -1.0
1549 };
1550 let expo = -2.0 * (s_order + p_order - m) as f64;
1551 let comb = binomial_f64(s_order + p_order - m - 1, p_order - m);
1552 a[m] = sign * kappa.powf(expo) * comb;
1553 }
1554 for n in 1..=s_order {
1555 let sign = if p_order.is_multiple_of(2) { 1.0 } else { -1.0 };
1556 let expo = -2.0 * (p_order + s_order - n) as f64;
1557 let comb = if p_order == 0 && n == s_order {
1558 1.0
1560 } else {
1561 let top = p_order + s_order - n - 1;
1562 binomial_f64(top, s_order - n)
1563 };
1564 b[n] = sign * kappa.powf(expo) * comb;
1565 }
1566 DuchonPartialFractionCoeffs { a, b }
1567}
1568
1569fn gauss_legendre_01_64() -> &'static [(f64, f64)] {
1578 use std::sync::OnceLock;
1579 static NODES: OnceLock<Vec<(f64, f64)>> = OnceLock::new();
1580 NODES.get_or_init(|| {
1581 const N: usize = 64;
1587 let nf = N as f64;
1588 let mut nodes: Vec<(f64, f64)> = Vec::with_capacity(N);
1589 let half = N.div_ceil(2);
1590 for i in 0..half {
1591 let mut x = (std::f64::consts::PI * (i as f64 + 0.75) / (nf + 0.5)).cos();
1593 let mut dp = 0.0_f64;
1594 for _ in 0..100 {
1595 let mut p0 = 1.0_f64;
1598 let mut p1 = x;
1599 for k in 2..=N {
1600 let kf = k as f64;
1601 let p2 = ((2.0 * kf - 1.0) * x * p1 - (kf - 1.0) * p0) / kf;
1602 p0 = p1;
1603 p1 = p2;
1604 }
1605 dp = nf * (x * p1 - p0) / (x * x - 1.0);
1607 let dx = p1 / dp;
1608 x -= dx;
1609 if dx.abs() <= 1e-16 * x.abs().max(1.0) {
1610 break;
1611 }
1612 }
1613 let w = 2.0 / ((1.0 - x * x) * dp * dp);
1615 nodes.push((x, w));
1617 if x.abs() > 1e-300 {
1618 nodes.push((-x, w));
1619 }
1620 }
1621 nodes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
1623 nodes
1624 .into_iter()
1625 .map(|(x, w)| (0.5 * (x + 1.0), 0.5 * w))
1626 .collect()
1627 })
1628}
1629
1630pub(crate) fn duchon_hybrid_kernel_stable_integral(
1660 r: f64,
1661 kappa: f64,
1662 p_order: usize,
1663 s_order: usize,
1664 k_dim: usize,
1665) -> Result<f64, BasisError> {
1666 assert!(
1667 duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
1668 "duchon_hybrid_kernel_stable_integral precondition violated: 2(p+s) > d and 2p < d required (p={p_order}, s={s_order}, d={k_dim})"
1669 );
1670 let p = p_order as f64;
1671 let s = s_order as f64;
1672 let half_d = 0.5 * k_dim as f64;
1673 let b = p + s - half_d;
1674 let pref = (4.0 * std::f64::consts::PI).powf(-half_d) / (gamma_lanczos(p) * gamma_lanczos(s));
1675 if r == 0.0 {
1676 let beta = gamma_lanczos(s - b) * gamma_lanczos(p) / gamma_lanczos(s - b + p);
1678 let value = pref * gamma_lanczos(b) * kappa.powf(-2.0 * b) * beta;
1679 if !value.is_finite() {
1680 crate::bail_invalid_basis!(
1681 "non-finite Duchon hybrid diagonal (stable form) for p={p_order}, s={s_order}, d={k_dim}"
1682 );
1683 }
1684 return Ok(value);
1685 }
1686 let mut acc = KahanSum::default();
1687 for &(w, weight) in gauss_legendre_01_64() {
1688 let sqrt_w = w.sqrt();
1690 let z = (kappa * r * sqrt_w).max(1e-300);
1691 let k_b = bessel_k_real_half_integer_or_integer(b.abs(), z)?;
1692 let smooth = 2.0 * (r / (2.0 * kappa * sqrt_w)).powf(b) * k_b;
1693 let factor = (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * smooth;
1694 acc.add(weight * factor);
1695 }
1696 let value = pref * acc.sum();
1697 if !value.is_finite() {
1698 crate::bail_invalid_basis!(
1699 "non-finite Duchon hybrid value (stable form) at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1700 );
1701 }
1702 Ok(value)
1703}
1704
1705pub(crate) fn duchon_hybrid_operator_stable_integral(
1732 r: f64,
1733 kappa: f64,
1734 p_order: usize,
1735 s_order: usize,
1736 k_dim: usize,
1737) -> Result<DuchonRegularizedOperatorCore, BasisError> {
1738 assert!(
1739 duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
1740 "duchon_hybrid_operator_stable_integral precondition violated: 2(p+s) > d and 2p < d required (p={p_order}, s={s_order}, d={k_dim})"
1741 );
1742 assert!(
1743 r > 0.0 && r.is_finite(),
1744 "duchon_hybrid_operator_stable_integral requires r > 0, got r={r}"
1745 );
1746 let p = p_order as f64;
1747 let s = s_order as f64;
1748 let half_d = 0.5 * k_dim as f64;
1749 let b = p + s - half_d;
1750 let pref = (4.0 * std::f64::consts::PI).powf(-half_d) / (gamma_lanczos(p) * gamma_lanczos(s));
1751
1752 let mut d1 = KahanSum::default();
1755 let mut d2 = KahanSum::default();
1756 let mut d3 = KahanSum::default();
1757 let mut d4 = KahanSum::default();
1758
1759 for &(w, weight) in gauss_legendre_01_64() {
1760 let sqrt_w = w.sqrt();
1761 let c = (kappa * sqrt_w).max(1e-300);
1762 let z = (c * r).max(1e-300);
1763
1764 let a0 = 2.0 * (2.0 * c).powf(-b);
1771 let mut terms: Vec<(f64, f64, i32)> = vec![(a0, b, 0)];
1772 let bessel = |j: i32| -> Result<f64, BasisError> {
1774 bessel_k_real_half_integer_or_integer((b + j as f64).abs(), z)
1775 };
1776 let evaluate = |terms: &Vec<(f64, f64, i32)>| -> Result<f64, BasisError> {
1777 let mut acc = KahanSum::default();
1778 for &(c0, a, j) in terms {
1779 if c0 == 0.0 {
1780 continue;
1781 }
1782 acc.add(c0 * r.powf(a) * bessel(j)?);
1783 }
1784 Ok(acc.sum())
1785 };
1786
1787 let mut slice_derivs = [0.0_f64; 4];
1788 for slot in slice_derivs.iter_mut() {
1789 let mut next: Vec<(f64, f64, i32)> = Vec::with_capacity(terms.len() * 3);
1791 for &(c0, a, j) in &terms {
1792 if c0 == 0.0 {
1793 continue;
1794 }
1795 if a != 0.0 {
1796 next.push((c0 * a, a - 1.0, j));
1797 }
1798 let half = -c0 * c * 0.5;
1799 next.push((half, a, j - 1));
1800 next.push((half, a, j + 1));
1801 }
1802 terms = next;
1803 *slot = evaluate(&terms)?;
1804 }
1805
1806 d1.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[0]);
1807 d2.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[1]);
1808 d3.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[2]);
1809 d4.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[3]);
1810 }
1811
1812 let phi1 = pref * d1.sum();
1813 let phi2 = pref * d2.sum();
1814 let phi3 = pref * d3.sum();
1815 let phi4 = pref * d4.sum();
1816 if !(phi1.is_finite() && phi2.is_finite() && phi3.is_finite() && phi4.is_finite()) {
1817 crate::bail_invalid_basis!(
1818 "non-finite Duchon hybrid operator (stable form) at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1819 );
1820 }
1821
1822 let inv_r = 1.0 / r;
1826 let q = phi1 * inv_r;
1827 let q_r = phi2 * inv_r - phi1 * inv_r * inv_r;
1830 let q_rr = phi3 * inv_r - 2.0 * phi2 * inv_r * inv_r + 2.0 * phi1 * inv_r * inv_r * inv_r;
1831 let q_rrr = phi4 * inv_r - 3.0 * phi3 * inv_r * inv_r + 6.0 * phi2 * inv_r * inv_r * inv_r
1832 - 6.0 * phi1 * inv_r * inv_r * inv_r * inv_r;
1833 let t = q_r * inv_r;
1834 let t_r = q_rr * inv_r - q_r * inv_r * inv_r;
1835 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;
1836
1837 Ok(DuchonRegularizedOperatorCore { q, t, t_r, t_rr })
1838}
1839
1840#[inline]
1849pub(crate) fn duchon_hybrid_stable_integral_applies(
1850 p_order: usize,
1851 s_order: usize,
1852 k_dim: usize,
1853) -> bool {
1854 s_order >= 1 && 2 * p_order < k_dim
1855}
1856
1857pub(crate) fn duchon_matern_kernel_general_from_distance(
1858 r: f64,
1859 length_scale: Option<f64>,
1860 p_order: usize,
1861 s_order: usize,
1862 k_dim: usize,
1863 coeffs: Option<&DuchonPartialFractionCoeffs>,
1864) -> Result<f64, BasisError> {
1865 if !r.is_finite() || r < 0.0 {
1866 crate::bail_invalid_basis!("Duchon kernel distance must be finite and non-negative");
1867 }
1868 let Some(length_scale) = length_scale else {
1869 return Ok(polyharmonic_kernel(
1870 r,
1871 pure_duchon_block_order(p_order, s_order as f64),
1872 k_dim,
1873 ));
1874 };
1875 if !length_scale.is_finite() || length_scale <= 0.0 {
1876 crate::bail_invalid_basis!("Duchon hybrid length_scale must be finite and positive");
1877 }
1878 let kappa = 1.0 / length_scale;
1879
1880 if duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim) {
1888 return duchon_hybrid_kernel_stable_integral(r, kappa, p_order, s_order, k_dim);
1889 }
1890
1891 let coeffs_local;
1892 let coeffs_ref = if let Some(c) = coeffs {
1893 c
1894 } else {
1895 coeffs_local = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
1896 &coeffs_local
1897 };
1898 let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale.max(1e-8);
1899 let kernel_finite_at_origin = 2 * (p_order + s_order) > k_dim;
1907 if r <= collision_taylor_radius && kernel_finite_at_origin {
1908 return duchon_hybrid_kernel_near_collision_value(
1909 r,
1910 length_scale,
1911 p_order,
1912 s_order,
1913 k_dim,
1914 coeffs_ref,
1915 );
1916 }
1917 let mut val = KahanSum::default();
1918 for (m, coeff) in coeffs_ref.a.iter().enumerate().skip(1) {
1919 if *coeff == 0.0 {
1920 continue;
1921 }
1922 val.add(coeff * polyharmonic_kernel(r, (m) as f64, k_dim));
1923 }
1924 for (n, coeff) in coeffs_ref.b.iter().enumerate().skip(1) {
1925 if *coeff == 0.0 {
1926 continue;
1927 }
1928 val.add(coeff * duchon_matern_block(r, kappa, n, k_dim)?);
1929 }
1930 Ok(val.sum())
1931}
1932
1933pub(crate) fn duchon_hybrid_kernel_collision_value(
1934 length_scale: f64,
1935 p_order: usize,
1936 s_order: usize,
1937 k_dim: usize,
1938 coeffs: &DuchonPartialFractionCoeffs,
1939) -> Result<f64, BasisError> {
1940 let spectral_order = 2 * (p_order + s_order);
1941 if spectral_order <= k_dim {
1942 crate::bail_invalid_basis!(
1943 "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}"
1944 );
1945 }
1946
1947 let kappa = 1.0 / length_scale.max(1e-300);
1948 let mut pure = KahanSum::default();
1949 let mut log_part = KahanSum::default();
1950 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1951 if a_m == 0.0 {
1952 continue;
1953 }
1954 let (block_pure, block_log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, 0);
1955 pure.add(a_m * block_pure);
1956 log_part.add(a_m * block_log);
1957 }
1958 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1959 if b_n == 0.0 {
1960 continue;
1961 }
1962 let (block_pure, block_log) = duchon_matern_block_taylor_r2j(kappa, n, k_dim, 0);
1963 pure.add(b_n * block_pure);
1964 log_part.add(b_n * block_log);
1965 }
1966 let value = pure.sum();
1967 let log_value = log_part.sum();
1968 if log_value.abs() > 1e-8 * value.abs().max(1e-30) {
1969 crate::bail_invalid_basis!(
1970 "Duchon hybrid diagonal log terms did not cancel: log={log_value:.6e}, value={value:.6e}; p={p_order}, s={s_order}, d={k_dim}"
1971 );
1972 }
1973 if !value.is_finite() {
1974 crate::bail_invalid_basis!(
1975 "non-finite Duchon hybrid diagonal value for p={p_order}, s={s_order}, d={k_dim}"
1976 );
1977 }
1978 Ok(value)
1979}
1980
1981pub(crate) fn duchon_hybrid_kernel_near_collision_value(
1982 r: f64,
1983 length_scale: f64,
1984 p_order: usize,
1985 s_order: usize,
1986 k_dim: usize,
1987 coeffs: &DuchonPartialFractionCoeffs,
1988) -> Result<f64, BasisError> {
1989 let mut value =
1990 duchon_hybrid_kernel_collision_value(length_scale, p_order, s_order, k_dim, coeffs)?;
1991 if r == 0.0 {
1992 return Ok(value);
1993 }
1994
1995 let smoothness_order = 2 * (p_order + s_order);
2009 let r2 = r * r;
2010 if smoothness_order > k_dim + 2 {
2011 let (phi_rr, _, _) =
2012 duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
2013 value += 0.5 * phi_rr * r2;
2014 }
2015 if smoothness_order > k_dim + 4 {
2016 let phi_rrrr = duchon_phi_rrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)?;
2017 value += (1.0 / 24.0) * phi_rrrr * r2 * r2;
2018 }
2019 if smoothness_order > k_dim + 6 {
2020 let phi_rrrrrr =
2021 duchon_phi_rrrrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)?;
2022 value += (1.0 / 720.0) * phi_rrrrrr * r2 * r2 * r2;
2023 }
2024 if !value.is_finite() {
2025 crate::bail_invalid_basis!(
2026 "non-finite Duchon hybrid near-collision value at r={r}, p={p_order}, s={s_order}, d={k_dim}"
2027 );
2028 }
2029 Ok(value)
2030}
2031
2032#[inline(always)]
2033pub(crate) fn stable_euclidean_norm<I>(components: I) -> f64
2034where
2035 I: IntoIterator<Item = f64>,
2036{
2037 let mut scale = 0.0_f64;
2038 let mut sumsq = 1.0_f64;
2039 let mut has_nonzero = false;
2040 for component in components {
2041 let abs = component.abs();
2042 if abs == 0.0 {
2043 continue;
2044 }
2045 if !abs.is_finite() {
2046 return f64::INFINITY;
2047 }
2048 if !has_nonzero {
2049 scale = abs;
2050 has_nonzero = true;
2051 continue;
2052 }
2053 if scale < abs {
2054 let ratio = scale / abs;
2055 sumsq = 1.0 + sumsq * ratio * ratio;
2056 scale = abs;
2057 } else {
2058 let ratio = abs / scale;
2059 sumsq += ratio * ratio;
2060 }
2061 }
2062 if has_nonzero {
2063 scale * sumsq.sqrt()
2064 } else {
2065 0.0
2066 }
2067}
2068
2069#[inline]
2070pub(crate) fn centered_aniso_log_scale_mean(eta: &[f64]) -> f64 {
2071 if eta.len() <= 1 {
2072 0.0
2073 } else {
2074 eta.iter().sum::<f64>() / eta.len() as f64
2075 }
2076}
2077
2078#[inline]
2079pub(crate) fn centered_aniso_log_scale(value: f64, mean: f64) -> f64 {
2080 let centered = value - mean;
2086 if centered.is_finite() {
2087 centered.clamp(-50.0, 50.0)
2088 } else if centered > 0.0 {
2089 50.0
2090 } else {
2091 -50.0
2092 }
2093}
2094
2095#[inline]
2096pub(crate) fn aniso_axis_scale(value: f64, mean: f64) -> f64 {
2097 centered_aniso_log_scale(value, mean).exp()
2098}
2099
2100#[inline]
2101pub(crate) fn aniso_metric_weight(value: f64, mean: f64) -> f64 {
2102 (2.0 * centered_aniso_log_scale(value, mean)).exp()
2103}
2104
2105pub(crate) fn centered_aniso_metric_weights(eta: &[f64]) -> Vec<f64> {
2106 let mean = centered_aniso_log_scale_mean(eta);
2107 eta.iter()
2108 .map(|&value| aniso_metric_weight(value, mean))
2109 .collect()
2110}
2111
2112#[inline]
2139pub(crate) fn aniso_distance_and_components(
2140 data_row: &[f64],
2141 center: &[f64],
2142 eta: &[f64],
2143) -> (f64, Vec<f64>) {
2144 assert_eq!(data_row.len(), center.len());
2145 assert_eq!(data_row.len(), eta.len());
2146 let d = data_row.len();
2147 let eta_mean = centered_aniso_log_scale_mean(eta);
2148 let mut s_vec = Vec::with_capacity(d);
2149 let mut scaled_components = Vec::with_capacity(d);
2150 for a in 0..d {
2151 let h_a = data_row[a] - center[a];
2152 let scale_a = aniso_axis_scale(eta[a], eta_mean);
2154 let scaled_h_a = scale_a * h_a;
2155 let s_a = scaled_h_a * scaled_h_a;
2156 scaled_components.push(scaled_h_a);
2157 s_vec.push(s_a);
2158 }
2159 (stable_euclidean_norm(scaled_components), s_vec)
2160}
2161
2162#[inline]
2167pub(crate) fn aniso_distance(data_row: &[f64], center: &[f64], eta: &[f64]) -> f64 {
2168 assert_eq!(data_row.len(), center.len());
2169 assert_eq!(data_row.len(), eta.len());
2170 let eta_mean = centered_aniso_log_scale_mean(eta);
2171 stable_euclidean_norm(
2172 (0..data_row.len()).map(|a| aniso_axis_scale(eta[a], eta_mean) * (data_row[a] - center[a])),
2173 )
2174}
2175
2176#[inline(always)]
2177pub(crate) fn euclidean_distance_rows(
2178 lhs: ArrayView2<'_, f64>,
2179 lhs_row: usize,
2180 rhs: ArrayView2<'_, f64>,
2181 rhs_row: usize,
2182) -> f64 {
2183 assert_eq!(lhs.ncols(), rhs.ncols());
2184 stable_euclidean_norm((0..lhs.ncols()).map(|axis| lhs[[lhs_row, axis]] - rhs[[rhs_row, axis]]))
2185}
2186
2187#[inline(always)]
2188pub(crate) fn aniso_axis_scales(eta: &[f64]) -> Vec<f64> {
2189 let eta_mean = centered_aniso_log_scale_mean(eta);
2190 eta.iter()
2191 .map(|&value| aniso_axis_scale(value, eta_mean))
2192 .collect()
2193}
2194
2195#[inline(always)]
2196pub(crate) fn aniso_distance_rows_with_scales(
2197 lhs: ArrayView2<'_, f64>,
2198 lhs_row: usize,
2199 rhs: ArrayView2<'_, f64>,
2200 rhs_row: usize,
2201 axis_scales: &[f64],
2202) -> f64 {
2203 assert_eq!(lhs.ncols(), rhs.ncols());
2204 assert_eq!(lhs.ncols(), axis_scales.len());
2205 stable_euclidean_norm(
2206 (0..lhs.ncols())
2207 .map(|axis| axis_scales[axis] * (lhs[[lhs_row, axis]] - rhs[[rhs_row, axis]])),
2208 )
2209}
2210
2211pub(crate) fn fill_symmetric_from_row_kernel<F>(
2212 matrix: &mut Array2<f64>,
2213 kernel: F,
2214) -> Result<(), BasisError>
2215where
2216 F: Fn(usize, usize) -> Result<f64, BasisError> + Sync,
2217{
2218 assert_eq!(matrix.nrows(), matrix.ncols());
2219 let k = matrix.nrows();
2220 matrix
2228 .axis_iter_mut(Axis(0))
2229 .into_par_iter()
2230 .enumerate()
2231 .try_for_each(|(i, mut row)| {
2232 for j in i..k {
2233 row[j] = kernel(i, j)?;
2234 }
2235 Ok::<(), BasisError>(())
2236 })?;
2237 for i in 1..k {
2238 for j in 0..i {
2239 matrix[[i, j]] = matrix[[j, i]];
2240 }
2241 }
2242 Ok(())
2243}
2244
2245pub(crate) fn points_in_aniso_y_space(points: ArrayView2<'_, f64>, eta: &[f64]) -> Array2<f64> {
2253 assert_eq!(points.ncols(), eta.len());
2254 let mut y = points.to_owned();
2255 let eta_mean = centered_aniso_log_scale_mean(eta);
2256 let weights: Vec<f64> = eta.iter().map(|&e| aniso_axis_scale(e, eta_mean)).collect();
2257 for a in 0..eta.len() {
2258 let w_a = weights[a];
2259 y.column_mut(a).mapv_inplace(|v| v * w_a);
2260 }
2261 y
2262}
2263
2264pub fn knot_cloud_axis_scales(centers: ArrayView2<'_, f64>) -> Vec<f64> {
2269 let k = centers.nrows();
2270 let d = centers.ncols();
2271 if k < 2 || d == 0 {
2272 return vec![1.0; d];
2273 }
2274 let n = k as f64;
2275 let mut scales = Vec::with_capacity(d);
2276 for a in 0..d {
2277 let col = centers.column(a);
2278 let mean = col.sum() / n;
2279 let var = col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2280 let sigma = var.sqrt();
2281 let sigma = if sigma < 1e-12 { 1.0 } else { sigma };
2283 scales.push(sigma.clamp(1e-6, 1e6));
2284 }
2285 scales
2286}
2287
2288pub fn initial_aniso_contrasts(centers: ArrayView2<'_, f64>) -> Vec<f64> {
2296 let d = centers.ncols();
2297 if d <= 1 {
2298 return Vec::new();
2299 }
2300 let scales = knot_cloud_axis_scales(centers);
2301 let mean_neg_log: f64 = scales.iter().map(|&s| -s.ln()).sum::<f64>() / d as f64;
2302 scales
2306 .iter()
2307 .map(|&scale| -scale.ln() - mean_neg_log)
2308 .collect()
2309}
2310
2311pub(crate) fn centered_aniso_contrasts(aniso: Option<&[f64]>) -> Option<Vec<f64>> {
2334 match aniso {
2335 Some(v) if v.len() > 1 => Some(center_aniso_log_scales(v)),
2336 Some(v) => Some(v.to_vec()),
2337 None => None,
2338 }
2339}
2340
2341pub(crate) fn auto_seed_aniso_contrasts(
2359 centers: ArrayView2<'_, f64>,
2360 aniso: Option<&[f64]>,
2361) -> Option<Vec<f64>> {
2362 let eta = match aniso {
2363 Some(v) if v.len() > 1 => v,
2364 Some(v) => return Some(v.to_vec()),
2365 None => return None,
2366 };
2367 let all_zero = eta.iter().all(|&e| e == 0.0);
2368 if !all_zero {
2369 return Some(center_aniso_log_scales(eta));
2370 }
2371 let contrasts = initial_aniso_contrasts(centers);
2372 if contrasts.is_empty() {
2373 Some(center_aniso_log_scales(eta))
2374 } else {
2375 Some(center_aniso_log_scales(&contrasts))
2376 }
2377}
2378
2379fn center_aniso_log_scales(eta: &[f64]) -> Vec<f64> {
2380 if eta.len() <= 1 {
2381 return eta.to_vec();
2382 }
2383 let mean = eta.iter().sum::<f64>() / eta.len() as f64;
2384 eta.iter()
2385 .map(|&v| {
2386 let centered = v - mean;
2387 if centered.abs() <= 1e-15 {
2388 0.0
2389 } else {
2390 centered
2391 }
2392 })
2393 .collect()
2394}
2395
2396#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2399pub enum AnisoSeedMode {
2400 AutoSeedFromGeometry,
2410 Literal,
2417}
2418
2419pub(crate) fn resolve_matern_forward_aniso(
2422 mode: AnisoSeedMode,
2423 centers: ArrayView2<'_, f64>,
2424 aniso: Option<&[f64]>,
2425) -> Option<Vec<f64>> {
2426 match mode {
2427 AnisoSeedMode::Literal => centered_aniso_contrasts(aniso),
2428 AnisoSeedMode::AutoSeedFromGeometry => auto_seed_aniso_contrasts(centers, aniso),
2429 }
2430}
2431
2432pub(crate) fn pairwise_distance_bounds(points: ArrayView2<'_, f64>) -> Option<(f64, f64)> {
2433 let n = points.nrows();
2434 let d = points.ncols();
2435 if n < 2 || d == 0 {
2436 return None;
2437 }
2438 let mut r_min = f64::INFINITY;
2439 let mut r_max = 0.0_f64;
2440 for i in 0..n {
2441 for j in (i + 1)..n {
2442 let r = stable_euclidean_norm((0..d).map(|c| points[[i, c]] - points[[j, c]]));
2443 if r.is_finite() && r > 0.0 {
2444 r_min = r_min.min(r);
2445 r_max = r_max.max(r);
2446 }
2447 }
2448 }
2449 if r_min.is_finite() && r_max.is_finite() && r_min > 0.0 && r_max > 0.0 {
2450 Some((r_min, r_max))
2451 } else {
2452 None
2453 }
2454}
2455
2456pub(crate) fn pairwise_distance_bounds_sampled(points: ArrayView2<'_, f64>) -> Option<(f64, f64)> {
2489 const K_CAP: usize = 1024;
2490 let n = points.nrows();
2491 let d = points.ncols();
2492 if n < 2 || d == 0 {
2493 return None;
2494 }
2495 if n <= K_CAP {
2496 return pairwise_distance_bounds(points);
2497 }
2498 let k = K_CAP;
2503 let denom = (k - 1) as f64;
2504 let span = (n - 1) as f64;
2505 let sample_index = |s: usize| -> usize { ((s as f64) * span / denom).round() as usize };
2506 let mut r_min = f64::INFINITY;
2507 let mut r_max = 0.0_f64;
2508 for i_idx in 0..k {
2509 let i = sample_index(i_idx);
2510 for j_idx in (i_idx + 1)..k {
2511 let j = sample_index(j_idx);
2512 let r = stable_euclidean_norm((0..d).map(|c| points[[i, c]] - points[[j, c]]));
2513 if r.is_finite() && r > 0.0 {
2514 r_min = r_min.min(r);
2515 r_max = r_max.max(r);
2516 }
2517 }
2518 }
2519 if r_min.is_finite() && r_max.is_finite() && r_min > 0.0 && r_max > 0.0 {
2520 Some((r_min, r_max))
2521 } else {
2522 None
2523 }
2524}
2525
2526#[cfg(test)]
2527mod bessel_k_accuracy_tests {
2528 use super::*;
2529
2530 #[test]
2539 fn bessel_k_matches_independent_high_precision_reference() {
2540 const BESSEL_K_REFERENCE: [[f64; 3]; 20] = [
2542 [1e-08, 18.536612259610777, 99999999.9999999],
2543 [0.0001, 9.326271913450276, 9999.999508686404],
2544 [0.01, 4.721244730161095, 99.97389411829624],
2545 [0.1, 2.4270690247020164, 9.853844780870606],
2546 [0.5, 0.9244190712276659, 1.656441120003301],
2547 [1.0, 0.42102443824070834, 0.6019072301972346],
2548 [1.5, 0.21380556264752573, 0.2773878004568438],
2549 [1.99, 0.1153017675517768, 0.14171756162240132],
2550 [2.0, 0.11389387274953344, 0.13986588181652243],
2551 [2.01, 0.11250436099872804, 0.1380408773192077],
2552 [2.5, 0.06234755320036619, 0.07389081634774707],
2553 [3.0, 0.03473950438627925, 0.040156431128194184],
2554 [5.0, 0.0036910983340425942, 0.004044613445452165],
2555 [8.0, 0.0001464707052228154, 0.00015536921180500115],
2556 [12.0, 2.2008253973114916e-06, 2.290757464767188e-06],
2557 [20.0, 5.741237815336525e-10, 5.883057969557038e-10],
2558 [50.0, 3.4101677497894956e-23, 3.4441022267175555e-23],
2559 [150.0, 7.336371406107646e-67, 7.36078548876807e-67],
2560 [400.0, 1.199780043200976e-175, 1.2012788332610325e-175],
2561 [700.0, 4.669776431685377e-306, 4.6731107967079664e-306],
2562 ];
2563
2564 const TOLERANCE: f64 = 8e-15;
2569 for [x, want_k0, want_k1] in BESSEL_K_REFERENCE {
2570 for (order, got, want) in [
2571 (0, bessel_k0_stable(x), want_k0),
2572 (1, bessel_k1_stable(x), want_k1),
2573 ] {
2574 let error = (got - want).abs() / want.abs();
2575 assert!(
2576 error < TOLERANCE,
2577 "K{order}({x}): got {got:.17e}, want {want:.17e} (rel {error:.3e})"
2578 );
2579 }
2580 }
2581 }
2582
2583 #[test]
2586 fn bessel_k_branch_crossover_has_no_step() {
2587 let delta = 1.0e-13;
2590 let slope = bessel_k0_stable(2.0) + bessel_k1_stable(2.0);
2593 for (order, f) in [
2594 (0, bessel_k0_stable as fn(f64) -> f64),
2595 (1, bessel_k1_stable),
2596 ] {
2597 let below = f(2.0 - delta);
2598 let above = f(2.0 + delta);
2599 let budget = 2.0 * delta * slope + 8.0 * f64::EPSILON * below.abs();
2600 assert!(
2601 (above - below).abs() < budget,
2602 "K{order} steps at the x=2 crossover: {below:.17e} -> {above:.17e} \
2603 (change {:.3e} > budget {budget:.3e})",
2604 (above - below).abs()
2605 );
2606 }
2607 }
2608
2609 #[test]
2615 fn the_two_bessel_k_implementations_in_this_crate_agree() {
2616 use crate::basis::closed_form_penalty::bessel_k;
2617 for x in [
2618 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,
2619 ] {
2620 for (order, fast) in [(0.0_f64, bessel_k0_stable(x)), (1.0, bessel_k1_stable(x))] {
2621 let general = bessel_k(order, x);
2622 let error = (fast - general).abs() / general.abs();
2623 assert!(
2624 error < 1e-13,
2625 "K{order}({x}): fast path {fast:.17e} vs Temme/Steed {general:.17e} \
2626 (rel {error:.3e})"
2627 );
2628 }
2629 }
2630 }
2631
2632 #[test]
2637 fn bessel_k_satisfies_the_wronskian_against_bessel_i() {
2638 for x in [
2639 0.05_f64, 0.5, 1.0, 1.99, 2.0, 2.01, 3.0, 6.0, 12.0, 30.0, 80.0,
2640 ] {
2641 let (centered_log_i0, ratio, _) = gam_math::special::bessel_i0_centered_terms(x);
2642 let i0 = (centered_log_i0 + x).exp();
2646 let i1 = ratio * i0;
2647 let wronskian = i0 * bessel_k1_stable(x) + i1 * bessel_k0_stable(x);
2648 let want = 1.0 / x;
2649 let error = (wronskian - want).abs() / want;
2650 assert!(
2654 error < 1e-13,
2655 "Wronskian at x={x}: got {wronskian:.17e}, want {want:.17e} (rel {error:.3e})"
2656 );
2657 }
2658 }
2659}
2660
2661#[cfg(test)]
2662mod duchon_hybrid_psd_tests {
2663 use super::*;
2664 use faer::Side;
2665 use gam_linalg::faer_ndarray::FaerEigh;
2666
2667 fn assert_pow_parity(label: &str, got: f64, reference: f64) {
2668 if got.to_bits() == reference.to_bits() || (got.is_nan() && reference.is_nan()) {
2669 return;
2670 }
2671 if got.is_infinite() || reference.is_infinite() {
2672 assert_eq!(got, reference, "{label}: infinity/sign mismatch");
2673 return;
2674 }
2675 let scale = got.abs().max(reference.abs()).max(f64::MIN_POSITIVE);
2676 let relative = (got - reference).abs() / scale;
2677 assert!(
2678 relative <= 2.0e-12 || (got - reference).abs() <= 1.0e-300,
2679 "{label}: got {got:.17e}, powf reference {reference:.17e}, relative error {relative:.3e}"
2680 );
2681 }
2682
2683 fn powf_polyharmonic_constants(m: f64, d: usize) -> (f64, f64, bool) {
2684 let half_d = 0.5 * d as f64;
2685 let alpha = 2.0 * m - d as f64;
2686 let log_case = d.is_multiple_of(2) && alpha >= 0.0 && (alpha % 2.0).abs() < 1.0e-12;
2687 let c = if log_case {
2688 let m_int = m.round() as usize;
2689 polyharmonic_log_sign(m_int, d)
2690 / (2.0_f64.powi((2 * m_int - 1) as i32)
2691 * std::f64::consts::PI.powf(half_d)
2692 * gamma_lanczos(m)
2693 * gamma_lanczos((m_int - d / 2 + 1) as f64))
2694 } else {
2695 gamma_lanczos(half_d - m)
2696 / (4.0_f64.powf(m) * std::f64::consts::PI.powf(half_d) * gamma_lanczos(m))
2697 };
2698 (c, alpha, log_case)
2699 }
2700
2701 fn powf_family_value(r: f64, c: f64, exponent: f64, log: f64, pure: f64) -> f64 {
2702 if r <= 0.0 {
2703 log_power_origin_limit(c, exponent, log, pure)
2704 } else {
2705 c * r.powf(exponent) * (log * r.ln() + pure)
2706 }
2707 }
2708
2709 fn differentiate_powf_family(exponent: &mut f64, log: &mut f64, pure: &mut f64) {
2710 let old_exponent = *exponent;
2711 *exponent -= 1.0;
2712 *pure = old_exponent * *pure + *log;
2713 *log *= old_exponent;
2714 }
2715
2716 fn powf_operator_reference(r: f64, m: usize, d: usize) -> [f64; 4] {
2717 let (c, alpha, log_case) = powf_polyharmonic_constants(m as f64, d);
2718 let (mut exponent, mut log, mut pure) = if log_case {
2719 (alpha, 1.0, 0.0)
2720 } else {
2721 (alpha, 0.0, 1.0)
2722 };
2723 differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2724 exponent -= 1.0; let q = powf_family_value(r, c, exponent, log, pure);
2726 differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2727 exponent -= 1.0; let t = powf_family_value(r, c, exponent, log, pure);
2729 differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2730 let t_r = powf_family_value(r, c, exponent, log, pure);
2731 differentiate_powf_family(&mut exponent, &mut log, &mut pure);
2732 let t_rr = powf_family_value(r, c, exponent, log, pure);
2733 [q, t, t_r, t_rr]
2734 }
2735
2736 #[test]
2737 fn pure_polyharmonic_integer_powers_match_powf_at_zero_tiny_and_large_radius() {
2738 let radii = [0.0_f64, 1.0e-40, 1.0e-12, 0.2, 1.0, 12.0, 1.0e40];
2739 for &(m, d) in &[(2usize, 1usize), (3, 2), (4, 5), (5, 6), (7, 9)] {
2740 let block = PolyharmonicBlockCoeff::new(m as f64, d);
2741 assert!(block.power_i32.is_some());
2742 let (reference_c, alpha, log_case) = powf_polyharmonic_constants(m as f64, d);
2743 assert_pow_parity("coefficient", block.c, reference_c);
2744 for &r in &radii {
2745 let reference_value = if r <= 0.0 {
2746 block.origin_limit()
2747 } else if log_case {
2748 reference_c * r.powf(alpha) * r.ln()
2749 } else {
2750 reference_c * r.powf(alpha)
2751 };
2752 assert_pow_parity("block value", block.eval(r), reference_value);
2753
2754 let got = polyharmonic_block_jet4(r, m as f64, d).unwrap();
2755 let got = [got.0, got.1, got.2, got.3, got.4];
2756 for derivative in 0..5 {
2757 let exponent = alpha - derivative as f64;
2758 let falling = falling_factorial(alpha, derivative);
2759 let reference = if log_case {
2760 powf_family_value(
2761 r,
2762 reference_c,
2763 exponent,
2764 falling,
2765 falling_factorial_derivative(alpha, derivative),
2766 )
2767 } else {
2768 powf_family_value(r, reference_c, exponent, 0.0, falling)
2769 };
2770 assert_pow_parity("jet channel", got[derivative], reference);
2771 }
2772
2773 let got = duchon_polyharmonic_operator_block_jets(r, m, d).unwrap();
2774 let got = [got.0, got.1, got.2, got.3];
2775 let reference = powf_operator_reference(r, m, d);
2776 for channel in 0..4 {
2777 assert_pow_parity("operator channel", got[channel], reference[channel]);
2778 }
2779 }
2780 }
2781 }
2782
2783 #[test]
2784 fn fractional_polyharmonic_power_retains_powf_path() {
2785 let (m, d) = (2.125_f64, 3usize);
2786 let block = PolyharmonicBlockCoeff::new(m, d);
2787 assert_eq!(block.power, 1.25);
2788 assert!(block.power_i32.is_none());
2789 let (c, alpha, log_case) = powf_polyharmonic_constants(m, d);
2790 assert!(!log_case);
2791 for &r in &[0.0_f64, 1.0e-40, 0.25, 3.0, 1.0e40] {
2792 let reference = if r <= 0.0 {
2793 log_power_origin_limit(c, alpha, 0.0, 1.0)
2794 } else {
2795 c * r.powf(alpha)
2796 };
2797 assert_pow_parity("fractional block", block.eval(r), reference);
2798 }
2799 }
2800
2801 #[test]
2802 fn pure_polyharmonic_powi_microbenchmark() {
2803 const N: usize = 20_000;
2804 let (m, d, r) = (7usize, 9usize, 0.731_f64);
2805 let start = std::time::Instant::now();
2806 let powf_sum = (0..N).fold(0.0, |sum, i| {
2807 let radius = std::hint::black_box(r + (i % 17) as f64 * 1.0e-6);
2808 sum + powf_operator_reference(radius, m, d)[0]
2809 });
2810 let powf_time = start.elapsed();
2811 let start = std::time::Instant::now();
2812 let powi_sum = (0..N).fold(0.0, |sum, i| {
2813 let radius = std::hint::black_box(r + (i % 17) as f64 * 1.0e-6);
2814 sum + duchon_polyharmonic_operator_block_jets(radius, m, d)
2815 .unwrap()
2816 .0
2817 });
2818 let powi_time = start.elapsed();
2819 assert_pow_parity("benchmark accumulator", powi_sum, powf_sum);
2820 eprintln!(
2821 "pure operator {N} calls: powf={powf_time:?}, powi={powi_time:?}, speedup={:.2}x",
2822 powf_time.as_secs_f64() / powi_time.as_secs_f64().max(f64::MIN_POSITIVE)
2823 );
2824 }
2825
2826 #[test]
2831 fn pure_duchon_cpd_guard_is_nullspace_degree_independent_issue_2278() {
2832 let err = validate_duchon_kernel_orders(None, 2, 1.0, 2)
2835 .expect_err("pure Duchon d=2, p=2, s=1 (2s>=d) must be rejected as ill-posed");
2836 let BasisError::InvalidInput(msg) = err else {
2837 panic!("expected an InvalidInput well-posedness error, got {err}");
2838 };
2839 assert!(
2840 msg.contains("dimension/2") || msg.contains("2s < d"),
2841 "message must name the CPD/well-posedness cause: {msg}"
2842 );
2843 assert!(validate_duchon_kernel_orders(None, 1, 1.0, 2).is_err());
2845 assert!(validate_duchon_kernel_orders(None, 2, 0.5, 2).is_ok());
2849 assert!(validate_duchon_kernel_orders(Some(1.0), 2, 1.0, 2).is_ok());
2852 }
2853
2854 #[test]
2865 fn sampled_diameter_is_n_stable_across_cap_threshold() {
2866 let grid = |n: usize| -> Array2<f64> {
2867 let mut x = Array2::<f64>::zeros((n, 1));
2868 for i in 0..n {
2869 x[[i, 0]] = (i as f64) / (n as f64 - 1.0) * 6.0 - 3.0;
2870 }
2871 x
2872 };
2873 let exact_diam = 6.0_f64;
2875 let mut last_rmax: Option<f64> = None;
2876 for &n in &[1000usize, 1025, 1500, 2000, 4000, 50_000] {
2877 let x = grid(n);
2878 let (r_min, r_max) =
2879 pairwise_distance_bounds_sampled(x.view()).expect("bounds for dense grid");
2880 assert!(
2883 (r_max - exact_diam).abs() <= 0.01 * exact_diam,
2884 "sampled r_max at n={n} = {r_max:.6} drifted from the true diameter \
2885 {exact_diam:.6}: the diameter estimate is n-dependent (#1033)"
2886 );
2887 if let Some(prev) = last_rmax {
2889 let rel = (r_max - prev).abs() / exact_diam;
2890 assert!(
2891 rel <= 0.02,
2892 "sampled r_max jumped {rel:.4} (rel) between n steps near n={n}: \
2893 {prev:.6} -> {r_max:.6}; outer-loop box is not n-stable (#1033)"
2894 );
2895 }
2896 last_rmax = Some(r_max);
2897 assert!(
2899 r_min.is_finite() && r_min > 0.0,
2900 "r_min must be positive at n={n}"
2901 );
2902 }
2903 }
2904
2905 #[test]
2910 fn sampled_indices_span_full_range() {
2911 const K_CAP: usize = 1024;
2912 let n = 2000usize; let k = K_CAP;
2914 let denom = (k - 1) as f64;
2915 let span = (n - 1) as f64;
2916 let idx = |s: usize| -> usize { ((s as f64) * span / denom).round() as usize };
2917 assert_eq!(idx(0), 0, "first sample must be index 0");
2918 assert_eq!(idx(k - 1), n - 1, "last sample must be the final index n-1");
2919 let mut max_gap = 0usize;
2922 for s in 1..k {
2923 max_gap = max_gap.max(idx(s) - idx(s - 1));
2924 }
2925 assert!(
2926 max_gap <= 2,
2927 "evenly-spaced samples should step by ~{:.2}; saw a gap of {max_gap} \
2928 (prefix clustering would leave one huge gap)",
2929 span / denom
2930 );
2931 }
2932
2933 fn fixture_centers(d: usize, n: usize) -> Array2<f64> {
2939 const BASES: [u64; 24] = [
2940 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83,
2941 89,
2942 ];
2943 let mut centers = Array2::<f64>::zeros((n, d));
2944 for i in 0..n {
2945 for axis in 0..d {
2946 let base = BASES[axis % BASES.len()];
2947 let mut f = 1.0_f64;
2951 let mut idx = (i + 1) as u64;
2952 let mut value = 0.0_f64;
2953 while idx > 0 {
2954 f /= base as f64;
2955 value += f * (idx % base) as f64;
2956 idx /= base;
2957 }
2958 centers[[i, axis]] = 2.0 * value - 1.0;
2959 }
2960 }
2961 centers
2962 }
2963
2964 fn lambda_min(matrix: &Array2<f64>) -> f64 {
2967 let sym = symmetrize_penalty(matrix);
2968 let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).expect("symmetric eigendecomposition");
2969 evals.iter().copied().fold(f64::INFINITY, f64::min)
2970 }
2971
2972 #[test]
2981 fn high_dim_hybrid_penalty_is_numerically_psd_1424() {
2982 let d = 16usize;
2983 let (nullspace_order, default_power) = duchon_cubic_default(d);
2991 assert!(matches!(nullspace_order, DuchonNullspaceOrder::Linear));
2992 assert!(
2993 (default_power - 7.5).abs() < 1e-12,
2994 "cubic-default power for d=16 is 7.5"
2995 );
2996 let power = 7.0_f64;
2997 assert_eq!(duchon_power_to_usize(power), 7);
2998 assert!(duchon_hybrid_stable_integral_applies(
3000 duchon_p_from_nullspace_order(nullspace_order),
3001 duchon_power_to_usize(power),
3002 d,
3003 ));
3004 let length_scale = Some(1.0_f64);
3005 let centers = fixture_centers(d, 4 * d);
3006
3007 let mut cache = BasisCacheContext::default();
3008 let z = kernel_constraint_nullspace(centers.view(), nullspace_order, &mut cache)
3009 .expect("constraint null space");
3010
3011 let omega = duchon_constrained_bending_penalty(
3012 centers.view(),
3013 length_scale,
3014 power,
3015 nullspace_order,
3016 None,
3017 &z,
3018 )
3019 .expect("constrained bending penalty assembles for the hybrid fixture");
3020 let (penalty, _scale) = normalize_penalty(&omega);
3021
3022 let lam_min = lambda_min(&penalty);
3023 assert!(
3024 lam_min >= -1e-10,
3025 "gam#1424: (d=16, m=2, s=7) hybrid penalty is not numerically PSD: \
3026 λ_min={lam_min:.6e} (was ≈ −0.26442 with the cancellation-prone \
3027 partial-fraction kernel)"
3028 );
3029 }
3030
3031 fn phi0_closed_form(p: usize, s: usize, d: usize, kappa: f64) -> f64 {
3043 let half_d = 0.5 * d as f64;
3044 let b = p as f64 + s as f64 - half_d;
3045 (4.0 * std::f64::consts::PI).powf(-half_d) / gamma_lanczos(s as f64)
3046 * gamma_lanczos(b)
3047 * kappa.powf(-2.0 * b)
3048 * gamma_lanczos(half_d - p as f64)
3049 / gamma_lanczos(half_d)
3050 }
3051
3052 #[test]
3059 fn hybrid_collision_diagonal_matches_closed_form_1604() {
3060 for &d in &[1usize, 3, 5] {
3063 for &p in &[1usize, 2, 3] {
3064 for &s in &[1usize, 2, 3, 4] {
3065 if 2 * (p + s) <= d {
3066 continue;
3067 }
3068 for &kappa in &[0.5f64, 1.0, 2.5] {
3069 let coeffs = duchon_partial_fraction_coeffs(p, s, kappa);
3070 let got =
3071 duchon_hybrid_kernel_collision_value(1.0 / kappa, p, s, d, &coeffs)
3072 .expect("collision diagonal");
3073 let want = phi0_closed_form(p, s, d, kappa);
3074 let rel = (got - want).abs() / want.abs().max(1e-300);
3075 assert!(
3076 rel < 1e-10,
3077 "φ(0) mismatch d={d} p={p} s={s} κ={kappa}: got {got:.12e}, want {want:.12e} (rel {rel:.2e})"
3078 );
3079 }
3080 }
3081 }
3082 }
3083 }
3084
3085 #[test]
3092 fn hybrid_near_collision_continuous_with_direct_1604() {
3093 for &d in &[1usize, 3] {
3094 for &p in &[1usize, 2] {
3095 for &s in &[2usize, 3] {
3096 if 2 * (p + s) <= d + 6 {
3097 continue;
3099 }
3100 for &kappa in &[0.5f64, 1.0, 2.0] {
3101 let length_scale = 1.0 / kappa;
3102 let coeffs = duchon_partial_fraction_coeffs(p, s, kappa);
3103 let r = 0.02 * length_scale;
3107 let taylor = duchon_hybrid_kernel_near_collision_value(
3108 r,
3109 length_scale,
3110 p,
3111 s,
3112 d,
3113 &coeffs,
3114 )
3115 .expect("near-collision value");
3116 let mut direct = 0.0f64;
3118 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
3119 if a_m != 0.0 {
3120 direct += a_m * polyharmonic_kernel(r, m as f64, d);
3121 }
3122 }
3123 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
3124 if b_n != 0.0 {
3125 direct += b_n
3126 * duchon_matern_block(r, kappa, n, d).expect("matern block");
3127 }
3128 }
3129 let rel = (taylor - direct).abs() / direct.abs().max(1e-300);
3130 assert!(
3131 rel < 1e-9,
3132 "near-collision vs direct mismatch d={d} p={p} s={s} κ={kappa} r={r}: \
3133 taylor {taylor:.12e}, direct {direct:.12e} (rel {rel:.2e})"
3134 );
3135 }
3136 }
3137 }
3138 }
3139 }
3140
3141 #[test]
3147 fn d1_hybrid_penalty_is_psd_1604() {
3148 let d = 1usize;
3149 let nullspace_order = DuchonNullspaceOrder::Linear; let centers = fixture_centers(d, 12);
3151 let mut cache = BasisCacheContext::default();
3152 let z = kernel_constraint_nullspace(centers.view(), nullspace_order, &mut cache)
3153 .expect("constraint null space");
3154 for &power in &[2.0f64, 3.0] {
3155 for &length_scale in &[0.5f64, 1.0, 10.0, 100.0] {
3156 let omega = duchon_constrained_bending_penalty(
3157 centers.view(),
3158 Some(length_scale),
3159 power,
3160 nullspace_order,
3161 None,
3162 &z,
3163 )
3164 .unwrap_or_else(|e| {
3165 panic!("d=1 p=2 s={power} ls={length_scale} penalty rejected: {e}")
3166 });
3167 let (penalty, _scale) = normalize_penalty(&omega);
3168 let lam_min = lambda_min(&penalty);
3169 assert!(
3170 lam_min >= -1e-9,
3171 "d=1 p=2 s={power} ls={length_scale}: λ_min={lam_min:.6e} (not PSD)"
3172 );
3173 }
3174 }
3175 }
3176
3177 #[test]
3185 fn low_dim_hybrid_kernel_values_unchanged_1424() {
3186 let d = 2usize;
3187 let p_order = 2usize; let s_order = 2usize;
3189 let kappa = 1.0_f64;
3190 let length_scale = Some(1.0_f64);
3191 assert!(!duchon_hybrid_stable_integral_applies(p_order, s_order, d));
3193 let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
3194
3195 for &r in &[0.25_f64, 0.75, 1.5] {
3196 let mut reference = 0.0_f64;
3200 for (m, &coeff) in coeffs.a.iter().enumerate().skip(1) {
3201 if coeff != 0.0 {
3202 reference += coeff * polyharmonic_kernel(r, m as f64, d);
3203 }
3204 }
3205 for (n, &coeff) in coeffs.b.iter().enumerate().skip(1) {
3206 if coeff != 0.0 {
3207 reference += coeff * duchon_matern_block(r, kappa, n, d).expect("matern block");
3208 }
3209 }
3210
3211 let got = duchon_matern_kernel_general_from_distance(
3212 r,
3213 length_scale,
3214 p_order,
3215 s_order,
3216 d,
3217 Some(&coeffs),
3218 )
3219 .expect("low-d hybrid kernel value");
3220 assert!(
3221 (got - reference).abs() <= 1e-10,
3222 "low-d hybrid kernel value regressed at r={r}: got {got:.15e}, reference {reference:.15e}"
3223 );
3224 }
3225 }
3226
3227 #[test]
3234 fn operator_penalties_auto_raise_order_issue_1817() {
3235 let mut centers = Array2::<f64>::zeros((12, 2));
3239 let mut row = 0;
3240 for i in 0..4 {
3241 for j in 0..3 {
3242 centers[[row, 0]] = i as f64 / 3.0;
3243 centers[[row, 1]] = j as f64 / 2.0;
3244 row += 1;
3245 }
3246 }
3247
3248 let dim = 2usize;
3249 let power = 0.0_f64;
3250 let requested = DuchonNullspaceOrder::Linear;
3251
3252 let requested_p = duchon_p_from_nullspace_order(requested);
3254 assert!(
3255 2.0 * (requested_p as f64 + power) <= dim as f64 + 2.0,
3256 "precondition: requested (p,s) must be on the failing side of the D2 margin"
3257 );
3258
3259 let effective = duchon_order_for_operator_margin(dim, power, requested, 2);
3262 let effective_p = duchon_p_from_nullspace_order(effective);
3263 assert!(
3264 2.0 * (effective_p as f64 + power) > dim as f64 + 2.0,
3265 "auto-raised order must satisfy 2(p+s) > d+2: got 2*({}+{})={} vs d+2={}",
3266 effective_p,
3267 power,
3268 2.0 * (effective_p as f64 + power),
3269 dim as f64 + 2.0
3270 );
3271
3272 let penalties = build_duchon_operator_penalty_matrices(
3276 centers.view(),
3277 None,
3278 None, power,
3280 requested,
3281 None,
3282 None,
3283 )
3284 .expect("Duchon mass+tension+stiffness penalties must build after auto-raise (#1817)");
3285 for m in [&penalties.mass, &penalties.tension, &penalties.stiffness] {
3286 assert!(
3287 m.iter().all(|v| v.is_finite()),
3288 "auto-raised operator penalty matrices must be finite"
3289 );
3290 }
3291 }
3292}