1use super::*;
2
3pub(crate) static DUCHON_DESIGN_BUILD_COUNT: std::sync::atomic::AtomicUsize =
11 std::sync::atomic::AtomicUsize::new(0);
12
13pub fn duchon_design_build_count() -> usize {
15 DUCHON_DESIGN_BUILD_COUNT.load(std::sync::atomic::Ordering::Relaxed)
16}
17
18pub(crate) fn duchon_coeff_exponents(p_order: usize, s_order: usize, m_or_n: usize) -> f64 {
19 -2.0 * (p_order + s_order - m_or_n) as f64
28}
29
30#[inline(always)]
31pub(crate) fn duchon_scaling_exponent(p_order: usize, s_order: usize, k_dim: usize) -> f64 {
32 k_dim as f64 - 2.0 * (p_order + s_order) as f64
33}
34
35#[derive(Clone, Copy)]
36pub(crate) struct DuchonMaternDerivativeTerm {
37 pub(crate) coeff: f64,
38 pub(crate) kappa_power: usize,
39 pub(crate) r_power: f64,
40 pub(crate) bessel_order: f64,
41}
42
43#[derive(Clone, Copy, Debug, Default)]
44pub(crate) struct DuchonRadialJets {
45 pub(crate) phi: f64,
46 pub(crate) phi_r: f64,
47 pub(crate) phi_rr: f64,
48 pub(crate) phi_rrr: f64,
49 pub(crate) q: f64,
50 pub(crate) q_r: f64,
51 pub(crate) q_rr: f64,
52 pub(crate) lap: f64,
53 pub(crate) lap_r: f64,
54 pub(crate) lap_rr: f64,
55 pub(crate) t: f64,
59 pub(crate) t_r: f64,
63 pub(crate) t_rr: f64,
70}
71
72#[derive(Clone, Copy, Debug, Default)]
73pub(crate) struct DuchonRegularizedOperatorCore {
74 pub(crate) q: f64,
75 pub(crate) t: f64,
76 pub(crate) t_r: f64,
77 pub(crate) t_rr: f64,
78}
79
80#[inline(always)]
81pub(crate) fn duchon_operator_jets_from_primary_core(
82 core: DuchonRegularizedOperatorCore,
83 r: f64,
84 d: f64,
85) -> DuchonRadialJets {
86 let r2 = r * r;
87 let mut out = DuchonRadialJets {
88 q: core.q,
89 t: core.t,
90 t_r: core.t_r,
91 t_rr: core.t_rr,
92 ..DuchonRadialJets::default()
93 };
94 out.q_r = r * out.t;
95 out.q_rr = out.t + r * out.t_r;
96 out.lap = d * out.q + r2 * out.t;
97 out.lap_r = (d + 2.0) * r * out.t + r2 * out.t_r;
98 out.lap_rr = (d + 2.0) * out.t + (d + 4.0) * r * out.t_r + r2 * out.t_rr;
99 out.phi_r = r * out.q;
100 out.phi_rr = out.q + r2 * out.t;
101 out.phi_rrr = 3.0 * r * out.t + r2 * out.t_r;
102
103 assert!(
104 ((out.phi_rr - (out.q + r * out.q_r)).abs()) <= 1e-10 * out.phi_rr.abs().max(1.0),
105 "radial scalar identity failed: phi_rr != q + r*q_r, phi_rr={}, q={}, r={}, q_r={}",
106 out.phi_rr,
107 out.q,
108 r,
109 out.q_r
110 );
111 assert!(
112 ((out.phi_rr - (out.q + r2 * out.t)).abs()) <= 1e-10 * out.phi_rr.abs().max(1.0),
113 "radial scalar identity failed: phi_rr != q + r2*t, phi_rr={}, q={}, r2={}, t={}",
114 out.phi_rr,
115 out.q,
116 r2,
117 out.t
118 );
119 assert!(
120 ((out.lap - (d * out.q + r2 * out.t)).abs()) <= 1e-10 * out.lap.abs().max(1.0),
121 "radial scalar identity failed: lap != d*q + r2*t, lap={}, d={}, q={}, r2={}, t={}",
122 out.lap,
123 d,
124 out.q,
125 r2,
126 out.t
127 );
128
129 out
130}
131
132#[inline(always)]
133pub(crate) fn scaled_log_kappa_derivatives(
134 value: f64,
135 radial_first: f64,
136 radialsecond: f64,
137 exponent: f64,
138 r: f64,
139) -> (f64, f64) {
140 let first = exponent * value + r * radial_first;
163 let second = exponent * exponent * value
164 + (2.0 * exponent + 1.0) * r * radial_first
165 + r * r * radialsecond;
166 (first, second)
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub enum DuchonPsiDirection {
190 Global,
194 Axis(usize),
196}
197
198#[inline(always)]
223pub fn duchon_axis_log_kappa_derivatives(
224 value: f64,
225 radial_first: f64,
226 radialsecond: f64,
227 exponent: f64,
228 r: f64,
229 dim: usize,
230 axis_share: f64,
231 second_axis_share: f64,
232 same_axis: bool,
233) -> (f64, f64) {
234 let a_term = r * radial_first;
235 let b_term = r * r * radialsecond - a_term;
236 let c = exponent / dim.max(1) as f64;
237 let first = c * value + a_term * axis_share;
238 let second = b_term * axis_share * second_axis_share
239 + c * a_term * (axis_share + second_axis_share)
240 + if same_axis {
241 2.0 * a_term * axis_share
242 } else {
243 0.0
244 }
245 + c * c * value;
246 (first, second)
247}
248
249#[inline(always)]
255pub fn duchon_direction_derivatives(
256 direction: DuchonPsiDirection,
257 value: f64,
258 radial_first: f64,
259 radialsecond: f64,
260 exponent: f64,
261 r: f64,
262 dim: usize,
263 axis_shares: &[f64],
264) -> (f64, f64) {
265 match direction {
266 DuchonPsiDirection::Global => {
267 scaled_log_kappa_derivatives(value, radial_first, radialsecond, exponent, r)
268 }
269 DuchonPsiDirection::Axis(a) => {
270 let share = axis_shares.get(a).copied().unwrap_or(0.0);
271 duchon_axis_log_kappa_derivatives(
272 value,
273 radial_first,
274 radialsecond,
275 exponent,
276 r,
277 dim,
278 share,
279 share,
280 true,
281 )
282 }
283 }
284}
285
286#[inline(always)]
294pub fn duchon_axis_shares(components: &[f64], r: f64) -> Vec<f64> {
295 let d = components.len().max(1);
296 let r2 = r * r;
297 if !(r2 > 0.0) || !r2.is_finite() {
298 return vec![1.0 / d as f64; components.len()];
299 }
300 components.iter().map(|&s| s / r2).collect()
301}
302
303#[inline(always)]
304pub(crate) fn duchon_q_psi_triplet_from_jets(
305 jets: &DuchonRadialJets,
306 p_order: usize,
307 s_order: usize,
308 k_dim: usize,
309 r: f64,
310) -> (f64, f64) {
311 scaled_log_kappa_derivatives(
312 jets.q,
313 jets.q_r,
314 jets.q_rr,
315 duchon_operator_scaling_exponent(p_order, s_order, k_dim),
316 r,
317 )
318}
319
320#[inline(always)]
321pub(crate) fn duchon_operator_scaling_exponent(
322 p_order: usize,
323 s_order: usize,
324 k_dim: usize,
325) -> f64 {
326 duchon_scaling_exponent(p_order, s_order, k_dim) + 2.0
342}
343
344pub(crate) fn duchon_regularized_operator_core(
345 r_eval: f64,
346 kappa: f64,
347 k_dim: usize,
348 coeffs: &DuchonPartialFractionCoeffs,
349) -> Result<DuchonRegularizedOperatorCore, BasisError> {
350 let mut q_sum = KahanSum::default();
354 let mut t_sum = KahanSum::default();
355 let mut t_r_sum = KahanSum::default();
356 let mut t_rr_sum = KahanSum::default();
357
358 for (m, coeff) in coeffs.a.iter().enumerate().skip(1) {
359 if *coeff == 0.0 {
360 continue;
361 }
362 let (q, t, t_r, t_rr) = duchon_polyharmonic_operator_block_jets(r_eval, m, k_dim)?;
363 q_sum.add(coeff * q);
364 t_sum.add(coeff * t);
365 t_r_sum.add(coeff * t_r);
366 t_rr_sum.add(coeff * t_rr);
367 }
368 let max_ladder_steps = coeffs
373 .b
374 .iter()
375 .enumerate()
376 .skip(1)
377 .filter(|(_, coeff)| **coeff != 0.0)
378 .map(|(n, _)| duchon_matern_block_max_ladder_steps(n, k_dim))
379 .max();
380 if let Some(max_ladder_steps) = max_ladder_steps {
381 let ladder =
382 BesselKLadder::build(kappa * r_eval, !k_dim.is_multiple_of(2), max_ladder_steps);
383 for (n, coeff) in coeffs.b.iter().enumerate().skip(1) {
384 if *coeff == 0.0 {
385 continue;
386 }
387 let (q, t, t_r, t_rr) =
388 duchon_matern_operator_block_jets_with_ladder(r_eval, kappa, n, k_dim, &ladder)?;
389 q_sum.add(coeff * q);
390 t_sum.add(coeff * t);
391 t_r_sum.add(coeff * t_r);
392 t_rr_sum.add(coeff * t_rr);
393 }
394 }
395 Ok(DuchonRegularizedOperatorCore {
396 q: q_sum.sum(),
397 t: t_sum.sum(),
398 t_r: t_r_sum.sum(),
399 t_rr: t_rr_sum.sum(),
400 })
401}
402
403#[inline(always)]
404pub(crate) fn duchon_collision_taylor_operator_core(
405 r: f64,
406 phi_rr_collision: f64,
407 t_collision: f64,
408 t_rr_collision: f64,
409) -> DuchonRegularizedOperatorCore {
410 let r2 = r * r;
411 let r4 = r2 * r2;
412 DuchonRegularizedOperatorCore {
413 q: phi_rr_collision + 0.5 * t_collision * r2 + 0.125 * t_rr_collision * r4,
414 t: t_collision + 0.5 * t_rr_collision * r2,
415 t_r: t_rr_collision * r,
416 t_rr: t_rr_collision,
417 }
418}
419
420pub(crate) fn duchon_radial_jets(
421 r: f64,
422 length_scale: f64,
423 p_order: usize,
424 s_order: usize,
425 k_dim: usize,
426 coeffs: &DuchonPartialFractionCoeffs,
427) -> Result<DuchonRadialJets, BasisError> {
428 let kappa = 1.0 / length_scale.max(1e-300);
429 let r_floor = DUCHON_DERIVATIVE_R_FLOOR_REL * length_scale.max(1e-8);
430 let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale.max(1e-8);
431 let r_eval = r.max(r_floor);
432 let d = k_dim as f64;
433
434 let phi = duchon_matern_kernel_general_from_distance(
436 r,
437 Some(length_scale),
438 p_order,
439 s_order,
440 k_dim,
441 Some(coeffs),
442 )?;
443 if !phi.is_finite() {
444 crate::bail_invalid_basis!(
445 "non-finite Duchon radial kernel value at r={r}, length_scale={length_scale}, p={p_order}, s={s_order}, dim={k_dim}"
446 );
447 }
448
449 let operator_core = if duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim) {
461 duchon_hybrid_operator_stable_integral(r_eval, kappa, p_order, s_order, k_dim)?
462 } else {
463 duchon_regularized_operator_core(r_eval, kappa, k_dim, coeffs)?
464 };
465 let generic_jets = duchon_operator_jets_from_primary_core(operator_core, r_eval, d);
466 let mut out = DuchonRadialJets {
467 phi,
468 ..generic_jets
469 };
470
471 let smoothness_order = 2 * (p_order + s_order);
478 let collision_q_exists = smoothness_order > k_dim + 2;
479 let collision_t_exists = smoothness_order > k_dim + 4;
480 let collision_t_rr_exists = smoothness_order > k_dim + 6;
481
482 if r <= collision_taylor_radius.max(r_floor) && collision_t_exists {
483 let (analytic_phi_rr, _, _) =
487 duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
488 let analytic_t_collision =
489 duchon_phi_rrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)? / 3.0;
490 let analytic_t_rr_collision = if collision_t_rr_exists {
491 duchon_phi_rrrrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)? / 15.0
492 } else {
493 0.0
497 };
498 let collision_jets = duchon_operator_jets_from_primary_core(
499 duchon_collision_taylor_operator_core(
500 r,
501 analytic_phi_rr,
502 analytic_t_collision,
503 analytic_t_rr_collision,
504 ),
505 r,
506 d,
507 );
508 out = DuchonRadialJets {
509 phi: out.phi,
510 ..collision_jets
511 };
512 } else if r < r_floor && collision_q_exists {
513 let (analytic_phi_rr, _, _) =
519 duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
520 out.phi_r = analytic_phi_rr * r;
521 out.phi_rr = analytic_phi_rr;
522 out.q = analytic_phi_rr;
523 out.q_r = 0.0;
524 out.lap = d * analytic_phi_rr;
525 out.lap_r = 0.0;
526 }
527 if !out.phi_r.is_finite()
528 || !out.phi_rr.is_finite()
529 || !out.phi_rrr.is_finite()
530 || !out.q.is_finite()
531 || !out.q_r.is_finite()
532 || !out.q_rr.is_finite()
533 || !out.lap.is_finite()
534 || !out.lap_r.is_finite()
535 || !out.lap_rr.is_finite()
536 || !out.t.is_finite()
537 || !out.t_r.is_finite()
538 || !out.t_rr.is_finite()
539 {
540 crate::bail_invalid_basis!(
541 "non-finite Duchon radial jets at r={r}, length_scale={length_scale}, p={p_order}, s={s_order}, dim={k_dim}"
542 );
543 }
544 Ok(out)
545}
546
547pub(crate) struct DuchonRadialCoreValueJet {
583 pub(crate) value: f64,
584 pub(crate) first: f64,
585 pub(crate) second: f64,
586 pub(crate) exponent: f64,
587}
588
589pub(crate) fn duchon_radial_core_value_jet(
590 r: f64,
591 length_scale: f64,
592 p_order: usize,
593 s_order: usize,
594 k_dim: usize,
595 coeffs: &DuchonPartialFractionCoeffs,
596) -> Result<DuchonRadialCoreValueJet, BasisError> {
597 let jets = duchon_radial_jets(r, length_scale, p_order, s_order, k_dim, coeffs)?;
598 Ok(DuchonRadialCoreValueJet {
599 value: jets.phi,
600 first: jets.phi_r,
601 second: jets.phi_rr,
602 exponent: duchon_scaling_exponent(p_order, s_order, k_dim),
603 })
604}
605
606pub(crate) fn duchonphi_rr_collision_psi_triplet(
607 length_scale: f64,
608 p_order: usize,
609 s_order: usize,
610 k_dim: usize,
611 coeffs: &DuchonPartialFractionCoeffs,
612) -> Result<(f64, f64, f64), BasisError> {
613 duchon_phi_even_derivative_collision_psi_triplet(
625 length_scale,
626 p_order,
627 s_order,
628 k_dim,
629 coeffs,
630 1,
631 )
632}
633
634pub(crate) const EULER_MASCHERONI: f64 = 0.577_215_664_901_532_9;
636
637#[inline(always)]
641pub(crate) fn digamma_pos_int(n: usize) -> f64 {
642 assert!(n >= 1, "digamma_pos_int requires n >= 1: n={n}");
643 let mut h = 0.0_f64;
644 for j in 1..n {
645 h += 1.0 / j as f64;
646 }
647 -EULER_MASCHERONI + h
648}
649
650pub(crate) fn duchon_matern_block_taylor_r2j(
664 kappa: f64,
665 n_order: usize,
666 k_dim: usize,
667 j: usize,
668) -> (f64, f64) {
669 let n = n_order as f64;
670 let k_half = 0.5 * k_dim as f64;
671 let nu = n - k_half;
672 let c = kappa.powf(k_half - n)
674 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
675
676 if k_dim.is_multiple_of(2) {
677 let nu_int = n_order as i64 - (k_dim as i64) / 2;
679 duchon_matern_block_taylor_r2j_integer_nu(kappa, c, nu_int, j)
680 } else {
681 duchon_matern_block_taylor_r2j_half_integer_nu(kappa, c, nu, j)
683 }
684}
685
686#[inline(always)]
687pub(crate) fn psi_power_triplet(value: f64, exponent: f64) -> (f64, f64, f64) {
688 (value, exponent * value, exponent * exponent * value)
689}
690
691#[inline(always)]
692pub(crate) fn psi_power_log_triplet(
693 base: f64,
694 exponent: f64,
695 log_kappa_half: f64,
696) -> (f64, f64, f64) {
697 (
698 base * log_kappa_half,
699 base * (exponent * log_kappa_half + 1.0),
700 base * (exponent * exponent * log_kappa_half + 2.0 * exponent),
701 )
702}
703
704#[inline(always)]
705pub(crate) fn add_triplet(dst: &mut (f64, f64, f64), inc: (f64, f64, f64)) {
706 dst.0 += inc.0;
707 dst.1 += inc.1;
708 dst.2 += inc.2;
709}
710
711pub(crate) fn duchon_matern_block_taylor_r2j_triplet(
715 kappa: f64,
716 n_order: usize,
717 k_dim: usize,
718 j: usize,
719) -> ((f64, f64, f64), (f64, f64, f64)) {
720 let n = n_order as f64;
721 let k_half = 0.5 * k_dim as f64;
722 let nu = n - k_half;
723 let c_const = 1.0
724 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
725 let c_exp = k_half - n;
726
727 let mut pure = (0.0, 0.0, 0.0);
728 let mut log_part = (0.0, 0.0, 0.0);
729 let log_kappa_half = (0.5 * kappa).ln();
730
731 if k_dim.is_multiple_of(2) {
732 let nu_int = n_order as i64 - (k_dim as i64) / 2;
733 let mu = nu_int.unsigned_abs() as usize;
734 let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 };
735
736 if nu_int >= 0 {
737 let nu_usize = nu_int as usize;
738
739 if j < nu_usize {
740 let sign = if j.is_multiple_of(2) { 1.0 } else { -1.0 };
741 let power = 2 * j as i32 - nu_usize as i32;
742 let coeff = 0.5 * sign * gamma_lanczos((nu_usize - j) as f64)
743 / gamma_lanczos((j + 1) as f64)
744 * 2.0_f64.powi(-power);
745 let exponent = c_exp + power as f64;
746 let value = c_const * coeff * kappa.powf(exponent);
747 add_triplet(&mut pure, psi_power_triplet(value, exponent));
748 }
749
750 if j >= nu_usize {
751 let k = j - nu_usize;
752 let inv_fac = 1.0
753 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((nu_usize + k + 1) as f64));
754 let power = (2 * k + nu_usize) as i32;
755 let exponent = c_exp + power as f64;
756 let kp_base = c_const * kappa.powf(exponent) * 2.0_f64.powi(-power);
757
758 let log_base = -sign_mu * kp_base * inv_fac;
759 add_triplet(&mut log_part, psi_power_triplet(log_base, exponent));
760 add_triplet(
761 &mut pure,
762 psi_power_log_triplet(log_base, exponent, log_kappa_half),
763 );
764
765 let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(nu_usize + k + 1);
766 let digamma_base = sign_mu * 0.5 * kp_base * inv_fac * psi_sum;
767 add_triplet(&mut pure, psi_power_triplet(digamma_base, exponent));
768 }
769 } else {
770 let k = j;
771 let inv_fac =
772 1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((mu + k + 1) as f64));
773 let power = (mu + 2 * k) as i32;
774 let exponent = c_exp + power as f64;
775 let kp_base = c_const * kappa.powf(exponent) * 2.0_f64.powi(-power);
776
777 let log_base = -sign_mu * kp_base * inv_fac;
778 add_triplet(&mut log_part, psi_power_triplet(log_base, exponent));
779 add_triplet(
780 &mut pure,
781 psi_power_log_triplet(log_base, exponent, log_kappa_half),
782 );
783
784 let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(mu + k + 1);
785 let digamma_base = sign_mu * 0.5 * kp_base * inv_fac * psi_sum;
786 add_triplet(&mut pure, psi_power_triplet(digamma_base, exponent));
787 }
788 } else {
789 let nu_abs = nu.abs();
790 let l = (nu_abs - 0.5).round().max(0.0) as usize;
796 let prefactor_const = (std::f64::consts::PI / 2.0).sqrt();
797 let prefactor_exp = -0.5;
798 let target = 2 * j;
799
800 for i in 0..=l {
801 let c_i = gamma_lanczos((l + i + 1) as f64)
802 / (gamma_lanczos((i + 1) as f64) * gamma_lanczos((l - i + 1) as f64));
803 let p_f64 = nu - 0.5 - i as f64;
804 let p_round = p_f64.round() as i64;
805 if (p_f64 - p_round as f64).abs() > 1e-12 {
806 continue;
807 }
808 let q_needed = target as i64 - p_round;
809 if q_needed < 0 {
810 continue;
811 }
812 let q = q_needed as usize;
813 let sign = if q.is_multiple_of(2) { 1.0 } else { -1.0 };
814 let exponent = c_exp + prefactor_exp - i as f64 + q as f64;
815 let value = c_const * prefactor_const * c_i * 2.0_f64.powi(-(i as i32)) * sign
816 / gamma_lanczos((q + 1) as f64)
817 * kappa.powf(exponent);
818 add_triplet(&mut pure, psi_power_triplet(value, exponent));
819 }
820 }
821
822 (pure, log_part)
823}
824
825pub(crate) fn duchon_matern_block_taylor_r2j_integer_nu(
837 kappa: f64,
838 c: f64,
839 nu_int: i64,
840 j: usize,
841) -> (f64, f64) {
842 let mu = nu_int.unsigned_abs() as usize; let kappa_half = 0.5 * kappa;
846
847 if nu_int >= 0 {
848 let nu = nu_int as usize;
849 let mut pure = 0.0;
854 let mut log_part = 0.0;
855
856 if j < nu {
858 let sign = if j.is_multiple_of(2) { 1.0 } else { -1.0 };
860 let coeff = sign * gamma_lanczos((nu - j) as f64) / gamma_lanczos((j + 1) as f64)
861 * kappa_half.powi(2 * j as i32 - nu as i32)
862 * 0.5;
863 pure += coeff;
864 }
865
866 if j >= nu {
868 let k = j - nu;
869 let inv_fac =
870 1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((nu + k + 1) as f64));
871 let kp = kappa_half.powi(2 * k as i32 + nu as i32);
872 let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 }; log_part += -sign_mu * kp * inv_fac;
876
877 let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(nu + k + 1);
882 pure += -sign_mu * kp * inv_fac * kappa_half.ln();
883 pure += sign_mu * 0.5 * kp * inv_fac * psi_sum;
884 }
885
886 (c * pure, c * log_part)
887 } else {
888 let k = j;
892 let inv_fac = 1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((mu + k + 1) as f64));
893 let kp = kappa_half.powi(mu as i32 + 2 * k as i32);
894 let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 };
895
896 let log_part = -sign_mu * kp * inv_fac;
898
899 let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(mu + k + 1);
901 let pure =
902 -sign_mu * kp * inv_fac * kappa_half.ln() + sign_mu * 0.5 * kp * inv_fac * psi_sum;
903
904 (c * pure, c * log_part)
905 }
906}
907
908pub(crate) fn duchon_matern_block_taylor_r2j_half_integer_nu(
919 kappa: f64,
920 c: f64,
921 nu: f64,
922 j: usize,
923) -> (f64, f64) {
924 let nu_abs = nu.abs();
925 let l = (nu_abs - 0.5).round().max(0.0) as usize;
929 let prefactor = (std::f64::consts::PI / (2.0 * kappa)).sqrt();
937
938 let target = 2 * j;
945 let mut pure = 0.0;
946
947 for i in 0..=l {
948 let c_i = gamma_lanczos((l + i + 1) as f64)
949 / (gamma_lanczos((i + 1) as f64) * gamma_lanczos((l - i + 1) as f64));
950 let inv_2kappa_i = (2.0 * kappa).powi(-(i as i32));
951
952 let p_f64 = nu - 0.5 - i as f64;
954 let p_round = p_f64.round() as i64;
955 if (p_f64 - p_round as f64).abs() > 1e-12 {
956 continue;
958 }
959 let q_needed = target as i64 - p_round;
960 if q_needed < 0 {
961 continue;
962 }
963 let q = q_needed as usize;
964 let exp_coeff = (-kappa).powi(q as i32) / gamma_lanczos((q + 1) as f64);
965 pure += c_i * inv_2kappa_i * exp_coeff;
966 }
967
968 (c * prefactor * pure, 0.0) }
970
971pub(crate) fn duchon_polyharmonic_block_taylor_r2j(m: usize, k_dim: usize, j: usize) -> (f64, f64) {
979 let k_half = 0.5 * k_dim as f64;
980 let alpha = 2 * m as i64 - k_dim as i64;
981
982 if alpha != 2 * j as i64 {
983 return (0.0, 0.0);
984 }
985
986 if k_dim.is_multiple_of(2) && m >= k_dim / 2 {
988 let c = polyharmonic_log_sign(m, k_dim)
990 / (2.0_f64.powi((2 * m - 1) as i32)
991 * std::f64::consts::PI.powf(k_half)
992 * gamma_lanczos(m as f64)
993 * gamma_lanczos((m - k_dim / 2 + 1) as f64));
994 (0.0, c)
995 } else {
996 let c = gamma_lanczos(k_half - m as f64)
998 / (4.0_f64.powi(m as i32)
999 * std::f64::consts::PI.powf(k_half)
1000 * gamma_lanczos(m as f64));
1001 (c, 0.0)
1002 }
1003}
1004
1005pub(crate) fn duchon_phi_even_derivative_collision(
1021 length_scale: f64,
1022 p_order: usize,
1023 s_order: usize,
1024 k_dim: usize,
1025 coeffs: &DuchonPartialFractionCoeffs,
1026 j: usize,
1027) -> Result<f64, BasisError> {
1028 let smoothness_order = 2 * (p_order + s_order);
1029 let required = k_dim + 2 * j;
1030
1031 if smoothness_order <= required {
1032 let min_power = (required / 2 + 1).saturating_sub(p_order);
1034 crate::bail_invalid_basis!(
1035 "Duchon collision derivative phi^({}) requires 2*(p+s) > dimension+{}; got 2*(p+s)={}, dimension={}, p={}, s={}. \
1036 This path needs the {}-order radial-kernel derivative at the origin, which is finite only for a smoother spline: raise power to >= {} (or reduce the joint smooth's dimension).",
1037 2 * j,
1038 2 * j,
1039 smoothness_order,
1040 k_dim,
1041 p_order,
1042 s_order,
1043 2 * j,
1044 min_power
1045 );
1046 }
1047
1048 let kappa = 1.0 / length_scale.max(1e-300);
1050 let mut total_pure = KahanSum::default();
1051 let mut total_log = KahanSum::default();
1052 let mut total_log_abs_scale = KahanSum::default();
1053
1054 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1056 if a_m == 0.0 {
1057 continue;
1058 }
1059 let (pure, log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, j);
1060 total_pure.add(a_m * pure);
1061 total_log.add(a_m * log);
1062 total_log_abs_scale.add((a_m * log).abs());
1063 }
1064
1065 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1067 if b_n == 0.0 {
1068 continue;
1069 }
1070 let (pure, log) = duchon_matern_block_taylor_r2j(kappa, n, k_dim, j);
1071 total_pure.add(b_n * pure);
1072 total_log.add(b_n * log);
1073 total_log_abs_scale.add((b_n * log).abs());
1074 }
1075 let total_pure = total_pure.sum();
1076 let total_log = total_log.sum();
1077 let total_log_abs_scale = total_log_abs_scale.sum();
1078
1079 let log_cancel_tol = 1e-10 * total_log_abs_scale.max(total_pure.abs()).max(1e-30);
1082 if total_log.abs() > log_cancel_tol {
1083 crate::bail_invalid_basis!(
1084 "Duchon Taylor a_{} log-coefficient did not cancel: log={total_log:.6e}, pure={total_pure:.6e}; \
1085 log_abs_scale={total_log_abs_scale:.6e}, tol={log_cancel_tol:.6e}; p={p_order}, s={s_order}, d={k_dim}",
1086 2 * j
1087 );
1088 }
1089
1090 let factorial_2j = gamma_lanczos((2 * j + 1) as f64);
1092 Ok(factorial_2j * total_pure)
1093}
1094
1095pub(crate) fn duchon_phi_even_derivative_collision_psi_triplet(
1096 length_scale: f64,
1097 p_order: usize,
1098 s_order: usize,
1099 k_dim: usize,
1100 coeffs: &DuchonPartialFractionCoeffs,
1101 j: usize,
1102) -> Result<(f64, f64, f64), BasisError> {
1103 let smoothness_order = 2 * (p_order + s_order);
1104 let required = k_dim + 2 * j;
1105
1106 if smoothness_order <= required {
1107 let min_power = (required / 2 + 1).saturating_sub(p_order);
1109 crate::bail_invalid_basis!(
1110 "Duchon collision derivative phi^({}) psi triplet requires 2*(p+s) > dimension+{}; got 2*(p+s)={}, dimension={}, p={}, s={}. \
1111 The exact two-block / transformation-normal path needs analytic length-scale derivatives of the kernel, which are finite only for a smoother spline: raise power to >= {} (or reduce the joint smooth's dimension).",
1112 2 * j,
1113 2 * j,
1114 smoothness_order,
1115 k_dim,
1116 p_order,
1117 s_order,
1118 min_power
1119 );
1120 }
1121
1122 let kappa = 1.0 / length_scale.max(1e-300);
1123 let mut value = KahanSum::default();
1124 let mut psi = KahanSum::default();
1125 let mut psi_psi = KahanSum::default();
1126 let mut log_value = KahanSum::default();
1127 let mut log_psi = KahanSum::default();
1128 let mut log_psi_psi = KahanSum::default();
1129 let mut log_abs_scale = KahanSum::default();
1130
1131 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1132 if a_m == 0.0 {
1133 continue;
1134 }
1135 let alpha_m = duchon_coeff_exponents(p_order, s_order, m);
1136 let (pure, log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, j);
1137 value.add(a_m * pure);
1138 psi.add(alpha_m * a_m * pure);
1139 psi_psi.add(alpha_m * alpha_m * a_m * pure);
1140 log_value.add(a_m * log);
1141 log_psi.add(alpha_m * a_m * log);
1142 log_psi_psi.add(alpha_m * alpha_m * a_m * log);
1143 log_abs_scale.add((a_m * log).abs());
1144 log_abs_scale.add((alpha_m * a_m * log).abs());
1145 log_abs_scale.add((alpha_m * alpha_m * a_m * log).abs());
1146 }
1147
1148 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1149 if b_n == 0.0 {
1150 continue;
1151 }
1152 let beta_n = duchon_coeff_exponents(p_order, s_order, n);
1153 let (pure, log) = duchon_matern_block_taylor_r2j_triplet(kappa, n, k_dim, j);
1154 value.add(b_n * pure.0);
1155 psi.add(beta_n * b_n * pure.0 + b_n * pure.1);
1156 psi_psi.add(beta_n * beta_n * b_n * pure.0 + 2.0 * beta_n * b_n * pure.1 + b_n * pure.2);
1157 log_value.add(b_n * log.0);
1158 log_psi.add(beta_n * b_n * log.0 + b_n * log.1);
1159 log_psi_psi.add(beta_n * beta_n * b_n * log.0 + 2.0 * beta_n * b_n * log.1 + b_n * log.2);
1160 let log_v = b_n * log.0;
1161 let log_p = beta_n * b_n * log.0 + b_n * log.1;
1162 let log_pp = beta_n * beta_n * b_n * log.0 + 2.0 * beta_n * b_n * log.1 + b_n * log.2;
1163 log_abs_scale.add(log_v.abs());
1164 log_abs_scale.add(log_p.abs());
1165 log_abs_scale.add(log_pp.abs());
1166 }
1167
1168 let value = value.sum();
1169 let psi = psi.sum();
1170 let psi_psi = psi_psi.sum();
1171 let log_value = log_value.sum();
1172 let log_psi = log_psi.sum();
1173 let log_psi_psi = log_psi_psi.sum();
1174 let log_abs_scale = log_abs_scale.sum();
1175 let scale = value.abs().max(psi.abs()).max(psi_psi.abs()).max(1e-30);
1176 let log_cancel_tol = 1e-10 * log_abs_scale.max(scale);
1177 if log_value.abs().max(log_psi.abs()).max(log_psi_psi.abs()) > log_cancel_tol {
1178 crate::bail_invalid_basis!(
1179 "Duchon Taylor a_{} log-coefficient derivative did not cancel: \
1180 log=({log_value:.6e}, {log_psi:.6e}, {log_psi_psi:.6e}), \
1181 value=({value:.6e}, {psi:.6e}, {psi_psi:.6e}), log_abs_scale={log_abs_scale:.6e}, tol={log_cancel_tol:.6e}; \
1182 p={p_order}, s={s_order}, d={k_dim}",
1183 2 * j
1184 );
1185 }
1186
1187 let factorial_2j = gamma_lanczos((2 * j + 1) as f64);
1188 Ok((
1189 factorial_2j * value,
1190 factorial_2j * psi,
1191 factorial_2j * psi_psi,
1192 ))
1193}
1194
1195pub(crate) fn duchon_phi_rrrr_collision(
1207 length_scale: f64,
1208 p_order: usize,
1209 s_order: usize,
1210 k_dim: usize,
1211 coeffs: &DuchonPartialFractionCoeffs,
1212) -> Result<f64, BasisError> {
1213 duchon_phi_even_derivative_collision(length_scale, p_order, s_order, k_dim, coeffs, 2)
1214}
1215
1216pub(crate) fn duchon_phi_rrrrrr_collision(
1228 length_scale: f64,
1229 p_order: usize,
1230 s_order: usize,
1231 k_dim: usize,
1232 coeffs: &DuchonPartialFractionCoeffs,
1233) -> Result<f64, BasisError> {
1234 duchon_phi_even_derivative_collision(length_scale, p_order, s_order, k_dim, coeffs, 3)
1235}
1236
1237pub(crate) fn duchon_frozen_radial_chart(
1260 z_kernel: Array2<f64>,
1261 spec: &DuchonBasisSpec,
1262 site: &str,
1263) -> Result<Array2<f64>, BasisError> {
1264 let Some(v) = spec.radial_reparam.as_ref() else {
1265 if z_kernel.ncols() == 0 {
1266 return Ok(z_kernel);
1269 }
1270 crate::bail_invalid_basis!(
1271 "Duchon {site} ψ-derivative requires the frozen data-metric radial reparam V, but the spec carries none while the constrained kernel block has {} columns. The forward build adopts a fresh V(ψ) for this spec, so a derivative taken in the raw Z chart is the exact derivative of a different penalty (#2638). Replay BasisMetadata::Duchon::radial_reparam onto the spec first, as the κ-optimizer does.",
1272 z_kernel.ncols()
1273 );
1274 };
1275 if v.nrows() != z_kernel.ncols() {
1276 crate::bail_dim_basis!(
1277 "Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
1278 v.dim(),
1279 z_kernel.ncols()
1280 );
1281 }
1282 Ok(fast_ab(&z_kernel, v))
1283}
1284
1285pub(crate) fn build_duchon_design_psi_derivativeswithworkspace(
1286 data: ArrayView2<'_, f64>,
1287 centers: ArrayView2<'_, f64>,
1288 spec: &DuchonBasisSpec,
1289 identifiability_transform: Option<&Array2<f64>>,
1290 workspace: &mut BasisWorkspace,
1291) -> Result<ScalarDesignPsiDerivatives, BasisError> {
1292 let length_scale = spec.length_scale.ok_or_else(|| {
1293 BasisError::InvalidInput(
1294 "exact Duchon log-kappa derivatives require hybrid Duchon with length_scale"
1295 .to_string(),
1296 )
1297 })?;
1298 let effective_nullspace_order = duchon_effective_nullspace_order(centers, spec.nullspace_order);
1304 let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
1305 let s_order = spec.power_as_usize();
1306 let kappa = 1.0 / length_scale;
1307 let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
1308 let z_kernel = duchon_frozen_radial_chart(
1311 kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?,
1312 spec,
1313 "design",
1314 )?;
1315 let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
1316 let p_padded = z_kernel.ncols() + poly_cols;
1317 if let Some(zf) = identifiability_transform
1318 && p_padded != zf.nrows()
1319 {
1320 crate::bail_dim_basis!(
1321 "Duchon identifiability transform mismatch in design derivatives: local cols={}, transform rows={}",
1322 p_padded,
1323 zf.nrows()
1324 );
1325 }
1326 let p_final = identifiability_transform
1327 .map(|zf| zf.ncols())
1328 .unwrap_or(p_padded);
1329 build_scalar_design_psi_derivatives_shared(
1330 data,
1331 centers,
1332 spec.aniso_log_scales.as_deref(),
1333 p_final,
1334 Some(z_kernel),
1335 identifiability_transform.cloned(),
1336 poly_cols,
1337 RadialScalarKind::Duchon {
1338 length_scale,
1339 p_order,
1340 s_order,
1341 dim: data.ncols(),
1342 coeffs,
1343 },
1344 duchon_scaling_exponent(p_order, s_order, data.ncols()),
1345 )
1346}
1347
1348pub fn build_duchon_basis_log_kappa_derivative(
1349 data: ArrayView2<'_, f64>,
1350 spec: &DuchonBasisSpec,
1351) -> Result<BasisPsiDerivativeResult, BasisError> {
1352 let mut workspace = BasisWorkspace::default();
1353 build_duchon_basis_log_kappa_derivativewithworkspace(data, spec, &mut workspace)
1354}
1355
1356pub fn build_duchon_basis_log_kappa_derivativewithworkspace(
1357 data: ArrayView2<'_, f64>,
1358 spec: &DuchonBasisSpec,
1359 workspace: &mut BasisWorkspace,
1360) -> Result<BasisPsiDerivativeResult, BasisError> {
1361 let mut bundle = build_duchon_basis_log_kappa_derivativeswithworkspace(data, spec, workspace)?;
1362 bundle.first.implicit_operator = bundle.implicit_operator;
1363 Ok(bundle.first)
1364}
1365
1366pub fn build_duchon_basis_log_kappa_derivatives(
1367 data: ArrayView2<'_, f64>,
1368 spec: &DuchonBasisSpec,
1369) -> Result<BasisPsiDerivativeBundle, BasisError> {
1370 let mut workspace = BasisWorkspace::default();
1371 build_duchon_basis_log_kappa_derivativeswithworkspace(data, spec, &mut workspace)
1372}
1373
1374pub(crate) fn duchon_operator_penalties_requested(spec: &DuchonOperatorPenaltySpec) -> bool {
1375 matches!(spec.mass, OperatorPenaltySpec::Active { .. })
1376 || matches!(spec.tension, OperatorPenaltySpec::Active { .. })
1377 || matches!(spec.stiffness, OperatorPenaltySpec::Active { .. })
1378}
1379
1380pub fn build_duchon_basis_log_kappa_derivativeswithworkspace(
1381 data: ArrayView2<'_, f64>,
1382 spec: &DuchonBasisSpec,
1383 workspace: &mut BasisWorkspace,
1384) -> Result<BasisPsiDerivativeBundle, BasisError> {
1385 if spec.periodic.is_some() {
1386 return build_periodic_duchon_basis_log_kappa_derivativeswithworkspace(
1387 data, spec, workspace,
1388 );
1389 }
1390 let chart = prepare_duchon_derivative_contextwithworkspace(data, spec, workspace)?;
1397 let operator_collocation_points =
1398 if duchon_operator_penalties_requested(&chart.spec.operator_penalties) {
1399 let m = (DUCHON_COLLOCATION_OVERSAMPLE * chart.centers.nrows()).min(data.nrows());
1400 Some(select_thin_plate_knots(data, m)?)
1401 } else {
1402 None
1403 };
1404 build_duchon_basis_log_kappa_derivativeswith_collocationwithworkspace(
1405 data,
1406 &chart.spec,
1407 chart.centers.view(),
1408 chart.identifiability_transform.as_ref(),
1409 operator_collocation_points
1410 .as_ref()
1411 .map(|points| points.view()),
1412 workspace,
1413 )
1414}
1415
1416pub fn build_duchon_basis_log_kappa_aniso_derivativeswith_collocationwithworkspace(
1431 data: ArrayView2<'_, f64>,
1432 spec: &DuchonBasisSpec,
1433 centers: ArrayView2<'_, f64>,
1434 identifiability_transform: Option<&Array2<f64>>,
1435 operator_collocation_points: Option<ArrayView2<'_, f64>>,
1436 workspace: &mut BasisWorkspace,
1437) -> Result<AnisoBasisPsiDerivatives, BasisError> {
1438 let dim = data.ncols();
1439 if !crate::basis::duchon_spec_supports_axis_psi(spec, dim) {
1440 crate::bail_invalid_basis!(
1441 "Duchon per-axis ψ derivatives requested for a spec whose per-axis surface is not \
1442 derived (dim={dim}, length_scale={:?}, periodic={}, power={})",
1443 spec.length_scale,
1444 spec.periodic.is_some(),
1445 spec.power
1446 );
1447 }
1448 let length_scale = spec.length_scale.expect("capability check requires a hybrid scale");
1449 let eta = spec
1450 .aniso_log_scales
1451 .clone()
1452 .expect("capability check requires resolved anisotropy");
1453 let effective_nullspace_order = duchon_effective_nullspace_order(centers, spec.nullspace_order);
1454 let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
1455 let s_order = spec.power_as_usize();
1456 let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, 1.0 / length_scale);
1457 let z_kernel = duchon_frozen_radial_chart(
1458 kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?,
1459 spec,
1460 "aniso design",
1461 )?;
1462 let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
1463 let p_padded = z_kernel.ncols() + poly_cols;
1464 if let Some(zf) = identifiability_transform
1465 && p_padded != zf.nrows()
1466 {
1467 crate::bail_dim_basis!(
1468 "Duchon identifiability transform mismatch in aniso design derivatives: local cols={}, transform rows={}",
1469 p_padded,
1470 zf.nrows()
1471 );
1472 }
1473 let p_final = identifiability_transform
1474 .map(|zf| zf.ncols())
1475 .unwrap_or(p_padded);
1476 let mut result = build_aniso_design_psi_derivatives_shared(
1477 data,
1478 centers,
1479 &eta,
1480 p_final,
1481 Some(z_kernel),
1482 identifiability_transform.cloned(),
1483 poly_cols,
1484 RadialScalarKind::Duchon {
1485 length_scale,
1486 p_order,
1487 s_order,
1488 dim,
1489 coeffs,
1490 },
1491 )?;
1492
1493 let directions: Vec<DuchonPsiDirection> = (0..dim).map(DuchonPsiDirection::Axis).collect();
1494 let native = crate::basis::build_duchon_native_penalty_psi_derivatives_in_directions(
1495 centers,
1496 spec,
1497 identifiability_transform,
1498 workspace,
1499 &directions,
1500 )?;
1501 let operator = if duchon_operator_penalties_requested(&spec.operator_penalties) {
1502 let Some(collocation_points) = operator_collocation_points else {
1503 crate::bail_invalid_basis!(
1504 "Duchon per-axis operator penalty derivatives require realized collocation points"
1505 );
1506 };
1507 crate::basis::build_duchon_operator_penalty_psi_derivatives_in_directions(
1508 collocation_points,
1509 centers,
1510 spec,
1511 identifiability_transform,
1512 workspace,
1513 &directions,
1514 )?
1515 } else {
1516 vec![(Vec::new(), Vec::new(), Vec::new()); dim]
1517 };
1518
1519 let mut penalties_first = Vec::with_capacity(dim);
1523 let mut penalties_second_diag = Vec::with_capacity(dim);
1524 let expected = native[0].0.len() + operator[0].0.len();
1525 for axis in 0..dim {
1526 if native[axis].0.len() != native[0].0.len()
1527 || operator[axis].0.len() != operator[0].0.len()
1528 {
1529 crate::bail_invalid_basis!(
1530 "Duchon per-axis penalty source counts disagree across axes: axis {axis} has \
1531 {}+{} blocks, axis 0 has {}+{}",
1532 native[axis].0.len(),
1533 operator[axis].0.len(),
1534 native[0].0.len(),
1535 operator[0].0.len()
1536 );
1537 }
1538 let mut first = Vec::with_capacity(expected);
1539 let mut second = Vec::with_capacity(expected);
1540 first.extend(native[axis].1.iter().cloned());
1541 first.extend(operator[axis].1.iter().cloned());
1542 second.extend(native[axis].2.iter().cloned());
1543 second.extend(operator[axis].2.iter().cloned());
1544 if first.len() != expected || second.len() != expected {
1545 crate::bail_invalid_basis!(
1546 "Duchon per-axis penalty derivative count mismatch on axis {axis}: assembled \
1547 {}/{} against {expected} active sources",
1548 first.len(),
1549 second.len()
1550 );
1551 }
1552 penalties_first.push(first);
1553 penalties_second_diag.push(second);
1554 }
1555 result.penalties_first = penalties_first;
1556 result.penalties_second_diag = penalties_second_diag;
1557 result.penalties_cross_pairs = Vec::new();
1562 result.penalties_cross_provider = None;
1563 Ok(result)
1564}
1565
1566pub fn build_duchon_basis_log_kappa_derivativeswith_collocationwithworkspace(
1567 data: ArrayView2<'_, f64>,
1568 spec: &DuchonBasisSpec,
1569 centers: ArrayView2<'_, f64>,
1570 identifiability_transform: Option<&Array2<f64>>,
1571 operator_collocation_points: Option<ArrayView2<'_, f64>>,
1572 workspace: &mut BasisWorkspace,
1573) -> Result<BasisPsiDerivativeBundle, BasisError> {
1574 let design_derivatives = build_duchon_design_psi_derivativeswithworkspace(
1575 data,
1576 centers,
1577 spec,
1578 identifiability_transform,
1579 workspace,
1580 )?;
1581 let (native_sources, native_first, native_second) =
1582 build_duchon_native_penalty_psi_derivatives(
1583 centers,
1584 spec,
1585 identifiability_transform,
1586 workspace,
1587 )?;
1588 let (operator_sources, operator_first, operator_second) = if duchon_operator_penalties_requested(
1589 &spec.operator_penalties,
1590 ) {
1591 let Some(collocation_points) = operator_collocation_points else {
1592 crate::bail_invalid_basis!(
1593 "Duchon log-kappa operator penalty derivatives require realized collocation points"
1594 );
1595 };
1596 build_duchon_operator_penalty_psi_derivatives(
1597 collocation_points,
1598 centers,
1599 spec,
1600 identifiability_transform,
1601 workspace,
1602 )?
1603 } else {
1604 (Vec::new(), Vec::new(), Vec::new())
1605 };
1606 let mut penalties_derivative = Vec::with_capacity(native_first.len() + operator_first.len());
1607 penalties_derivative.extend(native_first);
1608 penalties_derivative.extend(operator_first);
1609 let mut penaltiessecond_derivative =
1610 Vec::with_capacity(native_second.len() + operator_second.len());
1611 penaltiessecond_derivative.extend(native_second);
1612 penaltiessecond_derivative.extend(operator_second);
1613 let expected_derivative_count = native_sources.len() + operator_sources.len();
1614 if penalties_derivative.len() != expected_derivative_count {
1615 crate::bail_invalid_basis!(
1616 "Duchon penalty derivative count mismatch: assembled {}, expected {} from active penalty sources",
1617 penalties_derivative.len(),
1618 expected_derivative_count
1619 );
1620 }
1621 Ok(BasisPsiDerivativeBundle {
1622 first: BasisPsiDerivativeResult {
1623 design_derivative: design_derivatives.design_first,
1624 penalties_derivative,
1625 implicit_operator: None,
1626 },
1627 second: BasisPsiSecondDerivativeResult {
1628 designsecond_derivative: design_derivatives.design_second_diag,
1629 penaltiessecond_derivative,
1630 implicit_operator: None,
1631 },
1632 implicit_operator: design_derivatives.implicit_operator,
1633 })
1634}
1635
1636pub fn build_duchon_basis_log_kappasecond_derivative(
1637 data: ArrayView2<'_, f64>,
1638 spec: &DuchonBasisSpec,
1639) -> Result<BasisPsiSecondDerivativeResult, BasisError> {
1640 let mut workspace = BasisWorkspace::default();
1641 build_duchon_basis_log_kappasecond_derivativewithworkspace(data, spec, &mut workspace)
1642}
1643
1644pub fn build_duchon_basis_log_kappasecond_derivativewithworkspace(
1645 data: ArrayView2<'_, f64>,
1646 spec: &DuchonBasisSpec,
1647 workspace: &mut BasisWorkspace,
1648) -> Result<BasisPsiSecondDerivativeResult, BasisError> {
1649 let mut bundle = build_duchon_basis_log_kappa_derivativeswithworkspace(data, spec, workspace)?;
1650 bundle.second.implicit_operator = bundle.implicit_operator;
1651 Ok(bundle.second)
1652}
1653
1654pub(crate) fn duchon_kernel_amplification(
1672 centers: ArrayView2<'_, f64>,
1673 length_scale: Option<f64>,
1674 p_order: usize,
1675 s_order: usize,
1676 d: usize,
1677 aniso_log_scales: Option<&[f64]>,
1678 coeffs: Option<&DuchonPartialFractionCoeffs>,
1679 pure_poly_coeff: Option<&PolyharmonicBlockCoeff>,
1680) -> f64 {
1681 let k = centers.nrows();
1682 if k == 0 {
1683 return 1.0;
1684 }
1685 let axis_scales = aniso_log_scales.map(aniso_axis_scales);
1686 let mut max_abs = 0.0_f64;
1687 for i in 0..k {
1688 for j in i..k {
1689 let r = if let Some(scales) = axis_scales.as_deref() {
1690 aniso_distance_rows_with_scales(centers, i, centers, j, scales)
1691 } else {
1692 euclidean_distance_rows(centers, i, centers, j)
1693 };
1694 let val = if let Some(ppc) = pure_poly_coeff {
1695 ppc.eval(r)
1696 } else {
1697 match duchon_matern_kernel_general_from_distance(
1698 r,
1699 length_scale,
1700 p_order,
1701 s_order,
1702 d,
1703 coeffs,
1704 ) {
1705 Ok(v) => v,
1706 Err(_) => continue,
1707 }
1708 };
1709 if val.abs() > max_abs {
1710 max_abs = val.abs();
1711 }
1712 }
1713 }
1714 if max_abs > 0.0 && max_abs < 1e-10 {
1718 1.0 / max_abs
1719 } else {
1720 1.0
1721 }
1722}
1723
1724pub fn duchon_pure_kernel_amplification(
1741 centers: ArrayView2<'_, f64>,
1742 order: DuchonNullspaceOrder,
1743 power: f64,
1744) -> f64 {
1745 let dim = centers.ncols();
1746 if dim == 0 || centers.nrows() == 0 {
1747 return 1.0;
1748 }
1749 let effective_order = duchon_effective_nullspace_order(centers, order);
1750 let p_order = duchon_p_from_nullspace_order(effective_order);
1751 let s_order: f64 = power;
1752 let pure_poly_coeff =
1753 PolyharmonicBlockCoeff::new(pure_duchon_block_order(p_order, s_order), dim);
1754 duchon_kernel_amplification(
1755 centers,
1756 None,
1757 p_order,
1758 duchon_power_to_usize(s_order),
1759 dim,
1760 None,
1761 None,
1762 Some(&pure_poly_coeff),
1763 )
1764}
1765
1766pub(crate) fn build_duchon_basis_designwithworkspace(
1767 data: ArrayView2<'_, f64>,
1768 centers: ArrayView2<'_, f64>,
1769 length_scale: Option<f64>,
1770 power: f64,
1771 nullspace_order: DuchonNullspaceOrder,
1772 aniso_log_scales: Option<&[f64]>,
1773 radial_reparam: Option<&Array2<f64>>,
1774 spectral_kernel_transform: Option<&Array2<f64>>,
1775 workspace: &mut BasisWorkspace,
1776) -> Result<DuchonBasisDesign, BasisError> {
1777 DUCHON_DESIGN_BUILD_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1778 let n = data.nrows();
1779 let d = data.ncols();
1780 let k = centers.nrows();
1781
1782 if d == 0 {
1783 crate::bail_invalid_basis!("Duchon basis requires at least one covariate dimension");
1784 }
1785 if k == 0 {
1786 crate::bail_invalid_basis!("Duchon basis requires at least one center");
1787 }
1788 if centers.ncols() != d {
1789 crate::bail_dim_basis!(
1790 "Duchon basis dimension mismatch: data has {d} columns, centers have {}",
1791 centers.ncols()
1792 );
1793 }
1794 if data.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
1795 crate::bail_invalid_basis!("Duchon basis requires finite data and center values");
1796 }
1797 let nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
1800 let p_order = duchon_p_from_nullspace_order(nullspace_order);
1801 let s_order: f64 = power;
1802 let validation_power = if length_scale.is_some() {
1810 duchon_power_to_usize(s_order) as f64
1811 } else {
1812 s_order
1813 };
1814 validate_duchon_kernel_orders(length_scale, p_order, validation_power, d)?;
1815
1816 let center_mean: Vec<f64> = (0..d)
1832 .map(|c| centers.column(c).sum() / (k.max(1) as f64))
1833 .collect();
1834 let mut data_centered = data.to_owned();
1835 for c in 0..d {
1836 let mu = center_mean[c];
1837 data_centered.column_mut(c).mapv_inplace(|v| v - mu);
1838 }
1839
1840 let poly_block = polynomial_block_from_order(data_centered.view(), nullspace_order);
1841 if radial_reparam.is_some() && spectral_kernel_transform.is_some() {
1850 crate::bail_invalid_basis!(
1851 "Duchon design cannot combine landmark radial reparameterization with a direct \
1852 spectral kernel transform"
1853 );
1854 }
1855 let z_raw = if let Some(spectral) = spectral_kernel_transform {
1856 if spectral.nrows() != centers.nrows() {
1857 crate::bail_dim_basis!(
1858 "Duchon spectral kernel transform shape {:?} does not match {} centers",
1859 spectral.dim(),
1860 centers.nrows()
1861 );
1862 }
1863 spectral.clone()
1864 } else {
1865 kernel_constraint_nullspace(centers, nullspace_order, &mut workspace.cache)?
1866 };
1867 let z = if let Some(v) = radial_reparam {
1873 if v.nrows() != z_raw.ncols() {
1874 crate::bail_dim_basis!(
1875 "Duchon radial reparam shape {:?} does not match constrained kernel dimension {}",
1876 v.dim(),
1877 z_raw.ncols()
1878 );
1879 }
1880 fast_ab(&z_raw, v)
1881 } else {
1882 z_raw
1883 };
1884
1885 let coeffs = length_scale.map(|ls| {
1886 duchon_partial_fraction_coeffs(
1887 p_order,
1888 duchon_power_to_usize(s_order),
1889 1.0 / ls.max(1e-300),
1890 )
1891 });
1892
1893 let warn_bounds = match (length_scale, aniso_log_scales) {
1900 (Some(_), Some(eta)) => {
1901 let y_centers = points_in_aniso_y_space(centers, eta);
1902 pairwise_distance_bounds(y_centers.view())
1903 }
1904 (Some(_), None) => pairwise_distance_bounds(centers),
1905 (None, _) => None,
1906 };
1907 if let (Some(length_scale), Some((r_min, r_max))) = (length_scale, warn_bounds) {
1908 let kappa = 1.0 / length_scale.max(1e-300);
1909 let kappa_lo = 1e-2 / r_max;
1910 let kappa_hi = 1e2 / r_min;
1911 if kappa < kappa_lo || kappa > kappa_hi {
1912 log::debug!(
1913 "Duchon κ={} is outside recommended range [{}, {}] derived from centers (r_min={}, r_max={}); numerical conditioning may degrade",
1914 kappa,
1915 kappa_lo,
1916 kappa_hi,
1917 r_min,
1918 r_max
1919 );
1920 }
1921 }
1922
1923 let kernel_cols = z.ncols();
1924 let poly_cols = poly_block.ncols();
1925 let total_cols = kernel_cols + poly_cols;
1926
1927 let pure_poly_coeff = if length_scale.is_none() {
1930 Some(PolyharmonicBlockCoeff::new(
1931 (pure_duchon_block_order(p_order, s_order)) as f64,
1932 d,
1933 ))
1934 } else {
1935 None
1936 };
1937
1938 let axis_scales = aniso_log_scales.map(aniso_axis_scales);
1939 let kernel_amp = duchon_kernel_amplification(
1940 centers,
1941 length_scale,
1942 p_order,
1943 duchon_power_to_usize(s_order),
1944 d,
1945 aniso_log_scales,
1946 coeffs.as_ref(),
1947 pure_poly_coeff.as_ref(),
1948 );
1949 let hybrid_kind = match (length_scale, coeffs.as_ref()) {
1959 (Some(ls), Some(c)) if pure_poly_coeff.is_none() => Some(RadialScalarKind::Duchon {
1960 length_scale: ls,
1961 p_order,
1962 s_order: duchon_power_to_usize(s_order),
1963 dim: d,
1964 coeffs: c.clone(),
1965 }),
1966 _ => None,
1967 };
1968 let value_profile = hybrid_kind.as_ref().and_then(|kind| {
1969 if n.saturating_mul(k) < RADIAL_PROFILE_MIN_PAIRS {
1970 return None;
1971 }
1972 let (r_lo, r_hi) = (0..n)
1973 .into_par_iter()
1974 .map(|i| {
1975 let mut lo = f64::INFINITY;
1976 let mut hi = 0.0_f64;
1977 for j in 0..k {
1978 let r = if let Some(scales) = axis_scales.as_deref() {
1979 aniso_distance_rows_with_scales(data, i, centers, j, scales)
1980 } else {
1981 euclidean_distance_rows(data, i, centers, j)
1982 };
1983 if r > 0.0 {
1984 lo = lo.min(r);
1985 hi = hi.max(r);
1986 }
1987 }
1988 (lo, hi)
1989 })
1990 .reduce(
1991 || (f64::INFINITY, 0.0_f64),
1992 |a, b| (a.0.min(b.0), a.1.max(b.1)),
1993 );
1994 if r_lo.is_finite() && r_hi > r_lo {
1995 radial_profile::RadialProfile::build(kind, r_lo, r_hi)
1996 } else {
1997 None
1998 }
1999 });
2000 let mut basis = Array2::<f64>::zeros((n, total_cols));
2001 let chunk_size = 1024.min(n);
2004 let basis_result: Result<(), BasisError> = basis
2005 .axis_chunks_iter_mut(Axis(0), chunk_size)
2006 .into_par_iter()
2007 .enumerate()
2008 .try_for_each(|(ci, mut chunk)| {
2009 let mut kernel_row = vec![0.0; k];
2010 let chunk_start = ci * chunk_size;
2011 for local_i in 0..chunk.nrows() {
2012 let i = chunk_start + local_i;
2013 for j in 0..k {
2014 let r = if let Some(scales) = axis_scales.as_deref() {
2015 aniso_distance_rows_with_scales(data, i, centers, j, scales)
2016 } else {
2017 euclidean_distance_rows(data, i, centers, j)
2018 };
2019 let raw = if let Some(ref ppc) = pure_poly_coeff {
2020 ppc.eval(r)
2022 } else if let (Some(profile), Some(kind)) =
2023 (value_profile.as_ref(), hybrid_kind.as_ref())
2024 {
2025 profile.eval_or_exact(kind, r)?.0
2026 } else {
2027 duchon_matern_kernel_general_from_distance(
2028 r,
2029 length_scale,
2030 p_order,
2031 duchon_power_to_usize(s_order),
2032 d,
2033 coeffs.as_ref(),
2034 )?
2035 };
2036 kernel_row[j] = raw * kernel_amp;
2037 }
2038 let mut row = chunk.row_mut(local_i);
2044 row.slice_mut(s![..kernel_cols]).fill(0.0);
2045 for j in 0..k {
2046 let kv = kernel_row[j];
2047 if kv != 0.0 {
2048 let z_row = z.row(j);
2049 for col in 0..kernel_cols {
2050 row[col] += kv * z_row[col];
2051 }
2052 }
2053 }
2054 }
2055 Ok(())
2056 });
2057 basis_result?;
2058 if poly_cols > 0 {
2059 basis.slice_mut(s![.., kernel_cols..]).assign(&poly_block);
2060 }
2061
2062 Ok(DuchonBasisDesign { basis })
2063}
2064
2065pub fn build_duchon_basis(
2067 data: ArrayView2<'_, f64>,
2068 spec: &DuchonBasisSpec,
2069) -> Result<BasisBuildResult, BasisError> {
2070 let mut workspace = BasisWorkspace::default();
2071 build_duchon_basiswithworkspace(data, spec, &mut workspace)
2072}
2073
2074pub fn create_duchon_basis_1d_derivative_dense(
2075 t: ArrayView1<'_, f64>,
2076 centers: ArrayView1<'_, f64>,
2077 power: f64,
2078 nullspace_order: DuchonNullspaceOrder,
2079 periodic: bool,
2080 period: Option<f64>,
2081 order: usize,
2082) -> Result<Array2<f64>, BasisError> {
2083 create_duchon_basis_1d_derivative_dense_with_radial_reparam(
2084 t,
2085 centers,
2086 power,
2087 nullspace_order,
2088 periodic,
2089 period,
2090 None,
2091 order,
2092 )
2093}
2094
2095pub fn create_duchon_basis_1d_derivative_dense_with_radial_reparam(
2100 t: ArrayView1<'_, f64>,
2101 centers: ArrayView1<'_, f64>,
2102 power: f64,
2103 nullspace_order: DuchonNullspaceOrder,
2104 periodic: bool,
2105 period: Option<f64>,
2106 radial_reparam: Option<ArrayView2<'_, f64>>,
2107 order: usize,
2108) -> Result<Array2<f64>, BasisError> {
2109 if order > 2 {
2110 crate::bail_invalid_basis!(
2111 "Duchon basis derivative supports orders 0, 1, and 2; got order={order}"
2112 );
2113 }
2114 if t.is_empty() || centers.is_empty() {
2115 crate::bail_invalid_basis!("Duchon basis derivative requires non-empty t and centers");
2116 }
2117 if t.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
2118 crate::bail_invalid_basis!("Duchon basis derivative requires finite t and center values");
2119 }
2120 if !periodic && period.is_some() {
2121 crate::bail_invalid_basis!(
2122 "Duchon basis derivative period is only valid when periodic=true"
2123 );
2124 }
2125 if periodic && radial_reparam.is_some() {
2126 crate::bail_invalid_basis!(
2127 "periodic 1-D Duchon derivatives do not admit an open-domain radial reparameterization"
2128 );
2129 }
2130
2131 let data = t.to_owned().insert_axis(Axis(1));
2132 let center_matrix = centers.to_owned().insert_axis(Axis(1));
2133 let mut workspace = BasisWorkspace::default();
2134 let user_m = duchon_p_from_nullspace_order(nullspace_order);
2139 let effective_order = if periodic {
2140 DuchonNullspaceOrder::Zero
2141 } else {
2142 duchon_effective_nullspace_order(center_matrix.view(), nullspace_order)
2143 };
2144 let p_order = duchon_p_from_nullspace_order(effective_order);
2145 let s_order = duchon_power_to_usize(power);
2146 validate_duchon_kernel_orders(None, p_order, s_order as f64, 1)?;
2147
2148 if periodic {
2149 let (collapsed_centers, left, resolved_period) =
2156 prepare_periodic_duchon_centers_1d_with_period(center_matrix, period)?;
2157 let z = kernel_constraint_nullspace(
2158 collapsed_centers.view(),
2159 effective_order,
2160 &mut workspace.cache,
2161 )?;
2162 let kernel_cols = z.ncols();
2163 let k_centers = collapsed_centers.nrows();
2164 let centers_col0: Vec<f64> = collapsed_centers.column(0).to_vec();
2165 let mut raw_kernel = Array2::<f64>::zeros((t.len(), k_centers));
2166 for i in 0..t.len() {
2167 let x = wrap_to_period(t[i], left, resolved_period);
2168 for j in 0..k_centers {
2169 let mut delta = (x - centers_col0[j]).rem_euclid(resolved_period);
2171 if delta > 0.5 * resolved_period {
2172 delta -= resolved_period;
2173 }
2174 let r = delta.abs();
2175 let sign = if delta > 0.0 {
2176 1.0
2177 } else if delta < 0.0 {
2178 -1.0
2179 } else {
2180 0.0
2181 };
2182 let (phi, phi_r, phi_rr) =
2183 periodic_duchon_kernel_bernoulli_triplet(r, user_m, resolved_period)?;
2184 raw_kernel[[i, j]] = match order {
2185 0 => phi,
2186 1 => phi_r * sign,
2187 2 => phi_rr,
2188 other => {
2189 crate::bail_invalid_basis!(
2190 "Duchon basis derivative supports orders 0, 1, and 2; got order={other}"
2191 );
2192 }
2193 };
2194 }
2195 }
2196 let mut basis = Array2::<f64>::zeros((t.len(), kernel_cols + 1));
2199 let design_kernel = fast_ab(&raw_kernel, &z);
2200 basis
2201 .slice_mut(s![.., 0..kernel_cols])
2202 .assign(&design_kernel);
2203 if order == 0 {
2204 basis.column_mut(kernel_cols).fill(1.0);
2205 }
2206 return Ok(basis);
2207 }
2208
2209 let mut z =
2210 kernel_constraint_nullspace(center_matrix.view(), effective_order, &mut workspace.cache)?;
2211 if let Some(radial_reparam) = radial_reparam {
2212 if radial_reparam.nrows() != z.ncols() {
2213 crate::bail_dim_basis!(
2214 "Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
2215 radial_reparam.dim(),
2216 z.ncols()
2217 );
2218 }
2219 z = fast_ab(&z, &radial_reparam.to_owned());
2220 }
2221 let kernel_cols = z.ncols();
2222 let poly_cols = polynomial_block_from_order(data.view(), effective_order).ncols();
2223
2224 let pure_coeff =
2225 PolyharmonicBlockCoeff::new((pure_duchon_block_order(p_order, s_order as f64)) as f64, 1);
2226 let kernel_amp = duchon_kernel_amplification(
2227 center_matrix.view(),
2228 None,
2229 p_order,
2230 s_order,
2231 1,
2232 None,
2233 None,
2234 Some(&pure_coeff),
2235 );
2236
2237 let mut raw_kernel = Array2::<f64>::zeros((t.len(), centers.len()));
2238 for i in 0..t.len() {
2239 let x = t[i];
2240 for j in 0..centers.len() {
2241 let delta = x - centers[j];
2242 let r = delta.abs();
2243 let sign = if delta > 0.0 {
2244 1.0
2245 } else if delta < 0.0 {
2246 -1.0
2247 } else {
2248 0.0
2249 };
2250 let (phi, phi_r, phi_rr) =
2251 duchon_kernel_radial_triplet(r, None, p_order, s_order as f64, 1, None)?;
2252 raw_kernel[[i, j]] = match order {
2253 0 => phi,
2254 1 => phi_r * sign,
2255 2 => phi_rr,
2256 other => {
2257 crate::bail_invalid_basis!(
2258 "Duchon basis derivative supports orders 0, 1, and 2; got order={other}"
2259 );
2260 }
2261 } * kernel_amp;
2262 }
2263 }
2264
2265 let mut basis = Array2::<f64>::zeros((t.len(), kernel_cols + poly_cols));
2266 let design_kernel = fast_ab(&raw_kernel, &z);
2267 basis
2268 .slice_mut(s![.., 0..kernel_cols])
2269 .assign(&design_kernel);
2270 fill_duchon_1d_polynomial_derivative(&mut basis, kernel_cols, t, effective_order, order);
2271 Ok(basis)
2272}
2273
2274#[cfg(test)]
2275mod taylor_degree_tests {
2276 use super::*;
2277
2278 #[test]
2289 fn half_integer_matern_taylor_coeffs_1604() {
2290 let want_nu_3_2 = [0.25_f64, -0.125, -0.03125];
2291 let want_nu_5_2 = [0.1875_f64, -0.03125, 0.0078125];
2292 for (j, &want) in want_nu_3_2.iter().enumerate() {
2293 let (pure, log) = duchon_matern_block_taylor_r2j(1.0, 2, 1, j);
2294 assert!(log == 0.0, "no log term for half-integer ν (j={j}): {log}");
2295 assert!(
2296 (pure - want).abs() < 1e-13,
2297 "ν=3/2 r^{{{}}} coeff: got {pure:.15}, want {want}",
2298 2 * j
2299 );
2300 }
2301 for (j, &want) in want_nu_5_2.iter().enumerate() {
2302 let (pure, log) = duchon_matern_block_taylor_r2j(1.0, 3, 1, j);
2303 assert!(log == 0.0, "no log term for half-integer ν (j={j}): {log}");
2304 assert!(
2305 (pure - want).abs() < 1e-13,
2306 "ν=5/2 r^{{{}}} coeff: got {pure:.15}, want {want}",
2307 2 * j
2308 );
2309 }
2310 }
2311
2312 #[test]
2317 fn half_integer_matern_taylor_j0_matches_value_limit_1604() {
2318 let d = 1usize;
2319 for n in 1..=4usize {
2320 let nu = n as f64 - 0.5 * d as f64; for &kappa in &[0.3_f64, 1.0, 2.0, 7.5] {
2322 let (pure, _log) = duchon_matern_block_taylor_r2j(kappa, n, d, 0);
2323 let want = duchon_matern_block(0.0, kappa, n, d).expect("r→0 limit");
2325 let rel = (pure - want).abs() / want.abs().max(1e-300);
2326 assert!(
2327 rel < 1e-12,
2328 "ν={nu} κ={kappa}: Taylor j=0 {pure:.15e} vs value limit {want:.15e} (rel {rel:.2e})"
2329 );
2330 }
2331 }
2332 }
2333}
2334
2335#[cfg(test)]
2336mod end_to_end_1604_tests {
2337 use super::*;
2338 use gam_linalg::faer_ndarray::FaerEigh;
2339
2340 #[test]
2347 fn d1_hybrid_duchon_power_ge_2_builds_psd() {
2348 let n = 40usize;
2350 let mut data = Array2::<f64>::zeros((n, 1));
2351 for i in 0..n {
2352 data[[i, 0]] = -1.0 + 2.0 * (i as f64) / (n as f64 - 1.0);
2353 }
2354 for &power in &[2.0f64, 3.0] {
2355 let spec = DuchonBasisSpec {
2356 center_strategy: CenterStrategy::FarthestPoint { num_centers: 12 },
2357 periodic: None,
2358 length_scale: Some(0.5),
2359 power,
2360 nullspace_order: DuchonNullspaceOrder::Linear,
2361 identifiability: SpatialIdentifiability::None,
2362 aniso_log_scales: None,
2363 operator_penalties: DuchonOperatorPenaltySpec::default(),
2364 boundary: OneDimensionalBoundary::Open,
2365 radial_reparam: None,
2366 };
2367 let result = build_duchon_basis(data.view(), &spec).unwrap_or_else(|e| {
2368 panic!("d=1 hybrid Duchon power={power} build rejected (gam#1604): {e}")
2369 });
2370 assert!(
2371 !result.active_penalties.is_empty(),
2372 "d=1 hybrid Duchon power={power} produced no penalty"
2373 );
2374 for (k, penalty) in result.active_penalties.iter().enumerate() {
2375 let sym = symmetrize_penalty(&penalty.matrix);
2376 let (evals, _) =
2377 FaerEigh::eigh(&sym, faer::Side::Lower).expect("symmetric eigendecomposition");
2378 let lam_min = evals.iter().copied().fold(f64::INFINITY, f64::min);
2379 let lam_max = evals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2380 let tol = 1e-9 * lam_max.abs().max(1.0);
2381 assert!(
2382 lam_min >= -tol,
2383 "d=1 hybrid Duchon power={power} penalty[{k}] not PSD: λ_min={lam_min:.6e} (tol={tol:.3e})"
2384 );
2385 }
2386 }
2387 }
2388}