Skip to main content

gam_terms/basis/
workspace_cache.rs

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/// Explicit per-run workspace for reusable basis-construction caches.
61///
62/// Pass one workspace through repeated basis builds to avoid global mutable state
63/// and to keep caching scoped to a caller-controlled lifecycle.
64///
65/// Owned-data cache entries are byte-limited via the
66/// [`gam_runtime::resource::ResourcePolicy`] provided at construction; use
67/// [`BasisWorkspace::with_policy`] for large-scale workloads where a single
68/// entry can be multiple gigabytes.
69#[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    /// Returns the resource policy this workspace was configured with.
92    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/// Minimal cache-less intern: wraps an `ArrayView2` into an `Arc<Array2<f64>>`.
138///
139/// Used by derivative-operator builders that don't have a `BasisCacheContext`
140/// in scope (e.g. `build_aniso_design_psi_derivatives_shared`). The goal is the
141/// same as `shared_owned_data_matrix`: move the owned payload into an `Arc`
142/// once so that downstream `StreamingRadialState` copies share it via
143/// `Arc::clone` instead of materializing a fresh n×d `Array2<f64>` per axis.
144#[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/// Minimal cache-less intern for knot centers; mirrors
150/// `shared_owned_data_matrix_from_view`. Centers are typically k×d with k
151/// much smaller than n, but the `Arc::clone` pattern still avoids a k×d
152/// copy per axis when the same operator feeds multiple derivative paths.
153#[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
160/// Compute the kernel reparameterisation transform `Z = null(P_centers^T)`.
161///
162/// `Z` is a `(k, k − C(d+r, r))` orthonormal matrix whose columns span the
163/// null space of the polynomial side-condition system.  Reparameterising the
164/// radial kernel coefficients as `α = Z γ` enforces `P_centers^T α = 0` and
165/// reduces the kernel column count from `k` to `k − C(d+r, r)`.
166///
167/// After this projection the polynomial block `P_data` is appended as separate
168/// explicit unpenalized columns (see `build_duchon_basis_designwithworkspace`),
169/// so the pre-identifiability total width is always `k` (equal to the center
170/// count), regardless of the polynomial null-space dimension.
171///
172/// This is the step that absorbs the full `C(d+r, r)`-dimensional polynomial
173/// null space.  The subsequent `spatial_parametric_constraint_block` step only
174/// removes the intercept.
175pub(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
216/// Translation-invariant side-condition frame (#1375, mirroring the #1269 tp
217/// fix). `Z = null(P(centers)ᵀ)` is mathematically invariant to subtracting a
218/// per-axis constant from `centers` (the polynomial columns `{1, x, …}` and
219/// `{1, x − x̄, …}` span the same space, so `P` has the same column space and
220/// `P^T` the same null space), but the RRQR pivoting that materialises `Z`
221/// drifts under a large coordinate mean — landing on a different orthonormal
222/// basis of the SAME null space, which would desync the design `K·Z` from the
223/// penalty `ZᵀK_CC Z` across a covariate translation. Subtract the center-cloud
224/// per-axis mean so the factorisation is location-standardized; both a raw and
225/// an already-centered caller then produce bit-identical `Z`. The mean is a
226/// fixed property of the (frozen `UserProvided`) centers, replayed identically
227/// at predict.
228fn 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
242/// Factor `Z = null(P(centers)ᵀ)` for centers that are ALREADY mean-centered,
243/// wrapping a degradation context around the failure when the effective order
244/// was auto-degraded below the requested one.
245fn 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
268/// Cache-free canonical Duchon kernel-constraint null space: the SAME
269/// degrade → mean-center → RRQR construction the cached design path uses.
270/// Every consumer of a Duchon `Z` outside the workspace-cached design builder
271/// (e.g. the FFI derivative jets, which must be the exact column-for-column
272/// derivative of the forward design) must route through this one construction;
273/// re-deriving `Z` without the centering lands on a different orthonormal
274/// basis of the same null space and silently desyncs the jet's frame from the
275/// design's.
276pub 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            // Mirror the Duchon path: auto-degrade to Zero (constant-only) when
351            // there aren't enough centers to affinely span [1, x_1, ..., x_d].
352            // kernel_constraint_nullspace_from_matrix would otherwise hard-error
353            // via rrqr_nullspace_basis when centers.nrows() < d + 1.
354            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    // Gate the operator dials on the Matérn-ν RKHS smoothness so a rough kernel
389    // (e.g. ν=1/2) is not over-smoothed by a higher-order roughness penalty its
390    // own RKHS norm does not control (#707).
391    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
395/// True when every entry of `m` is finite.
396fn matrix_all_finite(m: &Array2<f64>) -> bool {
397    m.iter().all(|v| v.is_finite())
398}
399
400/// Discrete function Gram on Matérn's frozen center support.
401///
402/// The embedded primary contains `K_CC` in its kernel block. Evaluating the
403/// represented raw basis at the same centers gives `[K_CC | 1]`; applying the
404/// final kernel-identifiability chart and taking `B_CᵀB_C` therefore provides
405/// an exact compact Gram for this finite-rank representation without touching
406/// the training rows.
407pub(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(&center_design, transform),
431        None => center_design,
432    };
433    Ok(symmetrize_penalty(&fast_ata(&center_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    // gam#1379 — guard the Primary projected kernel Gram itself. It is `Zᵀ K Z`
442    // with a finite Matérn kernel `K`, so it is finite in exact arithmetic; if a
443    // degenerate trial geometry made it non-finite we cannot ship it as a
444    // penalty (the range-block eigensolve would abort the fit). Surface a clear
445    // basis error instead of an opaque downstream "non-finite range penalty".
446    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    // K_CC is strictly positive definite after center rank reduction. The ONLY
463    // structural null direction is the explicitly appended intercept. Kernel
464    // eigenvalues near a floating-point tolerance remain range directions; they
465    // must be conditioned/reduced, never reclassified into a κ-dependent null
466    // projector. This makes penalty topology structural and κ-invariant.
467    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
491/// Creates a Matérn spline basis from data and centers.
492///
493/// The design is `[K | 1]` when `include_intercept=true` and `[K]` otherwise, where:
494/// - `K_ij = k(||x_i - c_j||; length_scale, nu)` is the Matérn kernel block.
495///
496/// The default kernel penalty is `alpha' S alpha` with `S_jl = k(||c_j - c_l||)`, embedded
497/// in the full coefficient space. With intercept included, that column is unpenalized by
498/// `penalty_kernel`; optional `penalty_ridge` is the center-function-metric
499/// penalty for double-penalty shrinkage of the explicit intercept direction.
500///
501/// NOTE: This follows the RKHS Gram construction S = K_CC (not K_CC^{-1}) in
502/// coefficient space, with global scaling absorbed by the smoothing parameter λ.
503pub 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    // Practical safe operating range for κ from center geometry (document Eq. D.2):
554    //   κ in [1e-2 / r_max, 1e2 / r_min], with κ = 1/length_scale.
555    // Warn rather than silently clamp so callers keep explicit control.
556    // Under anisotropy the kernel metric is y-space (y_a = exp(η_a) x_a), so
557    // the relevant r_min/r_max are y-space pairwise distances, not raw.
558    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    // Distance computation: anisotropic when eta is present, isotropic otherwise.
581    // Under anisotropy we work in y-space (y = Ax), so r = |Ah| replaces |h|.
582    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    // Center-center Gram matrix K_CC. In RKHS form, the kernel penalty on
602    // radial coefficients is alpha^T K_CC alpha.
603    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    // RKHS coefficient penalty uses the center Gram matrix directly:
620    //   S = K_CC  (not K_CC^{-1}).
621    // This matches Duchon/Matérn spline theory where alpha^T K_CC alpha is the
622    // native-space quadratic form up to a global scaling absorbed by lambda.
623    penalty_kernel
624        .slice_mut(s![0..k, 0..k])
625        .assign(&center_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        // K_1 = (-ln(u) - 1)/(4π), u = (1 - cos(γ))/2, is log-singular
697        // at coincidence. A finite Gram diagonal therefore cannot be inferred
698        // from this closed form: the old epsilon floor silently selected one,
699        // equivalent to an unstated spectral resolution of about 3.8e9.
700        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
711/// Build a Wahba S² kernel matrix with the untruncated Sobolev kernel.
712///
713/// Untruncated Sobolev `m = 1` is refused because its coincident-point value
714/// diverges; use [`SphereWahbaKernel::SobolevTruncated`] with
715/// [`spherical_wahba_kernel_matrix_with_kind`] to state a finite resolution.
716pub 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
731/// Build a Wahba S² kernel matrix with an explicit kernel family.
732///
733/// Untruncated [`SphereWahbaKernel::Sobolev`] at `m = 1` is refused before
734/// either GPU dispatch or CPU scalar/SIMD evaluation. Its Gram diagonal does
735/// not exist; [`SphereWahbaKernel::SobolevTruncated`] is the explicit-
736/// resolution alternative.
737pub 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    // GPU fast path for the truncated-spectral kernels. The CPU SIMD loop
748    // (`spherical_wahba_kernel_matrix_cpu`) is the bit-defining oracle; the
749    // device only engages when `sphere_kernel_decision` admits the work (large
750    // `n·m`, `lmax ≤ 200`, memory budget). `None` ⇒ quiet CPU route (closed-form
751    // variant, no device, or below threshold); `Some(Err)` ⇒ admitted device
752    // failed ⇒ surface it (never a silent CPU degrade — the engagement-failure
753    // class this path kills).
754    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
771/// CPU oracle for the Wahba S² kernel design matrix — the bit-defining
772/// reference the GPU truncated path is held to. Always evaluates on host,
773/// regardless of the GPU dispatch decision, so parity tests and any caller that
774/// needs the deterministic reference can bypass device routing entirely.
775///
776/// It enforces the same kernel/order contract as
777/// [`spherical_wahba_kernel_matrix_with_kind`]; bypassing device routing does
778/// not bypass mathematical validation.
779pub 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    // Precompute (sin_lat, cos_lat, sin_lon, cos_lon) for each center once and
807    // reuse it across the whole N x K grid. The pair separation is then pure
808    // `+ - *` arithmetic on those eight numbers — see
809    // `super::sphere_half_angle` for why it is taken in chord form rather than
810    // as a dot product (#2489) and why it costs no transcendental call per
811    // (i, j) either way.
812    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                // SIMD over 4 centers at a time.
843                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                // Scalar tail (0..3 elements).
883                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        // Deliberately use distinct points. Refusal is a structural property of
927        // the requested Gram-kernel family, not a floating-point coincidence test.
928        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
1033/// Reduce a candidate list that is already tied on every `O(1)` invariant key to
1034/// the sub-list attaining the lexicographically least sorted **dot** profile —
1035/// the multiset `{uᵢ·u_j : j}` of one row against the whole cloud, in ascending
1036/// total order. Rows related by a rotation that maps the point cloud to itself
1037/// have the SAME profile, which is what makes it a legal tie-break key: it
1038/// depends on neither the frame nor the row order.
1039///
1040/// The tie-break machinery itself — extremum-then-refine, one `O(n log n)`
1041/// profile per candidate serving both the choice and the class filter, none at
1042/// all for a lone candidate — is shared with the Euclidean twin
1043/// ([`select_thin_plate_knots`]) in [`crate::basis::invariant_tie_break`]. Only
1044/// the pairwise scalar differs: a dot product here, a squared distance there.
1045fn 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
1061/// Remove coincident directions from one invariant tie class without choosing
1062/// between genuinely distinct tied directions. Representatives of coincident
1063/// rows are geometrically interchangeable and produce the same kernel column;
1064/// every distinct member of the symmetry class is retained.
1065fn 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
1084/// Select spherical-spline basis centers by **geodesic** farthest-point sampling
1085/// of the data cloud, returning a well-spread subset of the actual data rows.
1086///
1087/// This is the rotation-EQUIVARIANT center rule Wahba's reproducing-kernel smooth
1088/// needs. The kernel is a function of the geodesic angle alone (`k(cos γ)`,
1089/// `cos γ = uᵢ·u_c` for unit vectors `u`), so the continuous estimator is exactly
1090/// SO(3)-invariant; the finite-center discretization inherits that invariance
1091/// **iff** the centers rotate rigidly with the data. Every ingredient of this
1092/// selection is a dot product of data unit vectors — the mean-direction seed key
1093/// `uᵢ·Σⱼuⱼ`, the maximin nearest-center dot `max_c uᵢ·u_c`, and the sorted
1094/// dot-profile tie-break — and a dot product is invariant under any rotation `R`
1095/// (`(Ruᵢ)·(Ru_c) = uᵢ·u_c`). So under ANY rotation of the data the SAME physical
1096/// rows are selected, the returned centers are exactly those rows rotated, every
1097/// kernel entry `k(uᵢ·u_c)` is preserved, and the fit and every prediction are
1098/// invariant to the arbitrary choice of frame (a longitude origin, a tilt, any
1099/// element of SO(3)) — matching the rotation-invariant `harmonic` control (#2127).
1100///
1101/// On a symmetric cloud, invariant scalar keys can leave several **distinct**
1102/// rows exactly tied. No row-permutation-equivariant rule can choose one member
1103/// of such an orbit: a symmetry exchanging two tied rows would have to both
1104/// preserve and change that choice. The selector therefore adds the complete
1105/// distinct-direction tie class atomically. Because `num_centers` is an exact
1106/// resource contract, a class that does not fit in the remaining budget is
1107/// refused as unrepresentable rather than truncated by row index. Coincident
1108/// rows remain one kernel column; consequently a request exceeding the number
1109/// of distinct directions is also refused rather than silently undersized.
1110///
1111/// The previous implementation ignored `data` and laid down a fixed golden-angle
1112/// (Fibonacci) lattice pinned in the (lat, lon) frame: a rigid rotation moved the
1113/// data relative to the STATIONARY centers, changed every data-to-center geodesic
1114/// angle, and reshaped the fitted surface. Anchoring only the lattice's longitude
1115/// origin to the data (a first pass at #2127) fixed rotations about the pole but
1116/// left the frame-pinned latitudes exposed to a tilt.
1117///
1118/// The selection mirrors the Euclidean thin-plate knot picker
1119/// ([`select_thin_plate_knots`]) — centroid-nearest seed, maximin recursion,
1120/// invariant tie-breaks — but with geodesic (great-circle) distance in place of
1121/// Euclidean distance, which is the correct SO(3) invariant on S². Coincident
1122/// data directions are not selected twice (a duplicate center makes the Wahba
1123/// Gram singular).
1124///
1125/// Each step minimizes its composite key in extremum-then-refine order rather
1126/// than by carrying a running incumbent: the two `O(1)` keys in one parallel
1127/// reduction, then the rows attaining them in one parallel filter, then — only
1128/// over that set, and only if it holds more than one row — the `O(n log n)`
1129/// sorted dot profile. Lexicographic minimization is associative, so this is the
1130/// same total preorder the incumbent scan applied; what changes is that the
1131/// profile key is charged where it can still decide something instead of twice
1132/// per outer iteration whether or not anything is tied. On data with no exact
1133/// spherical symmetry it is never built at all (#2420).
1134pub 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    // Return the selected rows VERBATIM (in the data's own lat/lon units), so
1147    // the centers ARE data points and carry the rotation exactly.
1148    Ok(Array2::from_shape_fn((chosen.rows.len(), 2), |(r, c)| {
1149        data[[chosen.rows[r], c]]
1150    }))
1151}
1152
1153/// The row indices [`select_spherical_farthest_point_centers`] selects, with
1154/// the number of sorted dot profiles the selection had to build.
1155pub(crate) struct SphericalCenterSelection {
1156    pub(crate) rows: Vec<usize>,
1157    /// How many `O(n log n)` sorted dot profiles the tie-break machinery built.
1158    /// Zero whenever no invariant tie ever had more than one candidate — the
1159    /// property the extremum-then-refine order exists to guarantee (#2420) —
1160    /// so it is reported rather than observed through a callback.
1161    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    // Unit vectors on S² for each data row. The geodesic distance between rows is
1191    // a monotone-DECREASING function of the dot product `uᵢ·uⱼ = cos γ`, so every
1192    // "distance" comparison below is phrased directly in dot products — each of
1193    // which is exactly rotation invariant.
1194    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    // Mean-direction seed key `dot_to_sum[i] = uᵢ·Σⱼuⱼ`. `Σⱼuⱼ` is
1204    // rotation-EQUIVARIANT (it rotates rigidly with the data), so `argmax` is the
1205    // SAME physical row in every frame. Using the UNNORMALIZED resultant avoids
1206    // the fp blow-up of normalizing a near-zero mean direction on a well-covered
1207    // sphere (`dot_to_sum` is a homogeneous linear function of the resultant, so
1208    // its relative accuracy — and hence the argmax — is stable regardless of the
1209    // resultant's magnitude). Each component sum is taken in value-sorted order so
1210    // the key is also invariant to a pure row permutation (matching
1211    // `select_thin_plate_knots`).
1212    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        // Sorted in parallel but summed sequentially: the value-sorted ORDER is
1216        // what makes the key permutation invariant, and the accumulation must
1217        // stay left-to-right over that order to keep the sum bit-reproducible.
1218        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    // Seed class = rows nearest the mean direction (largest `dot_to_sum`), then
1227    // lexicographically smallest intrinsic dot profile. If that complete key is
1228    // tied, retain the whole symmetry orbit; a row-index tie-break is forbidden.
1229    // Both keys are minimized in extremum-then-refine order, so the `O(n log n)`
1230    // profile key is built only for rows that survive the `O(1)` one — none at
1231    // all when the mean-direction argmax is unique (#2420).
1232    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    // `max_dot[i]` = `max` over chosen centers `c` of `uᵢ·u_c` = `cos` of the
1256    // geodesic distance to the NEAREST chosen center. The maximin step picks the
1257    // unchosen row MINIMIZING it (farthest from all chosen).
1258    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(|&center| spherical_center_dot(&units[i], &units[center]))
1267            .fold(f64::NEG_INFINITY, f64::max);
1268    });
1269
1270    // A dot `≥ 1 − SPHERICAL_CENTER_COINCIDENT_TOL` is a geodesic angle
1271    // `≲ 1.4e-6` rad: the
1272    // candidate coincides with an already-chosen center, so selecting it would add
1273    // a duplicate kernel column and a singular Wahba Gram. Stopping here caps the
1274    // center set at the number of DISTINCT data directions.
1275    while selected.len() < target {
1276        // Maximin: prefer the larger geodesic distance to the chosen set (the
1277        // SMALLER `max_dot`). Exact `max_dot` ties — common on symmetric clouds
1278        // and in float arithmetic — break first toward the MORE PERIPHERAL row
1279        // (smaller `dot_to_sum`, which spreads centers outward and is rotation
1280        // invariant), then by the invariant dot-profile. A tie after all three
1281        // keys is a symmetry orbit and is completed atomically below.
1282        //
1283        // The composite key is minimized in extremum-then-refine order rather
1284        // than by a running incumbent: one parallel reduction for the two `O(1)`
1285        // keys, one parallel filter for the rows attaining them, and the
1286        // `O(n log n)` profile key only over THAT set. The set is also exactly the
1287        // tie-class filter's candidate set, so the profiles are built once and
1288        // serve both. A unique maximin winner therefore builds no profile at all,
1289        // where the incumbent scan built two per outer iteration — one for the
1290        // winner and one to compare the winner against itself (#2420).
1291        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 &center 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    /// The equatorial point is the unique centroid-nearest seed. North and
1387    /// south are then exactly tied by every intrinsic key and are exchanged by
1388    /// a data symmetry, so selecting either one by row index is impossible to
1389    /// reconcile with permutation invariance. The selector must add both as one
1390    /// atomic orbit. A duplicate equatorial row remains one kernel column.
1391    #[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(&centers);
1407            if let Some(expected) = &reference {
1408                assert_eq!(
1409                    &center_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    /// A symmetry orbit is indivisible. If even one orbit is larger than the
1432    /// declared resource budget, refusing is the only bounded equivariant
1433    /// answer; silently choosing a row-index representative is mathematically
1434    /// false and expanding without a bound can turn an O(m) request into O(n).
1435    #[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    /// The canonical gridded-geospatial layout, matching the `sphere_gpu`
1447    /// fixtures: latitude in (-85, 85), longitude spanning [-180, 180].
1448    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    /// Deterministic area-uniform cloud: no exact spherical symmetry, so no two
1460    /// rows can tie the maximin key exactly.
1461    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    /// The `O(n log n)` sorted dot profile is a tie-break, and a tie-break must
1480    /// only be paid for where something is actually tied. A cloud with no exact
1481    /// spherical symmetry has a unique maximin winner at every step, so the
1482    /// selection must complete having built NO profile at all — at any `n`, and
1483    /// for any center budget.
1484    ///
1485    /// The running-incumbent scan this replaced (#2420) built two profiles per
1486    /// outer iteration on exactly this input: one for the winner, and one to
1487    /// compare the winner against its own profile in the tie-class filter. At
1488    /// `m = 200` that was 400 sorts of `n` doubles to discover that nothing was
1489    /// tied, and it was 88% of the whole spherical basis build.
1490    #[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    /// On a regular lat/lon grid the tie-break IS reached — a parallel's rows are
1508    /// genuinely related by a rotation about the polar axis. The cost of reaching
1509    /// it must still be a property of the symmetry, not of the row count: the
1510    /// profile key may only be built for rows that tie both `O(1)` keys at the
1511    /// maximin extremum, so the count stays below one profile per selected center
1512    /// even as `n` grows 16-fold. The scan this replaced built strictly more than
1513    /// two per center regardless of `n`.
1514    #[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    /// The gate above must not be satisfiable by deleting the tie-break. On the
1535    /// pole/equator fixture the profile key is what proves north and south are one
1536    /// indivisible orbit, so it must genuinely be built there.
1537    #[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    /// Extremum-then-refine reaches the same physical answer as the incumbent
1549    /// scan it replaced only if the composite key is evaluated over the same
1550    /// candidate set. On two symmetric parallels every `max_dot` extremum is a
1551    /// multi-row tie, so the whole selection is decided inside the tie logic —
1552    /// and it must still be blind to row order at every budget.
1553    #[test]
1554    fn polar_ring_selection_is_row_order_blind_at_every_budget() {
1555        // Two parallels at ±30°, six points each.
1556        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(&centers));
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(&centers));
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(&center_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        // A strongly non-orthogonal kernel chart plus intercept rescaling. The
1629        // block structure is exactly Matérn's supported final transform: kernel
1630        // coordinates may shear/rescale, while the explicit intercept remains a
1631        // separate structural coordinate.
1632        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            &center_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(&center_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
1673/// Auto-derive a streaming row chunk size for dense basis evaluation.
1674///
1675/// The opt-in `streaming_chunk_size` knob has been removed from public specs:
1676/// streaming activates automatically when the would-be dense buffer
1677/// `n_rows * n_basis_cols * 8 bytes` exceeds 1 GiB. When streaming is
1678/// active, the chunk size is sized so each resident chunk holds ~256 MiB
1679/// of `f64` (`chunk = (256 MiB) / (n_basis_cols * 8)`), clamped to
1680/// `[1024, n_rows]`. Returning `None` means "do not stream, materialize
1681/// densely".
1682pub 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}