1use super::*;
2
3use super::invariant_tie_break::resolve_sorted_profile_tie;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub(crate) struct ConstraintNullspaceCacheKey {
7 pub(crate) centersrows: usize,
8 pub(crate) centers_cols: usize,
9 pub(crate) centers_hash: u64,
10 pub(crate) order: ConstraintNullspaceOrderKey,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub(crate) enum ConstraintNullspaceOrderKey {
15 Duchon(DuchonNullspaceOrder),
16 ThinPlate,
17}
18
19#[derive(Default, Clone, Debug)]
20pub(crate) struct ConstraintNullspaceCache {
21 pub(crate) map: HashMap<ConstraintNullspaceCacheKey, Arc<Array2<f64>>>,
22 pub(crate) order: Vec<ConstraintNullspaceCacheKey>,
23}
24
25pub(crate) const CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES: usize = 32;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub(crate) struct OwnedDataCacheKey {
29 pub(crate) rows: usize,
30 pub(crate) cols: usize,
31 pub(crate) ptr: usize,
32 pub(crate) stride0: isize,
33 pub(crate) stride1: isize,
34}
35
36#[derive(Debug)]
37pub(crate) struct BasisCacheContext {
38 pub(crate) constraint_nullspace: ConstraintNullspaceCache,
39 pub(crate) owned_data: gam_runtime::resource::ByteLruCache<OwnedDataCacheKey, Arc<Array2<f64>>>,
40}
41
42impl BasisCacheContext {
43 pub(crate) fn with_policy(policy: &gam_runtime::resource::ResourcePolicy) -> Self {
44 Self {
45 constraint_nullspace: ConstraintNullspaceCache::default(),
46 owned_data: gam_runtime::resource::ByteLruCache::with_max_entries(
47 policy.max_owned_data_cache_bytes,
48 gam_runtime::resource::OWNED_DATA_CACHE_MAX_ENTRIES,
49 ),
50 }
51 }
52}
53
54impl Default for BasisCacheContext {
55 fn default() -> Self {
56 Self::with_policy(&gam_runtime::resource::ResourcePolicy::default_library())
57 }
58}
59
60#[derive(Debug)]
70pub struct BasisWorkspace {
71 pub(crate) cache: BasisCacheContext,
72 pub(crate) policy: gam_runtime::resource::ResourcePolicy,
73}
74
75impl BasisWorkspace {
76 pub fn new() -> Self {
77 Self::default()
78 }
79
80 pub fn with_policy(policy: gam_runtime::resource::ResourcePolicy) -> Self {
81 Self {
82 cache: BasisCacheContext::with_policy(&policy),
83 policy,
84 }
85 }
86
87 pub fn default_library() -> Self {
88 Self::with_policy(gam_runtime::resource::ResourcePolicy::default_library())
89 }
90
91 pub fn policy(&self) -> &gam_runtime::resource::ResourcePolicy {
93 &self.policy
94 }
95}
96
97impl Default for BasisWorkspace {
98 fn default() -> Self {
99 Self::default_library()
100 }
101}
102
103pub(crate) fn hash_arrayview2(values: ArrayView2<'_, f64>) -> u64 {
104 let mut hasher = DefaultHasher::new();
105 values.nrows().hash(&mut hasher);
106 values.ncols().hash(&mut hasher);
107 for v in values {
108 v.to_bits().hash(&mut hasher);
109 }
110 hasher.finish()
111}
112
113pub(crate) fn shared_owned_data_matrix(
114 data: ArrayView2<'_, f64>,
115 cache: &BasisCacheContext,
116) -> Arc<Array2<f64>> {
117 let key = OwnedDataCacheKey {
118 rows: data.nrows(),
119 cols: data.ncols(),
120 ptr: data.as_ptr() as usize,
121 stride0: data.strides()[0],
122 stride1: data.strides()[1],
123 };
124 if let Some(hit) = cache.owned_data.get(&key) {
125 return hit;
126 }
127
128 let owned = Arc::new(data.to_owned());
129 if let Some(hit) = cache.owned_data.get(&key) {
130 return hit;
131 }
132
133 cache.owned_data.insert(key, owned.clone());
134 owned
135}
136
137#[inline]
145pub(crate) fn shared_owned_data_matrix_from_view(data: ArrayView2<'_, f64>) -> Arc<Array2<f64>> {
146 Arc::new(data.to_owned())
147}
148
149#[inline]
154pub(crate) fn shared_owned_centers_matrix_from_view(
155 centers: ArrayView2<'_, f64>,
156) -> Arc<Array2<f64>> {
157 Arc::new(centers.to_owned())
158}
159
160pub(crate) fn kernel_constraint_nullspace(
176 centers: ArrayView2<'_, f64>,
177 order: DuchonNullspaceOrder,
178 cache: &mut BasisCacheContext,
179) -> Result<Array2<f64>, BasisError> {
180 let effective_order = duchon_effective_nullspace_order(centers, order);
181 let centers_centered = mean_centered_centers(centers);
182 let centers = centers_centered.view();
183 let key = ConstraintNullspaceCacheKey {
184 centersrows: centers.nrows(),
185 centers_cols: centers.ncols(),
186 centers_hash: hash_arrayview2(centers),
187 order: ConstraintNullspaceOrderKey::Duchon(effective_order),
188 };
189
190 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
191 return Ok((**hit).clone());
192 }
193
194 let z = Arc::new(duchon_constraint_nullspace_of_centered(
195 centers,
196 order,
197 effective_order,
198 )?);
199
200 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
201 return Ok((**hit).clone());
202 }
203 cache.constraint_nullspace.map.insert(key, z.clone());
204 cache.constraint_nullspace.order.push(key);
205 while cache.constraint_nullspace.map.len() > CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES {
206 if cache.constraint_nullspace.order.is_empty() {
207 break;
208 }
209 let oldkey = cache.constraint_nullspace.order.remove(0);
210 cache.constraint_nullspace.map.remove(&oldkey);
211 }
212
213 Ok((*z).clone())
214}
215
216fn mean_centered_centers(centers: ArrayView2<'_, f64>) -> Array2<f64> {
229 let k = centers.nrows();
230 let d = centers.ncols();
231 let center_mean: Vec<f64> = (0..d)
232 .map(|c| centers.column(c).sum() / (k.max(1) as f64))
233 .collect();
234 let mut centers_centered = centers.to_owned();
235 for c in 0..d {
236 let mu = center_mean[c];
237 centers_centered.column_mut(c).mapv_inplace(|v| v - mu);
238 }
239 centers_centered
240}
241
242fn duchon_constraint_nullspace_of_centered(
246 centers: ArrayView2<'_, f64>,
247 order: DuchonNullspaceOrder,
248 effective_order: DuchonNullspaceOrder,
249) -> Result<Array2<f64>, BasisError> {
250 let degraded = effective_order != order;
251 let p_k = polynomial_block_from_order(centers, effective_order);
252 kernel_constraint_nullspace_from_matrix(p_k.view()).map_err(|err| {
253 if degraded {
254 BasisError::InvalidInput(format!(
255 "Duchon degraded from order={:?} to order={:?} due to insufficient centers ({} in dim={}); order={:?} construction then failed: {err}",
256 order,
257 effective_order,
258 centers.nrows(),
259 centers.ncols(),
260 effective_order,
261 ))
262 } else {
263 err
264 }
265 })
266}
267
268pub fn duchon_kernel_constraint_nullspace(
277 centers: ArrayView2<'_, f64>,
278 order: DuchonNullspaceOrder,
279) -> Result<Array2<f64>, BasisError> {
280 let effective_order = duchon_effective_nullspace_order(centers, order);
281 let centers_centered = mean_centered_centers(centers);
282 duchon_constraint_nullspace_of_centered(centers_centered.view(), order, effective_order)
283}
284
285pub(crate) fn thin_plate_kernel_constraint_nullspace(
286 centers: ArrayView2<'_, f64>,
287 cache: &mut BasisCacheContext,
288) -> Result<Array2<f64>, BasisError> {
289 let key = ConstraintNullspaceCacheKey {
290 centersrows: centers.nrows(),
291 centers_cols: centers.ncols(),
292 centers_hash: hash_arrayview2(centers),
293 order: ConstraintNullspaceOrderKey::ThinPlate,
294 };
295
296 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
297 return Ok((**hit).clone());
298 }
299
300 let p_k = thin_plate_polynomial_block(centers);
301 if centers.nrows() < p_k.ncols() {
302 crate::bail_invalid_basis!(
303 "thin-plate spline requires at least {} centers to span the degree-{} polynomial null space in dimension {}; got {}",
304 p_k.ncols(),
305 thin_plate_polynomial_degree(centers.ncols()),
306 centers.ncols(),
307 centers.nrows()
308 );
309 }
310 let (z, rank) =
311 rrqr_nullspace_basis(&p_k, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
312 if rank != p_k.ncols() {
313 crate::bail_invalid_basis!(
314 "thin-plate spline polynomial block is rank deficient at the selected centers: expected rank {}, got {}; choose geometrically independent centers for dimension {}",
315 p_k.ncols(),
316 rank,
317 centers.ncols()
318 );
319 }
320 let z = Arc::new(z);
321
322 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
323 return Ok((**hit).clone());
324 }
325 cache.constraint_nullspace.map.insert(key, z.clone());
326 cache.constraint_nullspace.order.push(key);
327 while cache.constraint_nullspace.map.len() > CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES {
328 if cache.constraint_nullspace.order.is_empty() {
329 break;
330 }
331 let oldkey = cache.constraint_nullspace.order.remove(0);
332 cache.constraint_nullspace.map.remove(&oldkey);
333 }
334
335 Ok((*z).clone())
336}
337
338pub(crate) fn matern_identifiability_transform(
339 centers: ArrayView2<'_, f64>,
340 identifiability: &MaternIdentifiability,
341) -> Result<Option<Array2<f64>>, BasisError> {
342 let k = centers.nrows();
343 match identifiability {
344 MaternIdentifiability::None => Ok(None),
345 MaternIdentifiability::CenterSumToZero => {
346 let q = Array2::<f64>::ones((k, 1));
347 Ok(Some(kernel_constraint_nullspace_from_matrix(q.view())?))
348 }
349 MaternIdentifiability::CenterLinearOrthogonal => {
350 let effective_order =
355 duchon_effective_nullspace_order(centers, DuchonNullspaceOrder::Linear);
356 let q = polynomial_block_from_order(centers, effective_order);
357 Ok(Some(kernel_constraint_nullspace_from_matrix(q.view())?))
358 }
359 MaternIdentifiability::FrozenTransform { transform, .. } => {
360 if transform.nrows() != k {
361 crate::bail_dim_basis!(
362 "frozen Matérn identifiability transform mismatch: centers={k}, transform rows={}",
363 transform.nrows()
364 );
365 }
366 Ok(Some(transform.clone()))
367 }
368 }
369}
370
371pub(crate) fn build_matern_operator_penalty_candidates(
372 centers: ArrayView2<'_, f64>,
373 length_scale: f64,
374 nu: MaternNu,
375 include_intercept: bool,
376 z_opt: Option<&Array2<f64>>,
377 aniso_log_scales: Option<&[f64]>,
378) -> Result<Vec<PenaltyCandidate>, BasisError> {
379 let ops = build_matern_collocation_operator_matrices(
380 centers,
381 None,
382 length_scale,
383 nu,
384 include_intercept,
385 z_opt.map(|z| z.view()),
386 aniso_log_scales,
387 )?;
388 let matern_spec = DuchonOperatorPenaltySpec::matern_for_smoothness(nu, centers.ncols());
392 operator_penalty_candidates_from_collocation(&ops.d0, &ops.d1, &ops.d2, &matern_spec)
393}
394
395fn matrix_all_finite(m: &Array2<f64>) -> bool {
397 m.iter().all(|v| v.is_finite())
398}
399
400pub(crate) fn matern_center_function_gram(
408 embedded_kernel: &Array2<f64>,
409 include_intercept: bool,
410 full_transform: Option<&Array2<f64>>,
411) -> Result<Array2<f64>, BasisError> {
412 if embedded_kernel.nrows() != embedded_kernel.ncols() {
413 crate::bail_dim_basis!("Matérn embedded kernel penalty must be square");
414 }
415 let total = embedded_kernel.nrows();
416 let k = total
417 .checked_sub(usize::from(include_intercept))
418 .ok_or_else(|| BasisError::InvalidInput("Matérn basis width underflow".to_string()))?;
419 if k == 0 {
420 crate::bail_invalid_basis!("Matérn function metric requires at least one center");
421 }
422 let mut center_design = Array2::<f64>::zeros((k, total));
423 center_design
424 .slice_mut(s![.., 0..k])
425 .assign(&embedded_kernel.slice(s![0..k, 0..k]));
426 if include_intercept {
427 center_design.column_mut(k).fill(1.0);
428 }
429 let center_design = match full_transform {
430 Some(transform) => fast_ab(¢er_design, transform),
431 None => center_design,
432 };
433 Ok(symmetrize_penalty(&fast_ata(¢er_design)))
434}
435
436pub(crate) fn matern_double_penalty_candidates(
437 primary: &Array2<f64>,
438 function_gram: &Array2<f64>,
439 include_intercept: bool,
440) -> Result<Vec<PenaltyCandidate>, BasisError> {
441 if !matrix_all_finite(primary) {
447 crate::bail_invalid_basis!(
448 "Matérn double-penalty primary kernel Gram is non-finite; the projected \
449 kernel `Zᵀ K Z` could not be formed at this length scale (degenerate \
450 geometry). Widen the data spread, change the length scale, or drop the term."
451 );
452 }
453 if primary.dim() != function_gram.dim() || !matrix_all_finite(function_gram) {
454 crate::bail_invalid_basis!(
455 "Matérn center function Gram is non-finite or does not match the primary penalty"
456 );
457 }
458 let mut candidates = vec![normalize_penalty_candidate(
459 primary.clone(),
460 PenaltySource::Primary,
461 )?];
462 if include_intercept {
468 let p = primary.nrows();
469 let mut intercept_frame = Array2::<f64>::zeros((p, 1));
470 intercept_frame[[p - 1, 0]] = 1.0;
471 let shrinkage = function_space_subspace_shrinkage(&intercept_frame, function_gram)?;
472 candidates.push(normalize_penalty_candidate(
473 shrinkage,
474 PenaltySource::DoublePenaltyNullspace,
475 )?);
476 }
477 Ok(candidates)
478}
479
480pub(crate) fn build_matern_double_penalty_candidates(
481 spline: &MaternSplineBasis,
482 full_transform: Option<&Array2<f64>>,
483) -> Result<Vec<PenaltyCandidate>, BasisError> {
484 let primary = project_penalty_matrix(&spline.penalty_kernel, full_transform);
485 let include_intercept = spline.num_polynomial_basis == 1;
486 let function_gram =
487 matern_center_function_gram(&spline.penalty_kernel, include_intercept, full_transform)?;
488 matern_double_penalty_candidates(&primary, &function_gram, include_intercept)
489}
490
491pub fn create_matern_spline_basiswithworkspace(
504 data: ArrayView2<'_, f64>,
505 centers: ArrayView2<'_, f64>,
506 length_scale: f64,
507 nu: MaternNu,
508 include_intercept: bool,
509 aniso_log_scales: Option<&[f64]>,
510 workspace: &mut BasisWorkspace,
511) -> Result<MaternSplineBasis, BasisError> {
512 let n = data.nrows();
513 let d = data.ncols();
514 let k = centers.nrows();
515 let total_cols = k + usize::from(include_intercept);
516 let dense_bytes = dense_design_bytes(n, total_cols);
517 if dense_bytes > workspace.policy().max_single_materialization_bytes {
518 crate::bail_invalid_basis!(
519 "Matérn basis dense design exceeds resource policy: n={n}, p={total_cols}, dense={:.1} MiB, cap={:.1} MiB",
520 dense_bytes as f64 / (1024.0 * 1024.0),
521 workspace.policy().max_single_materialization_bytes as f64 / (1024.0 * 1024.0),
522 );
523 }
524
525 if d == 0 {
526 crate::bail_invalid_basis!("Matérn basis requires at least one covariate dimension");
527 }
528 if k == 0 {
529 crate::bail_invalid_basis!("Matérn basis requires at least one center");
530 }
531 if centers.ncols() != d {
532 crate::bail_dim_basis!(
533 "Matérn basis dimension mismatch: data has {d} columns, centers have {}",
534 centers.ncols()
535 );
536 }
537 if data.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
538 crate::bail_invalid_basis!("Matérn basis requires finite data and center values");
539 }
540 validate_matern_length_scale(length_scale)?;
541 if let Some(eta) = aniso_log_scales {
542 if eta.len() != d {
543 crate::bail_dim_basis!(
544 "aniso_log_scales length {} does not match data dimension {d}",
545 eta.len()
546 );
547 }
548 if eta.iter().any(|v| !v.is_finite()) {
549 crate::bail_invalid_basis!("aniso_log_scales must contain finite values");
550 }
551 }
552
553 let warn_bounds = if let Some(eta) = aniso_log_scales {
559 let y_centers = points_in_aniso_y_space(centers, eta);
560 pairwise_distance_bounds(y_centers.view())
561 } else {
562 pairwise_distance_bounds(centers)
563 };
564 if let Some((r_min, r_max)) = warn_bounds {
565 let kappa = 1.0 / length_scale.max(1e-300);
566 let kappa_lo = 1e-2 / r_max;
567 let kappa_hi = 1e2 / r_min;
568 if kappa < kappa_lo || kappa > kappa_hi {
569 log::debug!(
570 "Matérn κ={} is outside recommended range [{}, {}] derived from centers (r_min={}, r_max={}); kernel conditioning may degrade",
571 kappa,
572 kappa_lo,
573 kappa_hi,
574 r_min,
575 r_max
576 );
577 }
578 }
579
580 let mut kernel_block = Array2::<f64>::zeros((n, k));
583 let mut center_kernel = Array2::<f64>::zeros((k, k));
584 let axis_scales = aniso_log_scales.map(aniso_axis_scales);
585 let kernel_result: Result<(), BasisError> = kernel_block
586 .axis_iter_mut(Axis(0))
587 .into_par_iter()
588 .enumerate()
589 .try_for_each(|(i, mut row)| {
590 for j in 0..k {
591 let r = if let Some(scales) = axis_scales.as_deref() {
592 aniso_distance_rows_with_scales(data, i, centers, j, scales)
593 } else {
594 euclidean_distance_rows(data, i, centers, j)
595 };
596 row[j] = matern_kernel_from_distance(r, length_scale, nu)?;
597 }
598 Ok(())
599 });
600 kernel_result?;
601 fill_symmetric_from_row_kernel(&mut center_kernel, |i, j| {
604 let r = if let Some(scales) = axis_scales.as_deref() {
605 aniso_distance_rows_with_scales(centers, i, centers, j, scales)
606 } else {
607 euclidean_distance_rows(centers, i, centers, j)
608 };
609 matern_kernel_from_distance(r, length_scale, nu)
610 })?;
611
612 let mut basis = Array2::<f64>::zeros((n, total_cols));
613 basis.slice_mut(s![.., 0..k]).assign(&kernel_block);
614 if include_intercept {
615 basis.column_mut(k).fill(1.0);
616 }
617
618 let mut penalty_kernel = Array2::<f64>::zeros((total_cols, total_cols));
619 penalty_kernel
624 .slice_mut(s![0..k, 0..k])
625 .assign(¢er_kernel);
626 let function_gram = matern_center_function_gram(&penalty_kernel, include_intercept, None)?;
627 let penalty_ridge = if include_intercept {
628 let mut intercept_frame = Array2::<f64>::zeros((total_cols, 1));
629 intercept_frame[[total_cols - 1, 0]] = 1.0;
630 function_space_subspace_shrinkage(&intercept_frame, &function_gram)?
631 } else {
632 Array2::<f64>::zeros((total_cols, total_cols))
633 };
634
635 Ok(MaternSplineBasis {
636 basis,
637 penalty_kernel,
638 penalty_ridge,
639 num_kernel_basis: k,
640 num_polynomial_basis: usize::from(include_intercept),
641 dimension: d,
642 })
643}
644
645#[inline]
646pub(crate) fn validate_lat_lon_matrix(
647 data: ArrayView2<'_, f64>,
648 context: &str,
649 radians: bool,
650) -> Result<(), BasisError> {
651 if data.ncols() != 2 {
652 crate::bail_dim_basis!(
653 "{context} requires exactly two columns: latitude and longitude; got {}",
654 data.ncols()
655 );
656 }
657 if data.nrows() == 0 {
658 crate::bail_invalid_basis!("{context} requires at least one row");
659 }
660 let (lat_lo, lat_hi, unit) = if radians {
661 (
662 -std::f64::consts::FRAC_PI_2,
663 std::f64::consts::FRAC_PI_2,
664 "radians",
665 )
666 } else {
667 (-90.0, 90.0, "degrees")
668 };
669 for (i, row) in data.outer_iter().enumerate() {
670 let lat = row[0];
671 let lon = row[1];
672 if !lat.is_finite() || !lon.is_finite() {
673 crate::bail_invalid_basis!(
674 "{context} requires finite latitude/longitude; row {i} has ({lat}, {lon})"
675 );
676 }
677 if !(lat_lo..=lat_hi).contains(&lat) {
678 crate::bail_invalid_basis!(
679 "{context} latitude must be in [{lat_lo}, {lat_hi}] {unit}; row {i} has {lat}"
680 );
681 }
682 }
683 Ok(())
684}
685
686fn validate_spherical_wahba_gram_request(
687 penalty_order: usize,
688 kernel: SphereWahbaKernel,
689) -> Result<(), BasisError> {
690 if !(1..=4).contains(&penalty_order) {
691 crate::bail_invalid_basis!(
692 "spherical spline penalty_order must be one of 1, 2, 3, 4; got {penalty_order}"
693 );
694 }
695 if matches!(kernel, SphereWahbaKernel::Sobolev) && penalty_order == 1 {
696 crate::bail_invalid_basis!(
701 "the m = 1 Sobolev sphere kernel is log-singular at coincident points, so its Gram \
702 diagonal does not exist and any finite value is a choice of resolution rather than a \
703 limit; use SobolevTruncated {{ lmax }} (the same kernel with the resolution stated, \
704 diagonal ~ ln(lmax)/2pi) or penalty_order >= 2, whose diagonals are finite closed \
705 forms (1/(4pi) at m = 2, (2*zeta3 - 2)/(4pi) at m = 3)"
706 );
707 }
708 Ok(())
709}
710
711pub fn spherical_wahba_kernel_matrix(
717 data: ArrayView2<'_, f64>,
718 centers: ArrayView2<'_, f64>,
719 penalty_order: usize,
720 radians: bool,
721) -> Result<Array2<f64>, BasisError> {
722 spherical_wahba_kernel_matrix_with_kind(
723 data,
724 centers,
725 penalty_order,
726 radians,
727 SphereWahbaKernel::Sobolev,
728 )
729}
730
731pub fn spherical_wahba_kernel_matrix_with_kind(
738 data: ArrayView2<'_, f64>,
739 centers: ArrayView2<'_, f64>,
740 penalty_order: usize,
741 radians: bool,
742 kernel: SphereWahbaKernel,
743) -> Result<Array2<f64>, BasisError> {
744 validate_spherical_wahba_gram_request(penalty_order, kernel)?;
745 validate_lat_lon_matrix(data, "spherical spline data", radians)?;
746 validate_lat_lon_matrix(centers, "spherical spline centers", radians)?;
747 if let Some(gpu_result) = crate::basis::sphere_gpu::try_build_truncated_kernel_matrix_gpu(
755 data,
756 centers,
757 penalty_order,
758 radians,
759 kernel,
760 ) {
761 let gpu_matrix = gpu_result.map_err(|err| {
762 BasisError::InvalidInput(format!(
763 "spherical spline GPU truncated kernel was admitted but failed on device: {err}"
764 ))
765 })?;
766 return Ok(gpu_matrix);
767 }
768 spherical_wahba_kernel_matrix_cpu_validated(data, centers, penalty_order, radians, kernel)
769}
770
771pub fn spherical_wahba_kernel_matrix_cpu(
780 data: ArrayView2<'_, f64>,
781 centers: ArrayView2<'_, f64>,
782 penalty_order: usize,
783 radians: bool,
784 kernel: SphereWahbaKernel,
785) -> Result<Array2<f64>, BasisError> {
786 validate_spherical_wahba_gram_request(penalty_order, kernel)?;
787 validate_lat_lon_matrix(data, "spherical spline data", radians)?;
788 validate_lat_lon_matrix(centers, "spherical spline centers", radians)?;
789 spherical_wahba_kernel_matrix_cpu_validated(data, centers, penalty_order, radians, kernel)
790}
791
792fn spherical_wahba_kernel_matrix_cpu_validated(
793 data: ArrayView2<'_, f64>,
794 centers: ArrayView2<'_, f64>,
795 penalty_order: usize,
796 radians: bool,
797 kernel: SphereWahbaKernel,
798) -> Result<Array2<f64>, BasisError> {
799 let n = data.nrows();
800 let k = centers.nrows();
801 let deg = if radians {
802 1.0
803 } else {
804 std::f64::consts::PI / 180.0
805 };
806 let mut sin_lat_c = Vec::<f64>::with_capacity(k);
813 let mut cos_lat_c = Vec::<f64>::with_capacity(k);
814 let mut sin_lon_c = Vec::<f64>::with_capacity(k);
815 let mut cos_lon_c = Vec::<f64>::with_capacity(k);
816 for c in centers.outer_iter() {
817 let trig = SphereTrig::from_radians(c[0] * deg, c[1] * deg);
818 sin_lat_c.push(trig.sin_lat);
819 cos_lat_c.push(trig.cos_lat);
820 sin_lon_c.push(trig.sin_lon);
821 cos_lon_c.push(trig.cos_lon);
822 }
823 let mut out = Array2::<f64>::zeros((n, k));
824 let err_flag = std::sync::atomic::AtomicBool::new(false);
825 out.axis_chunks_iter_mut(ndarray::Axis(0), 256)
826 .into_par_iter()
827 .enumerate()
828 .for_each(|(chunk_idx, mut block)| {
829 use wide::f64x4;
830 let row_offset = chunk_idx * 256;
831 let chunks = k / 4;
832 let tail = k % 4;
833 for (local_i, mut out_row) in block.outer_iter_mut().enumerate() {
834 let i = row_offset + local_i;
835 let row = SphereTrig::from_radians(data[(i, 0)] * deg, data[(i, 1)] * deg);
836 let row_v = SphereTrig {
837 sin_lat: f64x4::from(row.sin_lat),
838 cos_lat: f64x4::from(row.cos_lat),
839 sin_lon: f64x4::from(row.sin_lon),
840 cos_lon: f64x4::from(row.cos_lon),
841 };
842 for cidx in 0..chunks {
844 let base = cidx * 4;
845 let center_v = SphereTrig {
846 sin_lat: f64x4::from([
847 sin_lat_c[base],
848 sin_lat_c[base + 1],
849 sin_lat_c[base + 2],
850 sin_lat_c[base + 3],
851 ]),
852 cos_lat: f64x4::from([
853 cos_lat_c[base],
854 cos_lat_c[base + 1],
855 cos_lat_c[base + 2],
856 cos_lat_c[base + 3],
857 ]),
858 sin_lon: f64x4::from([
859 sin_lon_c[base],
860 sin_lon_c[base + 1],
861 sin_lon_c[base + 2],
862 sin_lon_c[base + 3],
863 ]),
864 cos_lon: f64x4::from([
865 cos_lon_c[base],
866 cos_lon_c[base + 1],
867 cos_lon_c[base + 2],
868 cos_lon_c[base + 3],
869 ]),
870 };
871 let (u, v) = half_angle_separation(row_v, center_v);
872 let vals = wahba_sphere_kernel_simd_kind(u, v, penalty_order, kernel);
873 let arr = vals.to_array();
874 for lane in 0..4 {
875 if !arr[lane].is_finite() {
876 err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
877 return;
878 }
879 out_row[base + lane] = arr[lane];
880 }
881 }
882 let tail_start = chunks * 4;
884 for t in 0..tail {
885 let j = tail_start + t;
886 let center = SphereTrig {
887 sin_lat: sin_lat_c[j],
888 cos_lat: cos_lat_c[j],
889 sin_lon: sin_lon_c[j],
890 cos_lon: cos_lon_c[j],
891 };
892 let sep = half_angle_separation_scalar(row, center);
893 match wahba_sphere_kernel_kind(sep, penalty_order, kernel) {
894 Ok(v) => out_row[j] = v,
895 Err(_) => {
896 err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
897 return;
898 }
899 }
900 }
901 }
902 });
903 if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
904 crate::bail_invalid_basis!("spherical spline kernel produced a non-finite value");
905 }
906 Ok(out)
907}
908
909#[cfg(test)]
910mod spherical_wahba_kernel_contract_2475_tests {
911 use super::*;
912 use ndarray::array;
913
914 fn assert_sobolev_m1_refusal(entry_point: &str, result: Result<Array2<f64>, BasisError>) {
915 let error = result.expect_err("untruncated Sobolev m=1 has no Gram diagonal");
916 let message = error.to_string();
917 assert!(
918 message.contains("log-singular") && message.contains("SobolevTruncated"),
919 "{entry_point} must identify both the mathematical defect and the explicit-resolution \
920 remedy; got: {message}"
921 );
922 }
923
924 #[test]
925 fn all_public_matrix_entry_points_refuse_untruncated_sobolev_m1() {
926 let data = array![[0.0, 0.0]];
929 let centers = array![[35.0, 70.0]];
930
931 assert_sobolev_m1_refusal(
932 "spherical_wahba_kernel_matrix",
933 spherical_wahba_kernel_matrix(data.view(), centers.view(), 1, false),
934 );
935 assert_sobolev_m1_refusal(
936 "spherical_wahba_kernel_matrix_with_kind",
937 spherical_wahba_kernel_matrix_with_kind(
938 data.view(),
939 centers.view(),
940 1,
941 false,
942 SphereWahbaKernel::Sobolev,
943 ),
944 );
945 assert_sobolev_m1_refusal(
946 "spherical_wahba_kernel_matrix_cpu",
947 spherical_wahba_kernel_matrix_cpu(
948 data.view(),
949 centers.view(),
950 1,
951 false,
952 SphereWahbaKernel::Sobolev,
953 ),
954 );
955 }
956
957 #[test]
958 fn explicit_resolution_and_finite_diagonal_m1_kernels_remain_available() {
959 let point = array![[0.0, 0.0]];
960
961 let pseudo = spherical_wahba_kernel_matrix_with_kind(
962 point.view(),
963 point.view(),
964 1,
965 false,
966 SphereWahbaKernel::Pseudo,
967 )
968 .expect("pseudo-Wahba m=1 has a finite analytic coincident-point value");
969 assert_eq!(
970 pseudo[(0, 0)],
971 1.0 / (4.0 * std::f64::consts::PI),
972 "the refusal must not absorb valid pseudo-Wahba m=1"
973 );
974
975 let truncated = spherical_wahba_kernel_matrix_with_kind(
976 point.view(),
977 point.view(),
978 1,
979 false,
980 SphereWahbaKernel::SobolevTruncated { lmax: 16 },
981 )
982 .expect("explicitly truncated Sobolev m=1 has a stated finite resolution");
983 assert!(
984 truncated[(0, 0)].is_finite(),
985 "a stated spectral resolution must produce a finite Gram diagonal"
986 );
987
988 spherical_wahba_kernel_matrix(point.view(), point.view(), 2, false)
989 .expect("untruncated Sobolev m=2 has a finite closed-form diagonal");
990 }
991}
992
993pub(crate) fn weighted_coefficient_sum_to_zero_transform(
994 weights: ArrayView1<'_, f64>,
995) -> Result<Array2<f64>, BasisError> {
996 let k = weights.len();
997 if k < 2 {
998 return Err(BasisError::InsufficientColumnsForConstraint { found: k });
999 }
1000 if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
1001 crate::bail_invalid_basis!(
1002 "sphere coefficient constraint weights must be finite and non-negative"
1003 );
1004 }
1005 let norm = weights.iter().map(|w| w * w).sum::<f64>().sqrt();
1006 if norm <= 0.0 {
1007 crate::bail_invalid_basis!("sphere coefficient constraint weights cannot all be zero");
1008 }
1009 let c = Array2::from_shape_vec((k, 1), weights.iter().map(|w| *w / norm).collect())
1010 .map_err(|e| BasisError::InvalidInput(format!("invalid sphere constraint weights: {e}")))?;
1011 let (z, rank) =
1012 rrqr_nullspace_basis(&c, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
1013 if rank >= k {
1014 return Err(BasisError::ConstraintNullspaceCollapsed {
1015 site: "weighted_coefficient_sum_to_zero_transform",
1016 cross_rank: rank,
1017 coeff_dim: k,
1018 cross_frobenius: 1.0,
1019 gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
1020 .to_string(),
1021 });
1022 }
1023 Ok(z)
1024}
1025
1026const SPHERICAL_CENTER_COINCIDENT_TOL: f64 = 1.0e-12;
1027
1028#[inline]
1029fn spherical_center_dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
1030 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1031}
1032
1033fn resolve_spherical_profile_tie<F>(
1046 units: &[[f64; 3]],
1047 tied: &[usize],
1048 on_profile_builds: &mut F,
1049) -> Vec<usize>
1050where
1051 F: FnMut(usize),
1052{
1053 resolve_sorted_profile_tie(
1054 units.len(),
1055 tied,
1056 |anchor, row| spherical_center_dot(&units[anchor], &units[row]),
1057 on_profile_builds,
1058 )
1059}
1060
1061fn distinct_spherical_orbit(
1066 units: &[[f64; 3]],
1067 candidates: &[usize],
1068 already_selected: &[usize],
1069) -> Vec<usize> {
1070 let mut distinct = Vec::with_capacity(candidates.len());
1071 'candidate: for &candidate in candidates {
1072 for &selected in already_selected.iter().chain(distinct.iter()) {
1073 if spherical_center_dot(&units[candidate], &units[selected])
1074 >= 1.0 - SPHERICAL_CENTER_COINCIDENT_TOL
1075 {
1076 continue 'candidate;
1077 }
1078 }
1079 distinct.push(candidate);
1080 }
1081 distinct
1082}
1083
1084pub fn select_spherical_farthest_point_centers(
1135 data: ArrayView2<'_, f64>,
1136 num_centers: usize,
1137 radians: bool,
1138) -> Result<Array2<f64>, BasisError> {
1139 let chosen = select_spherical_farthest_point_center_rows(data, num_centers, radians)?;
1140 log::debug!(
1141 "spherical farthest-point centers: {} of {} rows, {} sorted dot profile(s) built",
1142 chosen.rows.len(),
1143 data.nrows(),
1144 chosen.profile_builds
1145 );
1146 Ok(Array2::from_shape_fn((chosen.rows.len(), 2), |(r, c)| {
1149 data[[chosen.rows[r], c]]
1150 }))
1151}
1152
1153pub(crate) struct SphericalCenterSelection {
1156 pub(crate) rows: Vec<usize>,
1157 pub(crate) profile_builds: usize,
1162}
1163
1164fn select_spherical_farthest_point_center_rows(
1165 data: ArrayView2<'_, f64>,
1166 num_centers: usize,
1167 radians: bool,
1168) -> Result<SphericalCenterSelection, BasisError> {
1169 use rayon::prelude::*;
1170 let mut profile_builds = 0usize;
1171 validate_lat_lon_matrix(data, "spherical farthest-point centers", radians)?;
1172 if num_centers == 0 {
1173 crate::bail_invalid_basis!("spherical farthest-point center count must be positive");
1174 }
1175 let n = data.nrows();
1176 if n < 2 {
1177 return Err(BasisError::InsufficientColumnsForConstraint { found: n });
1178 }
1179 if num_centers > n {
1180 crate::bail_invalid_basis!(
1181 "requested {num_centers} spherical farthest-point centers but only {n} rows are available"
1182 );
1183 }
1184
1185 let to_rad = if radians {
1186 1.0
1187 } else {
1188 std::f64::consts::PI / 180.0
1189 };
1190 let units: Vec<[f64; 3]> = (0..n)
1195 .into_par_iter()
1196 .map(|i| {
1197 let lat = data[[i, 0]] * to_rad;
1198 let lon = data[[i, 1]] * to_rad;
1199 let cos_lat = lat.cos();
1200 [cos_lat * lon.cos(), cos_lat * lon.sin(), lat.sin()]
1201 })
1202 .collect();
1203 let mut sum = [0.0_f64; 3];
1213 for (c, sum_c) in sum.iter_mut().enumerate() {
1214 let mut col: Vec<f64> = units.par_iter().map(|u| u[c]).collect();
1215 col.par_sort_by(|a, b| a.total_cmp(b));
1219 *sum_c = col.iter().sum();
1220 }
1221 let dot_to_sum: Vec<f64> = units
1222 .par_iter()
1223 .map(|u| spherical_center_dot(u, &sum))
1224 .collect();
1225
1226 let seed_key = dot_to_sum.par_iter().copied().reduce(
1233 || f64::NEG_INFINITY,
1234 |a, b| if b.total_cmp(&a).is_gt() { b } else { a },
1235 );
1236 let seed_tied: Vec<usize> = (0..n)
1237 .into_par_iter()
1238 .filter(|&i| dot_to_sum[i].total_cmp(&seed_key).is_eq())
1239 .collect();
1240
1241 let target = num_centers;
1242 let seed_class = resolve_spherical_profile_tie(&units, &seed_tied, &mut |built: usize| {
1243 profile_builds += built;
1244 });
1245 let seed_orbit = distinct_spherical_orbit(&units, &seed_class, &[]);
1246 if seed_orbit.len() > target {
1247 crate::bail_invalid_basis!(
1248 "spherical farthest-point seed symmetry orbit has {} distinct directions, exceeding the requested center budget {target}; use a budget at least as large as the orbit or the harmonic sphere basis",
1249 seed_orbit.len()
1250 );
1251 }
1252
1253 let mut selected = Vec::with_capacity(target);
1254 let mut chosen = vec![false; n];
1255 let mut max_dot = vec![f64::NEG_INFINITY; n];
1259 for &i in &seed_class {
1260 chosen[i] = true;
1261 }
1262 selected.extend(seed_orbit);
1263 max_dot.par_iter_mut().enumerate().for_each(|(i, slot)| {
1264 *slot = selected
1265 .iter()
1266 .map(|¢er| spherical_center_dot(&units[i], &units[center]))
1267 .fold(f64::NEG_INFINITY, f64::max);
1268 });
1269
1270 while selected.len() < target {
1276 let cheap_key = (0..n)
1292 .into_par_iter()
1293 .filter(|&i| !chosen[i])
1294 .map(|i| (max_dot[i], dot_to_sum[i]))
1295 .reduce(
1296 || (f64::INFINITY, f64::INFINITY),
1297 |a, b| {
1298 if b.0.total_cmp(&a.0).then(b.1.total_cmp(&a.1)).is_lt() {
1299 b
1300 } else {
1301 a
1302 }
1303 },
1304 );
1305 let cheap_tied: Vec<usize> = (0..n)
1306 .into_par_iter()
1307 .filter(|&i| {
1308 !chosen[i]
1309 && max_dot[i].total_cmp(&cheap_key.0).is_eq()
1310 && dot_to_sum[i].total_cmp(&cheap_key.1).is_eq()
1311 })
1312 .collect();
1313 if cheap_tied.is_empty() {
1314 break;
1315 }
1316 if cheap_key.0 >= 1.0 - SPHERICAL_CENTER_COINCIDENT_TOL {
1317 break;
1318 }
1319
1320 let tied_class = resolve_spherical_profile_tie(&units, &cheap_tied, &mut |built: usize| {
1321 profile_builds += built;
1322 });
1323 let orbit = distinct_spherical_orbit(&units, &tied_class, &selected);
1324 let remaining = target - selected.len();
1325 if orbit.len() > remaining {
1326 crate::bail_invalid_basis!(
1327 "spherical farthest-point tie class has {} distinct directions but only {remaining} of the exact {target}-center budget remain; choose a compatible center count or the harmonic sphere basis",
1328 orbit.len(),
1329 );
1330 }
1331 for &i in &tied_class {
1332 chosen[i] = true;
1333 }
1334 if orbit.is_empty() {
1335 continue;
1336 }
1337 selected.extend(orbit.iter().copied());
1338 let chosen_ref = &chosen;
1339 let orbit_ref = &orbit;
1340 max_dot.par_iter_mut().enumerate().for_each(|(i, slot)| {
1341 if chosen_ref[i] {
1342 return;
1343 }
1344 for ¢er in orbit_ref {
1345 let d = spherical_center_dot(&units[i], &units[center]);
1346 if d > *slot {
1347 *slot = d;
1348 }
1349 }
1350 });
1351 }
1352
1353 if selected.len() < target {
1354 crate::bail_invalid_basis!(
1355 "requested {target} distinct spherical farthest-point centers but the data contain only {} numerically distinct directions",
1356 selected.len()
1357 );
1358 }
1359 if selected.len() < 2 {
1360 return Err(BasisError::InsufficientColumnsForConstraint {
1361 found: selected.len(),
1362 });
1363 }
1364
1365 Ok(SphericalCenterSelection {
1366 rows: selected,
1367 profile_builds,
1368 })
1369}
1370
1371#[cfg(test)]
1372mod spherical_farthest_point_symmetry_tests {
1373 use super::*;
1374 use ndarray::{Array2, array};
1375
1376 fn permute_rows(data: &Array2<f64>, order: &[usize]) -> Array2<f64> {
1377 Array2::from_shape_fn((order.len(), 2), |(row, col)| data[[order[row], col]])
1378 }
1379
1380 fn sorted_center_rows(centers: &Array2<f64>) -> Vec<[f64; 2]> {
1381 let mut rows: Vec<[f64; 2]> = centers.outer_iter().map(|row| [row[0], row[1]]).collect();
1382 rows.sort_by(|a, b| a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1])));
1383 rows
1384 }
1385
1386 #[test]
1392 fn symmetric_tie_orbit_is_completed_under_every_row_permutation() {
1393 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1394 let permutations = [[0_usize, 1, 2, 3], [0, 1, 3, 2], [2, 0, 3, 1], [3, 1, 2, 0]];
1395
1396 let mut reference: Option<Vec<[f64; 2]>> = None;
1397 for order in permutations {
1398 let permuted = permute_rows(&data, &order);
1399 let centers = select_spherical_farthest_point_centers(permuted.view(), 3, false)
1400 .expect("the complete three-direction symmetry orbit is representable");
1401 assert_eq!(
1402 centers.nrows(),
1403 3,
1404 "the exact three-center target must contain the complete north/south tie class"
1405 );
1406 let center_set = sorted_center_rows(¢ers);
1407 if let Some(expected) = &reference {
1408 assert_eq!(
1409 ¢er_set, expected,
1410 "selected physical center set changed under row permutation"
1411 );
1412 } else {
1413 reference = Some(center_set);
1414 }
1415 }
1416 }
1417
1418 #[test]
1419 fn incomplete_nonseed_tie_class_is_refused() {
1420 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1421 let error = select_spherical_farthest_point_centers(data.view(), 2, false)
1422 .expect_err("one remaining slot cannot split the north/south tie class");
1423 assert!(
1424 error
1425 .to_string()
1426 .contains("only 1 of the exact 2-center budget remain"),
1427 "unexpected refusal: {error}"
1428 );
1429 }
1430
1431 #[test]
1436 fn symmetry_orbit_larger_than_center_budget_is_refused() {
1437 let antipodal = array![[90.0_f64, 0.0], [-90.0, 0.0]];
1438 let error = select_spherical_farthest_point_centers(antipodal.view(), 1, false)
1439 .expect_err("a two-direction seed orbit cannot fit a one-center budget");
1440 assert!(
1441 error.to_string().contains("symmetry orbit"),
1442 "unexpected refusal: {error}"
1443 );
1444 }
1445
1446 fn latlon_grid(n_lat: usize, n_lon: usize) -> Array2<f64> {
1449 Array2::from_shape_fn((n_lat * n_lon, 2), |(row, col)| {
1450 let (i, j) = (row / n_lon, row % n_lon);
1451 if col == 0 {
1452 -85.0 + (170.0 * i as f64) / (n_lat.saturating_sub(1).max(1) as f64)
1453 } else {
1454 -180.0 + (360.0 * j as f64) / (n_lon.saturating_sub(1).max(1) as f64)
1455 }
1456 })
1457 }
1458
1459 fn latlon_cloud(n: usize) -> Array2<f64> {
1462 let mut state = 0x2545_F491_4F6C_DD1D_u64;
1463 let mut next = move || {
1464 state ^= state << 13;
1465 state ^= state >> 7;
1466 state ^= state << 17;
1467 (state >> 11) as f64 / (1u64 << 53) as f64
1468 };
1469 let draws: Vec<f64> = (0..2 * n).map(|_| next()).collect();
1470 Array2::from_shape_fn((n, 2), |(row, col)| {
1471 if col == 0 {
1472 (1.0 - 2.0 * draws[2 * row]).asin().to_degrees()
1473 } else {
1474 360.0 * draws[2 * row + 1] - 180.0
1475 }
1476 })
1477 }
1478
1479 #[test]
1491 fn spherical_center_selection_costs_no_profile_without_an_exact_tie() {
1492 for n in [2_000_usize, 8_000] {
1493 for m in [40_usize, 200] {
1494 let data = latlon_cloud(n);
1495 let chosen = select_spherical_farthest_point_center_rows(data.view(), m, false)
1496 .expect("an asymmetric cloud admits any center budget below n");
1497 assert_eq!(chosen.rows.len(), m, "exact center budget (n={n}, m={m})");
1498 assert_eq!(
1499 chosen.profile_builds, 0,
1500 "no row can tie the maximin key exactly on an asymmetric cloud, so the \
1501 profile tie-break must never be built (n={n}, m={m})"
1502 );
1503 }
1504 }
1505 }
1506
1507 #[test]
1515 fn spherical_center_profile_cost_does_not_scale_with_the_row_count() {
1516 for (n_lat, n_lon) in [(40_usize, 40_usize), (160, 160)] {
1517 for m in [40_usize, 200] {
1518 let data = latlon_grid(n_lat, n_lon);
1519 let n = data.nrows();
1520 let chosen = select_spherical_farthest_point_center_rows(data.view(), m, false)
1521 .expect("a lat/lon grid admits these center budgets");
1522 assert_eq!(chosen.rows.len(), m, "exact center budget (n={n}, m={m})");
1523 assert!(
1524 chosen.profile_builds < m,
1525 "profile-key builds must stay below one per selected center; got {} at \
1526 n={n} m={m} (the replaced incumbent scan built at least {})",
1527 chosen.profile_builds,
1528 2 * m
1529 );
1530 }
1531 }
1532 }
1533
1534 #[test]
1538 fn spherical_center_profile_key_is_still_built_where_it_decides_an_orbit() {
1539 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1540 let chosen = select_spherical_farthest_point_center_rows(data.view(), 3, false)
1541 .expect("the complete three-direction symmetry orbit is representable");
1542 assert!(
1543 chosen.profile_builds > 0,
1544 "the north/south orbit is only provable through the invariant profile key"
1545 );
1546 }
1547
1548 #[test]
1554 fn polar_ring_selection_is_row_order_blind_at_every_budget() {
1555 let ring: Vec<[f64; 2]> = [-30.0_f64, 30.0]
1557 .into_iter()
1558 .flat_map(|lat| (0..6).map(move |j| [lat, -180.0 + 60.0 * j as f64]))
1559 .collect();
1560 let data = Array2::from_shape_fn((ring.len(), 2), |(r, c)| ring[r][c]);
1561 let n = data.nrows();
1562
1563 for budget in 2..=n {
1564 let reference = select_spherical_farthest_point_centers(data.view(), budget, false)
1565 .map(|centers| sorted_center_rows(¢ers));
1566 for order in [
1567 (0..n).rev().collect::<Vec<usize>>(),
1568 (0..n).map(|i| (5 * i + 7) % n).collect::<Vec<usize>>(),
1569 (0..n)
1570 .step_by(5)
1571 .chain((1..n).step_by(5))
1572 .collect::<Vec<usize>>(),
1573 ] {
1574 if order.len() != n {
1575 continue;
1576 }
1577 let permuted = permute_rows(&data, &order);
1578 let got = select_spherical_farthest_point_centers(permuted.view(), budget, false)
1579 .map(|centers| sorted_center_rows(¢ers));
1580 match (&reference, &got) {
1581 (Ok(expected), Ok(actual)) => assert_eq!(
1582 actual, expected,
1583 "budget {budget}: selected physical directions changed under a row \
1584 permutation of a symmetric ring"
1585 ),
1586 (Err(a), Err(b)) => assert_eq!(
1587 a.to_string(),
1588 b.to_string(),
1589 "budget {budget}: refusal changed under a row permutation"
1590 ),
1591 _ => panic!(
1592 "budget {budget}: row order decided whether the request was \
1593 representable ({reference:?} vs {got:?})"
1594 ),
1595 }
1596 }
1597 }
1598 }
1599}
1600
1601#[cfg(test)]
1602mod matern_function_metric_tests {
1603 use super::*;
1604 use ndarray::array;
1605
1606 #[test]
1607 fn center_metric_null_ridge_is_covariant_and_targets_only_intercept_function() {
1608 let center_kernel = array![[1.4, 0.3, 0.1], [0.3, 1.2, 0.2], [0.1, 0.2, 1.1]];
1609 let mut embedded = Array2::<f64>::zeros((4, 4));
1610 embedded.slice_mut(s![0..3, 0..3]).assign(¢er_kernel);
1611 let gram =
1612 matern_center_function_gram(&embedded, true, None).expect("raw center function Gram");
1613 let base =
1614 matern_double_penalty_candidates(&embedded, &gram, true).expect("raw candidates");
1615 assert_eq!(base.len(), 2);
1616 let raw_ridge = base[1].matrix.dense() * base[1].normalization_scale;
1617
1618 let intercept = array![[0.0], [0.0], [0.0], [1.0]];
1619 let action_error = (&raw_ridge.dot(&intercept) - &gram.dot(&intercept))
1620 .iter()
1621 .map(|value| value.abs())
1622 .fold(0.0_f64, f64::max);
1623 assert!(
1624 action_error < 2.0e-13,
1625 "ridge must equal G on the structural intercept; error={action_error:.3e}"
1626 );
1627
1628 let transform = array![
1633 [0.2, 0.5, 0.0, 0.0],
1634 [0.0, 3.0, -0.4, 0.0],
1635 [0.0, 0.0, 1.7, 0.0],
1636 [0.0, 0.0, 0.0, 2.5]
1637 ];
1638 let primary_t = fast_atb(&transform, &fast_ab(&embedded, &transform));
1639 let gram_t = matern_center_function_gram(&embedded, true, Some(&transform))
1640 .expect("transformed center function Gram");
1641 let transformed = matern_double_penalty_candidates(&primary_t, &gram_t, true)
1642 .expect("transformed candidates");
1643 let ridge_t = transformed[1].matrix.dense() * transformed[1].normalization_scale;
1644 let expected = fast_atb(&transform, &fast_ab(&raw_ridge, &transform));
1645 let covariance_error = (&ridge_t - &expected)
1646 .iter()
1647 .map(|value| value.abs())
1648 .fold(0.0_f64, f64::max);
1649 assert!(
1650 covariance_error < 2.0e-12,
1651 "Matérn function ridge changed under a basis chart; error={covariance_error:.3e}"
1652 );
1653
1654 let no_intercept_gram = matern_center_function_gram(
1655 ¢er_kernel,
1656 false,
1657 Some(&transform.slice(s![0..3, 0..3]).to_owned()),
1658 )
1659 .expect("kernel-only Gram");
1660 let kernel_only = matern_double_penalty_candidates(
1661 &fast_atb(
1662 &transform.slice(s![0..3, 0..3]).to_owned(),
1663 &fast_ab(¢er_kernel, &transform.slice(s![0..3, 0..3]).to_owned()),
1664 ),
1665 &no_intercept_gram,
1666 false,
1667 )
1668 .expect("kernel-only candidates");
1669 assert_eq!(kernel_only.len(), 1, "an SPD kernel has no null ridge");
1670 }
1671}
1672
1673pub fn auto_streaming_chunk_size_for_dense(n_rows: usize, n_basis_cols: usize) -> Option<usize> {
1683 if n_rows == 0 || n_basis_cols == 0 {
1684 return None;
1685 }
1686 const DENSE_THRESHOLD_BYTES: usize = 1024 * 1024 * 1024;
1687 const TARGET_CHUNK_BYTES: usize = 256 * 1024 * 1024;
1688 const MIN_CHUNK_ROWS: usize = 1024;
1689 let dense_bytes = n_rows.saturating_mul(n_basis_cols).saturating_mul(8);
1690 if dense_bytes <= DENSE_THRESHOLD_BYTES {
1691 return None;
1692 }
1693 let row_bytes = n_basis_cols.saturating_mul(8).max(1);
1694 let raw_chunk = TARGET_CHUNK_BYTES / row_bytes;
1695 let clamped = raw_chunk.max(MIN_CHUNK_ROWS).min(n_rows);
1696 Some(clamped)
1697}