Skip to main content

gam_terms/basis/
types.rs

1use super::*;
2
3/// Wrapper to send a raw pointer across thread boundaries for parallel buffer fills.
4/// SAFETY: every `SendPtr` value must be built from live, properly aligned `f64`
5/// storage whose mutable borrow is held until all worker threads finish; callers
6/// may only dereference offsets that are in-bounds and disjoint across workers.
7#[derive(Clone, Copy)]
8pub(crate) struct SendPtr(pub(crate) *mut f64);
9
10// SAFETY: SendPtr only grants raw-pointer transport. Actual dereferences occur
11// at call sites after row-chunk partitioning proves each thread writes a
12// distinct in-bounds element of the backing Array/Vec allocation.
13unsafe impl Send for SendPtr {}
14
15// SAFETY: shared references to SendPtr are sound because the pointee is never
16// accessed through the wrapper without the call-site disjoint-offset proof.
17unsafe impl Sync for SendPtr {}
18
19impl SendPtr {
20    #[inline(always)]
21    pub(crate) fn add(self, offset: usize) -> *mut f64 {
22        // SAFETY: callers pass offsets within the backing allocation and only
23        // dereference the returned pointer after proving the target element is
24        // uniquely owned by that worker's chunk for the whole parallel region.
25        unsafe { self.0.add(offset) }
26    }
27}
28
29/// Re-export of the neutral basis-error contract. #1521: `BasisError` lives
30/// in `gam-problem` so `EstimationError` can wrap it (`#[from]`) without a
31/// back-edge; gam-terms re-exports it to preserve `gam_terms::basis::BasisError`.
32pub use gam_problem::BasisError;
33
34// ============================================================================
35// Unified Basis Generation API
36// ============================================================================
37
38/// Options for basis generation, controlling derivative order.
39#[derive(Clone, Copy, Debug, Default)]
40pub struct BasisOptions {
41    /// Derivative order: 0 = value (default), 1 = first derivative, 2 = second derivative
42    pub derivative_order: usize,
43    /// Basis family to evaluate.
44    pub basis_family: BasisFamily,
45}
46
47impl BasisOptions {
48    /// Create options for evaluating basis functions (no derivative).
49    pub const fn value() -> Self {
50        Self {
51            derivative_order: 0,
52            basis_family: BasisFamily::BSpline,
53        }
54    }
55
56    /// Create options for evaluating first derivatives of basis functions.
57    pub const fn first_derivative() -> Self {
58        Self {
59            derivative_order: 1,
60            basis_family: BasisFamily::BSpline,
61        }
62    }
63
64    /// Create options for evaluating second derivatives of basis functions.
65    pub const fn second_derivative() -> Self {
66        Self {
67            derivative_order: 2,
68            basis_family: BasisFamily::BSpline,
69        }
70    }
71
72    /// Create options for evaluating I-spline basis values.
73    pub const fn i_spline() -> Self {
74        Self {
75            derivative_order: 0,
76            basis_family: BasisFamily::ISpline,
77        }
78    }
79}
80
81/// Basis-family selector for 1D spline evaluation.
82#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
83pub enum BasisFamily {
84    /// Standard B-splines.
85    #[default]
86    BSpline,
87    /// M-splines: normalized B-splines, M_i = ((k+1)/(t_{i+k+1}-t_i)) B_i.
88    MSpline,
89    /// I-splines: integrated M-splines, implemented by right-cumulative
90    /// sums of B-splines at degree k+1.
91    ISpline,
92}
93
94/// Specifies the source of knots for basis generation.
95#[derive(Clone, Debug)]
96pub enum KnotSource<'a> {
97    /// Use a pre-computed knot vector.
98    Provided(ArrayView1<'a, f64>),
99    /// Generate uniformly spaced knots based on data range.
100    Generate {
101        /// Data range (min, max) for knot placement.
102        data_range: (f64, f64),
103        /// Number of internal knots to place between boundaries.
104        num_internal_knots: usize,
105    },
106}
107/// Thin-plate regression spline basis and penalty (order m=2).
108///
109/// The returned basis has columns `[K_c | P]` where:
110/// - `K_c` is the constrained radial basis block (`K * Z`) with
111///   `P(knots)^T * α = 0` enforced via nullspace projection
112/// - `P` is the TPS polynomial null-space block containing all monomials of
113///   total degree `< m`, where `m = thin_plate_penalty_order(d)` (so `P` is
114///   just `[1, x_1, ..., x_d]` for `d <= 3`)
115///
116/// The returned penalty matrix is block-diagonal with:
117/// - upper-left `Omega_c = Z^T Omega Z` for the constrained radial block
118/// - zero lower-right block for unpenalized polynomial terms.
119///
120/// For double-penalty GAMs, a second ridge penalty `I` is also returned so the
121/// caller can optimize `(lambda_bending, lambdaridge)` jointly.
122#[derive(Debug, Clone)]
123pub struct ThinPlateSplineBasis {
124    pub basis: Array2<f64>,
125    pub penalty_bending: Array2<f64>,
126    pub penalty_ridge: Array2<f64>,
127    pub num_kernel_basis: usize,
128    pub num_polynomial_basis: usize,
129    pub dimension: usize,
130    /// Wood-TPRS radial reparameterization matrix `V`.
131    ///
132    /// Rows live in the side-constrained radial coefficient space. Columns are
133    /// the retained positive bending eigendirections of `Z' Ω Z`; numerically
134    /// near-null radial directions are dropped before the basis is exposed.
135    /// Therefore `V` can be rectangular: design columns are `Φ Z V`, and the
136    /// radial penalty is `diag(Λ_retained)`.
137    pub radial_reparam: Array2<f64>,
138}
139
140/// Matérn smoothness parameter `nu` (half-integer variants with closed forms).
141#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
142pub enum MaternNu {
143    Half,
144    ThreeHalves,
145    FiveHalves,
146    SevenHalves,
147    NineHalves,
148}
149
150impl MaternNu {
151    /// The half-integer smoothness value ν as an `f64` (0.5, 1.5, …).
152    pub const fn half_integer_value(self) -> f64 {
153        match self {
154            MaternNu::Half => 0.5,
155            MaternNu::ThreeHalves => 1.5,
156            MaternNu::FiveHalves => 2.5,
157            MaternNu::SevenHalves => 3.5,
158            MaternNu::NineHalves => 4.5,
159        }
160    }
161}
162
163/// Matérn radial basis and penalties.
164#[derive(Debug, Clone)]
165pub struct MaternSplineBasis {
166    pub basis: Array2<f64>,
167    pub penalty_kernel: Array2<f64>,
168    pub penalty_ridge: Array2<f64>,
169    pub num_kernel_basis: usize,
170    pub num_polynomial_basis: usize,
171    pub dimension: usize,
172}
173
174#[derive(Debug, Clone)]
175pub(crate) struct DuchonBasisDesign {
176    pub(crate) basis: Array2<f64>,
177}
178
179/// Boundary-condition policy for one-dimensional smooth bases.
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub enum OneDimensionalBoundary {
182    /// Ordinary open interval basis with clamped endpoint behavior.
183    #[default]
184    Open,
185    /// Periodic/cyclic basis over the half-open interval `[start, end)`.
186    ///
187    /// Values are evaluated modulo `period = end - start`; the basis and its
188    /// first `degree - 1` derivatives agree at the two endpoints for B-splines.
189    Cyclic { start: f64, end: f64 },
190}
191
192impl OneDimensionalBoundary {
193    pub(crate) fn period(&self) -> Option<(f64, f64, f64)> {
194        match *self {
195            OneDimensionalBoundary::Open => None,
196            OneDimensionalBoundary::Cyclic { start, end } if end > start => {
197                Some((start, end, end - start))
198            }
199            OneDimensionalBoundary::Cyclic { .. } => None,
200        }
201    }
202}
203
204/// Which knot strategy to use for 1D B-spline bases.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub enum BSplineKnotSpec {
207    Generate {
208        data_range: (f64, f64),
209        num_internal_knots: usize,
210    },
211    /// Uniform cyclic B-spline basis on `[data_range.0, data_range.1)`.
212    ///
213    /// The first and last endpoints are identified, so evaluating at `x` and
214    /// `x + m * period` gives identical rows. `num_basis` is the number of
215    /// periodic control sites around the loop and must be at least
216    /// `degree + 1` for an unaliased local support stencil.
217    PeriodicUniform {
218        data_range: (f64, f64),
219        num_basis: usize,
220    },
221    Automatic {
222        num_internal_knots: Option<usize>,
223        placement: BSplineKnotPlacement,
224    },
225    Provided(Array1<f64>),
226    /// Natural cubic regression spline (`bs="cr"`/`"cs"`) knot set (#1074).
227    ///
228    /// Unlike the open-spline variants above, these `knots` are the `k`
229    /// Lancaster–Salkauskas knots `x*_1 < … < x*_k` that *directly* index the
230    /// basis values `β_i = f(x*_i)` — the basis dimension equals `knots.len()`
231    /// (not `knots.len() - degree - 1`). The 1-D builder routes this variant to
232    /// the cubic-regression builder; the cr identity therefore round-trips
233    /// through freeze/reload by virtue of the variant itself (no separate
234    /// metadata marker is required), and tensor margins inherit cr by carrying
235    /// this knotspec into `build_bspline_basis_1d`.
236    NaturalCubicRegression {
237        knots: Array1<f64>,
238    },
239}
240
241/// Internal-knot placement strategy when knots are automatically inferred.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243pub enum BSplineKnotPlacement {
244    Uniform,
245    Quantile,
246}
247
248/// 1D B-spline basis configuration.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct BSplineBasisSpec {
251    pub degree: usize,
252    pub penalty_order: usize,
253    pub knotspec: BSplineKnotSpec,
254    pub double_penalty: bool,
255    pub identifiability: BSplineIdentifiability,
256    #[serde(default)]
257    pub boundary: OneDimensionalBoundary,
258    /// Optional endpoint boundary constraints (Hermite-style pin of value and/or
259    /// derivative at the left/right knot extents). Default = `Free` on both
260    /// sides which is a no-op.
261    #[serde(default)]
262    pub boundary_conditions: BSplineBoundaryConditions,
263}
264
265/// Per-endpoint boundary constraint policy for B-spline 1D bases.
266#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
267pub enum BSplineEndpointBoundaryCondition {
268    /// No endpoint constraint.
269    #[default]
270    Free,
271    /// Pin the first derivative to zero at this endpoint.
272    Clamped,
273    /// Hermite pin: fix the endpoint value to `value` and its first derivative
274    /// to zero.
275    Anchored { value: f64 },
276}
277
278/// Left/right pair of B-spline endpoint constraints.
279#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
280pub struct BSplineBoundaryConditions {
281    #[serde(default)]
282    pub left: BSplineEndpointBoundaryCondition,
283    #[serde(default)]
284    pub right: BSplineEndpointBoundaryCondition,
285}
286
287impl BSplineBoundaryConditions {
288    pub const fn is_free(&self) -> bool {
289        matches!(self.left, BSplineEndpointBoundaryCondition::Free)
290            && matches!(self.right, BSplineEndpointBoundaryCondition::Free)
291    }
292
293    /// Whether either endpoint fixes the function's absolute level.
294    ///
295    /// An anchored endpoint (one *or* both sides) replaces the global intercept
296    /// as the level-setting constraint: the fitted function itself, not only a
297    /// centered deviation, must obey the endpoint pin. Centering that same
298    /// smooth to zero would impose a second, incompatible level constraint and
299    /// exclude every non-zero-mean anchored function from the model space, and a
300    /// free global intercept would float the whole curve off its pin. A
301    /// *two*-sided anchor fixes the level even more strongly than a one-sided
302    /// one, so it must be treated identically here — the earlier XOR (exactly
303    /// one endpoint) silently dropped both pins for the two-sided case (#2297).
304    pub const fn has_anchor(&self) -> bool {
305        matches!(self.left, BSplineEndpointBoundaryCondition::Anchored { .. })
306            || matches!(
307                self.right,
308                BSplineEndpointBoundaryCondition::Anchored { .. }
309            )
310    }
311
312    /// Whether either endpoint carries an inhomogeneous value constraint.
313    pub fn has_nonzero_anchor(&self) -> bool {
314        let nonzero = |condition: BSplineEndpointBoundaryCondition| {
315            matches!(
316                condition,
317                BSplineEndpointBoundaryCondition::Anchored { value } if value != 0.0
318            )
319        };
320        nonzero(self.left) || nonzero(self.right)
321    }
322}
323
324/// Per-smooth identifiability policy for 1D B-spline bases.
325///
326/// These constraints are applied directly in the builder via a reparameterization
327/// `B_constrained = B * Z`, and every penalty matrix is projected as
328/// `S_constrained = Z' S Z`, so solver geometry stays consistent.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub enum BSplineIdentifiability {
331    /// Keep unconstrained basis columns.
332    None,
333    /// Enforce weighted sum-to-zero: `B' w = 0` (or unweighted when `weights=None`).
334    // Smooth terms are centered by default to avoid intercept confounding.
335    WeightedSumToZero { weights: Option<Array1<f64>> },
336    /// Remove intercept + linear trend in coefficient space using Greville geometry.
337    RemoveLinearTrend,
338    /// Enforce orthogonality to supplied design columns `C` (n x q):
339    /// `B_c' W C = 0` (or unweighted when `weights=None`).
340    ///
341    /// To enforce `[intercept, x, ...]`, provide `columns` with those columns.
342    OrthogonalToDesignColumns {
343        columns: Array2<f64>,
344        weights: Option<Array1<f64>>,
345    },
346    /// Apply an explicit coefficient-space transform `Z` learned at fit time.
347    ///
348    /// This freezes identifiability behavior so prediction cannot drift based on
349    /// new-data distribution. The constrained basis is `B * Z`.
350    FrozenTransform { transform: Array2<f64> },
351}
352
353impl Default for BSplineIdentifiability {
354    fn default() -> Self {
355        BSplineIdentifiability::WeightedSumToZero { weights: None }
356    }
357}
358
359/// Spatial center selection strategy.
360///
361/// `num_centers` is the exact number of knot/center rows selected by the
362/// strategy. Polynomial nullspace columns are added separately by each basis
363/// builder and must never be folded into this count.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub enum CenterStrategy {
366    Auto(Box<CenterStrategy>),
367    /// Select a potentially rich knot cloud, then retain an explicit
368    /// low-dimensional spectral subspace of its kernel. The knot strategy and
369    /// retained rank are independent by construction.
370    DuchonSpectral {
371        knots: Box<CenterStrategy>,
372        basis: DuchonSpectralBasis,
373    },
374    UserProvided(Array2<f64>),
375    /// Joint multidimensional equal-mass partitioning in the full smooth space.
376    EqualMass {
377        num_centers: usize,
378    },
379    /// Covariate-representative equal-mass partitioning along one selected axis.
380    EqualMassCovarRepresentative {
381        num_centers: usize,
382    },
383    FarthestPoint {
384        num_centers: usize,
385    },
386    KMeans {
387        num_centers: usize,
388        max_iter: usize,
389    },
390    UniformGrid {
391        points_per_dim: usize,
392    },
393}
394
395impl CenterStrategy {
396    /// The number of centers this strategy will select, computed from the
397    /// strategy alone (no data pass). `d` is the smooth's covariate
398    /// dimensionality, needed only by `UniformGrid` whose count is
399    /// `points_per_dim^d`. Adaptive-fit provenance consults this before freeze,
400    /// because the frozen center matrix can contain periodic image expansion
401    /// and therefore is not the requested resolution for the next refit.
402    pub fn planned_num_centers(&self, d: usize) -> usize {
403        match self {
404            Self::Auto(inner) => inner.planned_num_centers(d),
405            Self::DuchonSpectral { knots, .. } => knots.planned_num_centers(d),
406            Self::UserProvided(centers) => centers.nrows(),
407            Self::EqualMass { num_centers }
408            | Self::EqualMassCovarRepresentative { num_centers }
409            | Self::FarthestPoint { num_centers }
410            | Self::KMeans { num_centers, .. } => *num_centers,
411            Self::UniformGrid { points_per_dim } => {
412                points_per_dim.saturating_pow(d.clamp(1, u32::MAX as usize) as u32)
413            }
414        }
415    }
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
419pub enum CenterStrategyKind {
420    UserProvided,
421    EqualMass,
422    EqualMassCovarRepresentative,
423    FarthestPoint,
424    KMeans,
425    UniformGrid,
426}
427
428/// Adaptive default center count for spatial smooths (TPS, Duchon, Matérn).
429///
430/// Use this when the user has not explicitly specified a knot/center count.
431/// The basis size is the sub-linear `ceil(8 * d_factor * n^0.4)`, clamped above
432/// at `K_MAX = 2000` and below at a *data-proportional* floor `min(200, n/8)` so
433/// the floor only engages once there are enough observations to support a rich
434/// basis. The result is additionally capped at `n/4` so the penalty matrices
435/// stay well-conditioned relative to the data:
436///
437/// | n      | d=1  | d=2  | d=5  |
438/// |--------|------|------|------|
439/// | 800    | 116  | 134  | 186  |
440/// | 1 000  | 127  | 146  | 200  |
441/// | 2 000  | 200  | 200  | 268  |
442/// | 10 000 | 319  | 367  | 510  |
443/// | 100 000| 801  | 921  | 1281 |
444/// | 400 000| 1393 | 1602 | 2000 |
445/// | 1 000 000| 2000 | 2000 | 2000 |
446///
447/// The flat `200` floor used to inflate moderate-`n` spatial smooths (a few
448/// hundred to ~2000 rows) up to a dense 200-column design even though the raw
449/// sub-linear count — and the mesh/knot density that mgcv and R-INLA use on the
450/// same data — is far smaller. On ~800 rows that turned a single 2-D thin-plate
451/// REML fit into an `O(n·p² + p³)` grind at `p ≈ 200` (#718). Smoothness is
452/// already controlled by REML's penalty weight λ, not by the center count, so a
453/// data-proportional floor recovers the same surface at a fraction of the cost.
454///
455/// # Arguments
456/// * `n` - sample size (number of observations)
457/// * `d` - covariate dimensionality (number of input variables in the smooth)
458pub fn default_num_centers(n: usize, d: usize) -> usize {
459    const K_MIN: usize = 200;
460    const K_MAX: usize = 2000;
461    const ALPHA: f64 = 0.4;
462    const C: f64 = 8.0;
463    /// Per-extra-dimension growth in the center count: each covariate axis
464    /// beyond the first widens the basis by 15% to keep the per-axis mesh
465    /// density roughly constant as the smooth's domain dimensionality grows.
466    const PER_DIM_GROWTH: f64 = 0.15;
467    /// Divisor for the data-proportional floor: the `K_MIN` floor only engages
468    /// once `n` exceeds `K_MIN * FLOOR_N_DIVISOR`, so small samples are not
469    /// forced up to a dense `K_MIN`-column design.
470    const FLOOR_N_DIVISOR: usize = 8;
471    /// Divisor for the conditioning cap: the center count never exceeds `n /
472    /// COND_N_DIVISOR`, keeping the penalty matrices well-conditioned relative
473    /// to the data.
474    const COND_N_DIVISOR: usize = 4;
475
476    let d_factor = 1.0 + PER_DIM_GROWTH * (d.max(1) - 1) as f64;
477    let raw = (C * d_factor * (n as f64).powf(ALPHA)).ceil() as usize;
478
479    // Data-proportional floor: never inflate beyond n/FLOOR_N_DIVISOR, so the
480    // K_MIN-center floor only takes effect once n is large enough (~1600) to
481    // genuinely support that many basis columns.
482    let floor = K_MIN.min(n / FLOOR_N_DIVISOR);
483    let k = raw.clamp(floor, K_MAX);
484
485    // Never exceed n itself; cap at n/COND_N_DIVISOR to keep the penalty
486    // matrices well-conditioned relative to the data.
487    k.min(n).min(n / COND_N_DIVISOR)
488}
489
490/// Conservative center count for a *secondary* (distributional) predictor's
491/// spatial smooth — e.g. the log-σ scale model in a Gaussian location-scale
492/// fit.
493///
494/// The mean is identified directly by the response, so it warrants the
495/// generous [`default_num_centers`] basis. A scale/shape predictor is
496/// identified only through (noisy) squared residuals: handing it a basis sized
497/// for the mean lets REML/LAML smoothing selection over-fit it, because where
498/// the fitted scale is driven small the *observed* information collapses and
499/// the determinant penalty stops holding the wiggle down (#501). This mirrors
500/// standard GAMLSS/mgcv practice of giving distribution parameters a modest
501/// default (mgcv's modest default basis for a 1-D `s()`), grown gently with
502/// dimensionality and never exceeding the generous primary-predictor default.
503pub fn conservative_secondary_centers(n: usize, d: usize) -> usize {
504    const BASE_1D_CENTERS: usize = 15;
505    let modest = BASE_1D_CENTERS.saturating_mul(d.max(1));
506    default_num_centers(n, d).min(modest).max(1)
507}
508
509/// Low-rank starting center count for saturation-driven spatial fitting.
510///
511/// The structural minimum (`d + 1` polynomial directions plus one radial
512/// direction) is only enough to make the algebra identifiable. It is not an
513/// adequate pilot function space: structure orthogonal to that single radial
514/// direction is absorbed into the residual, so REML can legitimately shrink
515/// the direction and report EDF below its ceiling even when the surface is
516/// badly under-resolved (#1689). Start from the project's established
517/// thin-plate-style low-rank resolution `10 * 3^(d - 1)` instead. This is the
518/// same dimension rule already used by the automatic Duchon builder, capped by
519/// [`default_num_centers`] so the pilot never exceeds the validated production
520/// basis at small sample sizes.
521pub fn starting_num_centers(n: usize, d: usize) -> usize {
522    let low_rank_resolution = 10usize
523        .saturating_mul(3usize.saturating_pow(d.saturating_sub(1).min(u32::MAX as usize) as u32));
524    low_rank_resolution
525        .min(default_num_centers(n, d))
526        .min(n)
527        .max(1)
528}
529
530/// Next evidence-backed center count for a saturated spatial basis, bounded by
531/// the already validated production-default resolution.
532///
533/// Growth is geometric so the number of certified refits is logarithmic. The
534/// ceiling is supplied by the owning workflow because it depends on the
535/// spatial family/dimension and resource plan; the standard formula workflow
536/// uses [`default_num_centers`]. Adaptive resolution may therefore avoid work
537/// below the previous default, but can never turn an ordinary fit into an
538/// unvalidated row-rank dense basis. `None` means the validated function-space
539/// ceiling has been reached.
540pub fn expanded_num_centers(current: usize, ceiling: usize) -> Option<usize> {
541    if current >= ceiling {
542        return None;
543    }
544    let expanded = current.saturating_mul(2).min(ceiling);
545    (expanded > current).then_some(expanded)
546}
547
548/// Is a fitted spatial smooth's basis SATURATED — i.e. does its own evidence say
549/// the data wants more resolution than its realized coefficient span provides (#1689)?
550///
551/// The penalizable capacity is `realized_width − nullspace_dim`: the unpenalized
552/// polynomial null space is always fully used, so it is excluded from the "is the
553/// PENALIZED part maxed out?" test. The supplied `edf` is the total term EDF;
554/// subtracting `nullspace_dim` yields its penalized contribution, which rises
555/// toward that capacity exactly as REML drives the penalty
556/// λ toward its floor to chase structure the basis cannot resolve. Saturated ⟺
557/// `edf ≥ capacity − ε`, with the margin `ε` DERIVED from the outer REML
558/// numerical resolution (`ε = capacity · resolution_tol`, floored at
559/// `resolution_tol` so a tiny-capacity block still has a positive margin) rather
560/// than a tuned knob. The workflow derives `resolution_tol` from the maximum of
561/// its outer convergence tolerance and any rho-independent penalty shrinkage
562/// floor, because that floor bounds how closely EDF can approach the algebraic
563/// ceiling even as lambda tends to zero. Non-positive capacity (a block whose
564/// null space already exhausts its columns) is never saturated. The absolute
565/// scale of `ε` is what the MSI truth-recovery sweep
566/// (sin8/kappa/large_scale + #1074) validates — the criterion SHAPE
567/// (edf-vs-capacity, nullspace excluded, tol-tied margin) is the load-bearing
568/// contract this function pins.
569pub fn basis_is_saturated(
570    edf: f64,
571    realized_width: usize,
572    nullspace_dim: usize,
573    resolution_tol: f64,
574) -> bool {
575    let capacity = realized_width.saturating_sub(nullspace_dim) as f64;
576    if !(capacity > 0.0) || !edf.is_finite() {
577        return false;
578    }
579    let penalized_edf = (edf - nullspace_dim as f64).clamp(0.0, capacity);
580    let margin = (capacity * resolution_tol).max(resolution_tol);
581    penalized_edf >= capacity - margin
582}
583
584/// Resource-aware plan for a spatial smooth (Duchon / Matérn / TPS).
585///
586/// Returned by [`plan_spatial_basis`]. Captures the resolved center count,
587/// final basis dimension `p`, the dense byte cost for the value matrix and
588/// each derivative tier, and a recommended storage mode that is consistent
589/// with the supplied [`gam_runtime::resource::ResourcePolicy`].
590#[derive(Clone, Debug)]
591pub struct SpatialBasisPlan {
592    pub n: usize,
593    pub d: usize,
594    pub centers: usize,
595    pub p_final_estimate: usize,
596    pub dense_design_bytes: usize,
597    pub first_derivative_dense_bytes: usize,
598    pub second_derivative_dense_bytes: usize,
599    pub recommended_storage: SpatialStorageMode,
600}
601
602/// Storage mode recommended by [`plan_spatial_basis`].
603///
604/// * `DenseValueDenseDerivatives` — both the value design and its derivative
605///   matrices fit under the policy's single-materialization budget.
606/// * `LazyValueImplicitDerivatives` — the value design fits dense but the
607///   derivative matrices do not; switch derivatives to the implicit operator.
608/// * `OperatorOnly` — neither the design nor its derivatives fit; everything
609///   must be operator-backed.
610#[derive(Clone, Copy, Debug, PartialEq, Eq)]
611pub enum SpatialStorageMode {
612    DenseValueDenseDerivatives,
613    LazyValueImplicitDerivatives,
614    OperatorOnly,
615}
616
617/// How [`plan_spatial_basis`] should pick the spatial center count.
618#[derive(Clone, Copy, Debug)]
619pub enum CenterCountRequest {
620    /// Use the heuristic [`default_num_centers`].
621    Default,
622    /// Use the caller-supplied count exactly.
623    Explicit(usize),
624    /// Use [`default_num_centers`] but cap at `cap` to bound dense cost.
625    HeuristicCapped { cap: usize },
626}
627
628/// Build a resource-aware plan for a spatial smooth basis.
629///
630/// Computes the resolved center count, final basis dimension, dense byte
631/// estimates for the value design and first/second derivative tiers, and a
632/// recommended [`SpatialStorageMode`] derived from `policy`. This is the
633/// resource-aware replacement for ad-hoc calls to [`default_num_centers`] /
634/// `heuristic_centers`.
635pub fn plan_spatial_basis(
636    n: usize,
637    d: usize,
638    requested_centers: CenterCountRequest,
639    nullspace_order: DuchonNullspaceOrder,
640    scale_dims: bool,
641    policy: &gam_runtime::resource::ResourcePolicy,
642) -> Result<SpatialBasisPlan, BasisError> {
643    if n == 0 {
644        crate::bail_invalid_basis!("plan_spatial_basis: n must be >= 1");
645    }
646    if d == 0 {
647        crate::bail_invalid_basis!("plan_spatial_basis: d must be >= 1");
648    }
649
650    // 1. Resolve center count.
651    let centers = match requested_centers {
652        CenterCountRequest::Default => default_num_centers(n, d),
653        CenterCountRequest::Explicit(k) => k,
654        CenterCountRequest::HeuristicCapped { cap } => default_num_centers(n, d).min(cap),
655    };
656
657    // 2. Nullspace dimension (Duchon polynomial null space of degree p-1).
658    //    `duchon_p_from_nullspace_order` returns m such that the null space is
659    //    polynomials of total degree < m, matching `duchon_nullspace_dimension`'s
660    //    `max_total_degree = m - 1` argument.
661    let m = duchon_p_from_nullspace_order(nullspace_order);
662    let nullspace_dim = if m == 0 {
663        0
664    } else {
665        duchon_nullspace_dimension(d, m - 1)
666    };
667
668    let p = centers.saturating_add(nullspace_dim);
669
670    // 3. Dense byte estimates.
671    let derivative_axes = if scale_dims { d } else { 0 };
672    let bytes_per_f64 = std::mem::size_of::<f64>();
673    let dense_design_bytes = bytes_per_f64.saturating_mul(n).saturating_mul(p);
674    let first_derivative_dense_bytes = dense_design_bytes.saturating_mul(derivative_axes);
675    // Diagonal second derivatives are also (D × n × p); off-diagonal cross terms
676    // would scale as D^2 but the planner reports the diagonal tier here.
677    let second_derivative_dense_bytes = first_derivative_dense_bytes;
678
679    // 4. Pick storage mode based on policy.
680    let recommended_storage = match policy.derivative_storage_mode {
681        gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired => {
682            SpatialStorageMode::OperatorOnly
683        }
684        gam_runtime::resource::DerivativeStorageMode::MaterializeIfSmall => {
685            let budget = policy.max_single_materialization_bytes;
686            if derivative_axes == 0 {
687                if dense_design_bytes <= budget {
688                    SpatialStorageMode::DenseValueDenseDerivatives
689                } else {
690                    SpatialStorageMode::LazyValueImplicitDerivatives
691                }
692            } else {
693                let total = dense_design_bytes
694                    .saturating_add(first_derivative_dense_bytes)
695                    .saturating_add(second_derivative_dense_bytes);
696                if total <= budget {
697                    SpatialStorageMode::DenseValueDenseDerivatives
698                } else if dense_design_bytes <= budget {
699                    SpatialStorageMode::LazyValueImplicitDerivatives
700                } else {
701                    SpatialStorageMode::OperatorOnly
702                }
703            }
704        }
705        gam_runtime::resource::DerivativeStorageMode::DiagnosticsOnly => {
706            // Diagnostic mode still prefers analytic storage for correctness.
707            SpatialStorageMode::OperatorOnly
708        }
709    };
710
711    Ok(SpatialBasisPlan {
712        n,
713        d,
714        centers,
715        p_final_estimate: p,
716        dense_design_bytes,
717        first_derivative_dense_bytes,
718        second_derivative_dense_bytes,
719        recommended_storage,
720    })
721}
722
723pub const fn default_spatial_center_strategy(num_centers: usize, d: usize) -> CenterStrategy {
724    if d <= 3 {
725        CenterStrategy::FarthestPoint { num_centers }
726    } else {
727        CenterStrategy::EqualMassCovarRepresentative { num_centers }
728    }
729}
730
731pub fn auto_spatial_center_strategy(num_centers: usize, d: usize) -> CenterStrategy {
732    let strategy = if d == 1 {
733        // In one dimension, farthest-point selection is the deterministic
734        // maximin grid over the observed domain. Equal-mass midpoints leave the
735        // low-frequency Duchon radial block slightly under-resolved at the
736        // boundaries, and REML then compensates with an over-smooth λ on
737        // low-noise signals (#504). The maximin grid matches the native
738        // reproducing-kernel interpolation geometry. The default strategy below
739        // extends the same space-filling contract to low-dimensional spatial
740        // GP bases, where kriging accuracy is governed by fill distance rather
741        // than marginal quantile balance.
742        CenterStrategy::FarthestPoint { num_centers }
743    } else {
744        default_spatial_center_strategy(num_centers, d)
745    };
746    CenterStrategy::Auto(Box::new(strategy))
747}
748
749pub const fn center_strategy_is_auto(strategy: &CenterStrategy) -> bool {
750    match strategy {
751        CenterStrategy::Auto(_) => true,
752        CenterStrategy::DuchonSpectral { knots, .. } => center_strategy_is_auto(knots),
753        _ => false,
754    }
755}
756
757pub(crate) fn realized_center_strategy(strategy: &CenterStrategy) -> &CenterStrategy {
758    match strategy {
759        CenterStrategy::Auto(inner) => inner.as_ref(),
760        CenterStrategy::DuchonSpectral { knots, .. } => realized_center_strategy(knots),
761        other => other,
762    }
763}
764
765pub(crate) fn center_strategy_spectral_basis(
766    strategy: &CenterStrategy,
767) -> Option<&DuchonSpectralBasis> {
768    match strategy {
769        CenterStrategy::Auto(inner) => center_strategy_spectral_basis(inner),
770        CenterStrategy::DuchonSpectral { basis, .. } => Some(basis),
771        _ => None,
772    }
773}
774
775/// Whether a Duchon center plan is fully fit-time-resolved.
776///
777/// A spectral plan is frozen only when both pieces of fit-time state are
778/// explicit: the selected knots and the learned kernel-to-basis transform.
779/// Keeping this predicate beside the state types prevents model validation
780/// from accidentally treating the spectral wrapper itself as an unresolved
781/// center-selection strategy.
782pub(crate) fn duchon_center_strategy_is_frozen(strategy: &CenterStrategy) -> bool {
783    match strategy {
784        CenterStrategy::UserProvided(_) => true,
785        CenterStrategy::DuchonSpectral {
786            knots,
787            basis: DuchonSpectralBasis::Frozen { .. },
788        } => matches!(knots.as_ref(), CenterStrategy::UserProvided(_)),
789        _ => false,
790    }
791}
792
793#[cfg(test)]
794mod duchon_center_state_tests {
795    use super::*;
796
797    #[test]
798    fn spectral_state_is_frozen_only_when_knots_and_transform_are_resolved() {
799        let centers = Array2::zeros((3, 2));
800        let unresolved = CenterStrategy::DuchonSpectral {
801            knots: Box::new(CenterStrategy::UserProvided(centers.clone())),
802            basis: DuchonSpectralBasis::Fresh { rank: 2 },
803        };
804        assert!(!duchon_center_strategy_is_frozen(&unresolved));
805
806        let resolved = CenterStrategy::DuchonSpectral {
807            knots: Box::new(CenterStrategy::UserProvided(centers)),
808            basis: DuchonSpectralBasis::Frozen {
809                rank: 2,
810                kernel_transform: Array2::zeros((3, 1)),
811                bending_penalty: Array2::zeros((1, 1)),
812            },
813        };
814        assert!(duchon_center_strategy_is_frozen(&resolved));
815    }
816}
817
818pub fn center_strategy_kind(strategy: &CenterStrategy) -> CenterStrategyKind {
819    match strategy {
820        CenterStrategy::Auto(inner) => center_strategy_kind(inner.as_ref()),
821        CenterStrategy::DuchonSpectral { knots, .. } => center_strategy_kind(knots),
822        CenterStrategy::UserProvided(_) => CenterStrategyKind::UserProvided,
823        CenterStrategy::EqualMass { .. } => CenterStrategyKind::EqualMass,
824        CenterStrategy::EqualMassCovarRepresentative { .. } => {
825            CenterStrategyKind::EqualMassCovarRepresentative
826        }
827        CenterStrategy::FarthestPoint { .. } => CenterStrategyKind::FarthestPoint,
828        CenterStrategy::KMeans { .. } => CenterStrategyKind::KMeans,
829        CenterStrategy::UniformGrid { .. } => CenterStrategyKind::UniformGrid,
830    }
831}
832
833pub fn center_strategy_num_centers(strategy: &CenterStrategy) -> Option<usize> {
834    match strategy {
835        CenterStrategy::Auto(inner) => center_strategy_num_centers(inner.as_ref()),
836        CenterStrategy::DuchonSpectral { knots, .. } => center_strategy_num_centers(knots),
837        CenterStrategy::UserProvided(centers) => Some(centers.nrows()),
838        CenterStrategy::EqualMass { num_centers }
839        | CenterStrategy::EqualMassCovarRepresentative { num_centers }
840        | CenterStrategy::FarthestPoint { num_centers }
841        | CenterStrategy::KMeans { num_centers, .. } => Some(*num_centers),
842        CenterStrategy::UniformGrid { .. } => None,
843    }
844}
845
846pub fn center_strategy_with_num_centers(
847    strategy: &CenterStrategy,
848    num_centers: usize,
849    d: usize,
850) -> Result<CenterStrategy, BasisError> {
851    validate_center_count(num_centers)?;
852    fn rebuild_inner(
853        strategy: &CenterStrategy,
854        num_centers: usize,
855        d: usize,
856    ) -> Result<CenterStrategy, BasisError> {
857        match strategy {
858            CenterStrategy::Auto(inner) => rebuild_inner(inner.as_ref(), num_centers, d),
859            CenterStrategy::DuchonSpectral { knots, basis } => Ok(CenterStrategy::DuchonSpectral {
860                knots: Box::new(rebuild_inner(knots, num_centers, d)?),
861                basis: basis.clone(),
862            }),
863            CenterStrategy::EqualMass { .. } => Ok(CenterStrategy::EqualMass { num_centers }),
864            CenterStrategy::EqualMassCovarRepresentative { .. } => {
865                Ok(CenterStrategy::EqualMassCovarRepresentative { num_centers })
866            }
867            CenterStrategy::FarthestPoint { .. } => {
868                Ok(CenterStrategy::FarthestPoint { num_centers })
869            }
870            CenterStrategy::KMeans { max_iter, .. } => Ok(CenterStrategy::KMeans {
871                num_centers,
872                max_iter: *max_iter,
873            }),
874            CenterStrategy::UniformGrid { .. } if d == 1 => Ok(CenterStrategy::UniformGrid {
875                points_per_dim: num_centers,
876            }),
877            CenterStrategy::UserProvided(_) | CenterStrategy::UniformGrid { .. } => {
878                Err(BasisError::InvalidInput(format!(
879                    "cannot replace center count for {:?} strategy",
880                    center_strategy_kind(strategy)
881                )))
882            }
883        }
884    }
885    let rebuilt = rebuild_inner(strategy, num_centers, d)?;
886    Ok(match strategy {
887        CenterStrategy::Auto(_) => CenterStrategy::Auto(Box::new(rebuilt)),
888        _ => rebuilt,
889    })
890}
891
892/// Thin-plate basis configuration.
893#[derive(Debug, Clone, Serialize, Deserialize)]
894pub struct ThinPlateBasisSpec {
895    pub center_strategy: CenterStrategy,
896    #[serde(default)]
897    pub periodic: Option<Vec<Option<f64>>>,
898    pub length_scale: f64,
899    pub double_penalty: bool,
900    #[serde(default)]
901    pub identifiability: SpatialIdentifiability,
902    /// Frozen Wood-TPRS radial reparameterization. When `Some`, the builder
903    /// reuses this `(raw_radial_cols) × (kept_radial_cols)` matrix instead of
904    /// recomputing it from the constrained kernel penalty eigensystem. The
905    /// rectangular case is the truncated regression-spline path; carrying it
906    /// into prediction guarantees identical radial modes to fit-time.
907    #[serde(default)]
908    pub radial_reparam: Option<Array2<f64>>,
909}
910
911/// Per-smooth identifiability policy for spatial (TPS / Duchon) bases.
912///
913/// For a raw local basis `B` and parametric design block `C`, the orthogonalized
914/// basis is `B_c = B Z` where columns of `Z` span `null((B^T C)^T)`. This enforces:
915///   `B_c^T C = 0`
916/// in the unweighted inner product, so spatial effects cannot absorb parametric
917/// directions that actually exist in the model. The standalone basis builder has
918/// only an implicit intercept available, so it centers smooths against that
919/// intercept. The term-collection builder augments `C` with explicit linear
920/// terms when those terms are present in the formula.
921#[derive(Debug, Default, Clone, Serialize, Deserialize)]
922pub enum SpatialIdentifiability {
923    /// Keep unconstrained basis columns.
924    None,
925    /// Orthogonalize the smooth against model-owned parametric columns.
926    // "Magic" default for modular GAMs with explicit parametric block:
927    // keep spatial smooth orthogonal to intercept/linear terms.
928    // ApproxKind: Exact (orthogonalization is an exact projection).
929    #[default]
930    OrthogonalToParametric,
931    /// Freeze a fit-time transform `Z`; prediction uses `B_new * Z` unchanged.
932    FrozenTransform { transform: Array2<f64> },
933}
934
935pub(crate) use sphere_half_angle::{
936    SphereTrig, ambient_half_angle_separation, half_angle_partials, half_angle_separation,
937    half_angle_separation_scalar,
938};
939
940pub(crate) use sphere_kernels::{
941    wahba_sphere_kernel_derivative_dhav_kind, wahba_sphere_kernel_kind,
942    wahba_sphere_kernel_simd_kind, wahba_sphere_kernel_sobolev_derivative_dhav,
943};
944
945pub use sphere_spectral::{
946    pseudo_s2_truncated_coefficients, sobolev_s2_truncated_coefficients,
947    sphere_truncated_spectral_eval,
948};
949
950/// User intent and resolved numeric state for a Matérn kernel length scale.
951///
952/// `Auto` remains auto-owned after the planner resolves its data-dependent
953/// numeric seed.  This is deliberately not represented by a magic floating
954/// point value: callers can distinguish an omitted `length_scale` from an
955/// explicit value before and after center planning, and subsequent κ updates
956/// preserve that provenance.
957#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
958pub enum MaternLengthScale {
959    Auto { resolved: Option<f64> },
960    Fixed(f64),
961}
962
963impl MaternLengthScale {
964    pub const fn auto() -> Self {
965        Self::Auto { resolved: None }
966    }
967
968    pub const fn fixed(value: f64) -> Self {
969        Self::Fixed(value)
970    }
971
972    pub const fn is_fixed(self) -> bool {
973        matches!(self, Self::Fixed(_))
974    }
975
976    pub const fn resolved(self) -> Option<f64> {
977        match self {
978            Self::Auto { resolved } => resolved,
979            Self::Fixed(value) => Some(value),
980        }
981    }
982
983    /// Install a numeric value without changing who owns the scale.
984    pub fn set_resolved(&mut self, value: f64) {
985        match self {
986            Self::Auto { resolved } => *resolved = Some(value),
987            Self::Fixed(fixed) => *fixed = value,
988        }
989    }
990
991    /// Resolve an omitted scale exactly once.  Replanning a frozen or
992    /// κ-updated Auto scale must retain its current numeric value.
993    pub fn resolve_auto_once(&mut self, value: f64) {
994        if let Self::Auto { resolved } = self
995            && resolved.is_none()
996        {
997            *resolved = Some(value);
998        }
999    }
1000}
1001
1002/// Matérn basis configuration.
1003#[derive(Debug, Clone, Serialize, Deserialize)]
1004pub struct MaternBasisSpec {
1005    pub center_strategy: CenterStrategy,
1006    #[serde(default)]
1007    pub periodic: Option<Vec<Option<f64>>>,
1008    pub length_scale: MaternLengthScale,
1009    pub nu: MaternNu,
1010    #[serde(default)]
1011    pub include_intercept: bool,
1012    pub double_penalty: bool,
1013    #[serde(default)]
1014    pub identifiability: MaternIdentifiability,
1015    /// Per-axis anisotropy log-scales η_a (contrasts with Ση_a = 0).
1016    ///
1017    /// This implements geometric anisotropy: Λ = κA where A = diag(exp(η_a)),
1018    /// det(A) = 1. The kernel is evaluated at r = κ|Ah| instead of r = κ|h|.
1019    /// The decomposition preserves the isotropic scaling law for global κ
1020    /// and adds d−1 shape parameters for directional relevance.
1021    ///
1022    /// Conditional positive definiteness is preserved under any invertible
1023    /// linear coordinate transform (Schoenberg), so the kernel remains valid.
1024    ///
1025    /// When Some, the distance is r = √(Σ_a exp(2η_a) · (x_a - c_a)²).
1026    /// When None, isotropic distance r = ‖x - c‖ is used.
1027    #[serde(default)]
1028    pub aniso_log_scales: Option<Vec<f64>>,
1029}
1030
1031/// Per-smooth identifiability policy for Matérn kernel coefficients.
1032///
1033/// These constraints are geometric (center-based), so they are stable across
1034/// train/predict and do not depend on response weights.
1035#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1036pub enum MaternIdentifiability {
1037    /// Keep the unconstrained kernel coefficient space.
1038    None,
1039    /// Enforce `1^T alpha = 0` at center locations (removes constant drift).
1040    // Safe default with model intercepts: prevent kernel block from absorbing
1041    // a global mean level.
1042    #[default]
1043    CenterSumToZero,
1044    /// Enforce orthogonality to `[1, c_1, ..., c_d]` at centers.
1045    /// Use this when explicit linear terms should own global trends.
1046    CenterLinearOrthogonal,
1047    /// Freeze a fit-time transform `Z` so prediction cannot drift.
1048    FrozenTransform { transform: Array2<f64> },
1049}
1050
1051/// Duchon null-space polynomial degree.
1052///
1053/// Controls the polynomial null space of the Duchon / polyharmonic spline. The
1054/// Duchon seminorm `‖D^m f‖²` annihilates all polynomials of total degree
1055/// `< m`, so those polynomials must be handled as explicit unpenalized columns.
1056///
1057/// The user-facing `order` knob selects the polynomial degree cutoff `r`, and
1058/// the resulting polynomial null space has dimension `C(d + r, r)` where `d`
1059/// is the covariate dimension.  In the `duchon(...)` formula DSL:
1060///
1061/// | `order=` | Variant         | max total degree | null-space dim  |
1062/// |----------|-----------------|------------------|-----------------|
1063/// | `0`      | `Zero`          | 0                | `C(d+0,0) = 1`  |
1064/// | `1`      | `Linear`        | 1                | `C(d+1,1) = d+1`|
1065/// | `k≥2`    | `Degree(k)`     | k                | `C(d+k,k)`      |
1066///
1067/// **How the polynomial null space is consumed during basis construction:**
1068///
1069/// 1. `polynomial_block_from_order` materialises an `(n, C(d+r,r))` block `P`
1070///    of monomials up to total degree `r` at the selected `centers`.
1071/// 2. `kernel_constraint_nullspace` computes `Z = null(P_centers^T)`, a
1072///    `(k, k − C(d+r,r))` matrix. Reparameterising the radial kernel
1073///    coefficients as `α = Z γ` enforces the side condition `P_centers^T α = 0`
1074///    and yields `k − C(d+r,r)` free kernel parameters.
1075/// 3. The polynomial block `P_data` evaluated at the data rows is appended to
1076///    the kernel block `Φ Z`, giving a total of
1077///    `(k − C(d+r,r)) + C(d+r,r) = k` columns before the spatial
1078///    identifiability transform.  Crucially, the total width equals the
1079///    requested center count `k`, **not** `k + C(d+r,r)`.
1080///
1081/// **Example — `duchon(PC1, PC2, PC3, centers=10, order=1)` (d=3):**
1082///
1083/// - Polynomial null space: `C(3+1,1) = 4` monomials `{1, x₁, x₂, x₃}`.
1084/// - Kernel columns after constraint: `10 − 4 = 6`.
1085/// - Appended polynomial block: 4 columns.
1086/// - Pre-identifiability total: `6 + 4 = 10` columns, i.e. exactly `centers`.
1087///
1088/// The variant naming matches the Duchon `m` parameter:
1089/// `Zero` → `m=1`, `Linear` → `m=2`, `Degree(k)` → `m=k+1`.
1090#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1091pub enum DuchonNullspaceOrder {
1092    Zero,
1093    Linear,
1094    Degree(usize),
1095}
1096
1097/// Explicit low-rank spectral construction for a Duchon kernel.
1098///
1099/// `rank` is the total retained spline dimension, including the polynomial
1100/// null space. `Fresh` asks the basis builder to compute the dominant
1101/// center-kernel eigenspace once. `Frozen` carries both the resulting direct
1102/// center-to-radial transform and the reduced bending operator into prediction
1103/// and derivative rebuilds. Keeping both pieces in the state is essential:
1104/// recomputing `Vᵀ K V` after a residual-certified Ritz solve silently replaces
1105/// the tiny tridiagonal's Galerkin operator by a numerically different one.
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1107#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
1108pub enum DuchonSpectralBasis {
1109    Fresh {
1110        rank: usize,
1111    },
1112    Frozen {
1113        rank: usize,
1114        kernel_transform: Array2<f64>,
1115        bending_penalty: Array2<f64>,
1116    },
1117}
1118
1119impl DuchonSpectralBasis {
1120    pub fn rank(&self) -> usize {
1121        match self {
1122            Self::Fresh { rank } | Self::Frozen { rank, .. } => *rank,
1123        }
1124    }
1125
1126    pub fn kernel_transform(&self) -> Option<&Array2<f64>> {
1127        match self {
1128            Self::Fresh { .. } => None,
1129            Self::Frozen {
1130                kernel_transform, ..
1131            } => Some(kernel_transform),
1132        }
1133    }
1134
1135    pub fn bending_penalty(&self) -> Option<&Array2<f64>> {
1136        match self {
1137            Self::Fresh { .. } => None,
1138            Self::Frozen {
1139                bending_penalty, ..
1140            } => Some(bending_penalty),
1141        }
1142    }
1143}
1144
1145/// Duchon-like basis configuration with explicit low-frequency null-space
1146/// control and explicit spectral power.
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1148#[serde(deny_unknown_fields)]
1149pub struct DuchonBasisSpec {
1150    pub center_strategy: CenterStrategy,
1151    #[serde(default)]
1152    pub periodic: Option<Vec<Option<f64>>>,
1153    /// Optional hybrid Matérn width. `None` means pure scale-free Duchon with
1154    /// spectrum `||w||^(2p + 2s)`. `Some(length_scale)` enables the hybrid
1155    /// spectrum `||w||^(2p) * (kappa^2 + ||w||^2)^s`, `kappa = 1/length_scale`.
1156    pub length_scale: Option<f64>,
1157    /// Literal Duchon spectral power `s` (`f64`, fractional values fully
1158    /// threaded end-to-end). The pure-Duchon kernel exponent is `2(p + s) − d`,
1159    /// so this is the knob that sets `φ(r)`: `s = 0` is the integer-order Duchon
1160    /// kernel `r^{2p−d}` (its `r²·log r` log case in even `d`, ≡ the thin-plate
1161    /// kernel); `s = (d − 1)/2` gives the cubic `r³` in every dimension.
1162    ///
1163    /// This field is taken LITERALLY by the basis builder — `power = 0` means
1164    /// `s = 0`, NOT "use a default". The magic cubic default (applied when the
1165    /// user gives no explicit power) is a request-layer choice resolved by the
1166    /// formula / CLI / pyffi front-ends via [`duchon_cubic_default`]; by the time
1167    /// a spec reaches the builder this value is the final intended `s`. The
1168    /// hybrid Duchon–Matérn path (`length_scale = Some`) still requires an
1169    /// integer `s` (read via `spec.power_as_usize()`).
1170    pub power: f64,
1171    pub nullspace_order: DuchonNullspaceOrder,
1172    #[serde(default)]
1173    pub identifiability: SpatialIdentifiability,
1174    /// Per-axis anisotropy log-scales η_a.
1175    ///
1176    /// For hybrid Duchon (`length_scale=Some`), these are centered contrasts in
1177    /// the decomposition Λ = κA with det(A)=1. For pure Duchon
1178    /// (`length_scale=None`), they parameterize shape-only axis warping on the
1179    /// public path and are centered before basis evaluation/writeback so no
1180    /// global length scale is introduced.
1181    ///
1182    /// When Some, the distance is r = √(Σ_a exp(2η_a) · (x_a - c_a)²).
1183    /// When None, isotropic distance r = ‖x - c‖ is used.
1184    #[serde(default)]
1185    pub aniso_log_scales: Option<Vec<f64>>,
1186    #[serde(default)]
1187    pub operator_penalties: DuchonOperatorPenaltySpec,
1188    #[serde(default)]
1189    pub boundary: OneDimensionalBoundary,
1190    /// Data-metric radial reparameterization `V` (#1355), mirroring the
1191    /// thin-plate Wood-TPRS reparam. When `Some`, the constrained kernel
1192    /// transform is folded to `Z·V` so the realized design columns rotate into
1193    /// the `G_c`-orthonormal generalized eigenbasis of `Ω_c v = μ G_c v` and the
1194    /// native penalty becomes the diagonal curvature-per-unit-data-variance
1195    /// spectrum (mgcv's cliff), preventing the REML over-smoothing collapse to
1196    /// EDF = 1. Frozen at the cold dense build and replayed verbatim by the
1197    /// predict / κ-trial / ψ-derivative paths so they stay bit-consistent with
1198    /// the fit-time design. `None` on the lazy/streaming path (huge `n`), which
1199    /// retains the original constrained basis.
1200    #[serde(default)]
1201    pub radial_reparam: Option<Array2<f64>>,
1202}
1203
1204impl DuchonBasisSpec {
1205    /// Integer view of `power` for the existing integer-only downstream chain.
1206    /// Non-finite or non-integer values fall back to `0` (the integer-only
1207    /// validators downstream already reject this case with a clear message).
1208    pub fn power_as_usize(&self) -> usize {
1209        duchon_power_to_usize(self.power)
1210    }
1211}
1212
1213/// Convert a Duchon spectral-power `f64` into the integer view used by the
1214/// closed-form code paths. Non-finite, negative, or fractional values clamp to
1215/// `0` so the validator downstream emits the canonical error.
1216pub fn duchon_power_to_usize(power: f64) -> usize {
1217    if !power.is_finite() || power < 0.0 {
1218        return 0;
1219    }
1220    let rounded = power.round();
1221    if (rounded - power).abs() > 1e-9 {
1222        return 0;
1223    }
1224    rounded as usize
1225}
1226
1227#[derive(Clone, Debug, Serialize, Deserialize)]
1228pub struct DuchonOperatorPenaltySpec {
1229    pub mass: OperatorPenaltySpec,
1230    pub tension: OperatorPenaltySpec,
1231    pub stiffness: OperatorPenaltySpec,
1232}
1233
1234#[derive(Clone, Debug, Serialize, Deserialize)]
1235pub enum OperatorPenaltySpec {
1236    Active {
1237        initial_log_lambda: f64,
1238        prior: Option<RhoPrior>,
1239    },
1240    Disabled,
1241}
1242
1243impl Default for DuchonOperatorPenaltySpec {
1244    fn default() -> Self {
1245        // ALL ON. The Duchon penalty is a Hilbert scale: curvature is the
1246        // always-on exact RKHS `Primary` Gram and the trend ridge is always on;
1247        // the lower orders — mass (amplitude `Σ(f−f̄)²`) and tension (first-order
1248        // roughness `Σ‖∇f‖²`) — are active here, collocated on a density-blind
1249        // data-support sample. REML deselects any the data don't support (SPEC:
1250        // recover the null by default, opt INTO overfitting). Stiffness (`D2`)
1251        // stays off — `Primary` is the exact, superior curvature. (The Matérn
1252        // collocation overlay builds its own `all_active()`; SAE atoms, which
1253        // ship only `Primary`, use `all_disabled()`.)
1254        Self {
1255            mass: OperatorPenaltySpec::Active {
1256                initial_log_lambda: 0.0,
1257                prior: None,
1258            },
1259            tension: OperatorPenaltySpec::Active {
1260                initial_log_lambda: 0.0,
1261                prior: None,
1262            },
1263            stiffness: OperatorPenaltySpec::Disabled,
1264        }
1265    }
1266}
1267
1268impl DuchonOperatorPenaltySpec {
1269
1270    pub fn all_disabled() -> Self {
1271        Self {
1272            mass: OperatorPenaltySpec::Disabled,
1273            tension: OperatorPenaltySpec::Disabled,
1274            stiffness: OperatorPenaltySpec::Disabled,
1275        }
1276    }
1277
1278    /// All three operator dials active — used by the Matérn collocation overlay.
1279    pub fn all_active() -> Self {
1280        let active = || OperatorPenaltySpec::Active {
1281            initial_log_lambda: 0.0,
1282            prior: None,
1283        };
1284        Self {
1285            mass: active(),
1286            tension: active(),
1287            stiffness: active(),
1288        }
1289    }
1290
1291    /// Operator-penalty dials appropriate for a Matérn-ν kernel in dimension `d`.
1292    ///
1293    /// The Matérn-ν RKHS is the Sobolev space `H^m` with `m = ν + d/2`: its
1294    /// squared norm controls the order-`j` derivative in L2 exactly when
1295    /// `j ≤ m`. The collocation overlay penalizes the squared L2 norms of the
1296    /// value (mass, `D0`, j=0), gradient (tension, `D1`, j=1) and Hessian
1297    /// (stiffness, `D2`, j=2). Activating a penalty whose derivative order
1298    /// exceeds the RKHS smoothness (`j > m`) imposes a roughness constraint the
1299    /// true kernel does NOT — it over-smooths the reduced-rank fit relative to
1300    /// the exact GP (mgcv `bs="gp"`, GpGp).
1301    ///
1302    /// The ν=1/2 Ornstein–Uhlenbeck kernel is the sole exception: its cusp at a
1303    /// center makes the collocated gradient/Hessian undefined, so it retains
1304    /// mass only (#707). Every differentiable order uses the inclusive Sobolev
1305    /// boundary. In particular ν=3/2 in d=1 has `m=2`, and its finite `D2`
1306    /// stiffness energy belongs to H². Omitting that block leaves the rough
1307    /// kernel with only mass+tension, inflates EDF, and changes the REML model
1308    /// class relative to its stated RKHS.
1309    pub fn matern_for_smoothness(nu: MaternNu, d: usize) -> Self {
1310        let m = nu.half_integer_value() + 0.5 * d as f64;
1311        // Tolerance keeps the mathematically inclusive `j ≤ m` boundary stable
1312        // under floating-point representation of half-integer orders.
1313        const ORDER_EPS: f64 = 1e-9;
1314        let active = || OperatorPenaltySpec::Active {
1315            initial_log_lambda: 0.0,
1316            prior: None,
1317        };
1318        let gate = |order: f64| {
1319            if !matches!(nu, MaternNu::Half) && m + ORDER_EPS >= order {
1320                active()
1321            } else {
1322                OperatorPenaltySpec::Disabled
1323            }
1324        };
1325        Self {
1326            mass: active(),
1327            tension: gate(1.0),
1328            stiffness: gate(2.0),
1329        }
1330    }
1331}
1332
1333/// Resolve a fully admissible Duchon `(nullspace_order, power)` pair.
1334///
1335/// Three constraints fold into one resolution:
1336///   (a) operator collocation up to `max_op`:        `2(p + s) > d + max_op`
1337///   (b) pure-mode CPD vs polynomial nullspace P_p:  `2s < d`
1338///       (Wendland Thm 8.17: pure polyharmonic kernel of order m = p+s in
1339///        R^d is CPD of order `m − ⌊d/2⌋ + 1[d even, log] / m − (d−1)/2
1340///        [d odd]`, and Duchon interpolation against P_p is well-posed iff
1341///        CPD order ≤ p, which collapses to `2s < d` since 2s, d are
1342///        integers and 2s is even.)
1343///   (a) implies the kernel-existence condition `2(p + s) > d`.
1344///   (b) is dropped when `length_scale` is `Some` (hybrid Matérn-blended
1345///       kernel is strictly PD, CPD order 0).
1346///
1347/// Strategy: at the requested `nullspace_order`, take the smallest `s`
1348/// satisfying (a). If that `s` violates (b) in pure mode, escalate the
1349/// nullspace order by one and retry. Termination: at `p ≥ ⌈(d+max_op)/2⌉ + 1`
1350/// the operator constraint (a) admits `s = 0`, and `0 < d` satisfies (b)
1351/// for any `d ≥ 1`, so escalation always converges.
1352///
1353/// The returned nullspace order is monotone in the request: it never
1354/// decreases the user's requested order — only strengthens it when pure-mode
1355/// CPD requires a richer polynomial absorption space.
1356pub fn resolve_duchon_orders(
1357    dim: usize,
1358    requested_nullspace_order: DuchonNullspaceOrder,
1359    max_operator_derivative_order: usize,
1360    length_scale: Option<f64>,
1361) -> (DuchonNullspaceOrder, usize) {
1362    assert!(dim >= 1, "Duchon basis requires dim >= 1");
1363    let pure = length_scale.is_none();
1364    let mut nullspace = requested_nullspace_order;
1365    // Bounded loop: escalation terminates by the argument above.
1366    for _ in 0..=(dim + max_operator_derivative_order + 1) {
1367        let p = duchon_p_from_nullspace_order(nullspace);
1368        // Smallest s with 2(p + s) > d + max_op:
1369        //   2p > d + max_op            ⇒ s = 0
1370        //   else s = ⌈(d + max_op + 1 − 2p) / 2⌉ = (d + max_op + 2 − 2p) / 2
1371        let s_op = if 2 * p > dim + max_operator_derivative_order {
1372            0
1373        } else {
1374            (dim + max_operator_derivative_order + 2 - 2 * p) / 2
1375        };
1376        if !pure || 2 * s_op < dim {
1377            return (nullspace, s_op);
1378        }
1379        nullspace = duchon_next_nullspace_order(nullspace);
1380    }
1381    // Bounded-loop fallback: by the analysis in the docstring, for
1382    // `p >= ceil((dim + max_op) / 2) + 1` the operator constraint admits
1383    // `s = 0` and (in pure mode) `0 < dim` satisfies the kernel-existence
1384    // condition. The loop above always reaches that regime within the bound,
1385    // so returning the last `nullspace` with `s = 0` is a valid answer.
1386    (nullspace, 0)
1387}
1388
1389#[inline]
1390pub(crate) fn duchon_next_nullspace_order(order: DuchonNullspaceOrder) -> DuchonNullspaceOrder {
1391    match order {
1392        DuchonNullspaceOrder::Zero => DuchonNullspaceOrder::Linear,
1393        DuchonNullspaceOrder::Linear => DuchonNullspaceOrder::Degree(2),
1394        DuchonNullspaceOrder::Degree(k) => DuchonNullspaceOrder::Degree(k + 1),
1395    }
1396}
1397
1398pub(crate) fn duchon_previous_nullspace_order(order: DuchonNullspaceOrder) -> DuchonNullspaceOrder {
1399    match order {
1400        DuchonNullspaceOrder::Zero => DuchonNullspaceOrder::Zero,
1401        DuchonNullspaceOrder::Linear => DuchonNullspaceOrder::Zero,
1402        DuchonNullspaceOrder::Degree(2) => DuchonNullspaceOrder::Linear,
1403        DuchonNullspaceOrder::Degree(k) => DuchonNullspaceOrder::Degree(k - 1),
1404    }
1405}
1406
1407/// Returns the maximum derivative order required by the *active* operator
1408/// penalties: 2 if stiffness is Active, else 1 if tension is Active, else 0.
1409/// Mass-only (or no active operator) penalties only require kernel validity
1410/// (`2(p+s) > d`), tension requires D1 collocation (`2(p+s) > d+1`), and
1411/// stiffness requires D2 collocation (`2(p+s) > d+2`).
1412pub fn duchon_max_active_operator_derivative_order(
1413    operator_penalties: &DuchonOperatorPenaltySpec,
1414) -> usize {
1415    if matches!(
1416        operator_penalties.stiffness,
1417        OperatorPenaltySpec::Active { .. }
1418    ) {
1419        2
1420    } else if matches!(
1421        operator_penalties.tension,
1422        OperatorPenaltySpec::Active { .. }
1423    ) {
1424        1
1425    } else {
1426        0
1427    }
1428}
1429
1430/// Metadata returned by generic basis builders.
1431#[derive(Debug, Clone)]
1432pub enum BasisMetadata {
1433    BSpline1D {
1434        knots: Array1<f64>,
1435        identifiability_transform: Option<Array2<f64>>,
1436        periodic: Option<(f64, f64, usize)>,
1437        /// Effective B-spline polynomial degree carried by `knots`.
1438        ///
1439        /// Persisted alongside `knots` so prediction can reconstruct an
1440        /// evaluator that matches fit-time geometry, even when the fit-time
1441        /// auto-shrink (issue #340) reduced the user's requested degree to
1442        /// fit the available data (`n` too small for cubic ⇒ quadratic ⇒
1443        /// linear). When `None` the consumer should fall back to the
1444        /// upstream `BSplineBasisSpec.degree` (legacy / non-shrunk path).
1445        degree: Option<usize>,
1446        /// Human-readable description of an automatic basis shrink (issue #340)
1447        /// when the user's requested `(degree, num_internal_knots)` exceeded the
1448        /// available evaluation count `n`. `Some(note)` records the before→after
1449        /// configuration; `None` means no auto-shrink occurred for this basis.
1450        auto_shrink_note: Option<String>,
1451        /// Raw-basis particular-solution coefficients `β_p` for a *non-zero*
1452        /// endpoint anchor (#2297), if any. The term carries a fixed affine
1453        /// offset function `B_raw(x) · β_p` in addition to its constrained
1454        /// design `B_raw(x) · Z`; the design assembler realizes that offset into
1455        /// the model's linear predictor at both fit and predict time. `None`
1456        /// for free / clamped / zero-anchor bases (the ordinary pure-linear
1457        /// chart). Recomputed deterministically from the frozen `knots`,
1458        /// `degree` and boundary conditions on every rebuild, so a saved model
1459        /// replays the identical offset; it is serialized here so the assembler
1460        /// need not re-derive it from the spec. This metadata is transient
1461        /// (rebuilt at predict from the serialized frozen spec), not persisted.
1462        anchor_offset_coeffs: Option<Array1<f64>>,
1463    },
1464    /// Natural cubic regression spline (`bs="cr"`/`"cs"`) metadata (#1074).
1465    ///
1466    /// `knots` are the `k` Lancaster–Salkauskas knots that index the basis
1467    /// values directly (basis dim = `knots.len()`). Predict-time rebuilds
1468    /// reconstruct the cr geometry from `knots` and replay the captured
1469    /// `identifiability_transform` exactly, mirroring `BSpline1D`.
1470    CubicRegression1D {
1471        knots: Array1<f64>,
1472        identifiability_transform: Option<Array2<f64>>,
1473    },
1474    ThinPlate {
1475        /// Kernel centers in the STANDARDIZED frame (`x / input_scale`).
1476        centers: Array2<f64>,
1477        /// Kernel range in the user's ORIGINAL units — a different frame from
1478        /// `centers`.  Any consumer that evaluates the kernel against
1479        /// `centers` must first go through
1480        /// [`crate::IsotropicScale::to_standardized_units`]; the frame tag is
1481        /// what makes forgetting that a compile error (#2636).
1482        length_scale: crate::OriginalUnits,
1483        periodic: Option<Vec<Option<f64>>>,
1484        identifiability_transform: Option<Array2<f64>>,
1485        /// Uniform coordinate scale used for isotropic input standardization.
1486        input_scale: crate::IsotropicScale,
1487        /// Wood-TPRS radial reparameterization carried into prediction so the
1488        /// rotated radial basis at predict-time matches fit-time exactly. `None`
1489        /// in the lazy/streaming path which retains the original basis.
1490        radial_reparam: Option<Array2<f64>>,
1491    },
1492    Sphere {
1493        centers: Array2<f64>,
1494        penalty_order: usize,
1495        method: SphereMethod,
1496        max_degree: Option<usize>,
1497        wahba_kernel: SphereWahbaKernel,
1498        constraint_transform: Option<Array2<f64>>,
1499    },
1500    /// Constant-curvature (`M_κ`) geodesic-kernel smooth (#944). `kappa` and
1501    /// the realized `length_scale` are persisted so predict-time (and the
1502    /// future ψ-channel per-trial) rebuilds replay the exact fit-time
1503    /// geometry; `constraint_transform` is the composed `z · z_parametric`
1504    /// frozen by the global identifiability pipeline (#532 pattern).
1505    ConstantCurvature {
1506        centers: Array2<f64>,
1507        kappa: f64,
1508        length_scale: f64,
1509        constraint_transform: Option<Array2<f64>>,
1510    },
1511    /// Measure-jet spline smooth: multiscale local-jet-residual energy of the
1512    /// empirical measure, quadratured on the center set. `centers` are the
1513    /// REALIZED barycenter nodes; `order_s` stores the spec's order sentinel
1514    /// verbatim as the mode marker (0.0 = per-level/spectral, > 0 = fused
1515    /// pin — persisting a realized default would flip the rebuilt mode). The
1516    /// penalty depends on the FIT data through `masses`, the realized
1517    /// `eps_band`, the support anchors, and the normalization scales, so all
1518    /// are persisted and replayed verbatim by
1519    /// predict-time (and per-ψ-trial) rebuilds — recomputing either from
1520    /// predict rows would change the penalty the coefficients were estimated
1521    /// under. `constraint_transform` is the composed `z · z_parametric`
1522    /// frozen by the global identifiability pipeline (#532 pattern).
1523    MeasureJet {
1524        centers: Array2<f64>,
1525        input_scale: crate::IsotropicScale,
1526        /// Kernel range in the STANDARDIZED frame — the SAME frame as
1527        /// `centers`, and the odd one out among the four Euclidean spatial
1528        /// families (ThinPlate/Matern/Duchon all store original units).  The
1529        /// asymmetry is deliberate: a frozen MeasureJet range replays
1530        /// verbatim.  It used to live only in a private policy enum
1531        /// (`InputFrameNormalization::AutoStandardizedFreshOriginalReplayRealized`)
1532        /// that the value could not carry; the tag now carries it (#2636).
1533        length_scale: crate::StandardizedUnits,
1534        eps_band: Vec<f64>,
1535        order_s: f64,
1536        alpha: f64,
1537        tau0: f64,
1538        masses: Array1<f64>,
1539        support_means: Vec<f64>,
1540        penalty_normalization_scales: Vec<f64>,
1541        raw_penalty_normalization_scales: Vec<f64>,
1542        fused_penalty_normalization_scale: Option<f64>,
1543        constraint_transform: Option<Array2<f64>>,
1544        /// Ambient input-measurement-error scale `σ_coord` (issue #2225): the
1545        /// perpendicular off-manifold residual spread of the fit rows, in the
1546        /// centers' (standardized) frame. `None` when it could not be estimated.
1547        /// Carried into `MeasureJetFrozenQuadrature::sigma_coord` at freeze time.
1548        sigma_coord: Option<f64>,
1549    },
1550    Matern {
1551        /// Kernel centers in the STANDARDIZED frame (`x / input_scale`).
1552        centers: Array2<f64>,
1553        /// Kernel range in the user's ORIGINAL units; see
1554        /// [`BasisMetadata::ThinPlate::length_scale`] for the frame contract.
1555        length_scale: crate::OriginalUnits,
1556        periodic: Option<Vec<Option<f64>>>,
1557        nu: MaternNu,
1558        include_intercept: bool,
1559        identifiability_transform: Option<Array2<f64>>,
1560        /// Uniform coordinate scale used for isotropic input standardization.
1561        input_scale: crate::IsotropicScale,
1562        /// Per-axis anisotropy log-scales η_a for geometric anisotropy.
1563        /// When Some, distance is r = √(Σ_a exp(2η_a) · (x_a - c_a)²).
1564        aniso_log_scales: Option<Vec<f64>>,
1565    },
1566    Duchon {
1567        /// Kernel centers in the STANDARDIZED frame (`x / input_scale`).
1568        centers: Array2<f64>,
1569        /// Hybrid Duchon–Matérn range in the user's ORIGINAL units, or `None`
1570        /// for the pure scale-free spectrum; see
1571        /// [`BasisMetadata::ThinPlate::length_scale`] for the frame contract.
1572        length_scale: Option<crate::OriginalUnits>,
1573        periodic: Option<Vec<Option<f64>>>,
1574        power: f64,
1575        nullspace_order: DuchonNullspaceOrder,
1576        identifiability_transform: Option<Array2<f64>>,
1577        /// Uniform coordinate scale used for isotropic input standardization.
1578        input_scale: crate::IsotropicScale,
1579        /// Per-axis anisotropy log-scales η_a, stored for prediction.
1580        aniso_log_scales: Option<Vec<f64>>,
1581        /// Support points used to build the active lower-order operator
1582        /// penalties (mass/tension/stiffness). Stored so runtime adaptive
1583        /// caches can rebuild the exact same operator rows instead of guessing
1584        /// from centers.
1585        operator_collocation_points: Option<Array2<f64>>,
1586        /// Data-metric radial reparameterization `V` (#1355). When `Some`, the
1587        /// constrained kernel transform is folded to `Z·V` so predict-time and
1588        /// κ-trial rebuilds replay the exact fit-time rotated radial basis.
1589        /// `None` on the lazy/streaming path (original constrained basis).
1590        radial_reparam: Option<Array2<f64>>,
1591        /// Frozen direct center-to-radial transform for an explicitly spectral
1592        /// basis. Unlike `radial_reparam`, this already includes the polynomial
1593        /// side-condition projection and therefore has `centers.nrows()` rows.
1594        spectral_basis: Option<DuchonSpectralBasis>,
1595    },
1596    Pca {
1597        feature_cols: Vec<usize>,
1598        basis_matrix: Array2<f64>,
1599        centered: bool,
1600        smooth_penalty: f64,
1601        center_mean: Option<Array1<f64>>,
1602        pca_basis_path: Option<std::path::PathBuf>,
1603        chunk_size: usize,
1604    },
1605    TensorBSpline {
1606        feature_cols: Vec<usize>,
1607        knots: Vec<Array1<f64>>,
1608        degrees: Vec<usize>,
1609        periods: Vec<Option<f64>>,
1610        /// Per-margin flag: `true` when that margin is a natural cubic
1611        /// regression spline (`NaturalCubicRegression` knotspec) rather than an
1612        /// open/periodic B-spline (#1074). Persisted so the tensor freeze
1613        /// rebuilds the cr marginal knotspec (value-at-knot) instead of an open
1614        /// `Provided(knots)` B-spline, keeping predict-time marginals identical
1615        /// to the fit-time cr margins. Defaults to all-`false` (legacy B-spline
1616        /// tensors) when deserialized from an older persisted model (the
1617        /// older-model default is applied on the persisted `SmoothBasisSpec`
1618        /// side; `BasisMetadata` itself is transient builder output and is not
1619        /// serde-serialized, so it carries no `#[serde]` attributes).
1620        is_cr: Vec<bool>,
1621        identifiability_transform: Option<Array2<f64>>,
1622    },
1623    SphereHarmonics {
1624        max_degree: usize,
1625        radians: bool,
1626    },
1627    /// Wrap an inner basis metadata to record a multiplicative `by` (continuous or
1628    /// factor) along a column of the dataset.
1629    BySmooth {
1630        inner: Box<BasisMetadata>,
1631        by_col: usize,
1632        levels: Option<Vec<u64>>,
1633        ordered: bool,
1634    },
1635    /// Factor-by-smooth (mgcv-style `s(x, by=g, bs="fs"|"sz"|"re")`).
1636    FactorSmooth {
1637        continuous_cols: Vec<usize>,
1638        group_col: usize,
1639        knots: Array1<f64>,
1640        degree: usize,
1641        periodic: Option<(f64, f64, usize)>,
1642        group_levels: Vec<u64>,
1643        flavour: String,
1644        /// `true` when the per-level marginal is a cubic regression spline
1645        /// (`NaturalCubicRegression` knotspec, mgcv's `bs="sz"` default marginal,
1646        /// #1074). Predict-time freeze must then restore a cr knotspec from the
1647        /// stored value-knots rather than treating them as a B-spline knot
1648        /// vector. Defaults to `false` (B-spline marginal) for backward compat.
1649        marginal_is_cr: bool,
1650    },
1651}
1652
1653/// Standardized basis build result for engine-level composition.
1654#[derive(Clone)]
1655pub struct BasisBuildResult {
1656    pub design: DesignMatrix,
1657    /// Fixed row-wise contribution carried by an affine basis chart.
1658    ///
1659    /// Ordinary bases are linear in their fitted coefficients and leave this
1660    /// as `None`. An inhomogeneous boundary condition, such as a non-zero
1661    /// B-spline endpoint anchor, realizes the basis as
1662    /// `offset(x) + design(x) * beta`; the known `offset(x)` belongs here, not
1663    /// in a fake coefficient column. Term-collection assembly sums these
1664    /// channels and routes the result through the model's ordinary likelihood
1665    /// offset at fit and prediction time.
1666    pub affine_offset: Option<Array1<f64>>,
1667    /// Canonical active penalties. Matrix, spectral metadata, operator form,
1668    /// and semantic identity are one record so dropping an earlier candidate
1669    /// cannot shift one channel without shifting all of them.
1670    pub active_penalties: Vec<ActivePenalty>,
1671    /// Candidate diagnostics excluded from the active smoothing-parameter
1672    /// layout. Dropped candidates never share a positional container with
1673    /// active matrices.
1674    pub dropped_penalties: Vec<DroppedPenaltyInfo>,
1675    pub metadata: BasisMetadata,
1676    /// Optional factored rowwise-Kronecker representation for tensor-product
1677    /// bases. When present, downstream code can keep the design operator-backed
1678    /// instead of forcing a fully materialized `n x prod(q_j)` block.
1679    pub kronecker_factored: Option<KroneckerFactoredBasis>,
1680    /// Joint-null absorption rotation for this basis, when the basis carries
1681    /// any penalties with a non-trivial joint null space.
1682    ///
1683    /// `Some(rotation)` records `Q = [U_range | U_null]` where `U_null` spans
1684    /// the joint null space `null(Σ_k S_k)` over this basis's active
1685    /// penalties (unscaled — the structural joint null is independent of
1686    /// `λ`). After the basis pipeline applies this rotation, the design
1687    /// becomes `X · Q` and each penalty becomes `Qᵀ S_k Q`, block-diagonal
1688    /// with a guaranteed-zero null tail. The same `Q` must be replayed at
1689    /// prediction time, so it is persisted in the fitted model. `None`
1690    /// indicates either no penalties on this basis, or a full-rank joint
1691    /// penalty (joint nullity = 0). A `Some` value is never recorded with
1692    /// `joint_nullity == 0` — the `None` discriminant is canonical for
1693    /// "nothing to absorb".
1694    ///
1695    /// Stage-2 commit A: this field is plumbed into the struct but neither
1696    /// computed nor applied yet. Stage-2 commit B populates it; Stage-2
1697    /// commit D applies the rotation to `design` and `penalties`.
1698    pub joint_null_rotation: Option<JointNullRotation>,
1699}
1700
1701/// Joint-null absorption rotation, attached to a smooth's basis when the
1702/// basis's joint penalty `Σ_k S_k` has a non-trivial null space.
1703///
1704/// The `rotation` field stores the orthonormal eigenvector matrix
1705/// `Q = [U_range | U_null]` of the symmetric joint penalty: the first
1706/// `range_dim = rotation.ncols() - joint_nullity` columns span
1707/// `range(Σ_k S_k)`; the remaining `joint_nullity` columns span
1708/// `null(Σ_k S_k)`. After the pipeline applies the rotation, the smooth's
1709/// coefficient vector satisfies `β = Q · γ`, the design becomes `X · Q`,
1710/// and each per-block penalty `S_k` becomes `Qᵀ S_k Q`, which is guaranteed
1711/// block-diagonal with a zero `(joint_nullity × joint_nullity)` tail
1712/// (because the joint null annihilates every active `S_k`).
1713#[derive(Clone, Serialize, Deserialize)]
1714pub struct JointNullRotation {
1715    /// `(p_smooth × p_smooth)` orthonormal matrix; range columns first,
1716    /// joint-null columns last.
1717    pub rotation: Array2<f64>,
1718    /// Number of columns at the tail of `rotation` that span the joint
1719    /// null space. Always `> 0` when wrapped in `Some` — the value `0`
1720    /// is encoded as `None`.
1721    pub joint_nullity: usize,
1722}
1723
1724impl std::fmt::Debug for JointNullRotation {
1725    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1726        f.debug_struct("JointNullRotation")
1727            .field(
1728                "rotation",
1729                &format_args!("{}×{}", self.rotation.nrows(), self.rotation.ncols()),
1730            )
1731            .field("joint_nullity", &self.joint_nullity)
1732            .finish()
1733    }
1734}
1735
1736impl std::fmt::Debug for BasisBuildResult {
1737    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1738        f.debug_struct("BasisBuildResult")
1739            .field("design", &self.design)
1740            .field(
1741                "affine_offset_len",
1742                &self.affine_offset.as_ref().map(|offset| offset.len()),
1743            )
1744            .field("active_penalties", &self.active_penalties)
1745            .field("dropped_penalties", &self.dropped_penalties)
1746            .field("metadata", &self.metadata)
1747            .field("kronecker_factored", &self.kronecker_factored)
1748            .field("joint_null_rotation", &self.joint_null_rotation)
1749            .finish()
1750    }
1751}
1752
1753/// Factored tensor-product basis metadata for operator-backed downstream use.
1754#[derive(Debug)]
1755pub struct KroneckerFactoredBasis {
1756    /// Marginal design matrices: `marginal_designs[j]` is `(n, q_j)`.
1757    pub marginal_designs: Vec<Array2<f64>>,
1758    /// Marginal penalty matrices: `marginal_penalties[k]` is `(q_k, q_k)`.
1759    pub marginal_penalties: Vec<Array2<f64>>,
1760    /// Marginal basis dimensions: `[q_0, ..., q_{d-1}]`.
1761    pub marginal_dims: Vec<usize>,
1762    /// Whether the system includes a global ridge (double) penalty.
1763    pub has_double_penalty: bool,
1764    /// λ-invariant tensor structure (marginal eigensystems, reparameterized
1765    /// marginals, shrinkage scale), memoized once per fit. The marginal
1766    /// designs/penalties are fixed for the whole fit, so the expensive marginal
1767    /// `eigh()` and `B_k·U_k` GEMMs only need to run once — every outer REML
1768    /// iterate (50+ on the #1082 tensor cases) then reuses this. Filled lazily
1769    /// on first use via [`Self::invariant_structure`]. NOT serialized and reset
1770    /// to empty on `Clone` (it is purely a within-fit performance cache; a fresh
1771    /// owner recomputes on first demand, keeping every result bit-identical).
1772    invariant: std::sync::OnceLock<std::sync::Arc<crate::kronecker::KroneckerInvariantStructure>>,
1773}
1774
1775impl Clone for KroneckerFactoredBasis {
1776    fn clone(&self) -> Self {
1777        Self {
1778            marginal_designs: self.marginal_designs.clone(),
1779            marginal_penalties: self.marginal_penalties.clone(),
1780            marginal_dims: self.marginal_dims.clone(),
1781            has_double_penalty: self.has_double_penalty,
1782            // Propagate the memoized structure when present so a clone made
1783            // mid-fit keeps the hoist; otherwise start empty (recomputed on
1784            // first demand, identical result).
1785            invariant: match self.invariant.get() {
1786                Some(s) => {
1787                    let cell = std::sync::OnceLock::new();
1788                    cell.get_or_init(|| std::sync::Arc::clone(s));
1789                    cell
1790                }
1791                None => std::sync::OnceLock::new(),
1792            },
1793        }
1794    }
1795}
1796
1797impl KroneckerFactoredBasis {
1798    /// Construct from the fixed marginal data with an empty invariant cache.
1799    pub fn new(
1800        marginal_designs: Vec<Array2<f64>>,
1801        marginal_penalties: Vec<Array2<f64>>,
1802        marginal_dims: Vec<usize>,
1803        has_double_penalty: bool,
1804    ) -> Self {
1805        Self {
1806            marginal_designs,
1807            marginal_penalties,
1808            marginal_dims,
1809            has_double_penalty,
1810            invariant: std::sync::OnceLock::new(),
1811        }
1812    }
1813
1814    /// Lazily compute (once) and return the λ-invariant tensor structure
1815    /// (marginal eigensystems, reparameterized marginals, shrinkage scale).
1816    ///
1817    /// Computed from the fixed marginal designs/penalties, so the result is the
1818    /// same on every call within a fit; the first call pays the `eigh()` cost
1819    /// and every later call is a pointer load. Because the cache is keyed on the
1820    /// fixed marginal data and `marginal_penalties`/`marginal_designs` are
1821    /// immutable for the fit's lifetime, no invalidation is needed.
1822    pub fn invariant_structure(
1823        &self,
1824    ) -> Result<std::sync::Arc<crate::kronecker::KroneckerInvariantStructure>, gam_problem::EstimationError> {
1825        // Fast path: already memoized.
1826        if let Some(s) = self.invariant.get() {
1827            return Ok(std::sync::Arc::clone(s));
1828        }
1829        // Compute outside the cell (fallible) and install via `get_or_init`. If a
1830        // concurrent racer already won, `get_or_init` drops our `computed` and
1831        // returns the stored one; either way the value is the unique function of
1832        // the fixed marginal data, so the returned Arc is correct.
1833        let computed = std::sync::Arc::new(crate::kronecker::KroneckerInvariantStructure::compute(
1834            &self.marginal_designs,
1835            &self.marginal_penalties,
1836            &self.marginal_dims,
1837        )?);
1838        let installed = self.invariant.get_or_init(|| computed);
1839        Ok(std::sync::Arc::clone(installed))
1840    }
1841}
1842
1843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1844pub enum PenaltySource {
1845    Primary,
1846    DoublePenaltyNullspace,
1847    OperatorMass,
1848    OperatorTension,
1849    OperatorStiffness,
1850    /// One per input axis `a` of a multivariate Duchon smooth: the gradient
1851    /// energy along axis `a`, `Σ(∂f/∂x_a)²`, each with its own REML λ_a. REML
1852    /// shrinks an axis's contribution toward flat only when it does not earn
1853    /// its keep — penalty-based ARD / variable relevance, the replacement for
1854    /// brittle kernel-η optimization. Emitted when `scale_dims` is on.
1855    OperatorRelevance {
1856        axis: usize,
1857    },
1858    TensorMarginal {
1859        dim: usize,
1860    },
1861    TensorSeparable {
1862        penalized_margins: Vec<usize>,
1863    },
1864    TensorGlobalRidge,
1865    Other(String),
1866}
1867
1868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1869pub enum PenaltyDropReason {
1870    ZeroMatrix,
1871    NumericalRankZero,
1872}
1873
1874fn default_normalization_scale() -> f64 {
1875    1.0
1876}
1877
1878/// Metadata for one retained penalty coordinate.
1879///
1880/// This type is active-only by construction. In particular it has no
1881/// `active` flag or optional drop reason: those fields allowed a metadata
1882/// position to exist without a corresponding matrix and made positional
1883/// indexing silently wrong after an earlier candidate was dropped.
1884#[derive(Debug, Clone, Serialize, Deserialize)]
1885pub struct ActivePenaltyInfo {
1886    pub source: PenaltySource,
1887    pub original_index: usize,
1888    pub effective_rank: usize,
1889    #[serde(default = "default_normalization_scale")]
1890    pub normalization_scale: f64,
1891    /// Kronecker factors preserved from tensor penalty construction.
1892    /// When present, spectral decomposition can use per-factor eigendecomposition.
1893    #[serde(skip)]
1894    pub kronecker_factors: Option<Vec<Array2<f64>>>,
1895    /// Structural null frame carried from the candidate's
1896    /// [`ConstructiveQuadratic`] (see
1897    /// [`ConstructiveQuadratic::with_structural_null_frame`]): the declared
1898    /// null space of the seminorm this penalty represents, in the penalty's
1899    /// own coefficient chart. Downstream rebuilds
1900    /// (`rebuild_metric_consistent_ridge` at the term-collection chokepoint)
1901    /// re-attach it so the double-penalty topology stays a carried theorem
1902    /// through every chart instead of a per-chart rank measurement (#2445).
1903    /// Runtime-only, like `kronecker_factors`: a frozen replay rebuilds it
1904    /// from the basis factory, which is the single source.
1905    #[serde(skip)]
1906    pub structural_null_frame: Option<Array2<f64>>,
1907}
1908
1909/// Diagnostic for one penalty candidate excluded from the optimizer layout.
1910/// It is intentionally a different type from [`ActivePenaltyInfo`] so a
1911/// dropped record cannot be used as an active matrix index.
1912#[derive(Debug, Clone, Serialize, Deserialize)]
1913pub struct DroppedPenaltyInfo {
1914    pub source: PenaltySource,
1915    pub original_index: usize,
1916    pub reason: PenaltyDropReason,
1917    #[serde(default = "default_normalization_scale")]
1918    pub normalization_scale: f64,
1919}
1920
1921/// One atomic active penalty identity.
1922///
1923/// Every field describes the same retained candidate. Consumers may reorder,
1924/// transform, or remove a penalty only by moving the whole record, which makes
1925/// matrix/role/nullity/operator skew unrepresentable.
1926#[derive(Clone)]
1927pub struct ActivePenalty {
1928    pub matrix: Array2<f64>,
1929    pub nullity: usize,
1930    pub null_eigenvectors: Option<Array2<f64>>,
1931    pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
1932    pub info: ActivePenaltyInfo,
1933}
1934
1935impl std::fmt::Debug for ActivePenalty {
1936    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1937        f.debug_struct("ActivePenalty")
1938            .field(
1939                "matrix",
1940                &format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
1941            )
1942            .field("nullity", &self.nullity)
1943            .field(
1944                "null_eigenvectors",
1945                &self
1946                    .null_eigenvectors
1947                    .as_ref()
1948                    .map(|basis| format!("{}×{}", basis.nrows(), basis.ncols())),
1949            )
1950            .field("op_dim", &self.op.as_ref().map(|op| op.dim()))
1951            .field("info", &self.info)
1952            .finish()
1953    }
1954}
1955
1956#[derive(Debug, Clone)]
1957pub struct FilteredPenalties {
1958    pub active: Vec<ActivePenalty>,
1959    pub dropped: Vec<DroppedPenaltyInfo>,
1960}
1961
1962/// A positive-semidefinite quadratic with a construction witness.
1963///
1964/// `factor` is the authoritative representation: for coefficients `β`, the
1965/// penalty is `‖factor · β‖²`, hence its dense matrix is
1966/// `factorᵀ factor` by construction.  The cached dense matrix exists only for
1967/// consumers that require it; rank/null-space logic must use the factor and
1968/// must never attempt to recover PSD provenance from signed eigenvalues of a
1969/// rounded dense congruence (#2318).
1970#[derive(Clone)]
1971pub struct ConstructiveQuadratic {
1972    factor: Array2<f64>,
1973    matrix: Array2<f64>,
1974    /// Orthonormal basis, in this quadratic's own coefficient chart, for the
1975    /// null space of the seminorm this matrix REPRESENTS — as opposed to the
1976    /// numerical null space of the matrix itself, which can differ by a
1977    /// deliberate conditioning term (#2445: the Duchon affine native ridge is
1978    /// `√ε`-relative and sits within a decade of the spectral rank cutoff, so
1979    /// a rank test on the shipped matrix decides penalty TOPOLOGY by the
1980    /// Gram's conditioning). `None` means "no structural declaration; a
1981    /// consumer that needs the null space must measure it". `Some` with zero
1982    /// columns is a declaration that the seminorm is structurally full rank.
1983    structural_null_frame: Option<Array2<f64>>,
1984}
1985
1986impl ConstructiveQuadratic {
1987    /// Construct directly from an energy factor `A`, representing `AᵀA`.
1988    pub fn from_energy_factor(factor: Array2<f64>, context: &str) -> Result<Self, BasisError> {
1989        if factor.iter().any(|value| !value.is_finite()) {
1990            crate::bail_invalid_basis!(
1991                "{context}: constructive penalty factor contains a non-finite value"
1992            );
1993        }
1994        let matrix = fast_ata(&factor);
1995        if matrix.iter().any(|value| !value.is_finite()) {
1996            crate::bail_invalid_basis!("{context}: constructive penalty Gram is not representable");
1997        }
1998        Ok(Self {
1999            factor,
2000            matrix,
2001            structural_null_frame: None,
2002        })
2003    }
2004
2005    /// Declare the structural null frame of the represented seminorm (see the
2006    /// field doc). The frame is a carried certificate (#2427): the factory
2007    /// that BUILT the seminorm knows its null space as a theorem (Duchon's
2008    /// polynomial block), and consumers that decide topology
2009    /// (`crate::basis::rebuild_metric_consistent_ridge`) consume the
2010    /// declaration instead of re-deriving it from a rank test on a matrix
2011    /// that deliberately contains a conditioning term.
2012    pub fn with_structural_null_frame(
2013        mut self,
2014        frame: Array2<f64>,
2015        context: &str,
2016    ) -> Result<Self, BasisError> {
2017        if frame.nrows() != self.matrix.nrows() {
2018            crate::bail_dim_basis!(
2019                "{context}: structural null frame has {} rows but the quadratic chart has {}",
2020                frame.nrows(),
2021                self.matrix.nrows()
2022            );
2023        }
2024        if frame.iter().any(|value| !value.is_finite()) {
2025            crate::bail_invalid_basis!("{context}: structural null frame is not finite");
2026        }
2027        // Orthonormality is what makes the congruence transport below exact.
2028        let gram = fast_ata(&frame);
2029        for row in 0..gram.nrows() {
2030            for col in 0..gram.ncols() {
2031                let expected = if row == col { 1.0 } else { 0.0 };
2032                if (gram[[row, col]] - expected).abs() > 1e-8 {
2033                    crate::bail_invalid_basis!(
2034                        "{context}: structural null frame is not orthonormal \
2035                         (FᵀF deviates by {:.3e} at [{row},{col}])",
2036                        (gram[[row, col]] - expected).abs()
2037                    );
2038                }
2039            }
2040        }
2041        self.structural_null_frame = Some(frame);
2042        Ok(self)
2043    }
2044
2045    /// The declared structural null frame, if any (see
2046    /// [`Self::with_structural_null_frame`]).
2047    pub fn structural_null_frame(&self) -> Option<&Array2<f64>> {
2048        self.structural_null_frame.as_ref()
2049    }
2050
2051    /// The declared frame restricted to the coefficient block `[lo, hi)`,
2052    /// or `None` when no frame is declared or the frame has support outside
2053    /// the block (in which case the block does not own the null space and a
2054    /// consumer must fall back to measuring).
2055    pub fn structural_null_frame_block(&self, lo: usize, hi: usize) -> Option<Array2<f64>> {
2056        let frame = self.structural_null_frame.as_ref()?;
2057        if lo >= hi || hi > frame.nrows() {
2058            return None;
2059        }
2060        let outside = frame
2061            .rows()
2062            .into_iter()
2063            .enumerate()
2064            .filter(|(row, _)| *row < lo || *row >= hi)
2065            .flat_map(|(_, row)| row.to_vec())
2066            .fold(0.0_f64, |acc, value| acc.max(value.abs()));
2067        if outside > 1e-12 {
2068            return None;
2069        }
2070        Some(frame.slice(s![lo..hi, ..]).to_owned())
2071    }
2072
2073    /// Checked bridge for legacy dense factories that already produce a PSD
2074    /// function quadratic but do not yet expose their native energy factor.
2075    ///
2076    /// This is deliberately fallible and reconstructs a factor from the
2077    /// canonical range spectrum. Material negative curvature is rejected; a
2078    /// caller can no longer place an unchecked `Array2` in a
2079    /// [`PenaltyCandidate`]. New factories should use
2080    /// [`Self::from_energy_factor`] so PSD is true by construction rather than
2081    /// inferred after dense assembly.
2082    pub fn try_from_dense_psd(dense: Array2<f64>, context: &str) -> Result<Self, BasisError> {
2083        if dense.nrows() != dense.ncols() {
2084            crate::bail_dim_basis!(
2085                "{context}: dense penalty must be square, got {}x{}",
2086                dense.nrows(),
2087                dense.ncols()
2088            );
2089        }
2090        if dense.iter().any(|value| !value.is_finite()) {
2091            crate::bail_invalid_basis!("{context}: dense penalty contains a non-finite value");
2092        }
2093        if dense.nrows() == 0 {
2094            return Self::from_energy_factor(Array2::zeros((0, 0)), context);
2095        }
2096        let sym = symmetrize_penalty(&dense);
2097        let (evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
2098        let tolerance = spectral_tolerance(&evals);
2099        if let Some(&negative) = evals.iter().find(|&&value| value < -tolerance) {
2100            return Err(BasisError::IndefinitePenalty {
2101                context: context.to_string(),
2102                min_eigenvalue: negative,
2103                tolerance,
2104                guidance: "supply the native energy factor for a PSD function penalty; negative curvature is not a penalty null direction".to_string(),
2105            });
2106        }
2107        let positive: Vec<usize> = evals
2108            .iter()
2109            .enumerate()
2110            .filter_map(|(index, &value)| (value > tolerance).then_some(index))
2111            .collect();
2112        let mut factor = Array2::<f64>::zeros((positive.len(), dense.nrows()));
2113        for (row, index) in positive.into_iter().enumerate() {
2114            let scale = evals[index].sqrt();
2115            for column in 0..dense.nrows() {
2116                factor[[row, column]] = scale * evecs[[column, index]];
2117            }
2118        }
2119        Self::from_energy_factor(factor, context)
2120    }
2121
2122    /// The authoritative rectangular energy factor.
2123    pub fn factor(&self) -> &Array2<f64> {
2124        &self.factor
2125    }
2126
2127    /// Dense materialization `AᵀA` for consumers that require a matrix.
2128    pub fn dense(&self) -> &Array2<f64> {
2129        &self.matrix
2130    }
2131
2132    /// Apply a coefficient gauge to the factor, preserving PSD by
2133    /// construction instead of multiplying the rounded dense Gram twice.
2134    ///
2135    /// A declared structural null frame is transported through the same
2136    /// congruence: for the (single-block) transform `T`, the structural null
2137    /// space of `TᵀST` is `{γ : Tγ ∈ span(F)} = null((I − FFᵀ)T)`, which is a
2138    /// well-conditioned computation on orthonormal inputs — the rank decision
2139    /// has O(1) principal-angle gaps, never the conditioning of `S` (#2445).
2140    pub fn restricted(
2141        &self,
2142        gauge: &gam_problem::Gauge,
2143        context: &str,
2144    ) -> Result<Self, BasisError> {
2145        let mut out =
2146            Self::from_energy_factor(gauge.restrict_quadratic_factor(&self.factor), context)?;
2147        if let Some(frame) = self.structural_null_frame.as_ref() {
2148            if gauge.n_blocks() == 1 {
2149                let transform = gauge.block_transform(0);
2150                out.structural_null_frame = transport_structural_null_frame(frame, &transform);
2151            }
2152            // Multi-block gauges do not arise on the paths that declare
2153            // frames; dropping the declaration is always safe (consumers
2154            // fall back to measuring), inventing one is not.
2155        }
2156        Ok(out)
2157    }
2158
2159    /// Multiply the represented quadratic by a finite non-negative scalar.
2160    pub fn scaled(&self, scale: f64, context: &str) -> Result<Self, BasisError> {
2161        if !scale.is_finite() || scale < 0.0 {
2162            crate::bail_invalid_basis!(
2163                "{context}: constructive penalty scale must be finite and non-negative, got {scale}"
2164            );
2165        }
2166        let root = scale.sqrt();
2167        let mut out = Self::from_energy_factor(self.factor.mapv(|value| value * root), context)?;
2168        // A positive rescale does not move the null space; scaling to exactly
2169        // zero collapses the seminorm and voids the declaration.
2170        if scale > 0.0 {
2171            out.structural_null_frame = self.structural_null_frame.clone();
2172        }
2173        Ok(out)
2174    }
2175
2176    /// Sum PSD quadratics by vertically concatenating their energy factors.
2177    pub fn sum(terms: &[Self], context: &str) -> Result<Self, BasisError> {
2178        let coefficient_dim = terms.first().map(|term| term.factor.ncols()).unwrap_or(0);
2179        if terms
2180            .iter()
2181            .any(|term| term.factor.ncols() != coefficient_dim)
2182        {
2183            crate::bail_dim_basis!(
2184                "{context}: constructive penalty sum has inconsistent coefficient dimensions"
2185            );
2186        }
2187        let rows = terms.iter().map(|term| term.factor.nrows()).sum();
2188        let mut factor = Array2::<f64>::zeros((rows, coefficient_dim));
2189        let mut start = 0usize;
2190        for term in terms {
2191            let end = start + term.factor.nrows();
2192            factor.slice_mut(s![start..end, ..]).assign(&term.factor);
2193            start = end;
2194        }
2195        Self::from_energy_factor(factor, context)
2196    }
2197
2198    /// The exact zero quadratic on a coefficient chart of `dimension`.
2199    pub fn zero(dimension: usize) -> Self {
2200        Self {
2201            factor: Array2::zeros((0, dimension)),
2202            matrix: Array2::zeros((dimension, dimension)),
2203            structural_null_frame: None,
2204        }
2205    }
2206}
2207
2208/// Transport a structural null frame through an injective coefficient
2209/// transform `T` (raw → reduced): the structural null space of `TᵀST` is
2210/// `{γ : Tγ ∈ span(F)} = null((I − FFᵀ)T)`, computed with a rank-revealing QR
2211/// on orthonormal inputs. Returns `Some` with possibly zero columns (a
2212/// structural "no null space survives the chart" is a valid declaration);
2213/// `None` only when the shapes cannot compose or the factorization fails, in
2214/// which case the declaration is dropped rather than guessed.
2215fn transport_structural_null_frame(
2216    frame: &Array2<f64>,
2217    transform: &Array2<f64>,
2218) -> Option<Array2<f64>> {
2219    if transform.nrows() != frame.nrows() {
2220        return None;
2221    }
2222    let projected = transform - &frame.dot(&frame.t().dot(transform));
2223    // `rrqr_nullspace_basis(a)` returns an orthonormal basis of `null(aᵀ)`,
2224    // so pass `projectedᵀ` to obtain `null(projected)` over the reduced
2225    // coordinates. Machine-precision cutoff: the singular values here are
2226    // sines of principal angles between orthonormal frames, so the rank gap
2227    // is O(1) unless the chart genuinely grazes the subspace.
2228    gam_linalg::faer_ndarray::rrqr_nullspace_basis(&projected.t().to_owned(), 1.0)
2229        .ok()
2230        .map(|(null, _)| null)
2231}
2232
2233impl std::fmt::Debug for ConstructiveQuadratic {
2234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2235        f.debug_struct("ConstructiveQuadratic")
2236            .field(
2237                "factor",
2238                &format_args!("{}×{}", self.factor.nrows(), self.factor.ncols()),
2239            )
2240            .field(
2241                "matrix",
2242                &format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
2243            )
2244            .field(
2245                "structural_null_frame",
2246                &self
2247                    .structural_null_frame
2248                    .as_ref()
2249                    .map(|frame| format!("{}×{}", frame.nrows(), frame.ncols())),
2250            )
2251            .finish()
2252    }
2253}
2254
2255impl std::ops::Deref for ConstructiveQuadratic {
2256    type Target = Array2<f64>;
2257
2258    fn deref(&self) -> &Self::Target {
2259        &self.matrix
2260    }
2261}
2262
2263#[derive(Clone)]
2264pub struct PenaltyCandidate {
2265    /// Constructive PSD quadratic. Raw dense matrices cannot inhabit a
2266    /// candidate without passing through a checked constructor.
2267    pub matrix: ConstructiveQuadratic,
2268    pub source: PenaltySource,
2269    pub normalization_scale: f64,
2270    /// Optional Kronecker factors whose product equals `matrix`.
2271    /// When present, spectral decomposition can be done per-factor
2272    /// (O(Σ q_j³) instead of O((Π q_j)³)).
2273    pub kronecker_factors: Option<Vec<Array2<f64>>>,
2274    /// Optional operator-form handle whose `as_dense()` matches `matrix`. When
2275    /// populated by the closed-form factories, this is propagated through to
2276    /// `CanonicalPenaltyBlock` so downstream consumers can use exact matvec
2277    /// algebra without rebuilding the dense Gram. When `None`, only the dense
2278    /// `matrix` path is available.
2279    pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
2280}
2281
2282impl std::fmt::Debug for PenaltyCandidate {
2283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2284        f.debug_struct("PenaltyCandidate")
2285            .field(
2286                "matrix",
2287                &format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
2288            )
2289            .field("source", &self.source)
2290            .field("normalization_scale", &self.normalization_scale)
2291            .field(
2292                "kronecker_factors",
2293                &self.kronecker_factors.as_ref().map(|v| v.len()),
2294            )
2295            .field("op", &self.op.as_ref().map(|o| o.dim()))
2296            .finish()
2297    }
2298}
2299
2300#[derive(Clone)]
2301pub struct CanonicalPenaltyBlock {
2302    pub sym_penalty: Array2<f64>,
2303    /// Eigenvalues from spectral decomposition (retained to avoid recomputation).
2304    pub eigenvalues: Array1<f64>,
2305    /// Eigenvectors from spectral decomposition (retained to avoid recomputation).
2306    pub eigenvectors: Array2<f64>,
2307    pub rank: usize,
2308    pub nullity: usize,
2309    /// Number of genuine negative-curvature eigendirections (`ev < -tol`).
2310    /// A non-PSD penalty has `negative_dim > 0`; these directions are
2311    /// neither range nor null and are never absorbed as unpenalized (#1425).
2312    pub negative_dim: usize,
2313    pub rank_tol: f64,
2314    pub noise_tol: f64,
2315    pub iszero: bool,
2316    /// Optional operator-form handle that is bit-equivalent to `sym_penalty`.
2317    /// Propagated from `PenaltyCandidate.op` when present so downstream
2318    /// consumers can use matvec without rebuilding the dense Gram.
2319    pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
2320}
2321
2322impl std::fmt::Debug for CanonicalPenaltyBlock {
2323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2324        f.debug_struct("CanonicalPenaltyBlock")
2325            .field(
2326                "sym_penalty",
2327                &format_args!("{}×{}", self.sym_penalty.nrows(), self.sym_penalty.ncols()),
2328            )
2329            .field("eigenvalues", &self.eigenvalues)
2330            .field(
2331                "eigenvectors",
2332                &format_args!(
2333                    "{}×{}",
2334                    self.eigenvectors.nrows(),
2335                    self.eigenvectors.ncols()
2336                ),
2337            )
2338            .field("rank", &self.rank)
2339            .field("nullity", &self.nullity)
2340            .field("negative_dim", &self.negative_dim)
2341            .field("rank_tol", &self.rank_tol)
2342            .field("noise_tol", &self.noise_tol)
2343            .field("iszero", &self.iszero)
2344            .field("op", &self.op.as_ref().map(|o| o.dim()))
2345            .finish()
2346    }
2347}
2348
2349#[derive(Debug)]
2350pub struct BasisPsiDerivativeResult {
2351    pub design_derivative: Array2<f64>,
2352    pub penalties_derivative: Vec<Array2<f64>>,
2353    /// Operator-backed design derivative for standalone first-derivative
2354    /// callers. Bundled first+second callers receive the shared operator on
2355    /// `BasisPsiDerivativeBundle` instead.
2356    pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
2357}
2358
2359#[derive(Debug)]
2360pub struct BasisPsiSecondDerivativeResult {
2361    pub designsecond_derivative: Array2<f64>,
2362    pub penaltiessecond_derivative: Vec<Array2<f64>>,
2363    /// Operator-backed design derivative for standalone second-derivative
2364    /// callers. Bundled first+second callers receive the shared operator on
2365    /// `BasisPsiDerivativeBundle` instead.
2366    pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
2367}
2368
2369#[derive(Debug)]
2370pub struct BasisPsiDerivativeBundle {
2371    pub first: BasisPsiDerivativeResult,
2372    pub second: BasisPsiSecondDerivativeResult,
2373    /// Shared operator-backed design derivative for the first and second
2374    /// psi derivatives. Bundled callers consume this once instead of storing
2375    /// duplicate materialized/streaming operators in both derivative payloads.
2376    pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
2377}
2378
2379/// Per-axis psi_a derivative package for anisotropic spatial terms.
2380///
2381/// For a d-dimensional anisotropic term, the kernel phi(r) depends on
2382/// the anisotropic distance r = |Lambda h| where Lambda = diag(kappa_a). Each axis a
2383/// has its own log-scale psi_a = log(kappa_a), yielding d first derivatives,
2384/// d diagonal second derivatives, and d*(d-1)/2 cross second derivatives.
2385///
2386/// The cross second derivative d2 phi/(d psi_a d psi_b) = t * s_a * s_b (a != b)
2387/// is rank-1, so we store the t_values and s_components vectors rather
2388/// than materializing d^2 matrices.
2389#[derive(Clone)]
2390pub struct AnisoBasisPsiDerivatives {
2391    /// d matrices, each (n x p_smooth): dX/d psi_a.
2392    pub design_first: Vec<Array2<f64>>,
2393    /// d matrices, each (n x p_smooth): d2X/d psi_a^2 (diagonal second derivatives).
2394    pub design_second_diag: Vec<Array2<f64>>,
2395    /// Cross second derivatives d2X/(d psi_a d psi_b) for a < b.
2396    pub design_second_cross: Vec<Array2<f64>>,
2397    /// Axis-pair indices corresponding to `design_second_cross`.
2398    pub design_second_cross_pairs: Vec<(usize, usize)>,
2399    /// d x num_penalties: dS_m/d psi_a for each axis a and penalty m.
2400    pub penalties_first: Vec<Vec<Array2<f64>>>,
2401    /// d x num_penalties: d2S_m/d psi_a^2 for each axis a and penalty m.
2402    pub penalties_second_diag: Vec<Vec<Array2<f64>>>,
2403    /// The (a, b) axis pairs supported by the on-demand cross-penalty
2404    /// provider. Only the upper triangle (a < b) is stored.
2405    pub penalties_cross_pairs: Vec<(usize, usize)>,
2406    /// On-demand cross-penalty second-derivative provider. Exact anisotropic
2407    /// cross-axis penalty seconds are streamed one pair at a time rather than
2408    /// stored as a dense upper triangle of blocks.
2409    pub penalties_cross_provider: Option<AnisoPenaltyCrossProvider>,
2410    /// Shared operator-backed representation of the anisotropic kernel-side
2411    /// design derivatives. When `design_first` / `design_second_diag` are empty,
2412    /// callers must use this operator directly; when they are present, this
2413    /// operator still provides exact cross-axis second derivatives without
2414    /// duplicating separate `t` / `s_a` storage layouts.
2415    pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
2416}
2417
2418#[derive(Clone)]
2419pub struct AnisoPenaltyCrossProvider(
2420    std::sync::Arc<
2421        dyn Fn(usize, usize) -> Result<Vec<Array2<f64>>, BasisError> + Send + Sync + 'static,
2422    >,
2423);
2424
2425impl AnisoPenaltyCrossProvider {
2426    pub(crate) fn new<F>(f: F) -> Self
2427    where
2428        F: Fn(usize, usize) -> Result<Vec<Array2<f64>>, BasisError> + Send + Sync + 'static,
2429    {
2430        Self(std::sync::Arc::new(f))
2431    }
2432
2433    pub fn evaluate(&self, axis_a: usize, axis_b: usize) -> Result<Vec<Array2<f64>>, BasisError> {
2434        (self.0)(axis_a, axis_b)
2435    }
2436}
2437
2438/// `Qᵀ S Q` for one penalty-derivative block, or `None` when `S` is not square
2439/// in `Q`'s dimension.
2440fn rotate_psi_penalty_block(s_local: &Array2<f64>, q: &Array2<f64>) -> Option<Array2<f64>> {
2441    if s_local.nrows() != q.nrows() || s_local.ncols() != q.nrows() {
2442        return None;
2443    }
2444    Some(fast_ab(&fast_atb(q, s_local), q))
2445}
2446
2447impl AnisoBasisPsiDerivatives {
2448    /// Carry a per-axis ψ-derivative bundle through the stage-2 joint-null
2449    /// absorption rotation `Q` the realized design was built in (gam#2760).
2450    ///
2451    /// `Q` acts on the smooth's COEFFICIENTS: the design becomes `X·Q` and each
2452    /// penalty block `S_k` becomes `Qᵀ S_k Q`, so every ψ-derivative of either
2453    /// object transforms the same way — differentiation and the (ψ-independent)
2454    /// rotation commute. The isotropic arm of the spatial ψ route has always
2455    /// done this; a per-axis bundle handed to a rotated design without it is an
2456    /// unrotated derivative of a rotated model, which is why the enrollment
2457    /// predicate used to exclude rotated terms from per-axis ψ altogether.
2458    ///
2459    /// Returns `Ok(None)` — an honest decline, matching the isotropic arm —
2460    /// when a block's shape does not admit `Q`, so the caller can fall back
2461    /// rather than assert on a mismatch it cannot repair.
2462    ///
2463    /// MUST be applied before any fixed row-space projector: `Q` acts on
2464    /// coefficients and the projector on rows, and the implicit operator
2465    /// refuses a coefficient transform once its projector is installed.
2466    pub fn rotated_by_joint_null(
2467        mut self,
2468        rotation: &JointNullRotation,
2469    ) -> Result<Option<Self>, BasisError> {
2470        let q = &rotation.rotation;
2471        if q.nrows() != q.ncols() {
2472            return Ok(None);
2473        }
2474        for matrix in self
2475            .design_first
2476            .iter_mut()
2477            .chain(self.design_second_diag.iter_mut())
2478            .chain(self.design_second_cross.iter_mut())
2479        {
2480            if matrix.ncols() != q.nrows() {
2481                return Ok(None);
2482            }
2483            *matrix = fast_ab(&*matrix, q);
2484        }
2485        for blocks in self
2486            .penalties_first
2487            .iter_mut()
2488            .chain(self.penalties_second_diag.iter_mut())
2489        {
2490            for block in blocks.iter_mut() {
2491                let Some(rotated) = rotate_psi_penalty_block(block, q) else {
2492                    return Ok(None);
2493                };
2494                *block = rotated;
2495            }
2496        }
2497        if let Some(provider) = self.penalties_cross_provider.take() {
2498            let q_owned = q.clone();
2499            self.penalties_cross_provider = Some(AnisoPenaltyCrossProvider::new(
2500                move |axis_a, axis_b| {
2501                    provider
2502                        .evaluate(axis_a, axis_b)?
2503                        .into_iter()
2504                        .map(|block| {
2505                            rotate_psi_penalty_block(&block, &q_owned).ok_or_else(|| {
2506                                BasisError::InvalidInput(format!(
2507                                    "anisotropic cross-penalty block for axes ({axis_a}, {axis_b}) \
2508                                     is {}x{}, which the joint-null rotation's {} coefficients \
2509                                     cannot rotate",
2510                                    block.nrows(),
2511                                    block.ncols(),
2512                                    q_owned.nrows()
2513                                ))
2514                            })
2515                        })
2516                        .collect()
2517                },
2518            ));
2519        }
2520        if let Some(operator) = self.implicit_operator.take() {
2521            if operator.p_out() != q.nrows() {
2522                return Ok(None);
2523            }
2524            self.implicit_operator = Some(operator.append_full_transform(q)?);
2525        }
2526        Ok(Some(self))
2527    }
2528}
2529
2530// ═══════════════════════════════════════════════════════════════════════════
2531//  Implicit derivative operator for scalable anisotropic REML gradients
2532// ═══════════════════════════════════════════════════════════════════════════
2533
2534pub(crate) const SPATIAL_CENTER_CENTER_MAX_BYTES: usize = 512 * 1024 * 1024; // 512 MiB
2535pub(crate) const DESIGN_CROSS_CHUNK_SIZE: usize = 1024;
2536
2537/// Determine whether implicit operators should be used based on problem size
2538/// and the supplied `ResourcePolicy`.
2539///
2540/// Returns `true` when the dense materialization of D first-derivative
2541/// matrices would exceed `policy.max_single_materialization_bytes`.
2542///
2543/// For D axes with n data points and p_smooth basis columns, the dense path
2544/// allocates D * n * p_smooth * 8 bytes for first-derivative matrices alone
2545/// (plus a similar amount for second derivatives). The implicit path stores
2546/// only the compact (n * n_knots) radial jets plus (n * n_knots * D) axis
2547/// fractions, which is O(n * k * D) instead of O(n * p * D).
2548pub fn should_use_implicit_operators_with_policy(
2549    n: usize,
2550    p: usize,
2551    d: usize,
2552    policy: &gam_runtime::resource::ResourcePolicy,
2553) -> bool {
2554    // Each first-derivative matrix is (n x p) f64 → n*p*8 bytes.
2555    // We need D of them for first derivatives, D for second diag, plus
2556    // the cross-t matrix and s_components. Conservative estimate: 3*D matrices.
2557    let dense_bytes = 3usize
2558        .saturating_mul(n)
2559        .saturating_mul(p)
2560        .saturating_mul(d)
2561        .saturating_mul(8);
2562    dense_bytes > policy.max_single_materialization_bytes
2563}
2564
2565pub(crate) fn implicit_radial_cache_bytes(n: usize, k: usize, n_axes: usize) -> usize {
2566    n.saturating_mul(k)
2567        .saturating_mul(n_axes.saturating_add(3))
2568        .saturating_mul(8)
2569}
2570
2571pub(crate) fn should_cache_implicit_radial_components(
2572    n: usize,
2573    k: usize,
2574    n_axes: usize,
2575    policy: &gam_runtime::resource::ResourcePolicy,
2576) -> bool {
2577    implicit_radial_cache_bytes(n, k, n_axes) <= policy.max_operator_cache_bytes
2578}
2579
2580pub fn assert_no_dense_derivative_materialization(n: usize, p: usize, d_pc: usize) {
2581    let first = dense_design_bytes(n, p).saturating_mul(d_pc);
2582    let second = dense_design_bytes(n, p).saturating_mul(d_pc.saturating_mul(d_pc));
2583    // Consult the library default ResourcePolicy. Production large-scale runs
2584    // configure `AnalyticOperatorRequired`, which still refuses every dense
2585    // materialization here. The default `MaterializeIfSmall` mode lets tiny
2586    // problems (and small-data/test usage) materialize as long as the combined
2587    // first- and second-order dense bytes fit under the single-materialization
2588    // byte budget. `DiagnosticsOnly` is treated like `MaterializeIfSmall` for
2589    // this guard: it permits dense materialization under the same byte cap.
2590    let policy = gam_runtime::resource::ResourcePolicy::default_library();
2591    let budget = policy.max_single_materialization_bytes;
2592    let needed = first.saturating_add(second);
2593    match policy.derivative_storage_mode {
2594        gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired => {
2595            // SAFETY: this assertion helper exists specifically to enforce
2596            // the large-scale invariant that spatial-PC Duchon derivative
2597            // designs never persist as dense `Array2<f64>` storage. When the
2598            // resource policy is `AnalyticOperatorRequired`, any caller that
2599            // reached this point has materialized something the strict
2600            // operator contract forbids.
2601            // SAFETY: AnalyticOperatorRequired forbids dense derivative materialization.
2602            panic!(
2603                "spatial PC Duchon derivative designs must remain operator-backed; refused persistent dense derivative materialization (n={n}, p={p}, d_pc={d_pc}, first_order={:.1} MiB, second_order={:.1} MiB)",
2604                first as f64 / (1024.0 * 1024.0),
2605                second as f64 / (1024.0 * 1024.0),
2606            );
2607        }
2608        gam_runtime::resource::DerivativeStorageMode::MaterializeIfSmall
2609        | gam_runtime::resource::DerivativeStorageMode::DiagnosticsOnly => {
2610            // SAFETY: exceeding the single-materialization budget here is a
2611            // contract violation by an upstream caller that must route through
2612            // the operator-backed path; failing loudly surfaces it rather than
2613            // silently materializing an oversized dense derivative design.
2614            assert!(
2615                needed <= budget,
2616                "spatial PC Duchon derivative designs would exceed the single-materialization budget; refused persistent dense derivative materialization (n={n}, p={p}, d_pc={d_pc}, first_order={:.1} MiB, second_order={:.1} MiB, budget={:.1} MiB)",
2617                first as f64 / (1024.0 * 1024.0),
2618                second as f64 / (1024.0 * 1024.0),
2619                budget as f64 / (1024.0 * 1024.0),
2620            );
2621        }
2622    }
2623}
2624
2625pub fn assert_spatial_centers_below_large_scale_cap(
2626    d_pc: usize,
2627    centers: ArrayView2<'_, f64>,
2628) -> Result<(), BasisError> {
2629    if centers.ncols() != d_pc {
2630        crate::bail_dim_basis!(
2631            "spatial PC center dimension mismatch: centers have {} columns, expected {d_pc}",
2632            centers.ncols()
2633        );
2634    }
2635    let k = centers.nrows();
2636    let centers_bytes = dense_design_bytes(k, d_pc);
2637    let center_center_bytes = dense_design_bytes(k, k);
2638    if centers_bytes > SPATIAL_CENTER_CENTER_MAX_BYTES {
2639        crate::bail_invalid_basis!(
2640            "spatial PC centers exceed center storage cap: K={k}, d_pc={d_pc}, centers={:.1} MiB, cap={:.1} MiB",
2641            centers_bytes as f64 / (1024.0 * 1024.0),
2642            SPATIAL_CENTER_CENTER_MAX_BYTES as f64 / (1024.0 * 1024.0),
2643        );
2644    }
2645    if center_center_bytes > SPATIAL_CENTER_CENTER_MAX_BYTES {
2646        crate::bail_invalid_basis!(
2647            "spatial PC centers exceed center-center large-scale cap: K={k}, d_pc={d_pc}, KxK={:.1} MiB, cap={:.1} MiB",
2648            center_center_bytes as f64 / (1024.0 * 1024.0),
2649            SPATIAL_CENTER_CENTER_MAX_BYTES as f64 / (1024.0 * 1024.0),
2650        );
2651    }
2652    Ok(())
2653}
2654
2655pub(crate) fn dense_design_bytes(n: usize, p: usize) -> usize {
2656    n.saturating_mul(p)
2657        .saturating_mul(std::mem::size_of::<f64>())
2658}
2659
2660pub(crate) fn should_use_lazy_spatial_design(
2661    n: usize,
2662    p: usize,
2663    policy: &gam_runtime::resource::ResourcePolicy,
2664) -> bool {
2665    matches!(
2666        policy.derivative_storage_mode,
2667        gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired
2668    ) || dense_design_bytes(n, p) > policy.max_single_materialization_bytes
2669}
2670
2671pub(crate) fn wrap_dense_design_with_transform(
2672    design: DesignMatrix,
2673    transform: &Array2<f64>,
2674    label: &str,
2675) -> Result<DesignMatrix, BasisError> {
2676    match design {
2677        DesignMatrix::Dense(inner) => {
2678            let op = CoefficientTransformOperator::new(inner, transform.clone()).map_err(|e| {
2679                BasisError::InvalidInput(format!("{label} coefficient transform failed: {e}"))
2680            })?;
2681            Ok(DesignMatrix::Dense(
2682                gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(op)),
2683            ))
2684        }
2685        DesignMatrix::Sparse(_) => Err(BasisError::InvalidInput(format!(
2686            "{label} coefficient transform requires a dense/operator-backed design"
2687        ))),
2688    }
2689}
2690
2691/// Single-pass `(Bᵀ(W·C), BᵀB)` accumulation over the streamed design.
2692///
2693/// Materialises each row chunk of the design **once** and reuses it for both
2694/// the constraint cross `Bᵀ(W·C)` and the Gram `BᵀB`. On the lazy chunked
2695/// spatial path each `try_row_chunk` re-evaluates all kernel columns for the
2696/// chunk, so accumulating both products in a single sweep halves the per-build
2697/// kernel re-evaluation work (the dominant cost at large scale) versus two
2698/// independent streaming passes — without changing the result beyond
2699/// floating-point reassociation. The cross is masked off (`q == 0`) by the
2700/// caller, which never invokes this when there is no constraint block.
2701pub(crate) fn design_cross_and_gram(
2702    design: &DesignMatrix,
2703    constraint_matrix: ArrayView2<'_, f64>,
2704    weights: Option<ArrayView1<'_, f64>>,
2705) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
2706    let n = design.nrows();
2707    let k = design.ncols();
2708    if constraint_matrix.nrows() != n {
2709        return Err(BasisError::ConstraintMatrixRowMismatch {
2710            basisrows: n,
2711            constraintrows: constraint_matrix.nrows(),
2712        });
2713    }
2714    if let Some(w) = weights
2715        && w.len() != n
2716    {
2717        return Err(BasisError::WeightsDimensionMismatch {
2718            expected: n,
2719            found: w.len(),
2720        });
2721    }
2722    let q = constraint_matrix.ncols();
2723    let mut cross = Array2::<f64>::zeros((k, q));
2724    let mut gram = Array2::<f64>::zeros((k, k));
2725    for start in (0..n).step_by(DESIGN_CROSS_CHUNK_SIZE) {
2726        let end = (start + DESIGN_CROSS_CHUNK_SIZE).min(n);
2727        let basis_chunk = design
2728            .try_row_chunk(start..end)
2729            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
2730        let mut constraint_chunk = constraint_matrix.slice(s![start..end, ..]).to_owned();
2731        if let Some(w) = weights {
2732            for (mut row, &weight) in constraint_chunk
2733                .axis_iter_mut(Axis(0))
2734                .zip(w.slice(s![start..end]).iter())
2735            {
2736                row *= weight;
2737            }
2738        }
2739        cross += &fast_atb(&basis_chunk, &constraint_chunk);
2740        gram += &fast_atb(&basis_chunk, &basis_chunk);
2741    }
2742    Ok((cross, gram))
2743}
2744
2745pub(crate) fn positive_spectral_whitener_from_gram(
2746    gram: &Array2<f64>,
2747) -> Result<Array2<f64>, BasisError> {
2748    // Inverse-square-root for the positive part of `gram`. Eigenvalues at or
2749    // below the relative rank tolerance `α·ε·n·max_eval` are *dropped*: the
2750    // returned whitener has shape `(n × keep)` where `keep` counts strictly
2751    // positive eigendirections of `gram`.
2752    //
2753    // Dropping (rather than ridging) is what makes the result a true
2754    // square-root inverse on the column space of `gram`. This whitener is
2755    // used by `stabilized_orthogonality_transform_from_gram` to make a
2756    // pre-existing transform `K_raw` orthonormal under the W-inner product:
2757    // when some columns of `K_raw` map to zero (or near-zero) under `B`, the
2758    // constrained Gram `K_raw^T G K_raw` is rank-deficient. Ridging those
2759    // tail directions with `1/sqrt(ε)` produced spurious basis columns
2760    // whose coefficient norms blew up to `~1/sqrt(ε)` while their image in
2761    // `B` was floating-point zero, contaminating downstream linear algebra
2762    // (in particular it forced `smooth.rs` to widen the post-transform
2763    // orthogonality residual tolerance to absorb a `cond ≈ 1/sqrt(ε)`
2764    // rounding floor). Dropping these directions is the right behavior:
2765    // they contribute nothing to `B`'s column space, and removing them
2766    // tightens the orthogonality residual back down to the genuine
2767    // floating-point limit.
2768    let (eigenvalues, eigenvectors) = gram.eigh(Side::Lower).map_err(BasisError::LinalgError)?;
2769    let n = gram.nrows();
2770    let max_eval = eigenvalues.iter().copied().fold(0.0_f64, f64::max);
2771    // Scale-invariant rank tolerance: the cutoff must track the Gram's own
2772    // spectrum (`α·ε·n·max_eval`), not an absolute floor. An earlier `max_eval
2773    // .max(1.0)` clamped the reference scale to 1.0, which is only harmless when
2774    // `max_eval ≥ 1`; for a genuinely well-conditioned but small-magnitude Gram
2775    // (e.g. a Duchon hybrid whose evaluated kernel sits far below unit scale in
2776    // moderate-to-high d) it inflated the tolerance to an absolute `α·ε·n` floor
2777    // that swallows the entire — perfectly valid — spectrum, spuriously reporting
2778    // `keep == 0`. Using the true `max_eval` makes `keep` invariant to a uniform
2779    // rescaling of the Gram (which scales every eigenvalue and the cutoff
2780    // identically). The residual `.max(f64::EPSILON)` only guards the degenerate
2781    // all-zero Gram so that numerical-zero roundoff directions are still dropped.
2782    let tol =
2783        (default_rrqr_rank_alpha() * f64::EPSILON * (n.max(1) as f64) * max_eval).max(f64::EPSILON);
2784    let keep = eigenvalues.iter().filter(|&&ev| ev > tol).count();
2785    if keep == 0 {
2786        let min_ev = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
2787        return Err(BasisError::ConstraintNullspaceCollapsed {
2788            site: "positive_spectral_whitener_from_gram",
2789            cross_rank: 0,
2790            coeff_dim: gram.nrows(),
2791            cross_frobenius: gram.iter().map(|v| v * v).sum::<f64>().sqrt(),
2792            gram_spectrum: format!(
2793                "max eigenvalue {max_eval:.3e} (min {min_ev:.3e}, spectral tolerance {tol:.3e})"
2794            ),
2795        });
2796    }
2797    // `eigh` returns eigenvalues in ascending order, so the largest `keep`
2798    // eigenvalues live at the tail.
2799    let eig_start = eigenvalues.len() - keep;
2800    let kept_vectors = eigenvectors.slice(s![.., eig_start..]).to_owned();
2801    let mut inv_sqrt = Array2::<f64>::zeros((keep, keep));
2802    for (out_i, eig_i) in (eig_start..eigenvalues.len()).enumerate() {
2803        inv_sqrt[[out_i, out_i]] = 1.0 / eigenvalues[eig_i].sqrt();
2804    }
2805    Ok(fast_ab(&kept_vectors, &inv_sqrt))
2806}
2807
2808pub(crate) fn stabilized_orthogonality_transform_from_gram(
2809    gram: &Array2<f64>,
2810    transform: &Array2<f64>,
2811) -> Result<Array2<f64>, BasisError> {
2812    let constrained_gram = {
2813        let gt = fast_ab(gram, transform);
2814        fast_atb(transform, &gt)
2815    };
2816    let whitening = positive_spectral_whitener_from_gram(&constrained_gram)?;
2817    Ok(fast_ab(transform, &whitening))
2818}
2819
2820pub(crate) fn orthogonality_transform_from_cross_and_gram(
2821    constraint_cross: &Array2<f64>,
2822    gram: &Array2<f64>,
2823) -> Result<Array2<f64>, BasisError> {
2824    // Compute null(M^T) directly on M = B^T W C (k × q) via column-pivoted QR.
2825    // Working in the original k-dim coefficient space rather than first
2826    // whitening by B^T B avoids a fundamental failure mode: when B is heavily
2827    // collinear, `positive_spectral_whitener_from_gram` truncates the design
2828    // column-space to a `keep`-dim subspace, and if `keep <= q` the subsequent
2829    // nullspace search has no room — even though dim null(M^T) = k - rank(M)
2830    // ≥ k - q is always positive when k > q. The constraint nullspace is a
2831    // property of M alone; conditioning of the design only matters for the
2832    // downstream stabilization of B*K_raw.
2833    let k = constraint_cross.nrows();
2834    if k == 0 {
2835        return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
2836    }
2837    let (transform_raw, rank) = rrqr_nullspace_basis(constraint_cross, default_rrqr_rank_alpha())
2838        .map_err(BasisError::LinalgError)?;
2839    if rank >= k || transform_raw.ncols() == 0 {
2840        return Err(BasisError::ConstraintNullspaceCollapsed {
2841            site: "orthogonality_transform_from_cross_and_gram",
2842            cross_rank: rank,
2843            coeff_dim: k,
2844            cross_frobenius: constraint_cross.iter().map(|v| v * v).sum::<f64>().sqrt(),
2845            gram_spectrum: "not computed (structural cross-rank collapse: null(Mᵀ) is empty, \
2846                            so no constrained design exists to eigendecompose)"
2847                .to_string(),
2848        });
2849    }
2850
2851    // Make the constrained design B*K_raw orthonormal under the W-inner product.
2852    // If the constrained Gram K_raw^T G K_raw is rank-deficient (because some
2853    // directions in null(M^T) collapse under B), the spectral whitener drops
2854    // them — that is the right behavior: a degenerate column never contributes
2855    // to B's column space and shouldn't appear in the reparameterized basis.
2856    stabilized_orthogonality_transform_from_gram(gram, &transform_raw)
2857}
2858
2859/// The part of `constraint_matrix` that `design`'s realized column span actually
2860/// **CONTAINS**, as vectors in row space (an `n × r` block, `r ≤ q`).
2861///
2862/// # Overlap is not containment, and only containment licenses a deletion
2863///
2864/// [`orthogonality_transform_for_design`] removes `rank(BᵀWC)` coefficient
2865/// directions from the smooth — one for every parametric direction the design
2866/// has any measurable overlap with. That is the wrong predicate. A direction may
2867/// be deleted **without loss** only when it is contained in the design's span:
2868/// then the deleted function IS the parametric column, the parametric block
2869/// keeps it, and the model span is unchanged. When the design merely
2870/// *correlates* with the parametric column, the deleted direction is a genuine
2871/// function the model can no longer represent at all.
2872///
2873/// `smooth_requires_parametric_orthogonality` asserts containment for the whole
2874/// kernel/radial class — *"their realized column span contains the constant …
2875/// a structural rank-1 collision"* — and for the constant-curvature geodesic
2876/// kernel that is false. Measured on the `kappa_one_...` fixture (400 rows, 30
2877/// centers), the orthogonal projection of the planted truth onto the span — the
2878/// best R² any fit could reach at any smoothing parameter — falls from **0.9984
2879/// to 0.8957** when the constraint is applied, and the loss grows with the
2880/// kernel range (0.9440 → 0.8915 from `ℓ = 0.2` to `ℓ = 3`) because the columns
2881/// grow more collinear and the deleted mean-carrying direction carries more.
2882/// The shipped pipeline's ceiling matches the hand-applied constraint to six
2883/// decimals at every range, and the fitted R² sits AT that ceiling: the fit is
2884/// not failing, a model dimension is missing.
2885///
2886/// # The test
2887///
2888/// Principal angles between `span(B)` and `span(C)`: with `G = BᵀWB`,
2889/// `M = BᵀWC` and `N = CᵀWC`, the generalized problem `MᵀG⁻M v = cos²θ · N v`
2890/// gives `cos θ_i` per direction, and `θ = 0` is containment. The decision is
2891/// made on `sin²θ = 1 − cos²θ`, and its threshold is DERIVED from that
2892/// expression's own resolution rather than chosen: it is a difference of two
2893/// `O(1)` quantities accumulated over `k + q` terms, so anything below
2894/// `(k + q)·ε` is indistinguishable from zero and anything above it is real.
2895/// The two populations are nowhere near that boundary — a contained constant
2896/// sits at `κ·ε`, a merely-correlated one at `10⁻¹`–`10⁰` — so the floor has six
2897/// orders of margin on both sides and no fixture rides it.
2898///
2899/// When EVERY direction is contained the original `constraint_matrix` is
2900/// returned unchanged rather than a rotation of it, so a basis whose span really
2901/// does contain the constant keeps its transform bit-for-bit.
2902pub fn contained_constraint_directions(
2903    design: &DesignMatrix,
2904    constraint_matrix: ArrayView2<'_, f64>,
2905    weights: Option<ArrayView1<'_, f64>>,
2906) -> Result<Array2<f64>, BasisError> {
2907    let n = design.nrows();
2908    let k = design.ncols();
2909    let q = constraint_matrix.ncols();
2910    if q == 0 || k == 0 {
2911        return Ok(Array2::zeros((n, 0)));
2912    }
2913    let normalized = unit_normalize_constraint_columns(constraint_matrix, weights);
2914    let (cross, gram) = design_cross_and_gram(design, normalized.view(), weights)?;
2915    // `N = CᵀWC` on the unit-normalized block: unit diagonal, off-diagonal
2916    // cosines between constraint columns.
2917    let mut constraint_gram = Array2::<f64>::zeros((q, q));
2918    for i in 0..q {
2919        for j in i..q {
2920            let mut acc = 0.0_f64;
2921            for row in 0..n {
2922                let w = weights.map_or(1.0, |ws| ws[row]);
2923                acc += w * normalized[[row, i]] * normalized[[row, j]];
2924            }
2925            constraint_gram[[i, j]] = acc;
2926            constraint_gram[[j, i]] = acc;
2927        }
2928    }
2929    // `MᵀG⁻M`, with `G⁻` truncated at the design Gram's own spectral floor.
2930    let (design_evals, design_evecs) =
2931        FaerEigh::eigh(&gram, Side::Lower).map_err(BasisError::LinalgError)?;
2932    let design_top = design_evals.iter().cloned().fold(0.0_f64, f64::max);
2933    let mut whitened_cross = design_evecs.t().dot(&cross);
2934    for i in 0..k {
2935        let scale = if design_evals[i] > design_top * (k as f64) * f64::EPSILON {
2936            1.0 / design_evals[i].sqrt()
2937        } else {
2938            0.0
2939        };
2940        for j in 0..q {
2941            whitened_cross[[i, j]] *= scale;
2942        }
2943    }
2944    let cos2 = whitened_cross.t().dot(&whitened_cross);
2945    // Whiten the constraint side so the problem is an ordinary symmetric
2946    // eigenproblem; a constraint block with dependent columns simply loses those
2947    // directions, which cannot be contained in anything as separate directions.
2948    let (constraint_evals, constraint_evecs) =
2949        FaerEigh::eigh(&constraint_gram, Side::Lower).map_err(BasisError::LinalgError)?;
2950    let constraint_top = constraint_evals.iter().cloned().fold(0.0_f64, f64::max);
2951    let keep: Vec<usize> = (0..q)
2952        .filter(|&i| constraint_evals[i] > constraint_top * (q as f64) * f64::EPSILON)
2953        .collect();
2954    if keep.is_empty() {
2955        return Ok(Array2::zeros((n, 0)));
2956    }
2957    let mut inverse_root = Array2::<f64>::zeros((q, keep.len()));
2958    for (slot, &i) in keep.iter().enumerate() {
2959        let scale = 1.0 / constraint_evals[i].sqrt();
2960        for row in 0..q {
2961            inverse_root[[row, slot]] = constraint_evecs[[row, i]] * scale;
2962        }
2963    }
2964    let reduced = inverse_root.t().dot(&cos2).dot(&inverse_root);
2965    let (_, angle_evecs) =
2966        FaerEigh::eigh(&reduced, Side::Lower).map_err(BasisError::LinalgError)?;
2967    // The principal directions themselves, in row space. The DECISION is not
2968    // taken on the eigenvalues: `sin²θ = 1 − cos²θ` is a difference of two O(1)
2969    // quantities and cannot resolve a small angle at all. Forming the residual
2970    // `c − B(BᵀWB)⁻BᵀWc` explicitly costs one `n × k` pass per direction and is
2971    // accurate to `ε‖c‖` however small the angle is, which is what makes `√ε` a
2972    // usable bar rather than a hopeful one.
2973    let directions = normalized.dot(&inverse_root.dot(&angle_evecs));
2974    let mut direction_cross = Array2::<f64>::zeros((k, directions.ncols()));
2975    for start in (0..n).step_by(DESIGN_CROSS_CHUNK_SIZE) {
2976        let end = (start + DESIGN_CROSS_CHUNK_SIZE).min(n);
2977        let basis_chunk = design
2978            .try_row_chunk(start..end)
2979            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
2980        let mut direction_chunk = directions.slice(s![start..end, ..]).to_owned();
2981        if let Some(ws) = weights {
2982            for (mut row, &weight) in direction_chunk
2983                .axis_iter_mut(Axis(0))
2984                .zip(ws.slice(s![start..end]).iter())
2985            {
2986                row *= weight;
2987            }
2988        }
2989        direction_cross += &fast_atb(&basis_chunk, &direction_chunk);
2990    }
2991    let mut design_pinv_cross = design_evecs.t().dot(&direction_cross);
2992    for i in 0..k {
2993        let scale = if design_evals[i] > design_top * (k as f64) * f64::EPSILON {
2994            1.0 / design_evals[i]
2995        } else {
2996            0.0
2997        };
2998        for j in 0..directions.ncols() {
2999            design_pinv_cross[[i, j]] *= scale;
3000        }
3001    }
3002    let coefficients = design_evecs.dot(&design_pinv_cross);
3003    let mut residual_sq = vec![0.0_f64; directions.ncols()];
3004    let mut direction_sq = vec![0.0_f64; directions.ncols()];
3005    for start in (0..n).step_by(DESIGN_CROSS_CHUNK_SIZE) {
3006        let end = (start + DESIGN_CROSS_CHUNK_SIZE).min(n);
3007        let basis_chunk = design
3008            .try_row_chunk(start..end)
3009            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
3010        let approximation = basis_chunk.dot(&coefficients);
3011        for j in 0..directions.ncols() {
3012            for row in start..end {
3013                let w = weights.map_or(1.0, |ws| ws[row]);
3014                let target = directions[[row, j]];
3015                let gap = target - approximation[[row - start, j]];
3016                residual_sq[j] += w * gap * gap;
3017                direction_sq[j] += w * target * target;
3018            }
3019        }
3020    }
3021    // `√ε`: the direction is reproduced by the design to within the square root
3022    // of machine precision, i.e. it IS in the span as far as this arithmetic can
3023    // tell. The two populations sit seven orders either side of it — a contained
3024    // constant reproduces at `κ·ε`, a merely-correlated one at `10⁻¹`–`10⁰`.
3025    let containment_bar = f64::EPSILON.sqrt();
3026    let contained: Vec<usize> = (0..directions.ncols())
3027        .filter(|&i| {
3028            direction_sq[i] > 0.0 && (residual_sq[i] / direction_sq[i]).sqrt() <= containment_bar
3029        })
3030        .collect();
3031    if contained.is_empty() {
3032        return Ok(Array2::zeros((n, 0)));
3033    }
3034    if contained.len() == keep.len() {
3035        // Every resolvable parametric direction is inside the span: this is the
3036        // case the shipped transform was written for, and it keeps it verbatim
3037        // rather than a rotation of it, so those bases do not move at all.
3038        return Ok(constraint_matrix.to_owned());
3039    }
3040    let mut out = Array2::<f64>::zeros((n, contained.len()));
3041    for (slot, &i) in contained.iter().enumerate() {
3042        for row in 0..n {
3043            out[[row, slot]] = directions[[row, i]];
3044        }
3045    }
3046    Ok(out)
3047}
3048
3049/// The span-preserving orthogonalization of a smooth design against a
3050/// constraint block: the realized block becomes `X·T − C·R`.
3051///
3052/// See [`parametric_residualization_for_design`] for the derivation. `T` is the
3053/// ordinary coefficient-space transform every basis already carries (it goes
3054/// into the basis metadata and restricts the penalties); `R` is the part that is
3055/// new, and it is what makes the construction cost no model dimension.
3056#[derive(Clone, Debug)]
3057pub struct ParametricResidualization {
3058    /// `T` — the coefficient-space transform, `p × k`.
3059    pub coefficient_transform: Array2<f64>,
3060    /// `R = B·T` in the RAW constraint block's own columns, `q × k`. The
3061    /// realized block is `X·T − C·R`, so a predict-time rebuild needs `C` at the
3062    /// new rows and this matrix, and nothing else.
3063    pub row_space_correction: Array2<f64>,
3064}
3065
3066/// Orthogonalize `design` against `constraint_matrix` **without deleting a model
3067/// dimension**, by projecting in row space rather than restricting in
3068/// coefficient space.
3069///
3070/// # Why this and not [`orthogonality_transform_for_design`]
3071///
3072/// That function returns a `Z` spanning `null((XᵀWC)ᵀ)`, so the realized block
3073/// becomes `X·Z` with span `col(X) ∩ col(C)^⊥` — it drops one coefficient
3074/// direction per parametric direction the cross resolves, whatever the geometry.
3075/// `76a520c45` established that such a deletion is free only under CONTAINMENT
3076/// (see [`contained_constraint_directions`]): when `C`'s direction is inside
3077/// `col(X)`, the deleted function IS the parametric column and the parametric
3078/// block keeps it. When it is not, the deleted direction is a genuine function
3079/// nothing else carries. That fix withheld the deletion, and left nothing in its
3080/// place — measured (gam#2747),
3081/// the shipped smooth block then sits at `‖XᵀC‖/(‖X‖‖C‖) = 1.6e-1 … 4.9e-1`
3082/// against the `1e-8` bar the same step asserts whenever a transform IS applied,
3083/// and `analyze_smooth_ownership`'s hierarchy is inert for every dependent
3084/// smooth, because an owner's realized columns are contained in no other basis's
3085/// span.
3086///
3087/// Residualization is the operation that is licensed unconditionally:
3088///
3089/// ```text
3090///     X̃ = X − C(CᵀWC)⁻CᵀWX          span([C | X̃]) = span([C | X])   ALWAYS
3091/// ```
3092///
3093/// — column operations on a block whose partner is in the model — so it makes
3094/// `X̃ᵀWC = 0` exactly while the joint span is untouched. The rank of `X̃` falls
3095/// by `dim(col X ∩ col C)` and by nothing else, so the whitener below drops
3096/// precisely the directions the deletion is entitled to drop and no others.
3097///
3098/// It is also CONTINUOUS in the containment residual, which the delete/don't
3099/// dichotomy is not: the direction the classical constraint removes has
3100/// residualized norm exactly `sin θ = ‖1 − P_X 1‖/‖1‖`, so as a basis approaches
3101/// containment its extra direction shrinks to zero and the two constructions
3102/// meet, instead of the model dimension stepping by one when a fit walks its own
3103/// range across a threshold.
3104///
3105/// # Numerics
3106///
3107/// `G̃ = X̃ᵀWX̃` is formed from `X̃` STREAMED chunk by chunk, not as
3108/// `G − M N⁻ Mᵀ`. The two are equal in exact arithmetic and the second is a
3109/// difference of near-equal `O(‖G‖)` quantities precisely in the contained case
3110/// this has to resolve — the same argument `contained_constraint_directions`
3111/// makes for forming its residual explicitly rather than reading `sin²θ` off
3112/// `1 − cos²θ`.
3113///
3114/// The constraint columns are unit-normalized internally, for the scale reason
3115/// [`orthogonality_transform_for_design`] documents at length; the normalization
3116/// is folded back into `row_space_correction` so the returned matrix is stated
3117/// against the RAW block a predict-time rebuild will reconstruct.
3118pub fn parametric_residualization_for_design(
3119    design: &DesignMatrix,
3120    constraint_matrix: ArrayView2<'_, f64>,
3121    weights: Option<ArrayView1<'_, f64>>,
3122) -> Result<ParametricResidualization, BasisError> {
3123    let n = design.nrows();
3124    let p = design.ncols();
3125    let q = constraint_matrix.ncols();
3126    if p == 0 {
3127        return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
3128    }
3129    if q == 0 {
3130        return Ok(ParametricResidualization {
3131            coefficient_transform: Array2::eye(p),
3132            row_space_correction: Array2::zeros((0, p)),
3133        });
3134    }
3135    if constraint_matrix.nrows() != n {
3136        return Err(BasisError::ConstraintMatrixRowMismatch {
3137            basisrows: n,
3138            constraintrows: constraint_matrix.nrows(),
3139        });
3140    }
3141    // Column norms are needed twice: to condition the cross/Gram below, and to
3142    // restate the correction against the raw block at the end.
3143    let mut column_norms = vec![0.0_f64; q];
3144    for (col, norm) in column_norms.iter_mut().enumerate() {
3145        let mut norm_sq = 0.0_f64;
3146        for row in 0..n {
3147            let value = constraint_matrix[[row, col]];
3148            let weight = weights.map_or(1.0, |ws| ws[row]);
3149            norm_sq += weight * value * value;
3150        }
3151        *norm = norm_sq.sqrt();
3152    }
3153    let normalized = unit_normalize_constraint_columns(constraint_matrix, weights);
3154
3155    // `N = ĈᵀWĈ`, and its pseudo-inverse truncated at its own spectral floor so
3156    // a constraint block with dependent columns simply loses those directions:
3157    // a direction that is a combination of the others is already projected out
3158    // by them.
3159    let mut constraint_gram = Array2::<f64>::zeros((q, q));
3160    for i in 0..q {
3161        for j in i..q {
3162            let mut acc = 0.0_f64;
3163            for row in 0..n {
3164                let weight = weights.map_or(1.0, |ws| ws[row]);
3165                acc += weight * normalized[[row, i]] * normalized[[row, j]];
3166            }
3167            constraint_gram[[i, j]] = acc;
3168            constraint_gram[[j, i]] = acc;
3169        }
3170    }
3171    let (constraint_evals, constraint_evecs) =
3172        FaerEigh::eigh(&constraint_gram, Side::Lower).map_err(BasisError::LinalgError)?;
3173    let constraint_top = constraint_evals.iter().cloned().fold(0.0_f64, f64::max);
3174    let constraint_floor = constraint_top * (q as f64) * f64::EPSILON;
3175    let mut constraint_pinv = Array2::<f64>::zeros((q, q));
3176    for slot in 0..q {
3177        if constraint_evals[slot] <= constraint_floor {
3178            continue;
3179        }
3180        let scale = 1.0 / constraint_evals[slot];
3181        for i in 0..q {
3182            for j in 0..q {
3183                constraint_pinv[[i, j]] +=
3184                    scale * constraint_evecs[[i, slot]] * constraint_evecs[[j, slot]];
3185            }
3186        }
3187    }
3188
3189    // `B̂ = N⁻ ĈᵀWX` (q × p): the regression of the design on the normalized
3190    // constraint block.
3191    let (cross, _gram) = design_cross_and_gram(design, normalized.view(), weights)?;
3192    let regression = constraint_pinv.dot(&cross.t());
3193
3194    // `G̃ = X̃ᵀWX̃`, streamed from the explicit residual.
3195    let mut residual_gram = Array2::<f64>::zeros((p, p));
3196    for start in (0..n).step_by(DESIGN_CROSS_CHUNK_SIZE) {
3197        let end = (start + DESIGN_CROSS_CHUNK_SIZE).min(n);
3198        let basis_chunk = design
3199            .try_row_chunk(start..end)
3200            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
3201        let residual_chunk = &basis_chunk - &normalized.slice(s![start..end, ..]).dot(&regression);
3202        let weighted = match weights {
3203            Some(ws) => {
3204                let mut scaled = residual_chunk.clone();
3205                for (mut row, &weight) in scaled
3206                    .axis_iter_mut(Axis(0))
3207                    .zip(ws.slice(s![start..end]).iter())
3208                {
3209                    row *= weight;
3210                }
3211                scaled
3212            }
3213            None => residual_chunk.clone(),
3214        };
3215        residual_gram += &fast_atb(&residual_chunk, &weighted);
3216    }
3217    // The streamed accumulation is symmetric in exact arithmetic; make it so in
3218    // floating point before the eigensolver is asked to assume it.
3219    for i in 0..p {
3220        for j in (i + 1)..p {
3221            let averaged = 0.5 * (residual_gram[[i, j]] + residual_gram[[j, i]]);
3222            residual_gram[[i, j]] = averaged;
3223            residual_gram[[j, i]] = averaged;
3224        }
3225    }
3226    let coefficient_transform = positive_spectral_whitener_from_gram(&residual_gram)?;
3227
3228    // Restate the correction against the RAW constraint columns: with
3229    // `Ĉ = C·diag(1/‖c_j‖)`, `Ĉ·B̂·T = C·(diag(1/‖c_j‖)·B̂·T)`.
3230    let mut row_space_correction = regression.dot(&coefficient_transform);
3231    for (row, norm) in column_norms.iter().enumerate() {
3232        let scale = if *norm > 0.0 && norm.is_finite() {
3233            1.0 / norm
3234        } else {
3235            0.0
3236        };
3237        for col in 0..row_space_correction.ncols() {
3238            row_space_correction[[row, col]] *= scale;
3239        }
3240    }
3241    Ok(ParametricResidualization {
3242        coefficient_transform,
3243        row_space_correction,
3244    })
3245}
3246
3247pub fn orthogonality_transform_for_design(
3248    design: &DesignMatrix,
3249    constraint_matrix: ArrayView2<'_, f64>,
3250    weights: Option<ArrayView1<'_, f64>>,
3251) -> Result<Array2<f64>, BasisError> {
3252    let k = design.ncols();
3253    if k == 0 {
3254        return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
3255    }
3256    let q = constraint_matrix.ncols();
3257    if q == 0 {
3258        return Ok(Array2::eye(k));
3259    }
3260    // Scale every constraint column to unit (weighted) L2 norm before forming the
3261    // design/constraint cross `M = Bᵀ W C`. The downstream rank detection
3262    // (`rrqr_nullspace_basis`) decides HOW MANY parametric directions the smooth
3263    // genuinely spans by testing the pivoted magnitudes of `M` against an
3264    // essentially absolute floor `α·ε·max(k,q)` (the `max(|R₀₀|, 1)` reference
3265    // clamps to 1 whenever the cross is sub-unit). With a RAW constraint column
3266    // that floor is scale-wrong: the all-ones intercept has norm √n, so `M`
3267    // carries a √n factor while the tolerance is referenced to 1. For a
3268    // kernel/radial smooth whose realized design is already (numerically)
3269    // orthogonal to the constant, `‖Bᵀ1‖` is pure floating-point roundoff
3270    // (~ε·‖B‖·√n); the √n inflation lands it right at the floor, so a rigid
3271    // rotation of the covariates — which only perturbs that roundoff — flips the
3272    // detected rank between 0 and 1. A spurious rank 1 then removes an ARBITRARY
3273    // real smooth direction (the pivot of a noise vector), and the fitted
3274    // surface, its EDF, and the REML-selected λ all drift under rotation
3275    // (gam#1818). Measuring the design's overlap with UNIT constraint directions
3276    // turns the test into a genuine rotation-invariant cosine: a real overlap is
3277    // O(1) and always detected, while roundoff-level overlap stays consistently
3278    // below the floor (rank 0). Column scaling of `C` leaves `null(Mᵀ)` — hence
3279    // the constrained-design span and the emitted transform — unchanged wherever
3280    // the rank is unchanged; it only removes the roundoff-driven rank flip.
3281    let normalized_constraint = unit_normalize_constraint_columns(constraint_matrix, weights);
3282    let (constraint_cross, gram) =
3283        design_cross_and_gram(design, normalized_constraint.view(), weights)?;
3284    orthogonality_transform_from_cross_and_gram(&constraint_cross, &gram)
3285}
3286
3287/// Scale each column of a constraint block to unit L2 norm under the inner
3288/// product used to form the identifiability cross — the `weights`-weighted
3289/// product when `Some`, the plain product otherwise. A column that is already
3290/// numerically zero (norm 0 or non-finite) is left untouched: its cross entries
3291/// are zero regardless, so it contributes no rank. The returned owned copy is
3292/// used only to build the cross; the emitted transform and the realized
3293/// constrained design are unaffected (column scaling of `C` preserves
3294/// `null(Mᵀ)`).
3295fn unit_normalize_constraint_columns(
3296    constraint_matrix: ArrayView2<'_, f64>,
3297    weights: Option<ArrayView1<'_, f64>>,
3298) -> Array2<f64> {
3299    let mut c = constraint_matrix.to_owned();
3300    let (n, q) = c.dim();
3301    for col in 0..q {
3302        let mut norm_sq = 0.0_f64;
3303        for row in 0..n {
3304            let v = c[[row, col]];
3305            let w = weights.map_or(1.0, |ws| ws[row]);
3306            norm_sq += w * v * v;
3307        }
3308        let norm = norm_sq.sqrt();
3309        if norm > 0.0 && norm.is_finite() {
3310            let inv = 1.0 / norm;
3311            for row in 0..n {
3312                c[[row, col]] *= inv;
3313            }
3314        }
3315    }
3316    c
3317}
3318
3319#[cfg(test)]
3320mod saturation_escalation_tests {
3321    use super::*;
3322
3323    #[test]
3324    fn starting_count_is_a_supported_low_rank_pilot_capped_by_default() {
3325        assert_eq!(starting_num_centers(800, 2), 30);
3326        assert_eq!(starting_num_centers(100_000, 1), 10);
3327        // The generic conditioning ceiling is `n / 4` and therefore reports
3328        // zero below four rows; the pilot retains the basis-wide one-center
3329        // degenerate minimum, which materialization subsequently raises to the
3330        // exact polynomial floor for the requested family.
3331        assert_eq!(starting_num_centers(3, 5), 1);
3332        assert_eq!(starting_num_centers(1, 2), 1);
3333    }
3334
3335    #[test]
3336    fn saturated_expansion_doubles_then_pins_at_validated_ceiling() {
3337        assert_eq!(expanded_num_centers(30, 157), Some(60));
3338        assert_eq!(expanded_num_centers(120, 157), Some(157));
3339        assert_eq!(expanded_num_centers(157, 157), None);
3340        assert_eq!(
3341            expanded_num_centers(usize::MAX - 1, usize::MAX),
3342            Some(usize::MAX)
3343        );
3344    }
3345
3346    #[test]
3347    fn saturation_excludes_the_nullspace_and_tracks_edf() {
3348        let tol = 1e-4;
3349        // Total term EDF includes the three-dimensional nullspace. Saturation
3350        // means its penalized component spends all 97 remaining directions.
3351        assert!(basis_is_saturated(100.0, 100, 3, tol));
3352        // Half-used basis is NOT saturated.
3353        assert!(!basis_is_saturated(48.5, 100, 3, tol));
3354        // Just below capacity by more than the derived margin: not saturated.
3355        assert!(!basis_is_saturated(90.0, 100, 3, tol));
3356        // A block whose null space already exhausts its columns has no penalizable
3357        // capacity and is never saturated.
3358        assert!(!basis_is_saturated(3.0, 3, 3, tol));
3359        assert!(!basis_is_saturated(f64::NAN, 100, 3, tol));
3360    }
3361
3362    #[test]
3363    fn saturation_is_monotone_in_edf() {
3364        let tol = 1e-3;
3365        let (k, null) = (60usize, 3usize);
3366        let full_width = k as f64;
3367        // Once saturated at some edf, any larger edf stays saturated.
3368        let mut first_true: Option<f64> = None;
3369        let mut e = full_width - 5.0;
3370        while e <= full_width {
3371            let sat = basis_is_saturated(e, k, null, tol);
3372            if sat && first_true.is_none() {
3373                first_true = Some(e);
3374            }
3375            if let Some(t) = first_true {
3376                assert!(
3377                    basis_is_saturated(e.max(t), k, null, tol),
3378                    "saturation must not flip back to false as edf grows"
3379                );
3380            }
3381            e += 0.25;
3382        }
3383        assert!(first_true.is_some(), "edf reaching capacity must saturate");
3384    }
3385}
3386
3387#[cfg(test)]
3388mod containment_tests {
3389    use super::*;
3390    fn dense(m: Array2<f64>) -> DesignMatrix {
3391        DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(m))
3392    }
3393
3394    /// A design whose span CONTAINS the constant keeps its constraint block
3395    /// verbatim — the shipped transform is right for it and must not move.
3396    #[test]
3397    fn a_span_that_contains_the_constant_keeps_the_whole_constraint_block() {
3398        let n = 40usize;
3399        let mut basis = Array2::<f64>::zeros((n, 3));
3400        for i in 0..n {
3401            let x = i as f64 / (n as f64 - 1.0);
3402            basis[[i, 0]] = 1.0;
3403            basis[[i, 1]] = x;
3404            basis[[i, 2]] = x * x;
3405        }
3406        let intercept = Array2::<f64>::ones((n, 1));
3407        let contained =
3408            contained_constraint_directions(&dense(basis), intercept.view(), None).expect("test");
3409        assert_eq!(
3410            contained.dim(),
3411            (n, 1),
3412            "the constant IS in this span, so the whole block is contained"
3413        );
3414        assert!(
3415            contained.iter().all(|&v| (v - 1.0).abs() < 1e-14),
3416            "an all-contained block must come back verbatim, not rotated"
3417        );
3418    }
3419
3420    /// A design that merely CORRELATES with the constant contributes nothing to
3421    /// residualize against — deleting a coefficient direction there removes a
3422    /// function the parametric block does not carry.
3423    #[test]
3424    fn a_span_that_only_correlates_with_the_constant_contains_nothing() {
3425        let n = 40usize;
3426        // Two strictly positive, non-constant columns: heavily correlated with
3427        // the constant (cosines ~0.99) and containing it in neither.
3428        let mut basis = Array2::<f64>::zeros((n, 2));
3429        for i in 0..n {
3430            let x = i as f64 / (n as f64 - 1.0);
3431            basis[[i, 0]] = (-0.3 * x).exp();
3432            basis[[i, 1]] = (-0.9 * x).exp();
3433        }
3434        let intercept = Array2::<f64>::ones((n, 1));
3435        let contained =
3436            contained_constraint_directions(&dense(basis.clone()), intercept.view(), None)
3437                .expect("test");
3438        assert_eq!(
3439            contained.ncols(),
3440            0,
3441            "a merely-correlated constant is not contained and licenses no deletion"
3442        );
3443        // And the correlation really is high, so this is not passing by the
3444        // directions being unrelated: the test would be vacuous if it were.
3445        let ones = Array1::<f64>::ones(n);
3446        let cross = basis.t().dot(&ones);
3447        let gram = basis.t().dot(&basis);
3448        let (evals, evecs) = FaerEigh::eigh(&gram, Side::Lower).expect("gram");
3449        let projected = evecs.t().dot(&cross);
3450        let mut solved = Array1::<f64>::zeros(projected.len());
3451        for i in 0..projected.len() {
3452            solved[i] = projected[i] / evals[i];
3453        }
3454        let fitted = basis.dot(&evecs.dot(&solved));
3455        let residual = &ones - &fitted;
3456        let sine = residual.dot(&residual).sqrt() / ones.dot(&ones).sqrt();
3457        assert!(
3458            sine > 1.0e-3 && sine < 0.5,
3459            "the fixture must be genuinely correlated-but-not-containing; sin θ = {sine}"
3460        );
3461    }
3462
3463    /// A block with one contained direction and one merely-correlated one keeps
3464    /// exactly the contained one, and it comes back orthogonal to nothing in
3465    /// particular — only its span matters downstream.
3466    #[test]
3467    fn a_mixed_block_keeps_exactly_the_contained_direction() {
3468        let n = 40usize;
3469        let mut basis = Array2::<f64>::zeros((n, 2));
3470        for i in 0..n {
3471            let x = i as f64 / (n as f64 - 1.0);
3472            basis[[i, 0]] = 1.0;
3473            basis[[i, 1]] = (-0.9 * x).exp();
3474        }
3475        let mut block = Array2::<f64>::zeros((n, 2));
3476        for i in 0..n {
3477            let x = i as f64 / (n as f64 - 1.0);
3478            block[[i, 0]] = 1.0;
3479            block[[i, 1]] = x;
3480        }
3481        let contained =
3482            contained_constraint_directions(&dense(basis), block.view(), None).expect("test");
3483        assert_eq!(
3484            contained.ncols(),
3485            1,
3486            "one of the two block directions is in the span and the other is not"
3487        );
3488        // The kept direction must BE the constant, up to scale and sign.
3489        let column = contained.column(0).to_owned();
3490        let first = column[0];
3491        assert!(
3492            first.abs() > 1e-8,
3493            "the kept direction must be non-degenerate"
3494        );
3495        assert!(
3496            column.iter().all(|&v| (v / first - 1.0).abs() < 1e-8),
3497            "the kept direction must be the constant, got {column:?}"
3498        );
3499    }
3500    /// The span-preserving construction, on the fixture that made the deletion
3501    /// wrong: two decaying exponentials that CORRELATE with the constant.
3502    ///
3503    /// Three properties, and they are the whole argument for residualizing
3504    /// rather than deleting:
3505    ///
3506    /// 1. the realized block comes out orthogonal to the constraint at roundoff
3507    ///    — the invariant the step exists for;
3508    /// 2. it costs NO coefficient direction — the deletion costs one;
3509    /// 3. `span([C | X·T − C·R]) == span([C | X])` exactly, which is what makes
3510    ///    (2) a fact rather than a preference.
3511    #[test]
3512    fn residualizing_a_correlated_block_is_orthogonal_and_costs_no_dimension() {
3513        let n = 40usize;
3514        let mut basis = Array2::<f64>::zeros((n, 2));
3515        for i in 0..n {
3516            let x = i as f64 / (n as f64 - 1.0);
3517            basis[[i, 0]] = (-0.3 * x).exp();
3518            basis[[i, 1]] = (-0.9 * x).exp();
3519        }
3520        let intercept = Array2::<f64>::ones((n, 1));
3521        let plan =
3522            parametric_residualization_for_design(&dense(basis.clone()), intercept.view(), None)
3523                .expect("test");
3524        assert_eq!(
3525            plan.coefficient_transform.ncols(),
3526            2,
3527            "a non-contained constraint costs no coefficient direction"
3528        );
3529        assert_eq!(plan.row_space_correction.dim(), (1, 2));
3530        let realized =
3531            basis.dot(&plan.coefficient_transform) - intercept.dot(&plan.row_space_correction);
3532        let cross = realized.t().dot(&intercept);
3533        let relative = cross.iter().map(|v| v * v).sum::<f64>().sqrt()
3534            / (realized.iter().map(|v| v * v).sum::<f64>().sqrt()
3535                * intercept.iter().map(|v| v * v).sum::<f64>().sqrt());
3536        // The bar is DERIVED rather than chosen. `X̃ᵀC` is accumulated over `n`
3537        // products of size `‖x‖‖c‖`, so relative to `‖X̃‖‖C‖` its floating-point
3538        // floor carries the amplification `‖X‖/‖X̃‖` — which on a fixture built
3539        // to be nearly collinear with its constraint is exactly `1/sin θ` and is
3540        // the reason a fixed `1e-14` would be a statement about this fixture
3541        // rather than about the arithmetic.
3542        let amplification = basis.iter().map(|v| v * v).sum::<f64>().sqrt()
3543            / realized.iter().map(|v| v * v).sum::<f64>().sqrt();
3544        let floor = (n as f64) * f64::EPSILON * amplification;
3545        assert!(
3546            floor < 1.0e-8,
3547            "the derived floor must stay far below the shipped ORTHOGONALITY_REL_RESIDUAL_TOL \
3548             or this assertion is vacuous; got {floor:e} at amplification {amplification:e}"
3549        );
3550        assert!(
3551            relative <= floor,
3552            "residualized block must be orthogonal to its constraint at the accumulation's own \
3553             floor; got {relative:e} against {floor:e}"
3554        );
3555        // Span preservation, stated as a measurement: the orthogonal projection
3556        // of an arbitrary vector onto `[C | X]` and onto `[C | X·T − C·R]` must
3557        // agree. The classical deletion FAILS this, and the same statistic on it
3558        // is asserted below so the test cannot pass by the fixture being easy.
3559        let mut target = Array1::<f64>::zeros(n);
3560        for i in 0..n {
3561            let x = i as f64 / (n as f64 - 1.0);
3562            target[i] = (-0.6 * x).exp();
3563        }
3564        let mut original = Array2::<f64>::zeros((n, 3));
3565        original.slice_mut(s![.., ..1]).assign(&intercept);
3566        original.slice_mut(s![.., 1..]).assign(&basis);
3567        let mut residualized = Array2::<f64>::zeros((n, 3));
3568        residualized.slice_mut(s![.., ..1]).assign(&intercept);
3569        residualized.slice_mut(s![.., 1..]).assign(&realized);
3570        let gap = |design: &Array2<f64>| -> f64 {
3571            let gram = design.t().dot(design);
3572            let rhs = design.t().dot(&target);
3573            let (evals, evecs) = FaerEigh::eigh(&gram, Side::Lower).expect("gram");
3574            let top = evals.iter().cloned().fold(0.0_f64, f64::max);
3575            let projected = evecs.t().dot(&rhs);
3576            let mut solved = Array1::<f64>::zeros(projected.len());
3577            for i in 0..projected.len() {
3578                if evals[i] > top * 1.0e-12 {
3579                    solved[i] = projected[i] / evals[i];
3580                }
3581            }
3582            let fitted = design.dot(&evecs.dot(&solved));
3583            let residual = &target - &fitted;
3584            residual.dot(&residual).sqrt() / target.dot(&target).sqrt()
3585        };
3586        let original_gap = gap(&original);
3587        let residualized_gap = gap(&residualized);
3588        assert!(
3589            (original_gap - residualized_gap).abs() <= 1.0e-10 * (1.0 + original_gap),
3590            "residualization must preserve the model span: {original_gap:e} vs {residualized_gap:e}"
3591        );
3592        // The negative control: the deletion this replaces LOSES span here.
3593        let deletion =
3594            orthogonality_transform_for_design(&dense(basis.clone()), intercept.view(), None)
3595                .expect("test");
3596        assert_eq!(
3597            deletion.ncols(),
3598            1,
3599            "the deletion costs exactly the dimension this test is about"
3600        );
3601        let mut deleted = Array2::<f64>::zeros((n, 2));
3602        deleted.slice_mut(s![.., ..1]).assign(&intercept);
3603        deleted.slice_mut(s![.., 1..]).assign(&basis.dot(&deletion));
3604        let deleted_gap = gap(&deleted);
3605        assert!(
3606            deleted_gap > 10.0 * original_gap.max(1.0e-14),
3607            "the fixture must be one where the deletion actually loses something: \
3608             {original_gap:e} -> {deleted_gap:e}"
3609        );
3610    }
3611
3612    /// On a span that CONTAINS the constant, residualization reproduces the
3613    /// classical constrained basis: it drops exactly one coefficient direction,
3614    /// by the rank test rather than by a predicate, and lands on the same span.
3615    ///
3616    /// This is the continuity claim made concrete — the two constructions are
3617    /// not alternatives that meet at a threshold, they agree at containment.
3618    #[test]
3619    fn residualizing_a_contained_block_reproduces_the_classical_deletion() {
3620        let n = 40usize;
3621        let mut basis = Array2::<f64>::zeros((n, 3));
3622        for i in 0..n {
3623            let x = i as f64 / (n as f64 - 1.0);
3624            basis[[i, 0]] = 1.0;
3625            basis[[i, 1]] = x;
3626            basis[[i, 2]] = x * x;
3627        }
3628        let intercept = Array2::<f64>::ones((n, 1));
3629        let plan =
3630            parametric_residualization_for_design(&dense(basis.clone()), intercept.view(), None)
3631                .expect("test");
3632        assert_eq!(
3633            plan.coefficient_transform.ncols(),
3634            2,
3635            "the constant IS in this span, so the rank test drops exactly one direction"
3636        );
3637        let realized =
3638            basis.dot(&plan.coefficient_transform) - intercept.dot(&plan.row_space_correction);
3639        let deletion =
3640            orthogonality_transform_for_design(&dense(basis.clone()), intercept.view(), None)
3641                .expect("test");
3642        let deleted = basis.dot(&deletion);
3643        assert_eq!(deleted.ncols(), realized.ncols());
3644        // Same span: each block's columns are reproduced by the other to
3645        // roundoff.
3646        let reproduces = |from: &Array2<f64>, to: &Array2<f64>| -> f64 {
3647            let gram = from.t().dot(from);
3648            let rhs = from.t().dot(to);
3649            let (evals, evecs) = FaerEigh::eigh(&gram, Side::Lower).expect("gram");
3650            let top = evals.iter().cloned().fold(0.0_f64, f64::max);
3651            let mut solved = evecs.t().dot(&rhs);
3652            for i in 0..evals.len() {
3653                let scale = if evals[i] > top * 1.0e-12 {
3654                    1.0 / evals[i]
3655                } else {
3656                    0.0
3657                };
3658                for j in 0..solved.ncols() {
3659                    solved[[i, j]] *= scale;
3660                }
3661            }
3662            let approximation = from.dot(&evecs.dot(&solved));
3663            let gap = to - &approximation;
3664            gap.iter().map(|v| v * v).sum::<f64>().sqrt()
3665                / to.iter().map(|v| v * v).sum::<f64>().sqrt().max(1.0e-300)
3666        };
3667        assert!(
3668            reproduces(&realized, &deleted) < 1.0e-12,
3669            "the deletion's span must be inside the residualization's"
3670        );
3671        assert!(
3672            reproduces(&deleted, &realized) < 1.0e-12,
3673            "the residualization's span must be inside the deletion's"
3674        );
3675        // And the correction is genuinely inert here: with the constant in the
3676        // span, the whitener already produced a block orthogonal to it.
3677        let cross = realized.t().dot(&intercept);
3678        let relative = cross.iter().map(|v| v * v).sum::<f64>().sqrt()
3679            / (realized.iter().map(|v| v * v).sum::<f64>().sqrt()
3680                * intercept.iter().map(|v| v * v).sum::<f64>().sqrt());
3681        assert!(relative < 1.0e-14, "got {relative:e}");
3682    }
3683}