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