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 degraded = effective_order != order;
182    // Translation-invariant side-condition frame (#1375, mirroring the #1269 tp
183    // fix). `Z = null(P(centers)ᵀ)` is mathematically invariant to subtracting a
184    // per-axis constant from `centers` (the polynomial columns `{1, x, …}` and
185    // `{1, x − x̄, …}` span the same space, so `P` has the same column space and
186    // `P^T` the same null space), but the RRQR pivoting that materialises `Z`
187    // drifts under a large coordinate mean — landing on a different orthonormal
188    // basis of the SAME null space, which would desync the design `K·Z` from the
189    // penalty `ZᵀK_CC Z` across a covariate translation. Subtract the center-cloud
190    // per-axis mean so the factorisation is location-standardized; both a raw and
191    // an already-centered caller then produce bit-identical `Z`. The mean is a
192    // fixed property of the (frozen `UserProvided`) centers, replayed identically
193    // at predict.
194    let k = centers.nrows();
195    let d = centers.ncols();
196    let center_mean: Vec<f64> = (0..d)
197        .map(|c| centers.column(c).sum() / (k.max(1) as f64))
198        .collect();
199    let mut centers_centered = centers.to_owned();
200    for c in 0..d {
201        let mu = center_mean[c];
202        centers_centered.column_mut(c).mapv_inplace(|v| v - mu);
203    }
204    let centers = centers_centered.view();
205    let key = ConstraintNullspaceCacheKey {
206        centersrows: centers.nrows(),
207        centers_cols: centers.ncols(),
208        centers_hash: hash_arrayview2(centers),
209        order: ConstraintNullspaceOrderKey::Duchon(effective_order),
210    };
211
212    if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
213        return Ok((**hit).clone());
214    }
215
216    let p_k = polynomial_block_from_order(centers, effective_order);
217    let z = Arc::new(kernel_constraint_nullspace_from_matrix(p_k.view()).map_err(|err| {
218        if degraded {
219            BasisError::InvalidInput(format!(
220                "Duchon degraded from order={:?} to order={:?} due to insufficient centers ({} in dim={}); order={:?} construction then failed: {err}",
221                order,
222                effective_order,
223                centers.nrows(),
224                centers.ncols(),
225                effective_order,
226            ))
227        } else {
228            err
229        }
230    })?);
231
232    if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
233        return Ok((**hit).clone());
234    }
235    cache.constraint_nullspace.map.insert(key, z.clone());
236    cache.constraint_nullspace.order.push(key);
237    while cache.constraint_nullspace.map.len() > CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES {
238        if cache.constraint_nullspace.order.is_empty() {
239            break;
240        }
241        let oldkey = cache.constraint_nullspace.order.remove(0);
242        cache.constraint_nullspace.map.remove(&oldkey);
243    }
244
245    Ok((*z).clone())
246}
247
248pub(crate) fn thin_plate_kernel_constraint_nullspace(
249    centers: ArrayView2<'_, f64>,
250    cache: &mut BasisCacheContext,
251) -> Result<Array2<f64>, BasisError> {
252    let key = ConstraintNullspaceCacheKey {
253        centersrows: centers.nrows(),
254        centers_cols: centers.ncols(),
255        centers_hash: hash_arrayview2(centers),
256        order: ConstraintNullspaceOrderKey::ThinPlate,
257    };
258
259    if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
260        return Ok((**hit).clone());
261    }
262
263    let p_k = thin_plate_polynomial_block(centers);
264    if centers.nrows() < p_k.ncols() {
265        crate::bail_invalid_basis!(
266            "thin-plate spline requires at least {} centers to span the degree-{} polynomial null space in dimension {}; got {}",
267            p_k.ncols(),
268            thin_plate_polynomial_degree(centers.ncols()),
269            centers.ncols(),
270            centers.nrows()
271        );
272    }
273    let (z, rank) =
274        rrqr_nullspace_basis(&p_k, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
275    if rank != p_k.ncols() {
276        crate::bail_invalid_basis!(
277            "thin-plate spline polynomial block is rank deficient at the selected centers: expected rank {}, got {}; choose geometrically independent centers for dimension {}",
278            p_k.ncols(),
279            rank,
280            centers.ncols()
281        );
282    }
283    let z = Arc::new(z);
284
285    if let Some(hit) = cache.constraint_nullspace.map.get(&key) {
286        return Ok((**hit).clone());
287    }
288    cache.constraint_nullspace.map.insert(key, z.clone());
289    cache.constraint_nullspace.order.push(key);
290    while cache.constraint_nullspace.map.len() > CONSTRAINT_NULLSPACE_CACHE_MAX_ENTRIES {
291        if cache.constraint_nullspace.order.is_empty() {
292            break;
293        }
294        let oldkey = cache.constraint_nullspace.order.remove(0);
295        cache.constraint_nullspace.map.remove(&oldkey);
296    }
297
298    Ok((*z).clone())
299}
300
301pub(crate) fn matern_identifiability_transform(
302    centers: ArrayView2<'_, f64>,
303    identifiability: &MaternIdentifiability,
304) -> Result<Option<Array2<f64>>, BasisError> {
305    let k = centers.nrows();
306    match identifiability {
307        MaternIdentifiability::None => Ok(None),
308        MaternIdentifiability::CenterSumToZero => {
309            let q = Array2::<f64>::ones((k, 1));
310            Ok(Some(kernel_constraint_nullspace_from_matrix(q.view())?))
311        }
312        MaternIdentifiability::CenterLinearOrthogonal => {
313            // Mirror the Duchon path: auto-degrade to Zero (constant-only) when
314            // there aren't enough centers to affinely span [1, x_1, ..., x_d].
315            // kernel_constraint_nullspace_from_matrix would otherwise hard-error
316            // via rrqr_nullspace_basis when centers.nrows() < d + 1.
317            let effective_order =
318                duchon_effective_nullspace_order(centers, DuchonNullspaceOrder::Linear);
319            let q = polynomial_block_from_order(centers, effective_order);
320            Ok(Some(kernel_constraint_nullspace_from_matrix(q.view())?))
321        }
322        MaternIdentifiability::FrozenTransform { transform, .. } => {
323            if transform.nrows() != k {
324                crate::bail_dim_basis!(
325                    "frozen Matérn identifiability transform mismatch: centers={k}, transform rows={}",
326                    transform.nrows()
327                );
328            }
329            Ok(Some(transform.clone()))
330        }
331    }
332}
333
334pub(crate) fn build_matern_operator_penalty_candidates(
335    centers: ArrayView2<'_, f64>,
336    length_scale: f64,
337    nu: MaternNu,
338    include_intercept: bool,
339    z_opt: Option<&Array2<f64>>,
340    aniso_log_scales: Option<&[f64]>,
341) -> Result<Vec<PenaltyCandidate>, BasisError> {
342    let ops = build_matern_collocation_operator_matrices(
343        centers,
344        None,
345        length_scale,
346        nu,
347        include_intercept,
348        z_opt.map(|z| z.view()),
349        aniso_log_scales,
350    )?;
351    // Gate the operator dials on the Matérn-ν RKHS smoothness so a rough kernel
352    // (e.g. ν=1/2) is not over-smoothed by a higher-order roughness penalty its
353    // own RKHS norm does not control (#707).
354    let matern_spec = DuchonOperatorPenaltySpec::matern_for_smoothness(nu, centers.ncols());
355    operator_penalty_candidates_from_collocation(&ops.d0, &ops.d1, &ops.d2, &matern_spec)
356}
357
358/// True when every entry of `m` is finite.
359fn matrix_all_finite(m: &Array2<f64>) -> bool {
360    m.iter().all(|v| v.is_finite())
361}
362
363/// Discrete function Gram on Matérn's frozen center support.
364///
365/// The embedded primary contains `K_CC` in its kernel block. Evaluating the
366/// represented raw basis at the same centers gives `[K_CC | 1]`; applying the
367/// final kernel-identifiability chart and taking `B_CᵀB_C` therefore provides
368/// an exact compact Gram for this finite-rank representation without touching
369/// the training rows.
370pub(crate) fn matern_center_function_gram(
371    embedded_kernel: &Array2<f64>,
372    include_intercept: bool,
373    full_transform: Option<&Array2<f64>>,
374) -> Result<Array2<f64>, BasisError> {
375    if embedded_kernel.nrows() != embedded_kernel.ncols() {
376        crate::bail_dim_basis!("Matérn embedded kernel penalty must be square");
377    }
378    let total = embedded_kernel.nrows();
379    let k = total
380        .checked_sub(usize::from(include_intercept))
381        .ok_or_else(|| BasisError::InvalidInput("Matérn basis width underflow".to_string()))?;
382    if k == 0 {
383        crate::bail_invalid_basis!("Matérn function metric requires at least one center");
384    }
385    let mut center_design = Array2::<f64>::zeros((k, total));
386    center_design
387        .slice_mut(s![.., 0..k])
388        .assign(&embedded_kernel.slice(s![0..k, 0..k]));
389    if include_intercept {
390        center_design.column_mut(k).fill(1.0);
391    }
392    let center_design = match full_transform {
393        Some(transform) => fast_ab(&center_design, transform),
394        None => center_design,
395    };
396    Ok(symmetrize_penalty(&fast_ata(&center_design)))
397}
398
399pub(crate) fn matern_double_penalty_candidates(
400    primary: &Array2<f64>,
401    function_gram: &Array2<f64>,
402    include_intercept: bool,
403) -> Result<Vec<PenaltyCandidate>, BasisError> {
404    // gam#1379 — guard the Primary projected kernel Gram itself. It is `Zᵀ K Z`
405    // with a finite Matérn kernel `K`, so it is finite in exact arithmetic; if a
406    // degenerate trial geometry made it non-finite we cannot ship it as a
407    // penalty (the range-block eigensolve would abort the fit). Surface a clear
408    // basis error instead of an opaque downstream "non-finite range penalty".
409    if !matrix_all_finite(primary) {
410        crate::bail_invalid_basis!(
411            "Matérn double-penalty primary kernel Gram is non-finite; the projected \
412             kernel `Zᵀ K Z` could not be formed at this length scale (degenerate \
413             geometry). Widen the data spread, change the length scale, or drop the term."
414        );
415    }
416    if primary.dim() != function_gram.dim() || !matrix_all_finite(function_gram) {
417        crate::bail_invalid_basis!(
418            "Matérn center function Gram is non-finite or does not match the primary penalty"
419        );
420    }
421    let mut candidates = vec![normalize_penalty_candidate(
422        primary.clone(),
423        PenaltySource::Primary,
424    )?];
425    // K_CC is strictly positive definite after center rank reduction. The ONLY
426    // structural null direction is the explicitly appended intercept. Kernel
427    // eigenvalues near a floating-point tolerance remain range directions; they
428    // must be conditioned/reduced, never reclassified into a κ-dependent null
429    // projector. This makes penalty topology structural and κ-invariant.
430    if include_intercept {
431        let p = primary.nrows();
432        let mut intercept_frame = Array2::<f64>::zeros((p, 1));
433        intercept_frame[[p - 1, 0]] = 1.0;
434        let shrinkage = function_space_subspace_shrinkage(&intercept_frame, function_gram)?;
435        candidates.push(normalize_penalty_candidate(
436            shrinkage,
437            PenaltySource::DoublePenaltyNullspace,
438        )?);
439    }
440    Ok(candidates)
441}
442
443pub(crate) fn build_matern_double_penalty_candidates(
444    spline: &MaternSplineBasis,
445    full_transform: Option<&Array2<f64>>,
446) -> Result<Vec<PenaltyCandidate>, BasisError> {
447    let primary = project_penalty_matrix(&spline.penalty_kernel, full_transform);
448    let include_intercept = spline.num_polynomial_basis == 1;
449    let function_gram =
450        matern_center_function_gram(&spline.penalty_kernel, include_intercept, full_transform)?;
451    matern_double_penalty_candidates(&primary, &function_gram, include_intercept)
452}
453
454/// Creates a Matérn spline basis from data and centers.
455///
456/// The design is `[K | 1]` when `include_intercept=true` and `[K]` otherwise, where:
457/// - `K_ij = k(||x_i - c_j||; length_scale, nu)` is the Matérn kernel block.
458///
459/// The default kernel penalty is `alpha' S alpha` with `S_jl = k(||c_j - c_l||)`, embedded
460/// in the full coefficient space. With intercept included, that column is unpenalized by
461/// `penalty_kernel`; optional `penalty_ridge` is the center-function-metric
462/// penalty for double-penalty shrinkage of the explicit intercept direction.
463///
464/// NOTE: This follows the RKHS Gram construction S = K_CC (not K_CC^{-1}) in
465/// coefficient space, with global scaling absorbed by the smoothing parameter λ.
466pub fn create_matern_spline_basiswithworkspace(
467    data: ArrayView2<'_, f64>,
468    centers: ArrayView2<'_, f64>,
469    length_scale: f64,
470    nu: MaternNu,
471    include_intercept: bool,
472    aniso_log_scales: Option<&[f64]>,
473    workspace: &mut BasisWorkspace,
474) -> Result<MaternSplineBasis, BasisError> {
475    let n = data.nrows();
476    let d = data.ncols();
477    let k = centers.nrows();
478    let total_cols = k + usize::from(include_intercept);
479    let dense_bytes = dense_design_bytes(n, total_cols);
480    if dense_bytes > workspace.policy().max_single_materialization_bytes {
481        crate::bail_invalid_basis!(
482            "Matérn basis dense design exceeds resource policy: n={n}, p={total_cols}, dense={:.1} MiB, cap={:.1} MiB",
483            dense_bytes as f64 / (1024.0 * 1024.0),
484            workspace.policy().max_single_materialization_bytes as f64 / (1024.0 * 1024.0),
485        );
486    }
487
488    if d == 0 {
489        crate::bail_invalid_basis!("Matérn basis requires at least one covariate dimension");
490    }
491    if k == 0 {
492        crate::bail_invalid_basis!("Matérn basis requires at least one center");
493    }
494    if centers.ncols() != d {
495        crate::bail_dim_basis!(
496            "Matérn basis dimension mismatch: data has {d} columns, centers have {}",
497            centers.ncols()
498        );
499    }
500    if data.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
501        crate::bail_invalid_basis!("Matérn basis requires finite data and center values");
502    }
503    validate_matern_length_scale(length_scale)?;
504    if let Some(eta) = aniso_log_scales {
505        if eta.len() != d {
506            crate::bail_dim_basis!(
507                "aniso_log_scales length {} does not match data dimension {d}",
508                eta.len()
509            );
510        }
511        if eta.iter().any(|v| !v.is_finite()) {
512            crate::bail_invalid_basis!("aniso_log_scales must contain finite values");
513        }
514    }
515
516    // Practical safe operating range for κ from center geometry (document Eq. D.2):
517    //   κ in [1e-2 / r_max, 1e2 / r_min], with κ = 1/length_scale.
518    // Warn rather than silently clamp so callers keep explicit control.
519    // Under anisotropy the kernel metric is y-space (y_a = exp(η_a) x_a), so
520    // the relevant r_min/r_max are y-space pairwise distances, not raw.
521    let warn_bounds = if let Some(eta) = aniso_log_scales {
522        let y_centers = points_in_aniso_y_space(centers, eta);
523        pairwise_distance_bounds(y_centers.view())
524    } else {
525        pairwise_distance_bounds(centers)
526    };
527    if let Some((r_min, r_max)) = warn_bounds {
528        let kappa = 1.0 / length_scale.max(1e-300);
529        let kappa_lo = 1e-2 / r_max;
530        let kappa_hi = 1e2 / r_min;
531        if kappa < kappa_lo || kappa > kappa_hi {
532            log::debug!(
533                "Matérn κ={} is outside recommended range [{}, {}] derived from centers (r_min={}, r_max={}); kernel conditioning may degrade",
534                kappa,
535                kappa_lo,
536                kappa_hi,
537                r_min,
538                r_max
539            );
540        }
541    }
542
543    // Distance computation: anisotropic when eta is present, isotropic otherwise.
544    // Under anisotropy we work in y-space (y = Ax), so r = |Ah| replaces |h|.
545    let mut kernel_block = Array2::<f64>::zeros((n, k));
546    let mut center_kernel = Array2::<f64>::zeros((k, k));
547    let axis_scales = aniso_log_scales.map(aniso_axis_scales);
548    let kernel_result: Result<(), BasisError> = kernel_block
549        .axis_iter_mut(Axis(0))
550        .into_par_iter()
551        .enumerate()
552        .try_for_each(|(i, mut row)| {
553            for j in 0..k {
554                let r = if let Some(scales) = axis_scales.as_deref() {
555                    aniso_distance_rows_with_scales(data, i, centers, j, scales)
556                } else {
557                    euclidean_distance_rows(data, i, centers, j)
558                };
559                row[j] = matern_kernel_from_distance(r, length_scale, nu)?;
560            }
561            Ok(())
562        });
563    kernel_result?;
564    // Center-center Gram matrix K_CC. In RKHS form, the kernel penalty on
565    // radial coefficients is alpha^T K_CC alpha.
566    fill_symmetric_from_row_kernel(&mut center_kernel, |i, j| {
567        let r = if let Some(scales) = axis_scales.as_deref() {
568            aniso_distance_rows_with_scales(centers, i, centers, j, scales)
569        } else {
570            euclidean_distance_rows(centers, i, centers, j)
571        };
572        matern_kernel_from_distance(r, length_scale, nu)
573    })?;
574
575    let mut basis = Array2::<f64>::zeros((n, total_cols));
576    basis.slice_mut(s![.., 0..k]).assign(&kernel_block);
577    if include_intercept {
578        basis.column_mut(k).fill(1.0);
579    }
580
581    let mut penalty_kernel = Array2::<f64>::zeros((total_cols, total_cols));
582    // RKHS coefficient penalty uses the center Gram matrix directly:
583    //   S = K_CC  (not K_CC^{-1}).
584    // This matches Duchon/Matérn spline theory where alpha^T K_CC alpha is the
585    // native-space quadratic form up to a global scaling absorbed by lambda.
586    penalty_kernel
587        .slice_mut(s![0..k, 0..k])
588        .assign(&center_kernel);
589    let function_gram = matern_center_function_gram(&penalty_kernel, include_intercept, None)?;
590    let penalty_ridge = if include_intercept {
591        let mut intercept_frame = Array2::<f64>::zeros((total_cols, 1));
592        intercept_frame[[total_cols - 1, 0]] = 1.0;
593        function_space_subspace_shrinkage(&intercept_frame, &function_gram)?
594    } else {
595        Array2::<f64>::zeros((total_cols, total_cols))
596    };
597
598    Ok(MaternSplineBasis {
599        basis,
600        penalty_kernel,
601        penalty_ridge,
602        num_kernel_basis: k,
603        num_polynomial_basis: usize::from(include_intercept),
604        dimension: d,
605    })
606}
607
608#[inline]
609pub(crate) fn validate_lat_lon_matrix(
610    data: ArrayView2<'_, f64>,
611    context: &str,
612    radians: bool,
613) -> Result<(), BasisError> {
614    if data.ncols() != 2 {
615        crate::bail_dim_basis!(
616            "{context} requires exactly two columns: latitude and longitude; got {}",
617            data.ncols()
618        );
619    }
620    if data.nrows() == 0 {
621        crate::bail_invalid_basis!("{context} requires at least one row");
622    }
623    let (lat_lo, lat_hi, unit) = if radians {
624        (
625            -std::f64::consts::FRAC_PI_2,
626            std::f64::consts::FRAC_PI_2,
627            "radians",
628        )
629    } else {
630        (-90.0, 90.0, "degrees")
631    };
632    for (i, row) in data.outer_iter().enumerate() {
633        let lat = row[0];
634        let lon = row[1];
635        if !lat.is_finite() || !lon.is_finite() {
636            crate::bail_invalid_basis!(
637                "{context} requires finite latitude/longitude; row {i} has ({lat}, {lon})"
638            );
639        }
640        if !(lat_lo..=lat_hi).contains(&lat) {
641            crate::bail_invalid_basis!(
642                "{context} latitude must be in [{lat_lo}, {lat_hi}] {unit}; row {i} has {lat}"
643            );
644        }
645    }
646    Ok(())
647}
648
649fn validate_spherical_wahba_gram_request(
650    penalty_order: usize,
651    kernel: SphereWahbaKernel,
652) -> Result<(), BasisError> {
653    if !(1..=4).contains(&penalty_order) {
654        crate::bail_invalid_basis!(
655            "spherical spline penalty_order must be one of 1, 2, 3, 4; got {penalty_order}"
656        );
657    }
658    if matches!(kernel, SphereWahbaKernel::Sobolev) && penalty_order == 1 {
659        // K_1 = (-ln(u) - 1)/(4π), u = (1 - cos(γ))/2, is log-singular
660        // at coincidence. A finite Gram diagonal therefore cannot be inferred
661        // from this closed form: the old epsilon floor silently selected one,
662        // equivalent to an unstated spectral resolution of about 3.8e9.
663        crate::bail_invalid_basis!(
664            "the m = 1 Sobolev sphere kernel is log-singular at coincident points, so its Gram \
665             diagonal does not exist and any finite value is a choice of resolution rather than a \
666             limit; use SobolevTruncated {{ lmax }} (the same kernel with the resolution stated, \
667             diagonal ~ ln(lmax)/2pi) or penalty_order >= 2, whose diagonals are finite closed \
668             forms (1/(4pi) at m = 2, (2*zeta3 - 2)/(4pi) at m = 3)"
669        );
670    }
671    Ok(())
672}
673
674/// Build a Wahba S² kernel matrix with the untruncated Sobolev kernel.
675///
676/// Untruncated Sobolev `m = 1` is refused because its coincident-point value
677/// diverges; use [`SphereWahbaKernel::SobolevTruncated`] with
678/// [`spherical_wahba_kernel_matrix_with_kind`] to state a finite resolution.
679pub fn spherical_wahba_kernel_matrix(
680    data: ArrayView2<'_, f64>,
681    centers: ArrayView2<'_, f64>,
682    penalty_order: usize,
683    radians: bool,
684) -> Result<Array2<f64>, BasisError> {
685    spherical_wahba_kernel_matrix_with_kind(
686        data,
687        centers,
688        penalty_order,
689        radians,
690        SphereWahbaKernel::Sobolev,
691    )
692}
693
694/// Build a Wahba S² kernel matrix with an explicit kernel family.
695///
696/// Untruncated [`SphereWahbaKernel::Sobolev`] at `m = 1` is refused before
697/// either GPU dispatch or CPU scalar/SIMD evaluation. Its Gram diagonal does
698/// not exist; [`SphereWahbaKernel::SobolevTruncated`] is the explicit-
699/// resolution alternative.
700pub fn spherical_wahba_kernel_matrix_with_kind(
701    data: ArrayView2<'_, f64>,
702    centers: ArrayView2<'_, f64>,
703    penalty_order: usize,
704    radians: bool,
705    kernel: SphereWahbaKernel,
706) -> Result<Array2<f64>, BasisError> {
707    validate_spherical_wahba_gram_request(penalty_order, kernel)?;
708    validate_lat_lon_matrix(data, "spherical spline data", radians)?;
709    validate_lat_lon_matrix(centers, "spherical spline centers", radians)?;
710    // GPU fast path for the truncated-spectral kernels. The CPU SIMD loop
711    // (`spherical_wahba_kernel_matrix_cpu`) is the bit-defining oracle; the
712    // device only engages when `sphere_kernel_decision` admits the work (large
713    // `n·m`, `lmax ≤ 200`, memory budget). `None` ⇒ quiet CPU route (closed-form
714    // variant, no device, or below threshold); `Some(Err)` ⇒ admitted device
715    // failed ⇒ surface it (never a silent CPU degrade — the engagement-failure
716    // class this path kills).
717    if let Some(gpu_result) = crate::basis::sphere_gpu::try_build_truncated_kernel_matrix_gpu(
718        data,
719        centers,
720        penalty_order,
721        radians,
722        kernel,
723    ) {
724        let gpu_matrix = gpu_result.map_err(|err| {
725            BasisError::InvalidInput(format!(
726                "spherical spline GPU truncated kernel was admitted but failed on device: {err}"
727            ))
728        })?;
729        return Ok(gpu_matrix);
730    }
731    spherical_wahba_kernel_matrix_cpu_validated(data, centers, penalty_order, radians, kernel)
732}
733
734/// CPU oracle for the Wahba S² kernel design matrix — the bit-defining
735/// reference the GPU truncated path is held to. Always evaluates on host,
736/// regardless of the GPU dispatch decision, so parity tests and any caller that
737/// needs the deterministic reference can bypass device routing entirely.
738///
739/// It enforces the same kernel/order contract as
740/// [`spherical_wahba_kernel_matrix_with_kind`]; bypassing device routing does
741/// not bypass mathematical validation.
742pub fn spherical_wahba_kernel_matrix_cpu(
743    data: ArrayView2<'_, f64>,
744    centers: ArrayView2<'_, f64>,
745    penalty_order: usize,
746    radians: bool,
747    kernel: SphereWahbaKernel,
748) -> Result<Array2<f64>, BasisError> {
749    validate_spherical_wahba_gram_request(penalty_order, kernel)?;
750    validate_lat_lon_matrix(data, "spherical spline data", radians)?;
751    validate_lat_lon_matrix(centers, "spherical spline centers", radians)?;
752    spherical_wahba_kernel_matrix_cpu_validated(data, centers, penalty_order, radians, kernel)
753}
754
755fn spherical_wahba_kernel_matrix_cpu_validated(
756    data: ArrayView2<'_, f64>,
757    centers: ArrayView2<'_, f64>,
758    penalty_order: usize,
759    radians: bool,
760    kernel: SphereWahbaKernel,
761) -> Result<Array2<f64>, BasisError> {
762    let n = data.nrows();
763    let k = centers.nrows();
764    let deg = if radians {
765        1.0
766    } else {
767        std::f64::consts::PI / 180.0
768    };
769    // Precompute (sin_lat, cos_lat, sin_lon, cos_lon) for each center once and
770    // reuse it across the whole N x K grid. The pair separation is then pure
771    // `+ - *` arithmetic on those eight numbers — see
772    // `super::sphere_half_angle` for why it is taken in chord form rather than
773    // as a dot product (#2489) and why it costs no transcendental call per
774    // (i, j) either way.
775    let mut sin_lat_c = Vec::<f64>::with_capacity(k);
776    let mut cos_lat_c = Vec::<f64>::with_capacity(k);
777    let mut sin_lon_c = Vec::<f64>::with_capacity(k);
778    let mut cos_lon_c = Vec::<f64>::with_capacity(k);
779    for c in centers.outer_iter() {
780        let trig = SphereTrig::from_radians(c[0] * deg, c[1] * deg);
781        sin_lat_c.push(trig.sin_lat);
782        cos_lat_c.push(trig.cos_lat);
783        sin_lon_c.push(trig.sin_lon);
784        cos_lon_c.push(trig.cos_lon);
785    }
786    let mut out = Array2::<f64>::zeros((n, k));
787    let err_flag = std::sync::atomic::AtomicBool::new(false);
788    out.axis_chunks_iter_mut(ndarray::Axis(0), 256)
789        .into_par_iter()
790        .enumerate()
791        .for_each(|(chunk_idx, mut block)| {
792            use wide::f64x4;
793            let row_offset = chunk_idx * 256;
794            let chunks = k / 4;
795            let tail = k % 4;
796            for (local_i, mut out_row) in block.outer_iter_mut().enumerate() {
797                let i = row_offset + local_i;
798                let row = SphereTrig::from_radians(data[(i, 0)] * deg, data[(i, 1)] * deg);
799                let row_v = SphereTrig {
800                    sin_lat: f64x4::from(row.sin_lat),
801                    cos_lat: f64x4::from(row.cos_lat),
802                    sin_lon: f64x4::from(row.sin_lon),
803                    cos_lon: f64x4::from(row.cos_lon),
804                };
805                // SIMD over 4 centers at a time.
806                for cidx in 0..chunks {
807                    let base = cidx * 4;
808                    let center_v = SphereTrig {
809                        sin_lat: f64x4::from([
810                            sin_lat_c[base],
811                            sin_lat_c[base + 1],
812                            sin_lat_c[base + 2],
813                            sin_lat_c[base + 3],
814                        ]),
815                        cos_lat: f64x4::from([
816                            cos_lat_c[base],
817                            cos_lat_c[base + 1],
818                            cos_lat_c[base + 2],
819                            cos_lat_c[base + 3],
820                        ]),
821                        sin_lon: f64x4::from([
822                            sin_lon_c[base],
823                            sin_lon_c[base + 1],
824                            sin_lon_c[base + 2],
825                            sin_lon_c[base + 3],
826                        ]),
827                        cos_lon: f64x4::from([
828                            cos_lon_c[base],
829                            cos_lon_c[base + 1],
830                            cos_lon_c[base + 2],
831                            cos_lon_c[base + 3],
832                        ]),
833                    };
834                    let (u, v) = half_angle_separation(row_v, center_v);
835                    let vals = wahba_sphere_kernel_simd_kind(u, v, penalty_order, kernel);
836                    let arr = vals.to_array();
837                    for lane in 0..4 {
838                        if !arr[lane].is_finite() {
839                            err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
840                            return;
841                        }
842                        out_row[base + lane] = arr[lane];
843                    }
844                }
845                // Scalar tail (0..3 elements).
846                let tail_start = chunks * 4;
847                for t in 0..tail {
848                    let j = tail_start + t;
849                    let center = SphereTrig {
850                        sin_lat: sin_lat_c[j],
851                        cos_lat: cos_lat_c[j],
852                        sin_lon: sin_lon_c[j],
853                        cos_lon: cos_lon_c[j],
854                    };
855                    let sep = half_angle_separation_scalar(row, center);
856                    match wahba_sphere_kernel_kind(sep, penalty_order, kernel) {
857                        Ok(v) => out_row[j] = v,
858                        Err(_) => {
859                            err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
860                            return;
861                        }
862                    }
863                }
864            }
865        });
866    if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
867        crate::bail_invalid_basis!("spherical spline kernel produced a non-finite value");
868    }
869    Ok(out)
870}
871
872#[cfg(test)]
873mod spherical_wahba_kernel_contract_2475_tests {
874    use super::*;
875    use ndarray::array;
876
877    fn assert_sobolev_m1_refusal(entry_point: &str, result: Result<Array2<f64>, BasisError>) {
878        let error = result.expect_err("untruncated Sobolev m=1 has no Gram diagonal");
879        let message = error.to_string();
880        assert!(
881            message.contains("log-singular") && message.contains("SobolevTruncated"),
882            "{entry_point} must identify both the mathematical defect and the explicit-resolution \
883             remedy; got: {message}"
884        );
885    }
886
887    #[test]
888    fn all_public_matrix_entry_points_refuse_untruncated_sobolev_m1() {
889        // Deliberately use distinct points. Refusal is a structural property of
890        // the requested Gram-kernel family, not a floating-point coincidence test.
891        let data = array![[0.0, 0.0]];
892        let centers = array![[35.0, 70.0]];
893
894        assert_sobolev_m1_refusal(
895            "spherical_wahba_kernel_matrix",
896            spherical_wahba_kernel_matrix(data.view(), centers.view(), 1, false),
897        );
898        assert_sobolev_m1_refusal(
899            "spherical_wahba_kernel_matrix_with_kind",
900            spherical_wahba_kernel_matrix_with_kind(
901                data.view(),
902                centers.view(),
903                1,
904                false,
905                SphereWahbaKernel::Sobolev,
906            ),
907        );
908        assert_sobolev_m1_refusal(
909            "spherical_wahba_kernel_matrix_cpu",
910            spherical_wahba_kernel_matrix_cpu(
911                data.view(),
912                centers.view(),
913                1,
914                false,
915                SphereWahbaKernel::Sobolev,
916            ),
917        );
918    }
919
920    #[test]
921    fn explicit_resolution_and_finite_diagonal_m1_kernels_remain_available() {
922        let point = array![[0.0, 0.0]];
923
924        let pseudo = spherical_wahba_kernel_matrix_with_kind(
925            point.view(),
926            point.view(),
927            1,
928            false,
929            SphereWahbaKernel::Pseudo,
930        )
931        .expect("pseudo-Wahba m=1 has a finite analytic coincident-point value");
932        assert_eq!(
933            pseudo[(0, 0)],
934            1.0 / (4.0 * std::f64::consts::PI),
935            "the refusal must not absorb valid pseudo-Wahba m=1"
936        );
937
938        let truncated = spherical_wahba_kernel_matrix_with_kind(
939            point.view(),
940            point.view(),
941            1,
942            false,
943            SphereWahbaKernel::SobolevTruncated { lmax: 16 },
944        )
945        .expect("explicitly truncated Sobolev m=1 has a stated finite resolution");
946        assert!(
947            truncated[(0, 0)].is_finite(),
948            "a stated spectral resolution must produce a finite Gram diagonal"
949        );
950
951        spherical_wahba_kernel_matrix(point.view(), point.view(), 2, false)
952            .expect("untruncated Sobolev m=2 has a finite closed-form diagonal");
953    }
954}
955
956pub(crate) fn weighted_coefficient_sum_to_zero_transform(
957    weights: ArrayView1<'_, f64>,
958) -> Result<Array2<f64>, BasisError> {
959    let k = weights.len();
960    if k < 2 {
961        return Err(BasisError::InsufficientColumnsForConstraint { found: k });
962    }
963    if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
964        crate::bail_invalid_basis!(
965            "sphere coefficient constraint weights must be finite and non-negative"
966        );
967    }
968    let norm = weights.iter().map(|w| w * w).sum::<f64>().sqrt();
969    if norm <= 0.0 {
970        crate::bail_invalid_basis!("sphere coefficient constraint weights cannot all be zero");
971    }
972    let c = Array2::from_shape_vec((k, 1), weights.iter().map(|w| *w / norm).collect())
973        .map_err(|e| BasisError::InvalidInput(format!("invalid sphere constraint weights: {e}")))?;
974    let (z, rank) =
975        rrqr_nullspace_basis(&c, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
976    if rank >= k {
977        return Err(BasisError::ConstraintNullspaceCollapsed {
978            site: "weighted_coefficient_sum_to_zero_transform",
979            cross_rank: rank,
980            coeff_dim: k,
981            cross_frobenius: 1.0,
982            gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
983                .to_string(),
984        });
985    }
986    Ok(z)
987}
988
989const SPHERICAL_CENTER_COINCIDENT_TOL: f64 = 1.0e-12;
990
991#[inline]
992fn spherical_center_dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
993    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
994}
995
996/// Reduce a candidate list that is already tied on every `O(1)` invariant key to
997/// the sub-list attaining the lexicographically least sorted **dot** profile —
998/// the multiset `{uᵢ·u_j : j}` of one row against the whole cloud, in ascending
999/// total order. Rows related by a rotation that maps the point cloud to itself
1000/// have the SAME profile, which is what makes it a legal tie-break key: it
1001/// depends on neither the frame nor the row order.
1002///
1003/// The tie-break machinery itself — extremum-then-refine, one `O(n log n)`
1004/// profile per candidate serving both the choice and the class filter, none at
1005/// all for a lone candidate — is shared with the Euclidean twin
1006/// ([`select_thin_plate_knots`]) in [`crate::basis::invariant_tie_break`]. Only
1007/// the pairwise scalar differs: a dot product here, a squared distance there.
1008fn resolve_spherical_profile_tie<F>(
1009    units: &[[f64; 3]],
1010    tied: &[usize],
1011    on_profile_builds: &mut F,
1012) -> Vec<usize>
1013where
1014    F: FnMut(usize),
1015{
1016    resolve_sorted_profile_tie(
1017        units.len(),
1018        tied,
1019        |anchor, row| spherical_center_dot(&units[anchor], &units[row]),
1020        on_profile_builds,
1021    )
1022}
1023
1024/// Remove coincident directions from one invariant tie class without choosing
1025/// between genuinely distinct tied directions. Representatives of coincident
1026/// rows are geometrically interchangeable and produce the same kernel column;
1027/// every distinct member of the symmetry class is retained.
1028fn distinct_spherical_orbit(
1029    units: &[[f64; 3]],
1030    candidates: &[usize],
1031    already_selected: &[usize],
1032) -> Vec<usize> {
1033    let mut distinct = Vec::with_capacity(candidates.len());
1034    'candidate: for &candidate in candidates {
1035        for &selected in already_selected.iter().chain(distinct.iter()) {
1036            if spherical_center_dot(&units[candidate], &units[selected])
1037                >= 1.0 - SPHERICAL_CENTER_COINCIDENT_TOL
1038            {
1039                continue 'candidate;
1040            }
1041        }
1042        distinct.push(candidate);
1043    }
1044    distinct
1045}
1046
1047/// Select spherical-spline basis centers by **geodesic** farthest-point sampling
1048/// of the data cloud, returning a well-spread subset of the actual data rows.
1049///
1050/// This is the rotation-EQUIVARIANT center rule Wahba's reproducing-kernel smooth
1051/// needs. The kernel is a function of the geodesic angle alone (`k(cos γ)`,
1052/// `cos γ = uᵢ·u_c` for unit vectors `u`), so the continuous estimator is exactly
1053/// SO(3)-invariant; the finite-center discretization inherits that invariance
1054/// **iff** the centers rotate rigidly with the data. Every ingredient of this
1055/// selection is a dot product of data unit vectors — the mean-direction seed key
1056/// `uᵢ·Σⱼuⱼ`, the maximin nearest-center dot `max_c uᵢ·u_c`, and the sorted
1057/// dot-profile tie-break — and a dot product is invariant under any rotation `R`
1058/// (`(Ruᵢ)·(Ru_c) = uᵢ·u_c`). So under ANY rotation of the data the SAME physical
1059/// rows are selected, the returned centers are exactly those rows rotated, every
1060/// kernel entry `k(uᵢ·u_c)` is preserved, and the fit and every prediction are
1061/// invariant to the arbitrary choice of frame (a longitude origin, a tilt, any
1062/// element of SO(3)) — matching the rotation-invariant `harmonic` control (#2127).
1063///
1064/// On a symmetric cloud, invariant scalar keys can leave several **distinct**
1065/// rows exactly tied. No row-permutation-equivariant rule can choose one member
1066/// of such an orbit: a symmetry exchanging two tied rows would have to both
1067/// preserve and change that choice. The selector therefore adds the complete
1068/// distinct-direction tie class atomically. Because `num_centers` is an exact
1069/// resource contract, a class that does not fit in the remaining budget is
1070/// refused as unrepresentable rather than truncated by row index. Coincident
1071/// rows remain one kernel column; consequently a request exceeding the number
1072/// of distinct directions is also refused rather than silently undersized.
1073///
1074/// The previous implementation ignored `data` and laid down a fixed golden-angle
1075/// (Fibonacci) lattice pinned in the (lat, lon) frame: a rigid rotation moved the
1076/// data relative to the STATIONARY centers, changed every data-to-center geodesic
1077/// angle, and reshaped the fitted surface. Anchoring only the lattice's longitude
1078/// origin to the data (a first pass at #2127) fixed rotations about the pole but
1079/// left the frame-pinned latitudes exposed to a tilt.
1080///
1081/// The selection mirrors the Euclidean thin-plate knot picker
1082/// ([`select_thin_plate_knots`]) — centroid-nearest seed, maximin recursion,
1083/// invariant tie-breaks — but with geodesic (great-circle) distance in place of
1084/// Euclidean distance, which is the correct SO(3) invariant on S². Coincident
1085/// data directions are not selected twice (a duplicate center makes the Wahba
1086/// Gram singular).
1087///
1088/// Each step minimizes its composite key in extremum-then-refine order rather
1089/// than by carrying a running incumbent: the two `O(1)` keys in one parallel
1090/// reduction, then the rows attaining them in one parallel filter, then — only
1091/// over that set, and only if it holds more than one row — the `O(n log n)`
1092/// sorted dot profile. Lexicographic minimization is associative, so this is the
1093/// same total preorder the incumbent scan applied; what changes is that the
1094/// profile key is charged where it can still decide something instead of twice
1095/// per outer iteration whether or not anything is tied. On data with no exact
1096/// spherical symmetry it is never built at all (#2420).
1097pub fn select_spherical_farthest_point_centers(
1098    data: ArrayView2<'_, f64>,
1099    num_centers: usize,
1100    radians: bool,
1101) -> Result<Array2<f64>, BasisError> {
1102    select_spherical_farthest_point_center_rows_with_observer(data, num_centers, radians, |_| {})
1103        .map(|chosen| {
1104            // Return the selected rows VERBATIM (in the data's own lat/lon units), so
1105            // the centers ARE data points and carry the rotation exactly.
1106            Array2::from_shape_fn((chosen.len(), 2), |(r, c)| data[[chosen[r], c]])
1107        })
1108}
1109
1110fn select_spherical_farthest_point_center_rows_with_observer<F>(
1111    data: ArrayView2<'_, f64>,
1112    num_centers: usize,
1113    radians: bool,
1114    mut on_profile_builds: F,
1115) -> Result<Vec<usize>, BasisError>
1116where
1117    F: FnMut(usize),
1118{
1119    use rayon::prelude::*;
1120    validate_lat_lon_matrix(data, "spherical farthest-point centers", radians)?;
1121    if num_centers == 0 {
1122        crate::bail_invalid_basis!("spherical farthest-point center count must be positive");
1123    }
1124    let n = data.nrows();
1125    if n < 2 {
1126        return Err(BasisError::InsufficientColumnsForConstraint { found: n });
1127    }
1128    if num_centers > n {
1129        crate::bail_invalid_basis!(
1130            "requested {num_centers} spherical farthest-point centers but only {n} rows are available"
1131        );
1132    }
1133
1134    let to_rad = if radians {
1135        1.0
1136    } else {
1137        std::f64::consts::PI / 180.0
1138    };
1139    // Unit vectors on S² for each data row. The geodesic distance between rows is
1140    // a monotone-DECREASING function of the dot product `uᵢ·uⱼ = cos γ`, so every
1141    // "distance" comparison below is phrased directly in dot products — each of
1142    // which is exactly rotation invariant.
1143    let units: Vec<[f64; 3]> = (0..n)
1144        .into_par_iter()
1145        .map(|i| {
1146            let lat = data[[i, 0]] * to_rad;
1147            let lon = data[[i, 1]] * to_rad;
1148            let cos_lat = lat.cos();
1149            [cos_lat * lon.cos(), cos_lat * lon.sin(), lat.sin()]
1150        })
1151        .collect();
1152    // Mean-direction seed key `dot_to_sum[i] = uᵢ·Σⱼuⱼ`. `Σⱼuⱼ` is
1153    // rotation-EQUIVARIANT (it rotates rigidly with the data), so `argmax` is the
1154    // SAME physical row in every frame. Using the UNNORMALIZED resultant avoids
1155    // the fp blow-up of normalizing a near-zero mean direction on a well-covered
1156    // sphere (`dot_to_sum` is a homogeneous linear function of the resultant, so
1157    // its relative accuracy — and hence the argmax — is stable regardless of the
1158    // resultant's magnitude). Each component sum is taken in value-sorted order so
1159    // the key is also invariant to a pure row permutation (matching
1160    // `select_thin_plate_knots`).
1161    let mut sum = [0.0_f64; 3];
1162    for (c, sum_c) in sum.iter_mut().enumerate() {
1163        let mut col: Vec<f64> = units.par_iter().map(|u| u[c]).collect();
1164        // Sorted in parallel but summed sequentially: the value-sorted ORDER is
1165        // what makes the key permutation invariant, and the accumulation must
1166        // stay left-to-right over that order to keep the sum bit-reproducible.
1167        col.par_sort_by(|a, b| a.total_cmp(b));
1168        *sum_c = col.iter().sum();
1169    }
1170    let dot_to_sum: Vec<f64> = units
1171        .par_iter()
1172        .map(|u| spherical_center_dot(u, &sum))
1173        .collect();
1174
1175    // Seed class = rows nearest the mean direction (largest `dot_to_sum`), then
1176    // lexicographically smallest intrinsic dot profile. If that complete key is
1177    // tied, retain the whole symmetry orbit; a row-index tie-break is forbidden.
1178    // Both keys are minimized in extremum-then-refine order, so the `O(n log n)`
1179    // profile key is built only for rows that survive the `O(1)` one — none at
1180    // all when the mean-direction argmax is unique (#2420).
1181    let seed_key = dot_to_sum.par_iter().copied().reduce(
1182        || f64::NEG_INFINITY,
1183        |a, b| if b.total_cmp(&a).is_gt() { b } else { a },
1184    );
1185    let seed_tied: Vec<usize> = (0..n)
1186        .into_par_iter()
1187        .filter(|&i| dot_to_sum[i].total_cmp(&seed_key).is_eq())
1188        .collect();
1189
1190    let target = num_centers;
1191    let seed_class = resolve_spherical_profile_tie(&units, &seed_tied, &mut on_profile_builds);
1192    let seed_orbit = distinct_spherical_orbit(&units, &seed_class, &[]);
1193    if seed_orbit.len() > target {
1194        crate::bail_invalid_basis!(
1195            "spherical farthest-point seed symmetry orbit has {} distinct directions, exceeding the requested center budget {target}; use a budget at least as large as the orbit or the harmonic sphere basis",
1196            seed_orbit.len()
1197        );
1198    }
1199
1200    let mut selected = Vec::with_capacity(target);
1201    let mut chosen = vec![false; n];
1202    // `max_dot[i]` = `max` over chosen centers `c` of `uᵢ·u_c` = `cos` of the
1203    // geodesic distance to the NEAREST chosen center. The maximin step picks the
1204    // unchosen row MINIMIZING it (farthest from all chosen).
1205    let mut max_dot = vec![f64::NEG_INFINITY; n];
1206    for &i in &seed_class {
1207        chosen[i] = true;
1208    }
1209    selected.extend(seed_orbit);
1210    max_dot.par_iter_mut().enumerate().for_each(|(i, slot)| {
1211        *slot = selected
1212            .iter()
1213            .map(|&center| spherical_center_dot(&units[i], &units[center]))
1214            .fold(f64::NEG_INFINITY, f64::max);
1215    });
1216
1217    // A dot `≥ 1 − SPHERICAL_CENTER_COINCIDENT_TOL` is a geodesic angle
1218    // `≲ 1.4e-6` rad: the
1219    // candidate coincides with an already-chosen center, so selecting it would add
1220    // a duplicate kernel column and a singular Wahba Gram. Stopping here caps the
1221    // center set at the number of DISTINCT data directions.
1222    while selected.len() < target {
1223        // Maximin: prefer the larger geodesic distance to the chosen set (the
1224        // SMALLER `max_dot`). Exact `max_dot` ties — common on symmetric clouds
1225        // and in float arithmetic — break first toward the MORE PERIPHERAL row
1226        // (smaller `dot_to_sum`, which spreads centers outward and is rotation
1227        // invariant), then by the invariant dot-profile. A tie after all three
1228        // keys is a symmetry orbit and is completed atomically below.
1229        //
1230        // The composite key is minimized in extremum-then-refine order rather
1231        // than by a running incumbent: one parallel reduction for the two `O(1)`
1232        // keys, one parallel filter for the rows attaining them, and the
1233        // `O(n log n)` profile key only over THAT set. The set is also exactly the
1234        // tie-class filter's candidate set, so the profiles are built once and
1235        // serve both. A unique maximin winner therefore builds no profile at all,
1236        // where the incumbent scan built two per outer iteration — one for the
1237        // winner and one to compare the winner against itself (#2420).
1238        let cheap_key = (0..n)
1239            .into_par_iter()
1240            .filter(|&i| !chosen[i])
1241            .map(|i| (max_dot[i], dot_to_sum[i]))
1242            .reduce(
1243                || (f64::INFINITY, f64::INFINITY),
1244                |a, b| {
1245                    if b.0.total_cmp(&a.0).then(b.1.total_cmp(&a.1)).is_lt() {
1246                        b
1247                    } else {
1248                        a
1249                    }
1250                },
1251            );
1252        let cheap_tied: Vec<usize> = (0..n)
1253            .into_par_iter()
1254            .filter(|&i| {
1255                !chosen[i]
1256                    && max_dot[i].total_cmp(&cheap_key.0).is_eq()
1257                    && dot_to_sum[i].total_cmp(&cheap_key.1).is_eq()
1258            })
1259            .collect();
1260        if cheap_tied.is_empty() {
1261            break;
1262        }
1263        if cheap_key.0 >= 1.0 - SPHERICAL_CENTER_COINCIDENT_TOL {
1264            break;
1265        }
1266
1267        let tied_class = resolve_spherical_profile_tie(&units, &cheap_tied, &mut on_profile_builds);
1268        let orbit = distinct_spherical_orbit(&units, &tied_class, &selected);
1269        let remaining = target - selected.len();
1270        if orbit.len() > remaining {
1271            crate::bail_invalid_basis!(
1272                "spherical farthest-point tie class has {} distinct directions but only {remaining} of the exact {target}-center budget remain; choose a compatible center count or the harmonic sphere basis",
1273                orbit.len(),
1274            );
1275        }
1276        for &i in &tied_class {
1277            chosen[i] = true;
1278        }
1279        if orbit.is_empty() {
1280            continue;
1281        }
1282        selected.extend(orbit.iter().copied());
1283        let chosen_ref = &chosen;
1284        let orbit_ref = &orbit;
1285        max_dot.par_iter_mut().enumerate().for_each(|(i, slot)| {
1286            if chosen_ref[i] {
1287                return;
1288            }
1289            for &center in orbit_ref {
1290                let d = spherical_center_dot(&units[i], &units[center]);
1291                if d > *slot {
1292                    *slot = d;
1293                }
1294            }
1295        });
1296    }
1297
1298    if selected.len() < target {
1299        crate::bail_invalid_basis!(
1300            "requested {target} distinct spherical farthest-point centers but the data contain only {} numerically distinct directions",
1301            selected.len()
1302        );
1303    }
1304    if selected.len() < 2 {
1305        return Err(BasisError::InsufficientColumnsForConstraint {
1306            found: selected.len(),
1307        });
1308    }
1309
1310    Ok(selected)
1311}
1312
1313#[cfg(test)]
1314mod spherical_farthest_point_symmetry_tests {
1315    use super::*;
1316    use ndarray::{Array2, array};
1317
1318    /// The row indices [`select_spherical_farthest_point_centers`] selects, with
1319    /// the number of sorted dot profiles the shared production algorithm built.
1320    struct SphericalCenterRows {
1321        rows: Vec<usize>,
1322        profile_builds: usize,
1323    }
1324
1325    fn select_spherical_farthest_point_center_rows(
1326        data: ArrayView2<'_, f64>,
1327        num_centers: usize,
1328        radians: bool,
1329    ) -> Result<SphericalCenterRows, BasisError> {
1330        let mut profile_builds = 0usize;
1331        let rows = select_spherical_farthest_point_center_rows_with_observer(
1332            data,
1333            num_centers,
1334            radians,
1335            |built| profile_builds += built,
1336        )?;
1337        Ok(SphericalCenterRows {
1338            rows,
1339            profile_builds,
1340        })
1341    }
1342
1343    fn permute_rows(data: &Array2<f64>, order: &[usize]) -> Array2<f64> {
1344        Array2::from_shape_fn((order.len(), 2), |(row, col)| data[[order[row], col]])
1345    }
1346
1347    fn sorted_center_rows(centers: &Array2<f64>) -> Vec<[f64; 2]> {
1348        let mut rows: Vec<[f64; 2]> = centers.outer_iter().map(|row| [row[0], row[1]]).collect();
1349        rows.sort_by(|a, b| a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1])));
1350        rows
1351    }
1352
1353    /// The equatorial point is the unique centroid-nearest seed. North and
1354    /// south are then exactly tied by every intrinsic key and are exchanged by
1355    /// a data symmetry, so selecting either one by row index is impossible to
1356    /// reconcile with permutation invariance. The selector must add both as one
1357    /// atomic orbit. A duplicate equatorial row remains one kernel column.
1358    #[test]
1359    fn symmetric_tie_orbit_is_completed_under_every_row_permutation() {
1360        let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1361        let permutations = [[0_usize, 1, 2, 3], [0, 1, 3, 2], [2, 0, 3, 1], [3, 1, 2, 0]];
1362
1363        let mut reference: Option<Vec<[f64; 2]>> = None;
1364        for order in permutations {
1365            let permuted = permute_rows(&data, &order);
1366            let centers = select_spherical_farthest_point_centers(permuted.view(), 3, false)
1367                .expect("the complete three-direction symmetry orbit is representable");
1368            assert_eq!(
1369                centers.nrows(),
1370                3,
1371                "the exact three-center target must contain the complete north/south tie class"
1372            );
1373            let center_set = sorted_center_rows(&centers);
1374            if let Some(expected) = &reference {
1375                assert_eq!(
1376                    &center_set, expected,
1377                    "selected physical center set changed under row permutation"
1378                );
1379            } else {
1380                reference = Some(center_set);
1381            }
1382        }
1383    }
1384
1385    #[test]
1386    fn incomplete_nonseed_tie_class_is_refused() {
1387        let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1388        let error = select_spherical_farthest_point_centers(data.view(), 2, false)
1389            .expect_err("one remaining slot cannot split the north/south tie class");
1390        assert!(
1391            error
1392                .to_string()
1393                .contains("only 1 of the exact 2-center budget remain"),
1394            "unexpected refusal: {error}"
1395        );
1396    }
1397
1398    /// A symmetry orbit is indivisible. If even one orbit is larger than the
1399    /// declared resource budget, refusing is the only bounded equivariant
1400    /// answer; silently choosing a row-index representative is mathematically
1401    /// false and expanding without a bound can turn an O(m) request into O(n).
1402    #[test]
1403    fn symmetry_orbit_larger_than_center_budget_is_refused() {
1404        let antipodal = array![[90.0_f64, 0.0], [-90.0, 0.0]];
1405        let error = select_spherical_farthest_point_centers(antipodal.view(), 1, false)
1406            .expect_err("a two-direction seed orbit cannot fit a one-center budget");
1407        assert!(
1408            error.to_string().contains("symmetry orbit"),
1409            "unexpected refusal: {error}"
1410        );
1411    }
1412
1413    /// The canonical gridded-geospatial layout, matching the `sphere_gpu`
1414    /// fixtures: latitude in (-85, 85), longitude spanning [-180, 180].
1415    fn latlon_grid(n_lat: usize, n_lon: usize) -> Array2<f64> {
1416        Array2::from_shape_fn((n_lat * n_lon, 2), |(row, col)| {
1417            let (i, j) = (row / n_lon, row % n_lon);
1418            if col == 0 {
1419                -85.0 + (170.0 * i as f64) / (n_lat.saturating_sub(1).max(1) as f64)
1420            } else {
1421                -180.0 + (360.0 * j as f64) / (n_lon.saturating_sub(1).max(1) as f64)
1422            }
1423        })
1424    }
1425
1426    /// Deterministic area-uniform cloud: no exact spherical symmetry, so no two
1427    /// rows can tie the maximin key exactly.
1428    fn latlon_cloud(n: usize) -> Array2<f64> {
1429        let mut state = 0x2545_F491_4F6C_DD1D_u64;
1430        let mut next = move || {
1431            state ^= state << 13;
1432            state ^= state >> 7;
1433            state ^= state << 17;
1434            (state >> 11) as f64 / (1u64 << 53) as f64
1435        };
1436        let draws: Vec<f64> = (0..2 * n).map(|_| next()).collect();
1437        Array2::from_shape_fn((n, 2), |(row, col)| {
1438            if col == 0 {
1439                (1.0 - 2.0 * draws[2 * row]).asin().to_degrees()
1440            } else {
1441                360.0 * draws[2 * row + 1] - 180.0
1442            }
1443        })
1444    }
1445
1446    /// The `O(n log n)` sorted dot profile is a tie-break, and a tie-break must
1447    /// only be paid for where something is actually tied. A cloud with no exact
1448    /// spherical symmetry has a unique maximin winner at every step, so the
1449    /// selection must complete having built NO profile at all — at any `n`, and
1450    /// for any center budget.
1451    ///
1452    /// The running-incumbent scan this replaced (#2420) built two profiles per
1453    /// outer iteration on exactly this input: one for the winner, and one to
1454    /// compare the winner against its own profile in the tie-class filter. At
1455    /// `m = 200` that was 400 sorts of `n` doubles to discover that nothing was
1456    /// tied, and it was 88% of the whole spherical basis build.
1457    #[test]
1458    fn spherical_center_selection_costs_no_profile_without_an_exact_tie() {
1459        for n in [2_000_usize, 8_000] {
1460            for m in [40_usize, 200] {
1461                let data = latlon_cloud(n);
1462                let chosen = select_spherical_farthest_point_center_rows(data.view(), m, false)
1463                    .expect("an asymmetric cloud admits any center budget below n");
1464                assert_eq!(chosen.rows.len(), m, "exact center budget (n={n}, m={m})");
1465                assert_eq!(
1466                    chosen.profile_builds, 0,
1467                    "no row can tie the maximin key exactly on an asymmetric cloud, so the \
1468                     profile tie-break must never be built (n={n}, m={m})"
1469                );
1470            }
1471        }
1472    }
1473
1474    /// On a regular lat/lon grid the tie-break IS reached — a parallel's rows are
1475    /// genuinely related by a rotation about the polar axis. The cost of reaching
1476    /// it must still be a property of the symmetry, not of the row count: the
1477    /// profile key may only be built for rows that tie both `O(1)` keys at the
1478    /// maximin extremum, so the count stays below one profile per selected center
1479    /// even as `n` grows 16-fold. The scan this replaced built strictly more than
1480    /// two per center regardless of `n`.
1481    #[test]
1482    fn spherical_center_profile_cost_does_not_scale_with_the_row_count() {
1483        for (n_lat, n_lon) in [(40_usize, 40_usize), (160, 160)] {
1484            for m in [40_usize, 200] {
1485                let data = latlon_grid(n_lat, n_lon);
1486                let n = data.nrows();
1487                let chosen = select_spherical_farthest_point_center_rows(data.view(), m, false)
1488                    .expect("a lat/lon grid admits these center budgets");
1489                assert_eq!(chosen.rows.len(), m, "exact center budget (n={n}, m={m})");
1490                assert!(
1491                    chosen.profile_builds < m,
1492                    "profile-key builds must stay below one per selected center; got {} at \
1493                     n={n} m={m} (the replaced incumbent scan built at least {})",
1494                    chosen.profile_builds,
1495                    2 * m
1496                );
1497            }
1498        }
1499    }
1500
1501    /// The gate above must not be satisfiable by deleting the tie-break. On the
1502    /// pole/equator fixture the profile key is what proves north and south are one
1503    /// indivisible orbit, so it must genuinely be built there.
1504    #[test]
1505    fn spherical_center_profile_key_is_still_built_where_it_decides_an_orbit() {
1506        let data = array![[0.0_f64, 0.0], [0.0, 0.0], [90.0, 0.0], [-90.0, 0.0]];
1507        let chosen = select_spherical_farthest_point_center_rows(data.view(), 3, false)
1508            .expect("the complete three-direction symmetry orbit is representable");
1509        assert!(
1510            chosen.profile_builds > 0,
1511            "the north/south orbit is only provable through the invariant profile key"
1512        );
1513    }
1514
1515    /// Extremum-then-refine reaches the same physical answer as the incumbent
1516    /// scan it replaced only if the composite key is evaluated over the same
1517    /// candidate set. On two symmetric parallels every `max_dot` extremum is a
1518    /// multi-row tie, so the whole selection is decided inside the tie logic —
1519    /// and it must still be blind to row order at every budget.
1520    #[test]
1521    fn polar_ring_selection_is_row_order_blind_at_every_budget() {
1522        // Two parallels at ±30°, six points each.
1523        let ring: Vec<[f64; 2]> = [-30.0_f64, 30.0]
1524            .into_iter()
1525            .flat_map(|lat| (0..6).map(move |j| [lat, -180.0 + 60.0 * j as f64]))
1526            .collect();
1527        let data = Array2::from_shape_fn((ring.len(), 2), |(r, c)| ring[r][c]);
1528        let n = data.nrows();
1529
1530        for budget in 2..=n {
1531            let reference = select_spherical_farthest_point_centers(data.view(), budget, false)
1532                .map(|centers| sorted_center_rows(&centers));
1533            for order in [
1534                (0..n).rev().collect::<Vec<usize>>(),
1535                (0..n).map(|i| (5 * i + 7) % n).collect::<Vec<usize>>(),
1536                (0..n)
1537                    .step_by(5)
1538                    .chain((1..n).step_by(5))
1539                    .collect::<Vec<usize>>(),
1540            ] {
1541                if order.len() != n {
1542                    continue;
1543                }
1544                let permuted = permute_rows(&data, &order);
1545                let got = select_spherical_farthest_point_centers(permuted.view(), budget, false)
1546                    .map(|centers| sorted_center_rows(&centers));
1547                match (&reference, &got) {
1548                    (Ok(expected), Ok(actual)) => assert_eq!(
1549                        actual, expected,
1550                        "budget {budget}: selected physical directions changed under a row \
1551                         permutation of a symmetric ring"
1552                    ),
1553                    (Err(a), Err(b)) => assert_eq!(
1554                        a.to_string(),
1555                        b.to_string(),
1556                        "budget {budget}: refusal changed under a row permutation"
1557                    ),
1558                    _ => panic!(
1559                        "budget {budget}: row order decided whether the request was \
1560                         representable ({reference:?} vs {got:?})"
1561                    ),
1562                }
1563            }
1564        }
1565    }
1566}
1567
1568#[cfg(test)]
1569mod matern_function_metric_tests {
1570    use super::*;
1571    use ndarray::array;
1572
1573    #[test]
1574    fn center_metric_null_ridge_is_covariant_and_targets_only_intercept_function() {
1575        let center_kernel = array![[1.4, 0.3, 0.1], [0.3, 1.2, 0.2], [0.1, 0.2, 1.1]];
1576        let mut embedded = Array2::<f64>::zeros((4, 4));
1577        embedded.slice_mut(s![0..3, 0..3]).assign(&center_kernel);
1578        let gram =
1579            matern_center_function_gram(&embedded, true, None).expect("raw center function Gram");
1580        let base =
1581            matern_double_penalty_candidates(&embedded, &gram, true).expect("raw candidates");
1582        assert_eq!(base.len(), 2);
1583        let raw_ridge = base[1].matrix.dense() * base[1].normalization_scale;
1584
1585        let intercept = array![[0.0], [0.0], [0.0], [1.0]];
1586        let action_error = (&raw_ridge.dot(&intercept) - &gram.dot(&intercept))
1587            .iter()
1588            .map(|value| value.abs())
1589            .fold(0.0_f64, f64::max);
1590        assert!(
1591            action_error < 2.0e-13,
1592            "ridge must equal G on the structural intercept; error={action_error:.3e}"
1593        );
1594
1595        // A strongly non-orthogonal kernel chart plus intercept rescaling. The
1596        // block structure is exactly Matérn's supported final transform: kernel
1597        // coordinates may shear/rescale, while the explicit intercept remains a
1598        // separate structural coordinate.
1599        let transform = array![
1600            [0.2, 0.5, 0.0, 0.0],
1601            [0.0, 3.0, -0.4, 0.0],
1602            [0.0, 0.0, 1.7, 0.0],
1603            [0.0, 0.0, 0.0, 2.5]
1604        ];
1605        let primary_t = fast_atb(&transform, &fast_ab(&embedded, &transform));
1606        let gram_t = matern_center_function_gram(&embedded, true, Some(&transform))
1607            .expect("transformed center function Gram");
1608        let transformed = matern_double_penalty_candidates(&primary_t, &gram_t, true)
1609            .expect("transformed candidates");
1610        let ridge_t = transformed[1].matrix.dense() * transformed[1].normalization_scale;
1611        let expected = fast_atb(&transform, &fast_ab(&raw_ridge, &transform));
1612        let covariance_error = (&ridge_t - &expected)
1613            .iter()
1614            .map(|value| value.abs())
1615            .fold(0.0_f64, f64::max);
1616        assert!(
1617            covariance_error < 2.0e-12,
1618            "Matérn function ridge changed under a basis chart; error={covariance_error:.3e}"
1619        );
1620
1621        let no_intercept_gram = matern_center_function_gram(
1622            &center_kernel,
1623            false,
1624            Some(&transform.slice(s![0..3, 0..3]).to_owned()),
1625        )
1626        .expect("kernel-only Gram");
1627        let kernel_only = matern_double_penalty_candidates(
1628            &fast_atb(
1629                &transform.slice(s![0..3, 0..3]).to_owned(),
1630                &fast_ab(&center_kernel, &transform.slice(s![0..3, 0..3]).to_owned()),
1631            ),
1632            &no_intercept_gram,
1633            false,
1634        )
1635        .expect("kernel-only candidates");
1636        assert_eq!(kernel_only.len(), 1, "an SPD kernel has no null ridge");
1637    }
1638}
1639
1640/// Auto-derive a streaming row chunk size for dense basis evaluation.
1641///
1642/// The opt-in `streaming_chunk_size` knob has been removed from public specs:
1643/// streaming activates automatically when the would-be dense buffer
1644/// `n_rows * n_basis_cols * 8 bytes` exceeds 1 GiB. When streaming is
1645/// active, the chunk size is sized so each resident chunk holds ~256 MiB
1646/// of `f64` (`chunk = (256 MiB) / (n_basis_cols * 8)`), clamped to
1647/// `[1024, n_rows]`. Returning `None` means "do not stream, materialize
1648/// densely".
1649pub fn auto_streaming_chunk_size_for_dense(n_rows: usize, n_basis_cols: usize) -> Option<usize> {
1650    if n_rows == 0 || n_basis_cols == 0 {
1651        return None;
1652    }
1653    const DENSE_THRESHOLD_BYTES: usize = 1024 * 1024 * 1024;
1654    const TARGET_CHUNK_BYTES: usize = 256 * 1024 * 1024;
1655    const MIN_CHUNK_ROWS: usize = 1024;
1656    let dense_bytes = n_rows.saturating_mul(n_basis_cols).saturating_mul(8);
1657    if dense_bytes <= DENSE_THRESHOLD_BYTES {
1658        return None;
1659    }
1660    let row_bytes = n_basis_cols.saturating_mul(8).max(1);
1661    let raw_chunk = TARGET_CHUNK_BYTES / row_bytes;
1662    let clamped = raw_chunk.max(MIN_CHUNK_ROWS).min(n_rows);
1663    Some(clamped)
1664}