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