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 degraded = effective_order != order;
182 let k = centers.nrows();
195 let d = centers.ncols();
196 let center_mean: Vec<f64> = (0..d)
197 .map(|c| centers.column(c).sum() / (k.max(1) as f64))
198 .collect();
199 let mut centers_centered = centers.to_owned();
200 for c in 0..d {
201 let mu = center_mean[c];
202 centers_centered.column_mut(c).mapv_inplace(|v| v - mu);
203 }
204 let centers = centers_centered.view();
205 let key = ConstraintNullspaceCacheKey {
206 centersrows: centers.nrows(),
207 centers_cols: centers.ncols(),
208 centers_hash: hash_arrayview2(centers),
209 order: ConstraintNullspaceOrderKey::Duchon(effective_order),
210 };
211
212 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
213 return Ok((**hit).clone());
214 }
215
216 let p_k = polynomial_block_from_order(centers, effective_order);
217 let z = Arc::new(kernel_constraint_nullspace_from_matrix(p_k.view()).map_err(|err| {
218 if degraded {
219 BasisError::InvalidInput(format!(
220 "Duchon degraded from order={:?} to order={:?} due to insufficient centers ({} in dim={}); order={:?} construction then failed: {err}",
221 order,
222 effective_order,
223 centers.nrows(),
224 centers.ncols(),
225 effective_order,
226 ))
227 } else {
228 err
229 }
230 })?);
231
232 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
233 return Ok((**hit).clone());
234 }
235 cache.constraint_nullspace.map.insert(key, z.clone());
236 cache.constraint_nullspace.order.push(key);
237 while cache.constraint_nullspace.map.len() > CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES {
238 if cache.constraint_nullspace.order.is_empty() {
239 break;
240 }
241 let oldkey = cache.constraint_nullspace.order.remove(0);
242 cache.constraint_nullspace.map.remove(&oldkey);
243 }
244
245 Ok((*z).clone())
246}
247
248pub(crate) fn thin_plate_kernel_constraint_nullspace(
249 centers: ArrayView2<'_, f64>,
250 cache: &mut BasisCacheContext,
251) -> Result<Array2<f64>, BasisError> {
252 let key = ConstraintNullspaceCacheKey {
253 centersrows: centers.nrows(),
254 centers_cols: centers.ncols(),
255 centers_hash: hash_arrayview2(centers),
256 order: ConstraintNullspaceOrderKey::ThinPlate,
257 };
258
259 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
260 return Ok((**hit).clone());
261 }
262
263 let p_k = thin_plate_polynomial_block(centers);
264 if centers.nrows() < p_k.ncols() {
265 crate::bail_invalid_basis!(
266 "thin-plate spline requires at least {} centers to span the degree-{} polynomial null space in dimension {}; got {}",
267 p_k.ncols(),
268 thin_plate_polynomial_degree(centers.ncols()),
269 centers.ncols(),
270 centers.nrows()
271 );
272 }
273 let (z, rank) =
274 rrqr_nullspace_basis(&p_k, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
275 if rank != p_k.ncols() {
276 crate::bail_invalid_basis!(
277 "thin-plate spline polynomial block is rank deficient at the selected centers: expected rank {}, got {}; choose geometrically independent centers for dimension {}",
278 p_k.ncols(),
279 rank,
280 centers.ncols()
281 );
282 }
283 let z = Arc::new(z);
284
285 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
286 return Ok((**hit).clone());
287 }
288 cache.constraint_nullspace.map.insert(key, z.clone());
289 cache.constraint_nullspace.order.push(key);
290 while cache.constraint_nullspace.map.len() > CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES {
291 if cache.constraint_nullspace.order.is_empty() {
292 break;
293 }
294 let oldkey = cache.constraint_nullspace.order.remove(0);
295 cache.constraint_nullspace.map.remove(&oldkey);
296 }
297
298 Ok((*z).clone())
299}
300
301pub(crate) fn matern_identifiability_transform(
302 centers: ArrayView2<'_, f64>,
303 identifiability: &MaternIdentifiability,
304) -> Result<Option<Array2<f64>>, BasisError> {
305 let k = centers.nrows();
306 match identifiability {
307 MaternIdentifiability::None => Ok(None),
308 MaternIdentifiability::CenterSumToZero => {
309 let q = Array2::<f64>::ones((k, 1));
310 Ok(Some(kernel_constraint_nullspace_from_matrix(q.view())?))
311 }
312 MaternIdentifiability::CenterLinearOrthogonal => {
313 let effective_order =
318 duchon_effective_nullspace_order(centers, DuchonNullspaceOrder::Linear);
319 let q = polynomial_block_from_order(centers, effective_order);
320 Ok(Some(kernel_constraint_nullspace_from_matrix(q.view())?))
321 }
322 MaternIdentifiability::FrozenTransform { transform, .. } => {
323 if transform.nrows() != k {
324 crate::bail_dim_basis!(
325 "frozen Matérn identifiability transform mismatch: centers={k}, transform rows={}",
326 transform.nrows()
327 );
328 }
329 Ok(Some(transform.clone()))
330 }
331 }
332}
333
334pub(crate) fn build_matern_operator_penalty_candidates(
335 centers: ArrayView2<'_, f64>,
336 length_scale: f64,
337 nu: MaternNu,
338 include_intercept: bool,
339 z_opt: Option<&Array2<f64>>,
340 aniso_log_scales: Option<&[f64]>,
341) -> Result<Vec<PenaltyCandidate>, BasisError> {
342 let ops = build_matern_collocation_operator_matrices(
343 centers,
344 None,
345 length_scale,
346 nu,
347 include_intercept,
348 z_opt.map(|z| z.view()),
349 aniso_log_scales,
350 )?;
351 let matern_spec = DuchonOperatorPenaltySpec::matern_for_smoothness(nu, centers.ncols());
355 operator_penalty_candidates_from_collocation(&ops.d0, &ops.d1, &ops.d2, &matern_spec)
356}
357
358fn matrix_all_finite(m: &Array2<f64>) -> bool {
360 m.iter().all(|v| v.is_finite())
361}
362
363pub(crate) fn matern_center_function_gram(
371 embedded_kernel: &Array2<f64>,
372 include_intercept: bool,
373 full_transform: Option<&Array2<f64>>,
374) -> Result<Array2<f64>, BasisError> {
375 if embedded_kernel.nrows() != embedded_kernel.ncols() {
376 crate::bail_dim_basis!("Matérn embedded kernel penalty must be square");
377 }
378 let total = embedded_kernel.nrows();
379 let k = total
380 .checked_sub(usize::from(include_intercept))
381 .ok_or_else(|| BasisError::InvalidInput("Matérn basis width underflow".to_string()))?;
382 if k == 0 {
383 crate::bail_invalid_basis!("Matérn function metric requires at least one center");
384 }
385 let mut center_design = Array2::<f64>::zeros((k, total));
386 center_design
387 .slice_mut(s![.., 0..k])
388 .assign(&embedded_kernel.slice(s![0..k, 0..k]));
389 if include_intercept {
390 center_design.column_mut(k).fill(1.0);
391 }
392 let center_design = match full_transform {
393 Some(transform) => fast_ab(¢er_design, transform),
394 None => center_design,
395 };
396 Ok(symmetrize_penalty(&fast_ata(¢er_design)))
397}
398
399pub(crate) fn matern_double_penalty_candidates(
400 primary: &Array2<f64>,
401 function_gram: &Array2<f64>,
402 include_intercept: bool,
403) -> Result<Vec<PenaltyCandidate>, BasisError> {
404 if !matrix_all_finite(primary) {
410 crate::bail_invalid_basis!(
411 "Matérn double-penalty primary kernel Gram is non-finite; the projected \
412 kernel `Zᵀ K Z` could not be formed at this length scale (degenerate \
413 geometry). Widen the data spread, change the length scale, or drop the term."
414 );
415 }
416 if primary.dim() != function_gram.dim() || !matrix_all_finite(function_gram) {
417 crate::bail_invalid_basis!(
418 "Matérn center function Gram is non-finite or does not match the primary penalty"
419 );
420 }
421 let mut candidates = vec![normalize_penalty_candidate(
422 primary.clone(),
423 PenaltySource::Primary,
424 )?];
425 if include_intercept {
431 let p = primary.nrows();
432 let mut intercept_frame = Array2::<f64>::zeros((p, 1));
433 intercept_frame[[p - 1, 0]] = 1.0;
434 let shrinkage = function_space_subspace_shrinkage(&intercept_frame, function_gram)?;
435 candidates.push(normalize_penalty_candidate(
436 shrinkage,
437 PenaltySource::DoublePenaltyNullspace,
438 )?);
439 }
440 Ok(candidates)
441}
442
443pub(crate) fn build_matern_double_penalty_candidates(
444 spline: &MaternSplineBasis,
445 full_transform: Option<&Array2<f64>>,
446) -> Result<Vec<PenaltyCandidate>, BasisError> {
447 let primary = project_penalty_matrix(&spline.penalty_kernel, full_transform);
448 let include_intercept = spline.num_polynomial_basis == 1;
449 let function_gram =
450 matern_center_function_gram(&spline.penalty_kernel, include_intercept, full_transform)?;
451 matern_double_penalty_candidates(&primary, &function_gram, include_intercept)
452}
453
454pub fn create_matern_spline_basiswithworkspace(
467 data: ArrayView2<'_, f64>,
468 centers: ArrayView2<'_, f64>,
469 length_scale: f64,
470 nu: MaternNu,
471 include_intercept: bool,
472 aniso_log_scales: Option<&[f64]>,
473 workspace: &mut BasisWorkspace,
474) -> Result<MaternSplineBasis, BasisError> {
475 let n = data.nrows();
476 let d = data.ncols();
477 let k = centers.nrows();
478 let total_cols = k + usize::from(include_intercept);
479 let dense_bytes = dense_design_bytes(n, total_cols);
480 if dense_bytes > workspace.policy().max_single_materialization_bytes {
481 crate::bail_invalid_basis!(
482 "Matérn basis dense design exceeds resource policy: n={n}, p={total_cols}, dense={:.1} MiB, cap={:.1} MiB",
483 dense_bytes as f64 / (1024.0 * 1024.0),
484 workspace.policy().max_single_materialization_bytes as f64 / (1024.0 * 1024.0),
485 );
486 }
487
488 if d == 0 {
489 crate::bail_invalid_basis!("Matérn basis requires at least one covariate dimension");
490 }
491 if k == 0 {
492 crate::bail_invalid_basis!("Matérn basis requires at least one center");
493 }
494 if centers.ncols() != d {
495 crate::bail_dim_basis!(
496 "Matérn basis dimension mismatch: data has {d} columns, centers have {}",
497 centers.ncols()
498 );
499 }
500 if data.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
501 crate::bail_invalid_basis!("Matérn basis requires finite data and center values");
502 }
503 validate_matern_length_scale(length_scale)?;
504 if let Some(eta) = aniso_log_scales {
505 if eta.len() != d {
506 crate::bail_dim_basis!(
507 "aniso_log_scales length {} does not match data dimension {d}",
508 eta.len()
509 );
510 }
511 if eta.iter().any(|v| !v.is_finite()) {
512 crate::bail_invalid_basis!("aniso_log_scales must contain finite values");
513 }
514 }
515
516 let warn_bounds = if let Some(eta) = aniso_log_scales {
522 let y_centers = points_in_aniso_y_space(centers, eta);
523 pairwise_distance_bounds(y_centers.view())
524 } else {
525 pairwise_distance_bounds(centers)
526 };
527 if let Some((r_min, r_max)) = warn_bounds {
528 let kappa = 1.0 / length_scale.max(1e-300);
529 let kappa_lo = 1e-2 / r_max;
530 let kappa_hi = 1e2 / r_min;
531 if kappa < kappa_lo || kappa > kappa_hi {
532 log::debug!(
533 "Matérn κ={} is outside recommended range [{}, {}] derived from centers (r_min={}, r_max={}); kernel conditioning may degrade",
534 kappa,
535 kappa_lo,
536 kappa_hi,
537 r_min,
538 r_max
539 );
540 }
541 }
542
543 let mut kernel_block = Array2::<f64>::zeros((n, k));
546 let mut center_kernel = Array2::<f64>::zeros((k, k));
547 let axis_scales = aniso_log_scales.map(aniso_axis_scales);
548 let kernel_result: Result<(), BasisError> = kernel_block
549 .axis_iter_mut(Axis(0))
550 .into_par_iter()
551 .enumerate()
552 .try_for_each(|(i, mut row)| {
553 for j in 0..k {
554 let r = if let Some(scales) = axis_scales.as_deref() {
555 aniso_distance_rows_with_scales(data, i, centers, j, scales)
556 } else {
557 euclidean_distance_rows(data, i, centers, j)
558 };
559 row[j] = matern_kernel_from_distance(r, length_scale, nu)?;
560 }
561 Ok(())
562 });
563 kernel_result?;
564 fill_symmetric_from_row_kernel(&mut center_kernel, |i, j| {
567 let r = if let Some(scales) = axis_scales.as_deref() {
568 aniso_distance_rows_with_scales(centers, i, centers, j, scales)
569 } else {
570 euclidean_distance_rows(centers, i, centers, j)
571 };
572 matern_kernel_from_distance(r, length_scale, nu)
573 })?;
574
575 let mut basis = Array2::<f64>::zeros((n, total_cols));
576 basis.slice_mut(s![.., 0..k]).assign(&kernel_block);
577 if include_intercept {
578 basis.column_mut(k).fill(1.0);
579 }
580
581 let mut penalty_kernel = Array2::<f64>::zeros((total_cols, total_cols));
582 penalty_kernel
587 .slice_mut(s![0..k, 0..k])
588 .assign(¢er_kernel);
589 let function_gram = matern_center_function_gram(&penalty_kernel, include_intercept, None)?;
590 let penalty_ridge = if include_intercept {
591 let mut intercept_frame = Array2::<f64>::zeros((total_cols, 1));
592 intercept_frame[[total_cols - 1, 0]] = 1.0;
593 function_space_subspace_shrinkage(&intercept_frame, &function_gram)?
594 } else {
595 Array2::<f64>::zeros((total_cols, total_cols))
596 };
597
598 Ok(MaternSplineBasis {
599 basis,
600 penalty_kernel,
601 penalty_ridge,
602 num_kernel_basis: k,
603 num_polynomial_basis: usize::from(include_intercept),
604 dimension: d,
605 })
606}
607
608#[inline]
609pub(crate) fn validate_lat_lon_matrix(
610 data: ArrayView2<'_, f64>,
611 context: &str,
612 radians: bool,
613) -> Result<(), BasisError> {
614 if data.ncols() != 2 {
615 crate::bail_dim_basis!(
616 "{context} requires exactly two columns: latitude and longitude; got {}",
617 data.ncols()
618 );
619 }
620 if data.nrows() == 0 {
621 crate::bail_invalid_basis!("{context} requires at least one row");
622 }
623 let (lat_lo, lat_hi, unit) = if radians {
624 (
625 -std::f64::consts::FRAC_PI_2,
626 std::f64::consts::FRAC_PI_2,
627 "radians",
628 )
629 } else {
630 (-90.0, 90.0, "degrees")
631 };
632 for (i, row) in data.outer_iter().enumerate() {
633 let lat = row[0];
634 let lon = row[1];
635 if !lat.is_finite() || !lon.is_finite() {
636 crate::bail_invalid_basis!(
637 "{context} requires finite latitude/longitude; row {i} has ({lat}, {lon})"
638 );
639 }
640 if !(lat_lo..=lat_hi).contains(&lat) {
641 crate::bail_invalid_basis!(
642 "{context} latitude must be in [{lat_lo}, {lat_hi}] {unit}; row {i} has {lat}"
643 );
644 }
645 }
646 Ok(())
647}
648
649fn validate_spherical_wahba_gram_request(
650 penalty_order: usize,
651 kernel: SphereWahbaKernel,
652) -> Result<(), BasisError> {
653 if !(1..=4).contains(&penalty_order) {
654 crate::bail_invalid_basis!(
655 "spherical spline penalty_order must be one of 1, 2, 3, 4; got {penalty_order}"
656 );
657 }
658 if matches!(kernel, SphereWahbaKernel::Sobolev) && penalty_order == 1 {
659 crate::bail_invalid_basis!(
664 "the m = 1 Sobolev sphere kernel is log-singular at coincident points, so its Gram \
665 diagonal does not exist and any finite value is a choice of resolution rather than a \
666 limit; use SobolevTruncated {{ lmax }} (the same kernel with the resolution stated, \
667 diagonal ~ ln(lmax)/2pi) or penalty_order >= 2, whose diagonals are finite closed \
668 forms (1/(4pi) at m = 2, (2*zeta3 - 2)/(4pi) at m = 3)"
669 );
670 }
671 Ok(())
672}
673
674pub fn spherical_wahba_kernel_matrix(
680 data: ArrayView2<'_, f64>,
681 centers: ArrayView2<'_, f64>,
682 penalty_order: usize,
683 radians: bool,
684) -> Result<Array2<f64>, BasisError> {
685 spherical_wahba_kernel_matrix_with_kind(
686 data,
687 centers,
688 penalty_order,
689 radians,
690 SphereWahbaKernel::Sobolev,
691 )
692}
693
694pub fn spherical_wahba_kernel_matrix_with_kind(
701 data: ArrayView2<'_, f64>,
702 centers: ArrayView2<'_, f64>,
703 penalty_order: usize,
704 radians: bool,
705 kernel: SphereWahbaKernel,
706) -> Result<Array2<f64>, BasisError> {
707 validate_spherical_wahba_gram_request(penalty_order, kernel)?;
708 validate_lat_lon_matrix(data, "spherical spline data", radians)?;
709 validate_lat_lon_matrix(centers, "spherical spline centers", radians)?;
710 if let Some(gpu_result) = crate::basis::sphere_gpu::try_build_truncated_kernel_matrix_gpu(
718 data,
719 centers,
720 penalty_order,
721 radians,
722 kernel,
723 ) {
724 let gpu_matrix = gpu_result.map_err(|err| {
725 BasisError::InvalidInput(format!(
726 "spherical spline GPU truncated kernel was admitted but failed on device: {err}"
727 ))
728 })?;
729 return Ok(gpu_matrix);
730 }
731 spherical_wahba_kernel_matrix_cpu_validated(data, centers, penalty_order, radians, kernel)
732}
733
734pub fn spherical_wahba_kernel_matrix_cpu(
743 data: ArrayView2<'_, f64>,
744 centers: ArrayView2<'_, f64>,
745 penalty_order: usize,
746 radians: bool,
747 kernel: SphereWahbaKernel,
748) -> Result<Array2<f64>, BasisError> {
749 validate_spherical_wahba_gram_request(penalty_order, kernel)?;
750 validate_lat_lon_matrix(data, "spherical spline data", radians)?;
751 validate_lat_lon_matrix(centers, "spherical spline centers", radians)?;
752 spherical_wahba_kernel_matrix_cpu_validated(data, centers, penalty_order, radians, kernel)
753}
754
755fn spherical_wahba_kernel_matrix_cpu_validated(
756 data: ArrayView2<'_, f64>,
757 centers: ArrayView2<'_, f64>,
758 penalty_order: usize,
759 radians: bool,
760 kernel: SphereWahbaKernel,
761) -> Result<Array2<f64>, BasisError> {
762 let n = data.nrows();
763 let k = centers.nrows();
764 let deg = if radians {
765 1.0
766 } else {
767 std::f64::consts::PI / 180.0
768 };
769 let mut sin_lat_c = Vec::<f64>::with_capacity(k);
776 let mut cos_lat_c = Vec::<f64>::with_capacity(k);
777 let mut sin_lon_c = Vec::<f64>::with_capacity(k);
778 let mut cos_lon_c = Vec::<f64>::with_capacity(k);
779 for c in centers.outer_iter() {
780 let trig = SphereTrig::from_radians(c[0] * deg, c[1] * deg);
781 sin_lat_c.push(trig.sin_lat);
782 cos_lat_c.push(trig.cos_lat);
783 sin_lon_c.push(trig.sin_lon);
784 cos_lon_c.push(trig.cos_lon);
785 }
786 let mut out = Array2::<f64>::zeros((n, k));
787 let err_flag = std::sync::atomic::AtomicBool::new(false);
788 out.axis_chunks_iter_mut(ndarray::Axis(0), 256)
789 .into_par_iter()
790 .enumerate()
791 .for_each(|(chunk_idx, mut block)| {
792 use wide::f64x4;
793 let row_offset = chunk_idx * 256;
794 let chunks = k / 4;
795 let tail = k % 4;
796 for (local_i, mut out_row) in block.outer_iter_mut().enumerate() {
797 let i = row_offset + local_i;
798 let row = SphereTrig::from_radians(data[(i, 0)] * deg, data[(i, 1)] * deg);
799 let row_v = SphereTrig {
800 sin_lat: f64x4::from(row.sin_lat),
801 cos_lat: f64x4::from(row.cos_lat),
802 sin_lon: f64x4::from(row.sin_lon),
803 cos_lon: f64x4::from(row.cos_lon),
804 };
805 for cidx in 0..chunks {
807 let base = cidx * 4;
808 let center_v = SphereTrig {
809 sin_lat: f64x4::from([
810 sin_lat_c[base],
811 sin_lat_c[base + 1],
812 sin_lat_c[base + 2],
813 sin_lat_c[base + 3],
814 ]),
815 cos_lat: f64x4::from([
816 cos_lat_c[base],
817 cos_lat_c[base + 1],
818 cos_lat_c[base + 2],
819 cos_lat_c[base + 3],
820 ]),
821 sin_lon: f64x4::from([
822 sin_lon_c[base],
823 sin_lon_c[base + 1],
824 sin_lon_c[base + 2],
825 sin_lon_c[base + 3],
826 ]),
827 cos_lon: f64x4::from([
828 cos_lon_c[base],
829 cos_lon_c[base + 1],
830 cos_lon_c[base + 2],
831 cos_lon_c[base + 3],
832 ]),
833 };
834 let (u, v) = half_angle_separation(row_v, center_v);
835 let vals = wahba_sphere_kernel_simd_kind(u, v, penalty_order, kernel);
836 let arr = vals.to_array();
837 for lane in 0..4 {
838 if !arr[lane].is_finite() {
839 err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
840 return;
841 }
842 out_row[base + lane] = arr[lane];
843 }
844 }
845 let tail_start = chunks * 4;
847 for t in 0..tail {
848 let j = tail_start + t;
849 let center = SphereTrig {
850 sin_lat: sin_lat_c[j],
851 cos_lat: cos_lat_c[j],
852 sin_lon: sin_lon_c[j],
853 cos_lon: cos_lon_c[j],
854 };
855 let sep = half_angle_separation_scalar(row, center);
856 match wahba_sphere_kernel_kind(sep, penalty_order, kernel) {
857 Ok(v) => out_row[j] = v,
858 Err(_) => {
859 err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
860 return;
861 }
862 }
863 }
864 }
865 });
866 if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
867 crate::bail_invalid_basis!("spherical spline kernel produced a non-finite value");
868 }
869 Ok(out)
870}
871
872#[cfg(test)]
873mod spherical_wahba_kernel_contract_2475_tests {
874 use super::*;
875 use ndarray::array;
876
877 fn assert_sobolev_m1_refusal(entry_point: &str, result: Result<Array2<f64>, BasisError>) {
878 let error = result.expect_err("untruncated Sobolev m=1 has no Gram diagonal");
879 let message = error.to_string();
880 assert!(
881 message.contains("log-singular") && message.contains("SobolevTruncated"),
882 "{entry_point} must identify both the mathematical defect and the explicit-resolution \
883 remedy; got: {message}"
884 );
885 }
886
887 #[test]
888 fn all_public_matrix_entry_points_refuse_untruncated_sobolev_m1() {
889 let data = array![[0.0, 0.0]];
892 let centers = array![[35.0, 70.0]];
893
894 assert_sobolev_m1_refusal(
895 "spherical_wahba_kernel_matrix",
896 spherical_wahba_kernel_matrix(data.view(), centers.view(), 1, false),
897 );
898 assert_sobolev_m1_refusal(
899 "spherical_wahba_kernel_matrix_with_kind",
900 spherical_wahba_kernel_matrix_with_kind(
901 data.view(),
902 centers.view(),
903 1,
904 false,
905 SphereWahbaKernel::Sobolev,
906 ),
907 );
908 assert_sobolev_m1_refusal(
909 "spherical_wahba_kernel_matrix_cpu",
910 spherical_wahba_kernel_matrix_cpu(
911 data.view(),
912 centers.view(),
913 1,
914 false,
915 SphereWahbaKernel::Sobolev,
916 ),
917 );
918 }
919
920 #[test]
921 fn explicit_resolution_and_finite_diagonal_m1_kernels_remain_available() {
922 let point = array![[0.0, 0.0]];
923
924 let pseudo = spherical_wahba_kernel_matrix_with_kind(
925 point.view(),
926 point.view(),
927 1,
928 false,
929 SphereWahbaKernel::Pseudo,
930 )
931 .expect("pseudo-Wahba m=1 has a finite analytic coincident-point value");
932 assert_eq!(
933 pseudo[(0, 0)],
934 1.0 / (4.0 * std::f64::consts::PI),
935 "the refusal must not absorb valid pseudo-Wahba m=1"
936 );
937
938 let truncated = spherical_wahba_kernel_matrix_with_kind(
939 point.view(),
940 point.view(),
941 1,
942 false,
943 SphereWahbaKernel::SobolevTruncated { lmax: 16 },
944 )
945 .expect("explicitly truncated Sobolev m=1 has a stated finite resolution");
946 assert!(
947 truncated[(0, 0)].is_finite(),
948 "a stated spectral resolution must produce a finite Gram diagonal"
949 );
950
951 spherical_wahba_kernel_matrix(point.view(), point.view(), 2, false)
952 .expect("untruncated Sobolev m=2 has a finite closed-form diagonal");
953 }
954}
955
956pub(crate) fn weighted_coefficient_sum_to_zero_transform(
957 weights: ArrayView1<'_, f64>,
958) -> Result<Array2<f64>, BasisError> {
959 let k = weights.len();
960 if k < 2 {
961 return Err(BasisError::InsufficientColumnsForConstraint { found: k });
962 }
963 if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
964 crate::bail_invalid_basis!(
965 "sphere coefficient constraint weights must be finite and non-negative"
966 );
967 }
968 let norm = weights.iter().map(|w| w * w).sum::<f64>().sqrt();
969 if norm <= 0.0 {
970 crate::bail_invalid_basis!("sphere coefficient constraint weights cannot all be zero");
971 }
972 let c = Array2::from_shape_vec((k, 1), weights.iter().map(|w| *w / norm).collect())
973 .map_err(|e| BasisError::InvalidInput(format!("invalid sphere constraint weights: {e}")))?;
974 let (z, rank) =
975 rrqr_nullspace_basis(&c, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
976 if rank >= k {
977 return Err(BasisError::ConstraintNullspaceCollapsed {
978 site: "weighted_coefficient_sum_to_zero_transform",
979 cross_rank: rank,
980 coeff_dim: k,
981 cross_frobenius: 1.0,
982 gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
983 .to_string(),
984 });
985 }
986 Ok(z)
987}
988
989const SPHERICAL_CENTER_COINCIDENT_TOL: f64 = 1.0e-12;
990
991#[inline]
992fn spherical_center_dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
993 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
994}
995
996fn resolve_spherical_profile_tie<F>(
1009 units: &[[f64; 3]],
1010 tied: &[usize],
1011 on_profile_builds: &mut F,
1012) -> Vec<usize>
1013where
1014 F: FnMut(usize),
1015{
1016 resolve_sorted_profile_tie(
1017 units.len(),
1018 tied,
1019 |anchor, row| spherical_center_dot(&units[anchor], &units[row]),
1020 on_profile_builds,
1021 )
1022}
1023
1024fn distinct_spherical_orbit(
1029 units: &[[f64; 3]],
1030 candidates: &[usize],
1031 already_selected: &[usize],
1032) -> Vec<usize> {
1033 let mut distinct = Vec::with_capacity(candidates.len());
1034 'candidate: for &candidate in candidates {
1035 for &selected in already_selected.iter().chain(distinct.iter()) {
1036 if spherical_center_dot(&units[candidate], &units[selected])
1037 >= 1.0 - SPHERICAL_CENTER_COINCIDENT_TOL
1038 {
1039 continue 'candidate;
1040 }
1041 }
1042 distinct.push(candidate);
1043 }
1044 distinct
1045}
1046
1047pub fn select_spherical_farthest_point_centers(
1098 data: ArrayView2<'_, f64>,
1099 num_centers: usize,
1100 radians: bool,
1101) -> Result<Array2<f64>, BasisError> {
1102 select_spherical_farthest_point_center_rows_with_observer(data, num_centers, radians, |_| {})
1103 .map(|chosen| {
1104 Array2::from_shape_fn((chosen.len(), 2), |(r, c)| data[[chosen[r], c]])
1107 })
1108}
1109
1110fn select_spherical_farthest_point_center_rows_with_observer<F>(
1111 data: ArrayView2<'_, f64>,
1112 num_centers: usize,
1113 radians: bool,
1114 mut on_profile_builds: F,
1115) -> Result<Vec<usize>, BasisError>
1116where
1117 F: FnMut(usize),
1118{
1119 use rayon::prelude::*;
1120 validate_lat_lon_matrix(data, "spherical farthest-point centers", radians)?;
1121 if num_centers == 0 {
1122 crate::bail_invalid_basis!("spherical farthest-point center count must be positive");
1123 }
1124 let n = data.nrows();
1125 if n < 2 {
1126 return Err(BasisError::InsufficientColumnsForConstraint { found: n });
1127 }
1128 if num_centers > n {
1129 crate::bail_invalid_basis!(
1130 "requested {num_centers} spherical farthest-point centers but only {n} rows are available"
1131 );
1132 }
1133
1134 let to_rad = if radians {
1135 1.0
1136 } else {
1137 std::f64::consts::PI / 180.0
1138 };
1139 let units: Vec<[f64; 3]> = (0..n)
1144 .into_par_iter()
1145 .map(|i| {
1146 let lat = data[[i, 0]] * to_rad;
1147 let lon = data[[i, 1]] * to_rad;
1148 let cos_lat = lat.cos();
1149 [cos_lat * lon.cos(), cos_lat * lon.sin(), lat.sin()]
1150 })
1151 .collect();
1152 let mut sum = [0.0_f64; 3];
1162 for (c, sum_c) in sum.iter_mut().enumerate() {
1163 let mut col: Vec<f64> = units.par_iter().map(|u| u[c]).collect();
1164 col.par_sort_by(|a, b| a.total_cmp(b));
1168 *sum_c = col.iter().sum();
1169 }
1170 let dot_to_sum: Vec<f64> = units
1171 .par_iter()
1172 .map(|u| spherical_center_dot(u, &sum))
1173 .collect();
1174
1175 let seed_key = dot_to_sum.par_iter().copied().reduce(
1182 || f64::NEG_INFINITY,
1183 |a, b| if b.total_cmp(&a).is_gt() { b } else { a },
1184 );
1185 let seed_tied: Vec<usize> = (0..n)
1186 .into_par_iter()
1187 .filter(|&i| dot_to_sum[i].total_cmp(&seed_key).is_eq())
1188 .collect();
1189
1190 let target = num_centers;
1191 let seed_class = resolve_spherical_profile_tie(&units, &seed_tied, &mut on_profile_builds);
1192 let seed_orbit = distinct_spherical_orbit(&units, &seed_class, &[]);
1193 if seed_orbit.len() > target {
1194 crate::bail_invalid_basis!(
1195 "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",
1196 seed_orbit.len()
1197 );
1198 }
1199
1200 let mut selected = Vec::with_capacity(target);
1201 let mut chosen = vec![false; n];
1202 let mut max_dot = vec![f64::NEG_INFINITY; n];
1206 for &i in &seed_class {
1207 chosen[i] = true;
1208 }
1209 selected.extend(seed_orbit);
1210 max_dot.par_iter_mut().enumerate().for_each(|(i, slot)| {
1211 *slot = selected
1212 .iter()
1213 .map(|¢er| spherical_center_dot(&units[i], &units[center]))
1214 .fold(f64::NEG_INFINITY, f64::max);
1215 });
1216
1217 while selected.len() < target {
1223 let cheap_key = (0..n)
1239 .into_par_iter()
1240 .filter(|&i| !chosen[i])
1241 .map(|i| (max_dot[i], dot_to_sum[i]))
1242 .reduce(
1243 || (f64::INFINITY, f64::INFINITY),
1244 |a, b| {
1245 if b.0.total_cmp(&a.0).then(b.1.total_cmp(&a.1)).is_lt() {
1246 b
1247 } else {
1248 a
1249 }
1250 },
1251 );
1252 let cheap_tied: Vec<usize> = (0..n)
1253 .into_par_iter()
1254 .filter(|&i| {
1255 !chosen[i]
1256 && max_dot[i].total_cmp(&cheap_key.0).is_eq()
1257 && dot_to_sum[i].total_cmp(&cheap_key.1).is_eq()
1258 })
1259 .collect();
1260 if cheap_tied.is_empty() {
1261 break;
1262 }
1263 if cheap_key.0 >= 1.0 - SPHERICAL_CENTER_COINCIDENT_TOL {
1264 break;
1265 }
1266
1267 let tied_class = resolve_spherical_profile_tie(&units, &cheap_tied, &mut on_profile_builds);
1268 let orbit = distinct_spherical_orbit(&units, &tied_class, &selected);
1269 let remaining = target - selected.len();
1270 if orbit.len() > remaining {
1271 crate::bail_invalid_basis!(
1272 "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",
1273 orbit.len(),
1274 );
1275 }
1276 for &i in &tied_class {
1277 chosen[i] = true;
1278 }
1279 if orbit.is_empty() {
1280 continue;
1281 }
1282 selected.extend(orbit.iter().copied());
1283 let chosen_ref = &chosen;
1284 let orbit_ref = &orbit;
1285 max_dot.par_iter_mut().enumerate().for_each(|(i, slot)| {
1286 if chosen_ref[i] {
1287 return;
1288 }
1289 for ¢er in orbit_ref {
1290 let d = spherical_center_dot(&units[i], &units[center]);
1291 if d > *slot {
1292 *slot = d;
1293 }
1294 }
1295 });
1296 }
1297
1298 if selected.len() < target {
1299 crate::bail_invalid_basis!(
1300 "requested {target} distinct spherical farthest-point centers but the data contain only {} numerically distinct directions",
1301 selected.len()
1302 );
1303 }
1304 if selected.len() < 2 {
1305 return Err(BasisError::InsufficientColumnsForConstraint {
1306 found: selected.len(),
1307 });
1308 }
1309
1310 Ok(selected)
1311}
1312
1313#[cfg(test)]
1314mod spherical_farthest_point_symmetry_tests {
1315 use super::*;
1316 use ndarray::{Array2, array};
1317
1318 struct SphericalCenterRows {
1321 rows: Vec<usize>,
1322 profile_builds: usize,
1323 }
1324
1325 fn select_spherical_farthest_point_center_rows(
1326 data: ArrayView2<'_, f64>,
1327 num_centers: usize,
1328 radians: bool,
1329 ) -> Result<SphericalCenterRows, BasisError> {
1330 let mut profile_builds = 0usize;
1331 let rows = select_spherical_farthest_point_center_rows_with_observer(
1332 data,
1333 num_centers,
1334 radians,
1335 |built| profile_builds += built,
1336 )?;
1337 Ok(SphericalCenterRows {
1338 rows,
1339 profile_builds,
1340 })
1341 }
1342
1343 fn permute_rows(data: &Array2<f64>, order: &[usize]) -> Array2<f64> {
1344 Array2::from_shape_fn((order.len(), 2), |(row, col)| data[[order[row], col]])
1345 }
1346
1347 fn sorted_center_rows(centers: &Array2<f64>) -> Vec<[f64; 2]> {
1348 let mut rows: Vec<[f64; 2]> = centers.outer_iter().map(|row| [row[0], row[1]]).collect();
1349 rows.sort_by(|a, b| a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1])));
1350 rows
1351 }
1352
1353 #[test]
1359 fn symmetric_tie_orbit_is_completed_under_every_row_permutation() {
1360 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1361 let permutations = [[0_usize, 1, 2, 3], [0, 1, 3, 2], [2, 0, 3, 1], [3, 1, 2, 0]];
1362
1363 let mut reference: Option<Vec<[f64; 2]>> = None;
1364 for order in permutations {
1365 let permuted = permute_rows(&data, &order);
1366 let centers = select_spherical_farthest_point_centers(permuted.view(), 3, false)
1367 .expect("the complete three-direction symmetry orbit is representable");
1368 assert_eq!(
1369 centers.nrows(),
1370 3,
1371 "the exact three-center target must contain the complete north/south tie class"
1372 );
1373 let center_set = sorted_center_rows(¢ers);
1374 if let Some(expected) = &reference {
1375 assert_eq!(
1376 ¢er_set, expected,
1377 "selected physical center set changed under row permutation"
1378 );
1379 } else {
1380 reference = Some(center_set);
1381 }
1382 }
1383 }
1384
1385 #[test]
1386 fn incomplete_nonseed_tie_class_is_refused() {
1387 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1388 let error = select_spherical_farthest_point_centers(data.view(), 2, false)
1389 .expect_err("one remaining slot cannot split the north/south tie class");
1390 assert!(
1391 error
1392 .to_string()
1393 .contains("only 1 of the exact 2-center budget remain"),
1394 "unexpected refusal: {error}"
1395 );
1396 }
1397
1398 #[test]
1403 fn symmetry_orbit_larger_than_center_budget_is_refused() {
1404 let antipodal = array![[90.0_f64, 0.0], [-90.0, 0.0]];
1405 let error = select_spherical_farthest_point_centers(antipodal.view(), 1, false)
1406 .expect_err("a two-direction seed orbit cannot fit a one-center budget");
1407 assert!(
1408 error.to_string().contains("symmetry orbit"),
1409 "unexpected refusal: {error}"
1410 );
1411 }
1412
1413 fn latlon_grid(n_lat: usize, n_lon: usize) -> Array2<f64> {
1416 Array2::from_shape_fn((n_lat * n_lon, 2), |(row, col)| {
1417 let (i, j) = (row / n_lon, row % n_lon);
1418 if col == 0 {
1419 -85.0 + (170.0 * i as f64) / (n_lat.saturating_sub(1).max(1) as f64)
1420 } else {
1421 -180.0 + (360.0 * j as f64) / (n_lon.saturating_sub(1).max(1) as f64)
1422 }
1423 })
1424 }
1425
1426 fn latlon_cloud(n: usize) -> Array2<f64> {
1429 let mut state = 0x2545_F491_4F6C_DD1D_u64;
1430 let mut next = move || {
1431 state ^= state << 13;
1432 state ^= state >> 7;
1433 state ^= state << 17;
1434 (state >> 11) as f64 / (1u64 << 53) as f64
1435 };
1436 let draws: Vec<f64> = (0..2 * n).map(|_| next()).collect();
1437 Array2::from_shape_fn((n, 2), |(row, col)| {
1438 if col == 0 {
1439 (1.0 - 2.0 * draws[2 * row]).asin().to_degrees()
1440 } else {
1441 360.0 * draws[2 * row + 1] - 180.0
1442 }
1443 })
1444 }
1445
1446 #[test]
1458 fn spherical_center_selection_costs_no_profile_without_an_exact_tie() {
1459 for n in [2_000_usize, 8_000] {
1460 for m in [40_usize, 200] {
1461 let data = latlon_cloud(n);
1462 let chosen = select_spherical_farthest_point_center_rows(data.view(), m, false)
1463 .expect("an asymmetric cloud admits any center budget below n");
1464 assert_eq!(chosen.rows.len(), m, "exact center budget (n={n}, m={m})");
1465 assert_eq!(
1466 chosen.profile_builds, 0,
1467 "no row can tie the maximin key exactly on an asymmetric cloud, so the \
1468 profile tie-break must never be built (n={n}, m={m})"
1469 );
1470 }
1471 }
1472 }
1473
1474 #[test]
1482 fn spherical_center_profile_cost_does_not_scale_with_the_row_count() {
1483 for (n_lat, n_lon) in [(40_usize, 40_usize), (160, 160)] {
1484 for m in [40_usize, 200] {
1485 let data = latlon_grid(n_lat, n_lon);
1486 let n = data.nrows();
1487 let chosen = select_spherical_farthest_point_center_rows(data.view(), m, false)
1488 .expect("a lat/lon grid admits these center budgets");
1489 assert_eq!(chosen.rows.len(), m, "exact center budget (n={n}, m={m})");
1490 assert!(
1491 chosen.profile_builds < m,
1492 "profile-key builds must stay below one per selected center; got {} at \
1493 n={n} m={m} (the replaced incumbent scan built at least {})",
1494 chosen.profile_builds,
1495 2 * m
1496 );
1497 }
1498 }
1499 }
1500
1501 #[test]
1505 fn spherical_center_profile_key_is_still_built_where_it_decides_an_orbit() {
1506 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1507 let chosen = select_spherical_farthest_point_center_rows(data.view(), 3, false)
1508 .expect("the complete three-direction symmetry orbit is representable");
1509 assert!(
1510 chosen.profile_builds > 0,
1511 "the north/south orbit is only provable through the invariant profile key"
1512 );
1513 }
1514
1515 #[test]
1521 fn polar_ring_selection_is_row_order_blind_at_every_budget() {
1522 let ring: Vec<[f64; 2]> = [-30.0_f64, 30.0]
1524 .into_iter()
1525 .flat_map(|lat| (0..6).map(move |j| [lat, -180.0 + 60.0 * j as f64]))
1526 .collect();
1527 let data = Array2::from_shape_fn((ring.len(), 2), |(r, c)| ring[r][c]);
1528 let n = data.nrows();
1529
1530 for budget in 2..=n {
1531 let reference = select_spherical_farthest_point_centers(data.view(), budget, false)
1532 .map(|centers| sorted_center_rows(¢ers));
1533 for order in [
1534 (0..n).rev().collect::<Vec<usize>>(),
1535 (0..n).map(|i| (5 * i + 7) % n).collect::<Vec<usize>>(),
1536 (0..n)
1537 .step_by(5)
1538 .chain((1..n).step_by(5))
1539 .collect::<Vec<usize>>(),
1540 ] {
1541 if order.len() != n {
1542 continue;
1543 }
1544 let permuted = permute_rows(&data, &order);
1545 let got = select_spherical_farthest_point_centers(permuted.view(), budget, false)
1546 .map(|centers| sorted_center_rows(¢ers));
1547 match (&reference, &got) {
1548 (Ok(expected), Ok(actual)) => assert_eq!(
1549 actual, expected,
1550 "budget {budget}: selected physical directions changed under a row \
1551 permutation of a symmetric ring"
1552 ),
1553 (Err(a), Err(b)) => assert_eq!(
1554 a.to_string(),
1555 b.to_string(),
1556 "budget {budget}: refusal changed under a row permutation"
1557 ),
1558 _ => panic!(
1559 "budget {budget}: row order decided whether the request was \
1560 representable ({reference:?} vs {got:?})"
1561 ),
1562 }
1563 }
1564 }
1565 }
1566}
1567
1568#[cfg(test)]
1569mod matern_function_metric_tests {
1570 use super::*;
1571 use ndarray::array;
1572
1573 #[test]
1574 fn center_metric_null_ridge_is_covariant_and_targets_only_intercept_function() {
1575 let center_kernel = array![[1.4, 0.3, 0.1], [0.3, 1.2, 0.2], [0.1, 0.2, 1.1]];
1576 let mut embedded = Array2::<f64>::zeros((4, 4));
1577 embedded.slice_mut(s![0..3, 0..3]).assign(¢er_kernel);
1578 let gram =
1579 matern_center_function_gram(&embedded, true, None).expect("raw center function Gram");
1580 let base =
1581 matern_double_penalty_candidates(&embedded, &gram, true).expect("raw candidates");
1582 assert_eq!(base.len(), 2);
1583 let raw_ridge = base[1].matrix.dense() * base[1].normalization_scale;
1584
1585 let intercept = array![[0.0], [0.0], [0.0], [1.0]];
1586 let action_error = (&raw_ridge.dot(&intercept) - &gram.dot(&intercept))
1587 .iter()
1588 .map(|value| value.abs())
1589 .fold(0.0_f64, f64::max);
1590 assert!(
1591 action_error < 2.0e-13,
1592 "ridge must equal G on the structural intercept; error={action_error:.3e}"
1593 );
1594
1595 let transform = array![
1600 [0.2, 0.5, 0.0, 0.0],
1601 [0.0, 3.0, -0.4, 0.0],
1602 [0.0, 0.0, 1.7, 0.0],
1603 [0.0, 0.0, 0.0, 2.5]
1604 ];
1605 let primary_t = fast_atb(&transform, &fast_ab(&embedded, &transform));
1606 let gram_t = matern_center_function_gram(&embedded, true, Some(&transform))
1607 .expect("transformed center function Gram");
1608 let transformed = matern_double_penalty_candidates(&primary_t, &gram_t, true)
1609 .expect("transformed candidates");
1610 let ridge_t = transformed[1].matrix.dense() * transformed[1].normalization_scale;
1611 let expected = fast_atb(&transform, &fast_ab(&raw_ridge, &transform));
1612 let covariance_error = (&ridge_t - &expected)
1613 .iter()
1614 .map(|value| value.abs())
1615 .fold(0.0_f64, f64::max);
1616 assert!(
1617 covariance_error < 2.0e-12,
1618 "Matérn function ridge changed under a basis chart; error={covariance_error:.3e}"
1619 );
1620
1621 let no_intercept_gram = matern_center_function_gram(
1622 ¢er_kernel,
1623 false,
1624 Some(&transform.slice(s![0..3, 0..3]).to_owned()),
1625 )
1626 .expect("kernel-only Gram");
1627 let kernel_only = matern_double_penalty_candidates(
1628 &fast_atb(
1629 &transform.slice(s![0..3, 0..3]).to_owned(),
1630 &fast_ab(¢er_kernel, &transform.slice(s![0..3, 0..3]).to_owned()),
1631 ),
1632 &no_intercept_gram,
1633 false,
1634 )
1635 .expect("kernel-only candidates");
1636 assert_eq!(kernel_only.len(), 1, "an SPD kernel has no null ridge");
1637 }
1638}
1639
1640pub fn auto_streaming_chunk_size_for_dense(n_rows: usize, n_basis_cols: usize) -> Option<usize> {
1650 if n_rows == 0 || n_basis_cols == 0 {
1651 return None;
1652 }
1653 const DENSE_THRESHOLD_BYTES: usize = 1024 * 1024 * 1024;
1654 const TARGET_CHUNK_BYTES: usize = 256 * 1024 * 1024;
1655 const MIN_CHUNK_ROWS: usize = 1024;
1656 let dense_bytes = n_rows.saturating_mul(n_basis_cols).saturating_mul(8);
1657 if dense_bytes <= DENSE_THRESHOLD_BYTES {
1658 return None;
1659 }
1660 let row_bytes = n_basis_cols.saturating_mul(8).max(1);
1661 let raw_chunk = TARGET_CHUNK_BYTES / row_bytes;
1662 let clamped = raw_chunk.max(MIN_CHUNK_ROWS).min(n_rows);
1663 Some(clamped)
1664}