1use super::*;
2
3#[derive(Debug, Clone)]
30pub struct ImplicitDesignPsiDerivative {
31 pub(crate) phi_values: Array1<f64>,
34
35 pub(crate) axis_components: Array2<f64>,
39
40 pub(crate) q_values: Array1<f64>,
43
44 pub(crate) t_values: Array1<f64>,
47
48 pub(crate) streaming: Option<StreamingRadialState>,
51
52 pub(crate) ident_transform: Option<Array2<f64>>,
59
60 pub(crate) full_ident_transform: Option<Array2<f64>>,
67
68 pub(crate) n: usize,
70
71 pub(crate) n_knots: usize,
73
74 pub(crate) n_poly: usize,
77
78 pub(crate) n_axes: usize,
80
81 pub(crate) psi_scale_share: f64,
83
84 pub(crate) axis_combinations: Option<Vec<Vec<(usize, f64)>>>,
87}
88
89pub struct LatentCoordDesignDerivative {
97 pub(crate) provider: Arc<dyn LocalDesignJacobianProvider>,
98}
99
100#[derive(Debug, Clone)]
101pub(crate) struct RadialLatentCoordLocalDesignJacobian {
102 pub(crate) latent: Arc<crate::latent::LatentCoordValues>,
103 pub(crate) centers: Arc<Array2<f64>>,
105 pub(crate) input_scale: crate::IsotropicScale,
113 pub(crate) radial_kind: RadialScalarKind,
114 pub(crate) ident_transform: Option<Array2<f64>>,
115 pub(crate) full_ident_transform: Option<Array2<f64>>,
116 pub(crate) n_poly: usize,
117 pub(crate) polynomial_order: Option<DuchonNullspaceOrder>,
118}
119
120#[derive(Debug, Clone)]
121pub(crate) struct JetLatentCoordLocalDesignJacobian {
122 pub(crate) latent: Arc<crate::latent::LatentCoordValues>,
123 pub(crate) jet: Arc<Array3<f64>>,
124 pub(crate) ident_transform: Option<Array2<f64>>,
125}
126
127impl std::fmt::Debug for LatentCoordDesignDerivative {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.debug_struct("LatentCoordDesignDerivative")
130 .field("n_data", &self.n_data())
131 .field("latent_dim", &self.latent_dim())
132 .field("n_axes", &self.n_axes())
133 .field("p_out", &self.p_out())
134 .field("provider", &self.provider)
135 .finish()
136 }
137}
138
139impl Clone for LatentCoordDesignDerivative {
140 fn clone(&self) -> Self {
141 Self {
142 provider: Arc::clone(&self.provider),
143 }
144 }
145}
146
147impl RadialLatentCoordLocalDesignJacobian {
148 pub(crate) fn p_constrained(&self) -> usize {
149 self.ident_transform
150 .as_ref()
151 .map_or(self.centers.nrows(), Array2::ncols)
152 }
153
154 pub(crate) fn p_after_pad(&self) -> usize {
155 self.p_constrained() + self.n_poly
156 }
157
158 pub(crate) fn p_out(&self) -> usize {
159 self.full_ident_transform
160 .as_ref()
161 .map_or(self.p_after_pad(), Array2::ncols)
162 }
163}
164
165impl JetLatentCoordLocalDesignJacobian {
166 pub(crate) fn p_out(&self) -> usize {
167 self.ident_transform
168 .as_ref()
169 .map_or(self.jet.shape()[1], Array2::ncols)
170 }
171}
172
173pub trait LocalDesignJacobianProvider: Send + Sync + std::fmt::Debug {
188 fn n_data(&self) -> usize;
190
191 fn latent_dim(&self) -> usize;
193
194 fn n_axes(&self) -> usize;
196
197 fn p_out(&self) -> usize;
199
200 fn local_design_jacobian_row(&self, row: usize, axis: usize)
203 -> Result<Array1<f64>, BasisError>;
204
205 fn row_axis(&self, flat_axis: usize) -> (usize, usize) {
208 let d = self.latent_dim();
209 (flat_axis / d, flat_axis % d)
210 }
211
212 fn forward_mul_axis(
214 &self,
215 flat_axis: usize,
216 u: &ArrayView1<'_, f64>,
217 ) -> Result<Array1<f64>, BasisError> {
218 assert!(
219 flat_axis < self.n_axes(),
220 "latent-coordinate derivative flat axis out of bounds in forward_mul_axis: flat_axis={flat_axis}, n_axes={}",
221 self.n_axes()
222 );
223 let (row, axis) = self.row_axis(flat_axis);
224 let local_jacobian = self.local_design_jacobian_row(row, axis)?;
225 assert_eq!(
226 u.len(),
227 local_jacobian.len(),
228 "latent-coordinate derivative coefficient length mismatch in forward_mul_axis"
229 );
230 let value = local_jacobian.dot(u);
231 let mut out = Array1::<f64>::zeros(self.n_data());
232 out[row] = value;
233 Ok(out)
234 }
235
236 fn transpose_mul_axis(
238 &self,
239 flat_axis: usize,
240 v: &ArrayView1<'_, f64>,
241 ) -> Result<Array1<f64>, BasisError> {
242 assert!(
243 flat_axis < self.n_axes(),
244 "latent-coordinate derivative flat axis out of bounds in transpose_mul_axis: flat_axis={flat_axis}, n_axes={}",
245 self.n_axes()
246 );
247 assert_eq!(
248 v.len(),
249 self.n_data(),
250 "latent-coordinate derivative row-adjoint length mismatch in transpose_mul_axis"
251 );
252 let (row, axis) = self.row_axis(flat_axis);
253 let scale = v[row];
254 Ok(self
255 .local_design_jacobian_row(row, axis)?
256 .mapv(|value| scale * value))
257 }
258
259 fn materialize_axis(&self, flat_axis: usize) -> Result<Array2<f64>, BasisError> {
262 assert!(
263 flat_axis < self.n_axes(),
264 "latent-coordinate derivative flat axis out of bounds in materialize_axis: flat_axis={flat_axis}, n_axes={}",
265 self.n_axes()
266 );
267 let (row, axis) = self.row_axis(flat_axis);
268 let projected = self.local_design_jacobian_row(row, axis)?;
269 let mut out = Array2::<f64>::zeros((self.n_data(), projected.len()));
270 out.row_mut(row).assign(&projected);
271 Ok(out)
272 }
273}
274
275pub(crate) const IMPLICIT_MATVEC_CHUNK_SIZE: usize = 1000;
278
279pub(crate) const IMPLICIT_MATVEC_PAR_THRESHOLD: usize = 10_000;
281
282pub(crate) const THIN_PLATE_PENALTY_PSI_TILE_ROWS: usize = 32;
285
286impl LatentCoordDesignDerivative {
287 pub(crate) fn from_local_design_jacobian_provider(
288 provider: Arc<dyn LocalDesignJacobianProvider>,
289 ) -> Self {
290 Self { provider }
291 }
292
293 pub fn new_matern(
299 latent: Arc<crate::latent::LatentCoordValues>,
300 centers: Arc<Array2<f64>>,
301 input_scale: crate::IsotropicScale,
302 length_scale: crate::OriginalUnits,
303 nu: MaternNu,
304 include_intercept: bool,
305 ident_transform: Option<Array2<f64>>,
306 ) -> Result<Self, BasisError> {
307 if latent.latent_dim() != centers.ncols() {
308 crate::bail_dim_basis!(
309 "LatentCoordDesignDerivative Matérn dimension mismatch: latent d={} centers d={}",
310 latent.latent_dim(),
311 centers.ncols()
312 );
313 }
314 let length_scale = input_scale
315 .to_standardized_units(length_scale)
316 .standardized_value();
317 Ok(Self::from_local_design_jacobian_provider(Arc::new(
318 RadialLatentCoordLocalDesignJacobian {
319 latent,
320 centers,
321 input_scale,
322 radial_kind: RadialScalarKind::Matern { length_scale, nu },
323 ident_transform,
324 full_ident_transform: None,
325 n_poly: usize::from(include_intercept),
326 polynomial_order: None,
327 },
328 )))
329 }
330
331 pub fn new_duchon(
334 latent: Arc<crate::latent::LatentCoordValues>,
335 centers: Arc<Array2<f64>>,
336 input_scale: crate::IsotropicScale,
337 length_scale: Option<crate::OriginalUnits>,
338 power: f64,
339 nullspace_order: DuchonNullspaceOrder,
340 full_ident_transform: Option<Array2<f64>>,
341 ) -> Result<Self, BasisError> {
342 if latent.latent_dim() != centers.ncols() {
343 crate::bail_dim_basis!(
344 "LatentCoordDesignDerivative Duchon dimension mismatch: latent d={} centers d={}",
345 latent.latent_dim(),
346 centers.ncols()
347 );
348 }
349 let effective_order = duchon_effective_nullspace_order(centers.view(), nullspace_order);
350 let p_order = duchon_p_from_nullspace_order(effective_order);
351 let s_order = power.max(0.0).round() as usize;
352 let length_scale = length_scale.map(|ell| {
357 input_scale
358 .to_standardized_units(ell)
359 .standardized_value()
360 });
361 let radial_kind = if let Some(length_scale) = length_scale {
362 RadialScalarKind::Duchon {
363 length_scale,
364 p_order,
365 s_order,
366 dim: centers.ncols(),
367 coeffs: duchon_partial_fraction_coeffs(
368 p_order,
369 s_order,
370 1.0 / length_scale.max(1e-300),
371 ),
372 }
373 } else {
374 RadialScalarKind::PureDuchon {
375 block_order: pure_duchon_block_order(p_order, power).max(1.0) as usize,
376 p_order,
377 s_order,
378 dim: centers.ncols(),
379 }
380 };
381 let mut workspace = BasisWorkspace::default();
382 let ident_transform =
383 kernel_constraint_nullspace(centers.view(), effective_order, &mut workspace.cache)?;
384 let n_poly = polynomial_block_from_order(centers.view(), effective_order).ncols();
385 Ok(Self::from_local_design_jacobian_provider(Arc::new(
386 RadialLatentCoordLocalDesignJacobian {
387 latent,
388 centers,
389 input_scale,
390 radial_kind,
391 ident_transform: Some(ident_transform),
392 full_ident_transform,
393 n_poly,
394 polynomial_order: Some(effective_order),
395 },
396 )))
397 }
398
399 pub fn new_sphere(
400 latent: Arc<crate::latent::LatentCoordValues>,
401 centers: Arc<Array2<f64>>,
402 penalty_order: usize,
403 ident_transform: Option<Array2<f64>>,
404 ) -> Result<Self, BasisError> {
405 if latent.latent_dim() != centers.ncols() {
406 crate::bail_dim_basis!(
407 "LatentCoordDesignDerivative sphere dimension mismatch: latent d={} centers d={}",
408 latent.latent_dim(),
409 centers.ncols()
410 );
411 }
412 let raw_jet = sphere_first_derivative_nd(
413 latent.as_matrix().view(),
414 centers.view(),
415 penalty_order,
416 true,
417 )?;
418 let jet = latent.design_gradient_wrt_t_dispatch(
419 crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
420 )?;
421 Self::from_jet(latent, jet, ident_transform)
422 }
423
424 pub fn new_periodic_bspline(
425 latent: Arc<crate::latent::LatentCoordValues>,
426 data_range: (f64, f64),
427 degree: usize,
428 num_basis: usize,
429 ident_transform: Option<Array2<f64>>,
430 ) -> Result<Self, BasisError> {
431 let raw_jet = periodic_bspline_first_derivative_nd(
432 latent.as_matrix().view(),
433 data_range,
434 degree,
435 num_basis,
436 )?;
437 let jet = latent.design_gradient_wrt_t_dispatch(
438 crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
439 )?;
440 Self::from_jet(latent, jet, ident_transform)
441 }
442
443 pub fn new_tensor_bspline(
444 latent: Arc<crate::latent::LatentCoordValues>,
445 knots_per_axis: Vec<Array1<f64>>,
446 degrees: Vec<usize>,
447 ident_transform: Option<Array2<f64>>,
448 ) -> Result<Self, BasisError> {
449 let knot_views = knots_per_axis
450 .iter()
451 .map(|knots| knots.view())
452 .collect::<Vec<_>>();
453 let raw_jet =
454 bspline_tensor_first_derivative(latent.as_matrix().view(), &knot_views, °rees)?;
455 let jet = latent.design_gradient_wrt_t_dispatch(
456 crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
457 )?;
458 Self::from_jet(latent, jet, ident_transform)
459 }
460
461 pub fn new_pca(
462 latent: Arc<crate::latent::LatentCoordValues>,
463 basis_matrix: Arc<Array2<f64>>,
464 ) -> Result<Self, BasisError> {
465 if latent.latent_dim() != basis_matrix.nrows() {
466 crate::bail_dim_basis!(
467 "LatentCoordDesignDerivative Pca dimension mismatch: latent d={} basis rows={}",
468 latent.latent_dim(),
469 basis_matrix.nrows()
470 );
471 }
472 let mut jet =
473 Array3::<f64>::zeros((latent.n_obs(), basis_matrix.ncols(), basis_matrix.nrows()));
474 for row in 0..latent.n_obs() {
475 for axis in 0..basis_matrix.nrows() {
476 for col in 0..basis_matrix.ncols() {
477 jet[[row, col, axis]] = basis_matrix[[axis, col]];
478 }
479 }
480 }
481 Self::from_jet(latent, jet, None)
482 }
483
484 pub fn from_jet(
485 latent: Arc<crate::latent::LatentCoordValues>,
486 jet: Array3<f64>,
487 ident_transform: Option<Array2<f64>>,
488 ) -> Result<Self, BasisError> {
489 if jet.shape()[0] != latent.n_obs() || jet.shape()[2] != latent.latent_dim() {
490 crate::bail_dim_basis!(
491 "LatentCoordDesignDerivative jet shape {:?} does not match latent shape ({}, {}, {})",
492 jet.shape(),
493 latent.n_obs(),
494 jet.shape()[1],
495 latent.latent_dim()
496 );
497 }
498 if let Some(z) = ident_transform.as_ref()
499 && z.nrows() != jet.shape()[1]
500 {
501 crate::bail_dim_basis!(
502 "LatentCoordDesignDerivative identifiability transform has {} rows but derivative jet has {} basis columns",
503 z.nrows(),
504 jet.shape()[1]
505 );
506 }
507 Ok(Self::from_local_design_jacobian_provider(Arc::new(
508 JetLatentCoordLocalDesignJacobian {
509 latent,
510 jet: Arc::new(jet),
511 ident_transform,
512 },
513 )))
514 }
515
516 pub(crate) fn n_data(&self) -> usize {
517 self.provider.n_data()
518 }
519
520 pub(crate) fn latent_dim(&self) -> usize {
521 self.provider.latent_dim()
522 }
523
524 pub fn n_axes(&self) -> usize {
525 self.provider.n_axes()
526 }
527
528 pub fn p_out(&self) -> usize {
529 self.provider.p_out()
530 }
531}
532
533impl RadialLatentCoordLocalDesignJacobian {
534 pub(crate) fn project_and_pad(
535 &self,
536 raw_knot: &Array1<f64>,
537 raw_poly: &Array1<f64>,
538 ) -> Result<Array1<f64>, BasisError> {
539 let constrained = match &self.ident_transform {
540 Some(z) => z.t().dot(raw_knot),
541 None => raw_knot.clone(),
542 };
543 let mut padded = Array1::<f64>::zeros(constrained.len() + self.n_poly);
544 padded
545 .slice_mut(s![..constrained.len()])
546 .assign(&constrained);
547 if self.n_poly > 0 {
548 padded.slice_mut(s![constrained.len()..]).assign(raw_poly);
549 }
550 Ok(match &self.full_ident_transform {
551 Some(zf) => zf.t().dot(&padded),
552 None => padded,
553 })
554 }
555
556 pub(crate) fn kernel_axis_scalar(
557 &self,
558 row: usize,
559 center: usize,
560 axis: usize,
561 ) -> Result<f64, BasisError> {
562 let t_row = self.latent.row(row);
566 let reciprocal = self.input_scale.reciprocal();
567 let mut r2 = 0.0_f64;
568 for a in 0..self.latent.latent_dim() {
569 let delta = t_row[a] * reciprocal - self.centers[[center, a]];
570 r2 += delta * delta;
571 }
572 let r = r2.sqrt();
573 if r == 0.0 {
574 if self.radial_kind.is_smooth_at_collision() {
580 return Ok(0.0);
581 }
582 return Err(BasisError::DegenerateAtCollision {
583 kernel: "RadialScalarKind (design axis)",
584 dim: self.latent.latent_dim(),
585 m: 0.0,
586 message: "radial scalar q = φ'/r has no finite limit at r = 0; \
587 the design row axis component is undefined",
588 });
589 }
590 let (_, q, _) = self.radial_kind.eval_design_triplet(r)?;
591 Ok(q * (t_row[axis] * reciprocal - self.centers[[center, axis]]) * reciprocal)
595 }
596
597 pub(crate) fn polynomial_axis_values(&self, row: usize, axis: usize) -> Array1<f64> {
598 let Some(order) = self.polynomial_order else {
599 return Array1::<f64>::zeros(self.n_poly);
600 };
601 let max_degree = match order {
602 DuchonNullspaceOrder::Zero => 0usize,
603 DuchonNullspaceOrder::Linear => 1usize,
604 DuchonNullspaceOrder::Degree(k) => k,
605 };
606 let t_row = self.latent.row(row);
611 let reciprocal = self.input_scale.reciprocal();
612 let exponents = monomial_exponents(self.latent.latent_dim(), max_degree);
613 let mut out = Array1::<f64>::zeros(exponents.len());
614 for (col, alpha) in exponents.iter().enumerate() {
615 let a_axis = alpha[axis];
616 if a_axis == 0 {
617 continue;
618 }
619 let mut value = a_axis as f64 * reciprocal;
620 for a in 0..self.latent.latent_dim() {
621 let exp_a = if a == axis { a_axis - 1 } else { alpha[a] };
622 if exp_a != 0 {
623 value *= (t_row[a] * reciprocal).powi(exp_a as i32);
624 }
625 }
626 out[col] = value;
627 }
628 out
629 }
630}
631
632impl JetLatentCoordLocalDesignJacobian {
633 pub(crate) fn project_jet(&self, raw_knot: &Array1<f64>) -> Result<Array1<f64>, BasisError> {
634 Ok(match &self.ident_transform {
635 Some(z) => z.t().dot(raw_knot),
636 None => raw_knot.clone(),
637 })
638 }
639}
640
641impl LocalDesignJacobianProvider for LatentCoordDesignDerivative {
642 fn n_data(&self) -> usize {
643 self.provider.n_data()
644 }
645
646 fn latent_dim(&self) -> usize {
647 self.provider.latent_dim()
648 }
649
650 fn n_axes(&self) -> usize {
651 self.provider.n_axes()
652 }
653
654 fn p_out(&self) -> usize {
655 self.provider.p_out()
656 }
657
658 fn local_design_jacobian_row(
659 &self,
660 row: usize,
661 axis: usize,
662 ) -> Result<Array1<f64>, BasisError> {
663 self.provider.local_design_jacobian_row(row, axis)
664 }
665}
666
667impl LocalDesignJacobianProvider for RadialLatentCoordLocalDesignJacobian {
668 fn n_data(&self) -> usize {
669 self.latent.n_obs()
670 }
671
672 fn latent_dim(&self) -> usize {
673 self.latent.latent_dim()
674 }
675
676 fn n_axes(&self) -> usize {
677 self.latent.len()
678 }
679
680 fn p_out(&self) -> usize {
681 Self::p_out(self)
682 }
683
684 fn local_design_jacobian_row(
685 &self,
686 row: usize,
687 axis: usize,
688 ) -> Result<Array1<f64>, BasisError> {
689 let mut raw_knot = Array1::<f64>::zeros(self.centers.nrows());
690 for center in 0..self.centers.nrows() {
691 raw_knot[center] = self.kernel_axis_scalar(row, center, axis)?;
692 }
693 let raw_poly = self.polynomial_axis_values(row, axis);
694 self.project_and_pad(&raw_knot, &raw_poly)
695 }
696}
697
698impl LocalDesignJacobianProvider for JetLatentCoordLocalDesignJacobian {
699 fn n_data(&self) -> usize {
700 self.latent.n_obs()
701 }
702
703 fn latent_dim(&self) -> usize {
704 self.latent.latent_dim()
705 }
706
707 fn n_axes(&self) -> usize {
708 self.latent.len()
709 }
710
711 fn p_out(&self) -> usize {
712 Self::p_out(self)
713 }
714
715 fn local_design_jacobian_row(
716 &self,
717 row: usize,
718 axis: usize,
719 ) -> Result<Array1<f64>, BasisError> {
720 let mut raw_knot = Array1::<f64>::zeros(self.jet.shape()[1]);
721 for basis_col in 0..self.jet.shape()[1] {
722 raw_knot[basis_col] = self.jet[[row, basis_col, axis]];
723 }
724 self.project_jet(&raw_knot)
725 }
726}
727
728impl ImplicitDesignPsiDerivative {
729 pub fn new(
742 phi_values: Array1<f64>,
743 q_values: Array1<f64>,
744 t_values: Array1<f64>,
745 axis_components: Array2<f64>,
746 ident_transform: Option<Array2<f64>>,
747 full_ident_transform: Option<Array2<f64>>,
748 n: usize,
749 n_knots: usize,
750 n_poly: usize,
751 n_axes: usize,
752 ) -> Self {
753 assert_eq!(
754 phi_values.len(),
755 n * n_knots,
756 "implicit psi derivative phi length mismatch: expected n*n_knots={}*{}={}, got {}",
757 n,
758 n_knots,
759 n * n_knots,
760 phi_values.len()
761 );
762 assert_eq!(
763 q_values.len(),
764 n * n_knots,
765 "implicit psi derivative q length mismatch: expected n*n_knots={}*{}={}, got {}",
766 n,
767 n_knots,
768 n * n_knots,
769 q_values.len()
770 );
771 assert_eq!(
772 t_values.len(),
773 n * n_knots,
774 "implicit psi derivative t length mismatch: expected n*n_knots={}*{}={}, got {}",
775 n,
776 n_knots,
777 n * n_knots,
778 t_values.len()
779 );
780 assert_eq!(
781 axis_components.nrows(),
782 n * n_knots,
783 "implicit psi derivative axis-component row mismatch: expected n*n_knots={}*{}={}, got {}",
784 n,
785 n_knots,
786 n * n_knots,
787 axis_components.nrows()
788 );
789 assert_eq!(
790 axis_components.ncols(),
791 n_axes,
792 "implicit psi derivative axis-component column mismatch: expected n_axes={n_axes}, got {}",
793 axis_components.ncols()
794 );
795 Self {
796 phi_values,
797 axis_components,
798 q_values,
799 t_values,
800 streaming: None,
801 ident_transform,
802 full_ident_transform,
803 n,
804 n_knots,
805 n_poly,
806 n_axes,
807 psi_scale_share: 0.0,
808 axis_combinations: None,
809 }
810 }
811
812 pub(crate) fn with_psi_scale_share(mut self, psi_scale_share: f64) -> Self {
813 self.psi_scale_share = psi_scale_share;
814 self
815 }
816
817 pub fn new_streaming(
826 data: Arc<Array2<f64>>,
827 centers: Arc<Array2<f64>>,
828 eta: Vec<f64>,
829 radial_kind: RadialScalarKind,
830 ident_transform: Option<Array2<f64>>,
831 full_ident_transform: Option<Array2<f64>>,
832 n_poly: usize,
833 ) -> Self {
834 let n = data.nrows();
835 let n_knots = centers.nrows();
836 let n_axes = data.ncols();
837 let psi_scale_share = radial_kind.raw_psi_isotropic_share();
838 assert_eq!(eta.len(), n_axes);
839 assert_eq!(
840 centers.ncols(),
841 n_axes,
842 "streaming radial centers have {} columns but data/eta have {n_axes}",
843 centers.ncols()
844 );
845 let metric_weights: Arc<[f64]> = Arc::from(centered_aniso_metric_weights(&eta));
846 Self {
847 phi_values: Array1::<f64>::zeros(0),
849 axis_components: Array2::<f64>::zeros((0, 0)),
850 q_values: Array1::<f64>::zeros(0),
851 t_values: Array1::<f64>::zeros(0),
852 streaming: Some(StreamingRadialState {
853 data,
854 centers,
855 axis_mode: StreamingAxisMode::PerAxis { metric_weights },
856 radial_kind,
857 triplet_cache: Arc::new(std::sync::OnceLock::new()),
858 }),
859 ident_transform,
860 full_ident_transform,
861 n,
862 n_knots,
863 n_poly,
864 n_axes,
865 psi_scale_share,
866 axis_combinations: None,
867 }
868 }
869
870 pub(crate) fn new_streaming_scalar(
874 data: Arc<Array2<f64>>,
875 centers: Arc<Array2<f64>>,
876 eta: Vec<f64>,
877 radial_kind: RadialScalarKind,
878 ident_transform: Option<Array2<f64>>,
879 full_ident_transform: Option<Array2<f64>>,
880 n_poly: usize,
881 ) -> Self {
882 let n = data.nrows();
883 let n_knots = centers.nrows();
884 let dim = data.ncols();
885 assert_eq!(eta.len(), dim);
886 assert_eq!(
887 centers.ncols(),
888 dim,
889 "streaming scalar radial centers have {} columns but data/eta have {dim}",
890 centers.ncols()
891 );
892 let metric_weights: Arc<[f64]> = Arc::from(centered_aniso_metric_weights(&eta));
893 Self {
894 phi_values: Array1::<f64>::zeros(0),
895 axis_components: Array2::<f64>::zeros((0, 0)),
896 q_values: Array1::<f64>::zeros(0),
897 t_values: Array1::<f64>::zeros(0),
898 streaming: Some(StreamingRadialState {
899 data,
900 centers,
901 axis_mode: StreamingAxisMode::ScalarTotal { metric_weights },
902 radial_kind,
903 triplet_cache: Arc::new(std::sync::OnceLock::new()),
904 }),
905 ident_transform,
906 full_ident_transform,
907 n,
908 n_knots,
909 n_poly,
910 n_axes: 1,
911 psi_scale_share: 0.0,
912 axis_combinations: None,
913 }
914 }
915
916 #[inline]
918 pub(crate) fn is_streaming(&self) -> bool {
919 self.streaming.is_some()
920 }
921
922 pub fn n_data(&self) -> usize {
924 self.n
925 }
926
927 pub fn n_axes(&self) -> usize {
929 self.axis_combinations
930 .as_ref()
931 .map_or(self.n_axes, Vec::len)
932 }
933
934 pub fn is_duchon_family(&self) -> bool {
935 self.streaming.as_ref().is_some_and(|state| {
936 matches!(
937 state.radial_kind,
938 RadialScalarKind::Duchon { .. } | RadialScalarKind::PureDuchon { .. }
939 )
940 }) || self.psi_scale_share != 0.0
941 }
942
943 pub(crate) fn enforces_dense_materialization_budget(&self) -> bool {
957 if self
958 .streaming
959 .as_ref()
960 .is_some_and(|state| state.radial_kind.enforces_dense_materialization_budget())
961 {
962 return true;
963 }
964 self.psi_scale_share != 0.0
970 }
971
972 pub fn p_out(&self) -> usize {
974 if let Some(ref zf) = self.full_ident_transform {
975 zf.ncols()
976 } else {
977 self.p_after_pad()
978 }
979 }
980
981 pub fn append_full_transform(mut self, transform: &Array2<f64>) -> Result<Self, BasisError> {
982 if transform.nrows() != self.p_out() {
983 crate::bail_dim_basis!(
984 "implicit psi derivative transform has {} rows but operator has {} output columns",
985 transform.nrows(),
986 self.p_out()
987 );
988 }
989 self.full_ident_transform = Some(match self.full_ident_transform.take() {
990 Some(existing) => fast_ab(&existing, transform),
991 None => transform.clone(),
992 });
993 Ok(self)
994 }
995
996 pub(crate) fn p_after_pad(&self) -> usize {
998 let p_constrained = self.p_constrained();
999 p_constrained + self.n_poly
1000 }
1001
1002 pub(crate) fn p_constrained(&self) -> usize {
1004 match &self.ident_transform {
1005 Some(z) => z.ncols(),
1006 None => self.n_knots,
1007 }
1008 }
1009
1010 pub(crate) fn accumulate_knot_vector<F>(&self, v: &ArrayView1<f64>, per_pair: F) -> Array1<f64>
1016 where
1017 F: Fn(usize) -> f64 + Send + Sync,
1018 {
1019 let n = self.n;
1020 let k = self.n_knots;
1021
1022 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1023 let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1025 let partial_sums: Vec<Array1<f64>> = (0..n_chunks)
1026 .into_par_iter()
1027 .map(|chunk_idx| {
1028 let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1029 let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1030 let mut local = Array1::<f64>::zeros(k);
1031 for i in start..end {
1032 let vi = v[i];
1033 if vi == 0.0 {
1034 continue;
1035 }
1036 let base = i * k;
1037 for j in 0..k {
1038 local[j] += vi * per_pair(base + j);
1039 }
1040 }
1041 local
1042 })
1043 .collect();
1044 let mut total = Array1::<f64>::zeros(k);
1045 for p in partial_sums {
1046 total += &p;
1047 }
1048 total
1049 } else {
1050 let mut total = Array1::<f64>::zeros(k);
1052 for i in 0..n {
1053 let vi = v[i];
1054 if vi == 0.0 {
1055 continue;
1056 }
1057 let base = i * k;
1058 for j in 0..k {
1059 total[j] += vi * per_pair(base + j);
1060 }
1061 }
1062 total
1063 }
1064 }
1065
1066 pub(crate) fn streaming_accumulate_knot_vector<G>(
1068 &self,
1069 v: &ArrayView1<f64>,
1070 deriv_fn: G,
1071 ) -> Result<Array1<f64>, BasisError>
1072 where
1073 G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1074 {
1075 let Some(st) = self.streaming.as_ref() else {
1076 return Err(BasisError::InvalidInput(
1077 "streaming_accumulate_knot_vector needs the streaming radial state, but this implicit \
1078 ψ-derivative operator was built without one"
1079 .to_string(),
1080 ));
1081 };
1082 let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1083 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1084 let err_flag = std::sync::atomic::AtomicBool::new(false);
1085 let nc = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1086 let ps: Vec<Array1<f64>> = (0..nc)
1087 .into_par_iter()
1088 .map(|ci| {
1089 let s = ci * IMPLICIT_MATVEC_CHUNK_SIZE;
1090 let e = (s + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1091 let mut loc = Array1::<f64>::zeros(k);
1092 let mut sb = vec![0.0; dim];
1093 for i in s..e {
1094 let vi = v[i];
1095 if vi == 0.0 {
1096 continue;
1097 }
1098 for j in 0..k {
1099 match st.compute_pair(i, j, &mut sb) {
1100 Ok((phi, q, t)) => {
1101 loc[j] += vi * deriv_fn(phi, q, t, &sb);
1102 }
1103 Err(_) => {
1104 err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
1105 return loc;
1106 }
1107 }
1108 }
1109 }
1110 loc
1111 })
1112 .collect();
1113 if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1114 crate::bail_invalid_basis!(
1115 "radial scalar evaluation failed during streaming accumulate_knot_vector"
1116 .into(),
1117 );
1118 }
1119 let mut tot = Array1::<f64>::zeros(k);
1120 for p in ps {
1121 tot += &p;
1122 }
1123 Ok(tot)
1124 } else {
1125 let mut tot = Array1::<f64>::zeros(k);
1126 let mut sb = vec![0.0; dim];
1127 for i in 0..n {
1128 let vi = v[i];
1129 if vi == 0.0 {
1130 continue;
1131 }
1132 for j in 0..k {
1133 let (phi, q, t) = st.compute_pair(i,j,&mut sb).map_err(|e| BasisError::InvalidInput(
1134 format!("radial scalar evaluation failed during streaming accumulate_knot_vector: {e}"),
1135 ))?;
1136 tot[j] += vi * deriv_fn(phi, q, t, &sb);
1137 }
1138 }
1139 Ok(tot)
1140 }
1141 }
1142 pub(crate) fn streaming_forward_mul<G>(
1144 &self,
1145 u_knot: &Array1<f64>,
1146 deriv_fn: G,
1147 ) -> Result<Array1<f64>, BasisError>
1148 where
1149 G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1150 {
1151 let Some(st) = self.streaming.as_ref() else {
1152 return Err(BasisError::InvalidInput(
1153 "streaming_forward_mul needs the streaming radial state, but this implicit \
1154 ψ-derivative operator was built without one"
1155 .to_string(),
1156 ));
1157 };
1158 let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1159 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1160 let err_flag = std::sync::atomic::AtomicBool::new(false);
1161 let nc = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1162 let cr: Vec<(usize, Vec<f64>)> = (0..nc)
1163 .into_par_iter()
1164 .map(|ci| {
1165 let s = ci * IMPLICIT_MATVEC_CHUNK_SIZE;
1166 let e = (s + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1167 let mut loc = vec![0.0; e - s];
1168 let mut sb = vec![0.0; dim];
1169 for i in s..e {
1170 let mut val = 0.0;
1171 for j in 0..k {
1172 match st.compute_pair(i, j, &mut sb) {
1173 Ok((phi, q, t)) => {
1174 val += deriv_fn(phi, q, t, &sb) * u_knot[j];
1175 }
1176 Err(_) => {
1177 err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
1178 break;
1179 }
1180 }
1181 }
1182 loc[i - s] = val;
1183 }
1184 (s, loc)
1185 })
1186 .collect();
1187 if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1188 crate::bail_invalid_basis!(
1189 "radial scalar evaluation failed during streaming forward_mul".into(),
1190 );
1191 }
1192 let mut res = Array1::<f64>::zeros(n);
1193 for (s, vs) in cr {
1194 for (o, &v) in vs.iter().enumerate() {
1195 res[s + o] = v;
1196 }
1197 }
1198 Ok(res)
1199 } else {
1200 let mut res = Array1::<f64>::zeros(n);
1201 let mut sb = vec![0.0; dim];
1202 for i in 0..n {
1203 let mut val = 0.0;
1204 for j in 0..k {
1205 let (phi, q, t) = st.compute_pair(i, j, &mut sb).map_err(|e| {
1206 BasisError::InvalidInput(format!(
1207 "radial scalar evaluation failed during streaming forward_mul: {e}"
1208 ))
1209 })?;
1210 val += deriv_fn(phi, q, t, &sb) * u_knot[j];
1211 }
1212 res[i] = val;
1213 }
1214 Ok(res)
1215 }
1216 }
1217 pub(crate) fn streaming_materialize<G>(&self, deriv_fn: G) -> Result<Array2<f64>, BasisError>
1219 where
1220 G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1221 {
1222 let Some(st) = self.streaming.as_ref() else {
1223 return Err(BasisError::InvalidInput(
1224 "streaming_materialize needs the streaming radial state, but this implicit \
1225 ψ-derivative operator was built without one"
1226 .to_string(),
1227 ));
1228 };
1229 let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1230 let mut raw = Array2::<f64>::zeros((n, k));
1231 let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
1232 let nc = n.div_ceil(cs);
1233 let err_flag = std::sync::atomic::AtomicBool::new(false);
1234 {
1235 let rp = SendPtr(raw.as_mut_ptr());
1236 let ef = &err_flag;
1237 (0..nc).into_par_iter().for_each(move |ci| {
1238 let s = ci * cs;
1239 let e = (s + cs).min(n);
1240 let mut sb = vec![0.0; dim];
1241 for i in s..e {
1242 for j in 0..k {
1243 match st.compute_pair(i, j, &mut sb) {
1244 Ok((phi, q, t)) => unsafe {
1248 *rp.add(i * k + j) = deriv_fn(phi, q, t, &sb);
1249 },
1250 Err(_) => {
1251 ef.store(true, std::sync::atomic::Ordering::Relaxed);
1252 return;
1253 }
1254 }
1255 }
1256 }
1257 });
1258 }
1259 if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1260 crate::bail_invalid_basis!(
1261 "radial scalar evaluation failed during streaming materialize".into(),
1262 );
1263 }
1264 Ok(self.project_matrix(raw))
1265 }
1266
1267 pub(crate) fn project_and_pad(&self, raw_knot_vec: &Array1<f64>) -> Array1<f64> {
1270 let constrained = match &self.ident_transform {
1272 Some(z) => z.t().dot(raw_knot_vec),
1273 None => raw_knot_vec.clone(),
1274 };
1275
1276 let p_padded = constrained.len() + self.n_poly;
1278 let mut padded = Array1::<f64>::zeros(p_padded);
1279 padded
1280 .slice_mut(s![..constrained.len()])
1281 .assign(&constrained);
1282
1283 match &self.full_ident_transform {
1285 Some(zf) => zf.t().dot(&padded),
1286 None => padded,
1287 }
1288 }
1289
1290 pub(crate) fn unproject(&self, u: &ArrayView1<f64>) -> Array1<f64> {
1293 let after_full = match &self.full_ident_transform {
1295 Some(zf) => zf.dot(u),
1296 None => u.to_owned(),
1297 };
1298
1299 let p_constrained = self.p_constrained();
1301 let smooth_part = after_full.slice(s![..p_constrained]);
1302
1303 match &self.ident_transform {
1305 Some(z) => z.dot(&smooth_part),
1306 None => smooth_part.to_owned(),
1307 }
1308 }
1309
1310 pub fn unproject_matrix(&self, u: &ArrayView2<f64>) -> Array2<f64> {
1316 assert_eq!(u.nrows(), self.p_out());
1317 let after_full = match &self.full_ident_transform {
1319 Some(zf) => fast_ab(zf, u),
1320 None => u.to_owned(),
1321 };
1322 let p_constrained = self.p_constrained();
1324 let smooth_part = after_full.slice(s![..p_constrained, ..]);
1325 match &self.ident_transform {
1327 Some(z) => fast_ab(z, &smooth_part),
1328 None => smooth_part.to_owned(),
1329 }
1330 }
1331
1332 pub fn transpose_mul(
1346 &self,
1347 axis: usize,
1348 v: &ArrayView1<f64>,
1349 ) -> Result<Array1<f64>, BasisError> {
1350 assert!(
1351 axis < self.n_axes(),
1352 "implicit psi first transpose axis out of bounds: axis={axis}, n_axes={}",
1353 self.n_axes()
1354 );
1355 assert_eq!(
1356 v.len(),
1357 self.n,
1358 "implicit psi first transpose row-adjoint length mismatch"
1359 );
1360 if self.axis_combinations.is_some() {
1361 let combo = self.transformed_axis_combination(axis);
1362 let combo_sum = Self::transformed_combo_sum(combo);
1363 if self.is_streaming() {
1364 let c = self.psi_scale_share;
1365 let raw = self.streaming_accumulate_knot_vector(v, |phi, q, _, sb| {
1366 let s_combo = combo
1367 .iter()
1368 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1369 .sum();
1370 Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
1371 })?;
1372 return Ok(self.project_and_pad(&raw));
1373 }
1374 let c = self.psi_scale_share;
1375 let raw = self.accumulate_knot_vector(v, |idx| {
1376 let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1377 Self::transformed_first_kernel_value(
1378 self.phi_values[idx],
1379 self.q_values[idx],
1380 s_combo,
1381 combo_sum,
1382 c,
1383 )
1384 });
1385 return Ok(self.project_and_pad(&raw));
1386 }
1387 if self.is_streaming() {
1388 let c = self.psi_scale_share;
1389 let raw =
1390 self.streaming_accumulate_knot_vector(v, |phi, q, _, sb| q * sb[axis] + c * phi)?;
1391 return Ok(self.project_and_pad(&raw));
1392 }
1393 let c = self.psi_scale_share;
1394 let af = &self.axis_components;
1395 let pv = &self.phi_values;
1396 let qv = &self.q_values;
1397 let raw = self.accumulate_knot_vector(v, |idx| qv[idx] * af[[idx, axis]] + c * pv[idx]);
1398 Ok(self.project_and_pad(&raw))
1399 }
1400
1401 pub fn forward_mul(&self, axis: usize, u: &ArrayView1<f64>) -> Result<Array1<f64>, BasisError> {
1409 assert!(
1410 axis < self.n_axes(),
1411 "implicit psi first forward axis out of bounds: axis={axis}, n_axes={}",
1412 self.n_axes()
1413 );
1414 assert_eq!(
1415 u.len(),
1416 self.p_out(),
1417 "implicit psi first forward coefficient length mismatch"
1418 );
1419 let u_knot = self.unproject(u);
1420 if self.axis_combinations.is_some() {
1421 let combo = self.transformed_axis_combination(axis);
1422 let combo_sum = Self::transformed_combo_sum(combo);
1423 if self.is_streaming() {
1424 let c = self.psi_scale_share;
1425 return self.streaming_forward_mul(&u_knot, |phi, q, _, sb| {
1426 let s_combo = combo
1427 .iter()
1428 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1429 .sum();
1430 Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
1431 });
1432 }
1433 let n = self.n;
1434 let k = self.n_knots;
1435 let c = self.psi_scale_share;
1436 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1437 let mut result = Array1::<f64>::zeros(n);
1438 let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1439 let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1440 .into_par_iter()
1441 .map(|chunk_idx| {
1442 let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1443 let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1444 let mut local = vec![0.0; end - start];
1445 for i in start..end {
1446 let base = i * k;
1447 let mut val = 0.0;
1448 for j in 0..k {
1449 let idx = base + j;
1450 let s_combo =
1451 self.transformed_combo_axis_value_materialized(idx, combo);
1452 val += Self::transformed_first_kernel_value(
1453 self.phi_values[idx],
1454 self.q_values[idx],
1455 s_combo,
1456 combo_sum,
1457 c,
1458 ) * u_knot[j];
1459 }
1460 local[i - start] = val;
1461 }
1462 (start, local)
1463 })
1464 .collect();
1465 for (start, vals) in chunk_results {
1466 for (offset, &v) in vals.iter().enumerate() {
1467 result[start + offset] = v;
1468 }
1469 }
1470 return Ok(result);
1471 }
1472 let mut result = Array1::<f64>::zeros(n);
1473 for i in 0..n {
1474 let base = i * k;
1475 let mut val = 0.0;
1476 for j in 0..k {
1477 let idx = base + j;
1478 let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1479 val += Self::transformed_first_kernel_value(
1480 self.phi_values[idx],
1481 self.q_values[idx],
1482 s_combo,
1483 combo_sum,
1484 c,
1485 ) * u_knot[j];
1486 }
1487 result[i] = val;
1488 }
1489 return Ok(result);
1490 }
1491 if self.is_streaming() {
1492 let c = self.psi_scale_share;
1493 return self.streaming_forward_mul(&u_knot, |phi, q, _, sb| q * sb[axis] + c * phi);
1494 }
1495 let n = self.n;
1496 let k = self.n_knots;
1497 let c = self.psi_scale_share;
1498 let af = &self.axis_components;
1499 let pv = &self.phi_values;
1500 let qv = &self.q_values;
1501
1502 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1503 let mut result = Array1::<f64>::zeros(n);
1504 let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1506 let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1507 .into_par_iter()
1508 .map(|chunk_idx| {
1509 let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1510 let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1511 let mut local = vec![0.0; end - start];
1512 for i in start..end {
1513 let base = i * k;
1514 let mut val = 0.0;
1515 for j in 0..k {
1516 val += (qv[base + j] * af[[base + j, axis]] + c * pv[base + j])
1517 * u_knot[j];
1518 }
1519 local[i - start] = val;
1520 }
1521 (start, local)
1522 })
1523 .collect();
1524 for (start, vals) in chunk_results {
1525 for (offset, &v) in vals.iter().enumerate() {
1526 result[start + offset] = v;
1527 }
1528 }
1529 Ok(result)
1530 } else {
1531 let mut result = Array1::<f64>::zeros(n);
1532 for i in 0..n {
1533 let base = i * k;
1534 let mut val = 0.0;
1535 for j in 0..k {
1536 val += (qv[base + j] * af[[base + j, axis]] + c * pv[base + j]) * u_knot[j];
1537 }
1538 result[i] = val;
1539 }
1540 Ok(result)
1541 }
1542 }
1543
1544 pub fn transpose_mul_second_diag(
1549 &self,
1550 axis: usize,
1551 v: &ArrayView1<f64>,
1552 ) -> Result<Array1<f64>, BasisError> {
1553 assert!(
1554 axis < self.n_axes(),
1555 "implicit psi second diagonal transpose axis out of bounds: axis={axis}, n_axes={}",
1556 self.n_axes()
1557 );
1558 assert_eq!(
1559 v.len(),
1560 self.n,
1561 "implicit psi second diagonal transpose row-adjoint length mismatch"
1562 );
1563 if self.axis_combinations.is_some() {
1564 let combo = self.transformed_axis_combination(axis);
1565 let combo_sum = Self::transformed_combo_sum(combo);
1566 if self.is_streaming() {
1567 let c = self.psi_scale_share;
1568 let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
1569 let s_combo = combo
1570 .iter()
1571 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1572 .sum();
1573 let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
1574 Self::transformed_second_kernel_value(
1575 phi, q, t, s_combo, combo_sum, s_combo, combo_sum, overlap_s, c,
1576 )
1577 })?;
1578 return Ok(self.project_and_pad(&raw));
1579 }
1580 let c = self.psi_scale_share;
1581 let raw = self.accumulate_knot_vector(v, |idx| {
1582 let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1583 let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
1584 Self::transformed_second_kernel_value(
1585 self.phi_values[idx],
1586 self.q_values[idx],
1587 self.t_values[idx],
1588 s_combo,
1589 combo_sum,
1590 s_combo,
1591 combo_sum,
1592 overlap_s,
1593 c,
1594 )
1595 });
1596 return Ok(self.project_and_pad(&raw));
1597 }
1598 if self.is_streaming() {
1599 let c = self.psi_scale_share;
1600 let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
1601 let s = sb[axis];
1602 2.0 * q * s + t * s * s + 2.0 * c * q * s + c * c * phi
1603 })?;
1604 return Ok(self.project_and_pad(&raw));
1605 }
1606 let c = self.psi_scale_share;
1607 let af = &self.axis_components;
1608 let pv = &self.phi_values;
1609 let qv = &self.q_values;
1610 let tv = &self.t_values;
1611 let raw = self.accumulate_knot_vector(v, |idx| {
1612 let s = af[[idx, axis]];
1613 2.0 * qv[idx] * s + tv[idx] * s * s + 2.0 * c * qv[idx] * s + c * c * pv[idx]
1614 });
1615 Ok(self.project_and_pad(&raw))
1616 }
1617
1618 pub fn transpose_mul_second_cross(
1620 &self,
1621 axis_d: usize,
1622 axis_e: usize,
1623 v: &ArrayView1<f64>,
1624 ) -> Result<Array1<f64>, BasisError> {
1625 assert!(
1626 axis_d < self.n_axes(),
1627 "implicit psi second cross transpose first axis out of bounds: axis_d={axis_d}, n_axes={}",
1628 self.n_axes()
1629 );
1630 assert!(
1631 axis_e < self.n_axes(),
1632 "implicit psi second cross transpose second axis out of bounds: axis_e={axis_e}, n_axes={}",
1633 self.n_axes()
1634 );
1635 assert_ne!(
1636 axis_d, axis_e,
1637 "implicit psi second cross transpose requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
1638 );
1639 assert_eq!(
1640 v.len(),
1641 self.n,
1642 "implicit psi second cross transpose row-adjoint length mismatch"
1643 );
1644 if self.axis_combinations.is_some() {
1645 let combo_d = self.transformed_axis_combination(axis_d);
1646 let combo_e = self.transformed_axis_combination(axis_e);
1647 let sum_d = Self::transformed_combo_sum(combo_d);
1648 let sum_e = Self::transformed_combo_sum(combo_e);
1649 if self.is_streaming() {
1650 let c = self.psi_scale_share;
1651 let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
1652 let s_d = combo_d
1653 .iter()
1654 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1655 .sum();
1656 let s_e = combo_e
1657 .iter()
1658 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1659 .sum();
1660 let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
1661 Self::transformed_second_kernel_value(
1662 phi, q, t, s_d, sum_d, s_e, sum_e, overlap_s, c,
1663 )
1664 })?;
1665 return Ok(self.project_and_pad(&raw));
1666 }
1667 let c = self.psi_scale_share;
1668 let raw = self.accumulate_knot_vector(v, |idx| {
1669 let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
1670 let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
1671 let overlap_s = self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
1672 Self::transformed_second_kernel_value(
1673 self.phi_values[idx],
1674 self.q_values[idx],
1675 self.t_values[idx],
1676 s_d,
1677 sum_d,
1678 s_e,
1679 sum_e,
1680 overlap_s,
1681 c,
1682 )
1683 });
1684 return Ok(self.project_and_pad(&raw));
1685 }
1686 if self.is_streaming() {
1687 let c = self.psi_scale_share;
1688 let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
1689 t * sb[axis_d] * sb[axis_e] + c * q * (sb[axis_d] + sb[axis_e]) + c * c * phi
1690 })?;
1691 return Ok(self.project_and_pad(&raw));
1692 }
1693 let c = self.psi_scale_share;
1694 let af = &self.axis_components;
1695 let pv = &self.phi_values;
1696 let qv = &self.q_values;
1697 let tv = &self.t_values;
1698 let raw = self.accumulate_knot_vector(v, |idx| {
1699 tv[idx] * af[[idx, axis_d]] * af[[idx, axis_e]]
1700 + c * qv[idx] * (af[[idx, axis_d]] + af[[idx, axis_e]])
1701 + c * c * pv[idx]
1702 });
1703 Ok(self.project_and_pad(&raw))
1704 }
1705
1706 pub fn forward_mul_second_diag(
1708 &self,
1709 axis: usize,
1710 u: &ArrayView1<f64>,
1711 ) -> Result<Array1<f64>, BasisError> {
1712 assert!(
1713 axis < self.n_axes(),
1714 "implicit psi second diagonal forward axis out of bounds: axis={axis}, n_axes={}",
1715 self.n_axes()
1716 );
1717 assert_eq!(
1718 u.len(),
1719 self.p_out(),
1720 "implicit psi second diagonal forward coefficient length mismatch"
1721 );
1722 let u_knot = self.unproject(u);
1723 if self.axis_combinations.is_some() {
1724 let combo = self.transformed_axis_combination(axis);
1725 let combo_sum = Self::transformed_combo_sum(combo);
1726 if self.is_streaming() {
1727 let c = self.psi_scale_share;
1728 return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
1729 let s_combo = combo
1730 .iter()
1731 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1732 .sum();
1733 let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
1734 Self::transformed_second_kernel_value(
1735 phi, q, t, s_combo, combo_sum, s_combo, combo_sum, overlap_s, c,
1736 )
1737 });
1738 }
1739 let n = self.n;
1740 let k = self.n_knots;
1741 let c = self.psi_scale_share;
1742 let compute_row = |i: usize| -> f64 {
1743 let base = i * k;
1744 let mut val = 0.0;
1745 for j in 0..k {
1746 let idx = base + j;
1747 let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1748 let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
1749 val += Self::transformed_second_kernel_value(
1750 self.phi_values[idx],
1751 self.q_values[idx],
1752 self.t_values[idx],
1753 s_combo,
1754 combo_sum,
1755 s_combo,
1756 combo_sum,
1757 overlap_s,
1758 c,
1759 ) * u_knot[j];
1760 }
1761 val
1762 };
1763 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1764 let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1765 let mut result = Array1::<f64>::zeros(n);
1766 let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1767 .into_par_iter()
1768 .map(|chunk_idx| {
1769 let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1770 let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1771 let local: Vec<f64> = (start..end).map(compute_row).collect();
1772 (start, local)
1773 })
1774 .collect();
1775 for (start, vals) in chunk_results {
1776 for (offset, &value) in vals.iter().enumerate() {
1777 result[start + offset] = value;
1778 }
1779 }
1780 return Ok(result);
1781 }
1782 return Ok(Array1::from_vec((0..n).map(compute_row).collect()));
1783 }
1784 if self.is_streaming() {
1785 let c = self.psi_scale_share;
1786 return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
1787 let s = sb[axis];
1788 2.0 * q * s + t * s * s + 2.0 * c * q * s + c * c * phi
1789 });
1790 }
1791 let n = self.n;
1792 let k = self.n_knots;
1793 let c = self.psi_scale_share;
1794 let af = &self.axis_components;
1795 let pv = &self.phi_values;
1796 let qv = &self.q_values;
1797 let tv = &self.t_values;
1798 let compute_row = |i: usize| -> f64 {
1799 let base = i * k;
1800 let mut val = 0.0;
1801 for j in 0..k {
1802 let s = af[[base + j, axis]];
1803 val += (2.0 * qv[base + j] * s
1804 + tv[base + j] * s * s
1805 + 2.0 * c * qv[base + j] * s
1806 + c * c * pv[base + j])
1807 * u_knot[j];
1808 }
1809 val
1810 };
1811
1812 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1813 let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1814 let mut result = Array1::<f64>::zeros(n);
1815 let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1816 .into_par_iter()
1817 .map(|chunk_idx| {
1818 let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1819 let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1820 let local: Vec<f64> = (start..end).map(compute_row).collect();
1821 (start, local)
1822 })
1823 .collect();
1824 for (start, vals) in chunk_results {
1825 for (offset, &value) in vals.iter().enumerate() {
1826 result[start + offset] = value;
1827 }
1828 }
1829 Ok(result)
1830 } else {
1831 Ok(Array1::from_vec((0..n).map(compute_row).collect()))
1832 }
1833 }
1834
1835 pub fn forward_mul_second_cross(
1837 &self,
1838 axis_d: usize,
1839 axis_e: usize,
1840 u: &ArrayView1<f64>,
1841 ) -> Result<Array1<f64>, BasisError> {
1842 assert!(
1843 axis_d < self.n_axes(),
1844 "implicit psi second cross forward first axis out of bounds: axis_d={axis_d}, n_axes={}",
1845 self.n_axes()
1846 );
1847 assert!(
1848 axis_e < self.n_axes(),
1849 "implicit psi second cross forward second axis out of bounds: axis_e={axis_e}, n_axes={}",
1850 self.n_axes()
1851 );
1852 assert_ne!(
1853 axis_d, axis_e,
1854 "implicit psi second cross forward requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
1855 );
1856 assert_eq!(
1857 u.len(),
1858 self.p_out(),
1859 "implicit psi second cross forward coefficient length mismatch"
1860 );
1861 let u_knot = self.unproject(u);
1862 if self.axis_combinations.is_some() {
1863 let combo_d = self.transformed_axis_combination(axis_d);
1864 let combo_e = self.transformed_axis_combination(axis_e);
1865 let sum_d = Self::transformed_combo_sum(combo_d);
1866 let sum_e = Self::transformed_combo_sum(combo_e);
1867 if self.is_streaming() {
1868 let c = self.psi_scale_share;
1869 return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
1870 let s_d = combo_d
1871 .iter()
1872 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1873 .sum();
1874 let s_e = combo_e
1875 .iter()
1876 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1877 .sum();
1878 let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
1879 Self::transformed_second_kernel_value(
1880 phi, q, t, s_d, sum_d, s_e, sum_e, overlap_s, c,
1881 )
1882 });
1883 }
1884 let n = self.n;
1885 let k = self.n_knots;
1886 let c = self.psi_scale_share;
1887 let compute_row = |i: usize| -> f64 {
1888 let base = i * k;
1889 let mut val = 0.0;
1890 for j in 0..k {
1891 let idx = base + j;
1892 let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
1893 let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
1894 let overlap_s =
1895 self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
1896 val += Self::transformed_second_kernel_value(
1897 self.phi_values[idx],
1898 self.q_values[idx],
1899 self.t_values[idx],
1900 s_d,
1901 sum_d,
1902 s_e,
1903 sum_e,
1904 overlap_s,
1905 c,
1906 ) * u_knot[j];
1907 }
1908 val
1909 };
1910 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1911 let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1912 let mut result = Array1::<f64>::zeros(n);
1913 let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1914 .into_par_iter()
1915 .map(|chunk_idx| {
1916 let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1917 let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1918 let local: Vec<f64> = (start..end).map(compute_row).collect();
1919 (start, local)
1920 })
1921 .collect();
1922 for (start, vals) in chunk_results {
1923 for (offset, &value) in vals.iter().enumerate() {
1924 result[start + offset] = value;
1925 }
1926 }
1927 return Ok(result);
1928 }
1929 return Ok(Array1::from_vec((0..n).map(compute_row).collect()));
1930 }
1931 if self.is_streaming() {
1932 let c = self.psi_scale_share;
1933 return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
1934 t * sb[axis_d] * sb[axis_e] + c * q * (sb[axis_d] + sb[axis_e]) + c * c * phi
1935 });
1936 }
1937 let n = self.n;
1938 let k = self.n_knots;
1939 let c = self.psi_scale_share;
1940 let af = &self.axis_components;
1941 let pv = &self.phi_values;
1942 let qv = &self.q_values;
1943 let tv = &self.t_values;
1944 let compute_row = |i: usize| -> f64 {
1945 let base = i * k;
1946 let mut val = 0.0;
1947 for j in 0..k {
1948 val += (tv[base + j] * af[[base + j, axis_d]] * af[[base + j, axis_e]]
1949 + c * qv[base + j] * (af[[base + j, axis_d]] + af[[base + j, axis_e]])
1950 + c * c * pv[base + j])
1951 * u_knot[j];
1952 }
1953 val
1954 };
1955
1956 if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1957 let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1958 let mut result = Array1::<f64>::zeros(n);
1959 let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1960 .into_par_iter()
1961 .map(|chunk_idx| {
1962 let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1963 let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1964 let local: Vec<f64> = (start..end).map(compute_row).collect();
1965 (start, local)
1966 })
1967 .collect();
1968 for (start, vals) in chunk_results {
1969 for (offset, &value) in vals.iter().enumerate() {
1970 result[start + offset] = value;
1971 }
1972 }
1973 Ok(result)
1974 } else {
1975 Ok(Array1::from_vec((0..n).map(compute_row).collect()))
1976 }
1977 }
1978
1979 pub fn materialize_first(&self, axis: usize) -> Result<Array2<f64>, BasisError> {
1986 assert!(
1987 axis < self.n_axes(),
1988 "implicit psi first materialization axis out of bounds: axis={axis}, n_axes={}",
1989 self.n_axes()
1990 );
1991 if self.enforces_dense_materialization_budget() {
1992 assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
1993 }
1994 if self.axis_combinations.is_some() {
1995 let combo = self.transformed_axis_combination(axis);
1996 let combo_sum = Self::transformed_combo_sum(combo);
1997 if self.is_streaming() {
1998 let c = self.psi_scale_share;
1999 return self.streaming_materialize(|phi, q, _, sb| {
2000 let s_combo = combo
2001 .iter()
2002 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2003 .sum();
2004 Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
2005 });
2006 }
2007 let n = self.n;
2008 let k = self.n_knots;
2009 let c = self.psi_scale_share;
2010 let mut raw = Array2::<f64>::zeros((n, k));
2011 for i in 0..n {
2012 let base = i * k;
2013 for j in 0..k {
2014 let idx = base + j;
2015 let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2016 raw[[i, j]] = Self::transformed_first_kernel_value(
2017 self.phi_values[idx],
2018 self.q_values[idx],
2019 s_combo,
2020 combo_sum,
2021 c,
2022 );
2023 }
2024 }
2025 return Ok(self.project_matrix(raw));
2026 }
2027 if self.is_streaming() {
2028 let c = self.psi_scale_share;
2029 return self.streaming_materialize(|phi, q, _, sb| q * sb[axis] + c * phi);
2030 }
2031 let n = self.n;
2032 let k = self.n_knots;
2033 let c = self.psi_scale_share;
2034 let mut raw = Array2::<f64>::zeros((n, k));
2035 for i in 0..n {
2036 let base = i * k;
2037 for j in 0..k {
2038 raw[[i, j]] = self.q_values[base + j] * self.axis_components[[base + j, axis]]
2039 + c * self.phi_values[base + j];
2040 }
2041 }
2042 Ok(self.project_matrix(raw))
2043 }
2044
2045 pub fn materialize_second_diag(&self, axis: usize) -> Result<Array2<f64>, BasisError> {
2047 assert!(
2048 axis < self.n_axes(),
2049 "implicit psi second diagonal materialization axis out of bounds: axis={axis}, n_axes={}",
2050 self.n_axes()
2051 );
2052 if self.enforces_dense_materialization_budget() {
2053 assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2054 }
2055 if self.axis_combinations.is_some() {
2056 let combo = self.transformed_axis_combination(axis);
2057 let combo_sum = Self::transformed_combo_sum(combo);
2058 if self.is_streaming() {
2059 let c = self.psi_scale_share;
2060 return self.streaming_materialize(|phi, q, t, sb| {
2061 let s_combo = combo
2062 .iter()
2063 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2064 .sum();
2065 let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
2066 Self::transformed_second_kernel_value(
2067 phi, q, t, s_combo, combo_sum, s_combo, combo_sum, overlap_s, c,
2068 )
2069 });
2070 }
2071 let n = self.n;
2072 let k = self.n_knots;
2073 let c = self.psi_scale_share;
2074 let mut raw = Array2::<f64>::zeros((n, k));
2075 for i in 0..n {
2076 let base = i * k;
2077 for j in 0..k {
2078 let idx = base + j;
2079 let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2080 let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
2081 raw[[i, j]] = Self::transformed_second_kernel_value(
2082 self.phi_values[idx],
2083 self.q_values[idx],
2084 self.t_values[idx],
2085 s_combo,
2086 combo_sum,
2087 s_combo,
2088 combo_sum,
2089 overlap_s,
2090 c,
2091 );
2092 }
2093 }
2094 return Ok(self.project_matrix(raw));
2095 }
2096 if self.is_streaming() {
2097 let c = self.psi_scale_share;
2098 return self.streaming_materialize(|phi, q, t, sb| {
2099 let s = sb[axis];
2100 2.0 * q * s + t * s * s + 2.0 * c * q * s + c * c * phi
2101 });
2102 }
2103 let n = self.n;
2104 let k = self.n_knots;
2105 let c = self.psi_scale_share;
2106 let mut raw = Array2::<f64>::zeros((n, k));
2107 for i in 0..n {
2108 let base = i * k;
2109 for j in 0..k {
2110 let s = self.axis_components[[base + j, axis]];
2111 raw[[i, j]] = 2.0 * self.q_values[base + j] * s
2112 + self.t_values[base + j] * s * s
2113 + 2.0 * c * self.q_values[base + j] * s
2114 + c * c * self.phi_values[base + j];
2115 }
2116 }
2117 Ok(self.project_matrix(raw))
2118 }
2119
2120 pub fn materialize_second_cross(
2124 &self,
2125 axis_d: usize,
2126 axis_e: usize,
2127 ) -> Result<Array2<f64>, BasisError> {
2128 assert!(
2129 axis_d < self.n_axes(),
2130 "implicit psi second cross materialization first axis out of bounds: axis_d={axis_d}, n_axes={}",
2131 self.n_axes()
2132 );
2133 assert!(
2134 axis_e < self.n_axes(),
2135 "implicit psi second cross materialization second axis out of bounds: axis_e={axis_e}, n_axes={}",
2136 self.n_axes()
2137 );
2138 assert_ne!(
2139 axis_d, axis_e,
2140 "implicit psi second cross materialization requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
2141 );
2142 if self.enforces_dense_materialization_budget() {
2143 assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2144 }
2145 if self.axis_combinations.is_some() {
2146 let combo_d = self.transformed_axis_combination(axis_d);
2147 let combo_e = self.transformed_axis_combination(axis_e);
2148 let sum_d = Self::transformed_combo_sum(combo_d);
2149 let sum_e = Self::transformed_combo_sum(combo_e);
2150 if self.is_streaming() {
2151 let c = self.psi_scale_share;
2152 return self.streaming_materialize(|phi, q, t, sb| {
2153 let s_d = combo_d
2154 .iter()
2155 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2156 .sum();
2157 let s_e = combo_e
2158 .iter()
2159 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2160 .sum();
2161 let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
2162 Self::transformed_second_kernel_value(
2163 phi, q, t, s_d, sum_d, s_e, sum_e, overlap_s, c,
2164 )
2165 });
2166 }
2167 let n = self.n;
2168 let k = self.n_knots;
2169 let c = self.psi_scale_share;
2170 let mut raw = Array2::<f64>::zeros((n, k));
2171 for i in 0..n {
2172 let base = i * k;
2173 for j in 0..k {
2174 let idx = base + j;
2175 let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
2176 let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
2177 let overlap_s =
2178 self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
2179 raw[[i, j]] = Self::transformed_second_kernel_value(
2180 self.phi_values[idx],
2181 self.q_values[idx],
2182 self.t_values[idx],
2183 s_d,
2184 sum_d,
2185 s_e,
2186 sum_e,
2187 overlap_s,
2188 c,
2189 );
2190 }
2191 }
2192 return Ok(self.project_matrix(raw));
2193 }
2194 if self.is_streaming() {
2195 let c = self.psi_scale_share;
2196 return self.streaming_materialize(|phi, q, t, sb| {
2197 t * sb[axis_d] * sb[axis_e] + c * q * (sb[axis_d] + sb[axis_e]) + c * c * phi
2198 });
2199 }
2200 let n = self.n;
2201 let k = self.n_knots;
2202 let c = self.psi_scale_share;
2203 let mut raw = Array2::<f64>::zeros((n, k));
2204 for i in 0..n {
2205 let base = i * k;
2206 for j in 0..k {
2207 raw[[i, j]] = self.t_values[base + j]
2208 * self.axis_components[[base + j, axis_d]]
2209 * self.axis_components[[base + j, axis_e]]
2210 + c * self.q_values[base + j]
2211 * (self.axis_components[[base + j, axis_d]]
2212 + self.axis_components[[base + j, axis_e]])
2213 + c * c * self.phi_values[base + j];
2214 }
2215 }
2216 Ok(self.project_matrix(raw))
2217 }
2218
2219 pub(crate) fn project_matrix(&self, raw: Array2<f64>) -> Array2<f64> {
2222 let constrained = match &self.ident_transform {
2224 Some(z) => fast_ab(&raw, z),
2225 None => raw,
2226 };
2227
2228 let padded = if self.n_poly > 0 {
2230 let cols = constrained.ncols();
2231 let mut out = Array2::<f64>::zeros((self.n, cols + self.n_poly));
2232 out.slice_mut(s![.., ..cols]).assign(&constrained);
2233 out
2234 } else {
2235 constrained
2236 };
2237
2238 match &self.full_ident_transform {
2240 Some(zf) => fast_ab(&padded, zf),
2241 None => padded,
2242 }
2243 }
2244
2245 pub(crate) fn project_matrix_rows(&self, raw: Array2<f64>) -> Array2<f64> {
2246 let nrows = raw.nrows();
2247 let constrained = match &self.ident_transform {
2248 Some(z) => fast_ab(&raw, z),
2249 None => raw,
2250 };
2251 let padded = if self.n_poly > 0 {
2252 let cols = constrained.ncols();
2253 let mut out = Array2::<f64>::zeros((nrows, cols + self.n_poly));
2254 out.slice_mut(s![.., ..cols]).assign(&constrained);
2255 out
2256 } else {
2257 constrained
2258 };
2259 match &self.full_ident_transform {
2260 Some(zf) => fast_ab(&padded, zf),
2261 None => padded,
2262 }
2263 }
2264
2265 pub(crate) fn row_chunk_with_kernel<G>(
2266 &self,
2267 rows: std::ops::Range<usize>,
2268 deriv_fn: G,
2269 ) -> Result<Array2<f64>, BasisError>
2270 where
2271 G: Fn(f64, f64, f64, &[f64], usize) -> f64,
2272 {
2273 let raw = self.row_chunk_with_kernel_raw(rows, deriv_fn)?;
2274 Ok(self.project_matrix_rows(raw))
2275 }
2276
2277 pub(crate) fn row_chunk_with_kernel_raw<G>(
2283 &self,
2284 rows: std::ops::Range<usize>,
2285 deriv_fn: G,
2286 ) -> Result<Array2<f64>, BasisError>
2287 where
2288 G: Fn(f64, f64, f64, &[f64], usize) -> f64,
2289 {
2290 let mut raw = Array2::<f64>::zeros((rows.end - rows.start, self.n_knots));
2291 if let Some(st) = self.streaming.as_ref() {
2292 let mut sb = vec![0.0; self.n_axes];
2293 if let Some(cache) = st.ensure_triplet_cache() {
2294 for (local, i) in rows.enumerate() {
2295 let base = i * self.n_knots;
2296 for j in 0..self.n_knots {
2297 let idx = base + j;
2298 st.fill_s_buf(i, j, &mut sb);
2299 raw[[local, j]] =
2300 deriv_fn(cache.phi[idx], cache.q[idx], cache.t[idx], &sb, idx);
2301 }
2302 }
2303 } else {
2304 for (local, i) in rows.enumerate() {
2305 for j in 0..self.n_knots {
2306 let (phi, q, t) = st.compute_pair(i, j, &mut sb)?;
2307 raw[[local, j]] = deriv_fn(phi, q, t, &sb, i * self.n_knots + j);
2308 }
2309 }
2310 }
2311 } else {
2312 for (local, i) in rows.enumerate() {
2313 let base = i * self.n_knots;
2314 for j in 0..self.n_knots {
2315 let idx = base + j;
2316 raw[[local, j]] = deriv_fn(
2317 self.phi_values[idx],
2318 self.q_values[idx],
2319 self.t_values[idx],
2320 &[],
2321 idx,
2322 );
2323 }
2324 }
2325 }
2326 Ok(raw)
2327 }
2328
2329 pub fn row_chunk_first(
2330 &self,
2331 axis: usize,
2332 rows: std::ops::Range<usize>,
2333 ) -> Result<Array2<f64>, BasisError> {
2334 assert!(
2335 axis < self.n_axes(),
2336 "implicit psi first row chunk axis out of bounds: axis={axis}, n_axes={}",
2337 self.n_axes()
2338 );
2339 let c = self.psi_scale_share;
2340 if self.axis_combinations.is_some() {
2341 let combo = self.transformed_axis_combination(axis);
2342 let combo_sum = Self::transformed_combo_sum(combo);
2343 return self.row_chunk_with_kernel(rows, |phi, q, _, sb, idx| {
2344 let s_combo = if sb.is_empty() {
2345 self.transformed_combo_axis_value_materialized(idx, combo)
2346 } else {
2347 combo
2348 .iter()
2349 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2350 .sum()
2351 };
2352 Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
2353 });
2354 }
2355 self.row_chunk_with_kernel(rows, |phi, q, _, sb, idx| {
2356 let s = if sb.is_empty() {
2357 self.axis_components[[idx, axis]]
2358 } else {
2359 sb[axis]
2360 };
2361 q * s + c * phi
2362 })
2363 }
2364
2365 pub fn row_chunk_first_raw(
2372 &self,
2373 axis: usize,
2374 rows: std::ops::Range<usize>,
2375 ) -> Result<Array2<f64>, BasisError> {
2376 assert!(
2377 axis < self.n_axes(),
2378 "implicit psi first raw row chunk axis out of bounds: axis={axis}, n_axes={}",
2379 self.n_axes()
2380 );
2381 let c = self.psi_scale_share;
2382 if self.axis_combinations.is_some() {
2383 let combo = self.transformed_axis_combination(axis);
2384 let combo_sum = Self::transformed_combo_sum(combo);
2385 return self.row_chunk_with_kernel_raw(rows, |phi, q, _, sb, idx| {
2386 let s_combo = if sb.is_empty() {
2387 self.transformed_combo_axis_value_materialized(idx, combo)
2388 } else {
2389 combo
2390 .iter()
2391 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2392 .sum()
2393 };
2394 Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
2395 });
2396 }
2397 self.row_chunk_with_kernel_raw(rows, |phi, q, _, sb, idx| {
2398 let s = if sb.is_empty() {
2399 self.axis_components[[idx, axis]]
2400 } else {
2401 sb[axis]
2402 };
2403 q * s + c * phi
2404 })
2405 }
2406
2407 pub fn row_chunk_second_diag(
2408 &self,
2409 axis: usize,
2410 rows: std::ops::Range<usize>,
2411 ) -> Result<Array2<f64>, BasisError> {
2412 assert!(
2413 axis < self.n_axes(),
2414 "implicit psi second diagonal row chunk axis out of bounds: axis={axis}, n_axes={}",
2415 self.n_axes()
2416 );
2417 let c = self.psi_scale_share;
2418 if self.axis_combinations.is_some() {
2419 let combo = self.transformed_axis_combination(axis);
2420 let combo_sum = Self::transformed_combo_sum(combo);
2421 return self.row_chunk_with_kernel(rows, |phi, q, t, sb, idx| {
2422 let s_combo = if sb.is_empty() {
2423 self.transformed_combo_axis_value_materialized(idx, combo)
2424 } else {
2425 combo
2426 .iter()
2427 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2428 .sum()
2429 };
2430 let overlap = if sb.is_empty() {
2431 self.transformed_combo_overlap_materialized(idx, combo, combo)
2432 } else {
2433 Self::transformed_combo_overlap_streaming(combo, combo, sb)
2434 };
2435 Self::transformed_second_kernel_value(
2436 phi, q, t, s_combo, combo_sum, s_combo, combo_sum, overlap, c,
2437 )
2438 });
2439 }
2440 self.row_chunk_with_kernel(rows, |phi, q, t, sb, idx| {
2441 let s = if sb.is_empty() {
2442 self.axis_components[[idx, axis]]
2443 } else {
2444 sb[axis]
2445 };
2446 2.0 * q * s + t * s * s + 2.0 * c * q * s + c * c * phi
2447 })
2448 }
2449
2450 pub fn row_chunk_second_cross(
2451 &self,
2452 axis_d: usize,
2453 axis_e: usize,
2454 rows: std::ops::Range<usize>,
2455 ) -> Result<Array2<f64>, BasisError> {
2456 assert!(
2457 axis_d < self.n_axes(),
2458 "implicit psi second cross row chunk first axis out of bounds: axis_d={axis_d}, n_axes={}",
2459 self.n_axes()
2460 );
2461 assert!(
2462 axis_e < self.n_axes(),
2463 "implicit psi second cross row chunk second axis out of bounds: axis_e={axis_e}, n_axes={}",
2464 self.n_axes()
2465 );
2466 assert_ne!(
2467 axis_d, axis_e,
2468 "implicit psi second cross row chunk requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
2469 );
2470 let c = self.psi_scale_share;
2471 if self.axis_combinations.is_some() {
2472 let combo_d = self.transformed_axis_combination(axis_d);
2473 let combo_e = self.transformed_axis_combination(axis_e);
2474 let sum_d = Self::transformed_combo_sum(combo_d);
2475 let sum_e = Self::transformed_combo_sum(combo_e);
2476 return self.row_chunk_with_kernel(rows, |phi, q, t, sb, idx| {
2477 let s_d = if sb.is_empty() {
2478 self.transformed_combo_axis_value_materialized(idx, combo_d)
2479 } else {
2480 combo_d
2481 .iter()
2482 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2483 .sum()
2484 };
2485 let s_e = if sb.is_empty() {
2486 self.transformed_combo_axis_value_materialized(idx, combo_e)
2487 } else {
2488 combo_e
2489 .iter()
2490 .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2491 .sum()
2492 };
2493 let overlap = if sb.is_empty() {
2494 self.transformed_combo_overlap_materialized(idx, combo_d, combo_e)
2495 } else {
2496 Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb)
2497 };
2498 Self::transformed_second_kernel_value(phi, q, t, s_d, sum_d, s_e, sum_e, overlap, c)
2499 });
2500 }
2501 self.row_chunk_with_kernel(rows, |phi, q, t, sb, idx| {
2502 let sd = if sb.is_empty() {
2503 self.axis_components[[idx, axis_d]]
2504 } else {
2505 sb[axis_d]
2506 };
2507 let se = if sb.is_empty() {
2508 self.axis_components[[idx, axis_e]]
2509 } else {
2510 sb[axis_e]
2511 };
2512 t * sd * se + c * q * (sd + se) + c * c * phi
2513 })
2514 }
2515
2516 pub fn row_vector_first_into(
2524 &self,
2525 axis: usize,
2526 row: usize,
2527 mut out: ArrayViewMut1<'_, f64>,
2528 ) -> Result<(), BasisError> {
2529 assert!(
2530 row < self.n,
2531 "implicit psi row-vector request out of bounds: row={row}, n={}",
2532 self.n
2533 );
2534 assert_eq!(
2535 out.len(),
2536 self.p_out(),
2537 "implicit psi row-vector output length mismatch"
2538 );
2539 let chunk = self.row_chunk_first(axis, row..row + 1)?;
2540 out.assign(&chunk.row(0));
2541 Ok(())
2542 }
2543
2544 pub(crate) fn transformed_axis_combination(&self, axis: usize) -> &[(usize, f64)] {
2545 self.axis_combinations
2546 .as_ref()
2547 .expect("transformed axis combinations")
2548 .get(axis)
2549 .map(Vec::as_slice)
2550 .expect("transformed axis index")
2551 }
2552
2553 #[inline]
2554 pub(crate) fn transformed_combo_sum(combo: &[(usize, f64)]) -> f64 {
2555 combo.iter().map(|(_, coeff)| *coeff).sum()
2556 }
2557
2558 #[inline]
2559 pub(crate) fn transformed_combo_axis_value_materialized(
2560 &self,
2561 idx: usize,
2562 combo: &[(usize, f64)],
2563 ) -> f64 {
2564 combo
2565 .iter()
2566 .map(|(raw_axis, coeff)| coeff * self.axis_components[[idx, *raw_axis]])
2567 .sum()
2568 }
2569
2570 #[inline]
2571 pub(crate) fn transformed_combo_overlap_streaming(
2572 combo_left: &[(usize, f64)],
2573 combo_right: &[(usize, f64)],
2574 sb: &[f64],
2575 ) -> f64 {
2576 let mut overlap = 0.0;
2577 for &(left_axis, left_coeff) in combo_left {
2578 for &(right_axis, right_coeff) in combo_right {
2579 if left_axis == right_axis {
2580 overlap += left_coeff * right_coeff * sb[left_axis];
2581 }
2582 }
2583 }
2584 overlap
2585 }
2586
2587 #[inline]
2588 pub(crate) fn transformed_combo_overlap_materialized(
2589 &self,
2590 idx: usize,
2591 combo_left: &[(usize, f64)],
2592 combo_right: &[(usize, f64)],
2593 ) -> f64 {
2594 let mut overlap = 0.0;
2595 for &(left_axis, left_coeff) in combo_left {
2596 for &(right_axis, right_coeff) in combo_right {
2597 if left_axis == right_axis {
2598 overlap += left_coeff * right_coeff * self.axis_components[[idx, left_axis]];
2599 }
2600 }
2601 }
2602 overlap
2603 }
2604
2605 #[inline]
2606 pub(crate) fn transformed_first_kernel_value(
2607 phi: f64,
2608 q: f64,
2609 s_combo: f64,
2610 coeff_sum: f64,
2611 psi_scale_share: f64,
2612 ) -> f64 {
2613 q * s_combo + psi_scale_share * coeff_sum * phi
2614 }
2615
2616 #[inline]
2617 pub(crate) fn transformed_second_kernel_value(
2618 phi: f64,
2619 q: f64,
2620 t: f64,
2621 s_left: f64,
2622 left_sum: f64,
2623 s_right: f64,
2624 right_sum: f64,
2625 overlap_s: f64,
2626 psi_scale_share: f64,
2627 ) -> f64 {
2628 t * s_left * s_right
2629 + 2.0 * q * overlap_s
2630 + psi_scale_share * q * (right_sum * s_left + left_sum * s_right)
2631 + psi_scale_share * psi_scale_share * left_sum * right_sum * phi
2632 }
2633}
2634
2635pub(crate) fn build_aniso_design_psi_derivatives_shared(
2636 data: ArrayView2<'_, f64>,
2637 centers: ArrayView2<'_, f64>,
2638 eta: &[f64],
2639 p_final: usize,
2640 ident_transform: Option<Array2<f64>>,
2641 full_ident_transform: Option<Array2<f64>>,
2642 n_poly: usize,
2643 radial_kind: RadialScalarKind,
2644) -> Result<AnisoBasisPsiDerivatives, BasisError> {
2645 let n = data.nrows();
2646 let k = centers.nrows();
2647 let dim = data.ncols();
2648 if eta.len() != dim {
2649 crate::bail_dim_basis!(
2650 "aniso design derivatives: eta.len()={} != data dimension {dim}",
2651 eta.len()
2652 );
2653 }
2654
2655 let policy = gam_runtime::resource::ResourcePolicy::default_library();
2656 let force_operator = radial_kind.is_duchon_family();
2657 let dense_derivatives_exceed_budget =
2658 should_use_implicit_operators_with_policy(n, p_final, dim, &policy);
2659 let operator_only = force_operator || dense_derivatives_exceed_budget;
2660 let cache_radial_components = should_cache_implicit_radial_components(n, k, dim, &policy);
2661 if operator_only && !cache_radial_components {
2681 let op = ImplicitDesignPsiDerivative::new_streaming(
2682 shared_owned_data_matrix_from_view(data),
2683 shared_owned_centers_matrix_from_view(centers),
2684 eta.to_vec(),
2685 radial_kind,
2686 ident_transform,
2687 full_ident_transform,
2688 n_poly,
2689 );
2690 return Ok(AnisoBasisPsiDerivatives {
2691 design_first: Vec::new(),
2692 design_second_diag: Vec::new(),
2693 design_second_cross: Vec::new(),
2694 design_second_cross_pairs: Vec::new(),
2695 penalties_first: vec![Vec::new(); dim],
2696 penalties_second_diag: vec![Vec::new(); dim],
2697 penalties_cross_pairs: Vec::new(),
2698 penalties_cross_provider: None,
2699 implicit_operator: Some(op),
2700 });
2701 }
2702
2703 let nk = n.checked_mul(k).ok_or_else(|| {
2708 BasisError::InvalidInput("aniso radial cache has too many data-center pairs".to_string())
2709 })?;
2710 if nk.checked_mul(dim).is_none() {
2711 crate::bail_invalid_basis!("aniso radial cache axis component storage is too large");
2712 }
2713 let mut phi_values = Array1::<f64>::zeros(nk);
2714 let mut q_values = Array1::<f64>::zeros(nk);
2715 let mut t_values = Array1::<f64>::zeros(nk);
2716 let mut axis_components = Array2::<f64>::zeros((nk, dim));
2717
2718 let psi_scale_share = radial_kind.raw_psi_isotropic_share();
2719
2720 let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
2721 let nc = n.div_ceil(cs);
2722 let first_err: std::sync::Mutex<Option<BasisError>> = std::sync::Mutex::new(None);
2731 let profile = if nk >= RADIAL_PROFILE_MIN_PAIRS {
2740 let mut r_lo = f64::INFINITY;
2741 let mut r_hi = 0.0_f64;
2742 let mut drb = vec![0.0; dim];
2743 let mut cb = vec![0.0; dim];
2744 for i in 0..n {
2745 for a in 0..dim {
2746 drb[a] = data[[i, a]];
2747 }
2748 for j in 0..k {
2749 for a in 0..dim {
2750 cb[a] = centers[[j, a]];
2751 }
2752 let (r, _) = aniso_distance_and_components(&drb, &cb, eta);
2753 if r > 0.0 {
2754 r_lo = r_lo.min(r);
2755 r_hi = r_hi.max(r);
2756 }
2757 }
2758 }
2759 if r_lo.is_finite() && r_hi > r_lo {
2760 radial_profile::RadialProfile::build(&radial_kind, r_lo, r_hi)
2761 } else {
2762 None
2763 }
2764 } else {
2765 None
2766 };
2767 {
2768 let pp = SendPtr(phi_values.as_mut_ptr());
2769 let qp = SendPtr(q_values.as_mut_ptr());
2770 let tp = SendPtr(t_values.as_mut_ptr());
2771 let ap = SendPtr(axis_components.as_mut_ptr());
2772 let ferr = &first_err;
2773 let profile_ref = profile.as_ref();
2774 (0..nc).into_par_iter().for_each(move |ci| {
2775 let start = ci * cs;
2776 let end = start.saturating_add(cs).min(n);
2777 let mut drb = vec![0.0; dim];
2778 let mut cb = vec![0.0; dim];
2779 for i in start..end {
2780 for a in 0..dim {
2781 drb[a] = data[[i, a]];
2782 }
2783 for j in 0..k {
2784 for a in 0..dim {
2785 cb[a] = centers[[j, a]];
2786 }
2787 let (r, sv) = aniso_distance_and_components(&drb, &cb, eta);
2788 let triplet = match profile_ref {
2789 Some(profile) => profile.eval_or_exact(&radial_kind, r),
2790 None => radial_kind.eval_design_triplet(r),
2791 };
2792 let (phi, q, t) = match triplet {
2793 Ok(p) => p,
2794 Err(e) => {
2795 let mut slot = ferr.lock().unwrap_or_else(|p| p.into_inner());
2796 if slot.is_none() {
2797 *slot = Some(e);
2798 }
2799 return;
2800 }
2801 };
2802 let flat = i * k + j;
2803 unsafe {
2807 *pp.add(flat) = phi;
2808 *qp.add(flat) = q;
2809 *tp.add(flat) = t;
2810 for a in 0..dim {
2811 *ap.add(flat * dim + a) = sv[a];
2812 }
2813 }
2814 }
2815 }
2816 });
2817 }
2818 if let Some(cause) = first_err.into_inner().unwrap_or_else(|p| p.into_inner()) {
2819 return Err(BasisError::InvalidInput(format!(
2820 "radial scalar evaluation failed during aniso derivative construction \
2821 (eta={eta:?}): {cause}"
2822 )));
2823 }
2824
2825 let op = ImplicitDesignPsiDerivative::new(
2826 phi_values,
2827 q_values,
2828 t_values,
2829 axis_components,
2830 ident_transform,
2831 full_ident_transform,
2832 n,
2833 k,
2834 n_poly,
2835 dim,
2836 )
2837 .with_psi_scale_share(psi_scale_share);
2838
2839 if operator_only {
2849 return Ok(AnisoBasisPsiDerivatives {
2850 design_first: Vec::new(),
2851 design_second_diag: Vec::new(),
2852 design_second_cross: Vec::new(),
2853 design_second_cross_pairs: Vec::new(),
2854 penalties_first: vec![Vec::new(); dim],
2855 penalties_second_diag: vec![Vec::new(); dim],
2856 penalties_cross_pairs: Vec::new(),
2857 penalties_cross_provider: None,
2858 implicit_operator: Some(op),
2859 });
2860 }
2861
2862 let design_first = (0..dim)
2863 .map(|a| op.materialize_first(a))
2864 .collect::<Result<Vec<_>, _>>()?;
2865 let design_second_diag = (0..dim)
2866 .map(|a| op.materialize_second_diag(a))
2867 .collect::<Result<Vec<_>, _>>()?;
2868
2869 Ok(AnisoBasisPsiDerivatives {
2870 design_first,
2871 design_second_diag,
2872 design_second_cross: Vec::new(),
2873 design_second_cross_pairs: Vec::new(),
2874 penalties_first: vec![Vec::new(); dim],
2875 penalties_second_diag: vec![Vec::new(); dim],
2876 penalties_cross_pairs: Vec::new(),
2877 penalties_cross_provider: None,
2878 implicit_operator: Some(op),
2879 })
2880}
2881
2882#[derive(Debug, Clone)]
2883pub(crate) struct ScalarDesignPsiDerivatives {
2884 pub(crate) design_first: Array2<f64>,
2885 pub(crate) design_second_diag: Array2<f64>,
2886 pub(crate) implicit_operator: Option<ImplicitDesignPsiDerivative>,
2887}
2888
2889pub(crate) fn build_scalar_design_psi_derivatives_shared(
2890 data: ArrayView2<'_, f64>,
2891 centers: ArrayView2<'_, f64>,
2892 fixed_eta: Option<&[f64]>,
2893 p_final: usize,
2894 ident_transform: Option<Array2<f64>>,
2895 full_ident_transform: Option<Array2<f64>>,
2896 n_poly: usize,
2897 radial_kind: RadialScalarKind,
2898 psi_scale_share: f64,
2899) -> Result<ScalarDesignPsiDerivatives, BasisError> {
2900 let n = data.nrows();
2901 let k = centers.nrows();
2902 let dim = data.ncols();
2903 if let Some(eta) = fixed_eta
2904 && eta.len() != dim
2905 {
2906 crate::bail_dim_basis!(
2907 "scalar design derivatives: eta.len()={} != data dimension {dim}",
2908 eta.len()
2909 );
2910 }
2911
2912 let policy = gam_runtime::resource::ResourcePolicy::default_library();
2913 let force_operator = radial_kind.is_duchon_family();
2914 let dense_derivatives_exceed_budget =
2915 should_use_implicit_operators_with_policy(n, p_final, 1, &policy);
2916 let operator_only = force_operator || dense_derivatives_exceed_budget;
2917 let cache_radial_components = should_cache_implicit_radial_components(n, k, 1, &policy);
2918 if operator_only && !cache_radial_components {
2919 let metric_eta = fixed_eta
2920 .map(|eta| eta.to_vec())
2921 .unwrap_or_else(|| vec![0.0; dim]);
2922 let op = ImplicitDesignPsiDerivative::new_streaming_scalar(
2923 shared_owned_data_matrix_from_view(data),
2924 shared_owned_centers_matrix_from_view(centers),
2925 metric_eta,
2926 radial_kind,
2927 ident_transform,
2928 full_ident_transform,
2929 n_poly,
2930 )
2931 .with_psi_scale_share(psi_scale_share);
2932 return Ok(ScalarDesignPsiDerivatives {
2933 design_first: Array2::<f64>::zeros((0, 0)),
2934 design_second_diag: Array2::<f64>::zeros((0, 0)),
2935 implicit_operator: Some(op),
2936 });
2937 }
2938
2939 let nk = n.checked_mul(k).ok_or_else(|| {
2940 BasisError::InvalidInput("scalar radial cache has too many data-center pairs".to_string())
2941 })?;
2942 let mut phi_values = Array1::<f64>::zeros(nk);
2943 let mut q_values = Array1::<f64>::zeros(nk);
2944 let mut t_values = Array1::<f64>::zeros(nk);
2945 let mut axis_components = Array2::<f64>::zeros((nk, 1));
2946
2947 let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
2948 let nc = n.div_ceil(cs);
2949 let first_err: std::sync::Mutex<Option<BasisError>> = std::sync::Mutex::new(None);
2950 let pair_r = |i: usize, j: usize, drb: &mut [f64], cb: &mut [f64]| -> f64 {
2954 if let Some(eta) = fixed_eta {
2955 for a in 0..dim {
2956 drb[a] = data[[i, a]];
2957 cb[a] = centers[[j, a]];
2958 }
2959 aniso_distance_and_components(drb, cb, eta).0
2960 } else {
2961 stable_euclidean_norm((0..dim).map(|a| data[[i, a]] - centers[[j, a]]))
2962 }
2963 };
2964 let profile = if nk >= RADIAL_PROFILE_MIN_PAIRS {
2965 let mut r_lo = f64::INFINITY;
2966 let mut r_hi = 0.0_f64;
2967 let mut drb = vec![0.0; dim];
2968 let mut cb = vec![0.0; dim];
2969 for i in 0..n {
2970 for j in 0..k {
2971 let r = pair_r(i, j, &mut drb, &mut cb);
2972 if r > 0.0 {
2973 r_lo = r_lo.min(r);
2974 r_hi = r_hi.max(r);
2975 }
2976 }
2977 }
2978 if r_lo.is_finite() && r_hi > r_lo {
2979 radial_profile::RadialProfile::build(&radial_kind, r_lo, r_hi)
2980 } else {
2981 None
2982 }
2983 } else {
2984 None
2985 };
2986 {
2987 let pp = SendPtr(phi_values.as_mut_ptr());
2988 let qp = SendPtr(q_values.as_mut_ptr());
2989 let tp = SendPtr(t_values.as_mut_ptr());
2990 let ap = SendPtr(axis_components.as_mut_ptr());
2991 let ferr = &first_err;
2992 let profile_ref = profile.as_ref();
2993 (0..nc).into_par_iter().for_each(move |ci| {
2994 let start = ci * cs;
2995 let end = start.saturating_add(cs).min(n);
2996 let mut data_row_buf = vec![0.0; dim];
2997 let mut center_buf = vec![0.0; dim];
2998 for i in start..end {
2999 for a in 0..dim {
3000 data_row_buf[a] = data[[i, a]];
3001 }
3002 for j in 0..k {
3003 let (r, scalar_component) = if let Some(eta) = fixed_eta {
3004 for a in 0..dim {
3005 center_buf[a] = centers[[j, a]];
3006 }
3007 let (r, components) =
3008 aniso_distance_and_components(&data_row_buf, ¢er_buf, eta);
3009 (r, components.into_iter().sum::<f64>())
3010 } else {
3011 let r =
3012 stable_euclidean_norm((0..dim).map(|a| data[[i, a]] - centers[[j, a]]));
3013 (r, r * r)
3014 };
3015 let triplet = match profile_ref {
3016 Some(profile) => profile.eval_or_exact(&radial_kind, r),
3017 None => radial_kind.eval_design_triplet(r),
3018 };
3019 let (phi, q, t) = match triplet {
3020 Ok(p) => p,
3021 Err(e) => {
3022 let mut slot = ferr.lock().unwrap_or_else(|p| p.into_inner());
3023 if slot.is_none() {
3024 *slot = Some(e);
3025 }
3026 return;
3027 }
3028 };
3029 let flat = i * k + j;
3030 unsafe {
3034 *pp.add(flat) = phi;
3035 *qp.add(flat) = q;
3036 *tp.add(flat) = t;
3037 *ap.add(flat) = scalar_component;
3038 }
3039 }
3040 }
3041 });
3042 }
3043 if let Some(cause) = first_err.into_inner().unwrap_or_else(|p| p.into_inner()) {
3044 return Err(BasisError::InvalidInput(format!(
3045 "radial scalar evaluation failed during scalar derivative construction: {cause}"
3046 )));
3047 }
3048
3049 let op = ImplicitDesignPsiDerivative::new(
3050 phi_values,
3051 q_values,
3052 t_values,
3053 axis_components,
3054 ident_transform,
3055 full_ident_transform,
3056 n,
3057 k,
3058 n_poly,
3059 1,
3060 )
3061 .with_psi_scale_share(psi_scale_share);
3062
3063 if operator_only {
3064 return Ok(ScalarDesignPsiDerivatives {
3065 design_first: Array2::<f64>::zeros((0, 0)),
3066 design_second_diag: Array2::<f64>::zeros((0, 0)),
3067 implicit_operator: Some(op),
3068 });
3069 }
3070
3071 Ok(ScalarDesignPsiDerivatives {
3072 design_first: op.materialize_first(0)?,
3073 design_second_diag: op.materialize_second_diag(0)?,
3074 implicit_operator: Some(op),
3075 })
3076}