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    UserProvided(Array2<f64>),
376    /// Joint multidimensional equal-mass partitioning in the full smooth space.
377    EqualMass {
378        num_centers: usize,
379    },
380    /// Covariate-representative equal-mass partitioning along one selected axis.
381    EqualMassCovarRepresentative {
382        num_centers: usize,
383    },
384    FarthestPoint {
385        num_centers: usize,
386    },
387    KMeans {
388        num_centers: usize,
389        max_iter: usize,
390    },
391    UniformGrid {
392        points_per_dim: usize,
393    },
394}
395
396impl CenterStrategy {
397    /// The number of centers this strategy will select, computed from the
398    /// strategy alone (no data pass). `d` is the smooth's covariate
399    /// dimensionality, needed only by `UniformGrid` whose count is
400    /// `points_per_dim^d`. Adaptive-fit provenance consults this before freeze,
401    /// because the frozen center matrix can contain periodic image expansion
402    /// and therefore is not the requested resolution for the next refit.
403    pub fn planned_num_centers(&self, d: usize) -> usize {
404        match self {
405            Self::Auto(inner) => inner.planned_num_centers(d),
406            Self::UserProvided(centers) => centers.nrows(),
407            Self::EqualMass { num_centers }
408            | Self::EqualMassCovarRepresentative { num_centers }
409            | Self::FarthestPoint { num_centers }
410            | Self::KMeans { num_centers, .. } => *num_centers,
411            Self::UniformGrid { points_per_dim } => {
412                points_per_dim.saturating_pow(d.clamp(1, u32::MAX as usize) as u32)
413            }
414        }
415    }
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
419pub enum CenterStrategyKind {
420    UserProvided,
421    EqualMass,
422    EqualMassCovarRepresentative,
423    FarthestPoint,
424    KMeans,
425    UniformGrid,
426}
427
428/// Adaptive default center count for spatial smooths (TPS, Duchon, Matérn).
429///
430/// Use this when the user has not explicitly specified a knot/center count.
431/// The basis size is the sub-linear `ceil(8 * d_factor * n^0.4)`, clamped above
432/// at `K_MAX = 2000` and below at a *data-proportional* floor `min(200, n/8)` so
433/// the floor only engages once there are enough observations to support a rich
434/// basis. The result is additionally capped at `n/4` so the penalty matrices
435/// stay well-conditioned relative to the data:
436///
437/// | n      | d=1  | d=2  | d=5  |
438/// |--------|------|------|------|
439/// | 800    | 116  | 134  | 186  |
440/// | 1 000  | 127  | 146  | 200  |
441/// | 2 000  | 200  | 200  | 268  |
442/// | 10 000 | 319  | 367  | 510  |
443/// | 100 000| 801  | 921  | 1281 |
444/// | 400 000| 1393 | 1602 | 2000 |
445/// | 1 000 000| 2000 | 2000 | 2000 |
446///
447/// The flat `200` floor used to inflate moderate-`n` spatial smooths (a few
448/// hundred to ~2000 rows) up to a dense 200-column design even though the raw
449/// sub-linear count — and the mesh/knot density that mgcv and R-INLA use on the
450/// same data — is far smaller. On ~800 rows that turned a single 2-D thin-plate
451/// REML fit into an `O(n·p² + p³)` grind at `p ≈ 200` (#718). Smoothness is
452/// already controlled by REML's penalty weight λ, not by the center count, so a
453/// data-proportional floor recovers the same surface at a fraction of the cost.
454///
455/// # Arguments
456/// * `n` - sample size (number of observations)
457/// * `d` - covariate dimensionality (number of input variables in the smooth)
458pub fn default_num_centers(n: usize, d: usize) -> usize {
459    const K_MIN: usize = 200;
460    const K_MAX: usize = 2000;
461    const ALPHA: f64 = 0.4;
462    const C: f64 = 8.0;
463    /// Per-extra-dimension growth in the center count: each covariate axis
464    /// beyond the first widens the basis by 15% to keep the per-axis mesh
465    /// density roughly constant as the smooth's domain dimensionality grows.
466    const PER_DIM_GROWTH: f64 = 0.15;
467    /// Divisor for the data-proportional floor: the `K_MIN` floor only engages
468    /// once `n` exceeds `K_MIN * FLOOR_N_DIVISOR`, so small samples are not
469    /// forced up to a dense `K_MIN`-column design.
470    const FLOOR_N_DIVISOR: usize = 8;
471    /// Divisor for the conditioning cap: the center count never exceeds `n /
472    /// COND_N_DIVISOR`, keeping the penalty matrices well-conditioned relative
473    /// to the data.
474    const COND_N_DIVISOR: usize = 4;
475
476    let d_factor = 1.0 + PER_DIM_GROWTH * (d.max(1) - 1) as f64;
477    let raw = (C * d_factor * (n as f64).powf(ALPHA)).ceil() as usize;
478
479    // Data-proportional floor: never inflate beyond n/FLOOR_N_DIVISOR, so the
480    // K_MIN-center floor only takes effect once n is large enough (~1600) to
481    // genuinely support that many basis columns.
482    let floor = K_MIN.min(n / FLOOR_N_DIVISOR);
483    let k = raw.clamp(floor, K_MAX);
484
485    // Never exceed n itself; cap at n/COND_N_DIVISOR to keep the penalty
486    // matrices well-conditioned relative to the data.
487    k.min(n).min(n / COND_N_DIVISOR)
488}
489
490/// Conservative center count for a *secondary* (distributional) predictor's
491/// spatial smooth — e.g. the log-σ scale model in a Gaussian location-scale
492/// fit.
493///
494/// The mean is identified directly by the response, so it warrants the
495/// generous [`default_num_centers`] basis. A scale/shape predictor is
496/// identified only through (noisy) squared residuals: handing it a basis sized
497/// for the mean lets REML/LAML smoothing selection over-fit it, because where
498/// the fitted scale is driven small the *observed* information collapses and
499/// the determinant penalty stops holding the wiggle down (#501). This mirrors
500/// standard GAMLSS/mgcv practice of giving distribution parameters a modest
501/// default (mgcv's modest default basis for a 1-D `s()`), grown gently with
502/// dimensionality and never exceeding the generous primary-predictor default.
503pub fn conservative_secondary_centers(n: usize, d: usize) -> usize {
504    const BASE_1D_CENTERS: usize = 15;
505    let modest = BASE_1D_CENTERS.saturating_mul(d.max(1));
506    default_num_centers(n, d).min(modest).max(1)
507}
508
509/// Low-rank starting center count for saturation-driven spatial fitting.
510///
511/// The structural minimum (`d + 1` polynomial directions plus one radial
512/// direction) is only enough to make the algebra identifiable. It is not an
513/// adequate pilot function space: structure orthogonal to that single radial
514/// direction is absorbed into the residual, so REML can legitimately shrink
515/// the direction and report EDF below its ceiling even when the surface is
516/// badly under-resolved (#1689). Start from the project's established
517/// thin-plate-style low-rank resolution `10 * 3^(d - 1)` instead. This is the
518/// same dimension rule already used by the automatic Duchon builder, capped by
519/// [`default_num_centers`] so the pilot never exceeds the validated production
520/// basis at small sample sizes.
521pub fn starting_num_centers(n: usize, d: usize) -> usize {
522    let low_rank_resolution = 10usize
523        .saturating_mul(3usize.saturating_pow(d.saturating_sub(1).min(u32::MAX as usize) as u32));
524    low_rank_resolution
525        .min(default_num_centers(n, d))
526        .min(n)
527        .max(1)
528}
529
530/// Next evidence-backed center count for a saturated spatial basis, bounded by
531/// the already validated production-default resolution.
532///
533/// Growth is geometric so the number of certified refits is logarithmic. The
534/// ceiling is supplied by the owning workflow because it depends on the
535/// spatial family/dimension and resource plan; the standard formula workflow
536/// uses [`default_num_centers`]. Adaptive resolution may therefore avoid work
537/// below the previous default, but can never turn an ordinary fit into an
538/// unvalidated row-rank dense basis. `None` means the validated function-space
539/// ceiling has been reached.
540pub fn expanded_num_centers(current: usize, ceiling: usize) -> Option<usize> {
541    if current >= ceiling {
542        return None;
543    }
544    let expanded = current.saturating_mul(2).min(ceiling);
545    (expanded > current).then_some(expanded)
546}
547
548/// Is a fitted spatial smooth's basis SATURATED — i.e. does its own evidence say
549/// the data wants more resolution than its realized coefficient span provides (#1689)?
550///
551/// The penalizable capacity is `realized_width − nullspace_dim`: the unpenalized
552/// polynomial null space is always fully used, so it is excluded from the "is the
553/// PENALIZED part maxed out?" test. The supplied `edf` is the total term EDF;
554/// subtracting `nullspace_dim` yields its penalized contribution, which rises
555/// toward that capacity exactly as REML drives the penalty
556/// λ toward its floor to chase structure the basis cannot resolve. Saturated ⟺
557/// `edf ≥ capacity − ε`, with the margin `ε` DERIVED from the outer REML
558/// numerical resolution (`ε = capacity · resolution_tol`, floored at
559/// `resolution_tol` so a tiny-capacity block still has a positive margin) rather
560/// than a tuned knob. The workflow derives `resolution_tol` from the maximum of
561/// its outer convergence tolerance and any rho-independent penalty shrinkage
562/// floor, because that floor bounds how closely EDF can approach the algebraic
563/// ceiling even as lambda tends to zero. Non-positive capacity (a block whose
564/// null space already exhausts its columns) is never saturated. The absolute
565/// scale of `ε` is what the MSI truth-recovery sweep
566/// (sin8/kappa/large_scale + #1074) validates — the criterion SHAPE
567/// (edf-vs-capacity, nullspace excluded, tol-tied margin) is the load-bearing
568/// contract this function pins.
569pub fn basis_is_saturated(
570    edf: f64,
571    realized_width: usize,
572    nullspace_dim: usize,
573    resolution_tol: f64,
574) -> bool {
575    let capacity = realized_width.saturating_sub(nullspace_dim) as f64;
576    if !(capacity > 0.0) || !edf.is_finite() {
577        return false;
578    }
579    let penalized_edf = (edf - nullspace_dim as f64).clamp(0.0, capacity);
580    let margin = (capacity * resolution_tol).max(resolution_tol);
581    penalized_edf >= capacity - margin
582}
583
584/// Resource-aware plan for a spatial smooth (Duchon / Matérn / TPS).
585///
586/// Returned by [`plan_spatial_basis`]. Captures the resolved center count,
587/// final basis dimension `p`, the dense byte cost for the value matrix and
588/// each derivative tier, and a recommended storage mode that is consistent
589/// with the supplied [`gam_runtime::resource::ResourcePolicy`].
590#[derive(Clone, Debug)]
591pub struct SpatialBasisPlan {
592    pub n: usize,
593    pub d: usize,
594    pub centers: usize,
595    pub p_final_estimate: usize,
596    pub dense_design_bytes: usize,
597    pub first_derivative_dense_bytes: usize,
598    pub second_derivative_dense_bytes: usize,
599    pub recommended_storage: SpatialStorageMode,
600}
601
602/// Storage mode recommended by [`plan_spatial_basis`].
603///
604/// * `DenseValueDenseDerivatives` — both the value design and its derivative
605///   matrices fit under the policy's single-materialization budget.
606/// * `LazyValueImplicitDerivatives` — the value design fits dense but the
607///   derivative matrices do not; switch derivatives to the implicit operator.
608/// * `OperatorOnly` — neither the design nor its derivatives fit; everything
609///   must be operator-backed.
610#[derive(Clone, Copy, Debug, PartialEq, Eq)]
611pub enum SpatialStorageMode {
612    DenseValueDenseDerivatives,
613    LazyValueImplicitDerivatives,
614    OperatorOnly,
615}
616
617/// How [`plan_spatial_basis`] should pick the spatial center count.
618#[derive(Clone, Copy, Debug)]
619pub enum CenterCountRequest {
620    /// Use the heuristic [`default_num_centers`].
621    Default,
622    /// Use the caller-supplied count exactly.
623    Explicit(usize),
624    /// Use [`default_num_centers`] but cap at `cap` to bound dense cost.
625    HeuristicCapped { cap: usize },
626}
627
628/// Build a resource-aware plan for a spatial smooth basis.
629///
630/// Computes the resolved center count, final basis dimension, dense byte
631/// estimates for the value design and first/second derivative tiers, and a
632/// recommended [`SpatialStorageMode`] derived from `policy`. This is the
633/// resource-aware replacement for ad-hoc calls to [`default_num_centers`] /
634/// [`heuristic_centers`](crate::term_builder::heuristic_centers).
635pub fn plan_spatial_basis(
636    n: usize,
637    d: usize,
638    requested_centers: CenterCountRequest,
639    nullspace_order: DuchonNullspaceOrder,
640    scale_dims: bool,
641    policy: &gam_runtime::resource::ResourcePolicy,
642) -> Result<SpatialBasisPlan, BasisError> {
643    if n == 0 {
644        crate::bail_invalid_basis!("plan_spatial_basis: n must be >= 1");
645    }
646    if d == 0 {
647        crate::bail_invalid_basis!("plan_spatial_basis: d must be >= 1");
648    }
649
650    // 1. Resolve center count.
651    let centers = match requested_centers {
652        CenterCountRequest::Default => default_num_centers(n, d),
653        CenterCountRequest::Explicit(k) => k,
654        CenterCountRequest::HeuristicCapped { cap } => default_num_centers(n, d).min(cap),
655    };
656
657    // 2. Nullspace dimension (Duchon polynomial null space of degree p-1).
658    //    `duchon_p_from_nullspace_order` returns m such that the null space is
659    //    polynomials of total degree < m, matching `duchon_nullspace_dimension`'s
660    //    `max_total_degree = m - 1` argument.
661    let m = duchon_p_from_nullspace_order(nullspace_order);
662    let nullspace_dim = if m == 0 {
663        0
664    } else {
665        duchon_nullspace_dimension(d, m - 1)
666    };
667
668    let p = centers.saturating_add(nullspace_dim);
669
670    // 3. Dense byte estimates.
671    let derivative_axes = if scale_dims { d } else { 0 };
672    let bytes_per_f64 = std::mem::size_of::<f64>();
673    let dense_design_bytes = bytes_per_f64.saturating_mul(n).saturating_mul(p);
674    let first_derivative_dense_bytes = dense_design_bytes.saturating_mul(derivative_axes);
675    // Diagonal second derivatives are also (D × n × p); off-diagonal cross terms
676    // would scale as D^2 but the planner reports the diagonal tier here.
677    let second_derivative_dense_bytes = first_derivative_dense_bytes;
678
679    // 4. Pick storage mode based on policy.
680    let recommended_storage = match policy.derivative_storage_mode {
681        gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired => {
682            SpatialStorageMode::OperatorOnly
683        }
684        gam_runtime::resource::DerivativeStorageMode::MaterializeIfSmall => {
685            let budget = policy.max_single_materialization_bytes;
686            if derivative_axes == 0 {
687                if dense_design_bytes <= budget {
688                    SpatialStorageMode::DenseValueDenseDerivatives
689                } else {
690                    SpatialStorageMode::LazyValueImplicitDerivatives
691                }
692            } else {
693                let total = dense_design_bytes
694                    .saturating_add(first_derivative_dense_bytes)
695                    .saturating_add(second_derivative_dense_bytes);
696                if total <= budget {
697                    SpatialStorageMode::DenseValueDenseDerivatives
698                } else if dense_design_bytes <= budget {
699                    SpatialStorageMode::LazyValueImplicitDerivatives
700                } else {
701                    SpatialStorageMode::OperatorOnly
702                }
703            }
704        }
705        gam_runtime::resource::DerivativeStorageMode::DiagnosticsOnly => {
706            // Diagnostic mode still prefers analytic storage for correctness.
707            SpatialStorageMode::OperatorOnly
708        }
709    };
710
711    Ok(SpatialBasisPlan {
712        n,
713        d,
714        centers,
715        p_final_estimate: p,
716        dense_design_bytes,
717        first_derivative_dense_bytes,
718        second_derivative_dense_bytes,
719        recommended_storage,
720    })
721}
722
723pub const fn default_spatial_center_strategy(num_centers: usize, d: usize) -> CenterStrategy {
724    if d <= 3 {
725        CenterStrategy::FarthestPoint { num_centers }
726    } else {
727        CenterStrategy::EqualMassCovarRepresentative { num_centers }
728    }
729}
730
731pub fn auto_spatial_center_strategy(num_centers: usize, d: usize) -> CenterStrategy {
732    let strategy = if d == 1 {
733        // In one dimension, farthest-point selection is the deterministic
734        // maximin grid over the observed domain. Equal-mass midpoints leave the
735        // low-frequency Duchon radial block slightly under-resolved at the
736        // boundaries, and REML then compensates with an over-smooth λ on
737        // low-noise signals (#504). The maximin grid matches the native
738        // reproducing-kernel interpolation geometry. The default strategy below
739        // extends the same space-filling contract to low-dimensional spatial
740        // GP bases, where kriging accuracy is governed by fill distance rather
741        // than marginal quantile balance.
742        CenterStrategy::FarthestPoint { num_centers }
743    } else {
744        default_spatial_center_strategy(num_centers, d)
745    };
746    CenterStrategy::Auto(Box::new(strategy))
747}
748
749pub const fn center_strategy_is_auto(strategy: &CenterStrategy) -> bool {
750    matches!(strategy, CenterStrategy::Auto(_))
751}
752
753pub(crate) fn realized_center_strategy(strategy: &CenterStrategy) -> &CenterStrategy {
754    match strategy {
755        CenterStrategy::Auto(inner) => inner.as_ref(),
756        other => other,
757    }
758}
759
760pub fn center_strategy_kind(strategy: &CenterStrategy) -> CenterStrategyKind {
761    match strategy {
762        CenterStrategy::Auto(inner) => center_strategy_kind(inner.as_ref()),
763        CenterStrategy::UserProvided(_) => CenterStrategyKind::UserProvided,
764        CenterStrategy::EqualMass { .. } => CenterStrategyKind::EqualMass,
765        CenterStrategy::EqualMassCovarRepresentative { .. } => {
766            CenterStrategyKind::EqualMassCovarRepresentative
767        }
768        CenterStrategy::FarthestPoint { .. } => CenterStrategyKind::FarthestPoint,
769        CenterStrategy::KMeans { .. } => CenterStrategyKind::KMeans,
770        CenterStrategy::UniformGrid { .. } => CenterStrategyKind::UniformGrid,
771    }
772}
773
774pub fn center_strategy_num_centers(strategy: &CenterStrategy) -> Option<usize> {
775    match strategy {
776        CenterStrategy::Auto(inner) => center_strategy_num_centers(inner.as_ref()),
777        CenterStrategy::UserProvided(centers) => Some(centers.nrows()),
778        CenterStrategy::EqualMass { num_centers }
779        | CenterStrategy::EqualMassCovarRepresentative { num_centers }
780        | CenterStrategy::FarthestPoint { num_centers }
781        | CenterStrategy::KMeans { num_centers, .. } => Some(*num_centers),
782        CenterStrategy::UniformGrid { .. } => None,
783    }
784}
785
786pub fn center_strategy_with_num_centers(
787    strategy: &CenterStrategy,
788    num_centers: usize,
789    d: usize,
790) -> Result<CenterStrategy, BasisError> {
791    validate_center_count(num_centers)?;
792    fn rebuild_inner(
793        strategy: &CenterStrategy,
794        num_centers: usize,
795        d: usize,
796    ) -> Result<CenterStrategy, BasisError> {
797        match strategy {
798            CenterStrategy::Auto(inner) => rebuild_inner(inner.as_ref(), num_centers, d),
799            CenterStrategy::EqualMass { .. } => Ok(CenterStrategy::EqualMass { num_centers }),
800            CenterStrategy::EqualMassCovarRepresentative { .. } => {
801                Ok(CenterStrategy::EqualMassCovarRepresentative { num_centers })
802            }
803            CenterStrategy::FarthestPoint { .. } => {
804                Ok(CenterStrategy::FarthestPoint { num_centers })
805            }
806            CenterStrategy::KMeans { max_iter, .. } => Ok(CenterStrategy::KMeans {
807                num_centers,
808                max_iter: *max_iter,
809            }),
810            CenterStrategy::UniformGrid { .. } if d == 1 => Ok(CenterStrategy::UniformGrid {
811                points_per_dim: num_centers,
812            }),
813            CenterStrategy::UserProvided(_) | CenterStrategy::UniformGrid { .. } => {
814                Err(BasisError::InvalidInput(format!(
815                    "cannot replace center count for {:?} strategy",
816                    center_strategy_kind(strategy)
817                )))
818            }
819        }
820    }
821    let rebuilt = rebuild_inner(strategy, num_centers, d)?;
822    Ok(match strategy {
823        CenterStrategy::Auto(_) => CenterStrategy::Auto(Box::new(rebuilt)),
824        _ => rebuilt,
825    })
826}
827
828/// Thin-plate basis configuration.
829#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct ThinPlateBasisSpec {
831    pub center_strategy: CenterStrategy,
832    #[serde(default)]
833    pub periodic: Option<Vec<Option<f64>>>,
834    pub length_scale: f64,
835    pub double_penalty: bool,
836    #[serde(default)]
837    pub identifiability: SpatialIdentifiability,
838    /// Frozen Wood-TPRS radial reparameterization. When `Some`, the builder
839    /// reuses this `(raw_radial_cols) × (kept_radial_cols)` matrix instead of
840    /// recomputing it from the constrained kernel penalty eigensystem. The
841    /// rectangular case is the truncated regression-spline path; carrying it
842    /// into prediction guarantees identical radial modes to fit-time.
843    #[serde(default)]
844    pub radial_reparam: Option<Array2<f64>>,
845}
846
847/// Per-smooth identifiability policy for spatial (TPS / Duchon) bases.
848///
849/// For a raw local basis `B` and parametric design block `C`, the orthogonalized
850/// basis is `B_c = B Z` where columns of `Z` span `null((B^T C)^T)`. This enforces:
851///   `B_c^T C = 0`
852/// in the unweighted inner product, so spatial effects cannot absorb parametric
853/// directions that actually exist in the model. The standalone basis builder has
854/// only an implicit intercept available, so it centers smooths against that
855/// intercept. The term-collection builder augments `C` with explicit linear
856/// terms when those terms are present in the formula.
857#[derive(Debug, Default, Clone, Serialize, Deserialize)]
858pub enum SpatialIdentifiability {
859    /// Keep unconstrained basis columns.
860    None,
861    /// Orthogonalize the smooth against model-owned parametric columns.
862    // "Magic" default for modular GAMs with explicit parametric block:
863    // keep spatial smooth orthogonal to intercept/linear terms.
864    // ApproxKind: Exact (orthogonalization is an exact projection).
865    #[default]
866    OrthogonalToParametric,
867    /// Freeze a fit-time transform `Z`; prediction uses `B_new * Z` unchanged.
868    FrozenTransform { transform: Array2<f64> },
869}
870
871pub(crate) use sphere_half_angle::{
872    SphereTrig, ambient_half_angle_separation, half_angle_partials, half_angle_separation,
873    half_angle_separation_scalar,
874};
875
876pub(crate) use sphere_kernels::{
877    wahba_sphere_kernel_derivative_dhav_kind, wahba_sphere_kernel_kind,
878    wahba_sphere_kernel_simd_kind, wahba_sphere_kernel_sobolev_derivative_dhav,
879};
880
881pub use sphere_spectral::{
882    pseudo_s2_truncated_coefficients, sobolev_s2_truncated_coefficients,
883    sphere_truncated_spectral_eval,
884};
885
886/// User intent and resolved numeric state for a Matérn kernel length scale.
887///
888/// `Auto` remains auto-owned after the planner resolves its data-dependent
889/// numeric seed.  This is deliberately not represented by a magic floating
890/// point value: callers can distinguish an omitted `length_scale` from an
891/// explicit value before and after center planning, and subsequent κ updates
892/// preserve that provenance.
893#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
894pub enum MaternLengthScale {
895    Auto { resolved: Option<f64> },
896    Fixed(f64),
897}
898
899impl MaternLengthScale {
900    pub const fn auto() -> Self {
901        Self::Auto { resolved: None }
902    }
903
904    pub const fn fixed(value: f64) -> Self {
905        Self::Fixed(value)
906    }
907
908    pub const fn is_fixed(self) -> bool {
909        matches!(self, Self::Fixed(_))
910    }
911
912    pub const fn resolved(self) -> Option<f64> {
913        match self {
914            Self::Auto { resolved } => resolved,
915            Self::Fixed(value) => Some(value),
916        }
917    }
918
919    /// Install a numeric value without changing who owns the scale.
920    pub fn set_resolved(&mut self, value: f64) {
921        match self {
922            Self::Auto { resolved } => *resolved = Some(value),
923            Self::Fixed(fixed) => *fixed = value,
924        }
925    }
926
927    /// Resolve an omitted scale exactly once.  Replanning a frozen or
928    /// κ-updated Auto scale must retain its current numeric value.
929    pub fn resolve_auto_once(&mut self, value: f64) {
930        if let Self::Auto { resolved } = self
931            && resolved.is_none()
932        {
933            *resolved = Some(value);
934        }
935    }
936}
937
938/// Matérn basis configuration.
939#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct MaternBasisSpec {
941    pub center_strategy: CenterStrategy,
942    #[serde(default)]
943    pub periodic: Option<Vec<Option<f64>>>,
944    pub length_scale: MaternLengthScale,
945    pub nu: MaternNu,
946    #[serde(default)]
947    pub include_intercept: bool,
948    pub double_penalty: bool,
949    #[serde(default)]
950    pub identifiability: MaternIdentifiability,
951    /// Per-axis anisotropy log-scales η_a (contrasts with Ση_a = 0).
952    ///
953    /// This implements geometric anisotropy: Λ = κA where A = diag(exp(η_a)),
954    /// det(A) = 1. The kernel is evaluated at r = κ|Ah| instead of r = κ|h|.
955    /// The decomposition preserves the isotropic scaling law for global κ
956    /// and adds d−1 shape parameters for directional relevance.
957    ///
958    /// Conditional positive definiteness is preserved under any invertible
959    /// linear coordinate transform (Schoenberg), so the kernel remains valid.
960    ///
961    /// When Some, the distance is r = √(Σ_a exp(2η_a) · (x_a - c_a)²).
962    /// When None, isotropic distance r = ‖x - c‖ is used.
963    #[serde(default)]
964    pub aniso_log_scales: Option<Vec<f64>>,
965}
966
967/// Per-smooth identifiability policy for Matérn kernel coefficients.
968///
969/// These constraints are geometric (center-based), so they are stable across
970/// train/predict and do not depend on response weights.
971#[derive(Debug, Default, Clone, Serialize, Deserialize)]
972pub enum MaternIdentifiability {
973    /// Keep the unconstrained kernel coefficient space.
974    None,
975    /// Enforce `1^T alpha = 0` at center locations (removes constant drift).
976    // Safe default with model intercepts: prevent kernel block from absorbing
977    // a global mean level.
978    #[default]
979    CenterSumToZero,
980    /// Enforce orthogonality to `[1, c_1, ..., c_d]` at centers.
981    /// Use this when explicit linear terms should own global trends.
982    CenterLinearOrthogonal,
983    /// Freeze a fit-time transform `Z` so prediction cannot drift.
984    FrozenTransform { transform: Array2<f64> },
985}
986
987/// Duchon null-space polynomial degree.
988///
989/// Controls the polynomial null space of the Duchon / polyharmonic spline. The
990/// Duchon seminorm `‖D^m f‖²` annihilates all polynomials of total degree
991/// `< m`, so those polynomials must be handled as explicit unpenalized columns.
992///
993/// The user-facing `order` knob selects the polynomial degree cutoff `r`, and
994/// the resulting polynomial null space has dimension `C(d + r, r)` where `d`
995/// is the covariate dimension.  In the `duchon(...)` formula DSL:
996///
997/// | `order=` | Variant         | max total degree | null-space dim  |
998/// |----------|-----------------|------------------|-----------------|
999/// | `0`      | `Zero`          | 0                | `C(d+0,0) = 1`  |
1000/// | `1`      | `Linear`        | 1                | `C(d+1,1) = d+1`|
1001/// | `k≥2`    | `Degree(k)`     | k                | `C(d+k,k)`      |
1002///
1003/// **How the polynomial null space is consumed during basis construction:**
1004///
1005/// 1. `polynomial_block_from_order` materialises an `(n, C(d+r,r))` block `P`
1006///    of monomials up to total degree `r` at the selected `centers`.
1007/// 2. `kernel_constraint_nullspace` computes `Z = null(P_centers^T)`, a
1008///    `(k, k − C(d+r,r))` matrix. Reparameterising the radial kernel
1009///    coefficients as `α = Z γ` enforces the side condition `P_centers^T α = 0`
1010///    and yields `k − C(d+r,r)` free kernel parameters.
1011/// 3. The polynomial block `P_data` evaluated at the data rows is appended to
1012///    the kernel block `Φ Z`, giving a total of
1013///    `(k − C(d+r,r)) + C(d+r,r) = k` columns before the spatial
1014///    identifiability transform.  Crucially, the total width equals the
1015///    requested center count `k`, **not** `k + C(d+r,r)`.
1016///
1017/// **Example — `duchon(PC1, PC2, PC3, centers=10, order=1)` (d=3):**
1018///
1019/// - Polynomial null space: `C(3+1,1) = 4` monomials `{1, x₁, x₂, x₃}`.
1020/// - Kernel columns after constraint: `10 − 4 = 6`.
1021/// - Appended polynomial block: 4 columns.
1022/// - Pre-identifiability total: `6 + 4 = 10` columns, i.e. exactly `centers`.
1023///
1024/// The variant naming matches the Duchon `m` parameter:
1025/// `Zero` → `m=1`, `Linear` → `m=2`, `Degree(k)` → `m=k+1`.
1026#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1027pub enum DuchonNullspaceOrder {
1028    Zero,
1029    Linear,
1030    Degree(usize),
1031}
1032
1033/// Duchon-like basis configuration with explicit low-frequency null-space
1034/// control and explicit spectral power.
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1036#[serde(deny_unknown_fields)]
1037pub struct DuchonBasisSpec {
1038    pub center_strategy: CenterStrategy,
1039    #[serde(default)]
1040    pub periodic: Option<Vec<Option<f64>>>,
1041    /// Optional hybrid Matérn width. `None` means pure scale-free Duchon with
1042    /// spectrum `||w||^(2p + 2s)`. `Some(length_scale)` enables the hybrid
1043    /// spectrum `||w||^(2p) * (kappa^2 + ||w||^2)^s`, `kappa = 1/length_scale`.
1044    pub length_scale: Option<f64>,
1045    /// Literal Duchon spectral power `s` (`f64`, fractional values fully
1046    /// threaded end-to-end). The pure-Duchon kernel exponent is `2(p + s) − d`,
1047    /// so this is the knob that sets `φ(r)`: `s = 0` is the integer-order Duchon
1048    /// kernel `r^{2p−d}` (its `r²·log r` log case in even `d`, ≡ the thin-plate
1049    /// kernel); `s = (d − 1)/2` gives the cubic `r³` in every dimension.
1050    ///
1051    /// This field is taken LITERALLY by the basis builder — `power = 0` means
1052    /// `s = 0`, NOT "use a default". The magic cubic default (applied when the
1053    /// user gives no explicit power) is a request-layer choice resolved by the
1054    /// formula / CLI / pyffi front-ends via [`duchon_cubic_default`]; by the time
1055    /// a spec reaches the builder this value is the final intended `s`. The
1056    /// hybrid Duchon–Matérn path (`length_scale = Some`) still requires an
1057    /// integer `s` (read via `spec.power_as_usize()`).
1058    pub power: f64,
1059    pub nullspace_order: DuchonNullspaceOrder,
1060    #[serde(default)]
1061    pub identifiability: SpatialIdentifiability,
1062    /// Per-axis anisotropy log-scales η_a.
1063    ///
1064    /// For hybrid Duchon (`length_scale=Some`), these are centered contrasts in
1065    /// the decomposition Λ = κA with det(A)=1. For pure Duchon
1066    /// (`length_scale=None`), they parameterize shape-only axis warping on the
1067    /// public path and are centered before basis evaluation/writeback so no
1068    /// global length scale is introduced.
1069    ///
1070    /// When Some, the distance is r = √(Σ_a exp(2η_a) · (x_a - c_a)²).
1071    /// When None, isotropic distance r = ‖x - c‖ is used.
1072    #[serde(default)]
1073    pub aniso_log_scales: Option<Vec<f64>>,
1074    #[serde(default)]
1075    pub operator_penalties: DuchonOperatorPenaltySpec,
1076    #[serde(default)]
1077    pub boundary: OneDimensionalBoundary,
1078    /// Data-metric radial reparameterization `V` (#1355), mirroring the
1079    /// thin-plate Wood-TPRS reparam. When `Some`, the constrained kernel
1080    /// transform is folded to `Z·V` so the realized design columns rotate into
1081    /// the `G_c`-orthonormal generalized eigenbasis of `Ω_c v = μ G_c v` and the
1082    /// native penalty becomes the diagonal curvature-per-unit-data-variance
1083    /// spectrum (mgcv's cliff), preventing the REML over-smoothing collapse to
1084    /// EDF = 1. Frozen at the cold dense build and replayed verbatim by the
1085    /// predict / κ-trial / ψ-derivative paths so they stay bit-consistent with
1086    /// the fit-time design. `None` on the lazy/streaming path (huge `n`), which
1087    /// retains the original constrained basis.
1088    #[serde(default)]
1089    pub radial_reparam: Option<Array2<f64>>,
1090}
1091
1092impl DuchonBasisSpec {
1093    /// Integer view of `power` for the existing integer-only downstream chain.
1094    /// Non-finite or non-integer values fall back to `0` (the integer-only
1095    /// validators downstream already reject this case with a clear message).
1096    pub fn power_as_usize(&self) -> usize {
1097        duchon_power_to_usize(self.power)
1098    }
1099}
1100
1101/// Convert a Duchon spectral-power `f64` into the integer view used by the
1102/// closed-form code paths. Non-finite, negative, or fractional values clamp to
1103/// `0` so the validator downstream emits the canonical error.
1104pub fn duchon_power_to_usize(power: f64) -> usize {
1105    if !power.is_finite() || power < 0.0 {
1106        return 0;
1107    }
1108    let rounded = power.round();
1109    if (rounded - power).abs() > 1e-9 {
1110        return 0;
1111    }
1112    rounded as usize
1113}
1114
1115#[derive(Clone, Debug, Serialize, Deserialize)]
1116pub struct DuchonOperatorPenaltySpec {
1117    pub mass: OperatorPenaltySpec,
1118    pub tension: OperatorPenaltySpec,
1119    pub stiffness: OperatorPenaltySpec,
1120}
1121
1122#[derive(Clone, Debug, Serialize, Deserialize)]
1123pub enum OperatorPenaltySpec {
1124    Active {
1125        initial_log_lambda: f64,
1126        prior: Option<RhoPrior>,
1127    },
1128    Disabled,
1129}
1130
1131impl Default for DuchonOperatorPenaltySpec {
1132    fn default() -> Self {
1133        // ALL ON. The Duchon penalty is a Hilbert scale: curvature is the
1134        // always-on exact RKHS `Primary` Gram and the trend ridge is always on;
1135        // the lower orders — mass (amplitude `Σ(f−f̄)²`) and tension (first-order
1136        // roughness `Σ‖∇f‖²`) — are active here, collocated on a density-blind
1137        // data-support sample. REML deselects any the data don't support (SPEC:
1138        // recover the null by default, opt INTO overfitting). Stiffness (`D2`)
1139        // stays off — `Primary` is the exact, superior curvature. (The Matérn
1140        // collocation overlay builds its own `all_active()`; SAE atoms, which
1141        // ship only `Primary`, use `all_disabled()`.)
1142        Self {
1143            mass: OperatorPenaltySpec::Active {
1144                initial_log_lambda: 0.0,
1145                prior: None,
1146            },
1147            tension: OperatorPenaltySpec::Active {
1148                initial_log_lambda: 0.0,
1149                prior: None,
1150            },
1151            stiffness: OperatorPenaltySpec::Disabled,
1152        }
1153    }
1154}
1155
1156impl DuchonOperatorPenaltySpec {
1157    pub fn has_active_operator_penalty(&self) -> bool {
1158        matches!(self.mass, OperatorPenaltySpec::Active { .. })
1159            || matches!(self.tension, OperatorPenaltySpec::Active { .. })
1160            || matches!(self.stiffness, OperatorPenaltySpec::Active { .. })
1161    }
1162
1163    pub fn all_disabled() -> Self {
1164        Self {
1165            mass: OperatorPenaltySpec::Disabled,
1166            tension: OperatorPenaltySpec::Disabled,
1167            stiffness: OperatorPenaltySpec::Disabled,
1168        }
1169    }
1170
1171    /// All three operator dials active — used by the Matérn collocation overlay.
1172    pub fn all_active() -> Self {
1173        let active = || OperatorPenaltySpec::Active {
1174            initial_log_lambda: 0.0,
1175            prior: None,
1176        };
1177        Self {
1178            mass: active(),
1179            tension: active(),
1180            stiffness: active(),
1181        }
1182    }
1183
1184    /// Operator-penalty dials appropriate for a Matérn-ν kernel in dimension `d`.
1185    ///
1186    /// The Matérn-ν RKHS is the Sobolev space `H^m` with `m = ν + d/2`: its
1187    /// squared norm controls the order-`j` derivative in L2 exactly when
1188    /// `j ≤ m`. The collocation overlay penalizes the squared L2 norms of the
1189    /// value (mass, `D0`, j=0), gradient (tension, `D1`, j=1) and Hessian
1190    /// (stiffness, `D2`, j=2). Activating a penalty whose derivative order
1191    /// exceeds the RKHS smoothness (`j > m`) imposes a roughness constraint the
1192    /// true kernel does NOT — it over-smooths the reduced-rank fit relative to
1193    /// the exact GP (mgcv `bs="gp"`, GpGp).
1194    ///
1195    /// The ν=1/2 Ornstein–Uhlenbeck kernel is the sole exception: its cusp at a
1196    /// center makes the collocated gradient/Hessian undefined, so it retains
1197    /// mass only (#707). Every differentiable order uses the inclusive Sobolev
1198    /// boundary. In particular ν=3/2 in d=1 has `m=2`, and its finite `D2`
1199    /// stiffness energy belongs to H². Omitting that block leaves the rough
1200    /// kernel with only mass+tension, inflates EDF, and changes the REML model
1201    /// class relative to its stated RKHS.
1202    pub fn matern_for_smoothness(nu: MaternNu, d: usize) -> Self {
1203        let m = nu.half_integer_value() + 0.5 * d as f64;
1204        // Tolerance keeps the mathematically inclusive `j ≤ m` boundary stable
1205        // under floating-point representation of half-integer orders.
1206        const ORDER_EPS: f64 = 1e-9;
1207        let active = || OperatorPenaltySpec::Active {
1208            initial_log_lambda: 0.0,
1209            prior: None,
1210        };
1211        let gate = |order: f64| {
1212            if !matches!(nu, MaternNu::Half) && m + ORDER_EPS >= order {
1213                active()
1214            } else {
1215                OperatorPenaltySpec::Disabled
1216            }
1217        };
1218        Self {
1219            mass: active(),
1220            tension: gate(1.0),
1221            stiffness: gate(2.0),
1222        }
1223    }
1224}
1225
1226pub fn minimum_duchon_power_for_operator_penalties(
1227    dim: usize,
1228    nullspace_order: DuchonNullspaceOrder,
1229    max_operator_derivative_order: usize,
1230) -> usize {
1231    let p = duchon_p_from_nullspace_order(nullspace_order);
1232    let mut s = 0usize;
1233    while 2 * (p + s) <= dim + max_operator_derivative_order {
1234        s += 1;
1235    }
1236    s
1237}
1238
1239/// Resolve a fully admissible Duchon `(nullspace_order, power)` pair.
1240///
1241/// Three constraints fold into one resolution:
1242///   (a) operator collocation up to `max_op`:        `2(p + s) > d + max_op`
1243///   (b) pure-mode CPD vs polynomial nullspace P_p:  `2s < d`
1244///       (Wendland Thm 8.17: pure polyharmonic kernel of order m = p+s in
1245///        R^d is CPD of order `m − ⌊d/2⌋ + 1[d even, log] / m − (d−1)/2
1246///        [d odd]`, and Duchon interpolation against P_p is well-posed iff
1247///        CPD order ≤ p, which collapses to `2s < d` since 2s, d are
1248///        integers and 2s is even.)
1249///   (a) implies the kernel-existence condition `2(p + s) > d`.
1250///   (b) is dropped when `length_scale` is `Some` (hybrid Matérn-blended
1251///       kernel is strictly PD, CPD order 0).
1252///
1253/// Strategy: at the requested `nullspace_order`, take the smallest `s`
1254/// satisfying (a). If that `s` violates (b) in pure mode, escalate the
1255/// nullspace order by one and retry. Termination: at `p ≥ ⌈(d+max_op)/2⌉ + 1`
1256/// the operator constraint (a) admits `s = 0`, and `0 < d` satisfies (b)
1257/// for any `d ≥ 1`, so escalation always converges.
1258///
1259/// The returned nullspace order is monotone in the request: it never
1260/// decreases the user's requested order — only strengthens it when pure-mode
1261/// CPD requires a richer polynomial absorption space.
1262pub fn resolve_duchon_orders(
1263    dim: usize,
1264    requested_nullspace_order: DuchonNullspaceOrder,
1265    max_operator_derivative_order: usize,
1266    length_scale: Option<f64>,
1267) -> (DuchonNullspaceOrder, usize) {
1268    assert!(dim >= 1, "Duchon basis requires dim >= 1");
1269    let pure = length_scale.is_none();
1270    let mut nullspace = requested_nullspace_order;
1271    // Bounded loop: escalation terminates by the argument above.
1272    for _ in 0..=(dim + max_operator_derivative_order + 1) {
1273        let p = duchon_p_from_nullspace_order(nullspace);
1274        // Smallest s with 2(p + s) > d + max_op:
1275        //   2p > d + max_op            ⇒ s = 0
1276        //   else s = ⌈(d + max_op + 1 − 2p) / 2⌉ = (d + max_op + 2 − 2p) / 2
1277        let s_op = if 2 * p > dim + max_operator_derivative_order {
1278            0
1279        } else {
1280            (dim + max_operator_derivative_order + 2 - 2 * p) / 2
1281        };
1282        if !pure || 2 * s_op < dim {
1283            return (nullspace, s_op);
1284        }
1285        nullspace = duchon_next_nullspace_order(nullspace);
1286    }
1287    // Bounded-loop fallback: by the analysis in the docstring, for
1288    // `p >= ceil((dim + max_op) / 2) + 1` the operator constraint admits
1289    // `s = 0` and (in pure mode) `0 < dim` satisfies the kernel-existence
1290    // condition. The loop above always reaches that regime within the bound,
1291    // so returning the last `nullspace` with `s = 0` is a valid answer.
1292    (nullspace, 0)
1293}
1294
1295#[inline]
1296pub(crate) fn duchon_next_nullspace_order(order: DuchonNullspaceOrder) -> DuchonNullspaceOrder {
1297    match order {
1298        DuchonNullspaceOrder::Zero => DuchonNullspaceOrder::Linear,
1299        DuchonNullspaceOrder::Linear => DuchonNullspaceOrder::Degree(2),
1300        DuchonNullspaceOrder::Degree(k) => DuchonNullspaceOrder::Degree(k + 1),
1301    }
1302}
1303
1304pub(crate) fn duchon_previous_nullspace_order(order: DuchonNullspaceOrder) -> DuchonNullspaceOrder {
1305    match order {
1306        DuchonNullspaceOrder::Zero => DuchonNullspaceOrder::Zero,
1307        DuchonNullspaceOrder::Linear => DuchonNullspaceOrder::Zero,
1308        DuchonNullspaceOrder::Degree(2) => DuchonNullspaceOrder::Linear,
1309        DuchonNullspaceOrder::Degree(k) => DuchonNullspaceOrder::Degree(k - 1),
1310    }
1311}
1312
1313/// Returns the maximum derivative order required by the *active* operator
1314/// penalties: 2 if stiffness is Active, else 1 if tension is Active, else 0.
1315/// Mass-only (or no active operator) penalties only require kernel validity
1316/// (`2(p+s) > d`), tension requires D1 collocation (`2(p+s) > d+1`), and
1317/// stiffness requires D2 collocation (`2(p+s) > d+2`).
1318pub fn duchon_max_active_operator_derivative_order(
1319    operator_penalties: &DuchonOperatorPenaltySpec,
1320) -> usize {
1321    if matches!(
1322        operator_penalties.stiffness,
1323        OperatorPenaltySpec::Active { .. }
1324    ) {
1325        2
1326    } else if matches!(
1327        operator_penalties.tension,
1328        OperatorPenaltySpec::Active { .. }
1329    ) {
1330        1
1331    } else {
1332        0
1333    }
1334}
1335
1336/// Metadata returned by generic basis builders.
1337#[derive(Debug, Clone)]
1338pub enum BasisMetadata {
1339    BSpline1D {
1340        knots: Array1<f64>,
1341        identifiability_transform: Option<Array2<f64>>,
1342        periodic: Option<(f64, f64, usize)>,
1343        /// Effective B-spline polynomial degree carried by `knots`.
1344        ///
1345        /// Persisted alongside `knots` so prediction can reconstruct an
1346        /// evaluator that matches fit-time geometry, even when the fit-time
1347        /// auto-shrink (issue #340) reduced the user's requested degree to
1348        /// fit the available data (`n` too small for cubic ⇒ quadratic ⇒
1349        /// linear). When `None` the consumer should fall back to the
1350        /// upstream `BSplineBasisSpec.degree` (legacy / non-shrunk path).
1351        degree: Option<usize>,
1352        /// Human-readable description of an automatic basis shrink (issue #340)
1353        /// when the user's requested `(degree, num_internal_knots)` exceeded the
1354        /// available evaluation count `n`. `Some(note)` records the before→after
1355        /// configuration; `None` means no auto-shrink occurred for this basis.
1356        auto_shrink_note: Option<String>,
1357        /// Raw-basis particular-solution coefficients `β_p` for a *non-zero*
1358        /// endpoint anchor (#2297), if any. The term carries a fixed affine
1359        /// offset function `B_raw(x) · β_p` in addition to its constrained
1360        /// design `B_raw(x) · Z`; the design assembler realizes that offset into
1361        /// the model's linear predictor at both fit and predict time. `None`
1362        /// for free / clamped / zero-anchor bases (the ordinary pure-linear
1363        /// chart). Recomputed deterministically from the frozen `knots`,
1364        /// `degree` and boundary conditions on every rebuild, so a saved model
1365        /// replays the identical offset; it is serialized here so the assembler
1366        /// need not re-derive it from the spec. This metadata is transient
1367        /// (rebuilt at predict from the serialized frozen spec), not persisted.
1368        anchor_offset_coeffs: Option<Array1<f64>>,
1369    },
1370    /// Natural cubic regression spline (`bs="cr"`/`"cs"`) metadata (#1074).
1371    ///
1372    /// `knots` are the `k` Lancaster–Salkauskas knots that index the basis
1373    /// values directly (basis dim = `knots.len()`). Predict-time rebuilds
1374    /// reconstruct the cr geometry from `knots` and replay the captured
1375    /// `identifiability_transform` exactly, mirroring `BSpline1D`.
1376    CubicRegression1D {
1377        knots: Array1<f64>,
1378        identifiability_transform: Option<Array2<f64>>,
1379    },
1380    ThinPlate {
1381        centers: Array2<f64>,
1382        length_scale: f64,
1383        periodic: Option<Vec<Option<f64>>>,
1384        identifiability_transform: Option<Array2<f64>>,
1385        /// Uniform coordinate scale used for isotropic input standardization.
1386        input_scale: crate::IsotropicScale,
1387        /// Wood-TPRS radial reparameterization carried into prediction so the
1388        /// rotated radial basis at predict-time matches fit-time exactly. `None`
1389        /// in the lazy/streaming path which retains the original basis.
1390        radial_reparam: Option<Array2<f64>>,
1391    },
1392    Sphere {
1393        centers: Array2<f64>,
1394        penalty_order: usize,
1395        method: SphereMethod,
1396        max_degree: Option<usize>,
1397        wahba_kernel: SphereWahbaKernel,
1398        constraint_transform: Option<Array2<f64>>,
1399    },
1400    /// Constant-curvature (`M_κ`) geodesic-kernel smooth (#944). `kappa` and
1401    /// the realized `length_scale` are persisted so predict-time (and the
1402    /// future ψ-channel per-trial) rebuilds replay the exact fit-time
1403    /// geometry; `constraint_transform` is the composed `z · z_parametric`
1404    /// frozen by the global identifiability pipeline (#532 pattern).
1405    ConstantCurvature {
1406        centers: Array2<f64>,
1407        kappa: f64,
1408        length_scale: f64,
1409        constraint_transform: Option<Array2<f64>>,
1410    },
1411    /// Measure-jet spline smooth: multiscale local-jet-residual energy of the
1412    /// empirical measure, quadratured on the center set. `centers` are the
1413    /// REALIZED barycenter nodes; `order_s` stores the spec's order sentinel
1414    /// verbatim as the mode marker (0.0 = per-level/spectral, > 0 = fused
1415    /// pin — persisting a realized default would flip the rebuilt mode). The
1416    /// penalty depends on the FIT data through `masses`, the realized
1417    /// `eps_band`, the support anchors, and the normalization scales, so all
1418    /// are persisted and replayed verbatim by
1419    /// predict-time (and per-ψ-trial) rebuilds — recomputing either from
1420    /// predict rows would change the penalty the coefficients were estimated
1421    /// under. `constraint_transform` is the composed `z · z_parametric`
1422    /// frozen by the global identifiability pipeline (#532 pattern).
1423    MeasureJet {
1424        centers: Array2<f64>,
1425        input_scale: crate::IsotropicScale,
1426        length_scale: f64,
1427        eps_band: Vec<f64>,
1428        order_s: f64,
1429        alpha: f64,
1430        tau0: f64,
1431        masses: Array1<f64>,
1432        support_means: Vec<f64>,
1433        penalty_normalization_scales: Vec<f64>,
1434        raw_penalty_normalization_scales: Vec<f64>,
1435        fused_penalty_normalization_scale: Option<f64>,
1436        constraint_transform: Option<Array2<f64>>,
1437        /// Ambient input-measurement-error scale `σ_coord` (issue #2225): the
1438        /// perpendicular off-manifold residual spread of the fit rows, in the
1439        /// centers' (standardized) frame. `None` when it could not be estimated.
1440        /// Carried into `MeasureJetFrozenQuadrature::sigma_coord` at freeze time.
1441        sigma_coord: Option<f64>,
1442    },
1443    Matern {
1444        centers: Array2<f64>,
1445        length_scale: f64,
1446        periodic: Option<Vec<Option<f64>>>,
1447        nu: MaternNu,
1448        include_intercept: bool,
1449        identifiability_transform: Option<Array2<f64>>,
1450        /// Uniform coordinate scale used for isotropic input standardization.
1451        input_scale: crate::IsotropicScale,
1452        /// Per-axis anisotropy log-scales η_a for geometric anisotropy.
1453        /// When Some, distance is r = √(Σ_a exp(2η_a) · (x_a - c_a)²).
1454        aniso_log_scales: Option<Vec<f64>>,
1455    },
1456    Duchon {
1457        centers: Array2<f64>,
1458        length_scale: Option<f64>,
1459        periodic: Option<Vec<Option<f64>>>,
1460        power: f64,
1461        nullspace_order: DuchonNullspaceOrder,
1462        identifiability_transform: Option<Array2<f64>>,
1463        /// Uniform coordinate scale used for isotropic input standardization.
1464        input_scale: crate::IsotropicScale,
1465        /// Per-axis anisotropy log-scales η_a, stored for prediction.
1466        aniso_log_scales: Option<Vec<f64>>,
1467        /// Support points used to build the active lower-order operator
1468        /// penalties (mass/tension/stiffness). Stored so runtime adaptive
1469        /// caches can rebuild the exact same operator rows instead of guessing
1470        /// from centers.
1471        operator_collocation_points: Option<Array2<f64>>,
1472        /// Data-metric radial reparameterization `V` (#1355). When `Some`, the
1473        /// constrained kernel transform is folded to `Z·V` so predict-time and
1474        /// κ-trial rebuilds replay the exact fit-time rotated radial basis.
1475        /// `None` on the lazy/streaming path (original constrained basis).
1476        radial_reparam: Option<Array2<f64>>,
1477    },
1478    Pca {
1479        feature_cols: Vec<usize>,
1480        basis_matrix: Array2<f64>,
1481        centered: bool,
1482        smooth_penalty: f64,
1483        center_mean: Option<Array1<f64>>,
1484        pca_basis_path: Option<std::path::PathBuf>,
1485        chunk_size: usize,
1486    },
1487    TensorBSpline {
1488        feature_cols: Vec<usize>,
1489        knots: Vec<Array1<f64>>,
1490        degrees: Vec<usize>,
1491        periods: Vec<Option<f64>>,
1492        /// Per-margin flag: `true` when that margin is a natural cubic
1493        /// regression spline (`NaturalCubicRegression` knotspec) rather than an
1494        /// open/periodic B-spline (#1074). Persisted so the tensor freeze
1495        /// rebuilds the cr marginal knotspec (value-at-knot) instead of an open
1496        /// `Provided(knots)` B-spline, keeping predict-time marginals identical
1497        /// to the fit-time cr margins. Defaults to all-`false` (legacy B-spline
1498        /// tensors) when deserialized from an older persisted model (the
1499        /// older-model default is applied on the persisted `SmoothBasisSpec`
1500        /// side; `BasisMetadata` itself is transient builder output and is not
1501        /// serde-serialized, so it carries no `#[serde]` attributes).
1502        is_cr: Vec<bool>,
1503        identifiability_transform: Option<Array2<f64>>,
1504    },
1505    SphereHarmonics {
1506        max_degree: usize,
1507        radians: bool,
1508    },
1509    /// Wrap an inner basis metadata to record a multiplicative `by` (continuous or
1510    /// factor) along a column of the dataset.
1511    BySmooth {
1512        inner: Box<BasisMetadata>,
1513        by_col: usize,
1514        levels: Option<Vec<u64>>,
1515        ordered: bool,
1516    },
1517    /// Factor-by-smooth (mgcv-style `s(x, by=g, bs="fs"|"sz"|"re")`).
1518    FactorSmooth {
1519        continuous_cols: Vec<usize>,
1520        group_col: usize,
1521        knots: Array1<f64>,
1522        degree: usize,
1523        periodic: Option<(f64, f64, usize)>,
1524        group_levels: Vec<u64>,
1525        flavour: String,
1526        /// `true` when the per-level marginal is a cubic regression spline
1527        /// (`NaturalCubicRegression` knotspec, mgcv's `bs="sz"` default marginal,
1528        /// #1074). Predict-time freeze must then restore a cr knotspec from the
1529        /// stored value-knots rather than treating them as a B-spline knot
1530        /// vector. Defaults to `false` (B-spline marginal) for backward compat.
1531        marginal_is_cr: bool,
1532    },
1533}
1534
1535/// Standardized basis build result for engine-level composition.
1536#[derive(Clone)]
1537pub struct BasisBuildResult {
1538    pub design: DesignMatrix,
1539    /// Fixed row-wise contribution carried by an affine basis chart.
1540    ///
1541    /// Ordinary bases are linear in their fitted coefficients and leave this
1542    /// as `None`. An inhomogeneous boundary condition, such as a non-zero
1543    /// B-spline endpoint anchor, realizes the basis as
1544    /// `offset(x) + design(x) * beta`; the known `offset(x)` belongs here, not
1545    /// in a fake coefficient column. Term-collection assembly sums these
1546    /// channels and routes the result through the model's ordinary likelihood
1547    /// offset at fit and prediction time.
1548    pub affine_offset: Option<Array1<f64>>,
1549    /// Canonical active penalties. Matrix, spectral metadata, operator form,
1550    /// and semantic identity are one record so dropping an earlier candidate
1551    /// cannot shift one channel without shifting all of them.
1552    pub active_penalties: Vec<ActivePenalty>,
1553    /// Candidate diagnostics excluded from the active smoothing-parameter
1554    /// layout. Dropped candidates never share a positional container with
1555    /// active matrices.
1556    pub dropped_penalties: Vec<DroppedPenaltyInfo>,
1557    pub metadata: BasisMetadata,
1558    /// Optional factored rowwise-Kronecker representation for tensor-product
1559    /// bases. When present, downstream code can keep the design operator-backed
1560    /// instead of forcing a fully materialized `n x prod(q_j)` block.
1561    pub kronecker_factored: Option<KroneckerFactoredBasis>,
1562    /// Joint-null absorption rotation for this basis, when the basis carries
1563    /// any penalties with a non-trivial joint null space.
1564    ///
1565    /// `Some(rotation)` records `Q = [U_range | U_null]` where `U_null` spans
1566    /// the joint null space `null(Σ_k S_k)` over this basis's active
1567    /// penalties (unscaled — the structural joint null is independent of
1568    /// `λ`). After the basis pipeline applies this rotation, the design
1569    /// becomes `X · Q` and each penalty becomes `Qᵀ S_k Q`, block-diagonal
1570    /// with a guaranteed-zero null tail. The same `Q` must be replayed at
1571    /// prediction time, so it is persisted in the fitted model. `None`
1572    /// indicates either no penalties on this basis, or a full-rank joint
1573    /// penalty (joint nullity = 0). A `Some` value is never recorded with
1574    /// `joint_nullity == 0` — the `None` discriminant is canonical for
1575    /// "nothing to absorb".
1576    ///
1577    /// Stage-2 commit A: this field is plumbed into the struct but neither
1578    /// computed nor applied yet. Stage-2 commit B populates it; Stage-2
1579    /// commit D applies the rotation to `design` and `penalties`.
1580    pub joint_null_rotation: Option<JointNullRotation>,
1581}
1582
1583/// Joint-null absorption rotation, attached to a smooth's basis when the
1584/// basis's joint penalty `Σ_k S_k` has a non-trivial null space.
1585///
1586/// The `rotation` field stores the orthonormal eigenvector matrix
1587/// `Q = [U_range | U_null]` of the symmetric joint penalty: the first
1588/// `range_dim = rotation.ncols() - joint_nullity` columns span
1589/// `range(Σ_k S_k)`; the remaining `joint_nullity` columns span
1590/// `null(Σ_k S_k)`. After the pipeline applies the rotation, the smooth's
1591/// coefficient vector satisfies `β = Q · γ`, the design becomes `X · Q`,
1592/// and each per-block penalty `S_k` becomes `Qᵀ S_k Q`, which is guaranteed
1593/// block-diagonal with a zero `(joint_nullity × joint_nullity)` tail
1594/// (because the joint null annihilates every active `S_k`).
1595#[derive(Clone, Serialize, Deserialize)]
1596pub struct JointNullRotation {
1597    /// `(p_smooth × p_smooth)` orthonormal matrix; range columns first,
1598    /// joint-null columns last.
1599    pub rotation: Array2<f64>,
1600    /// Number of columns at the tail of `rotation` that span the joint
1601    /// null space. Always `> 0` when wrapped in `Some` — the value `0`
1602    /// is encoded as `None`.
1603    pub joint_nullity: usize,
1604}
1605
1606impl std::fmt::Debug for JointNullRotation {
1607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1608        f.debug_struct("JointNullRotation")
1609            .field(
1610                "rotation",
1611                &format_args!("{}×{}", self.rotation.nrows(), self.rotation.ncols()),
1612            )
1613            .field("joint_nullity", &self.joint_nullity)
1614            .finish()
1615    }
1616}
1617
1618impl std::fmt::Debug for BasisBuildResult {
1619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1620        f.debug_struct("BasisBuildResult")
1621            .field("design", &self.design)
1622            .field(
1623                "affine_offset_len",
1624                &self.affine_offset.as_ref().map(|offset| offset.len()),
1625            )
1626            .field("active_penalties", &self.active_penalties)
1627            .field("dropped_penalties", &self.dropped_penalties)
1628            .field("metadata", &self.metadata)
1629            .field("kronecker_factored", &self.kronecker_factored)
1630            .field("joint_null_rotation", &self.joint_null_rotation)
1631            .finish()
1632    }
1633}
1634
1635/// Factored tensor-product basis metadata for operator-backed downstream use.
1636#[derive(Debug)]
1637pub struct KroneckerFactoredBasis {
1638    /// Marginal design matrices: `marginal_designs[j]` is `(n, q_j)`.
1639    pub marginal_designs: Vec<Array2<f64>>,
1640    /// Marginal penalty matrices: `marginal_penalties[k]` is `(q_k, q_k)`.
1641    pub marginal_penalties: Vec<Array2<f64>>,
1642    /// Marginal basis dimensions: `[q_0, ..., q_{d-1}]`.
1643    pub marginal_dims: Vec<usize>,
1644    /// Whether the system includes a global ridge (double) penalty.
1645    pub has_double_penalty: bool,
1646    /// λ-invariant tensor structure (marginal eigensystems, reparameterized
1647    /// marginals, shrinkage scale), memoized once per fit. The marginal
1648    /// designs/penalties are fixed for the whole fit, so the expensive marginal
1649    /// `eigh()` and `B_k·U_k` GEMMs only need to run once — every outer REML
1650    /// iterate (50+ on the #1082 tensor cases) then reuses this. Filled lazily
1651    /// on first use via [`Self::invariant_structure`]. NOT serialized and reset
1652    /// to empty on `Clone` (it is purely a within-fit performance cache; a fresh
1653    /// owner recomputes on first demand, keeping every result bit-identical).
1654    invariant: std::sync::OnceLock<std::sync::Arc<crate::kronecker::KroneckerInvariantStructure>>,
1655}
1656
1657impl Clone for KroneckerFactoredBasis {
1658    fn clone(&self) -> Self {
1659        Self {
1660            marginal_designs: self.marginal_designs.clone(),
1661            marginal_penalties: self.marginal_penalties.clone(),
1662            marginal_dims: self.marginal_dims.clone(),
1663            has_double_penalty: self.has_double_penalty,
1664            // Propagate the memoized structure when present so a clone made
1665            // mid-fit keeps the hoist; otherwise start empty (recomputed on
1666            // first demand, identical result).
1667            invariant: match self.invariant.get() {
1668                Some(s) => {
1669                    let cell = std::sync::OnceLock::new();
1670                    cell.get_or_init(|| std::sync::Arc::clone(s));
1671                    cell
1672                }
1673                None => std::sync::OnceLock::new(),
1674            },
1675        }
1676    }
1677}
1678
1679impl KroneckerFactoredBasis {
1680    /// Construct from the fixed marginal data with an empty invariant cache.
1681    pub fn new(
1682        marginal_designs: Vec<Array2<f64>>,
1683        marginal_penalties: Vec<Array2<f64>>,
1684        marginal_dims: Vec<usize>,
1685        has_double_penalty: bool,
1686    ) -> Self {
1687        Self {
1688            marginal_designs,
1689            marginal_penalties,
1690            marginal_dims,
1691            has_double_penalty,
1692            invariant: std::sync::OnceLock::new(),
1693        }
1694    }
1695
1696    /// Lazily compute (once) and return the λ-invariant tensor structure
1697    /// (marginal eigensystems, reparameterized marginals, shrinkage scale).
1698    ///
1699    /// Computed from the fixed marginal designs/penalties, so the result is the
1700    /// same on every call within a fit; the first call pays the `eigh()` cost
1701    /// and every later call is a pointer load. Because the cache is keyed on the
1702    /// fixed marginal data and `marginal_penalties`/`marginal_designs` are
1703    /// immutable for the fit's lifetime, no invalidation is needed.
1704    pub fn invariant_structure(
1705        &self,
1706    ) -> Result<std::sync::Arc<crate::kronecker::KroneckerInvariantStructure>, BasisError> {
1707        // Fast path: already memoized.
1708        if let Some(s) = self.invariant.get() {
1709            return Ok(std::sync::Arc::clone(s));
1710        }
1711        // Compute outside the cell (fallible) and install via `get_or_init`. If a
1712        // concurrent racer already won, `get_or_init` drops our `computed` and
1713        // returns the stored one; either way the value is the unique function of
1714        // the fixed marginal data, so the returned Arc is correct.
1715        let computed = std::sync::Arc::new(crate::kronecker::KroneckerInvariantStructure::compute(
1716            &self.marginal_designs,
1717            &self.marginal_penalties,
1718            &self.marginal_dims,
1719        )?);
1720        let installed = self.invariant.get_or_init(|| computed);
1721        Ok(std::sync::Arc::clone(installed))
1722    }
1723}
1724
1725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1726pub enum PenaltySource {
1727    Primary,
1728    DoublePenaltyNullspace,
1729    OperatorMass,
1730    OperatorTension,
1731    OperatorStiffness,
1732    /// One per input axis `a` of a multivariate Duchon smooth: the gradient
1733    /// energy along axis `a`, `Σ(∂f/∂x_a)²`, each with its own REML λ_a. REML
1734    /// shrinks an axis's contribution toward flat only when it does not earn
1735    /// its keep — penalty-based ARD / variable relevance, the replacement for
1736    /// brittle kernel-η optimization. Emitted when `scale_dims` is on.
1737    OperatorRelevance {
1738        axis: usize,
1739    },
1740    TensorMarginal {
1741        dim: usize,
1742    },
1743    TensorSeparable {
1744        penalized_margins: Vec<usize>,
1745    },
1746    TensorGlobalRidge,
1747    Other(String),
1748}
1749
1750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1751pub enum PenaltyDropReason {
1752    ZeroMatrix,
1753    NumericalRankZero,
1754}
1755
1756fn default_normalization_scale() -> f64 {
1757    1.0
1758}
1759
1760/// Metadata for one retained penalty coordinate.
1761///
1762/// This type is active-only by construction. In particular it has no
1763/// `active` flag or optional drop reason: those fields allowed a metadata
1764/// position to exist without a corresponding matrix and made positional
1765/// indexing silently wrong after an earlier candidate was dropped.
1766#[derive(Debug, Clone, Serialize, Deserialize)]
1767pub struct ActivePenaltyInfo {
1768    pub source: PenaltySource,
1769    pub original_index: usize,
1770    pub effective_rank: usize,
1771    #[serde(default = "default_normalization_scale")]
1772    pub normalization_scale: f64,
1773    /// Kronecker factors preserved from tensor penalty construction.
1774    /// When present, spectral decomposition can use per-factor eigendecomposition.
1775    #[serde(skip)]
1776    pub kronecker_factors: Option<Vec<Array2<f64>>>,
1777    /// Structural null frame carried from the candidate's
1778    /// [`ConstructiveQuadratic`] (see
1779    /// [`ConstructiveQuadratic::with_structural_null_frame`]): the declared
1780    /// null space of the seminorm this penalty represents, in the penalty's
1781    /// own coefficient chart. Downstream rebuilds
1782    /// (`rebuild_metric_consistent_ridge` at the term-collection chokepoint)
1783    /// re-attach it so the double-penalty topology stays a carried theorem
1784    /// through every chart instead of a per-chart rank measurement (#2445).
1785    /// Runtime-only, like `kronecker_factors`: a frozen replay rebuilds it
1786    /// from the basis factory, which is the single source.
1787    #[serde(skip)]
1788    pub structural_null_frame: Option<Array2<f64>>,
1789}
1790
1791/// Diagnostic for one penalty candidate excluded from the optimizer layout.
1792/// It is intentionally a different type from [`ActivePenaltyInfo`] so a
1793/// dropped record cannot be used as an active matrix index.
1794#[derive(Debug, Clone, Serialize, Deserialize)]
1795pub struct DroppedPenaltyInfo {
1796    pub source: PenaltySource,
1797    pub original_index: usize,
1798    pub reason: PenaltyDropReason,
1799    #[serde(default = "default_normalization_scale")]
1800    pub normalization_scale: f64,
1801}
1802
1803/// One atomic active penalty identity.
1804///
1805/// Every field describes the same retained candidate. Consumers may reorder,
1806/// transform, or remove a penalty only by moving the whole record, which makes
1807/// matrix/role/nullity/operator skew unrepresentable.
1808#[derive(Clone)]
1809pub struct ActivePenalty {
1810    pub matrix: Array2<f64>,
1811    pub nullity: usize,
1812    pub null_eigenvectors: Option<Array2<f64>>,
1813    pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
1814    pub info: ActivePenaltyInfo,
1815}
1816
1817impl std::fmt::Debug for ActivePenalty {
1818    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1819        f.debug_struct("ActivePenalty")
1820            .field(
1821                "matrix",
1822                &format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
1823            )
1824            .field("nullity", &self.nullity)
1825            .field(
1826                "null_eigenvectors",
1827                &self
1828                    .null_eigenvectors
1829                    .as_ref()
1830                    .map(|basis| format!("{}×{}", basis.nrows(), basis.ncols())),
1831            )
1832            .field("op_dim", &self.op.as_ref().map(|op| op.dim()))
1833            .field("info", &self.info)
1834            .finish()
1835    }
1836}
1837
1838#[derive(Debug, Clone)]
1839pub struct FilteredPenalties {
1840    pub active: Vec<ActivePenalty>,
1841    pub dropped: Vec<DroppedPenaltyInfo>,
1842}
1843
1844/// A positive-semidefinite quadratic with a construction witness.
1845///
1846/// `factor` is the authoritative representation: for coefficients `β`, the
1847/// penalty is `‖factor · β‖²`, hence its dense matrix is
1848/// `factorᵀ factor` by construction.  The cached dense matrix exists only for
1849/// consumers that require it; rank/null-space logic must use the factor and
1850/// must never attempt to recover PSD provenance from signed eigenvalues of a
1851/// rounded dense congruence (#2318).
1852#[derive(Clone)]
1853pub struct ConstructiveQuadratic {
1854    factor: Array2<f64>,
1855    matrix: Array2<f64>,
1856    /// Orthonormal basis, in this quadratic's own coefficient chart, for the
1857    /// null space of the seminorm this matrix REPRESENTS — as opposed to the
1858    /// numerical null space of the matrix itself, which can differ by a
1859    /// deliberate conditioning term (#2445: the Duchon affine native ridge is
1860    /// `√ε`-relative and sits within a decade of the spectral rank cutoff, so
1861    /// a rank test on the shipped matrix decides penalty TOPOLOGY by the
1862    /// Gram's conditioning). `None` means "no structural declaration; a
1863    /// consumer that needs the null space must measure it". `Some` with zero
1864    /// columns is a declaration that the seminorm is structurally full rank.
1865    structural_null_frame: Option<Array2<f64>>,
1866}
1867
1868impl ConstructiveQuadratic {
1869    /// Construct directly from an energy factor `A`, representing `AᵀA`.
1870    pub fn from_energy_factor(factor: Array2<f64>, context: &str) -> Result<Self, BasisError> {
1871        if factor.iter().any(|value| !value.is_finite()) {
1872            crate::bail_invalid_basis!(
1873                "{context}: constructive penalty factor contains a non-finite value"
1874            );
1875        }
1876        let matrix = fast_ata(&factor);
1877        if matrix.iter().any(|value| !value.is_finite()) {
1878            crate::bail_invalid_basis!("{context}: constructive penalty Gram is not representable");
1879        }
1880        Ok(Self {
1881            factor,
1882            matrix,
1883            structural_null_frame: None,
1884        })
1885    }
1886
1887    /// Declare the structural null frame of the represented seminorm (see the
1888    /// field doc). The frame is a carried certificate (#2427): the factory
1889    /// that BUILT the seminorm knows its null space as a theorem (Duchon's
1890    /// polynomial block), and consumers that decide topology
1891    /// ([`crate::basis::rebuild_metric_consistent_ridge`]) consume the
1892    /// declaration instead of re-deriving it from a rank test on a matrix
1893    /// that deliberately contains a conditioning term.
1894    pub fn with_structural_null_frame(
1895        mut self,
1896        frame: Array2<f64>,
1897        context: &str,
1898    ) -> Result<Self, BasisError> {
1899        if frame.nrows() != self.matrix.nrows() {
1900            crate::bail_dim_basis!(
1901                "{context}: structural null frame has {} rows but the quadratic chart has {}",
1902                frame.nrows(),
1903                self.matrix.nrows()
1904            );
1905        }
1906        if frame.iter().any(|value| !value.is_finite()) {
1907            crate::bail_invalid_basis!("{context}: structural null frame is not finite");
1908        }
1909        // Orthonormality is what makes the congruence transport below exact.
1910        let gram = fast_ata(&frame);
1911        for row in 0..gram.nrows() {
1912            for col in 0..gram.ncols() {
1913                let expected = if row == col { 1.0 } else { 0.0 };
1914                if (gram[[row, col]] - expected).abs() > 1e-8 {
1915                    crate::bail_invalid_basis!(
1916                        "{context}: structural null frame is not orthonormal \
1917                         (FᵀF deviates by {:.3e} at [{row},{col}])",
1918                        (gram[[row, col]] - expected).abs()
1919                    );
1920                }
1921            }
1922        }
1923        self.structural_null_frame = Some(frame);
1924        Ok(self)
1925    }
1926
1927    /// The declared structural null frame, if any (see
1928    /// [`Self::with_structural_null_frame`]).
1929    pub fn structural_null_frame(&self) -> Option<&Array2<f64>> {
1930        self.structural_null_frame.as_ref()
1931    }
1932
1933    /// The declared frame restricted to the coefficient block `[lo, hi)`,
1934    /// or `None` when no frame is declared or the frame has support outside
1935    /// the block (in which case the block does not own the null space and a
1936    /// consumer must fall back to measuring).
1937    pub fn structural_null_frame_block(&self, lo: usize, hi: usize) -> Option<Array2<f64>> {
1938        let frame = self.structural_null_frame.as_ref()?;
1939        if lo >= hi || hi > frame.nrows() {
1940            return None;
1941        }
1942        let outside = frame
1943            .rows()
1944            .into_iter()
1945            .enumerate()
1946            .filter(|(row, _)| *row < lo || *row >= hi)
1947            .flat_map(|(_, row)| row.to_vec())
1948            .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1949        if outside > 1e-12 {
1950            return None;
1951        }
1952        Some(frame.slice(s![lo..hi, ..]).to_owned())
1953    }
1954
1955    /// Checked bridge for legacy dense factories that already produce a PSD
1956    /// function quadratic but do not yet expose their native energy factor.
1957    ///
1958    /// This is deliberately fallible and reconstructs a factor from the
1959    /// canonical range spectrum. Material negative curvature is rejected; a
1960    /// caller can no longer place an unchecked `Array2` in a
1961    /// [`PenaltyCandidate`]. New factories should use
1962    /// [`Self::from_energy_factor`] so PSD is true by construction rather than
1963    /// inferred after dense assembly.
1964    pub fn try_from_dense_psd(dense: Array2<f64>, context: &str) -> Result<Self, BasisError> {
1965        if dense.nrows() != dense.ncols() {
1966            crate::bail_dim_basis!(
1967                "{context}: dense penalty must be square, got {}x{}",
1968                dense.nrows(),
1969                dense.ncols()
1970            );
1971        }
1972        if dense.iter().any(|value| !value.is_finite()) {
1973            crate::bail_invalid_basis!("{context}: dense penalty contains a non-finite value");
1974        }
1975        if dense.nrows() == 0 {
1976            return Self::from_energy_factor(Array2::zeros((0, 0)), context);
1977        }
1978        let sym = symmetrize_penalty(&dense);
1979        let (evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
1980        let tolerance = spectral_tolerance(&sym, &evals);
1981        if let Some(&negative) = evals.iter().find(|&&value| value < -tolerance) {
1982            return Err(BasisError::IndefinitePenalty {
1983                context: context.to_string(),
1984                min_eigenvalue: negative,
1985                tolerance,
1986                guidance: "supply the native energy factor for a PSD function penalty; negative curvature is not a penalty null direction".to_string(),
1987            });
1988        }
1989        let positive: Vec<usize> = evals
1990            .iter()
1991            .enumerate()
1992            .filter_map(|(index, &value)| (value > tolerance).then_some(index))
1993            .collect();
1994        let mut factor = Array2::<f64>::zeros((positive.len(), dense.nrows()));
1995        for (row, index) in positive.into_iter().enumerate() {
1996            let scale = evals[index].sqrt();
1997            for column in 0..dense.nrows() {
1998                factor[[row, column]] = scale * evecs[[column, index]];
1999            }
2000        }
2001        Self::from_energy_factor(factor, context)
2002    }
2003
2004    /// The authoritative rectangular energy factor.
2005    pub fn factor(&self) -> &Array2<f64> {
2006        &self.factor
2007    }
2008
2009    /// Dense materialization `AᵀA` for consumers that require a matrix.
2010    pub fn dense(&self) -> &Array2<f64> {
2011        &self.matrix
2012    }
2013
2014    /// Consume this quadratic and return its dense materialization.
2015    pub fn into_dense(self) -> Array2<f64> {
2016        self.matrix
2017    }
2018
2019    /// Apply a coefficient gauge to the factor, preserving PSD by
2020    /// construction instead of multiplying the rounded dense Gram twice.
2021    ///
2022    /// A declared structural null frame is transported through the same
2023    /// congruence: for the (single-block) transform `T`, the structural null
2024    /// space of `TᵀST` is `{γ : Tγ ∈ span(F)} = null((I − FFᵀ)T)`, which is a
2025    /// well-conditioned computation on orthonormal inputs — the rank decision
2026    /// has O(1) principal-angle gaps, never the conditioning of `S` (#2445).
2027    pub fn restricted(
2028        &self,
2029        gauge: &gam_problem::Gauge,
2030        context: &str,
2031    ) -> Result<Self, BasisError> {
2032        let mut out =
2033            Self::from_energy_factor(gauge.restrict_quadratic_factor(&self.factor), context)?;
2034        if let Some(frame) = self.structural_null_frame.as_ref() {
2035            if gauge.n_blocks() == 1 {
2036                let transform = gauge.block_transform(0);
2037                out.structural_null_frame = transport_structural_null_frame(frame, &transform);
2038            }
2039            // Multi-block gauges do not arise on the paths that declare
2040            // frames; dropping the declaration is always safe (consumers
2041            // fall back to measuring), inventing one is not.
2042        }
2043        Ok(out)
2044    }
2045
2046    /// Multiply the represented quadratic by a finite non-negative scalar.
2047    pub fn scaled(&self, scale: f64, context: &str) -> Result<Self, BasisError> {
2048        if !scale.is_finite() || scale < 0.0 {
2049            crate::bail_invalid_basis!(
2050                "{context}: constructive penalty scale must be finite and non-negative, got {scale}"
2051            );
2052        }
2053        let root = scale.sqrt();
2054        let mut out = Self::from_energy_factor(self.factor.mapv(|value| value * root), context)?;
2055        // A positive rescale does not move the null space; scaling to exactly
2056        // zero collapses the seminorm and voids the declaration.
2057        if scale > 0.0 {
2058            out.structural_null_frame = self.structural_null_frame.clone();
2059        }
2060        Ok(out)
2061    }
2062
2063    /// Sum PSD quadratics by vertically concatenating their energy factors.
2064    pub fn sum(terms: &[Self], context: &str) -> Result<Self, BasisError> {
2065        let coefficient_dim = terms.first().map(|term| term.factor.ncols()).unwrap_or(0);
2066        if terms
2067            .iter()
2068            .any(|term| term.factor.ncols() != coefficient_dim)
2069        {
2070            crate::bail_dim_basis!(
2071                "{context}: constructive penalty sum has inconsistent coefficient dimensions"
2072            );
2073        }
2074        let rows = terms.iter().map(|term| term.factor.nrows()).sum();
2075        let mut factor = Array2::<f64>::zeros((rows, coefficient_dim));
2076        let mut start = 0usize;
2077        for term in terms {
2078            let end = start + term.factor.nrows();
2079            factor.slice_mut(s![start..end, ..]).assign(&term.factor);
2080            start = end;
2081        }
2082        Self::from_energy_factor(factor, context)
2083    }
2084
2085    /// The exact zero quadratic on a coefficient chart of `dimension`.
2086    pub fn zero(dimension: usize) -> Self {
2087        Self {
2088            factor: Array2::zeros((0, dimension)),
2089            matrix: Array2::zeros((dimension, dimension)),
2090            structural_null_frame: None,
2091        }
2092    }
2093}
2094
2095/// Transport a structural null frame through an injective coefficient
2096/// transform `T` (raw → reduced): the structural null space of `TᵀST` is
2097/// `{γ : Tγ ∈ span(F)} = null((I − FFᵀ)T)`, computed with a rank-revealing QR
2098/// on orthonormal inputs. Returns `Some` with possibly zero columns (a
2099/// structural "no null space survives the chart" is a valid declaration);
2100/// `None` only when the shapes cannot compose or the factorization fails, in
2101/// which case the declaration is dropped rather than guessed.
2102fn transport_structural_null_frame(
2103    frame: &Array2<f64>,
2104    transform: &Array2<f64>,
2105) -> Option<Array2<f64>> {
2106    if transform.nrows() != frame.nrows() {
2107        return None;
2108    }
2109    let projected = transform - &frame.dot(&frame.t().dot(transform));
2110    // `rrqr_nullspace_basis(a)` returns an orthonormal basis of `null(aᵀ)`,
2111    // so pass `projectedᵀ` to obtain `null(projected)` over the reduced
2112    // coordinates. Machine-precision cutoff: the singular values here are
2113    // sines of principal angles between orthonormal frames, so the rank gap
2114    // is O(1) unless the chart genuinely grazes the subspace.
2115    gam_linalg::faer_ndarray::rrqr_nullspace_basis(&projected.t().to_owned(), 1.0)
2116        .ok()
2117        .map(|(null, _)| null)
2118}
2119
2120impl std::fmt::Debug for ConstructiveQuadratic {
2121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2122        f.debug_struct("ConstructiveQuadratic")
2123            .field(
2124                "factor",
2125                &format_args!("{}×{}", self.factor.nrows(), self.factor.ncols()),
2126            )
2127            .field(
2128                "matrix",
2129                &format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
2130            )
2131            .field(
2132                "structural_null_frame",
2133                &self
2134                    .structural_null_frame
2135                    .as_ref()
2136                    .map(|frame| format!("{}×{}", frame.nrows(), frame.ncols())),
2137            )
2138            .finish()
2139    }
2140}
2141
2142impl std::ops::Deref for ConstructiveQuadratic {
2143    type Target = Array2<f64>;
2144
2145    fn deref(&self) -> &Self::Target {
2146        &self.matrix
2147    }
2148}
2149
2150#[derive(Clone)]
2151pub struct PenaltyCandidate {
2152    /// Constructive PSD quadratic. Raw dense matrices cannot inhabit a
2153    /// candidate without passing through a checked constructor.
2154    pub matrix: ConstructiveQuadratic,
2155    pub source: PenaltySource,
2156    pub normalization_scale: f64,
2157    /// Optional Kronecker factors whose product equals `matrix`.
2158    /// When present, spectral decomposition can be done per-factor
2159    /// (O(Σ q_j³) instead of O((Π q_j)³)).
2160    pub kronecker_factors: Option<Vec<Array2<f64>>>,
2161    /// Optional operator-form handle whose `as_dense()` matches `matrix`. When
2162    /// populated by the closed-form factories, this is propagated through to
2163    /// `CanonicalPenaltyBlock` so downstream consumers can use exact matvec
2164    /// algebra without rebuilding the dense Gram. When `None`, only the dense
2165    /// `matrix` path is available.
2166    pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
2167}
2168
2169impl std::fmt::Debug for PenaltyCandidate {
2170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2171        f.debug_struct("PenaltyCandidate")
2172            .field(
2173                "matrix",
2174                &format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
2175            )
2176            .field("source", &self.source)
2177            .field("normalization_scale", &self.normalization_scale)
2178            .field(
2179                "kronecker_factors",
2180                &self.kronecker_factors.as_ref().map(|v| v.len()),
2181            )
2182            .field("op", &self.op.as_ref().map(|o| o.dim()))
2183            .finish()
2184    }
2185}
2186
2187#[derive(Clone)]
2188pub struct CanonicalPenaltyBlock {
2189    pub sym_penalty: Array2<f64>,
2190    /// Eigenvalues from spectral decomposition (retained to avoid recomputation).
2191    pub eigenvalues: Array1<f64>,
2192    /// Eigenvectors from spectral decomposition (retained to avoid recomputation).
2193    pub eigenvectors: Array2<f64>,
2194    pub rank: usize,
2195    pub nullity: usize,
2196    /// Number of genuine negative-curvature eigendirections (`ev < -tol`).
2197    /// A non-PSD penalty has `negative_dim > 0`; these directions are
2198    /// neither range nor null and are never absorbed as unpenalized (#1425).
2199    pub negative_dim: usize,
2200    pub tol: f64,
2201    pub iszero: bool,
2202    /// Optional operator-form handle that is bit-equivalent to `sym_penalty`.
2203    /// Propagated from `PenaltyCandidate.op` when present so downstream
2204    /// consumers can use matvec without rebuilding the dense Gram.
2205    pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
2206}
2207
2208impl std::fmt::Debug for CanonicalPenaltyBlock {
2209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2210        f.debug_struct("CanonicalPenaltyBlock")
2211            .field(
2212                "sym_penalty",
2213                &format_args!("{}×{}", self.sym_penalty.nrows(), self.sym_penalty.ncols()),
2214            )
2215            .field("eigenvalues", &self.eigenvalues)
2216            .field(
2217                "eigenvectors",
2218                &format_args!(
2219                    "{}×{}",
2220                    self.eigenvectors.nrows(),
2221                    self.eigenvectors.ncols()
2222                ),
2223            )
2224            .field("rank", &self.rank)
2225            .field("nullity", &self.nullity)
2226            .field("negative_dim", &self.negative_dim)
2227            .field("tol", &self.tol)
2228            .field("iszero", &self.iszero)
2229            .field("op", &self.op.as_ref().map(|o| o.dim()))
2230            .finish()
2231    }
2232}
2233
2234#[derive(Debug)]
2235pub struct BasisPsiDerivativeResult {
2236    pub design_derivative: Array2<f64>,
2237    pub penalties_derivative: Vec<Array2<f64>>,
2238    /// Operator-backed design derivative for standalone first-derivative
2239    /// callers. Bundled first+second callers receive the shared operator on
2240    /// `BasisPsiDerivativeBundle` instead.
2241    pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
2242}
2243
2244#[derive(Debug)]
2245pub struct BasisPsiSecondDerivativeResult {
2246    pub designsecond_derivative: Array2<f64>,
2247    pub penaltiessecond_derivative: Vec<Array2<f64>>,
2248    /// Operator-backed design derivative for standalone second-derivative
2249    /// callers. Bundled first+second callers receive the shared operator on
2250    /// `BasisPsiDerivativeBundle` instead.
2251    pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
2252}
2253
2254#[derive(Debug)]
2255pub struct BasisPsiDerivativeBundle {
2256    pub first: BasisPsiDerivativeResult,
2257    pub second: BasisPsiSecondDerivativeResult,
2258    /// Shared operator-backed design derivative for the first and second
2259    /// psi derivatives. Bundled callers consume this once instead of storing
2260    /// duplicate materialized/streaming operators in both derivative payloads.
2261    pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
2262}
2263
2264/// Per-axis psi_a derivative package for anisotropic spatial terms.
2265///
2266/// For a d-dimensional anisotropic term, the kernel phi(r) depends on
2267/// the anisotropic distance r = |Lambda h| where Lambda = diag(kappa_a). Each axis a
2268/// has its own log-scale psi_a = log(kappa_a), yielding d first derivatives,
2269/// d diagonal second derivatives, and d*(d-1)/2 cross second derivatives.
2270///
2271/// The cross second derivative d2 phi/(d psi_a d psi_b) = t * s_a * s_b (a != b)
2272/// is rank-1, so we store the t_values and s_components vectors rather
2273/// than materializing d^2 matrices.
2274#[derive(Clone)]
2275pub struct AnisoBasisPsiDerivatives {
2276    /// d matrices, each (n x p_smooth): dX/d psi_a.
2277    pub design_first: Vec<Array2<f64>>,
2278    /// d matrices, each (n x p_smooth): d2X/d psi_a^2 (diagonal second derivatives).
2279    pub design_second_diag: Vec<Array2<f64>>,
2280    /// Cross second derivatives d2X/(d psi_a d psi_b) for a < b.
2281    pub design_second_cross: Vec<Array2<f64>>,
2282    /// Axis-pair indices corresponding to `design_second_cross`.
2283    pub design_second_cross_pairs: Vec<(usize, usize)>,
2284    /// d x num_penalties: dS_m/d psi_a for each axis a and penalty m.
2285    pub penalties_first: Vec<Vec<Array2<f64>>>,
2286    /// d x num_penalties: d2S_m/d psi_a^2 for each axis a and penalty m.
2287    pub penalties_second_diag: Vec<Vec<Array2<f64>>>,
2288    /// The (a, b) axis pairs supported by the on-demand cross-penalty
2289    /// provider. Only the upper triangle (a < b) is stored.
2290    pub penalties_cross_pairs: Vec<(usize, usize)>,
2291    /// On-demand cross-penalty second-derivative provider. Exact anisotropic
2292    /// cross-axis penalty seconds are streamed one pair at a time rather than
2293    /// stored as a dense upper triangle of blocks.
2294    pub penalties_cross_provider: Option<AnisoPenaltyCrossProvider>,
2295    /// Shared operator-backed representation of the anisotropic kernel-side
2296    /// design derivatives. When `design_first` / `design_second_diag` are empty,
2297    /// callers must use this operator directly; when they are present, this
2298    /// operator still provides exact cross-axis second derivatives without
2299    /// duplicating separate `t` / `s_a` storage layouts.
2300    pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
2301}
2302
2303#[derive(Clone)]
2304pub struct AnisoPenaltyCrossProvider(
2305    std::sync::Arc<
2306        dyn Fn(usize, usize) -> Result<Vec<Array2<f64>>, BasisError> + Send + Sync + 'static,
2307    >,
2308);
2309
2310impl AnisoPenaltyCrossProvider {
2311    pub(crate) fn new<F>(f: F) -> Self
2312    where
2313        F: Fn(usize, usize) -> Result<Vec<Array2<f64>>, BasisError> + Send + Sync + 'static,
2314    {
2315        Self(std::sync::Arc::new(f))
2316    }
2317
2318    pub fn evaluate(&self, axis_a: usize, axis_b: usize) -> Result<Vec<Array2<f64>>, BasisError> {
2319        (self.0)(axis_a, axis_b)
2320    }
2321}
2322
2323// ═══════════════════════════════════════════════════════════════════════════
2324//  Implicit derivative operator for scalable anisotropic REML gradients
2325// ═══════════════════════════════════════════════════════════════════════════
2326
2327pub(crate) const SPATIAL_CENTER_CENTER_MAX_BYTES: usize = 512 * 1024 * 1024; // 512 MiB
2328pub(crate) const DESIGN_CROSS_CHUNK_SIZE: usize = 1024;
2329
2330/// Determine whether implicit operators should be used based on problem size
2331/// and the supplied [`ResourcePolicy`].
2332///
2333/// Returns `true` when the dense materialization of D first-derivative
2334/// matrices would exceed `policy.max_single_materialization_bytes`.
2335///
2336/// For D axes with n data points and p_smooth basis columns, the dense path
2337/// allocates D * n * p_smooth * 8 bytes for first-derivative matrices alone
2338/// (plus a similar amount for second derivatives). The implicit path stores
2339/// only the compact (n * n_knots) radial jets plus (n * n_knots * D) axis
2340/// fractions, which is O(n * k * D) instead of O(n * p * D).
2341pub fn should_use_implicit_operators_with_policy(
2342    n: usize,
2343    p: usize,
2344    d: usize,
2345    policy: &gam_runtime::resource::ResourcePolicy,
2346) -> bool {
2347    // Each first-derivative matrix is (n x p) f64 → n*p*8 bytes.
2348    // We need D of them for first derivatives, D for second diag, plus
2349    // the cross-t matrix and s_components. Conservative estimate: 3*D matrices.
2350    let dense_bytes = 3usize
2351        .saturating_mul(n)
2352        .saturating_mul(p)
2353        .saturating_mul(d)
2354        .saturating_mul(8);
2355    dense_bytes > policy.max_single_materialization_bytes
2356}
2357
2358pub(crate) fn implicit_radial_cache_bytes(n: usize, k: usize, n_axes: usize) -> usize {
2359    n.saturating_mul(k)
2360        .saturating_mul(n_axes.saturating_add(3))
2361        .saturating_mul(8)
2362}
2363
2364pub(crate) fn should_cache_implicit_radial_components(
2365    n: usize,
2366    k: usize,
2367    n_axes: usize,
2368    policy: &gam_runtime::resource::ResourcePolicy,
2369) -> bool {
2370    implicit_radial_cache_bytes(n, k, n_axes) <= policy.max_operator_cache_bytes
2371}
2372
2373pub fn assert_no_dense_derivative_materialization(n: usize, p: usize, d_pc: usize) {
2374    let first = dense_design_bytes(n, p).saturating_mul(d_pc);
2375    let second = dense_design_bytes(n, p).saturating_mul(d_pc.saturating_mul(d_pc));
2376    // Consult the library default ResourcePolicy. Production large-scale runs
2377    // configure `AnalyticOperatorRequired`, which still refuses every dense
2378    // materialization here. The default `MaterializeIfSmall` mode lets tiny
2379    // problems (and small-data/test usage) materialize as long as the combined
2380    // first- and second-order dense bytes fit under the single-materialization
2381    // byte budget. `DiagnosticsOnly` is treated like `MaterializeIfSmall` for
2382    // this guard: it permits dense materialization under the same byte cap.
2383    let policy = gam_runtime::resource::ResourcePolicy::default_library();
2384    let budget = policy.max_single_materialization_bytes;
2385    let needed = first.saturating_add(second);
2386    match policy.derivative_storage_mode {
2387        gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired => {
2388            // SAFETY: this assertion helper exists specifically to enforce
2389            // the large-scale invariant that spatial-PC Duchon derivative
2390            // designs never persist as dense `Array2<f64>` storage. When the
2391            // resource policy is `AnalyticOperatorRequired`, any caller that
2392            // reached this point has materialized something the strict
2393            // operator contract forbids.
2394            // SAFETY: AnalyticOperatorRequired forbids dense derivative materialization.
2395            panic!(
2396                "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)",
2397                first as f64 / (1024.0 * 1024.0),
2398                second as f64 / (1024.0 * 1024.0),
2399            );
2400        }
2401        gam_runtime::resource::DerivativeStorageMode::MaterializeIfSmall
2402        | gam_runtime::resource::DerivativeStorageMode::DiagnosticsOnly => {
2403            // SAFETY: exceeding the single-materialization budget here is a
2404            // contract violation by an upstream caller that must route through
2405            // the operator-backed path; failing loudly surfaces it rather than
2406            // silently materializing an oversized dense derivative design.
2407            assert!(
2408                needed <= budget,
2409                "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)",
2410                first as f64 / (1024.0 * 1024.0),
2411                second as f64 / (1024.0 * 1024.0),
2412                budget as f64 / (1024.0 * 1024.0),
2413            );
2414        }
2415    }
2416}
2417
2418pub fn assert_spatial_centers_below_large_scale_cap(
2419    d_pc: usize,
2420    centers: ArrayView2<'_, f64>,
2421) -> Result<(), BasisError> {
2422    if centers.ncols() != d_pc {
2423        crate::bail_dim_basis!(
2424            "spatial PC center dimension mismatch: centers have {} columns, expected {d_pc}",
2425            centers.ncols()
2426        );
2427    }
2428    let k = centers.nrows();
2429    let centers_bytes = dense_design_bytes(k, d_pc);
2430    let center_center_bytes = dense_design_bytes(k, k);
2431    if centers_bytes > SPATIAL_CENTER_CENTER_MAX_BYTES {
2432        crate::bail_invalid_basis!(
2433            "spatial PC centers exceed center storage cap: K={k}, d_pc={d_pc}, centers={:.1} MiB, cap={:.1} MiB",
2434            centers_bytes as f64 / (1024.0 * 1024.0),
2435            SPATIAL_CENTER_CENTER_MAX_BYTES as f64 / (1024.0 * 1024.0),
2436        );
2437    }
2438    if center_center_bytes > SPATIAL_CENTER_CENTER_MAX_BYTES {
2439        crate::bail_invalid_basis!(
2440            "spatial PC centers exceed center-center large-scale cap: K={k}, d_pc={d_pc}, KxK={:.1} MiB, cap={:.1} MiB",
2441            center_center_bytes as f64 / (1024.0 * 1024.0),
2442            SPATIAL_CENTER_CENTER_MAX_BYTES as f64 / (1024.0 * 1024.0),
2443        );
2444    }
2445    Ok(())
2446}
2447
2448pub(crate) fn dense_design_bytes(n: usize, p: usize) -> usize {
2449    n.saturating_mul(p)
2450        .saturating_mul(std::mem::size_of::<f64>())
2451}
2452
2453pub(crate) fn should_use_lazy_spatial_design(
2454    n: usize,
2455    p: usize,
2456    policy: &gam_runtime::resource::ResourcePolicy,
2457) -> bool {
2458    matches!(
2459        policy.derivative_storage_mode,
2460        gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired
2461    ) || dense_design_bytes(n, p) > policy.max_single_materialization_bytes
2462}
2463
2464pub(crate) fn wrap_dense_design_with_transform(
2465    design: DesignMatrix,
2466    transform: &Array2<f64>,
2467    label: &str,
2468) -> Result<DesignMatrix, BasisError> {
2469    match design {
2470        DesignMatrix::Dense(inner) => {
2471            let op = CoefficientTransformOperator::new(inner, transform.clone()).map_err(|e| {
2472                BasisError::InvalidInput(format!("{label} coefficient transform failed: {e}"))
2473            })?;
2474            Ok(DesignMatrix::Dense(
2475                gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(op)),
2476            ))
2477        }
2478        DesignMatrix::Sparse(_) => Err(BasisError::InvalidInput(format!(
2479            "{label} coefficient transform requires a dense/operator-backed design"
2480        ))),
2481    }
2482}
2483
2484/// Single-pass `(Bᵀ(W·C), BᵀB)` accumulation over the streamed design.
2485///
2486/// Materialises each row chunk of the design **once** and reuses it for both
2487/// the constraint cross `Bᵀ(W·C)` and the Gram `BᵀB`. On the lazy chunked
2488/// spatial path each `try_row_chunk` re-evaluates all kernel columns for the
2489/// chunk, so accumulating both products in a single sweep halves the per-build
2490/// kernel re-evaluation work (the dominant cost at large scale) versus two
2491/// independent streaming passes — without changing the result beyond
2492/// floating-point reassociation. The cross is masked off (`q == 0`) by the
2493/// caller, which never invokes this when there is no constraint block.
2494pub(crate) fn design_cross_and_gram(
2495    design: &DesignMatrix,
2496    constraint_matrix: ArrayView2<'_, f64>,
2497    weights: Option<ArrayView1<'_, f64>>,
2498) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
2499    let n = design.nrows();
2500    let k = design.ncols();
2501    if constraint_matrix.nrows() != n {
2502        return Err(BasisError::ConstraintMatrixRowMismatch {
2503            basisrows: n,
2504            constraintrows: constraint_matrix.nrows(),
2505        });
2506    }
2507    if let Some(w) = weights
2508        && w.len() != n
2509    {
2510        return Err(BasisError::WeightsDimensionMismatch {
2511            expected: n,
2512            found: w.len(),
2513        });
2514    }
2515    let q = constraint_matrix.ncols();
2516    let mut cross = Array2::<f64>::zeros((k, q));
2517    let mut gram = Array2::<f64>::zeros((k, k));
2518    for start in (0..n).step_by(DESIGN_CROSS_CHUNK_SIZE) {
2519        let end = (start + DESIGN_CROSS_CHUNK_SIZE).min(n);
2520        let basis_chunk = design
2521            .try_row_chunk(start..end)
2522            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
2523        let mut constraint_chunk = constraint_matrix.slice(s![start..end, ..]).to_owned();
2524        if let Some(w) = weights {
2525            for (mut row, &weight) in constraint_chunk
2526                .axis_iter_mut(Axis(0))
2527                .zip(w.slice(s![start..end]).iter())
2528            {
2529                row *= weight;
2530            }
2531        }
2532        cross += &fast_atb(&basis_chunk, &constraint_chunk);
2533        gram += &fast_atb(&basis_chunk, &basis_chunk);
2534    }
2535    Ok((cross, gram))
2536}
2537
2538pub(crate) fn positive_spectral_whitener_from_gram(
2539    gram: &Array2<f64>,
2540) -> Result<Array2<f64>, BasisError> {
2541    // Inverse-square-root for the positive part of `gram`. Eigenvalues at or
2542    // below the relative rank tolerance `α·ε·n·max_eval` are *dropped*: the
2543    // returned whitener has shape `(n × keep)` where `keep` counts strictly
2544    // positive eigendirections of `gram`.
2545    //
2546    // Dropping (rather than ridging) is what makes the result a true
2547    // square-root inverse on the column space of `gram`. This whitener is
2548    // used by `stabilized_orthogonality_transform_from_gram` to make a
2549    // pre-existing transform `K_raw` orthonormal under the W-inner product:
2550    // when some columns of `K_raw` map to zero (or near-zero) under `B`, the
2551    // constrained Gram `K_raw^T G K_raw` is rank-deficient. Ridging those
2552    // tail directions with `1/sqrt(ε)` produced spurious basis columns
2553    // whose coefficient norms blew up to `~1/sqrt(ε)` while their image in
2554    // `B` was floating-point zero, contaminating downstream linear algebra
2555    // (in particular it forced `smooth.rs` to widen the post-transform
2556    // orthogonality residual tolerance to absorb a `cond ≈ 1/sqrt(ε)`
2557    // rounding floor). Dropping these directions is the right behavior:
2558    // they contribute nothing to `B`'s column space, and removing them
2559    // tightens the orthogonality residual back down to the genuine
2560    // floating-point limit.
2561    let (eigenvalues, eigenvectors) = gram.eigh(Side::Lower).map_err(BasisError::LinalgError)?;
2562    let n = gram.nrows();
2563    let max_eval = eigenvalues.iter().copied().fold(0.0_f64, f64::max);
2564    // Scale-invariant rank tolerance: the cutoff must track the Gram's own
2565    // spectrum (`α·ε·n·max_eval`), not an absolute floor. An earlier `max_eval
2566    // .max(1.0)` clamped the reference scale to 1.0, which is only harmless when
2567    // `max_eval ≥ 1`; for a genuinely well-conditioned but small-magnitude Gram
2568    // (e.g. a Duchon hybrid whose evaluated kernel sits far below unit scale in
2569    // moderate-to-high d) it inflated the tolerance to an absolute `α·ε·n` floor
2570    // that swallows the entire — perfectly valid — spectrum, spuriously reporting
2571    // `keep == 0`. Using the true `max_eval` makes `keep` invariant to a uniform
2572    // rescaling of the Gram (which scales every eigenvalue and the cutoff
2573    // identically). The residual `.max(f64::EPSILON)` only guards the degenerate
2574    // all-zero Gram so that numerical-zero roundoff directions are still dropped.
2575    let tol =
2576        (default_rrqr_rank_alpha() * f64::EPSILON * (n.max(1) as f64) * max_eval).max(f64::EPSILON);
2577    let keep = eigenvalues.iter().filter(|&&ev| ev > tol).count();
2578    if keep == 0 {
2579        let min_ev = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
2580        return Err(BasisError::ConstraintNullspaceCollapsed {
2581            site: "positive_spectral_whitener_from_gram",
2582            cross_rank: 0,
2583            coeff_dim: gram.nrows(),
2584            cross_frobenius: gram.iter().map(|v| v * v).sum::<f64>().sqrt(),
2585            gram_spectrum: format!(
2586                "max eigenvalue {max_eval:.3e} (min {min_ev:.3e}, spectral tolerance {tol:.3e})"
2587            ),
2588        });
2589    }
2590    // `eigh` returns eigenvalues in ascending order, so the largest `keep`
2591    // eigenvalues live at the tail.
2592    let eig_start = eigenvalues.len() - keep;
2593    let kept_vectors = eigenvectors.slice(s![.., eig_start..]).to_owned();
2594    let mut inv_sqrt = Array2::<f64>::zeros((keep, keep));
2595    for (out_i, eig_i) in (eig_start..eigenvalues.len()).enumerate() {
2596        inv_sqrt[[out_i, out_i]] = 1.0 / eigenvalues[eig_i].sqrt();
2597    }
2598    Ok(fast_ab(&kept_vectors, &inv_sqrt))
2599}
2600
2601pub(crate) fn stabilized_orthogonality_transform_from_gram(
2602    gram: &Array2<f64>,
2603    transform: &Array2<f64>,
2604) -> Result<Array2<f64>, BasisError> {
2605    let constrained_gram = {
2606        let gt = fast_ab(gram, transform);
2607        fast_atb(transform, &gt)
2608    };
2609    let whitening = positive_spectral_whitener_from_gram(&constrained_gram)?;
2610    Ok(fast_ab(transform, &whitening))
2611}
2612
2613pub(crate) fn orthogonality_transform_from_cross_and_gram(
2614    constraint_cross: &Array2<f64>,
2615    gram: &Array2<f64>,
2616) -> Result<Array2<f64>, BasisError> {
2617    // Compute null(M^T) directly on M = B^T W C (k × q) via column-pivoted QR.
2618    // Working in the original k-dim coefficient space rather than first
2619    // whitening by B^T B avoids a fundamental failure mode: when B is heavily
2620    // collinear, `positive_spectral_whitener_from_gram` truncates the design
2621    // column-space to a `keep`-dim subspace, and if `keep <= q` the subsequent
2622    // nullspace search has no room — even though dim null(M^T) = k - rank(M)
2623    // ≥ k - q is always positive when k > q. The constraint nullspace is a
2624    // property of M alone; conditioning of the design only matters for the
2625    // downstream stabilization of B*K_raw.
2626    let k = constraint_cross.nrows();
2627    if k == 0 {
2628        return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
2629    }
2630    let (transform_raw, rank) = rrqr_nullspace_basis(constraint_cross, default_rrqr_rank_alpha())
2631        .map_err(BasisError::LinalgError)?;
2632    if rank >= k || transform_raw.ncols() == 0 {
2633        return Err(BasisError::ConstraintNullspaceCollapsed {
2634            site: "orthogonality_transform_from_cross_and_gram",
2635            cross_rank: rank,
2636            coeff_dim: k,
2637            cross_frobenius: constraint_cross.iter().map(|v| v * v).sum::<f64>().sqrt(),
2638            gram_spectrum: "not computed (structural cross-rank collapse: null(Mᵀ) is empty, \
2639                            so no constrained design exists to eigendecompose)"
2640                .to_string(),
2641        });
2642    }
2643
2644    // Make the constrained design B*K_raw orthonormal under the W-inner product.
2645    // If the constrained Gram K_raw^T G K_raw is rank-deficient (because some
2646    // directions in null(M^T) collapse under B), the spectral whitener drops
2647    // them — that is the right behavior: a degenerate column never contributes
2648    // to B's column space and shouldn't appear in the reparameterized basis.
2649    stabilized_orthogonality_transform_from_gram(gram, &transform_raw)
2650}
2651
2652pub fn orthogonality_transform_for_design(
2653    design: &DesignMatrix,
2654    constraint_matrix: ArrayView2<'_, f64>,
2655    weights: Option<ArrayView1<'_, f64>>,
2656) -> Result<Array2<f64>, BasisError> {
2657    let k = design.ncols();
2658    if k == 0 {
2659        return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
2660    }
2661    let q = constraint_matrix.ncols();
2662    if q == 0 {
2663        return Ok(Array2::eye(k));
2664    }
2665    // Scale every constraint column to unit (weighted) L2 norm before forming the
2666    // design/constraint cross `M = Bᵀ W C`. The downstream rank detection
2667    // (`rrqr_nullspace_basis`) decides HOW MANY parametric directions the smooth
2668    // genuinely spans by testing the pivoted magnitudes of `M` against an
2669    // essentially absolute floor `α·ε·max(k,q)` (the `max(|R₀₀|, 1)` reference
2670    // clamps to 1 whenever the cross is sub-unit). With a RAW constraint column
2671    // that floor is scale-wrong: the all-ones intercept has norm √n, so `M`
2672    // carries a √n factor while the tolerance is referenced to 1. For a
2673    // kernel/radial smooth whose realized design is already (numerically)
2674    // orthogonal to the constant, `‖Bᵀ1‖` is pure floating-point roundoff
2675    // (~ε·‖B‖·√n); the √n inflation lands it right at the floor, so a rigid
2676    // rotation of the covariates — which only perturbs that roundoff — flips the
2677    // detected rank between 0 and 1. A spurious rank 1 then removes an ARBITRARY
2678    // real smooth direction (the pivot of a noise vector), and the fitted
2679    // surface, its EDF, and the REML-selected λ all drift under rotation
2680    // (gam#1818). Measuring the design's overlap with UNIT constraint directions
2681    // turns the test into a genuine rotation-invariant cosine: a real overlap is
2682    // O(1) and always detected, while roundoff-level overlap stays consistently
2683    // below the floor (rank 0). Column scaling of `C` leaves `null(Mᵀ)` — hence
2684    // the constrained-design span and the emitted transform — unchanged wherever
2685    // the rank is unchanged; it only removes the roundoff-driven rank flip.
2686    let normalized_constraint = unit_normalize_constraint_columns(constraint_matrix, weights);
2687    let (constraint_cross, gram) =
2688        design_cross_and_gram(design, normalized_constraint.view(), weights)?;
2689    orthogonality_transform_from_cross_and_gram(&constraint_cross, &gram)
2690}
2691
2692/// Scale each column of a constraint block to unit L2 norm under the inner
2693/// product used to form the identifiability cross — the `weights`-weighted
2694/// product when `Some`, the plain product otherwise. A column that is already
2695/// numerically zero (norm 0 or non-finite) is left untouched: its cross entries
2696/// are zero regardless, so it contributes no rank. The returned owned copy is
2697/// used only to build the cross; the emitted transform and the realized
2698/// constrained design are unaffected (column scaling of `C` preserves
2699/// `null(Mᵀ)`).
2700fn unit_normalize_constraint_columns(
2701    constraint_matrix: ArrayView2<'_, f64>,
2702    weights: Option<ArrayView1<'_, f64>>,
2703) -> Array2<f64> {
2704    let mut c = constraint_matrix.to_owned();
2705    let (n, q) = c.dim();
2706    for col in 0..q {
2707        let mut norm_sq = 0.0_f64;
2708        for row in 0..n {
2709            let v = c[[row, col]];
2710            let w = weights.map_or(1.0, |ws| ws[row]);
2711            norm_sq += w * v * v;
2712        }
2713        let norm = norm_sq.sqrt();
2714        if norm > 0.0 && norm.is_finite() {
2715            let inv = 1.0 / norm;
2716            for row in 0..n {
2717                c[[row, col]] *= inv;
2718            }
2719        }
2720    }
2721    c
2722}
2723
2724#[cfg(test)]
2725mod saturation_escalation_tests {
2726    use super::*;
2727
2728    #[test]
2729    fn starting_count_is_a_supported_low_rank_pilot_capped_by_default() {
2730        assert_eq!(starting_num_centers(800, 2), 30);
2731        assert_eq!(starting_num_centers(100_000, 1), 10);
2732        // The generic conditioning ceiling is `n / 4` and therefore reports
2733        // zero below four rows; the pilot retains the basis-wide one-center
2734        // degenerate minimum, which materialization subsequently raises to the
2735        // exact polynomial floor for the requested family.
2736        assert_eq!(starting_num_centers(3, 5), 1);
2737        assert_eq!(starting_num_centers(1, 2), 1);
2738    }
2739
2740    #[test]
2741    fn saturated_expansion_doubles_then_pins_at_validated_ceiling() {
2742        assert_eq!(expanded_num_centers(30, 157), Some(60));
2743        assert_eq!(expanded_num_centers(120, 157), Some(157));
2744        assert_eq!(expanded_num_centers(157, 157), None);
2745        assert_eq!(
2746            expanded_num_centers(usize::MAX - 1, usize::MAX),
2747            Some(usize::MAX)
2748        );
2749    }
2750
2751    #[test]
2752    fn saturation_excludes_the_nullspace_and_tracks_edf() {
2753        let tol = 1e-4;
2754        // Total term EDF includes the three-dimensional nullspace. Saturation
2755        // means its penalized component spends all 97 remaining directions.
2756        assert!(basis_is_saturated(100.0, 100, 3, tol));
2757        // Half-used basis is NOT saturated.
2758        assert!(!basis_is_saturated(48.5, 100, 3, tol));
2759        // Just below capacity by more than the derived margin: not saturated.
2760        assert!(!basis_is_saturated(90.0, 100, 3, tol));
2761        // A block whose null space already exhausts its columns has no penalizable
2762        // capacity and is never saturated.
2763        assert!(!basis_is_saturated(3.0, 3, 3, tol));
2764        assert!(!basis_is_saturated(f64::NAN, 100, 3, tol));
2765    }
2766
2767    #[test]
2768    fn saturation_is_monotone_in_edf() {
2769        let tol = 1e-3;
2770        let (k, null) = (60usize, 3usize);
2771        let full_width = k as f64;
2772        // Once saturated at some edf, any larger edf stays saturated.
2773        let mut first_true: Option<f64> = None;
2774        let mut e = full_width - 5.0;
2775        while e <= full_width {
2776            let sat = basis_is_saturated(e, k, null, tol);
2777            if sat && first_true.is_none() {
2778                first_true = Some(e);
2779            }
2780            if let Some(t) = first_true {
2781                assert!(
2782                    basis_is_saturated(e.max(t), k, null, tol),
2783                    "saturation must not flip back to false as edf grows"
2784                );
2785            }
2786            e += 0.25;
2787        }
2788        assert!(first_true.is_some(), "edf reaching capacity must saturate");
2789    }
2790}