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) value_hash: u64,
38}
39
40#[derive(Debug)]
41pub(crate) struct BasisCacheContext {
42 pub(crate) constraint_nullspace: ConstraintNullspaceCache,
43 pub(crate) owned_data: gam_runtime::resource::ByteLruCache<OwnedDataCacheKey, Arc<Array2<f64>>>,
44}
45
46impl BasisCacheContext {
47 pub(crate) fn with_policy(policy: &gam_runtime::resource::ResourcePolicy) -> Self {
48 Self {
49 constraint_nullspace: ConstraintNullspaceCache::default(),
50 owned_data: gam_runtime::resource::ByteLruCache::with_max_entries(
51 policy.max_owned_data_cache_bytes,
52 gam_runtime::resource::OWNED_DATA_CACHE_MAX_ENTRIES,
53 ),
54 }
55 }
56}
57
58impl Default for BasisCacheContext {
59 fn default() -> Self {
60 Self::with_policy(&gam_runtime::resource::ResourcePolicy::default_library())
61 }
62}
63
64#[derive(Debug)]
74pub struct BasisWorkspace {
75 pub(crate) cache: BasisCacheContext,
76 pub(crate) policy: gam_runtime::resource::ResourcePolicy,
77}
78
79impl BasisWorkspace {
80 pub fn new() -> Self {
81 Self::default()
82 }
83
84 pub fn with_policy(policy: gam_runtime::resource::ResourcePolicy) -> Self {
85 Self {
86 cache: BasisCacheContext::with_policy(&policy),
87 policy,
88 }
89 }
90
91 pub fn default_library() -> Self {
92 Self::with_policy(gam_runtime::resource::ResourcePolicy::default_library())
93 }
94
95 pub fn policy(&self) -> &gam_runtime::resource::ResourcePolicy {
97 &self.policy
98 }
99}
100
101impl Default for BasisWorkspace {
102 fn default() -> Self {
103 Self::default_library()
104 }
105}
106
107pub(crate) fn hash_arrayview2(values: ArrayView2<'_, f64>) -> u64 {
108 let mut hasher = DefaultHasher::new();
109 values.nrows().hash(&mut hasher);
110 values.ncols().hash(&mut hasher);
111 for v in values {
112 v.to_bits().hash(&mut hasher);
113 }
114 hasher.finish()
115}
116
117pub(crate) fn shared_owned_data_matrix(
118 data: ArrayView2<'_, f64>,
119 cache: &BasisCacheContext,
120) -> Arc<Array2<f64>> {
121 let key = OwnedDataCacheKey {
122 rows: data.nrows(),
123 cols: data.ncols(),
124 value_hash: hash_arrayview2(data),
125 };
126 if let Some(hit) = cache.owned_data.get(&key) {
127 return hit;
128 }
129
130 let owned = Arc::new(data.to_owned());
131 if let Some(hit) = cache.owned_data.get(&key) {
132 return hit;
133 }
134
135 cache.owned_data.insert(key, owned.clone());
136 owned
137}
138
139#[inline]
147pub(crate) fn shared_owned_data_matrix_from_view(data: ArrayView2<'_, f64>) -> Arc<Array2<f64>> {
148 Arc::new(data.to_owned())
149}
150
151#[inline]
156pub(crate) fn shared_owned_centers_matrix_from_view(
157 centers: ArrayView2<'_, f64>,
158) -> Arc<Array2<f64>> {
159 Arc::new(centers.to_owned())
160}
161
162pub(crate) fn kernel_constraint_nullspace(
178 centers: ArrayView2<'_, f64>,
179 order: DuchonNullspaceOrder,
180 cache: &mut BasisCacheContext,
181) -> Result<Array2<f64>, BasisError> {
182 let effective_order = duchon_effective_nullspace_order(centers, order);
183 let centers_centered = mean_centered_centers(centers);
184 let centers = centers_centered.view();
185 let key = ConstraintNullspaceCacheKey {
186 centersrows: centers.nrows(),
187 centers_cols: centers.ncols(),
188 centers_hash: hash_arrayview2(centers),
189 order: ConstraintNullspaceOrderKey::Duchon(effective_order),
190 };
191
192 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
193 return Ok((**hit).clone());
194 }
195
196 let z = Arc::new(duchon_constraint_nullspace_of_centered(
197 centers,
198 order,
199 effective_order,
200 )?);
201
202 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
203 return Ok((**hit).clone());
204 }
205 cache.constraint_nullspace.map.insert(key, z.clone());
206 cache.constraint_nullspace.order.push(key);
207 while cache.constraint_nullspace.map.len() > CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES {
208 if cache.constraint_nullspace.order.is_empty() {
209 break;
210 }
211 let oldkey = cache.constraint_nullspace.order.remove(0);
212 cache.constraint_nullspace.map.remove(&oldkey);
213 }
214
215 Ok((*z).clone())
216}
217
218fn mean_centered_centers(centers: ArrayView2<'_, f64>) -> Array2<f64> {
231 let k = centers.nrows();
232 let d = centers.ncols();
233 let center_mean: Vec<f64> = (0..d)
234 .map(|c| centers.column(c).sum() / (k.max(1) as f64))
235 .collect();
236 let mut centers_centered = centers.to_owned();
237 for c in 0..d {
238 let mu = center_mean[c];
239 centers_centered.column_mut(c).mapv_inplace(|v| v - mu);
240 }
241 centers_centered
242}
243
244fn duchon_constraint_nullspace_of_centered(
248 centers: ArrayView2<'_, f64>,
249 order: DuchonNullspaceOrder,
250 effective_order: DuchonNullspaceOrder,
251) -> Result<Array2<f64>, BasisError> {
252 let degraded = effective_order != order;
253 let p_k = polynomial_block_from_order(centers, effective_order);
254 kernel_constraint_nullspace_from_matrix(p_k.view()).map_err(|err| {
255 if degraded {
256 BasisError::InvalidInput(format!(
257 "Duchon degraded from order={:?} to order={:?} due to insufficient centers ({} in dim={}); order={:?} construction then failed: {err}",
258 order,
259 effective_order,
260 centers.nrows(),
261 centers.ncols(),
262 effective_order,
263 ))
264 } else {
265 err
266 }
267 })
268}
269
270pub fn duchon_kernel_constraint_nullspace(
279 centers: ArrayView2<'_, f64>,
280 order: DuchonNullspaceOrder,
281) -> Result<Array2<f64>, BasisError> {
282 let effective_order = duchon_effective_nullspace_order(centers, order);
283 let centers_centered = mean_centered_centers(centers);
284 duchon_constraint_nullspace_of_centered(centers_centered.view(), order, effective_order)
285}
286
287pub(crate) fn thin_plate_kernel_constraint_nullspace(
288 centers: ArrayView2<'_, f64>,
289 cache: &mut BasisCacheContext,
290) -> Result<Array2<f64>, BasisError> {
291 let key = ConstraintNullspaceCacheKey {
292 centersrows: centers.nrows(),
293 centers_cols: centers.ncols(),
294 centers_hash: hash_arrayview2(centers),
295 order: ConstraintNullspaceOrderKey::ThinPlate,
296 };
297
298 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
299 return Ok((**hit).clone());
300 }
301
302 let p_k = thin_plate_polynomial_block(centers);
303 if centers.nrows() < p_k.ncols() {
304 crate::bail_invalid_basis!(
305 "thin-plate spline requires at least {} centers to span the degree-{} polynomial null space in dimension {}; got {}",
306 p_k.ncols(),
307 thin_plate_polynomial_degree(centers.ncols()),
308 centers.ncols(),
309 centers.nrows()
310 );
311 }
312 let (z, rank) =
313 rrqr_nullspace_basis(&p_k, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
314 if rank != p_k.ncols() {
315 crate::bail_invalid_basis!(
316 "thin-plate spline polynomial block is rank deficient at the selected centers: expected rank {}, got {}; choose geometrically independent centers for dimension {}",
317 p_k.ncols(),
318 rank,
319 centers.ncols()
320 );
321 }
322 let z = Arc::new(z);
323
324 if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
325 return Ok((**hit).clone());
326 }
327 cache.constraint_nullspace.map.insert(key, z.clone());
328 cache.constraint_nullspace.order.push(key);
329 while cache.constraint_nullspace.map.len() > CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES {
330 if cache.constraint_nullspace.order.is_empty() {
331 break;
332 }
333 let oldkey = cache.constraint_nullspace.order.remove(0);
334 cache.constraint_nullspace.map.remove(&oldkey);
335 }
336
337 Ok((*z).clone())
338}
339
340pub(crate) fn matern_identifiability_transform(
341 centers: ArrayView2<'_, f64>,
342 identifiability: &MaternIdentifiability,
343) -> Result<Option<Array2<f64>>, BasisError> {
344 let k = centers.nrows();
345 match identifiability {
346 MaternIdentifiability::None => Ok(None),
347 MaternIdentifiability::CenterSumToZero => {
348 let q = Array2::<f64>::ones((k, 1));
349 Ok(Some(kernel_constraint_nullspace_from_matrix(q.view())?))
350 }
351 MaternIdentifiability::CenterLinearOrthogonal => {
352 let effective_order =
357 duchon_effective_nullspace_order(centers, DuchonNullspaceOrder::Linear);
358 let q = polynomial_block_from_order(centers, effective_order);
359 Ok(Some(kernel_constraint_nullspace_from_matrix(q.view())?))
360 }
361 MaternIdentifiability::FrozenTransform { transform, .. } => {
362 if transform.nrows() != k {
363 crate::bail_dim_basis!(
364 "frozen Matérn identifiability transform mismatch: centers={k}, transform rows={}",
365 transform.nrows()
366 );
367 }
368 Ok(Some(transform.clone()))
369 }
370 }
371}
372
373pub(crate) fn build_matern_operator_penalty_candidates(
374 centers: ArrayView2<'_, f64>,
375 length_scale: f64,
376 nu: MaternNu,
377 include_intercept: bool,
378 z_opt: Option<&Array2<f64>>,
379 aniso_log_scales: Option<&[f64]>,
380) -> Result<Vec<PenaltyCandidate>, BasisError> {
381 let ops = build_matern_collocation_operator_matrices(
382 centers,
383 None,
384 length_scale,
385 nu,
386 include_intercept,
387 z_opt.map(|z| z.view()),
388 aniso_log_scales,
389 )?;
390 let matern_spec = DuchonOperatorPenaltySpec::matern_for_smoothness(nu, centers.ncols());
394 operator_penalty_candidates_from_collocation(&ops.d0, &ops.d1, &ops.d2, &matern_spec)
395}
396
397fn matrix_all_finite(m: &Array2<f64>) -> bool {
399 m.iter().all(|v| v.is_finite())
400}
401
402pub(crate) fn matern_center_function_gram(
410 embedded_kernel: &Array2<f64>,
411 include_intercept: bool,
412 full_transform: Option<&Array2<f64>>,
413) -> Result<Array2<f64>, BasisError> {
414 if embedded_kernel.nrows() != embedded_kernel.ncols() {
415 crate::bail_dim_basis!("Matérn embedded kernel penalty must be square");
416 }
417 let total = embedded_kernel.nrows();
418 let k = total
419 .checked_sub(usize::from(include_intercept))
420 .ok_or_else(|| BasisError::InvalidInput("Matérn basis width underflow".to_string()))?;
421 if k == 0 {
422 crate::bail_invalid_basis!("Matérn function metric requires at least one center");
423 }
424 let mut center_design = Array2::<f64>::zeros((k, total));
425 center_design
426 .slice_mut(s![.., 0..k])
427 .assign(&embedded_kernel.slice(s![0..k, 0..k]));
428 if include_intercept {
429 center_design.column_mut(k).fill(1.0);
430 }
431 let center_design = match full_transform {
432 Some(transform) => fast_ab(¢er_design, transform),
433 None => center_design,
434 };
435 Ok(symmetrize_penalty(&fast_ata(¢er_design)))
436}
437
438pub(crate) fn matern_double_penalty_candidates(
439 primary: &Array2<f64>,
440 function_gram: &Array2<f64>,
441 include_intercept: bool,
442) -> Result<Vec<PenaltyCandidate>, BasisError> {
443 if !matrix_all_finite(primary) {
449 crate::bail_invalid_basis!(
450 "Matérn double-penalty primary kernel Gram is non-finite; the projected \
451 kernel `Zᵀ K Z` could not be formed at this length scale (degenerate \
452 geometry). Widen the data spread, change the length scale, or drop the term."
453 );
454 }
455 if primary.dim() != function_gram.dim() || !matrix_all_finite(function_gram) {
456 crate::bail_invalid_basis!(
457 "Matérn center function Gram is non-finite or does not match the primary penalty"
458 );
459 }
460 let mut candidates = vec![normalize_penalty_candidate(
461 primary.clone(),
462 PenaltySource::Primary,
463 )?];
464 if include_intercept {
470 let p = primary.nrows();
471 let mut intercept_frame = Array2::<f64>::zeros((p, 1));
472 intercept_frame[[p - 1, 0]] = 1.0;
473 let shrinkage = function_space_subspace_shrinkage(&intercept_frame, function_gram)?;
474 candidates.push(normalize_penalty_candidate(
475 shrinkage,
476 PenaltySource::DoublePenaltyNullspace,
477 )?);
478 }
479 Ok(candidates)
480}
481
482pub(crate) fn build_matern_double_penalty_candidates(
483 spline: &MaternSplineBasis,
484 full_transform: Option<&Array2<f64>>,
485) -> Result<Vec<PenaltyCandidate>, BasisError> {
486 let primary = project_penalty_matrix(&spline.penalty_kernel, full_transform);
487 let include_intercept = spline.num_polynomial_basis == 1;
488 let function_gram =
489 matern_center_function_gram(&spline.penalty_kernel, include_intercept, full_transform)?;
490 matern_double_penalty_candidates(&primary, &function_gram, include_intercept)
491}
492
493pub fn create_matern_spline_basiswithworkspace(
506 data: ArrayView2<'_, f64>,
507 centers: ArrayView2<'_, f64>,
508 length_scale: f64,
509 nu: MaternNu,
510 include_intercept: bool,
511 aniso_log_scales: Option<&[f64]>,
512 workspace: &mut BasisWorkspace,
513) -> Result<MaternSplineBasis, BasisError> {
514 let n = data.nrows();
515 let d = data.ncols();
516 let k = centers.nrows();
517 let total_cols = k + usize::from(include_intercept);
518 let dense_bytes = dense_design_bytes(n, total_cols);
519 if dense_bytes > workspace.policy().max_single_materialization_bytes {
520 crate::bail_invalid_basis!(
521 "Matérn basis dense design exceeds resource policy: n={n}, p={total_cols}, dense={:.1} MiB, cap={:.1} MiB",
522 dense_bytes as f64 / (1024.0 * 1024.0),
523 workspace.policy().max_single_materialization_bytes as f64 / (1024.0 * 1024.0),
524 );
525 }
526
527 if d == 0 {
528 crate::bail_invalid_basis!("Matérn basis requires at least one covariate dimension");
529 }
530 if k == 0 {
531 crate::bail_invalid_basis!("Matérn basis requires at least one center");
532 }
533 if centers.ncols() != d {
534 crate::bail_dim_basis!(
535 "Matérn basis dimension mismatch: data has {d} columns, centers have {}",
536 centers.ncols()
537 );
538 }
539 if data.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
540 crate::bail_invalid_basis!("Matérn basis requires finite data and center values");
541 }
542 validate_matern_length_scale(length_scale)?;
543 if let Some(eta) = aniso_log_scales {
544 if eta.len() != d {
545 crate::bail_dim_basis!(
546 "aniso_log_scales length {} does not match data dimension {d}",
547 eta.len()
548 );
549 }
550 if eta.iter().any(|v| !v.is_finite()) {
551 crate::bail_invalid_basis!("aniso_log_scales must contain finite values");
552 }
553 }
554
555 let warn_bounds = if let Some(eta) = aniso_log_scales {
561 let y_centers = points_in_aniso_y_space(centers, eta);
562 pairwise_distance_bounds(y_centers.view())
563 } else {
564 pairwise_distance_bounds(centers)
565 };
566 if let Some((r_min, r_max)) = warn_bounds {
567 let kappa = duchon_inverse_length_scale(length_scale, "Matérn spline basis operating range")?;
568 let kappa_lo = 1e-2 / r_max;
569 let kappa_hi = 1e2 / r_min;
570 if kappa < kappa_lo || kappa > kappa_hi {
571 log::debug!(
572 "Matérn κ={} is outside recommended range [{}, {}] derived from centers (r_min={}, r_max={}); kernel conditioning may degrade",
573 kappa,
574 kappa_lo,
575 kappa_hi,
576 r_min,
577 r_max
578 );
579 }
580 }
581
582 let mut kernel_block = Array2::<f64>::zeros((n, k));
585 let mut center_kernel = Array2::<f64>::zeros((k, k));
586 let axis_scales = aniso_log_scales.map(aniso_axis_scales);
587 let kernel_result: Result<(), BasisError> = kernel_block
588 .axis_iter_mut(Axis(0))
589 .into_par_iter()
590 .enumerate()
591 .try_for_each(|(i, mut row)| {
592 for j in 0..k {
593 let r = if let Some(scales) = axis_scales.as_deref() {
594 aniso_distance_rows_with_scales(data, i, centers, j, scales)
595 } else {
596 euclidean_distance_rows(data, i, centers, j)
597 };
598 row[j] = matern_kernel_from_distance(r, length_scale, nu)?;
599 }
600 Ok(())
601 });
602 kernel_result?;
603 fill_symmetric_from_row_kernel(&mut center_kernel, |i, j| {
606 let r = if let Some(scales) = axis_scales.as_deref() {
607 aniso_distance_rows_with_scales(centers, i, centers, j, scales)
608 } else {
609 euclidean_distance_rows(centers, i, centers, j)
610 };
611 matern_kernel_from_distance(r, length_scale, nu)
612 })?;
613
614 let mut basis = Array2::<f64>::zeros((n, total_cols));
615 basis.slice_mut(s![.., 0..k]).assign(&kernel_block);
616 if include_intercept {
617 basis.column_mut(k).fill(1.0);
618 }
619
620 let mut penalty_kernel = Array2::<f64>::zeros((total_cols, total_cols));
621 penalty_kernel
626 .slice_mut(s![0..k, 0..k])
627 .assign(¢er_kernel);
628 let function_gram = matern_center_function_gram(&penalty_kernel, include_intercept, None)?;
629 let penalty_ridge = if include_intercept {
630 let mut intercept_frame = Array2::<f64>::zeros((total_cols, 1));
631 intercept_frame[[total_cols - 1, 0]] = 1.0;
632 function_space_subspace_shrinkage(&intercept_frame, &function_gram)?
633 } else {
634 Array2::<f64>::zeros((total_cols, total_cols))
635 };
636
637 Ok(MaternSplineBasis {
638 basis,
639 penalty_kernel,
640 penalty_ridge,
641 num_kernel_basis: k,
642 num_polynomial_basis: usize::from(include_intercept),
643 dimension: d,
644 })
645}
646
647#[inline]
648pub(crate) fn validate_lat_lon_matrix(
649 data: ArrayView2<'_, f64>,
650 context: &str,
651 radians: bool,
652) -> Result<(), BasisError> {
653 if data.ncols() != 2 {
654 crate::bail_dim_basis!(
655 "{context} requires exactly two columns: latitude and longitude; got {}",
656 data.ncols()
657 );
658 }
659 if data.nrows() == 0 {
660 crate::bail_invalid_basis!("{context} requires at least one row");
661 }
662 let (lat_lo, lat_hi, unit) = if radians {
663 (
664 -std::f64::consts::FRAC_PI_2,
665 std::f64::consts::FRAC_PI_2,
666 "radians",
667 )
668 } else {
669 (-90.0, 90.0, "degrees")
670 };
671 for (i, row) in data.outer_iter().enumerate() {
672 let lat = row[0];
673 let lon = row[1];
674 if !lat.is_finite() || !lon.is_finite() {
675 crate::bail_invalid_basis!(
676 "{context} requires finite latitude/longitude; row {i} has ({lat}, {lon})"
677 );
678 }
679 if !(lat_lo..=lat_hi).contains(&lat) {
680 crate::bail_invalid_basis!(
681 "{context} latitude must be in [{lat_lo}, {lat_hi}] {unit}; row {i} has {lat}"
682 );
683 }
684 }
685 Ok(())
686}
687
688fn validate_spherical_wahba_gram_request(
689 penalty_order: usize,
690 kernel: SphereWahbaKernel,
691) -> Result<(), BasisError> {
692 if !(1..=4).contains(&penalty_order) {
693 crate::bail_invalid_basis!(
694 "spherical spline penalty_order must be one of 1, 2, 3, 4; got {penalty_order}"
695 );
696 }
697 if matches!(kernel, SphereWahbaKernel::Sobolev) && penalty_order == 1 {
698 crate::bail_invalid_basis!(
703 "the m = 1 Sobolev sphere kernel is log-singular at coincident points, so its Gram \
704 diagonal does not exist and any finite value is a choice of resolution rather than a \
705 limit; use SobolevTruncated {{ lmax }} (the same kernel with the resolution stated, \
706 diagonal ~ ln(lmax)/2pi) or penalty_order >= 2, whose diagonals are finite closed \
707 forms (1/(4pi) at m = 2, (2*zeta3 - 2)/(4pi) at m = 3)"
708 );
709 }
710 Ok(())
711}
712
713pub fn spherical_wahba_kernel_matrix_with_kind(
720 data: ArrayView2<'_, f64>,
721 centers: ArrayView2<'_, f64>,
722 penalty_order: usize,
723 radians: bool,
724 kernel: SphereWahbaKernel,
725) -> Result<Array2<f64>, BasisError> {
726 validate_spherical_wahba_gram_request(penalty_order, kernel)?;
727 validate_lat_lon_matrix(data, "spherical spline data", radians)?;
728 validate_lat_lon_matrix(centers, "spherical spline centers", radians)?;
729 if let Some(gpu_result) = crate::basis::sphere_gpu::try_build_truncated_kernel_matrix_gpu(
737 data,
738 centers,
739 penalty_order,
740 radians,
741 kernel,
742 ) {
743 let gpu_matrix = gpu_result.map_err(|err| {
744 BasisError::InvalidInput(format!(
745 "spherical spline GPU truncated kernel was admitted but failed on device: {err}"
746 ))
747 })?;
748 return Ok(gpu_matrix);
749 }
750 spherical_wahba_kernel_matrix_cpu_validated(data, centers, penalty_order, radians, kernel)
751}
752
753fn spherical_wahba_kernel_matrix_cpu_validated(
754 data: ArrayView2<'_, f64>,
755 centers: ArrayView2<'_, f64>,
756 penalty_order: usize,
757 radians: bool,
758 kernel: SphereWahbaKernel,
759) -> Result<Array2<f64>, BasisError> {
760 let n = data.nrows();
761 let k = centers.nrows();
762 let deg = if radians {
763 1.0
764 } else {
765 std::f64::consts::PI / 180.0
766 };
767 let mut sin_lat_c = Vec::<f64>::with_capacity(k);
774 let mut cos_lat_c = Vec::<f64>::with_capacity(k);
775 let mut sin_lon_c = Vec::<f64>::with_capacity(k);
776 let mut cos_lon_c = Vec::<f64>::with_capacity(k);
777 for c in centers.outer_iter() {
778 let trig = SphereTrig::from_radians(c[0] * deg, c[1] * deg);
779 sin_lat_c.push(trig.sin_lat);
780 cos_lat_c.push(trig.cos_lat);
781 sin_lon_c.push(trig.sin_lon);
782 cos_lon_c.push(trig.cos_lon);
783 }
784 let mut out = Array2::<f64>::zeros((n, k));
785 let err_flag = std::sync::atomic::AtomicBool::new(false);
786 out.axis_chunks_iter_mut(ndarray::Axis(0), 256)
787 .into_par_iter()
788 .enumerate()
789 .for_each(|(chunk_idx, mut block)| {
790 use wide::f64x4;
791 let row_offset = chunk_idx * 256;
792 let chunks = k / 4;
793 let tail = k % 4;
794 for (local_i, mut out_row) in block.outer_iter_mut().enumerate() {
795 let i = row_offset + local_i;
796 let row = SphereTrig::from_radians(data[(i, 0)] * deg, data[(i, 1)] * deg);
797 let row_v = SphereTrig {
798 sin_lat: f64x4::from(row.sin_lat),
799 cos_lat: f64x4::from(row.cos_lat),
800 sin_lon: f64x4::from(row.sin_lon),
801 cos_lon: f64x4::from(row.cos_lon),
802 };
803 for cidx in 0..chunks {
805 let base = cidx * 4;
806 let center_v = SphereTrig {
807 sin_lat: f64x4::from([
808 sin_lat_c[base],
809 sin_lat_c[base + 1],
810 sin_lat_c[base + 2],
811 sin_lat_c[base + 3],
812 ]),
813 cos_lat: f64x4::from([
814 cos_lat_c[base],
815 cos_lat_c[base + 1],
816 cos_lat_c[base + 2],
817 cos_lat_c[base + 3],
818 ]),
819 sin_lon: f64x4::from([
820 sin_lon_c[base],
821 sin_lon_c[base + 1],
822 sin_lon_c[base + 2],
823 sin_lon_c[base + 3],
824 ]),
825 cos_lon: f64x4::from([
826 cos_lon_c[base],
827 cos_lon_c[base + 1],
828 cos_lon_c[base + 2],
829 cos_lon_c[base + 3],
830 ]),
831 };
832 let (u, v) = half_angle_separation(row_v, center_v);
833 let vals = wahba_sphere_kernel_simd_kind(u, v, penalty_order, kernel);
834 let arr = vals.to_array();
835 for lane in 0..4 {
836 if !arr[lane].is_finite() {
837 err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
838 return;
839 }
840 out_row[base + lane] = arr[lane];
841 }
842 }
843 let tail_start = chunks * 4;
845 for t in 0..tail {
846 let j = tail_start + t;
847 let center = SphereTrig {
848 sin_lat: sin_lat_c[j],
849 cos_lat: cos_lat_c[j],
850 sin_lon: sin_lon_c[j],
851 cos_lon: cos_lon_c[j],
852 };
853 let sep = half_angle_separation_scalar(row, center);
854 match wahba_sphere_kernel_kind(sep, penalty_order, kernel) {
855 Ok(v) => out_row[j] = v,
856 Err(_) => {
857 err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
858 return;
859 }
860 }
861 }
862 }
863 });
864 if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
865 crate::bail_invalid_basis!("spherical spline kernel produced a non-finite value");
866 }
867 Ok(out)
868}
869
870pub(crate) fn weighted_coefficient_sum_to_zero_transform(
871 weights: ArrayView1<'_, f64>,
872) -> Result<Array2<f64>, BasisError> {
873 let k = weights.len();
874 if k < 2 {
875 return Err(BasisError::InsufficientColumnsForConstraint { found: k });
876 }
877 if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
878 crate::bail_invalid_basis!(
879 "sphere coefficient constraint weights must be finite and non-negative"
880 );
881 }
882 let norm = weights.iter().map(|w| w * w).sum::<f64>().sqrt();
883 if norm <= 0.0 {
884 crate::bail_invalid_basis!("sphere coefficient constraint weights cannot all be zero");
885 }
886 let c = Array2::from_shape_vec((k, 1), weights.iter().map(|w| *w / norm).collect())
887 .map_err(|e| BasisError::InvalidInput(format!("invalid sphere constraint weights: {e}")))?;
888 let (z, rank) =
889 rrqr_nullspace_basis(&c, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
890 if rank >= k {
891 return Err(BasisError::ConstraintNullspaceCollapsed {
892 site: "weighted_coefficient_sum_to_zero_transform",
893 cross_rank: rank,
894 coeff_dim: k,
895 cross_frobenius: 1.0,
896 gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
897 .to_string(),
898 });
899 }
900 Ok(z)
901}
902
903const SPHERICAL_CENTER_COINCIDENT_TOL: f64 = 1.0e-12;
904
905#[inline]
906fn spherical_center_dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
907 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
908}
909
910fn resolve_spherical_profile_tie<F>(
923 units: &[[f64; 3]],
924 tied: &[usize],
925 on_profile_builds: &mut F,
926) -> Vec<usize>
927where
928 F: FnMut(usize),
929{
930 resolve_sorted_profile_tie(
931 units.len(),
932 tied,
933 |anchor, row| spherical_center_dot(&units[anchor], &units[row]),
934 on_profile_builds,
935 )
936}
937
938fn distinct_spherical_orbit(
943 units: &[[f64; 3]],
944 candidates: &[usize],
945 already_selected: &[usize],
946) -> Vec<usize> {
947 let mut distinct = Vec::with_capacity(candidates.len());
948 'candidate: for &candidate in candidates {
949 for &selected in already_selected.iter().chain(distinct.iter()) {
950 if spherical_center_dot(&units[candidate], &units[selected])
951 >= 1.0 - SPHERICAL_CENTER_COINCIDENT_TOL
952 {
953 continue 'candidate;
954 }
955 }
956 distinct.push(candidate);
957 }
958 distinct
959}
960
961pub fn select_spherical_farthest_point_centers(
1012 data: ArrayView2<'_, f64>,
1013 num_centers: usize,
1014 radians: bool,
1015) -> Result<Array2<f64>, BasisError> {
1016 let chosen = select_spherical_farthest_point_center_rows(data, num_centers, radians)?;
1017 log::debug!(
1018 "spherical farthest-point centers: {} of {} rows, {} sorted dot profile(s) built",
1019 chosen.rows.len(),
1020 data.nrows(),
1021 chosen.profile_builds
1022 );
1023 Ok(Array2::from_shape_fn((chosen.rows.len(), 2), |(r, c)| {
1026 data[[chosen.rows[r], c]]
1027 }))
1028}
1029
1030pub(crate) struct SphericalCenterSelection {
1033 pub(crate) rows: Vec<usize>,
1034 pub(crate) profile_builds: usize,
1039}
1040
1041fn select_spherical_farthest_point_center_rows(
1042 data: ArrayView2<'_, f64>,
1043 num_centers: usize,
1044 radians: bool,
1045) -> Result<SphericalCenterSelection, BasisError> {
1046 use rayon::prelude::*;
1047 let mut profile_builds = 0usize;
1048 validate_lat_lon_matrix(data, "spherical farthest-point centers", radians)?;
1049 if num_centers == 0 {
1050 crate::bail_invalid_basis!("spherical farthest-point center count must be positive");
1051 }
1052 let n = data.nrows();
1053 if n < 2 {
1054 return Err(BasisError::InsufficientColumnsForConstraint { found: n });
1055 }
1056 if num_centers > n {
1057 crate::bail_invalid_basis!(
1058 "requested {num_centers} spherical farthest-point centers but only {n} rows are available"
1059 );
1060 }
1061
1062 let to_rad = if radians {
1063 1.0
1064 } else {
1065 std::f64::consts::PI / 180.0
1066 };
1067 let units: Vec<[f64; 3]> = (0..n)
1072 .into_par_iter()
1073 .map(|i| {
1074 let lat = data[[i, 0]] * to_rad;
1075 let lon = data[[i, 1]] * to_rad;
1076 let cos_lat = lat.cos();
1077 [cos_lat * lon.cos(), cos_lat * lon.sin(), lat.sin()]
1078 })
1079 .collect();
1080 let mut sum = [0.0_f64; 3];
1090 for (c, sum_c) in sum.iter_mut().enumerate() {
1091 let mut col: Vec<f64> = units.par_iter().map(|u| u[c]).collect();
1092 col.par_sort_by(|a, b| a.total_cmp(b));
1096 *sum_c = col.iter().sum();
1097 }
1098 let dot_to_sum: Vec<f64> = units
1099 .par_iter()
1100 .map(|u| spherical_center_dot(u, &sum))
1101 .collect();
1102
1103 let seed_key = dot_to_sum.par_iter().copied().reduce(
1110 || f64::NEG_INFINITY,
1111 |a, b| if b.total_cmp(&a).is_gt() { b } else { a },
1112 );
1113 let seed_tied: Vec<usize> = (0..n)
1114 .into_par_iter()
1115 .filter(|&i| dot_to_sum[i].total_cmp(&seed_key).is_eq())
1116 .collect();
1117
1118 let target = num_centers;
1119 let seed_class = resolve_spherical_profile_tie(&units, &seed_tied, &mut |built: usize| {
1120 profile_builds += built;
1121 });
1122 let seed_orbit = distinct_spherical_orbit(&units, &seed_class, &[]);
1123 if seed_orbit.len() > target {
1124 crate::bail_invalid_basis!(
1125 "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",
1126 seed_orbit.len()
1127 );
1128 }
1129
1130 let mut selected = Vec::with_capacity(target);
1131 let mut chosen = vec![false; n];
1132 let mut max_dot = vec![f64::NEG_INFINITY; n];
1136 for &i in &seed_class {
1137 chosen[i] = true;
1138 }
1139 selected.extend(seed_orbit);
1140 max_dot.par_iter_mut().enumerate().for_each(|(i, slot)| {
1141 *slot = selected
1142 .iter()
1143 .map(|¢er| spherical_center_dot(&units[i], &units[center]))
1144 .fold(f64::NEG_INFINITY, f64::max);
1145 });
1146
1147 while selected.len() < target {
1153 let cheap_key = (0..n)
1169 .into_par_iter()
1170 .filter(|&i| !chosen[i])
1171 .map(|i| (max_dot[i], dot_to_sum[i]))
1172 .reduce(
1173 || (f64::INFINITY, f64::INFINITY),
1174 |a, b| {
1175 if b.0.total_cmp(&a.0).then(b.1.total_cmp(&a.1)).is_lt() {
1176 b
1177 } else {
1178 a
1179 }
1180 },
1181 );
1182 let cheap_tied: Vec<usize> = (0..n)
1183 .into_par_iter()
1184 .filter(|&i| {
1185 !chosen[i]
1186 && max_dot[i].total_cmp(&cheap_key.0).is_eq()
1187 && dot_to_sum[i].total_cmp(&cheap_key.1).is_eq()
1188 })
1189 .collect();
1190 if cheap_tied.is_empty() {
1191 break;
1192 }
1193 if cheap_key.0 >= 1.0 - SPHERICAL_CENTER_COINCIDENT_TOL {
1194 break;
1195 }
1196
1197 let tied_class = resolve_spherical_profile_tie(&units, &cheap_tied, &mut |built: usize| {
1198 profile_builds += built;
1199 });
1200 let orbit = distinct_spherical_orbit(&units, &tied_class, &selected);
1201 let remaining = target - selected.len();
1202 if orbit.len() > remaining {
1203 crate::bail_invalid_basis!(
1204 "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",
1205 orbit.len(),
1206 );
1207 }
1208 for &i in &tied_class {
1209 chosen[i] = true;
1210 }
1211 if orbit.is_empty() {
1212 continue;
1213 }
1214 selected.extend(orbit.iter().copied());
1215 let chosen_ref = &chosen;
1216 let orbit_ref = &orbit;
1217 max_dot.par_iter_mut().enumerate().for_each(|(i, slot)| {
1218 if chosen_ref[i] {
1219 return;
1220 }
1221 for ¢er in orbit_ref {
1222 let d = spherical_center_dot(&units[i], &units[center]);
1223 if d > *slot {
1224 *slot = d;
1225 }
1226 }
1227 });
1228 }
1229
1230 if selected.len() < target {
1231 crate::bail_invalid_basis!(
1232 "requested {target} distinct spherical farthest-point centers but the data contain only {} numerically distinct directions",
1233 selected.len()
1234 );
1235 }
1236 if selected.len() < 2 {
1237 return Err(BasisError::InsufficientColumnsForConstraint {
1238 found: selected.len(),
1239 });
1240 }
1241
1242 Ok(SphericalCenterSelection {
1243 rows: selected,
1244 profile_builds,
1245 })
1246}
1247
1248#[cfg(test)]
1249mod spherical_farthest_point_symmetry_tests {
1250 use super::*;
1251 use ndarray::{Array2, array};
1252
1253 fn permute_rows(data: &Array2<f64>, order: &[usize]) -> Array2<f64> {
1254 Array2::from_shape_fn((order.len(), 2), |(row, col)| data[[order[row], col]])
1255 }
1256
1257 fn sorted_center_rows(centers: &Array2<f64>) -> Vec<[f64; 2]> {
1258 let mut rows: Vec<[f64; 2]> = centers.outer_iter().map(|row| [row[0], row[1]]).collect();
1259 rows.sort_by(|a, b| a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1])));
1260 rows
1261 }
1262
1263 #[test]
1269 fn symmetric_tie_orbit_is_completed_under_every_row_permutation() {
1270 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1271 let permutations = [[0_usize, 1, 2, 3], [0, 1, 3, 2], [2, 0, 3, 1], [3, 1, 2, 0]];
1272
1273 let mut reference: Option<Vec<[f64; 2]>> = None;
1274 for order in permutations {
1275 let permuted = permute_rows(&data, &order);
1276 let centers = select_spherical_farthest_point_centers(permuted.view(), 3, false)
1277 .expect("the complete three-direction symmetry orbit is representable");
1278 assert_eq!(
1279 centers.nrows(),
1280 3,
1281 "the exact three-center target must contain the complete north/south tie class"
1282 );
1283 let center_set = sorted_center_rows(¢ers);
1284 if let Some(expected) = &reference {
1285 assert_eq!(
1286 ¢er_set, expected,
1287 "selected physical center set changed under row permutation"
1288 );
1289 } else {
1290 reference = Some(center_set);
1291 }
1292 }
1293 }
1294
1295 #[test]
1296 fn incomplete_nonseed_tie_class_is_refused() {
1297 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1298 let error = select_spherical_farthest_point_centers(data.view(), 2, false)
1299 .expect_err("one remaining slot cannot split the north/south tie class");
1300 assert!(
1301 error
1302 .to_string()
1303 .contains("only 1 of the exact 2-center budget remain"),
1304 "unexpected refusal: {error}"
1305 );
1306 }
1307
1308 #[test]
1313 fn symmetry_orbit_larger_than_center_budget_is_refused() {
1314 let antipodal = array![[90.0_f64, 0.0], [-90.0, 0.0]];
1315 let error = select_spherical_farthest_point_centers(antipodal.view(), 1, false)
1316 .expect_err("a two-direction seed orbit cannot fit a one-center budget");
1317 assert!(
1318 error.to_string().contains("symmetry orbit"),
1319 "unexpected refusal: {error}"
1320 );
1321 }
1322
1323 fn latlon_grid(n_lat: usize, n_lon: usize) -> Array2<f64> {
1326 Array2::from_shape_fn((n_lat * n_lon, 2), |(row, col)| {
1327 let (i, j) = (row / n_lon, row % n_lon);
1328 if col == 0 {
1329 -85.0 + (170.0 * i as f64) / (n_lat.saturating_sub(1).max(1) as f64)
1330 } else {
1331 -180.0 + (360.0 * j as f64) / (n_lon.saturating_sub(1).max(1) as f64)
1332 }
1333 })
1334 }
1335
1336 fn latlon_cloud(n: usize) -> Array2<f64> {
1339 let mut state = 0x2545_F491_4F6C_DD1D_u64;
1340 let mut next = move || {
1341 state ^= state << 13;
1342 state ^= state >> 7;
1343 state ^= state << 17;
1344 (state >> 11) as f64 / (1u64 << 53) as f64
1345 };
1346 let draws: Vec<f64> = (0..2 * n).map(|_| next()).collect();
1347 Array2::from_shape_fn((n, 2), |(row, col)| {
1348 if col == 0 {
1349 (1.0 - 2.0 * draws[2 * row]).asin().to_degrees()
1350 } else {
1351 360.0 * draws[2 * row + 1] - 180.0
1352 }
1353 })
1354 }
1355
1356 #[test]
1368 fn spherical_center_selection_costs_no_profile_without_an_exact_tie() {
1369 for n in [2_000_usize, 8_000] {
1370 for m in [40_usize, 200] {
1371 let data = latlon_cloud(n);
1372 let chosen = select_spherical_farthest_point_center_rows(data.view(), m, false)
1373 .expect("an asymmetric cloud admits any center budget below n");
1374 assert_eq!(chosen.rows.len(), m, "exact center budget (n={n}, m={m})");
1375 assert_eq!(
1376 chosen.profile_builds, 0,
1377 "no row can tie the maximin key exactly on an asymmetric cloud, so the \
1378 profile tie-break must never be built (n={n}, m={m})"
1379 );
1380 }
1381 }
1382 }
1383
1384 #[test]
1392 fn spherical_center_profile_cost_does_not_scale_with_the_row_count() {
1393 for (n_lat, n_lon) in [(40_usize, 40_usize), (160, 160)] {
1394 for m in [40_usize, 200] {
1395 let data = latlon_grid(n_lat, n_lon);
1396 let n = data.nrows();
1397 let chosen = select_spherical_farthest_point_center_rows(data.view(), m, false)
1398 .expect("a lat/lon grid admits these center budgets");
1399 assert_eq!(chosen.rows.len(), m, "exact center budget (n={n}, m={m})");
1400 assert!(
1401 chosen.profile_builds < m,
1402 "profile-key builds must stay below one per selected center; got {} at \
1403 n={n} m={m} (the replaced incumbent scan built at least {})",
1404 chosen.profile_builds,
1405 2 * m
1406 );
1407 }
1408 }
1409 }
1410
1411 #[test]
1415 fn spherical_center_profile_key_is_still_built_where_it_decides_an_orbit() {
1416 let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1417 let chosen = select_spherical_farthest_point_center_rows(data.view(), 3, false)
1418 .expect("the complete three-direction symmetry orbit is representable");
1419 assert!(
1420 chosen.profile_builds > 0,
1421 "the north/south orbit is only provable through the invariant profile key"
1422 );
1423 }
1424
1425 #[test]
1431 fn polar_ring_selection_is_row_order_blind_at_every_budget() {
1432 let ring: Vec<[f64; 2]> = [-30.0_f64, 30.0]
1434 .into_iter()
1435 .flat_map(|lat| (0..6).map(move |j| [lat, -180.0 + 60.0 * j as f64]))
1436 .collect();
1437 let data = Array2::from_shape_fn((ring.len(), 2), |(r, c)| ring[r][c]);
1438 let n = data.nrows();
1439
1440 for budget in 2..=n {
1441 let reference = select_spherical_farthest_point_centers(data.view(), budget, false)
1442 .map(|centers| sorted_center_rows(¢ers));
1443 for order in [
1444 (0..n).rev().collect::<Vec<usize>>(),
1445 (0..n).map(|i| (5 * i + 7) % n).collect::<Vec<usize>>(),
1446 (0..n)
1447 .step_by(5)
1448 .chain((1..n).step_by(5))
1449 .collect::<Vec<usize>>(),
1450 ] {
1451 if order.len() != n {
1452 continue;
1453 }
1454 let permuted = permute_rows(&data, &order);
1455 let got = select_spherical_farthest_point_centers(permuted.view(), budget, false)
1456 .map(|centers| sorted_center_rows(¢ers));
1457 match (&reference, &got) {
1458 (Ok(expected), Ok(actual)) => assert_eq!(
1459 actual, expected,
1460 "budget {budget}: selected physical directions changed under a row \
1461 permutation of a symmetric ring"
1462 ),
1463 (Err(a), Err(b)) => assert_eq!(
1464 a.to_string(),
1465 b.to_string(),
1466 "budget {budget}: refusal changed under a row permutation"
1467 ),
1468 _ => panic!(
1469 "budget {budget}: row order decided whether the request was \
1470 representable ({reference:?} vs {got:?})"
1471 ),
1472 }
1473 }
1474 }
1475 }
1476}
1477
1478#[cfg(test)]
1479mod matern_function_metric_tests {
1480 use super::*;
1481 use ndarray::array;
1482
1483 #[test]
1484 fn center_metric_null_ridge_is_covariant_and_targets_only_intercept_function() {
1485 let center_kernel = array![[1.4, 0.3, 0.1], [0.3, 1.2, 0.2], [0.1, 0.2, 1.1]];
1486 let mut embedded = Array2::<f64>::zeros((4, 4));
1487 embedded.slice_mut(s![0..3, 0..3]).assign(¢er_kernel);
1488 let gram =
1489 matern_center_function_gram(&embedded, true, None).expect("raw center function Gram");
1490 let base =
1491 matern_double_penalty_candidates(&embedded, &gram, true).expect("raw candidates");
1492 assert_eq!(base.len(), 2);
1493 let raw_ridge = base[1].matrix.dense() * base[1].normalization_scale;
1494
1495 let intercept = array![[0.0], [0.0], [0.0], [1.0]];
1496 let action_error = (&raw_ridge.dot(&intercept) - &gram.dot(&intercept))
1497 .iter()
1498 .map(|value| value.abs())
1499 .fold(0.0_f64, f64::max);
1500 assert!(
1501 action_error < 2.0e-13,
1502 "ridge must equal G on the structural intercept; error={action_error:.3e}"
1503 );
1504
1505 let transform = array![
1510 [0.2, 0.5, 0.0, 0.0],
1511 [0.0, 3.0, -0.4, 0.0],
1512 [0.0, 0.0, 1.7, 0.0],
1513 [0.0, 0.0, 0.0, 2.5]
1514 ];
1515 let primary_t = fast_atb(&transform, &fast_ab(&embedded, &transform));
1516 let gram_t = matern_center_function_gram(&embedded, true, Some(&transform))
1517 .expect("transformed center function Gram");
1518 let transformed = matern_double_penalty_candidates(&primary_t, &gram_t, true)
1519 .expect("transformed candidates");
1520 let ridge_t = transformed[1].matrix.dense() * transformed[1].normalization_scale;
1521 let expected = fast_atb(&transform, &fast_ab(&raw_ridge, &transform));
1522 let covariance_error = (&ridge_t - &expected)
1523 .iter()
1524 .map(|value| value.abs())
1525 .fold(0.0_f64, f64::max);
1526 assert!(
1527 covariance_error < 2.0e-12,
1528 "Matérn function ridge changed under a basis chart; error={covariance_error:.3e}"
1529 );
1530
1531 let no_intercept_gram = matern_center_function_gram(
1532 ¢er_kernel,
1533 false,
1534 Some(&transform.slice(s![0..3, 0..3]).to_owned()),
1535 )
1536 .expect("kernel-only Gram");
1537 let kernel_only = matern_double_penalty_candidates(
1538 &fast_atb(
1539 &transform.slice(s![0..3, 0..3]).to_owned(),
1540 &fast_ab(¢er_kernel, &transform.slice(s![0..3, 0..3]).to_owned()),
1541 ),
1542 &no_intercept_gram,
1543 false,
1544 )
1545 .expect("kernel-only candidates");
1546 assert_eq!(kernel_only.len(), 1, "an SPD kernel has no null ridge");
1547 }
1548}
1549
1550pub fn auto_streaming_chunk_size_for_dense(n_rows: usize, n_basis_cols: usize) -> Option<usize> {
1560 if n_rows == 0 || n_basis_cols == 0 {
1561 return None;
1562 }
1563 const DENSE_THRESHOLD_BYTES: usize = 1024 * 1024 * 1024;
1564 const TARGET_CHUNK_BYTES: usize = 256 * 1024 * 1024;
1565 const MIN_CHUNK_ROWS: usize = 1024;
1566 let dense_bytes = n_rows.saturating_mul(n_basis_cols).saturating_mul(8);
1567 if dense_bytes <= DENSE_THRESHOLD_BYTES {
1568 return None;
1569 }
1570 let row_bytes = n_basis_cols.saturating_mul(8).max(1);
1571 let raw_chunk = TARGET_CHUNK_BYTES / row_bytes;
1572 let clamped = raw_chunk.max(MIN_CHUNK_ROWS).min(n_rows);
1573 Some(clamped)
1574}