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    /// [`hash_arrayview2`] of the view's values: the identity of the MATRIX. A
32    /// data pointer plus strides named an allocation instead (gam#2515): the
33    /// cache outlives the views that feed it, a data matrix freed after one
34    /// basis build is routinely reallocated at the same address for the next
35    /// same-shaped one, and the hit then handed back the previous matrix's
36    /// owned copy as this one's data.
37    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/// Explicit per-run workspace for reusable basis-construction caches.
65///
66/// Pass one workspace through repeated basis builds to avoid global mutable state
67/// and to keep caching scoped to a caller-controlled lifecycle.
68///
69/// Owned-data cache entries are byte-limited via the
70/// [`gam_runtime::resource::ResourcePolicy`] provided at construction; use
71/// [`BasisWorkspace::with_policy`] for large-scale workloads where a single
72/// entry can be multiple gigabytes.
73#[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    /// Returns the resource policy this workspace was configured with.
96    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/// Minimal cache-less intern: wraps an `ArrayView2` into an `Arc<Array2<f64>>`.
140///
141/// Used by derivative-operator builders that don't have a `BasisCacheContext`
142/// in scope (e.g. `build_aniso_design_psi_derivatives_shared`). The goal is the
143/// same as `shared_owned_data_matrix`: move the owned payload into an `Arc`
144/// once so that downstream `StreamingRadialState` copies share it via
145/// `Arc::clone` instead of materializing a fresh n×d `Array2<f64>` per axis.
146#[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/// Minimal cache-less intern for knot centers; mirrors
152/// `shared_owned_data_matrix_from_view`. Centers are typically k×d with k
153/// much smaller than n, but the `Arc::clone` pattern still avoids a k×d
154/// copy per axis when the same operator feeds multiple derivative paths.
155#[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
162/// Compute the kernel reparameterisation transform `Z = null(P_centers^T)`.
163///
164/// `Z` is a `(k, k − C(d+r, r))` orthonormal matrix whose columns span the
165/// null space of the polynomial side-condition system.  Reparameterising the
166/// radial kernel coefficients as `α = Z γ` enforces `P_centers^T α = 0` and
167/// reduces the kernel column count from `k` to `k − C(d+r, r)`.
168///
169/// After this projection the polynomial block `P_data` is appended as separate
170/// explicit unpenalized columns (see `build_duchon_basis_designwithworkspace`),
171/// so the pre-identifiability total width is always `k` (equal to the center
172/// count), regardless of the polynomial null-space dimension.
173///
174/// This is the step that absorbs the full `C(d+r, r)`-dimensional polynomial
175/// null space.  The subsequent `spatial_parametric_constraint_block` step only
176/// removes the intercept.
177pub(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
218/// Translation-invariant side-condition frame (#1375, mirroring the #1269 tp
219/// fix). `Z = null(P(centers)ᵀ)` is mathematically invariant to subtracting a
220/// per-axis constant from `centers` (the polynomial columns `{1, x, …}` and
221/// `{1, x − x̄, …}` span the same space, so `P` has the same column space and
222/// `P^T` the same null space), but the RRQR pivoting that materialises `Z`
223/// drifts under a large coordinate mean — landing on a different orthonormal
224/// basis of the SAME null space, which would desync the design `K·Z` from the
225/// penalty `ZᵀK_CC Z` across a covariate translation. Subtract the center-cloud
226/// per-axis mean so the factorisation is location-standardized; both a raw and
227/// an already-centered caller then produce bit-identical `Z`. The mean is a
228/// fixed property of the (frozen `UserProvided`) centers, replayed identically
229/// at predict.
230fn 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
244/// Factor `Z = null(P(centers)ᵀ)` for centers that are ALREADY mean-centered,
245/// wrapping a degradation context around the failure when the effective order
246/// was auto-degraded below the requested one.
247fn 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
270/// Cache-free canonical Duchon kernel-constraint null space: the SAME
271/// degrade → mean-center → RRQR construction the cached design path uses.
272/// Every consumer of a Duchon `Z` outside the workspace-cached design builder
273/// (e.g. the FFI derivative jets, which must be the exact column-for-column
274/// derivative of the forward design) must route through this one construction;
275/// re-deriving `Z` without the centering lands on a different orthonormal
276/// basis of the same null space and silently desyncs the jet's frame from the
277/// design's.
278pub 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            // Mirror the Duchon path: auto-degrade to Zero (constant-only) when
353            // there aren't enough centers to affinely span [1, x_1, ..., x_d].
354            // kernel_constraint_nullspace_from_matrix would otherwise hard-error
355            // via rrqr_nullspace_basis when centers.nrows() < d + 1.
356            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    // Gate the operator dials on the Matérn-ν RKHS smoothness so a rough kernel
391    // (e.g. ν=1/2) is not over-smoothed by a higher-order roughness penalty its
392    // own RKHS norm does not control (#707).
393    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
397/// True when every entry of `m` is finite.
398fn matrix_all_finite(m: &Array2<f64>) -> bool {
399    m.iter().all(|v| v.is_finite())
400}
401
402/// Discrete function Gram on Matérn's frozen center support.
403///
404/// The embedded primary contains `K_CC` in its kernel block. Evaluating the
405/// represented raw basis at the same centers gives `[K_CC | 1]`; applying the
406/// final kernel-identifiability chart and taking `B_CᵀB_C` therefore provides
407/// an exact compact Gram for this finite-rank representation without touching
408/// the training rows.
409pub(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(&center_design, transform),
433        None => center_design,
434    };
435    Ok(symmetrize_penalty(&fast_ata(&center_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    // gam#1379 — guard the Primary projected kernel Gram itself. It is `Zᵀ K Z`
444    // with a finite Matérn kernel `K`, so it is finite in exact arithmetic; if a
445    // degenerate trial geometry made it non-finite we cannot ship it as a
446    // penalty (the range-block eigensolve would abort the fit). Surface a clear
447    // basis error instead of an opaque downstream "non-finite range penalty".
448    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    // K_CC is strictly positive definite after center rank reduction. The ONLY
465    // structural null direction is the explicitly appended intercept. Kernel
466    // eigenvalues near a floating-point tolerance remain range directions; they
467    // must be conditioned/reduced, never reclassified into a κ-dependent null
468    // projector. This makes penalty topology structural and κ-invariant.
469    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
493/// Creates a Matérn spline basis from data and centers.
494///
495/// The design is `[K | 1]` when `include_intercept=true` and `[K]` otherwise, where:
496/// - `K_ij = k(||x_i - c_j||; length_scale, nu)` is the Matérn kernel block.
497///
498/// The default kernel penalty is `alpha' S alpha` with `S_jl = k(||c_j - c_l||)`, embedded
499/// in the full coefficient space. With intercept included, that column is unpenalized by
500/// `penalty_kernel`; optional `penalty_ridge` is the center-function-metric
501/// penalty for double-penalty shrinkage of the explicit intercept direction.
502///
503/// NOTE: This follows the RKHS Gram construction S = K_CC (not K_CC^{-1}) in
504/// coefficient space, with global scaling absorbed by the smoothing parameter λ.
505pub 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    // Practical safe operating range for κ from center geometry (document Eq. D.2):
556    //   κ in [1e-2 / r_max, 1e2 / r_min], with κ = 1/length_scale.
557    // Warn rather than silently clamp so callers keep explicit control.
558    // Under anisotropy the kernel metric is y-space (y_a = exp(η_a) x_a), so
559    // the relevant r_min/r_max are y-space pairwise distances, not raw.
560    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    // Distance computation: anisotropic when eta is present, isotropic otherwise.
583    // Under anisotropy we work in y-space (y = Ax), so r = |Ah| replaces |h|.
584    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    // Center-center Gram matrix K_CC. In RKHS form, the kernel penalty on
604    // radial coefficients is alpha^T K_CC alpha.
605    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    // RKHS coefficient penalty uses the center Gram matrix directly:
622    //   S = K_CC  (not K_CC^{-1}).
623    // This matches Duchon/Matérn spline theory where alpha^T K_CC alpha is the
624    // native-space quadratic form up to a global scaling absorbed by lambda.
625    penalty_kernel
626        .slice_mut(s![0..k, 0..k])
627        .assign(&center_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        // K_1 = (-ln(u) - 1)/(4π), u = (1 - cos(γ))/2, is log-singular
699        // at coincidence. A finite Gram diagonal therefore cannot be inferred
700        // from this closed form: the old epsilon floor silently selected one,
701        // equivalent to an unstated spectral resolution of about 3.8e9.
702        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
713/// Build a Wahba S² kernel matrix with an explicit kernel family.
714///
715/// Untruncated [`SphereWahbaKernel::Sobolev`] at `m = 1` is refused before
716/// either GPU dispatch or CPU scalar/SIMD evaluation. Its Gram diagonal does
717/// not exist; [`SphereWahbaKernel::SobolevTruncated`] is the explicit-
718/// resolution alternative.
719pub 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    // GPU fast path for the truncated-spectral kernels. The CPU SIMD loop
730    // (`spherical_wahba_kernel_matrix_cpu`) is the bit-defining oracle; the
731    // device only engages when `sphere_kernel_decision` admits the work (large
732    // `n·m`, `lmax ≤ 200`, memory budget). `None` ⇒ quiet CPU route (closed-form
733    // variant, no device, or below threshold); `Some(Err)` ⇒ admitted device
734    // failed ⇒ surface it (never a silent CPU degrade — the engagement-failure
735    // class this path kills).
736    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    // Precompute (sin_lat, cos_lat, sin_lon, cos_lon) for each center once and
768    // reuse it across the whole N x K grid. The pair separation is then pure
769    // `+ - *` arithmetic on those eight numbers — see
770    // `super::sphere_half_angle` for why it is taken in chord form rather than
771    // as a dot product (#2489) and why it costs no transcendental call per
772    // (i, j) either way.
773    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                // SIMD over 4 centers at a time.
804                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                // Scalar tail (0..3 elements).
844                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
910/// Reduce a candidate list that is already tied on every `O(1)` invariant key to
911/// the sub-list attaining the lexicographically least sorted **dot** profile —
912/// the multiset `{uᵢ·u_j : j}` of one row against the whole cloud, in ascending
913/// total order. Rows related by a rotation that maps the point cloud to itself
914/// have the SAME profile, which is what makes it a legal tie-break key: it
915/// depends on neither the frame nor the row order.
916///
917/// The tie-break machinery itself — extremum-then-refine, one `O(n log n)`
918/// profile per candidate serving both the choice and the class filter, none at
919/// all for a lone candidate — is shared with the Euclidean twin
920/// ([`select_thin_plate_knots`]) in [`crate::basis::invariant_tie_break`]. Only
921/// the pairwise scalar differs: a dot product here, a squared distance there.
922fn 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
938/// Remove coincident directions from one invariant tie class without choosing
939/// between genuinely distinct tied directions. Representatives of coincident
940/// rows are geometrically interchangeable and produce the same kernel column;
941/// every distinct member of the symmetry class is retained.
942fn 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
961/// Select spherical-spline basis centers by **geodesic** farthest-point sampling
962/// of the data cloud, returning a well-spread subset of the actual data rows.
963///
964/// This is the rotation-EQUIVARIANT center rule Wahba's reproducing-kernel smooth
965/// needs. The kernel is a function of the geodesic angle alone (`k(cos γ)`,
966/// `cos γ = uᵢ·u_c` for unit vectors `u`), so the continuous estimator is exactly
967/// SO(3)-invariant; the finite-center discretization inherits that invariance
968/// **iff** the centers rotate rigidly with the data. Every ingredient of this
969/// selection is a dot product of data unit vectors — the mean-direction seed key
970/// `uᵢ·Σⱼuⱼ`, the maximin nearest-center dot `max_c uᵢ·u_c`, and the sorted
971/// dot-profile tie-break — and a dot product is invariant under any rotation `R`
972/// (`(Ruᵢ)·(Ru_c) = uᵢ·u_c`). So under ANY rotation of the data the SAME physical
973/// rows are selected, the returned centers are exactly those rows rotated, every
974/// kernel entry `k(uᵢ·u_c)` is preserved, and the fit and every prediction are
975/// invariant to the arbitrary choice of frame (a longitude origin, a tilt, any
976/// element of SO(3)) — matching the rotation-invariant `harmonic` control (#2127).
977///
978/// On a symmetric cloud, invariant scalar keys can leave several **distinct**
979/// rows exactly tied. No row-permutation-equivariant rule can choose one member
980/// of such an orbit: a symmetry exchanging two tied rows would have to both
981/// preserve and change that choice. The selector therefore adds the complete
982/// distinct-direction tie class atomically. Because `num_centers` is an exact
983/// resource contract, a class that does not fit in the remaining budget is
984/// refused as unrepresentable rather than truncated by row index. Coincident
985/// rows remain one kernel column; consequently a request exceeding the number
986/// of distinct directions is also refused rather than silently undersized.
987///
988/// The previous implementation ignored `data` and laid down a fixed golden-angle
989/// (Fibonacci) lattice pinned in the (lat, lon) frame: a rigid rotation moved the
990/// data relative to the STATIONARY centers, changed every data-to-center geodesic
991/// angle, and reshaped the fitted surface. Anchoring only the lattice's longitude
992/// origin to the data (a first pass at #2127) fixed rotations about the pole but
993/// left the frame-pinned latitudes exposed to a tilt.
994///
995/// The selection mirrors the Euclidean thin-plate knot picker
996/// ([`select_thin_plate_knots`]) — centroid-nearest seed, maximin recursion,
997/// invariant tie-breaks — but with geodesic (great-circle) distance in place of
998/// Euclidean distance, which is the correct SO(3) invariant on S². Coincident
999/// data directions are not selected twice (a duplicate center makes the Wahba
1000/// Gram singular).
1001///
1002/// Each step minimizes its composite key in extremum-then-refine order rather
1003/// than by carrying a running incumbent: the two `O(1)` keys in one parallel
1004/// reduction, then the rows attaining them in one parallel filter, then — only
1005/// over that set, and only if it holds more than one row — the `O(n log n)`
1006/// sorted dot profile. Lexicographic minimization is associative, so this is the
1007/// same total preorder the incumbent scan applied; what changes is that the
1008/// profile key is charged where it can still decide something instead of twice
1009/// per outer iteration whether or not anything is tied. On data with no exact
1010/// spherical symmetry it is never built at all (#2420).
1011pub 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    // Return the selected rows VERBATIM (in the data's own lat/lon units), so
1024    // the centers ARE data points and carry the rotation exactly.
1025    Ok(Array2::from_shape_fn((chosen.rows.len(), 2), |(r, c)| {
1026        data[[chosen.rows[r], c]]
1027    }))
1028}
1029
1030/// The row indices [`select_spherical_farthest_point_centers`] selects, with
1031/// the number of sorted dot profiles the selection had to build.
1032pub(crate) struct SphericalCenterSelection {
1033    pub(crate) rows: Vec<usize>,
1034    /// How many `O(n log n)` sorted dot profiles the tie-break machinery built.
1035    /// Zero whenever no invariant tie ever had more than one candidate — the
1036    /// property the extremum-then-refine order exists to guarantee (#2420) —
1037    /// so it is reported rather than observed through a callback.
1038    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    // Unit vectors on S² for each data row. The geodesic distance between rows is
1068    // a monotone-DECREASING function of the dot product `uᵢ·uⱼ = cos γ`, so every
1069    // "distance" comparison below is phrased directly in dot products — each of
1070    // which is exactly rotation invariant.
1071    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    // Mean-direction seed key `dot_to_sum[i] = uᵢ·Σⱼuⱼ`. `Σⱼuⱼ` is
1081    // rotation-EQUIVARIANT (it rotates rigidly with the data), so `argmax` is the
1082    // SAME physical row in every frame. Using the UNNORMALIZED resultant avoids
1083    // the fp blow-up of normalizing a near-zero mean direction on a well-covered
1084    // sphere (`dot_to_sum` is a homogeneous linear function of the resultant, so
1085    // its relative accuracy — and hence the argmax — is stable regardless of the
1086    // resultant's magnitude). Each component sum is taken in value-sorted order so
1087    // the key is also invariant to a pure row permutation (matching
1088    // `select_thin_plate_knots`).
1089    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        // Sorted in parallel but summed sequentially: the value-sorted ORDER is
1093        // what makes the key permutation invariant, and the accumulation must
1094        // stay left-to-right over that order to keep the sum bit-reproducible.
1095        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    // Seed class = rows nearest the mean direction (largest `dot_to_sum`), then
1104    // lexicographically smallest intrinsic dot profile. If that complete key is
1105    // tied, retain the whole symmetry orbit; a row-index tie-break is forbidden.
1106    // Both keys are minimized in extremum-then-refine order, so the `O(n log n)`
1107    // profile key is built only for rows that survive the `O(1)` one — none at
1108    // all when the mean-direction argmax is unique (#2420).
1109    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    // `max_dot[i]` = `max` over chosen centers `c` of `uᵢ·u_c` = `cos` of the
1133    // geodesic distance to the NEAREST chosen center. The maximin step picks the
1134    // unchosen row MINIMIZING it (farthest from all chosen).
1135    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(|&center| spherical_center_dot(&units[i], &units[center]))
1144            .fold(f64::NEG_INFINITY, f64::max);
1145    });
1146
1147    // A dot `≥ 1 − SPHERICAL_CENTER_COINCIDENT_TOL` is a geodesic angle
1148    // `≲ 1.4e-6` rad: the
1149    // candidate coincides with an already-chosen center, so selecting it would add
1150    // a duplicate kernel column and a singular Wahba Gram. Stopping here caps the
1151    // center set at the number of DISTINCT data directions.
1152    while selected.len() < target {
1153        // Maximin: prefer the larger geodesic distance to the chosen set (the
1154        // SMALLER `max_dot`). Exact `max_dot` ties — common on symmetric clouds
1155        // and in float arithmetic — break first toward the MORE PERIPHERAL row
1156        // (smaller `dot_to_sum`, which spreads centers outward and is rotation
1157        // invariant), then by the invariant dot-profile. A tie after all three
1158        // keys is a symmetry orbit and is completed atomically below.
1159        //
1160        // The composite key is minimized in extremum-then-refine order rather
1161        // than by a running incumbent: one parallel reduction for the two `O(1)`
1162        // keys, one parallel filter for the rows attaining them, and the
1163        // `O(n log n)` profile key only over THAT set. The set is also exactly the
1164        // tie-class filter's candidate set, so the profiles are built once and
1165        // serve both. A unique maximin winner therefore builds no profile at all,
1166        // where the incumbent scan built two per outer iteration — one for the
1167        // winner and one to compare the winner against itself (#2420).
1168        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 &center 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    /// The equatorial point is the unique centroid-nearest seed. North and
1264    /// south are then exactly tied by every intrinsic key and are exchanged by
1265    /// a data symmetry, so selecting either one by row index is impossible to
1266    /// reconcile with permutation invariance. The selector must add both as one
1267    /// atomic orbit. A duplicate equatorial row remains one kernel column.
1268    #[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(&centers);
1284            if let Some(expected) = &reference {
1285                assert_eq!(
1286                    &center_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    /// A symmetry orbit is indivisible. If even one orbit is larger than the
1309    /// declared resource budget, refusing is the only bounded equivariant
1310    /// answer; silently choosing a row-index representative is mathematically
1311    /// false and expanding without a bound can turn an O(m) request into O(n).
1312    #[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    /// The canonical gridded-geospatial layout, matching the `sphere_gpu`
1324    /// fixtures: latitude in (-85, 85), longitude spanning [-180, 180].
1325    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    /// Deterministic area-uniform cloud: no exact spherical symmetry, so no two
1337    /// rows can tie the maximin key exactly.
1338    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    /// The `O(n log n)` sorted dot profile is a tie-break, and a tie-break must
1357    /// only be paid for where something is actually tied. A cloud with no exact
1358    /// spherical symmetry has a unique maximin winner at every step, so the
1359    /// selection must complete having built NO profile at all — at any `n`, and
1360    /// for any center budget.
1361    ///
1362    /// The running-incumbent scan this replaced (#2420) built two profiles per
1363    /// outer iteration on exactly this input: one for the winner, and one to
1364    /// compare the winner against its own profile in the tie-class filter. At
1365    /// `m = 200` that was 400 sorts of `n` doubles to discover that nothing was
1366    /// tied, and it was 88% of the whole spherical basis build.
1367    #[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    /// On a regular lat/lon grid the tie-break IS reached — a parallel's rows are
1385    /// genuinely related by a rotation about the polar axis. The cost of reaching
1386    /// it must still be a property of the symmetry, not of the row count: the
1387    /// profile key may only be built for rows that tie both `O(1)` keys at the
1388    /// maximin extremum, so the count stays below one profile per selected center
1389    /// even as `n` grows 16-fold. The scan this replaced built strictly more than
1390    /// two per center regardless of `n`.
1391    #[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    /// The gate above must not be satisfiable by deleting the tie-break. On the
1412    /// pole/equator fixture the profile key is what proves north and south are one
1413    /// indivisible orbit, so it must genuinely be built there.
1414    #[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    /// Extremum-then-refine reaches the same physical answer as the incumbent
1426    /// scan it replaced only if the composite key is evaluated over the same
1427    /// candidate set. On two symmetric parallels every `max_dot` extremum is a
1428    /// multi-row tie, so the whole selection is decided inside the tie logic —
1429    /// and it must still be blind to row order at every budget.
1430    #[test]
1431    fn polar_ring_selection_is_row_order_blind_at_every_budget() {
1432        // Two parallels at ±30°, six points each.
1433        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(&centers));
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(&centers));
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(&center_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        // A strongly non-orthogonal kernel chart plus intercept rescaling. The
1506        // block structure is exactly Matérn's supported final transform: kernel
1507        // coordinates may shear/rescale, while the explicit intercept remains a
1508        // separate structural coordinate.
1509        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            &center_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(&center_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
1550/// Auto-derive a streaming row chunk size for dense basis evaluation.
1551///
1552/// The opt-in `streaming_chunk_size` knob has been removed from public specs:
1553/// streaming activates automatically when the would-be dense buffer
1554/// `n_rows * n_basis_cols * 8 bytes` exceeds 1 GiB. When streaming is
1555/// active, the chunk size is sized so each resident chunk holds ~256 MiB
1556/// of `f64` (`chunk = (256 MiB) / (n_basis_cols * 8)`), clamped to
1557/// `[1024, n_rows]`. Returning `None` means "do not stream, materialize
1558/// densely".
1559pub 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}