1use super::*;
2
3pub fn build_duchon_collocation_operator_matrices(
4 centers: ArrayView2<'_, f64>,
5 collocationweights: Option<ArrayView1<'_, f64>>,
6 length_scale: Option<f64>,
7 power: f64,
8 nullspace_order: DuchonNullspaceOrder,
9 aniso_log_scales: Option<&[f64]>,
10 identifiability_transform: Option<ArrayView2<'_, f64>>,
11 max_operator_derivative_order: usize,
12) -> Result<CollocationOperatorMatrices, BasisError> {
13 let mut workspace = BasisWorkspace::default();
14 build_duchon_collocation_operator_matriceswithworkspace(
15 centers,
16 centers,
17 collocationweights,
18 length_scale,
19 power,
20 nullspace_order,
21 aniso_log_scales,
22 identifiability_transform,
23 max_operator_derivative_order,
24 &mut workspace,
25 )
26}
27
28pub fn build_duchon_operator_penalty_matrices(
29 centers: ArrayView2<'_, f64>,
30 collocationweights: Option<ArrayView1<'_, f64>>,
31 length_scale: Option<f64>,
32 power: f64,
33 nullspace_order: DuchonNullspaceOrder,
34 aniso_log_scales: Option<&[f64]>,
35 identifiability_transform: Option<ArrayView2<'_, f64>>,
36) -> Result<DuchonOperatorPenaltyMatrices, BasisError> {
37 let ops = build_duchon_collocation_operator_matrices(
38 centers,
39 collocationweights,
40 length_scale,
41 power,
42 nullspace_order,
43 aniso_log_scales,
44 identifiability_transform,
45 2,
46 )?;
47 let (mass, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d0)));
48 let (tension, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d1)));
49 let (stiffness, _) = normalize_penalty(&symmetrize(&fast_ata(&ops.d2)));
50 Ok(DuchonOperatorPenaltyMatrices {
51 mass,
52 tension,
53 stiffness,
54 })
55}
56
57pub fn build_thin_plate_penalty_matrix(
58 centers: ArrayView2<'_, f64>,
59 length_scale: f64,
60) -> Result<ThinPlatePenaltyMatrix, BasisError> {
61 let mut workspace = BasisWorkspace::default();
62 let kernel_transform = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
63 let (penalty, _) =
64 build_thin_plate_penalty_matrices(centers, length_scale, &kernel_transform, false)?;
65 let (penalty, _) = normalize_penalty(&penalty);
66 Ok(ThinPlatePenaltyMatrix { penalty })
67}
68
69pub fn build_duchon_collocation_operator_matriceswithworkspace(
70 centers: ArrayView2<'_, f64>,
71 collocation_points: ArrayView2<'_, f64>,
72 collocationweights: Option<ArrayView1<'_, f64>>,
73 length_scale: Option<f64>,
74 power: f64,
75 nullspace_order: DuchonNullspaceOrder,
76 aniso_log_scales: Option<&[f64]>,
77 identifiability_transform: Option<ArrayView2<'_, f64>>,
78 max_operator_derivative_order: usize,
79 workspace: &mut BasisWorkspace,
80) -> Result<CollocationOperatorMatrices, BasisError> {
81 let nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
88 let p_order = duchon_p_from_nullspace_order(nullspace_order);
89 let s_order: f64 = power;
90 let p_colloc = collocation_points.nrows();
91 let n_basis = centers.nrows();
92 let dim = centers.ncols();
93 if collocation_points.ncols() != dim {
94 crate::bail_dim_basis!(
95 "collocation points dim {} != centers dim {dim}",
96 collocation_points.ncols()
97 );
98 }
99 validate_duchon_collocation_orders(
100 length_scale,
101 p_order,
102 s_order,
103 dim,
104 max_operator_derivative_order,
105 )?;
106 if let Some(eta) = aniso_log_scales
107 && eta.len() != dim
108 {
109 crate::bail_dim_basis!(
110 "Duchon anisotropy dimension mismatch: got {}, expected {dim}",
111 eta.len()
112 );
113 }
114 let coeffs = length_scale.map(|scale| {
118 let s_int = duchon_power_to_usize(s_order);
119 duchon_partial_fraction_coeffs(p_order, s_int, 1.0 / scale.max(1e-300))
120 });
121 let metric_weights: Option<Vec<f64>> = aniso_log_scales.map(centered_aniso_metric_weights);
122 let row_scales = if let Some(w) = collocationweights {
123 if w.len() != p_colloc {
124 crate::bail_dim_basis!(
125 "collocation weight length mismatch: got {}, expected {p_colloc}",
126 w.len()
127 );
128 }
129 let mut out = Vec::with_capacity(p_colloc);
130 for &wk in w {
131 if !wk.is_finite() || wk < 0.0 {
132 crate::bail_invalid_basis!(
133 "collocation weights must be finite and non-negative; got {wk}"
134 );
135 }
136 out.push(wk.sqrt());
137 }
138 out
139 } else {
140 vec![1.0; p_colloc]
141 };
142 let z = kernel_constraint_nullspace(centers, nullspace_order, &mut workspace.cache)?;
143 let build_d1 = max_operator_derivative_order >= 1;
151 let build_d2 = max_operator_derivative_order >= 2;
152 let mut d0_raw = Array2::<f64>::zeros((p_colloc, n_basis));
153 let mut d1_raw = Array2::<f64>::zeros((if build_d1 { p_colloc * dim } else { 0 }, n_basis));
154 let mut d2_raw =
155 Array2::<f64>::zeros((if build_d2 { p_colloc * dim * dim } else { 0 }, n_basis));
156 const R_EPS: f64 = 1e-10;
157 for i in 0..p_colloc {
158 let scale_i = row_scales[i];
159 for j in 0..n_basis {
160 let r = if let Some(eta) = aniso_log_scales {
161 let row_i: Vec<f64> = (0..dim).map(|a| collocation_points[[i, a]]).collect();
162 let row_j: Vec<f64> = (0..dim).map(|a| centers[[j, a]]).collect();
163 aniso_distance(&row_i, &row_j, eta)
164 } else {
165 stable_euclidean_norm(
166 (0..dim).map(|axis| collocation_points[[i, axis]] - centers[[j, axis]]),
167 )
168 };
169 let r = r.max(R_EPS);
175 let (phi, q, t) = if let (Some(length_scale), Some(coeffs)) =
176 (length_scale, coeffs.as_ref())
177 {
178 let jets =
179 duchon_radial_jets(r, length_scale, p_order, s_order as usize, dim, coeffs)?;
180 (jets.phi, jets.q, jets.t)
181 } else {
182 let (phi, phi_r, phi_rr) = duchon_kernel_radial_triplet(
183 r,
184 length_scale,
185 p_order,
186 s_order,
187 dim,
188 coeffs.as_ref(),
189 )?;
190 let q = if r > R_EPS { phi_r / r } else { phi_rr };
191 let t = if r > R_EPS {
192 (phi_rr - q) / (r * r)
193 } else {
194 0.0
195 };
196 (phi, q, t)
197 };
198 if !phi.is_finite() || !q.is_finite() || !t.is_finite() {
199 crate::bail_invalid_basis!(
200 "non-finite Duchon collocation operator derivative at (colloc {i}, center {j}), r={r}"
201 );
202 }
203 d0_raw[[i, j]] = scale_i * phi;
204 if build_d2 {
205 for axis_a in 0..dim {
206 let h_a = collocation_points[[i, axis_a]] - centers[[j, axis_a]];
207 let w_a = metric_weights
208 .as_ref()
209 .map(|weights| weights[axis_a])
210 .unwrap_or(1.0);
211 for axis_b in 0..dim {
212 let h_b = collocation_points[[i, axis_b]] - centers[[j, axis_b]];
213 let w_b = metric_weights
214 .as_ref()
215 .map(|weights| weights[axis_b])
216 .unwrap_or(1.0);
217 let diagonal = if axis_a == axis_b { q * w_a } else { 0.0 };
218 let mixed = if r > R_EPS {
219 t * w_a * h_a * w_b * h_b
220 } else {
221 0.0
222 };
223 let value = diagonal + mixed;
224 let row_i = (i * dim + axis_a) * dim + axis_b;
225 d2_raw[[row_i, j]] = scale_i * value;
226 }
227 }
228 }
229 if build_d1 && r > R_EPS {
230 for axis in 0..dim {
231 let delta = collocation_points[[i, axis]] - centers[[j, axis]];
232 let axis_scale = metric_weights
233 .as_ref()
234 .map(|weights| weights[axis])
235 .unwrap_or(1.0);
236 d1_raw[[i * dim + axis, j]] = scale_i * q * axis_scale * delta;
237 }
238 }
239 }
240 }
241 let d0_kernel = fast_ab(&d0_raw, &z);
242 let poly = polynomial_block_from_order(centers, nullspace_order);
243 let poly_collocation = polynomial_block_from_order(collocation_points, nullspace_order);
244 let poly_d1 = if build_d1 {
245 polynomial_derivative_block(collocation_points, nullspace_order, 1)
246 } else {
247 Array2::<f64>::zeros((0, poly.ncols()))
248 };
249 let poly_d2 = if build_d2 {
250 polynomial_derivative_block(collocation_points, nullspace_order, 2)
251 } else {
252 Array2::<f64>::zeros((0, poly.ncols()))
253 };
254 let kernel_cols = d0_kernel.ncols();
255 let poly_cols = poly.ncols();
256 let total_cols = kernel_cols + poly_cols;
257 let mut d0 = Array2::<f64>::zeros((p_colloc, total_cols));
265 d0.slice_mut(s![.., 0..kernel_cols]).assign(&d0_kernel);
266 d0.slice_mut(s![.., kernel_cols..total_cols])
267 .assign(&poly_collocation);
268 let mut d1 = Array2::<f64>::zeros((if build_d1 { p_colloc * dim } else { 0 }, total_cols));
269 if build_d1 {
270 d1.slice_mut(s![.., 0..kernel_cols])
271 .assign(&fast_ab(&d1_raw, &z));
272 d1.slice_mut(s![.., kernel_cols..total_cols])
273 .assign(&poly_d1);
274 }
275 let mut d2 =
276 Array2::<f64>::zeros((if build_d2 { p_colloc * dim * dim } else { 0 }, total_cols));
277 if build_d2 {
278 d2.slice_mut(s![.., 0..kernel_cols])
279 .assign(&fast_ab(&d2_raw, &z));
280 d2.slice_mut(s![.., kernel_cols..total_cols])
281 .assign(&poly_d2);
282 }
283 if let Some(z) = identifiability_transform {
284 let z = z.to_owned();
285 d0 = fast_ab(&d0, &z);
286 d1 = fast_ab(&d1, &z);
287 d2 = fast_ab(&d2, &z);
288 }
289 Ok(CollocationOperatorMatrices {
290 d0,
291 d1,
292 d2,
293 collocation_points: collocation_points.to_owned(),
294 kernel_nullspace_transform: Some(z),
295 polynomial_block_cols: poly_cols,
296 })
297}
298
299fn polynomial_derivative_block(
300 points: ArrayView2<'_, f64>,
301 order: DuchonNullspaceOrder,
302 derivative_order: usize,
303) -> Array2<f64> {
304 let n = points.nrows();
305 let d = points.ncols();
306 let degree = match order {
307 DuchonNullspaceOrder::Zero => 0,
308 DuchonNullspaceOrder::Linear => 1,
309 DuchonNullspaceOrder::Degree(degree) => degree,
310 };
311 let exponents = monomial_exponents(d, degree);
312 match derivative_order {
313 1 => {
314 let mut block = Array2::<f64>::zeros((n * d, exponents.len()));
315 for row in 0..n {
316 for axis in 0..d {
317 let out_row = row * d + axis;
318 for (col, exps) in exponents.iter().enumerate() {
319 block[[out_row, col]] = monomial_derivative_value(points, row, exps, axis);
320 }
321 }
322 }
323 block
324 }
325 2 => {
326 let mut block = Array2::<f64>::zeros((n * d * d, exponents.len()));
327 for row in 0..n {
328 for axis_a in 0..d {
329 for axis_b in 0..d {
330 let out_row = (row * d + axis_a) * d + axis_b;
331 for (col, exps) in exponents.iter().enumerate() {
332 block[[out_row, col]] =
333 monomial_second_derivative_value(points, row, exps, axis_a, axis_b);
334 }
335 }
336 }
337 }
338 block
339 }
340 _ => Array2::<f64>::zeros((0, exponents.len())),
341 }
342}
343
344fn monomial_derivative_value(
345 points: ArrayView2<'_, f64>,
346 row: usize,
347 exponents: &[usize],
348 axis: usize,
349) -> f64 {
350 let exponent = exponents[axis];
351 if exponent == 0 {
352 return 0.0;
353 }
354 let mut value = exponent as f64;
355 for a in 0..points.ncols() {
356 let power = exponents[a] - usize::from(a == axis);
357 if power != 0 {
358 value *= points[[row, a]].powi(power as i32);
359 }
360 }
361 value
362}
363
364fn monomial_second_derivative_value(
365 points: ArrayView2<'_, f64>,
366 row: usize,
367 exponents: &[usize],
368 axis_a: usize,
369 axis_b: usize,
370) -> f64 {
371 let coeff = if axis_a == axis_b {
372 let exponent = exponents[axis_a];
373 if exponent < 2 {
374 return 0.0;
375 }
376 (exponent * (exponent - 1)) as f64
377 } else {
378 let exponent_a = exponents[axis_a];
379 let exponent_b = exponents[axis_b];
380 if exponent_a == 0 || exponent_b == 0 {
381 return 0.0;
382 }
383 (exponent_a * exponent_b) as f64
384 };
385 let mut value = coeff;
386 for axis in 0..points.ncols() {
387 let consumed = usize::from(axis == axis_a) + usize::from(axis == axis_b);
388 let power = exponents[axis] - consumed;
389 if power != 0 {
390 value *= points[[row, axis]].powi(power as i32);
391 }
392 }
393 value
394}
395
396#[inline(always)]
397pub(crate) fn bessel_k0_stable(x: f64) -> f64 {
398 let x_pos = x.max(1e-300);
399 if x_pos <= 2.0 {
400 return bessel_k0_small_series(x_pos);
401 }
402 let y = 2.0 / x_pos;
403 (-x_pos).exp() / x_pos.sqrt()
404 * (1.253_314_14
405 + y * (-0.078_323_58
406 + y * (0.021_895_68
407 + y * (-0.010_624_46
408 + y * (0.005_878_72 + y * (-0.002_515_40 + y * 0.000_532_08))))))
409}
410
411#[inline(always)]
412pub(crate) fn bessel_k1_stable(x: f64) -> f64 {
413 let x_pos = x.max(1e-300);
414 if x_pos <= 2.0 {
415 return bessel_k1_small_series(x_pos);
416 }
417 let y = 2.0 / x_pos;
418 (-x_pos).exp() / x_pos.sqrt()
419 * (1.253_314_14
420 + y * (0.234_986_19
421 + y * (-0.036_556_20
422 + y * (0.015_042_68
423 + y * (-0.007_803_53 + y * (0.003_256_14 + y * -0.000_682_45))))))
424}
425
426#[inline(always)]
427pub(crate) fn bessel_k0_k1_small_series(x: f64) -> (f64, f64) {
428 const EULER_GAMMA: f64 = 0.577_215_664_901_532_9;
429 let y = 0.25 * x * x;
430 let log_half_plus_gamma = 0.5 * y.ln() + EULER_GAMMA;
431 let mut i0 = 1.0;
432 let mut i1 = 0.5 * x;
433 let mut harmonic = 0.0;
434 let mut y_power_over_fact_sq = 1.0;
435 let mut k0_series = 0.0;
436 let mut k0_series_y_derivative_times_y = 0.0;
437 for k in 1..=256 {
438 let kf = k as f64;
439 harmonic += 1.0 / kf;
440 y_power_over_fact_sq *= y / (kf * kf);
441 let k0_term = harmonic * y_power_over_fact_sq;
442 k0_series += k0_term;
443 k0_series_y_derivative_times_y += kf * k0_term;
444 i0 += y_power_over_fact_sq;
445 i1 += 0.5 * x * y_power_over_fact_sq / (kf + 1.0);
446 if k0_term.abs() <= f64::EPSILON * i0.abs().max(k0_series.abs()).max(1.0) {
447 break;
448 }
449 }
450
451 let k0 = -log_half_plus_gamma * i0 + k0_series;
452 let k1 = i0 / x + log_half_plus_gamma * i1 - (2.0 / x) * k0_series_y_derivative_times_y;
453 (k0, k1)
454}
455
456#[inline(always)]
457pub(crate) fn bessel_k0_small_series(x: f64) -> f64 {
458 bessel_k0_k1_small_series(x).0
459}
460
461#[inline(always)]
462pub(crate) fn bessel_k1_small_series(x: f64) -> f64 {
463 bessel_k0_k1_small_series(x).1
464}
465
466pub(crate) const DUCHON_DERIVATIVE_R_FLOOR_REL: f64 = 1e-5;
467
468pub(crate) const DUCHON_COLLISION_TAYLOR_REL: f64 = 1e-4;
469
470pub(crate) const RADIAL_PROFILE_MIN_PAIRS: usize = 16_384;
477
478#[inline(always)]
479pub(crate) fn duchon_p_from_nullspace_order(order: DuchonNullspaceOrder) -> usize {
480 match order {
481 DuchonNullspaceOrder::Zero => 1,
486 DuchonNullspaceOrder::Linear => 2,
487 DuchonNullspaceOrder::Degree(degree) => degree + 1,
488 }
489}
490
491pub(crate) fn duchon_effective_nullspace_order(
501 centers: ArrayView2<'_, f64>,
502 order: DuchonNullspaceOrder,
503) -> DuchonNullspaceOrder {
504 if order == DuchonNullspaceOrder::Zero {
505 return order;
506 }
507 let mut effective = order;
508 while effective != DuchonNullspaceOrder::Zero
509 && centers.nrows() <= polynomial_block_from_order(centers, effective).ncols()
510 {
511 effective = duchon_previous_nullspace_order(effective);
512 }
513 if effective != order {
514 static SEEN: std::sync::OnceLock<
517 std::sync::Mutex<std::collections::HashSet<(usize, usize, DuchonNullspaceOrder)>>,
518 > = std::sync::OnceLock::new();
519 let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
520 let key = (centers.nrows(), centers.ncols(), order);
521 let fresh = seen.lock().map(|mut s| s.insert(key)).unwrap_or(true);
522 if fresh {
523 let requested_cols = polynomial_block_from_order(centers, order).ncols();
524 let effective_cols = polynomial_block_from_order(centers, effective).ncols();
525 log::warn!(
526 "Duchon nullspace order={:?} in dim={} with {} centers leaves no radial kernel columns (polynomial_cols={}); degrading to {:?} (polynomial_cols={})",
527 order,
528 centers.ncols(),
529 centers.nrows(),
530 requested_cols,
531 effective,
532 effective_cols
533 );
534 }
535 }
536 effective
537}
538
539#[inline(always)]
540pub(crate) fn gamma_lanczos(x: f64) -> f64 {
541 const G: f64 = 7.0;
543 const P: [f64; 9] = [
544 0.999_999_999_999_809_9,
545 676.520_368_121_885_1,
546 -1_259.139_216_722_402_8,
547 771.323_428_777_653_1,
548 -176.615_029_162_140_6,
549 12.507_343_278_686_905,
550 -0.138_571_095_265_720_12,
551 9.984_369_578_019_571e-6,
552 1.505_632_735_149_311_6e-7,
553 ];
554 if x < 0.5 {
555 let pix = std::f64::consts::PI * x;
556 return std::f64::consts::PI / (pix.sin() * gamma_lanczos(1.0 - x));
557 }
558 let z = x - 1.0;
559 let mut a = P[0];
560 for (i, coeff) in P.iter().enumerate().skip(1) {
561 a += coeff / (z + i as f64);
562 }
563 let t = z + G + 0.5;
564 (2.0 * std::f64::consts::PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * a
565}
566
567#[inline(always)]
568pub(crate) fn bessel_k_integer_order(n: usize, z: f64) -> f64 {
569 let zz = z.max(1e-300);
570 if n == 0 {
571 return bessel_k0_stable(zz);
572 }
573 if n == 1 {
574 return bessel_k1_stable(zz);
575 }
576 let mut km1 = bessel_k0_stable(zz);
577 let mut k = bessel_k1_stable(zz);
578 for m in 1..n {
579 let kp1 = km1 + 2.0 * (m as f64) * k / zz;
580 km1 = k;
581 k = kp1;
582 }
583 k
584}
585
586#[inline(always)]
587pub(crate) fn bessel_k_half_integer_order(l: usize, z: f64) -> f64 {
588 let zz = z.max(1e-300);
600 let k_half = (std::f64::consts::PI / (2.0 * zz)).sqrt() * (-zz).exp();
601 if l == 0 {
602 return k_half;
603 }
604 let mut km1 = k_half;
605 let mut k = k_half * (1.0 + 1.0 / zz);
606 for m in 1..l {
607 let nu = m as f64 + 0.5;
608 let kp1 = km1 + 2.0 * nu * k / zz;
609 km1 = k;
610 k = kp1;
611 }
612 k
613}
614
615#[inline(always)]
616pub(crate) fn bessel_k_real_half_integer_or_integer(
617 nu_abs: f64,
618 z: f64,
619) -> Result<f64, BasisError> {
620 let two_nu = (2.0 * nu_abs).round();
621 if (two_nu - 2.0 * nu_abs).abs() > 1e-12 {
622 crate::bail_invalid_basis!(
623 "unsupported Bessel-K order ν={nu_abs}; only integer/half-integer orders are supported"
624 );
625 }
626 let two_nu_i = two_nu as i64;
627 if two_nu_i % 2 == 0 {
628 let n = (two_nu_i / 2).max(0) as usize;
629 Ok(bessel_k_integer_order(n, z))
630 } else {
631 let l = ((two_nu_i - 1) / 2).max(0) as usize;
632 Ok(bessel_k_half_integer_order(l, z))
633 }
634}
635
636#[derive(Clone, Copy)]
640pub(crate) struct PolyharmonicBlockCoeff {
641 pub(crate) c: f64,
642 pub(crate) power: f64,
643 pub(crate) is_log_case: bool,
644}
645
646impl PolyharmonicBlockCoeff {
647 pub(crate) fn new(m: f64, k_dim: usize) -> Self {
648 assert!(
649 m.is_finite() && m > 0.0,
650 "PolyharmonicBlockCoeff::new: m must be finite and > 0, got {m}"
651 );
652 let k_half = 0.5 * k_dim as f64;
653 let power = 2.0 * m - k_dim as f64;
654 const LOG_EPS: f64 = 1e-12;
658 let two_m = 2.0 * m;
659 let is_log_case = k_dim.is_multiple_of(2) && {
660 let n_f = (power / 2.0).round();
661 n_f >= 0.0 && (n_f * 2.0 - power).abs() < LOG_EPS
662 };
663 if is_log_case {
664 let m_int = m.round() as i64;
665 let m_minus_half_d_plus_one = (m - k_half + 1.0).round() as i64;
666 let c = polyharmonic_log_sign(m_int as usize, k_dim)
667 / (2.0_f64.powi((two_m.round() as i32) - 1)
668 * std::f64::consts::PI.powf(k_half)
669 * gamma_lanczos(m)
670 * gamma_lanczos(m_minus_half_d_plus_one as f64));
671 Self {
672 c,
673 power,
674 is_log_case: true,
675 }
676 } else {
677 let c = gamma_lanczos(k_half - m)
678 / (4.0_f64.powf(m) * std::f64::consts::PI.powf(k_half) * gamma_lanczos(m));
679 Self {
680 c,
681 power,
682 is_log_case: false,
683 }
684 }
685 }
686
687 #[inline(always)]
688 pub(crate) fn eval(&self, r: f64) -> f64 {
689 if r <= 0.0 {
690 return self.origin_limit();
691 }
692 if self.is_log_case {
693 self.c * r.powf(self.power) * r.max(1e-300).ln()
694 } else {
695 self.c * r.powf(self.power)
696 }
697 }
698
699 #[inline(always)]
700 pub(crate) fn origin_limit(&self) -> f64 {
701 if self.is_log_case {
702 log_power_origin_limit(self.c, self.power, 1.0, 0.0)
703 } else {
704 log_power_origin_limit(self.c, self.power, 0.0, 1.0)
705 }
706 }
707}
708
709pub(crate) fn polyharmonic_kernel(r: f64, m: f64, k_dim: usize) -> f64 {
710 PolyharmonicBlockCoeff::new(m, k_dim).eval(r)
711}
712
713#[inline(always)]
714pub(crate) fn signed_infinity(sign: f64) -> f64 {
715 if sign.is_sign_negative() {
716 f64::NEG_INFINITY
717 } else {
718 f64::INFINITY
719 }
720}
721
722#[inline(always)]
723pub(crate) fn log_power_origin_limit(
724 coeff: f64,
725 exponent: f64,
726 log_coeff: f64,
727 pure_coeff: f64,
728) -> f64 {
729 if log_coeff == 0.0 && pure_coeff == 0.0 {
730 return 0.0;
731 }
732 if exponent > 0.0 {
733 return 0.0;
734 }
735 if exponent == 0.0 {
736 if log_coeff != 0.0 {
737 signed_infinity(-coeff * log_coeff)
738 } else {
739 coeff * pure_coeff
740 }
741 } else if log_coeff != 0.0 {
742 signed_infinity(-coeff * log_coeff)
743 } else {
744 signed_infinity(coeff * pure_coeff)
745 }
746}
747
748#[inline(always)]
749pub(crate) fn polyharmonic_log_sign(m: usize, k_dim: usize) -> f64 {
750 assert!(
751 k_dim.is_multiple_of(2),
752 "polyharmonic_log_sign requires even kernel dimension: k_dim={k_dim}, m={m}"
753 );
754 (-1.0_f64).powi(m as i32 - (k_dim as i32 / 2) + 1)
755}
756
757#[inline(always)]
758pub(crate) fn duchon_matern_block(
759 r: f64,
760 kappa: f64,
761 n_order: usize,
762 k_dim: usize,
763) -> Result<f64, BasisError> {
764 let n = n_order as f64;
765 let k_half = 0.5 * k_dim as f64;
766 let nu = n - k_half;
767 let nu_abs = nu.abs();
768 let c = kappa.powf(k_half - n)
769 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
770 if r <= 0.0 {
771 if nu > 0.0 {
772 return Ok(c * 2.0_f64.powf(nu - 1.0) * gamma_lanczos(nu) * kappa.powf(-nu));
774 }
775 crate::bail_invalid_basis!(
781 "Duchon Matérn block at r=0 with ν={nu} ≤ 0 is divergent; \
782 evaluate the hybrid kernel diagonal via the collision routine"
783 );
784 }
785 let z = (kappa * r).max(1e-300);
786 let k_nu = bessel_k_real_half_integer_or_integer(nu_abs, z)?;
787 Ok(c * r.powf(nu) * k_nu)
788}
789
790#[inline(always)]
791pub(crate) fn polyharmonic_kernel_triplet(
792 r: f64,
793 m: f64,
794 k_dim: usize,
795) -> Result<(f64, f64, f64), BasisError> {
796 let (value, first, second, _, _) = polyharmonic_block_jet4(r, m, k_dim)?;
797 Ok((value, first, second))
798}
799
800#[inline(always)]
801pub(crate) fn falling_factorial(alpha: f64, order: usize) -> f64 {
802 (0..order).fold(1.0, |acc, idx| acc * (alpha - idx as f64))
803}
804
805#[inline(always)]
806pub(crate) fn falling_factorial_derivative(alpha: f64, order: usize) -> f64 {
807 if order == 0 {
808 return 0.0;
809 }
810 let mut total = 0.0;
811 for omit in 0..order {
812 let mut term = 1.0;
813 for idx in 0..order {
814 if idx != omit {
815 term *= alpha - idx as f64;
816 }
817 }
818 total += term;
819 }
820 total
821}
822
823pub(crate) fn polyharmonic_block_jet4(
830 r: f64,
831 m: f64,
832 k_dim: usize,
833) -> Result<(f64, f64, f64, f64, f64), BasisError> {
834 if !r.is_finite() || r < 0.0 {
835 crate::bail_invalid_basis!("polyharmonic distance must be finite and non-negative");
836 }
837 assert!(
838 m.is_finite() && m > 0.0,
839 "polyharmonic_block_jet4: m must be finite and > 0, got {m}"
840 );
841
842 let k_half = 0.5 * k_dim as f64;
843 let alpha = 2.0 * m - k_dim as f64;
844 const LOG_EPS: f64 = 1e-12;
847 let is_log_case = k_dim.is_multiple_of(2) && {
848 let n_f = (alpha / 2.0).round();
849 n_f >= 0.0 && (n_f * 2.0 - alpha).abs() < LOG_EPS
850 };
851 if is_log_case {
852 let m_int = m.round() as usize;
853 let c = polyharmonic_log_sign(m_int, k_dim)
854 / (2.0_f64.powi((2 * m_int - 1) as i32)
855 * std::f64::consts::PI.powf(k_half)
856 * gamma_lanczos(m)
857 * gamma_lanczos((m_int - k_dim / 2 + 1) as f64));
858 let mut out = [0.0; 5];
859 for d in 0..5 {
860 let e = alpha - d as f64;
861 let ff = falling_factorial(alpha, d);
862 let ff_d = falling_factorial_derivative(alpha, d);
863 out[d] = if r <= 0.0 {
864 log_power_origin_limit(c, e, ff, ff_d)
865 } else {
866 c * r.powf(e) * (ff * r.ln() + ff_d)
867 };
868 }
869 return Ok((out[0], out[1], out[2], out[3], out[4]));
870 }
871
872 let c = gamma_lanczos(k_half - m)
873 / (4.0_f64.powf(m) * std::f64::consts::PI.powf(k_half) * gamma_lanczos(m));
874 let mut out = [0.0; 5];
875 for d in 0..5 {
876 let e = alpha - d as f64;
877 let ff = falling_factorial(alpha, d);
878 out[d] = if r <= 0.0 {
879 log_power_origin_limit(c, e, 0.0, ff)
880 } else {
881 c * ff * r.powf(e)
882 };
883 }
884 Ok((out[0], out[1], out[2], out[3], out[4]))
885}
886
887#[inline(always)]
888pub(crate) fn log_power_family_derivative(
889 exponent: f64,
890 log_coeff: f64,
891 pure_coeff: f64,
892) -> (f64, f64, f64) {
893 (
894 exponent - 1.0,
895 exponent * log_coeff,
896 exponent * pure_coeff + log_coeff,
897 )
898}
899
900#[inline(always)]
901pub(crate) fn log_power_family_value(
902 r: f64,
903 coeff: f64,
904 exponent: f64,
905 log_coeff: f64,
906 pure_coeff: f64,
907) -> f64 {
908 if r <= 0.0 {
909 log_power_origin_limit(coeff, exponent, log_coeff, pure_coeff)
910 } else {
911 coeff * r.powf(exponent) * (log_coeff * r.ln() + pure_coeff)
912 }
913}
914
915#[inline(always)]
916pub(crate) fn duchon_polyharmonic_operator_block_jets(
917 r: f64,
918 m: f64,
919 k_dim: usize,
920) -> Result<(f64, f64, f64, f64), BasisError> {
921 if !r.is_finite() || r < 0.0 {
922 crate::bail_invalid_basis!("polyharmonic distance must be finite and non-negative");
923 }
924 assert!(
925 m.is_finite() && m > 0.0,
926 "duchon_polyharmonic_operator_block_jets: m must be finite and > 0, got {m}"
927 );
928
929 let k_half = 0.5 * k_dim as f64;
930 let alpha = 2.0 * m - k_dim as f64;
931 const LOG_EPS: f64 = 1e-12;
935 let is_log_case = k_dim.is_multiple_of(2) && {
936 let n_f = (alpha / 2.0).round();
937 n_f >= 0.0 && (n_f * 2.0 - alpha).abs() < LOG_EPS
938 };
939 let (c, phi_log_coeff, phi_pure_coeff) = if is_log_case {
940 let m_int = m.round() as usize;
941 (
942 polyharmonic_log_sign(m_int, k_dim)
943 / (2.0_f64.powi((2 * m_int - 1) as i32)
944 * std::f64::consts::PI.powf(k_half)
945 * gamma_lanczos(m)
946 * gamma_lanczos((m_int - k_dim / 2 + 1) as f64)),
947 1.0,
948 0.0,
949 )
950 } else {
951 (
952 gamma_lanczos(k_half - m)
953 / (4.0_f64.powf(m) * std::f64::consts::PI.powf(k_half) * gamma_lanczos(m)),
954 0.0,
955 1.0,
956 )
957 };
958
959 let (phi_r_exp, phi_r_log, phi_r_pure) =
960 log_power_family_derivative(alpha, phi_log_coeff, phi_pure_coeff);
961 let q_exp = phi_r_exp - 1.0;
962 let q = log_power_family_value(r, c, q_exp, phi_r_log, phi_r_pure);
963
964 let (q_r_exp_raw, q_r_log, q_r_pure) =
965 log_power_family_derivative(q_exp, phi_r_log, phi_r_pure);
966 let t_exp = q_r_exp_raw - 1.0;
967 let t = log_power_family_value(r, c, t_exp, q_r_log, q_r_pure);
968
969 let (t_r_exp, t_r_log, t_r_pure) = log_power_family_derivative(t_exp, q_r_log, q_r_pure);
970 let t_r = log_power_family_value(r, c, t_r_exp, t_r_log, t_r_pure);
971
972 let (t_rr_exp, t_rr_log, t_rr_pure) = log_power_family_derivative(t_r_exp, t_r_log, t_r_pure);
973 let t_rr = log_power_family_value(r, c, t_rr_exp, t_rr_log, t_rr_pure);
974
975 Ok((q, t, t_r, t_rr))
976}
977
978pub(crate) struct BesselKLadder {
996 pub(crate) values: SmallVec<[f64; 16]>,
998 pub(crate) half_integer: bool,
999}
1000
1001impl BesselKLadder {
1002 pub(crate) fn build(z: f64, half_integer: bool, max_order_steps: usize) -> Self {
1003 let zz = z.max(1e-300);
1004 let mut values: SmallVec<[f64; 16]> = SmallVec::with_capacity(max_order_steps + 2);
1005 if half_integer {
1006 let k_half = (std::f64::consts::PI / (2.0 * zz)).sqrt() * (-zz).exp();
1008 values.push(k_half);
1009 values.push(k_half * (1.0 + 1.0 / zz));
1010 } else {
1011 values.push(bessel_k0_stable(zz));
1012 values.push(bessel_k1_stable(zz));
1013 }
1014 let base = if half_integer { 0.5 } else { 0.0 };
1015 for i in 1..max_order_steps {
1016 let nu = base + i as f64;
1017 let next = values[i - 1] + 2.0 * nu * values[i] / zz;
1018 values.push(next);
1019 }
1020 Self {
1021 values,
1022 half_integer,
1023 }
1024 }
1025
1026 #[inline]
1028 pub(crate) fn k_abs(&self, order_abs: f64) -> f64 {
1029 let base = if self.half_integer { 0.5 } else { 0.0 };
1030 let idx = (order_abs - base).round() as usize;
1031 self.values[idx]
1032 }
1033}
1034
1035pub(crate) fn duchon_matern_family_jets_with_ladder(
1057 r: f64,
1058 kappa: f64,
1059 coeff: f64,
1060 mu: f64,
1061 max_j: usize,
1062 ladder: &BesselKLadder,
1063 out: &mut [f64],
1064) -> Result<(), BasisError> {
1065 if max_j > 4 || out.len() <= max_j {
1066 crate::bail_invalid_basis!(
1067 "Duchon Matérn-family ladder jets support derivative orders 0..=4 with an output slot per order"
1068 );
1069 }
1070 if r <= 0.0 {
1071 out[..=max_j].fill(0.0);
1072 if mu > 0.0 {
1073 out[0] = coeff * 2.0_f64.powf(mu - 1.0) * gamma_lanczos(mu) * kappa.powf(-mu);
1074 }
1075 return Ok(());
1076 }
1077 let mut terms: SmallVec<[DuchonMaternDerivativeTerm; 16]> =
1078 smallvec![DuchonMaternDerivativeTerm {
1079 coeff,
1080 kappa_power: 0,
1081 r_power: mu,
1082 bessel_order: mu,
1083 }];
1084 for (j, slot) in out.iter_mut().enumerate().take(max_j + 1) {
1085 if j > 0 {
1086 let mut next: SmallVec<[DuchonMaternDerivativeTerm; 16]> =
1087 SmallVec::with_capacity(terms.len() * 2);
1088 for term in &terms {
1089 let stay_coeff = term.coeff * (term.r_power - term.bessel_order);
1090 if stay_coeff != 0.0 {
1091 next.push(DuchonMaternDerivativeTerm {
1092 coeff: stay_coeff,
1093 kappa_power: term.kappa_power,
1094 r_power: term.r_power - 1.0,
1095 bessel_order: term.bessel_order,
1096 });
1097 }
1098 next.push(DuchonMaternDerivativeTerm {
1099 coeff: -term.coeff,
1100 kappa_power: term.kappa_power + 1,
1101 r_power: term.r_power,
1102 bessel_order: term.bessel_order - 1.0,
1103 });
1104 }
1105 terms = next;
1106 }
1107 let mut value = KahanSum::default();
1108 for term in &terms {
1109 if term.coeff == 0.0 {
1110 continue;
1111 }
1112 value.add(
1113 term.coeff
1114 * kappa.powi(term.kappa_power as i32)
1115 * r.powf(term.r_power)
1116 * ladder.k_abs(term.bessel_order.abs()),
1117 );
1118 }
1119 *slot = value.sum();
1120 }
1121 Ok(())
1122}
1123
1124pub(crate) fn duchon_matern_block_max_ladder_steps(n_order: usize, k_dim: usize) -> usize {
1128 let nu = n_order as f64 - 0.5 * k_dim as f64;
1129 let candidates = [
1130 (nu - 1.0).abs(),
1131 (nu - 2.0).abs(),
1132 (nu - 3.0).abs(),
1133 (nu - 4.0).abs(),
1134 ];
1135 let max_abs = candidates.iter().copied().fold(0.0_f64, f64::max);
1136 max_abs.floor() as usize + 1
1137}
1138
1139pub(crate) fn duchon_matern_operator_block_jets_with_ladder(
1140 r: f64,
1141 kappa: f64,
1142 n_order: usize,
1143 k_dim: usize,
1144 ladder: &BesselKLadder,
1145) -> Result<(f64, f64, f64, f64), BasisError> {
1146 if r <= 0.0 {
1147 return Ok((0.0, 0.0, 0.0, 0.0));
1148 }
1149 let n = n_order as f64;
1150 let k_half = 0.5 * k_dim as f64;
1151 let nu = n - k_half;
1152 let c = kappa.powf(k_half - n)
1153 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
1154
1155 let mut q_out = [0.0_f64; 1];
1156 duchon_matern_family_jets_with_ladder(r, kappa, -c * kappa, nu - 1.0, 0, ladder, &mut q_out)?;
1157 let mut t_out = [0.0_f64; 3];
1158 duchon_matern_family_jets_with_ladder(
1159 r,
1160 kappa,
1161 c * kappa * kappa,
1162 nu - 2.0,
1163 2,
1164 ladder,
1165 &mut t_out,
1166 )?;
1167 Ok((q_out[0], t_out[0], t_out[1], t_out[2]))
1168}
1169
1170#[inline(always)]
1171pub(crate) fn pure_duchon_block_order(p_order: usize, s_order: f64) -> f64 {
1172 p_order as f64 + s_order
1173}
1174
1175pub(crate) fn validate_duchon_kernel_orders(
1176 length_scale: Option<f64>,
1177 p_order: usize,
1178 s_order: f64,
1179 k_dim: usize,
1180) -> Result<(), BasisError> {
1181 if k_dim == 0 {
1182 crate::bail_invalid_basis!("Duchon basis requires at least one covariate dimension");
1183 }
1184 if let Some(scale) = length_scale
1185 && (!scale.is_finite() || scale <= 0.0)
1186 {
1187 crate::bail_invalid_basis!("Duchon hybrid length_scale must be finite and positive");
1188 }
1189 if !s_order.is_finite() || s_order < 0.0 {
1228 crate::bail_invalid_basis!("Duchon spectral power must be finite and ≥ 0; got s={s_order}");
1229 }
1230 if length_scale.is_none() && p_order < 2 && 2.0 * s_order >= k_dim as f64 {
1231 crate::bail_invalid_basis!(
1232 "pure Duchon requires power < dimension/2 for nullspace degree < {p_order}; got power={s_order}, dimension={k_dim}"
1233 );
1234 }
1235 let spectral_order = 2.0 * (p_order as f64 + s_order);
1236 if spectral_order <= k_dim as f64 {
1237 crate::bail_invalid_basis!(
1238 "Duchon pointwise kernel values require 2*(p+s) > dimension; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1239 );
1240 }
1241 Ok(())
1242}
1243
1244pub(crate) fn validate_duchon_collocation_orders(
1245 length_scale: Option<f64>,
1246 p_order: usize,
1247 s_order: f64,
1248 k_dim: usize,
1249 max_operator_derivative_order: usize,
1250) -> Result<(), BasisError> {
1251 validate_duchon_kernel_orders(length_scale, p_order, s_order, k_dim)?;
1254 let spectral_order = 2.0 * (p_order as f64 + s_order);
1267 if max_operator_derivative_order >= 1 && spectral_order <= k_dim as f64 + 1.0 {
1268 crate::bail_invalid_basis!(
1269 "Duchon D1 collocation requires 2*(p+s) > dimension+1; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1270 );
1271 }
1272 if max_operator_derivative_order >= 2 && spectral_order <= k_dim as f64 + 2.0 {
1273 crate::bail_invalid_basis!(
1274 "Duchon D2 collocation requires 2*(p+s) > dimension+2; got 2*(p+s)={spectral_order}, dimension={k_dim}, p={p_order}, s={s_order}"
1275 );
1276 }
1277 Ok(())
1278}
1279
1280#[derive(Debug, Clone)]
1281pub struct DuchonPartialFractionCoeffs {
1282 pub(crate) a: Vec<f64>,
1283 pub(crate) b: Vec<f64>,
1284}
1285
1286#[inline(always)]
1287pub(crate) fn duchon_partial_fraction_coeffs(
1288 p_order: usize,
1289 s_order: usize,
1290 kappa: f64,
1291) -> DuchonPartialFractionCoeffs {
1292 let mut a = vec![0.0_f64; p_order + 1]; let mut b = vec![0.0_f64; s_order + 1]; if s_order == 0 {
1296 if p_order > 0 {
1297 a[p_order] = 1.0;
1300 }
1301 return DuchonPartialFractionCoeffs { a, b };
1302 }
1303 for m in 1..=p_order {
1304 let sign = if (p_order - m).is_multiple_of(2) {
1305 1.0
1306 } else {
1307 -1.0
1308 };
1309 let expo = -2.0 * (s_order + p_order - m) as f64;
1310 let comb = binomial_f64(s_order + p_order - m - 1, p_order - m);
1311 a[m] = sign * kappa.powf(expo) * comb;
1312 }
1313 for n in 1..=s_order {
1314 let sign = if p_order.is_multiple_of(2) { 1.0 } else { -1.0 };
1315 let expo = -2.0 * (p_order + s_order - n) as f64;
1316 let comb = if p_order == 0 && n == s_order {
1317 1.0
1319 } else {
1320 let top = p_order + s_order - n - 1;
1321 binomial_f64(top, s_order - n)
1322 };
1323 b[n] = sign * kappa.powf(expo) * comb;
1324 }
1325 DuchonPartialFractionCoeffs { a, b }
1326}
1327
1328fn gauss_legendre_01_64() -> &'static [(f64, f64)] {
1337 use std::sync::OnceLock;
1338 static NODES: OnceLock<Vec<(f64, f64)>> = OnceLock::new();
1339 NODES.get_or_init(|| {
1340 const N: usize = 64;
1346 let nf = N as f64;
1347 let mut nodes: Vec<(f64, f64)> = Vec::with_capacity(N);
1348 let half = N.div_ceil(2);
1349 for i in 0..half {
1350 let mut x = (std::f64::consts::PI * (i as f64 + 0.75) / (nf + 0.5)).cos();
1352 let mut dp = 0.0_f64;
1353 for _ in 0..100 {
1354 let mut p0 = 1.0_f64;
1357 let mut p1 = x;
1358 for k in 2..=N {
1359 let kf = k as f64;
1360 let p2 = ((2.0 * kf - 1.0) * x * p1 - (kf - 1.0) * p0) / kf;
1361 p0 = p1;
1362 p1 = p2;
1363 }
1364 dp = nf * (x * p1 - p0) / (x * x - 1.0);
1366 let dx = p1 / dp;
1367 x -= dx;
1368 if dx.abs() <= 1e-16 * x.abs().max(1.0) {
1369 break;
1370 }
1371 }
1372 let w = 2.0 / ((1.0 - x * x) * dp * dp);
1374 nodes.push((x, w));
1376 if x.abs() > 1e-300 {
1377 nodes.push((-x, w));
1378 }
1379 }
1380 nodes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
1382 nodes
1383 .into_iter()
1384 .map(|(x, w)| (0.5 * (x + 1.0), 0.5 * w))
1385 .collect()
1386 })
1387}
1388
1389pub(crate) fn duchon_hybrid_kernel_stable_integral(
1419 r: f64,
1420 kappa: f64,
1421 p_order: usize,
1422 s_order: usize,
1423 k_dim: usize,
1424) -> Result<f64, BasisError> {
1425 assert!(
1426 duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
1427 "duchon_hybrid_kernel_stable_integral precondition violated: 2(p+s) > d and 2p < d required (p={p_order}, s={s_order}, d={k_dim})"
1428 );
1429 let p = p_order as f64;
1430 let s = s_order as f64;
1431 let half_d = 0.5 * k_dim as f64;
1432 let b = p + s - half_d;
1433 let pref = (4.0 * std::f64::consts::PI).powf(-half_d) / (gamma_lanczos(p) * gamma_lanczos(s));
1434 if r == 0.0 {
1435 let beta = gamma_lanczos(s - b) * gamma_lanczos(p) / gamma_lanczos(s - b + p);
1437 let value = pref * gamma_lanczos(b) * kappa.powf(-2.0 * b) * beta;
1438 if !value.is_finite() {
1439 crate::bail_invalid_basis!(
1440 "non-finite Duchon hybrid diagonal (stable form) for p={p_order}, s={s_order}, d={k_dim}"
1441 );
1442 }
1443 return Ok(value);
1444 }
1445 let mut acc = KahanSum::default();
1446 for &(w, weight) in gauss_legendre_01_64() {
1447 let sqrt_w = w.sqrt();
1449 let z = (kappa * r * sqrt_w).max(1e-300);
1450 let k_b = bessel_k_real_half_integer_or_integer(b.abs(), z)?;
1451 let smooth = 2.0 * (r / (2.0 * kappa * sqrt_w)).powf(b) * k_b;
1452 let factor = (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * smooth;
1453 acc.add(weight * factor);
1454 }
1455 let value = pref * acc.sum();
1456 if !value.is_finite() {
1457 crate::bail_invalid_basis!(
1458 "non-finite Duchon hybrid value (stable form) at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1459 );
1460 }
1461 Ok(value)
1462}
1463
1464pub(crate) fn duchon_hybrid_operator_stable_integral(
1491 r: f64,
1492 kappa: f64,
1493 p_order: usize,
1494 s_order: usize,
1495 k_dim: usize,
1496) -> Result<DuchonRegularizedOperatorCore, BasisError> {
1497 assert!(
1498 duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
1499 "duchon_hybrid_operator_stable_integral precondition violated: 2(p+s) > d and 2p < d required (p={p_order}, s={s_order}, d={k_dim})"
1500 );
1501 assert!(
1502 r > 0.0 && r.is_finite(),
1503 "duchon_hybrid_operator_stable_integral requires r > 0, got r={r}"
1504 );
1505 let p = p_order as f64;
1506 let s = s_order as f64;
1507 let half_d = 0.5 * k_dim as f64;
1508 let b = p + s - half_d;
1509 let pref = (4.0 * std::f64::consts::PI).powf(-half_d) / (gamma_lanczos(p) * gamma_lanczos(s));
1510
1511 let mut d1 = KahanSum::default();
1514 let mut d2 = KahanSum::default();
1515 let mut d3 = KahanSum::default();
1516 let mut d4 = KahanSum::default();
1517
1518 for &(w, weight) in gauss_legendre_01_64() {
1519 let sqrt_w = w.sqrt();
1520 let c = (kappa * sqrt_w).max(1e-300);
1521 let z = (c * r).max(1e-300);
1522
1523 let a0 = 2.0 * (2.0 * c).powf(-b);
1530 let mut terms: Vec<(f64, f64, i32)> = vec![(a0, b, 0)];
1531 let bessel = |j: i32| -> Result<f64, BasisError> {
1533 bessel_k_real_half_integer_or_integer((b + j as f64).abs(), z)
1534 };
1535 let evaluate = |terms: &Vec<(f64, f64, i32)>| -> Result<f64, BasisError> {
1536 let mut acc = KahanSum::default();
1537 for &(c0, a, j) in terms {
1538 if c0 == 0.0 {
1539 continue;
1540 }
1541 acc.add(c0 * r.powf(a) * bessel(j)?);
1542 }
1543 Ok(acc.sum())
1544 };
1545
1546 let mut slice_derivs = [0.0_f64; 4];
1547 for slot in slice_derivs.iter_mut() {
1548 let mut next: Vec<(f64, f64, i32)> = Vec::with_capacity(terms.len() * 3);
1550 for &(c0, a, j) in &terms {
1551 if c0 == 0.0 {
1552 continue;
1553 }
1554 if a != 0.0 {
1555 next.push((c0 * a, a - 1.0, j));
1556 }
1557 let half = -c0 * c * 0.5;
1558 next.push((half, a, j - 1));
1559 next.push((half, a, j + 1));
1560 }
1561 terms = next;
1562 *slot = evaluate(&terms)?;
1563 }
1564
1565 d1.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[0]);
1566 d2.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[1]);
1567 d3.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[2]);
1568 d4.add(weight * (1.0 - w).powf(p - 1.0) * w.powf(s - 1.0) * slice_derivs[3]);
1569 }
1570
1571 let phi1 = pref * d1.sum();
1572 let phi2 = pref * d2.sum();
1573 let phi3 = pref * d3.sum();
1574 let phi4 = pref * d4.sum();
1575 if !(phi1.is_finite() && phi2.is_finite() && phi3.is_finite() && phi4.is_finite()) {
1576 crate::bail_invalid_basis!(
1577 "non-finite Duchon hybrid operator (stable form) at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1578 );
1579 }
1580
1581 let inv_r = 1.0 / r;
1585 let q = phi1 * inv_r;
1586 let q_r = phi2 * inv_r - phi1 * inv_r * inv_r;
1589 let q_rr = phi3 * inv_r - 2.0 * phi2 * inv_r * inv_r + 2.0 * phi1 * inv_r * inv_r * inv_r;
1590 let q_rrr = phi4 * inv_r - 3.0 * phi3 * inv_r * inv_r + 6.0 * phi2 * inv_r * inv_r * inv_r
1591 - 6.0 * phi1 * inv_r * inv_r * inv_r * inv_r;
1592 let t = q_r * inv_r;
1593 let t_r = q_rr * inv_r - q_r * inv_r * inv_r;
1594 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;
1595
1596 Ok(DuchonRegularizedOperatorCore { q, t, t_r, t_rr })
1597}
1598
1599#[inline]
1608pub(crate) fn duchon_hybrid_stable_integral_applies(
1609 p_order: usize,
1610 s_order: usize,
1611 k_dim: usize,
1612) -> bool {
1613 s_order >= 1 && 2 * p_order < k_dim
1614}
1615
1616pub(crate) fn duchon_matern_kernel_general_from_distance(
1617 r: f64,
1618 length_scale: Option<f64>,
1619 p_order: usize,
1620 s_order: usize,
1621 k_dim: usize,
1622 coeffs: Option<&DuchonPartialFractionCoeffs>,
1623) -> Result<f64, BasisError> {
1624 if !r.is_finite() || r < 0.0 {
1625 crate::bail_invalid_basis!("Duchon kernel distance must be finite and non-negative");
1626 }
1627 let Some(length_scale) = length_scale else {
1628 return Ok(polyharmonic_kernel(
1629 r,
1630 pure_duchon_block_order(p_order, s_order as f64),
1631 k_dim,
1632 ));
1633 };
1634 if !length_scale.is_finite() || length_scale <= 0.0 {
1635 crate::bail_invalid_basis!("Duchon hybrid length_scale must be finite and positive");
1636 }
1637 let kappa = 1.0 / length_scale;
1638
1639 if duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim) {
1647 return duchon_hybrid_kernel_stable_integral(r, kappa, p_order, s_order, k_dim);
1648 }
1649
1650 let coeffs_local;
1651 let coeffs_ref = if let Some(c) = coeffs {
1652 c
1653 } else {
1654 coeffs_local = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
1655 &coeffs_local
1656 };
1657 let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale.max(1e-8);
1658 let kernel_finite_at_origin = 2 * (p_order + s_order) > k_dim;
1666 if r <= collision_taylor_radius && kernel_finite_at_origin {
1667 return duchon_hybrid_kernel_near_collision_value(
1668 r,
1669 length_scale,
1670 p_order,
1671 s_order,
1672 k_dim,
1673 coeffs_ref,
1674 );
1675 }
1676 let mut val = KahanSum::default();
1677 for (m, coeff) in coeffs_ref.a.iter().enumerate().skip(1) {
1678 if *coeff == 0.0 {
1679 continue;
1680 }
1681 val.add(coeff * polyharmonic_kernel(r, (m) as f64, k_dim));
1682 }
1683 for (n, coeff) in coeffs_ref.b.iter().enumerate().skip(1) {
1684 if *coeff == 0.0 {
1685 continue;
1686 }
1687 val.add(coeff * duchon_matern_block(r, kappa, n, k_dim)?);
1688 }
1689 Ok(val.sum())
1690}
1691
1692pub(crate) fn duchon_hybrid_kernel_collision_value(
1693 length_scale: f64,
1694 p_order: usize,
1695 s_order: usize,
1696 k_dim: usize,
1697 coeffs: &DuchonPartialFractionCoeffs,
1698) -> Result<f64, BasisError> {
1699 let spectral_order = 2 * (p_order + s_order);
1700 if spectral_order <= k_dim {
1701 crate::bail_invalid_basis!(
1702 "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}"
1703 );
1704 }
1705
1706 let kappa = 1.0 / length_scale.max(1e-300);
1707 let mut pure = KahanSum::default();
1708 let mut log_part = KahanSum::default();
1709 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1710 if a_m == 0.0 {
1711 continue;
1712 }
1713 let (block_pure, block_log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, 0);
1714 pure.add(a_m * block_pure);
1715 log_part.add(a_m * block_log);
1716 }
1717 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1718 if b_n == 0.0 {
1719 continue;
1720 }
1721 let (block_pure, block_log) = duchon_matern_block_taylor_r2j(kappa, n, k_dim, 0);
1722 pure.add(b_n * block_pure);
1723 log_part.add(b_n * block_log);
1724 }
1725 let value = pure.sum();
1726 let log_value = log_part.sum();
1727 if log_value.abs() > 1e-8 * value.abs().max(1e-30) {
1728 crate::bail_invalid_basis!(
1729 "Duchon hybrid diagonal log terms did not cancel: log={log_value:.6e}, value={value:.6e}; p={p_order}, s={s_order}, d={k_dim}"
1730 );
1731 }
1732 if !value.is_finite() {
1733 crate::bail_invalid_basis!(
1734 "non-finite Duchon hybrid diagonal value for p={p_order}, s={s_order}, d={k_dim}"
1735 );
1736 }
1737 Ok(value)
1738}
1739
1740pub(crate) fn duchon_hybrid_kernel_near_collision_value(
1741 r: f64,
1742 length_scale: f64,
1743 p_order: usize,
1744 s_order: usize,
1745 k_dim: usize,
1746 coeffs: &DuchonPartialFractionCoeffs,
1747) -> Result<f64, BasisError> {
1748 let mut value =
1749 duchon_hybrid_kernel_collision_value(length_scale, p_order, s_order, k_dim, coeffs)?;
1750 if r == 0.0 {
1751 return Ok(value);
1752 }
1753
1754 let smoothness_order = 2 * (p_order + s_order);
1768 let r2 = r * r;
1769 if smoothness_order > k_dim + 2 {
1770 let (phi_rr, _, _) =
1771 duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
1772 value += 0.5 * phi_rr * r2;
1773 }
1774 if smoothness_order > k_dim + 4 {
1775 let phi_rrrr = duchon_phi_rrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)?;
1776 value += (1.0 / 24.0) * phi_rrrr * r2 * r2;
1777 }
1778 if smoothness_order > k_dim + 6 {
1779 let phi_rrrrrr =
1780 duchon_phi_rrrrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)?;
1781 value += (1.0 / 720.0) * phi_rrrrrr * r2 * r2 * r2;
1782 }
1783 if !value.is_finite() {
1784 crate::bail_invalid_basis!(
1785 "non-finite Duchon hybrid near-collision value at r={r}, p={p_order}, s={s_order}, d={k_dim}"
1786 );
1787 }
1788 Ok(value)
1789}
1790
1791#[inline(always)]
1792pub(crate) fn stable_euclidean_norm<I>(components: I) -> f64
1793where
1794 I: IntoIterator<Item = f64>,
1795{
1796 let mut scale = 0.0_f64;
1797 let mut sumsq = 1.0_f64;
1798 let mut has_nonzero = false;
1799 for component in components {
1800 let abs = component.abs();
1801 if abs == 0.0 {
1802 continue;
1803 }
1804 if !abs.is_finite() {
1805 return f64::INFINITY;
1806 }
1807 if !has_nonzero {
1808 scale = abs;
1809 has_nonzero = true;
1810 continue;
1811 }
1812 if scale < abs {
1813 let ratio = scale / abs;
1814 sumsq = 1.0 + sumsq * ratio * ratio;
1815 scale = abs;
1816 } else {
1817 let ratio = abs / scale;
1818 sumsq += ratio * ratio;
1819 }
1820 }
1821 if has_nonzero {
1822 scale * sumsq.sqrt()
1823 } else {
1824 0.0
1825 }
1826}
1827
1828#[inline]
1829pub(crate) fn centered_aniso_log_scale_mean(eta: &[f64]) -> f64 {
1830 if eta.len() <= 1 {
1831 0.0
1832 } else {
1833 eta.iter().sum::<f64>() / eta.len() as f64
1834 }
1835}
1836
1837#[inline]
1838pub(crate) fn centered_aniso_log_scale(value: f64, mean: f64) -> f64 {
1839 let centered = value - mean;
1845 if centered.is_finite() {
1846 centered.clamp(-50.0, 50.0)
1847 } else if centered > 0.0 {
1848 50.0
1849 } else {
1850 -50.0
1851 }
1852}
1853
1854#[inline]
1855pub(crate) fn aniso_axis_scale(value: f64, mean: f64) -> f64 {
1856 centered_aniso_log_scale(value, mean).exp()
1857}
1858
1859#[inline]
1860pub(crate) fn aniso_metric_weight(value: f64, mean: f64) -> f64 {
1861 (2.0 * centered_aniso_log_scale(value, mean)).exp()
1862}
1863
1864pub(crate) fn centered_aniso_metric_weights(eta: &[f64]) -> Vec<f64> {
1865 let mean = centered_aniso_log_scale_mean(eta);
1866 eta.iter()
1867 .map(|&value| aniso_metric_weight(value, mean))
1868 .collect()
1869}
1870
1871#[inline]
1898pub(crate) fn aniso_distance_and_components(
1899 data_row: &[f64],
1900 center: &[f64],
1901 eta: &[f64],
1902) -> (f64, Vec<f64>) {
1903 assert_eq!(data_row.len(), center.len());
1904 assert_eq!(data_row.len(), eta.len());
1905 let d = data_row.len();
1906 let eta_mean = centered_aniso_log_scale_mean(eta);
1907 let mut s_vec = Vec::with_capacity(d);
1908 let mut scaled_components = Vec::with_capacity(d);
1909 for a in 0..d {
1910 let h_a = data_row[a] - center[a];
1911 let scale_a = aniso_axis_scale(eta[a], eta_mean);
1913 let scaled_h_a = scale_a * h_a;
1914 let s_a = scaled_h_a * scaled_h_a;
1915 scaled_components.push(scaled_h_a);
1916 s_vec.push(s_a);
1917 }
1918 (stable_euclidean_norm(scaled_components), s_vec)
1919}
1920
1921#[inline]
1926pub(crate) fn aniso_distance(data_row: &[f64], center: &[f64], eta: &[f64]) -> f64 {
1927 assert_eq!(data_row.len(), center.len());
1928 assert_eq!(data_row.len(), eta.len());
1929 let eta_mean = centered_aniso_log_scale_mean(eta);
1930 stable_euclidean_norm(
1931 (0..data_row.len()).map(|a| aniso_axis_scale(eta[a], eta_mean) * (data_row[a] - center[a])),
1932 )
1933}
1934
1935#[inline(always)]
1936pub(crate) fn euclidean_distance_rows(
1937 lhs: ArrayView2<'_, f64>,
1938 lhs_row: usize,
1939 rhs: ArrayView2<'_, f64>,
1940 rhs_row: usize,
1941) -> f64 {
1942 assert_eq!(lhs.ncols(), rhs.ncols());
1943 stable_euclidean_norm((0..lhs.ncols()).map(|axis| lhs[[lhs_row, axis]] - rhs[[rhs_row, axis]]))
1944}
1945
1946#[inline(always)]
1947pub(crate) fn aniso_axis_scales(eta: &[f64]) -> Vec<f64> {
1948 let eta_mean = centered_aniso_log_scale_mean(eta);
1949 eta.iter()
1950 .map(|&value| aniso_axis_scale(value, eta_mean))
1951 .collect()
1952}
1953
1954#[inline(always)]
1955pub(crate) fn aniso_distance_rows_with_scales(
1956 lhs: ArrayView2<'_, f64>,
1957 lhs_row: usize,
1958 rhs: ArrayView2<'_, f64>,
1959 rhs_row: usize,
1960 axis_scales: &[f64],
1961) -> f64 {
1962 assert_eq!(lhs.ncols(), rhs.ncols());
1963 assert_eq!(lhs.ncols(), axis_scales.len());
1964 stable_euclidean_norm(
1965 (0..lhs.ncols())
1966 .map(|axis| axis_scales[axis] * (lhs[[lhs_row, axis]] - rhs[[rhs_row, axis]])),
1967 )
1968}
1969
1970pub(crate) fn fill_symmetric_from_row_kernel<F>(
1971 matrix: &mut Array2<f64>,
1972 kernel: F,
1973) -> Result<(), BasisError>
1974where
1975 F: Fn(usize, usize) -> Result<f64, BasisError> + Sync,
1976{
1977 assert_eq!(matrix.nrows(), matrix.ncols());
1978 let k = matrix.nrows();
1979 matrix
1987 .axis_iter_mut(Axis(0))
1988 .into_par_iter()
1989 .enumerate()
1990 .try_for_each(|(i, mut row)| {
1991 for j in i..k {
1992 row[j] = kernel(i, j)?;
1993 }
1994 Ok::<(), BasisError>(())
1995 })?;
1996 for i in 1..k {
1997 for j in 0..i {
1998 matrix[[i, j]] = matrix[[j, i]];
1999 }
2000 }
2001 Ok(())
2002}
2003
2004pub(crate) fn points_in_aniso_y_space(points: ArrayView2<'_, f64>, eta: &[f64]) -> Array2<f64> {
2012 assert_eq!(points.ncols(), eta.len());
2013 let mut y = points.to_owned();
2014 let eta_mean = centered_aniso_log_scale_mean(eta);
2015 let weights: Vec<f64> = eta.iter().map(|&e| aniso_axis_scale(e, eta_mean)).collect();
2016 for a in 0..eta.len() {
2017 let w_a = weights[a];
2018 y.column_mut(a).mapv_inplace(|v| v * w_a);
2019 }
2020 y
2021}
2022
2023pub fn knot_cloud_axis_scales(centers: ArrayView2<'_, f64>) -> Vec<f64> {
2028 let k = centers.nrows();
2029 let d = centers.ncols();
2030 if k < 2 || d == 0 {
2031 return vec![1.0; d];
2032 }
2033 let n = k as f64;
2034 let mut scales = Vec::with_capacity(d);
2035 for a in 0..d {
2036 let col = centers.column(a);
2037 let mean = col.sum() / n;
2038 let var = col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2039 let sigma = var.sqrt();
2040 let sigma = if sigma < 1e-12 { 1.0 } else { sigma };
2042 scales.push(sigma.clamp(1e-6, 1e6));
2043 }
2044 scales
2045}
2046
2047pub fn initial_aniso_contrasts(centers: ArrayView2<'_, f64>) -> Vec<f64> {
2055 let d = centers.ncols();
2056 if d <= 1 {
2057 return Vec::new();
2058 }
2059 let scales = knot_cloud_axis_scales(centers);
2060 let mean_neg_log: f64 = scales.iter().map(|&s| -s.ln()).sum::<f64>() / d as f64;
2061 scales
2065 .iter()
2066 .map(|&scale| -scale.ln() - mean_neg_log)
2067 .collect()
2068}
2069
2070pub(crate) fn centered_aniso_contrasts(aniso: Option<&[f64]>) -> Option<Vec<f64>> {
2093 match aniso {
2094 Some(v) if v.len() > 1 => Some(center_aniso_log_scales(v)),
2095 Some(v) => Some(v.to_vec()),
2096 None => None,
2097 }
2098}
2099
2100pub(crate) fn auto_seed_aniso_contrasts(
2118 centers: ArrayView2<'_, f64>,
2119 aniso: Option<&[f64]>,
2120) -> Option<Vec<f64>> {
2121 let eta = match aniso {
2122 Some(v) if v.len() > 1 => v,
2123 Some(v) => return Some(v.to_vec()),
2124 None => return None,
2125 };
2126 let all_zero = eta.iter().all(|&e| e == 0.0);
2127 if !all_zero {
2128 return Some(center_aniso_log_scales(eta));
2129 }
2130 let contrasts = initial_aniso_contrasts(centers);
2131 if contrasts.is_empty() {
2132 Some(center_aniso_log_scales(eta))
2133 } else {
2134 Some(center_aniso_log_scales(&contrasts))
2135 }
2136}
2137
2138fn center_aniso_log_scales(eta: &[f64]) -> Vec<f64> {
2139 if eta.len() <= 1 {
2140 return eta.to_vec();
2141 }
2142 let mean = eta.iter().sum::<f64>() / eta.len() as f64;
2143 eta.iter()
2144 .map(|&v| {
2145 let centered = v - mean;
2146 if centered.abs() <= 1e-15 {
2147 0.0
2148 } else {
2149 centered
2150 }
2151 })
2152 .collect()
2153}
2154
2155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2158pub enum AnisoSeedMode {
2159 AutoSeedFromGeometry,
2169 Literal,
2176}
2177
2178pub(crate) fn resolve_matern_forward_aniso(
2181 mode: AnisoSeedMode,
2182 centers: ArrayView2<'_, f64>,
2183 aniso: Option<&[f64]>,
2184) -> Option<Vec<f64>> {
2185 match mode {
2186 AnisoSeedMode::Literal => centered_aniso_contrasts(aniso),
2187 AnisoSeedMode::AutoSeedFromGeometry => auto_seed_aniso_contrasts(centers, aniso),
2188 }
2189}
2190
2191pub(crate) fn pairwise_distance_bounds(points: ArrayView2<'_, f64>) -> Option<(f64, f64)> {
2192 let n = points.nrows();
2193 let d = points.ncols();
2194 if n < 2 || d == 0 {
2195 return None;
2196 }
2197 let mut r_min = f64::INFINITY;
2198 let mut r_max = 0.0_f64;
2199 for i in 0..n {
2200 for j in (i + 1)..n {
2201 let r = stable_euclidean_norm((0..d).map(|c| points[[i, c]] - points[[j, c]]));
2202 if r.is_finite() && r > 0.0 {
2203 r_min = r_min.min(r);
2204 r_max = r_max.max(r);
2205 }
2206 }
2207 }
2208 if r_min.is_finite() && r_max.is_finite() && r_min > 0.0 && r_max > 0.0 {
2209 Some((r_min, r_max))
2210 } else {
2211 None
2212 }
2213}
2214
2215pub(crate) fn pairwise_distance_bounds_sampled(points: ArrayView2<'_, f64>) -> Option<(f64, f64)> {
2248 const K_CAP: usize = 1024;
2249 let n = points.nrows();
2250 let d = points.ncols();
2251 if n < 2 || d == 0 {
2252 return None;
2253 }
2254 if n <= K_CAP {
2255 return pairwise_distance_bounds(points);
2256 }
2257 let k = K_CAP;
2262 let denom = (k - 1) as f64;
2263 let span = (n - 1) as f64;
2264 let sample_index = |s: usize| -> usize { ((s as f64) * span / denom).round() as usize };
2265 let mut r_min = f64::INFINITY;
2266 let mut r_max = 0.0_f64;
2267 for i_idx in 0..k {
2268 let i = sample_index(i_idx);
2269 for j_idx in (i_idx + 1)..k {
2270 let j = sample_index(j_idx);
2271 let r = stable_euclidean_norm((0..d).map(|c| points[[i, c]] - points[[j, c]]));
2272 if r.is_finite() && r > 0.0 {
2273 r_min = r_min.min(r);
2274 r_max = r_max.max(r);
2275 }
2276 }
2277 }
2278 if r_min.is_finite() && r_max.is_finite() && r_min > 0.0 && r_max > 0.0 {
2279 Some((r_min, r_max))
2280 } else {
2281 None
2282 }
2283}
2284
2285#[cfg(test)]
2286mod duchon_hybrid_psd_tests {
2287 use super::*;
2288 use faer::Side;
2289 use gam_linalg::faer_ndarray::FaerEigh;
2290
2291 #[test]
2302 fn sampled_diameter_is_n_stable_across_cap_threshold() {
2303 let grid = |n: usize| -> Array2<f64> {
2304 let mut x = Array2::<f64>::zeros((n, 1));
2305 for i in 0..n {
2306 x[[i, 0]] = (i as f64) / (n as f64 - 1.0) * 6.0 - 3.0;
2307 }
2308 x
2309 };
2310 let exact_diam = 6.0_f64;
2312 let mut last_rmax: Option<f64> = None;
2313 for &n in &[1000usize, 1025, 1500, 2000, 4000, 50_000] {
2314 let x = grid(n);
2315 let (r_min, r_max) =
2316 pairwise_distance_bounds_sampled(x.view()).expect("bounds for dense grid");
2317 assert!(
2320 (r_max - exact_diam).abs() <= 0.01 * exact_diam,
2321 "sampled r_max at n={n} = {r_max:.6} drifted from the true diameter \
2322 {exact_diam:.6}: the diameter estimate is n-dependent (#1033)"
2323 );
2324 if let Some(prev) = last_rmax {
2326 let rel = (r_max - prev).abs() / exact_diam;
2327 assert!(
2328 rel <= 0.02,
2329 "sampled r_max jumped {rel:.4} (rel) between n steps near n={n}: \
2330 {prev:.6} -> {r_max:.6}; outer-loop box is not n-stable (#1033)"
2331 );
2332 }
2333 last_rmax = Some(r_max);
2334 assert!(
2336 r_min.is_finite() && r_min > 0.0,
2337 "r_min must be positive at n={n}"
2338 );
2339 }
2340 }
2341
2342 #[test]
2347 fn sampled_indices_span_full_range() {
2348 const K_CAP: usize = 1024;
2349 let n = 2000usize; let k = K_CAP;
2351 let denom = (k - 1) as f64;
2352 let span = (n - 1) as f64;
2353 let idx = |s: usize| -> usize { ((s as f64) * span / denom).round() as usize };
2354 assert_eq!(idx(0), 0, "first sample must be index 0");
2355 assert_eq!(idx(k - 1), n - 1, "last sample must be the final index n-1");
2356 let mut max_gap = 0usize;
2359 for s in 1..k {
2360 max_gap = max_gap.max(idx(s) - idx(s - 1));
2361 }
2362 assert!(
2363 max_gap <= 2,
2364 "evenly-spaced samples should step by ~{:.2}; saw a gap of {max_gap} \
2365 (prefix clustering would leave one huge gap)",
2366 span / denom
2367 );
2368 }
2369
2370 fn fixture_centers(d: usize, n: usize) -> Array2<f64> {
2376 const BASES: [u64; 24] = [
2377 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83,
2378 89,
2379 ];
2380 let mut centers = Array2::<f64>::zeros((n, d));
2381 for i in 0..n {
2382 for axis in 0..d {
2383 let base = BASES[axis % BASES.len()];
2384 let mut f = 1.0_f64;
2388 let mut idx = (i + 1) as u64;
2389 let mut value = 0.0_f64;
2390 while idx > 0 {
2391 f /= base as f64;
2392 value += f * (idx % base) as f64;
2393 idx /= base;
2394 }
2395 centers[[i, axis]] = 2.0 * value - 1.0;
2396 }
2397 }
2398 centers
2399 }
2400
2401 fn lambda_min(matrix: &Array2<f64>) -> f64 {
2404 let sym = symmetrize_penalty(matrix);
2405 let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).expect("symmetric eigendecomposition");
2406 evals.iter().copied().fold(f64::INFINITY, f64::min)
2407 }
2408
2409 #[test]
2418 fn high_dim_hybrid_penalty_is_numerically_psd_1424() {
2419 let d = 16usize;
2420 let (nullspace_order, default_power) = duchon_cubic_default(d);
2428 assert!(matches!(nullspace_order, DuchonNullspaceOrder::Linear));
2429 assert!(
2430 (default_power - 7.5).abs() < 1e-12,
2431 "cubic-default power for d=16 is 7.5"
2432 );
2433 let power = 7.0_f64;
2434 assert_eq!(duchon_power_to_usize(power), 7);
2435 assert!(duchon_hybrid_stable_integral_applies(
2437 duchon_p_from_nullspace_order(nullspace_order),
2438 duchon_power_to_usize(power),
2439 d,
2440 ));
2441 let length_scale = Some(1.0_f64);
2442 let centers = fixture_centers(d, 4 * d);
2443
2444 let mut cache = BasisCacheContext::default();
2445 let z = kernel_constraint_nullspace(centers.view(), nullspace_order, &mut cache)
2446 .expect("constraint null space");
2447
2448 let omega = duchon_constrained_bending_penalty(
2449 centers.view(),
2450 length_scale,
2451 power,
2452 nullspace_order,
2453 None,
2454 &z,
2455 )
2456 .expect("constrained bending penalty assembles for the hybrid fixture");
2457 let (penalty, _scale) = normalize_penalty(&omega);
2458
2459 let lam_min = lambda_min(&penalty);
2460 assert!(
2461 lam_min >= -1e-10,
2462 "gam#1424: (d=16, m=2, s=7) hybrid penalty is not numerically PSD: \
2463 λ_min={lam_min:.6e} (was ≈ −0.26442 with the cancellation-prone \
2464 partial-fraction kernel)"
2465 );
2466 }
2467
2468 fn phi0_closed_form(p: usize, s: usize, d: usize, kappa: f64) -> f64 {
2480 let half_d = 0.5 * d as f64;
2481 let b = p as f64 + s as f64 - half_d;
2482 (4.0 * std::f64::consts::PI).powf(-half_d) / gamma_lanczos(s as f64)
2483 * gamma_lanczos(b)
2484 * kappa.powf(-2.0 * b)
2485 * gamma_lanczos(half_d - p as f64)
2486 / gamma_lanczos(half_d)
2487 }
2488
2489 #[test]
2496 fn hybrid_collision_diagonal_matches_closed_form_1604() {
2497 for &d in &[1usize, 3, 5] {
2500 for &p in &[1usize, 2, 3] {
2501 for &s in &[1usize, 2, 3, 4] {
2502 if 2 * (p + s) <= d {
2503 continue;
2504 }
2505 for &kappa in &[0.5f64, 1.0, 2.5] {
2506 let coeffs = duchon_partial_fraction_coeffs(p, s, kappa);
2507 let got =
2508 duchon_hybrid_kernel_collision_value(1.0 / kappa, p, s, d, &coeffs)
2509 .expect("collision diagonal");
2510 let want = phi0_closed_form(p, s, d, kappa);
2511 let rel = (got - want).abs() / want.abs().max(1e-300);
2512 assert!(
2513 rel < 1e-10,
2514 "φ(0) mismatch d={d} p={p} s={s} κ={kappa}: got {got:.12e}, want {want:.12e} (rel {rel:.2e})"
2515 );
2516 }
2517 }
2518 }
2519 }
2520 }
2521
2522 #[test]
2529 fn hybrid_near_collision_continuous_with_direct_1604() {
2530 for &d in &[1usize, 3] {
2531 for &p in &[1usize, 2] {
2532 for &s in &[2usize, 3] {
2533 if 2 * (p + s) <= d + 6 {
2534 continue;
2536 }
2537 for &kappa in &[0.5f64, 1.0, 2.0] {
2538 let length_scale = 1.0 / kappa;
2539 let coeffs = duchon_partial_fraction_coeffs(p, s, kappa);
2540 let r = 0.02 * length_scale;
2544 let taylor = duchon_hybrid_kernel_near_collision_value(
2545 r,
2546 length_scale,
2547 p,
2548 s,
2549 d,
2550 &coeffs,
2551 )
2552 .expect("near-collision value");
2553 let mut direct = 0.0f64;
2555 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
2556 if a_m != 0.0 {
2557 direct += a_m * polyharmonic_kernel(r, m as f64, d);
2558 }
2559 }
2560 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
2561 if b_n != 0.0 {
2562 direct += b_n
2563 * duchon_matern_block(r, kappa, n, d).expect("matern block");
2564 }
2565 }
2566 let rel = (taylor - direct).abs() / direct.abs().max(1e-300);
2567 assert!(
2568 rel < 1e-9,
2569 "near-collision vs direct mismatch d={d} p={p} s={s} κ={kappa} r={r}: \
2570 taylor {taylor:.12e}, direct {direct:.12e} (rel {rel:.2e})"
2571 );
2572 }
2573 }
2574 }
2575 }
2576 }
2577
2578 #[test]
2584 fn d1_hybrid_penalty_is_psd_1604() {
2585 let d = 1usize;
2586 let nullspace_order = DuchonNullspaceOrder::Linear; let centers = fixture_centers(d, 12);
2588 let mut cache = BasisCacheContext::default();
2589 let z = kernel_constraint_nullspace(centers.view(), nullspace_order, &mut cache)
2590 .expect("constraint null space");
2591 for &power in &[2.0f64, 3.0] {
2592 for &length_scale in &[0.5f64, 1.0, 10.0, 100.0] {
2593 let omega = duchon_constrained_bending_penalty(
2594 centers.view(),
2595 Some(length_scale),
2596 power,
2597 nullspace_order,
2598 None,
2599 &z,
2600 )
2601 .unwrap_or_else(|e| {
2602 panic!("d=1 p=2 s={power} ls={length_scale} penalty rejected: {e}")
2603 });
2604 let (penalty, _scale) = normalize_penalty(&omega);
2605 let lam_min = lambda_min(&penalty);
2606 assert!(
2607 lam_min >= -1e-9,
2608 "d=1 p=2 s={power} ls={length_scale}: λ_min={lam_min:.6e} (not PSD)"
2609 );
2610 }
2611 }
2612 }
2613
2614 #[test]
2622 fn low_dim_hybrid_kernel_values_unchanged_1424() {
2623 let d = 2usize;
2624 let p_order = 2usize; let s_order = 2usize;
2626 let kappa = 1.0_f64;
2627 let length_scale = Some(1.0_f64);
2628 assert!(!duchon_hybrid_stable_integral_applies(p_order, s_order, d));
2630 let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
2631
2632 for &r in &[0.25_f64, 0.75, 1.5] {
2633 let mut reference = 0.0_f64;
2637 for (m, &coeff) in coeffs.a.iter().enumerate().skip(1) {
2638 if coeff != 0.0 {
2639 reference += coeff * polyharmonic_kernel(r, m as f64, d);
2640 }
2641 }
2642 for (n, &coeff) in coeffs.b.iter().enumerate().skip(1) {
2643 if coeff != 0.0 {
2644 reference += coeff * duchon_matern_block(r, kappa, n, d).expect("matern block");
2645 }
2646 }
2647
2648 let got = duchon_matern_kernel_general_from_distance(
2649 r,
2650 length_scale,
2651 p_order,
2652 s_order,
2653 d,
2654 Some(&coeffs),
2655 )
2656 .expect("low-d hybrid kernel value");
2657 assert!(
2658 (got - reference).abs() <= 1e-10,
2659 "low-d hybrid kernel value regressed at r={r}: got {got:.15e}, reference {reference:.15e}"
2660 );
2661 }
2662 }
2663}