gam_terms/basis/duchon_thinplate.rs
1use super::*;
2
3use super::invariant_tie_break::resolve_sorted_profile_tie;
4use gam_linalg::lanczos::{SymmetricExtremeLanczosOptions, symmetric_extreme_lanczos_eigenpairs};
5
6/// Cross-disease Duchon basis cache.
7///
8/// The biobank workload fits many models (e.g. 17 diseases) over the SAME base
9/// cohort: identical individuals, identical predictor columns (PC1..PC15, sex,
10/// ages, geography); only the response/PRS column changes per fit. The Duchon
11/// spatial basis — center/knot selection, the thin-plate kernel evaluation, the
12/// kernel-constraint nullspace reparameterisation, the identifiability
13/// transform, and the penalty Grams — is a PURE FUNCTION of `(data, spec)`: it
14/// never reads the response. Its dense-versus-lazy REPRESENTATION additionally
15/// depends on the workspace's storage-routing policy — but that is a property
16/// of how the same basis is carried, so it is checked against the cached entry
17/// on lookup rather than folded into the key (see [`route_matches_policy`]).
18/// Thus diseases sharing the same columns can reuse the complete
19/// [`BasisBuildResult`] without crossing a caller's materialization boundary.
20///
21/// This is a content-addressed, size-bounded, recomputable memo mirroring the
22/// FFI cross-disease column-encode cache (`encoded_column_cache` in
23/// `crates/gam-pyffi/src/manifold_and_posterior_ffi.rs`): the key is a 128-bit
24/// fingerprint of the data matrix CONTENT (shape + every element bit-pattern),
25/// the basis spec, and the caller's declared storage MODE. A different cohort
26/// or spec therefore MISSES; matching diseases HIT. A hit clones the cached `BasisBuildResult` (cheap
27/// `Arc`/ndarray clones vs. the kernel build + RRQR audit), so results are
28/// bit-identical to the miss path.
29/// Eviction (LRU under a byte budget) only ever forfeits the perf benefit, never
30/// correctness, since every value is exactly recomputable from its key.
31type DuchonBasisCacheKey = (u64, u64);
32
33#[derive(Clone)]
34struct CachedDuchonBasis {
35 result: BasisBuildResult,
36 /// The storage route the *building* policy selected for this realized
37 /// shape — `true` for the streamed/operator design, `false` for the
38 /// materialized one.
39 ///
40 /// This is how the memory policy participates in the memo WITHOUT
41 /// participating in the memo's KEY. The key is `(data, spec)`: it names
42 /// WHICH basis this is, and that is a question memory has no vote in. The
43 /// route names HOW that basis is carried, and a hit is served only when the
44 /// asking policy would pick the same route for the same shape
45 /// ([`route_matches_policy`]). Hashing the cap into the key instead — which
46 /// is what shipped before #2684 — spelled a routing preference as a
47 /// difference of identity, so two processes that would have built the very
48 /// same basis missed each other over a byte count neither of them chose.
49 route_lazy: bool,
50}
51
52impl gam_runtime::resource::ResidentBytes for CachedDuchonBasis {
53 fn resident_bytes(&self) -> usize {
54 // Coarse charge: the dominant resident cost is the dense design columns
55 // and the penalty Grams. An estimate suffices — the byte budget only
56 // bounds the cache, it never affects correctness.
57 let design_bytes = self
58 .result
59 .design
60 .nrows()
61 .saturating_mul(self.result.design.ncols())
62 .saturating_mul(std::mem::size_of::<f64>());
63 let penalty_bytes: usize = self
64 .result
65 .active_penalties
66 .iter()
67 .map(|penalty| {
68 penalty
69 .matrix
70 .len()
71 .saturating_mul(std::mem::size_of::<f64>())
72 })
73 .sum();
74 design_bytes
75 .saturating_add(penalty_bytes)
76 .saturating_add(4096)
77 }
78}
79
80/// Process-wide Duchon basis memo. 1 GiB matches the established large-scale
81/// densification ceiling used elsewhere; with ~17 diseases over one cohort the
82/// working set is a single `BasisBuildResult`, so even a modest budget retains
83/// the shared basis across the whole sweep.
84fn duchon_basis_cache()
85-> &'static gam_runtime::resource::ByteLruCache<DuchonBasisCacheKey, CachedDuchonBasis> {
86 static CACHE: std::sync::OnceLock<
87 gam_runtime::resource::ByteLruCache<DuchonBasisCacheKey, CachedDuchonBasis>,
88 > = std::sync::OnceLock::new();
89 CACHE.get_or_init(|| gam_runtime::resource::ByteLruCache::new(1 << 30))
90}
91
92/// 128-bit content fingerprint of `(data, spec)`. Two independent hashers (one
93/// unseeded, one seeded with a fixed golden-ratio constant) widen the key to
94/// 128 bits so accidental collisions across a batch are negligible. The data
95/// contribution hashes the shape plus EVERY element's IEEE-754 bit pattern, so
96/// any change of rows, columns, or values — i.e. a different cohort / subsample
97/// — produces a different key and misses. The spec is hashed via its serialized
98/// form (the spec carries `serde` derives), capturing center strategy, power,
99/// length scale, nullspace order, anisotropy, identifiability, and operator
100/// penalty dials. The storage MODE is hashed because it is an explicit caller
101/// choice — a caller that has committed to operator-only math is asking for a
102/// different artifact, not for a different copy of the same one.
103///
104/// What is deliberately NOT hashed is the materialization CAP (#2684). A byte
105/// ceiling is a statement about the machine, not about the model: two processes
106/// handed the same `(data, spec)` must agree on which basis that is, whatever
107/// their ceilings say. Before this change the cap was hashed here, so the
108/// artifact's very identity moved with a number the caller never chose — and
109/// while that cap was read from FREE memory, four processes on one node
110/// computed four different fingerprints for identical inputs. The cap now
111/// enters at the only place it has standing: whether a cached result's storage
112/// route is the route this policy would pick ([`route_matches_policy`]).
113fn duchon_basis_fingerprint(
114 data: ArrayView2<'_, f64>,
115 spec: &DuchonBasisSpec,
116 policy: &gam_runtime::resource::ResourcePolicy,
117) -> Option<DuchonBasisCacheKey> {
118 let spec_bytes = serde_json::to_vec(spec).ok()?;
119 let mut lo = DefaultHasher::new();
120 let mut hi = DefaultHasher::new();
121 // Seed `hi` so its stream is statistically independent of `lo`.
122 0x9E37_79B9_7F4A_7C15u64.hash(&mut hi);
123
124 let (nrows, ncols) = data.dim();
125 for h in [&mut lo, &mut hi] {
126 nrows.hash(h);
127 ncols.hash(h);
128 }
129 // Hash element bit-patterns in a fixed (row-major) order, independent of the
130 // view's underlying memory layout, so two views over the same logical matrix
131 // fingerprint identically.
132 for row in data.rows() {
133 for &v in row {
134 let bits = v.to_bits();
135 bits.hash(&mut lo);
136 bits.hash(&mut hi);
137 }
138 }
139 for h in [&mut lo, &mut hi] {
140 spec_bytes.len().hash(h);
141 spec_bytes.hash(h);
142 let storage_mode = match policy.derivative_storage_mode {
143 gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired => 0_u8,
144 gam_runtime::resource::DerivativeStorageMode::MaterializeIfSmall => 1_u8,
145 gam_runtime::resource::DerivativeStorageMode::DiagnosticsOnly => 2_u8,
146 };
147 storage_mode.hash(h);
148 }
149 Some((lo.finish(), hi.finish()))
150}
151
152/// The storage route `policy` selects for a design of this realized shape.
153///
154/// Evaluated on the FINAL design rather than on the pre-identifiability width
155/// the builder routed on, and evaluated by the same function at insert and at
156/// lookup. That is what makes the comparison in [`route_matches_policy`] exact
157/// and idempotent: whatever a build produces, storing `f(shape, building
158/// policy)` means a later lookup under that same policy recomputes the same
159/// answer and hits. A predicate that could disagree with itself on its own
160/// output would turn every lookup in the disagreement band into a silent
161/// permanent cache miss.
162fn realized_route_is_lazy(
163 result: &BasisBuildResult,
164 policy: &gam_runtime::resource::ResourcePolicy,
165) -> bool {
166 should_use_lazy_spatial_design(result.design.nrows(), result.design.ncols(), policy)
167}
168
169/// Whether a cached basis may be served to a caller holding `policy`.
170///
171/// The question is NOT "is this policy as permissive as the one that built it"
172/// — that would make the answer depend on arrival order, so a permissive caller
173/// would get a dense or a streamed design according to who ran first. It is the
174/// symmetric one: do the two policies route this shape the same way? If they
175/// do, the cached artifact is the artifact this caller would have built. If
176/// they do not, the caller wanted a differently-carried copy of the same basis
177/// and gets one built for it.
178fn route_matches_policy(
179 cached: &CachedDuchonBasis,
180 policy: &gam_runtime::resource::ResourcePolicy,
181) -> bool {
182 realized_route_is_lazy(&cached.result, policy) == cached.route_lazy
183}
184
185pub fn build_duchon_basiswithworkspace(
186 data: ArrayView2<'_, f64>,
187 spec: &DuchonBasisSpec,
188 workspace: &mut BasisWorkspace,
189) -> Result<BasisBuildResult, BasisError> {
190 if let Some(key) = duchon_basis_fingerprint(data, spec, workspace.policy()) {
191 if let Some(hit) = duchon_basis_cache().get(&key) {
192 if route_matches_policy(&hit, workspace.policy()) {
193 return Ok(hit.result);
194 }
195 }
196 let result = build_duchon_basis_uncached(data, spec, workspace)?;
197 let route_lazy = realized_route_is_lazy(&result, workspace.policy());
198 duchon_basis_cache().insert(
199 key,
200 CachedDuchonBasis {
201 result: result.clone(),
202 route_lazy,
203 },
204 );
205 return Ok(result);
206 }
207 build_duchon_basis_uncached(data, spec, workspace)
208}
209
210/// Build a Duchon design whose COLUMN SPACE is a property of the spec alone
211/// (gam#237).
212///
213/// [`build_duchon_basis`] adopts a *data-metric* radial chart when none is
214/// frozen: it forms `G_c = (K·Z)ᵀ(K·Z)` from the realized design and keeps only
215/// the `G_c` eigen-directions above a numerical floor — "design columns with no
216/// realized data support", as the whitening step's own comment puts it. `G_c`
217/// has rank at most `n`, so the surviving width is `min(K−p, n) + p`. For a FIT
218/// that is a deliberate rank reduction and it is safe, because the fit freezes
219/// the chart into basis metadata and replays it at predict time. For a
220/// basis-evaluation primitive, with no fit and nothing to freeze, it means the
221/// design's width is a function of the frame it is handed. Measured, one spec
222/// (12 centers, `d=2`, `m=2`), varying only the evaluation row count:
223///
224/// ```text
225/// before: 1 row -> 4 cols, 5 rows -> 8, 9 rows -> 12, 30 rows -> 12
226/// after : 1 row -> 12 cols, 5 rows -> 12, 9 rows -> 12, 30 rows -> 12
227/// ```
228///
229/// A basis that changes dimension with the number of points you evaluate it at
230/// cannot be applied twice consistently, and its `basis_size` is unknowable
231/// without the data. This entry point instead derives the chart from the
232/// CENTERS — the same `Ω_c` bending eigenbasis
233/// `thin_plate_radial_reparam_data_metric` already falls back to when the
234/// realized Gram is degenerate — and freezes it into the spec before building.
235/// The width is then `K` exactly: measured across 63 configurations
236/// (`d ∈ {2,3,4}` × `m ∈ {1,2,3}` × `K ∈ {6..12}`), 63 of 63 emit `cols == K`.
237/// It is `K` rather than something smaller because `Ω_c` is the bending energy
238/// on the ALREADY constrained kernel block, whose polynomial null space has been
239/// projected out, so all `K−p` modes carry genuine curvature and none falls
240/// below the `K·ε·λ_max` roundoff floor.
241///
242/// Callers that ARE fitting should keep using [`build_duchon_basis`]: the
243/// data-metric chart is what removes the REML over-smoothing collapse (#1355),
244/// and it is legitimate there precisely because a fit persists it.
245pub fn build_duchon_basis_spec_chart(
246 data: ArrayView2<'_, f64>,
247 spec: &DuchonBasisSpec,
248) -> Result<BasisBuildResult, BasisError> {
249 // An explicitly frozen chart already makes the basis spec-determined, and
250 // the periodic/cyclic builders never reach the data-metric branch at all,
251 // so in both cases the ordinary path is already frame-independent.
252 if center_strategy_spectral_basis(&spec.center_strategy).is_some()
253 || spec.radial_reparam.is_some()
254 || spec.periodic.is_some()
255 || spec.boundary.period().is_some()
256 {
257 return build_duchon_basis(data, spec);
258 }
259 let mut workspace = BasisWorkspace::default();
260 let centers = select_centers_by_strategy(data, &spec.center_strategy)?;
261 let effective_nullspace_order =
262 duchon_effective_nullspace_order(centers.view(), spec.nullspace_order);
263 let aniso = auto_seed_aniso_contrasts(centers.view(), spec.aniso_log_scales.as_deref());
264 let kernel_transform = kernel_constraint_nullspace(
265 centers.view(),
266 effective_nullspace_order,
267 &mut workspace.cache,
268 )?;
269 if kernel_transform.ncols() == 0 {
270 return build_duchon_basis(data, spec);
271 }
272 let omega_constrained = duchon_constrained_bending_penalty(
273 centers.view(),
274 spec.length_scale,
275 spec.power,
276 effective_nullspace_order,
277 aniso.as_deref(),
278 &kernel_transform,
279 )?;
280 let (v, _mu) = thin_plate_radial_reparam_from_constrained_penalty(&omega_constrained)?;
281 if v.ncols() == 0 {
282 // A degenerate chart would gut the basis; the unrotated design is a
283 // better answer than an empty one, and it is still frame-independent
284 // because nothing data-derived went into it.
285 return build_duchon_basis(data, spec);
286 }
287 let mut spec_chart = spec.clone();
288 spec_chart.radial_reparam = Some(v);
289 build_duchon_basis(data, &spec_chart)
290}
291
292/// Dominant center-kernel eigenspace followed by the exact polynomial
293/// side-condition projection used by Duchon regression splines.
294///
295/// The center count controls Nyström resolution; `rank` independently controls
296/// the final spline width. This is the construction mgcv calls a low-rank
297/// Duchon spline: retain the `rank` eigenpairs of largest magnitude, then remove
298/// the polynomial component *inside that eigenspace*. Projecting the full
299/// center space first and truncating afterward is a different approximation.
300struct DuchonSpectralKernelChart {
301 kernel_transform: Array2<f64>,
302 bending_penalty: Array2<f64>,
303}
304
305fn duchon_spectral_kernel_chart(
306 centers: ArrayView2<'_, f64>,
307 length_scale: Option<f64>,
308 power: f64,
309 nullspace_order: DuchonNullspaceOrder,
310 aniso_log_scales: Option<&[f64]>,
311 rank: usize,
312) -> Result<DuchonSpectralKernelChart, BasisError> {
313 let (center_kernel, kernel_amp) = duchon_center_kernel_value_matrix(
314 centers,
315 length_scale,
316 power,
317 nullspace_order,
318 aniso_log_scales,
319 )?;
320 let dim = center_kernel.nrows();
321
322 // Match mgcv::slanczos' deliberately tiny deterministic LCG exactly. The
323 // start vector selects a finite-precision Krylov chart, so using merely
324 // another deterministic random sequence needlessly rotates the truncated
325 // approximation away from the reference even when knots and rank match.
326 let mut state = 1_u64;
327 let mut start = vec![0.0_f64; dim];
328 for value in &mut start {
329 state = (state * 106 + 1283) % 6075;
330 *value = state as f64 / 6075.0 - 0.5;
331 }
332 let check_every = (rank / 2).max(10).min((dim / 10).max(1));
333
334 let pairs = symmetric_extreme_lanczos_eigenpairs(
335 dim,
336 &start,
337 SymmetricExtremeLanczosOptions {
338 target_rank: rank,
339 max_steps: 128,
340 check_every,
341 relative_residual_tol: f64::EPSILON.sqrt(),
342 breakdown_tol: 1e-14,
343 },
344 |q, image| gam_linalg::faer_ndarray::symmetric_matvec_into(¢er_kernel, q, image),
345 )
346 .map_err(BasisError::InvalidInput)?;
347 let selected = pairs.eigenvectors;
348
349 let mut centers_centered = centers.to_owned();
350 for axis in 0..centers.ncols() {
351 let mean = centers.column(axis).sum() / centers.nrows() as f64;
352 centers_centered
353 .column_mut(axis)
354 .mapv_inplace(|value| value - mean);
355 }
356 let polynomial = polynomial_block_from_order(centers_centered.view(), nullspace_order);
357 let polynomial_in_eigenspace = fast_atb(&selected, &polynomial);
358 let spectral_constraint =
359 kernel_constraint_nullspace_from_matrix(polynomial_in_eigenspace.view())?;
360 let kernel_transform = fast_ab(&selected, &spectral_constraint);
361 let mut diagonal = Array2::<f64>::zeros((rank, rank));
362 for (index, &eigenvalue) in pairs.eigenvalues.iter().enumerate() {
363 diagonal[[index, index]] = eigenvalue;
364 }
365 let reduced = fast_ab(
366 &fast_atb(&spectral_constraint, &diagonal),
367 &spectral_constraint,
368 )
369 .mapv(|value| value * kernel_amp * kernel_amp);
370 Ok(DuchonSpectralKernelChart {
371 kernel_transform,
372 bending_penalty: symmetrize_penalty(&reduced),
373 })
374}
375
376#[cfg(test)]
377mod duchon_spectral_basis_tests {
378 use super::*;
379
380 fn asymmetric_centers() -> Array2<f64> {
381 Array2::from_shape_fn((24, 4), |(row, axis)| {
382 let x = (row + 1) as f64;
383 let a = (axis + 2) as f64;
384 (x * a.sqrt()).sin() + (x / (a + 0.5)).cos() + 0.01 * x * a
385 })
386 }
387
388 #[test]
389 fn spectral_transform_has_requested_rank_is_deterministic_and_obeys_side_condition() {
390 let centers = asymmetric_centers();
391 let rank = 6;
392 let chart = duchon_spectral_kernel_chart(
393 centers.view(),
394 None,
395 1.5,
396 DuchonNullspaceOrder::Zero,
397 None,
398 rank,
399 )
400 .expect("small asymmetric cloud has a certifiable spectral basis");
401 let replay = duchon_spectral_kernel_chart(
402 centers.view(),
403 None,
404 1.5,
405 DuchonNullspaceOrder::Zero,
406 None,
407 rank,
408 )
409 .expect("deterministic replay");
410
411 // rank includes the single constant null-space column.
412 assert_eq!(chart.kernel_transform.dim(), (centers.nrows(), rank - 1));
413 assert_eq!(chart.kernel_transform, replay.kernel_transform);
414 assert_eq!(chart.bending_penalty, replay.bending_penalty);
415
416 let gram = fast_atb(&chart.kernel_transform, &chart.kernel_transform);
417 for i in 0..gram.nrows() {
418 for j in 0..gram.ncols() {
419 let target = if i == j { 1.0 } else { 0.0 };
420 assert!(
421 (gram[[i, j]] - target).abs() <= 2e-10,
422 "spectral transform is not orthonormal at ({i}, {j}): {}",
423 gram[[i, j]]
424 );
425 }
426 }
427 for column in chart.kernel_transform.columns() {
428 assert!(
429 column.sum().abs() <= 2e-10,
430 "constant polynomial side condition was not removed: sum={}",
431 column.sum()
432 );
433 }
434 }
435}
436
437fn build_duchon_basis_uncached(
438 data: ArrayView2<'_, f64>,
439 spec: &DuchonBasisSpec,
440 workspace: &mut BasisWorkspace,
441) -> Result<BasisBuildResult, BasisError> {
442 if let Some((_start, _end, period)) = spec.boundary.period() {
443 // A 1-D cyclic boundary is the formula-DSL spelling of periodicity.
444 // Normalize it onto `spec.periodic` so ALL periodic 1-D Duchon terms
445 // share the single Bernoulli Green's-function construction
446 // (`build_periodic_duchon_basis_1d`): the exact periodic kernel of
447 // `(d²/dx²)^m` with the exact RKHS Gram penalty `ω = zᵀK_centers z`.
448 // The former wrapped-min-distance kernel + coefficient-difference
449 // penalty path was both a SPEC 5 violation (penalty on coefficients,
450 // not the function) and a live forward/derivative desync: every
451 // derivative/jet consumer (`create_duchon_basis_1d_derivative_dense`,
452 // the log-κ derivative builders, the pyffi periodic jet) reconstructs
453 // the Bernoulli design, never the wrapped-distance one. Only the
454 // period LENGTH matters — the periodic kernel depends on cyclic
455 // distance mod period, so the boundary's absolute phase anchor is
456 // immaterial (the wrap anchor is re-derived deterministically from
457 // the frozen center set at fit and predict time alike).
458 if data.ncols() != 1 {
459 crate::bail_invalid_basis!(
460 "cyclic-boundary Duchon smooths require exactly one covariate"
461 );
462 }
463 let mut spec_periodic = spec.clone();
464 spec_periodic.boundary = crate::basis::OneDimensionalBoundary::Open;
465 spec_periodic.periodic = Some(vec![Some(period)]);
466 let centers = select_centers_by_strategy(data, &spec_periodic.center_strategy)?;
467 assert_spatial_centers_below_large_scale_cap(data.ncols(), centers.view())?;
468 return build_periodic_duchon_basis_1d(data, &spec_periodic, centers, workspace);
469 }
470 let centers = select_centers_by_strategy(data, &spec.center_strategy)?;
471 assert_spatial_centers_below_large_scale_cap(data.ncols(), centers.view())?;
472 if let Some(periodic) = spec.periodic.as_ref() {
473 if periodic.len() != data.ncols() {
474 crate::bail_invalid_basis!(
475 "periodic must have length d={}, got {}",
476 data.ncols(),
477 periodic.len()
478 );
479 }
480 if data.ncols() > 1 && periodic.iter().any(Option::is_some) {
481 let flags = periodic.iter().map(Option::is_some).collect::<Vec<_>>();
482 let periods = periodic
483 .iter()
484 .map(|axis| axis.unwrap_or(1.0))
485 .collect::<Vec<_>>();
486 return build_duchon_basis_mixed_periodicity_auto(data, spec, &flags, Some(&periods));
487 }
488 return build_periodic_duchon_basis_1d(data, spec, centers, workspace);
489 }
490 // `spec.power` is the LITERAL Duchon spectral power `s` at the basis layer.
491 // The kernel exponent is `2(p+s) − d`, so `power = 0` means `s = 0` — the
492 // integer-order Duchon kernel `r^{2(p)−d}` (its `r²·log r` log case in even
493 // `d`, which equals the thin-plate kernel) — and is honored verbatim, NOT
494 // read as "apply a default". The magic cubic default (no explicit power ⇒
495 // `s = (d−1)/2`, `φ(r)=r³`) is a REQUEST-LAYER choice the formula/CLI/pyffi
496 // front-ends resolve via `duchon_cubic_default`; the builder uses whatever
497 // `(nullspace_order, power)` it is handed, so both Duchon spectral powers —
498 // `s = 0` (thin-plate kernel) and `s = (d−1)/2` (fractional cubic) — are
499 // reachable through this one construction.
500 //
501 // Auto-degrade the requested null-space order to Zero when the selected
502 // centers cannot span the requested polynomial block. Every downstream
503 // consumer of `spec.nullspace_order` in this function MUST use the
504 // effective order, otherwise the penalty/nullspace is built with a
505 // different order than the basis.
506 let effective_nullspace_order =
507 duchon_effective_nullspace_order(centers.view(), spec.nullspace_order);
508 let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
509 // Initialize anisotropy contrasts from knot cloud geometry when the caller
510 // enabled scale-dimensions but left η at the zero default. Duchon η is a
511 // FIXED, geometry-derived basis parameter (never a REML hyper-axis), so the
512 // all-zero auto-seed sentinel is the intended seeding mechanism here — unlike
513 // the Matérn forward path, whose η is optimized and must be honored literally.
514 let aniso = auto_seed_aniso_contrasts(centers.view(), spec.aniso_log_scales.as_deref());
515 // The native reproducing-norm Gram penalty (`Primary`) is assembled from
516 // kernel VALUES at the center pairs (K_CC), not from collocated D1/D2
517 // derivative operators, so the build only requires the pointwise kernel to
518 // EXIST (`2(p+s) > d`). The stricter operator-collocation orders
519 // (`2(p+s) > d+1` / `> d+2`) are a property of the old triple-operator
520 // penalties that this path no longer builds; enforcing them here would
521 // spuriously reject valid kernels — e.g. the `s=0` thin-plate `r²·log r`
522 // (`2(p+s)=d+2` in 2D), which the native Gram handles fine.
523 //
524 // Validate against the spectral power the kernel actually evaluates. The
525 // scale-free native Gram (`length_scale=None`) uses the literal fractional
526 // `spec.power`. The hybrid Matérn-blended kernel (`length_scale=Some`) is
527 // built from the integer partial-fraction expansion of `(κ²+‖w‖²)^s` and
528 // reads `s` back through `power_as_usize` (a fractional `spec.power` is
529 // truncated to that integer). Validating the raw fractional power on the
530 // hybrid path desyncs the `2(p+s) > d` well-posedness gate from the realized
531 // kernel: e.g. the cubic default `s=(d-1)/2=1.5` at p=2, d=4 truncates to
532 // s=0 where `2(p+s)=4=d` is NOT finite at the origin, yet `spec.power=1.5`
533 // passes the gate — the resulting non-finite Gram crashes the constraint
534 // eigendecomposition (gh#750). Gate on the truncated integer for hybrid so
535 // that case is rejected here with a clear message while every valid hybrid
536 // config (e.g. 1D, where `2(2+0)=4>1` stays finite) still builds.
537 let validation_power = if spec.length_scale.is_some() {
538 spec.power_as_usize() as f64
539 } else {
540 spec.power
541 };
542 validate_duchon_kernel_orders(spec.length_scale, p_order, validation_power, data.ncols())?;
543 let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
544 let spectral_basis = center_strategy_spectral_basis(&spec.center_strategy);
545 if let Some(spectral) = spectral_basis {
546 let rank = spectral.rank();
547 if rank <= poly_cols || rank > centers.nrows() {
548 crate::bail_invalid_basis!(
549 "Duchon spectral rank must satisfy polynomial_columns < rank <= centers: \
550 polynomial_columns={poly_cols}, rank={rank}, centers={}",
551 centers.nrows()
552 );
553 }
554 if spec.radial_reparam.is_some() {
555 crate::bail_invalid_basis!(
556 "Duchon spectral basis and landmark data-metric radial reparameterization \
557 are mutually exclusive"
558 );
559 }
560 if spec.length_scale.is_some() {
561 crate::bail_invalid_basis!(
562 "Duchon spectral reduction currently requires the scale-free kernel; \
563 a moving hybrid range would change the retained eigenspace"
564 );
565 }
566 }
567 let mut realized_spectral_basis = None;
568 let mut spectral_bending_penalty = None;
569 let mut kernel_transform = if let Some(spectral) = spectral_basis {
570 let rank = spectral.rank();
571 let chart = match (spectral.kernel_transform(), spectral.bending_penalty()) {
572 (Some(frozen), Some(frozen_penalty)) => {
573 if frozen.nrows() != centers.nrows()
574 || frozen.ncols() != rank.saturating_sub(poly_cols)
575 {
576 crate::bail_dim_basis!(
577 "Duchon frozen spectral transform has shape {:?}; expected ({}, {})",
578 frozen.dim(),
579 centers.nrows(),
580 rank.saturating_sub(poly_cols)
581 );
582 }
583 if frozen_penalty.dim() != (frozen.ncols(), frozen.ncols()) {
584 crate::bail_dim_basis!(
585 "Duchon frozen spectral bending penalty has shape {:?}; expected ({}, {})",
586 frozen_penalty.dim(),
587 frozen.ncols(),
588 frozen.ncols()
589 );
590 }
591 DuchonSpectralKernelChart {
592 kernel_transform: frozen.clone(),
593 bending_penalty: frozen_penalty.clone(),
594 }
595 }
596 (None, None) => duchon_spectral_kernel_chart(
597 centers.view(),
598 spec.length_scale,
599 spec.power,
600 effective_nullspace_order,
601 aniso.as_deref(),
602 rank,
603 )?,
604 _ => crate::bail_invalid_basis!(
605 "Duchon frozen spectral state must contain both transform and bending penalty"
606 ),
607 };
608 spectral_bending_penalty = Some(chart.bending_penalty.clone());
609 realized_spectral_basis = Some(DuchonSpectralBasis::Frozen {
610 rank,
611 kernel_transform: chart.kernel_transform.clone(),
612 bending_penalty: chart.bending_penalty,
613 });
614 chart.kernel_transform
615 } else {
616 kernel_constraint_nullspace(
617 centers.view(),
618 effective_nullspace_order,
619 &mut workspace.cache,
620 )?
621 };
622 let base_cols = kernel_transform.ncols() + poly_cols;
623 let dense_bytes = dense_design_bytes(data.nrows(), base_cols);
624 let use_lazy = should_use_lazy_spatial_design(data.nrows(), base_cols, workspace.policy());
625 // #1355: data-metric radial reparameterization `V`, frozen into metadata so
626 // predict / κ-trial rebuilds replay the exact fit-time rotated radial basis.
627 // A FROZEN `V` (predict / κ-trial / replay) is folded into the constrained
628 // kernel transform on EVERY path so the design stays consistent with the
629 // frozen penalty. A FRESH `V` is computed on every cold path: the dense
630 // builder obtains its realized Gram from the materialized kernel block,
631 // while the lazy builder streams the same Gram through the chunked operator.
632 let mut frozen_radial_reparam: Option<Array2<f64>> = None;
633 if let Some(v) = spec.radial_reparam.as_ref() {
634 if v.nrows() != kernel_transform.ncols() {
635 crate::bail_dim_basis!(
636 "Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
637 v.dim(),
638 kernel_transform.ncols()
639 );
640 }
641 kernel_transform = fast_ab(&kernel_transform, v);
642 frozen_radial_reparam = Some(v.clone());
643 }
644 let (design, identifiability_transform) = if use_lazy {
645 // log::info! — deliberate memory-saving choice, not an anomaly.
646 log::info!(
647 "Duchon basis switching to lazy chunked design: n={} p={} ({:.1} MiB dense)",
648 data.nrows(),
649 base_cols,
650 dense_bytes as f64 / (1024.0 * 1024.0),
651 );
652 let d = data.ncols();
653 let shared_data = shared_owned_data_matrix(data, &workspace.cache);
654 let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
655 let s_order: f64 = spec.power;
656 let length_scale = spec.length_scale;
657 let s_order_int = length_scale.map(|_| duchon_power_to_usize(s_order));
658 let coeffs = length_scale.map(|ls| {
659 // Hybrid Matérn (length_scale = Some) uses the integer
660 // partial-fraction chain; assert at this boundary so the
661 // scale-free path stays fractional-clean.
662 duchon_partial_fraction_coeffs(
663 p_order,
664 s_order_int.expect("hybrid Duchon requires integer power"),
665 1.0 / ls.max(1e-300),
666 )
667 });
668 let pure_poly_coeff = if length_scale.is_none() {
669 Some(PolyharmonicBlockCoeff::new(
670 pure_duchon_block_order(p_order, s_order),
671 d,
672 ))
673 } else {
674 None
675 };
676 // Translation-invariant polynomial frame (#1375): build the explicit
677 // poly null-space columns at coordinates centered by the center-cloud
678 // per-axis mean, matching `build_duchon_basis_designwithworkspace` (dense
679 // path) and the side-condition `Z` (centered inside
680 // `kernel_constraint_nullspace`). The kernel block reads `data − centers`
681 // differences, so it is already translation-invariant and stays raw.
682 let center_mean: Vec<f64> = (0..d)
683 .map(|c| centers.column(c).sum() / (centers.nrows().max(1) as f64))
684 .collect();
685 let mut data_centered = data.to_owned();
686 for c in 0..d {
687 let mu = center_mean[c];
688 data_centered.column_mut(c).mapv_inplace(|v| v - mu);
689 }
690 let poly_block =
691 polynomial_block_from_order(data_centered.view(), effective_nullspace_order);
692 let kernel_amp = duchon_kernel_amplification(
693 centers.view(),
694 length_scale,
695 p_order,
696 duchon_power_to_usize(s_order),
697 d,
698 aniso.as_deref(),
699 coeffs.as_ref(),
700 pure_poly_coeff.as_ref(),
701 );
702 // Build the same kernel evaluator for the raw-Gram pass and the final
703 // operator. The evaluator owns its anisotropic metric weights, so the
704 // two streamed passes share the exact function without sharing mutable
705 // state or materialising the n×p design.
706 let make_kernel = || {
707 let coeffs = coeffs.clone();
708 let pure_poly_coeff = pure_poly_coeff;
709 let metric_weights = aniso.as_ref().map(|eta| {
710 eta.iter()
711 .map(|&value| (2.0 * value).exp())
712 .collect::<Vec<_>>()
713 });
714 Arc::new(move |data_row: &[f64], center_row: &[f64]| -> f64 {
715 let r = if let Some(weights) = metric_weights.as_ref() {
716 let mut squared_radius = 0.0_f64;
717 for axis in 0..data_row.len() {
718 let delta = data_row[axis] - center_row[axis];
719 squared_radius += weights[axis] * delta * delta;
720 }
721 squared_radius.sqrt()
722 } else {
723 stable_euclidean_norm((0..d).map(|axis| data_row[axis] - center_row[axis]))
724 };
725 let raw = if let Some(ppc) = pure_poly_coeff {
726 ppc.eval(r)
727 } else {
728 duchon_matern_kernel_general_from_distance(
729 r,
730 length_scale,
731 p_order,
732 s_order_int.expect("hybrid Duchon requires integer power"),
733 d,
734 coeffs.as_ref(),
735 )
736 .expect("validated Duchon inputs should not fail")
737 };
738 raw * kernel_amp
739 }) as Arc<dyn crate::chunked_kernel_design::SpatialKernelEvaluator>
740 };
741 // The data-metric radial chart is a property of the represented kernel
742 // function, not of the isotropic special case. Stream the raw
743 // constrained Gram for every cold lazy build, including anisotropic and
744 // operator-penalty configurations, then solve the same generalized
745 // eigenproblem as the dense path. This keeps VᵀG_cV=I and
746 // VᵀΩ_cV=diag(μ) without ever allocating n×p.
747 if spectral_basis.is_none() && frozen_radial_reparam.is_none() {
748 let raw_gauge = Arc::new(gam_problem::Gauge::from_block_transforms(&[
749 kernel_transform.clone(),
750 ]));
751 let raw_op = ChunkedKernelDesignOperator::new(
752 shared_data.clone(),
753 Arc::new(centers.clone()),
754 make_kernel(),
755 Some(raw_gauge),
756 Some(Arc::new(poly_block.clone())),
757 workspace.policy().material_policy(),
758 )
759 .map_err(BasisError::InvalidInput)?;
760 let ones = Array1::<f64>::ones(raw_op.nrows());
761 let raw_gram = raw_op.diag_xtw_x(&ones).map_err(BasisError::InvalidInput)?;
762 let kernel_cols = kernel_transform.ncols();
763 let design_gram =
764 symmetrize_penalty(&raw_gram.slice(s![..kernel_cols, ..kernel_cols]).to_owned());
765 let omega_constrained = duchon_constrained_bending_penalty(
766 centers.view(),
767 spec.length_scale,
768 spec.power,
769 effective_nullspace_order,
770 aniso.as_deref(),
771 &kernel_transform,
772 )?;
773 let (v, _mu) = thin_plate_radial_reparam_data_metric(&omega_constrained, &design_gram)?;
774 if v.ncols() > 0 {
775 kernel_transform = fast_ab(&kernel_transform, &v);
776 frozen_radial_reparam = Some(v);
777 }
778 }
779 let kernel_gauge = Arc::new(gam_problem::Gauge::from_block_transforms(&[
780 kernel_transform.clone(),
781 ]));
782 let base_op = ChunkedKernelDesignOperator::new(
783 shared_data,
784 Arc::new(centers.clone()),
785 make_kernel(),
786 Some(kernel_gauge),
787 Some(Arc::new(poly_block)),
788 workspace.policy().material_policy(),
789 )
790 .map_err(BasisError::InvalidInput)?;
791 let base_design = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
792 Arc::new(base_op),
793 ));
794 let identifiability_transform = spatial_identifiability_transform_from_design_matrix(
795 data,
796 &base_design,
797 &spec.identifiability,
798 "Duchon",
799 )?;
800 let design = if let Some(transform) = identifiability_transform.as_ref() {
801 wrap_dense_design_with_transform(base_design, transform, "Duchon")?
802 } else {
803 base_design
804 };
805 (design, identifiability_transform)
806 } else {
807 // #1355: dense path applies the data-metric radial reparameterization
808 // `V` (mirroring the thin-plate Wood-TPRS reparam) so the native
809 // penalty's cliff-less Mercer spectrum is replaced by the
810 // curvature-per-unit-data-variance spectrum (mgcv's cliff), removing the
811 // REML over-smoothing collapse to EDF = 1. `V` is frozen at the cold
812 // build and replayed verbatim from `spec.radial_reparam` on the
813 // predict / κ-trial paths.
814 // A FRESH `V` is computed only when no frozen reparam was supplied
815 // (`frozen_radial_reparam` already folded above on the replay paths). At
816 // that point `kernel_transform` is still the raw `Z`.
817 //
818 // The reparam is adopted for EVERY configuration, including the default
819 // all-on Hilbert scale (mass+tension active). The frozen `V` is threaded
820 // into the operator collocation builder (`duchon_operator_penalty_candidates`
821 // → `build_duchon_collocation_operator_matriceswithworkspace`) so the
822 // mass/tension blocks are assembled directly in the same `K·Z·V` frame as
823 // the design and the native `Primary` penalty — no design↔penalty desync.
824 // Skipping the reparam whenever operators were active (the old gate) left
825 // the default Duchon on the raw cliff-less Mercer spectrum, so REML
826 // over-selected EDF (a single 2-D bump fit to EDF≈30/49), which in turn
827 // made the fit a knife-edge unstable to ulp-level covariate rotation and
828 // unable to collapse toward the null on an irrelevant covariate. Restoring
829 // the cliff for the default is what makes those recoveries hold.
830 // When the fresh data-metric reparam is computed, its `raw` (un-rotated)
831 // design is built here from a full `n×k` kernel evaluation. That SAME
832 // realized design is the base of the final basis — rotating it by the
833 // adopted `V` gives the fit-time design without a second kernel pass —
834 // so carry it forward instead of rebuilding it below (#1718). This
835 // halves the cold-build kernel work for explicit native-only Duchon
836 // configurations (`all_disabled()`, no frozen reparam), closing their
837 // wall-time gap to `thinplate(x, z)` without changing default terms.
838 // The chart resolution itself lives in `duchon_resolve_radial_chart`, so
839 // the ψ-derivative context resolves the IDENTICAL frame from the same
840 // `(data, spec)` instead of assuming the raw `Z` (#2638).
841 let mut prebuilt_raw_basis: Option<Array2<f64>> = None;
842 if spectral_basis.is_none()
843 && frozen_radial_reparam.is_none()
844 && kernel_transform.ncols() > 0
845 {
846 let resolved = duchon_resolve_radial_chart(
847 data,
848 centers.view(),
849 spec,
850 effective_nullspace_order,
851 aniso.as_deref(),
852 &kernel_transform,
853 workspace,
854 )?;
855 if let Some(v) = resolved.reparam {
856 kernel_transform = fast_ab(&kernel_transform, &v);
857 frozen_radial_reparam = Some(v);
858 }
859 prebuilt_raw_basis = Some(resolved.basis);
860 }
861 let basis = if let Some(basis) = prebuilt_raw_basis {
862 basis
863 } else {
864 build_duchon_basis_designwithworkspace(
865 data,
866 centers.view(),
867 spec.length_scale,
868 spec.power,
869 effective_nullspace_order,
870 aniso.as_deref(),
871 frozen_radial_reparam.as_ref(),
872 realized_spectral_basis
873 .as_ref()
874 .and_then(DuchonSpectralBasis::kernel_transform),
875 workspace,
876 )?
877 .basis
878 };
879 let identifiability_transform = spatial_identifiability_transform_from_design(
880 data,
881 basis.view(),
882 &spec.identifiability,
883 "Duchon",
884 )?;
885 let design = if let Some(z) = identifiability_transform.as_ref() {
886 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(fast_ab(
887 &basis, z,
888 )))
889 } else {
890 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(basis))
891 };
892 (design, identifiability_transform)
893 };
894 // The Duchon penalty is a HILBERT SCALE of pure function-penalties, each a
895 // plain block with its own REML λ (REML deselects what the data don't need):
896 // * curvature = the EXACT RKHS reproducing-norm Gram (`Primary`), `n`-free;
897 // * trend = the affine null-space slope ridge (`DoublePenaltyNullspace`);
898 // * tension `Σ‖∇f‖²` + mass `Σ(f−f̄)²` = collocated on a density-blind `O(k)`
899 // farthest-point sample of the data support (their continuous integrals
900 // diverge for the polyharmonic kernel, so the support quadrature *is* the
901 // penalty — `O(k)`-in-`n`, not the old sparse-center collocation that
902 // under-resolved the basis and exploded).
903 let operator_collocation_points = {
904 let any_operator = matches!(
905 spec.operator_penalties.mass,
906 OperatorPenaltySpec::Active { .. }
907 ) || matches!(
908 spec.operator_penalties.tension,
909 OperatorPenaltySpec::Active { .. }
910 ) || matches!(
911 spec.operator_penalties.stiffness,
912 OperatorPenaltySpec::Active { .. }
913 );
914 if any_operator {
915 let m = (DUCHON_COLLOCATION_OVERSAMPLE * centers.nrows()).min(data.nrows());
916 Some(select_thin_plate_knots(data, m)?)
917 } else {
918 None
919 }
920 };
921 let mut candidates = duchon_native_penalty_candidates_with_curvature(
922 centers.view(),
923 spec.length_scale,
924 spec.power,
925 effective_nullspace_order,
926 aniso.as_deref(),
927 &kernel_transform,
928 identifiability_transform.as_ref(),
929 spectral_bending_penalty.as_ref(),
930 )?;
931 if let Some(points) = operator_collocation_points.as_ref() {
932 candidates.extend(duchon_operator_penalty_candidates(
933 points.view(),
934 centers.view(),
935 &spec.operator_penalties,
936 spec.length_scale,
937 spec.power,
938 effective_nullspace_order,
939 aniso.is_some(),
940 identifiability_transform.as_ref(),
941 frozen_radial_reparam.as_ref(),
942 workspace,
943 )?);
944 }
945 let filtered = filter_penalty_candidates(candidates)?;
946 Ok(BasisBuildResult {
947 design,
948 affine_offset: None,
949 active_penalties: filtered.active,
950 dropped_penalties: filtered.dropped,
951 joint_null_rotation: None,
952 metadata: BasisMetadata::Duchon {
953 centers,
954 // The builder standardizes nothing of its own — it emits
955 // `input_scale: ONE` — so the range it was handed IS this
956 // metadata's original-units range. The term-collection wrapper
957 // that DID standardize replaces the scale and the range together
958 // (`term_specs.rs`), keeping the tag honest on both sides.
959 length_scale: spec.length_scale.map(crate::OriginalUnits::new),
960 periodic: spec.periodic.clone(),
961 power: spec.power,
962 nullspace_order: effective_nullspace_order,
963 identifiability_transform,
964 input_scale: crate::IsotropicScale::ONE,
965 aniso_log_scales: aniso,
966 operator_collocation_points,
967 radial_reparam: frozen_radial_reparam,
968 spectral_basis: realized_spectral_basis,
969 },
970 kronecker_factored: None,
971 })
972}
973
974/// Rebuild the Duchon penalty list at a NEW `length_scale` purely from FROZEN
975/// basis geometry — no data rows touched (#1033, n-free per-ψ penalty re-key).
976///
977/// The κ-loop fast path skips the n-row `reset_surface`, so it needs `S(ψ_new)`
978/// reconstructed exactly and `n`-free at each trial length-scale. This mirrors
979/// the cold penalty assembly (`build_duchon_basis_uncached` lines ~345-396)
980/// EXACTLY, but every input is taken from the already-frozen
981/// `BasisMetadata::Duchon` (centers, identifiability transform, operator
982/// collocation points) plus the spec's `(power, nullspace_order,
983/// aniso_log_scales, operator_penalties)`. The only thing that moves is
984/// `length_scale`.
985///
986/// The polynomial-column count is `C(d + r, r)` — a pure function of `(d, r)` —
987/// so it is recomputed from the centers (`polynomial_block_from_order(centers,
988/// order).ncols()`), which equals the cold build's `polynomial_block_from_order(
989/// data, order).ncols()` because `.ncols()` does not depend on the row count.
990///
991/// Returns the per-block penalty matrices (term-local frame, same order/count
992/// the cold build emits) and the active per-block nullspace dims — exactly the
993/// objects the cold build feeds into `filter_penalty_candidates`.
994pub fn duchon_penalties_at_length_scale(
995 centers: ArrayView2<'_, f64>,
996 identifiability_transform: Option<&Array2<f64>>,
997 operator_collocation_points: Option<ArrayView2<'_, f64>>,
998 operator_penalties: &DuchonOperatorPenaltySpec,
999 power: f64,
1000 nullspace_order: DuchonNullspaceOrder,
1001 aniso_log_scales: Option<&[f64]>,
1002 radial_reparam: Option<&Array2<f64>>,
1003 length_scale: Option<f64>,
1004 workspace: &mut BasisWorkspace,
1005) -> Result<(Vec<Array2<f64>>, Vec<usize>), BasisError> {
1006 // Recompute the effective order + auto-seeded anisotropy exactly as the cold
1007 // build does (duchon_thinplate.rs:151/159). Both are pure functions of the
1008 // frozen centers + spec, so the κ trial replays the SAME structural choices.
1009 let effective_nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
1010 let aniso = auto_seed_aniso_contrasts(centers, aniso_log_scales);
1011 // n-free kernel-constraint nullspace (from centers; cached on the workspace).
1012 let mut kernel_transform =
1013 kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?;
1014 // #1355: fold the frozen data-metric reparam `Z' = Z·V` so the κ-trial
1015 // penalty `Z'ᵀ K_CC(ψ) Z' = diag(μ(ψ))` matches the rotated design.
1016 if let Some(v) = radial_reparam {
1017 if v.nrows() != kernel_transform.ncols() {
1018 crate::bail_dim_basis!(
1019 "Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
1020 v.dim(),
1021 kernel_transform.ncols()
1022 );
1023 }
1024 kernel_transform = fast_ab(&kernel_transform, v);
1025 }
1026 let mut candidates = duchon_native_penalty_candidates(
1027 centers,
1028 length_scale,
1029 power,
1030 effective_nullspace_order,
1031 aniso.as_deref(),
1032 &kernel_transform,
1033 identifiability_transform,
1034 )?;
1035 if let Some(points) = operator_collocation_points {
1036 candidates.extend(duchon_operator_penalty_candidates(
1037 points,
1038 centers,
1039 operator_penalties,
1040 length_scale,
1041 power,
1042 effective_nullspace_order,
1043 aniso.is_some(),
1044 identifiability_transform,
1045 radial_reparam,
1046 workspace,
1047 )?);
1048 }
1049 let filtered = filter_penalty_candidates(candidates)?;
1050 Ok((
1051 filtered
1052 .active
1053 .iter()
1054 .map(|penalty| penalty.matrix.clone())
1055 .collect(),
1056 filtered
1057 .active
1058 .iter()
1059 .map(|penalty| penalty.nullity)
1060 .collect(),
1061 ))
1062}
1063
1064/// Materialise the polynomial null-space block for a Duchon basis.
1065///
1066/// Returns an `(n, C(d+r, r))` matrix whose columns are all monomials of total
1067/// degree `≤ r` evaluated at `points`, where `r` is the degree implied by
1068/// `order` and `d = points.ncols()`.
1069///
1070/// | `order` | columns | content |
1071/// |----------------|----------------|------------------------------|
1072/// | `Zero` | 1 | constant `1` |
1073/// | `Linear` | `d + 1` | `[1, x₁, …, x_d]` |
1074/// | `Degree(k)` | `C(d+k, k)` | all monomials ≤ degree `k` |
1075///
1076/// **Role in basis construction:**
1077/// At *centers*, this block forms the side-condition matrix `Q` whose null
1078/// space `null(Q^T)` is the kernel reparameterisation transform `Z`. At
1079/// *data rows*, the same block is appended as explicit unpenalized columns so
1080/// the smooth can represent low-degree polynomial trends. The column count
1081/// equals `C(d + r, r)` by the stars-and-bars identity.
1082pub(crate) fn polynomial_block_from_order(
1083 points: ArrayView2<'_, f64>,
1084 order: DuchonNullspaceOrder,
1085) -> Array2<f64> {
1086 let n = points.nrows();
1087 let d = points.ncols();
1088 match order {
1089 DuchonNullspaceOrder::Zero => Array2::<f64>::ones((n, 1)),
1090 DuchonNullspaceOrder::Linear => {
1091 let mut poly = Array2::<f64>::zeros((n, d + 1));
1092 poly.column_mut(0).fill(1.0);
1093 for c in 0..d {
1094 poly.column_mut(c + 1).assign(&points.column(c));
1095 }
1096 poly
1097 }
1098 DuchonNullspaceOrder::Degree(degree) => monomial_basis_block(points, degree),
1099 }
1100}
1101
1102/// How far the Duchon range floor sits above the spectral rank cutoff it is
1103/// defined relative to: two decades.
1104///
1105/// The margin has to survive the later identifiability congruence `Tᵀ(·)T` and
1106/// the Frobenius renormalization that happen between this floor and the point
1107/// where the assembled block's rank is scored, while staying far below the
1108/// statistical scale. It is a margin on the cutoff, never a magnitude of its
1109/// own — hence a multiplier rather than a second literal.
1110const RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF: f64 = 100.0;
1111
1112/// Range-floor the reparam'd Duchon Primary curvature block so its numerical
1113/// null space is exactly the polynomial null space, not inflated by the
1114/// ill-conditioned kernel Gram's low-curvature tail.
1115///
1116/// The default duchon adopts the data-metric radial reparam `V`, so the Primary
1117/// penalty kernel block is `Vᵀ Ω_c V` — diagonal in the `μ` (generalized
1118/// curvature) eigenvalues. The Duchon polyharmonic Gram is extremely
1119/// ill-conditioned (cond ≫ 1e10 at k=20), so most `μ` fall far below the
1120/// numerical-rank cutoff [`spectral_tolerance`] that
1121/// [`analyze_penalty_block`] uses to partition range vs null. Those genuine
1122/// low-curvature directions are then mis-classified as UNPENALIZED null:
1123/// retained in the design (they clear the SEPARATE `k·ε` design-support floor)
1124/// but shrinkable by NO `λ`, so the smooth cannot collapse toward the null on an
1125/// irrelevant covariate (measured `nulldim = 19` vs the affine `{1,x} = 2`
1126/// expected on the gam#1815 null-recovery fixture) and REML over-selects EDF.
1127///
1128/// Lift the smallest eigenvalues to a relative floor
1129/// [`RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF`] times that cutoff, evaluated at
1130/// the EMBEDDED penalty dimension (kernel+poly), so the floor clears the
1131/// tolerance the assembled block is scored against and every retained mode is a
1132/// genuine — if weak — penalized `Range` direction; REML's `λ→∞` tail then
1133/// collapses them. The floor is far below the
1134/// statistical scale and lifts only the lowest-curvature (near-linear) modes, so
1135/// signal recovery (e.g. the sin8 centers=50 escape) is unchanged — the
1136/// high-curvature signal modes sit orders of magnitude above the floor.
1137pub(crate) fn duchon_range_floor_curvature(
1138 omega: &Array2<f64>,
1139 embedded_penalty_dim: usize,
1140) -> Result<Array2<f64>, BasisError> {
1141 let n = omega.nrows();
1142 if n == 0 {
1143 return Ok(omega.clone());
1144 }
1145 let sym = symmetrize_penalty(omega);
1146 let (mut evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
1147 let lam_max = evals.iter().copied().fold(0.0_f64, |a, v| a.max(v.abs()));
1148 if !lam_max.is_finite() || lam_max <= 0.0 {
1149 return Ok(sym);
1150 }
1151 // Read the cutoff from the same helper `analyze_penalty_block` scores this
1152 // block with, at the EMBEDDED dimension, and lift by the stated margin.
1153 // Writing the product out as a literal is what let the doc comment above
1154 // drift to "one decade" while the code kept two.
1155 let floor = RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF
1156 * spectral_tolerance_for_dim(embedded_penalty_dim.max(n), &evals);
1157 let mut floored = false;
1158 for v in evals.iter_mut() {
1159 if v.is_finite() && *v < floor {
1160 *v = floor;
1161 floored = true;
1162 }
1163 }
1164 if !floored {
1165 return Ok(sym);
1166 }
1167 // Reconstruct `U diag(evals) Uᵀ` with the floored spectrum.
1168 let mut out = Array2::<f64>::zeros((n, n));
1169 for j in 0..n {
1170 let lam = evals[j];
1171 for a in 0..n {
1172 let ua = evecs[[a, j]];
1173 if ua == 0.0 {
1174 continue;
1175 }
1176 for b in 0..n {
1177 out[[a, b]] += ua * lam * evecs[[b, j]];
1178 }
1179 }
1180 }
1181 Ok(symmetrize_penalty(&out))
1182}
1183
1184/// First and second log-κ (ψ) derivatives of the range-floored curvature Gram.
1185///
1186/// The forward `duchon_native_penalty_candidates` ships the `Primary` block as
1187/// `range_floor(Ω(ψ))`, where `range_floor` clamps every eigenvalue below
1188/// `floor(ψ) = max(embedded_dim, n)·1e-8·λ_max(Ω(ψ))` up to that floor (#1815).
1189/// The clamp is a spectral function `Ω ↦ U max(Λ, φ) Uᵀ` whose threshold `φ`
1190/// itself moves with ψ (through `λ_max`), so its ψ-derivative is NOT the plain
1191/// `Ω'`: the near-null curvature modes — precisely the high-frequency modes with
1192/// the *largest* `λ'` — are pinned to `φ(ψ)`, killing their own derivative and
1193/// replacing it with `φ' = c·λ_max'`. Omitting this makes the analytic Primary
1194/// log-κ gradient overstate the true (floored) penalty derivative by ~60% on a
1195/// 1-D hybrid Duchon, desyncing the outer REML gradient from the cost it is
1196/// built on. This helper differentiates the clamp exactly via the
1197/// Daleckii–Krein calculus (first and second Fréchet derivatives of a spectral
1198/// function) plus the explicit `φ(ψ)` dependence.
1199pub(crate) struct RangeFloorPsiJet {
1200 pub value: Array2<f64>,
1201 pub first: Array2<f64>,
1202 pub second: Array2<f64>,
1203}
1204
1205pub(crate) fn duchon_range_floor_curvature_psi_jet(
1206 omega: &Array2<f64>,
1207 omega_psi: &Array2<f64>,
1208 omega_psi_psi: &Array2<f64>,
1209 embedded_penalty_dim: usize,
1210) -> Result<RangeFloorPsiJet, BasisError> {
1211 let n = omega.nrows();
1212 let sym = symmetrize_penalty(omega);
1213 let sym_psi = symmetrize_penalty(omega_psi);
1214 let sym_psi_psi = symmetrize_penalty(omega_psi_psi);
1215 let passthrough = || RangeFloorPsiJet {
1216 value: sym.clone(),
1217 first: sym_psi.clone(),
1218 second: sym_psi_psi.clone(),
1219 };
1220 if n == 0 {
1221 return Ok(passthrough());
1222 }
1223 let (evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
1224 // Index of the eigenvalue of largest magnitude (mirrors `range_floor`'s
1225 // `λ_max = max|λ|`; for the PSD curvature Gram this is the top eigenvalue).
1226 let mut imax = 0usize;
1227 let mut lam_max = 0.0_f64;
1228 for (i, &v) in evals.iter().enumerate() {
1229 if v.abs() > lam_max {
1230 lam_max = v.abs();
1231 imax = i;
1232 }
1233 }
1234 if !lam_max.is_finite() || lam_max <= 0.0 {
1235 return Ok(passthrough());
1236 }
1237 let c = (embedded_penalty_dim.max(n) as f64) * 1e-8;
1238 let floor = c * lam_max;
1239 // No mode below the floor ⇒ the clamp is locally the identity, so the plain
1240 // derivatives pass through unchanged (matches `range_floor`'s early return).
1241 if !evals.iter().any(|&v| v.is_finite() && v < floor) {
1242 return Ok(passthrough());
1243 }
1244
1245 let u = &evecs;
1246 // Derivative matrices resolved into the eigenbasis: A = Uᵀ Ω' U, A2 = Uᵀ Ω'' U.
1247 let a = u.t().dot(&sym_psi).dot(u);
1248 let a2 = u.t().dot(&sym_psi_psi).dot(u);
1249
1250 // Moving threshold φ(ψ) = c·λ_max(ψ). Hellmann–Feynman for the (assumed
1251 // simple) extremal eigenvalue: λ_max' = sign·A_{imax,imax}; the standard
1252 // second-order eigenvalue perturbation gives λ_max''.
1253 let sign = if evals[imax] >= 0.0 { 1.0 } else { -1.0 };
1254 let tol = 1e-9 * lam_max;
1255 let lam_max_prime = sign * a[[imax, imax]];
1256 let mut lam_max_pp = a2[[imax, imax]];
1257 for k in 0..n {
1258 if k == imax {
1259 continue;
1260 }
1261 let denom = evals[imax] - evals[k];
1262 if denom.abs() > tol {
1263 lam_max_pp += 2.0 * a[[imax, k]] * a[[imax, k]] / denom;
1264 }
1265 }
1266 let lam_max_pp = sign * lam_max_pp;
1267 let floor_prime = c * lam_max_prime;
1268 let floor_pp = c * lam_max_pp;
1269
1270 // Scalar clamp g(λ) = max(λ, φ) and the indicator of the clamped subspace
1271 // (∂g/∂φ = 1 on clamped modes, 0 otherwise).
1272 let g = |lam: f64| lam.max(floor);
1273 let gprime = |lam: f64| if lam > floor { 1.0 } else { 0.0 };
1274 let clamped = |lam: f64| if lam <= floor { 1.0 } else { 0.0 };
1275
1276 // First divided difference of g (Daleckii–Krein weight for the implicit
1277 // Ω-dependence), and the same for the clamp indicator (∂Γ¹/∂φ).
1278 let fdd = |la: f64, lb: f64| -> f64 {
1279 if (la - lb).abs() > tol {
1280 (g(la) - g(lb)) / (la - lb)
1281 } else {
1282 gprime(0.5 * (la + lb))
1283 }
1284 };
1285 let fdd_clamp = |la: f64, lb: f64| -> f64 {
1286 if (la - lb).abs() > tol {
1287 (clamped(la) - clamped(lb)) / (la - lb)
1288 } else {
1289 0.0
1290 }
1291 };
1292 // Second divided difference of g (weight for the second Fréchet derivative).
1293 let sdd = |la: f64, lb: f64, lc: f64| -> f64 {
1294 if (la - lc).abs() > tol {
1295 (fdd(la, lb) - fdd(lb, lc)) / (la - lc)
1296 } else if (la - lb).abs() > tol {
1297 (gprime(la) * (la - lb) - (g(la) - g(lb))) / ((la - lb) * (la - lb))
1298 } else {
1299 0.0
1300 }
1301 };
1302
1303 // Assemble the eigenbasis blocks, then rotate back with U (·) Uᵀ.
1304 // Value: G = U diag(g(λ)) Uᵀ.
1305 let mut gam1 = Array2::<f64>::zeros((n, n)); // Γ¹ ⊙ · weight
1306 let mut gam_clamp = Array2::<f64>::zeros((n, n)); // ∂Γ¹/∂φ weight
1307 for i in 0..n {
1308 for j in 0..n {
1309 gam1[[i, j]] = fdd(evals[i], evals[j]);
1310 gam_clamp[[i, j]] = fdd_clamp(evals[i], evals[j]);
1311 }
1312 }
1313 // Clamped-subspace projector in the eigenbasis (diagonal).
1314 let clamp_diag: Vec<f64> = evals.iter().map(|&l| clamped(l)).collect();
1315
1316 // First derivative in the eigenbasis:
1317 // B1 = Γ¹ ⊙ A (implicit Ω-dependence, Daleckii–Krein)
1318 // + φ' · Π (explicit moving-threshold dependence)
1319 let mut b1 = &gam1 * &a;
1320 for i in 0..n {
1321 b1[[i, i]] += floor_prime * clamp_diag[i];
1322 }
1323
1324 // Second derivative in the eigenbasis:
1325 // B2 = Γ¹ ⊙ A2 (D_Ω h[Ω''])
1326 // + 2 · Σ_k sdd(λ_i,λ_k,λ_j) A_ik A_kj (D²_Ω h[Ω',Ω'])
1327 // + 2 φ' · (∂Γ¹/∂φ ⊙ A) (cross Ω–φ term)
1328 // + φ'' · Π (explicit φ'')
1329 let mut b2 = &gam1 * &a2;
1330 // second Fréchet block
1331 for i in 0..n {
1332 for j in 0..n {
1333 let mut acc = 0.0;
1334 for k in 0..n {
1335 acc += sdd(evals[i], evals[k], evals[j]) * a[[i, k]] * a[[k, j]];
1336 }
1337 b2[[i, j]] += 2.0 * acc;
1338 }
1339 }
1340 let cross = (&gam_clamp * &a).mapv(|v| 2.0 * floor_prime * v);
1341 b2 = b2 + cross;
1342 for i in 0..n {
1343 b2[[i, i]] += floor_pp * clamp_diag[i];
1344 }
1345
1346 // Value block diag(g(λ)).
1347 let mut value_eig = Array2::<f64>::zeros((n, n));
1348 for i in 0..n {
1349 value_eig[[i, i]] = g(evals[i]);
1350 }
1351
1352 let rotate = |m: &Array2<f64>| symmetrize_penalty(&u.dot(m).dot(&u.t()));
1353 Ok(RangeFloorPsiJet {
1354 value: rotate(&value_eig),
1355 first: rotate(&b1),
1356 second: rotate(&b2),
1357 })
1358}
1359
1360pub fn monomial_exponents(dimension: usize, max_total_degree: usize) -> Vec<Vec<usize>> {
1361 fn recurse(
1362 axis: usize,
1363 remaining_degree: usize,
1364 current: &mut [usize],
1365 out: &mut Vec<Vec<usize>>,
1366 ) {
1367 if axis + 1 == current.len() {
1368 current[axis] = remaining_degree;
1369 out.push(current.to_vec());
1370 return;
1371 }
1372 for exponent in (0..=remaining_degree).rev() {
1373 current[axis] = exponent;
1374 recurse(axis + 1, remaining_degree - exponent, current, out);
1375 }
1376 }
1377
1378 if dimension == 0 {
1379 return vec![Vec::new()];
1380 }
1381
1382 let mut out = Vec::new();
1383 let mut current = vec![0usize; dimension];
1384 for total_degree in 0..=max_total_degree {
1385 recurse(0, total_degree, &mut current, &mut out);
1386 }
1387 out
1388}
1389
1390pub fn duchon_nullspace_dimension(dimension: usize, max_total_degree: usize) -> usize {
1391 monomial_exponents(dimension, max_total_degree).len()
1392}
1393
1394pub(crate) fn monomial_basis_block(
1395 points: ArrayView2<'_, f64>,
1396 max_total_degree: usize,
1397) -> Array2<f64> {
1398 let n = points.nrows();
1399 let exponents = monomial_exponents(points.ncols(), max_total_degree);
1400 let mut block = Array2::<f64>::zeros((n, exponents.len()));
1401 for (col, exponents) in exponents.iter().enumerate() {
1402 for row in 0..n {
1403 let mut value = 1.0;
1404 for axis in 0..points.ncols() {
1405 let exponent = exponents[axis];
1406 if exponent != 0 {
1407 value *= points[[row, axis]].powi(exponent as i32);
1408 }
1409 }
1410 block[[row, col]] = value;
1411 }
1412 }
1413 block
1414}
1415
1416#[inline(always)]
1417pub(crate) fn thin_plate_polynomial_degree(dimension: usize) -> usize {
1418 thin_plate_penalty_order(dimension).saturating_sub(1)
1419}
1420
1421pub(crate) fn thin_plate_polynomial_block(points: ArrayView2<'_, f64>) -> Array2<f64> {
1422 monomial_basis_block(points, thin_plate_polynomial_degree(points.ncols()))
1423}
1424
1425pub fn thin_plate_polynomial_basis_dimension(dimension: usize) -> usize {
1426 monomial_exponents(dimension, thin_plate_polynomial_degree(dimension)).len()
1427}
1428
1429/// Row-order-canonical realized design Gram `symmetrize(KᵀK)` for the data-metric
1430/// radial reparam (#1347/#1355).
1431///
1432/// `KᵀK = Σ_row (row)ᵀ(row)` is mathematically invariant to a pure row
1433/// permutation of the training data, but `fast_atb` accumulates the outer
1434/// products in the kernel block's stored (data) row order, so floating-point
1435/// non-associativity lets a reordering perturb the Gram by an ulp. The reparam
1436/// eigendecomposition fed by this Gram is near-degenerate (the thin-plate radial
1437/// spectrum has a long low-curvature tail), so that ulp rotates its eigenvectors
1438/// and makes the fitted `s(x, bs="tp")` basis — and hence the curve — depend on
1439/// row order. That is the residual ~2e-7 row-permutation drift owed under
1440/// gam#1378 that survives the value-anchored knot set and centroid seed (the
1441/// local `bs="cr"/"ps"` bases never form this data-metric radial Gram, so they
1442/// stayed bit-stable). Summing the rows in a canonical lexicographic (`total_cmp`)
1443/// order gives the identical addition sequence for every permutation of the same
1444/// unordered row set — the rows are a pure function of the data and genuinely
1445/// equal rows contribute equal, order-free terms — so the Gram, its
1446/// eigendecomposition, and the reparam become bit-identical across row order.
1447fn data_metric_design_gram(kernel_block: ArrayView2<'_, f64>) -> Array2<f64> {
1448 let n = kernel_block.nrows();
1449 let mut order: Vec<usize> = (0..n).collect();
1450 order.sort_by(|&a, &b| {
1451 for c in 0..kernel_block.ncols() {
1452 match kernel_block[[a, c]].total_cmp(&kernel_block[[b, c]]) {
1453 std::cmp::Ordering::Equal => {}
1454 ord => return ord,
1455 }
1456 }
1457 std::cmp::Ordering::Equal
1458 });
1459 let sorted = kernel_block.select(Axis(0), &order);
1460 symmetrize_penalty(&fast_atb(&sorted, &sorted))
1461}
1462
1463/// Selects which radial penalty eigenmodes to expose as basis columns.
1464///
1465/// The constrained radial penalty `Ω` is SPD in exact arithmetic — the
1466/// polynomial null space `{1, x, …}` has already been removed by the gauge
1467/// restriction, so every nonzero eigenvalue is a genuine bending direction
1468/// and must be retained (this matches mgcv's thin-plate construction, which
1469/// keeps all `k − M` radial modes and relies on REML, not basis truncation,
1470/// to set the effective degrees of freedom). The only modes that are NOT
1471/// real curvature directions are **roundoff dust**: eigenvalues at or below
1472/// the LAPACK numerical-rank floor `K·ε·λ_max` (Golub & Van Loan, *Matrix
1473/// Computations*, §2.5.6) are exact zeros polluted by floating-point error
1474/// from the constraint restriction and carry no information.
1475///
1476/// The threshold is therefore the standard numerical-rank floor — derived,
1477/// scale-free, and tuning-free. It deliberately does NOT prune low-but-real
1478/// bending modes by magnitude: doing so was the #1271 hill-climb (a swept
1479/// `max_eval·tol` cutoff) that over-pruned the nonlinear arms (lidar /
1480/// by-factor truth recovery collapsed) while still missing the linear EDF
1481/// bar. The genuine over-fit on near-linear data is a REML smoothing issue
1482/// (the diagonalised radial penalty's wide eigenvalue spread under a single
1483/// `λ` leaves a flat REML profile, so the outer optimiser terminates at an
1484/// interior `λ` that under-smooths), not a basis-rank issue — pruning cannot
1485/// fix it without destroying the bending capacity real data needs.
1486fn thin_plate_retained_radial_indices(evals: &Array1<f64>) -> Vec<usize> {
1487 let k = evals.len();
1488 if k == 0 {
1489 return Vec::new();
1490 }
1491 let max_eval = evals
1492 .iter()
1493 .copied()
1494 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1495 if !max_eval.is_finite() || max_eval <= 0.0 {
1496 return Vec::new();
1497 }
1498 // Numerical-rank floor: anything at or below `K·ε·λ_max` is roundoff dust
1499 // from the gauge restriction, not a real bending mode. Everything above it
1500 // is genuine curvature and is kept.
1501 let num_floor = (k as f64) * f64::EPSILON * max_eval;
1502 evals
1503 .iter()
1504 .enumerate()
1505 .filter_map(|(idx, &value)| (value.abs() > num_floor).then_some(idx))
1506 .collect()
1507}
1508
1509pub(crate) fn thin_plate_radial_reparam_from_constrained_penalty(
1510 omega_constrained: &Array2<f64>,
1511) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
1512 let kernel_cols = omega_constrained.nrows();
1513 if kernel_cols != omega_constrained.ncols() {
1514 crate::bail_dim_basis!(
1515 "thin-plate constrained radial penalty must be square: got {:?}",
1516 omega_constrained.dim()
1517 );
1518 }
1519 if kernel_cols == 0 {
1520 return Ok((Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0)));
1521 }
1522 let sym = symmetrize_penalty(omega_constrained);
1523 let (mut evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
1524 for value in evals.iter_mut() {
1525 if *value < 0.0 {
1526 *value = 0.0;
1527 }
1528 }
1529 let keep = thin_plate_retained_radial_indices(&evals);
1530 Ok((evecs.select(Axis(1), &keep), evals.select(Axis(0), &keep)))
1531}
1532
1533/// Thin-plate radial reparameterization in the **realized data metric** (#1347).
1534///
1535/// The penalty is the polyharmonic bending energy `Ω_c = Zᵀ K_CC Z` (the RKHS
1536/// reproducing-norm Gram on the constrained kernel coefficients). gam's old
1537/// reparam eigendecomposed `Ω_c` alone, laying its raw eigenvalues on the
1538/// penalty diagonal. But the constraint `Z` has already quotiented out the
1539/// `{1, x}` polynomial null space, so `Ω_c` is full-rank with a smooth Mercer
1540/// tail and **no cliff** — its smallest eigenvalues are genuine low-curvature
1541/// bending directions that nonetheless carry large variance over the data.
1542/// Under a single REML `λ` those near-null modes cost almost nothing yet absorb
1543/// EDF freely, over-fitting near-linear data (mean EDF ≈ 5.3 vs mgcv ≈ 2.1).
1544///
1545/// mgcv's TPRS instead penalizes bending energy **relative to the realized
1546/// design metric** — equivalently it solves the generalized eigenproblem
1547///
1548/// ```text
1549/// Ω_c v = μ G_c v , G_c = (K Z)ᵀ (K Z)
1550/// ```
1551///
1552/// where `G_c` is the Gram of the realized constrained kernel design columns.
1553/// The eigenvalue `μ = (vᵀ Ω_c v)/(vᵀ G_c v)` is curvature per unit
1554/// data-variance: it spreads the spectrum the way mgcv's does (top mode, a
1555/// `0.77` second mode, then a clean geometric cliff to the tail), so a single
1556/// `λ` can no longer buy near-free wiggle. The returned eigenvectors `V` are
1557/// `G_c`-orthonormal (`Vᵀ G_c V = I`), so the rotated design `K Z V` has an
1558/// identity Gram and the penalty is exactly `diag(μ) = Vᵀ Ω_c V` — which the
1559/// frozen-replay / length-scale paths already recover via `diag(Vᵀ Ω_c V)`, so
1560/// no downstream change is needed. The model space `span(K Z V) = span(K Z)` is
1561/// unchanged (`V` is invertible), preserving full nonlinear capacity.
1562pub(crate) fn thin_plate_radial_reparam_data_metric(
1563 omega_constrained: &Array2<f64>,
1564 design_gram: &Array2<f64>,
1565) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
1566 let k = omega_constrained.nrows();
1567 if k != omega_constrained.ncols() || design_gram.nrows() != k || design_gram.ncols() != k {
1568 crate::bail_dim_basis!(
1569 "thin-plate data-metric reparam requires square k×k Ω_c and G_c: Ω_c={:?}, G_c={:?}",
1570 omega_constrained.dim(),
1571 design_gram.dim()
1572 );
1573 }
1574 if k == 0 {
1575 return Ok((Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0)));
1576 }
1577 // Whiten by G_c: G_c = U_g D_g U_gᵀ ; W = U_g D_g^{-1/2} (drop near-null G_c
1578 // directions, which are design columns with no realized data support).
1579 let g_sym = symmetrize_penalty(design_gram);
1580 let (g_evals, g_evecs) =
1581 FaerEigh::eigh(&g_sym, Side::Lower).map_err(BasisError::LinalgError)?;
1582 let gmax = g_evals.iter().copied().fold(0.0_f64, |a, b| a.max(b.abs()));
1583 if !gmax.is_finite() || gmax <= 0.0 {
1584 // Degenerate design Gram: fall back to the plain bending eigenbasis.
1585 return thin_plate_radial_reparam_from_constrained_penalty(omega_constrained);
1586 }
1587 let g_floor = (k as f64) * f64::EPSILON * gmax;
1588 let mut cols: Vec<usize> = Vec::with_capacity(k);
1589 for j in 0..k {
1590 if g_evals[j] > g_floor {
1591 cols.push(j);
1592 }
1593 }
1594 let m = cols.len();
1595 if m == 0 {
1596 return thin_plate_radial_reparam_from_constrained_penalty(omega_constrained);
1597 }
1598 let mut w = Array2::<f64>::zeros((k, m));
1599 for (c, &j) in cols.iter().enumerate() {
1600 let inv_sqrt = 1.0 / g_evals[j].sqrt();
1601 for i in 0..k {
1602 w[[i, c]] = g_evecs[[i, j]] * inv_sqrt;
1603 }
1604 }
1605 // M = Wᵀ Ω_c W (m×m), eig(M) = (μ, P). Generalized eigenvectors V = W P.
1606 let omega_sym = symmetrize_penalty(omega_constrained);
1607 let wt_omega = fast_atb(&w, &omega_sym);
1608 let m_mat = symmetrize_penalty(&fast_ab(&wt_omega, &w));
1609 let (mut mu, p_mat) = FaerEigh::eigh(&m_mat, Side::Lower).map_err(BasisError::LinalgError)?;
1610 for value in mu.iter_mut() {
1611 if *value < 0.0 {
1612 *value = 0.0;
1613 }
1614 }
1615 let v_full = fast_ab(&w, &p_mat); // k×m, G_c-orthonormal columns
1616 let keep = thin_plate_retained_radial_indices(&mu);
1617 Ok((v_full.select(Axis(1), &keep), mu.select(Axis(0), &keep)))
1618}
1619
1620/// The Duchon coefficient chart resolved against one `(data, spec)` pair: the
1621/// adopted data-metric radial reparameterization `V` together with the realized
1622/// pre-identifiability design expressed IN that chart.
1623///
1624/// See [`duchon_resolve_radial_chart`] for why this is a type rather than two
1625/// inlined blocks.
1626pub(crate) struct DuchonResolvedRadialChart {
1627 /// The adopted reparam `V`, or `None` when none was adopted (degenerate
1628 /// generalized eigenproblem, or no constrained kernel columns at all).
1629 pub(crate) reparam: Option<Array2<f64>>,
1630 /// The realized pre-identifiability design in the resolved chart:
1631 /// `[K·Z·V | P]` when `V` was adopted, `[K·Z | P]` otherwise.
1632 pub(crate) basis: Array2<f64>,
1633}
1634
1635/// Resolve the Duchon coefficient chart for a spec that does not carry one.
1636///
1637/// # Why this exists
1638///
1639/// `build_duchon_basis` ships every design column and every penalty in the
1640/// `Z·V` frame, where `V` is the data-metric radial reparameterization (#1355)
1641/// solving the generalized eigenproblem `Ω_c v = μ G_c v`. On a replay path
1642/// (`spec.radial_reparam = Some(V)`) that chart is handed in. On a COLD path it
1643/// is computed here — and until #2638 it was computed *only* here, inline in
1644/// the forward builder, which meant every other consumer of the same spec
1645/// silently assumed "no frozen reparam" ⇒ "no reparam", i.e. the raw `Z` frame.
1646///
1647/// That assumption is what broke the log-κ derivative surface. The ψ-jet
1648/// builders fold `V` only `if let Some(v) = spec.radial_reparam`, so on a cold
1649/// spec they assembled `dS/dψ` in the un-rotated `Z` frame — a right derivative
1650/// of a matrix the forward never ships. Measured on the `_no_ident` fixture at
1651/// ε = 1e-5: the returned Primary jet was 32× the true frozen-chart jet and the
1652/// OperatorMass jet 242× too small, with the whole residual accounted for by
1653/// chart motion (`|FD_cold − FD_frozen| = 2.49e-1` against a `|A − FD|` of
1654/// 1.6e-5 once both sides sit in the same chart).
1655///
1656/// Routing both the forward and the derivative context through this one
1657/// function makes the frame a property of `(data, spec)` rather than of which
1658/// builder you happened to call.
1659///
1660/// # Cost
1661///
1662/// One `n×k` kernel materialization, which the caller gets back in
1663/// [`DuchonResolvedRadialChart::basis`] — the rotated design is obtained as
1664/// `(K·Z)·V = K·(Z·V)` rather than by a second kernel pass (#1718).
1665pub(crate) fn duchon_resolve_radial_chart(
1666 data: ArrayView2<'_, f64>,
1667 centers: ArrayView2<'_, f64>,
1668 spec: &DuchonBasisSpec,
1669 effective_nullspace_order: DuchonNullspaceOrder,
1670 aniso: Option<&[f64]>,
1671 kernel_transform: &Array2<f64>,
1672 workspace: &mut BasisWorkspace,
1673) -> Result<DuchonResolvedRadialChart, BasisError> {
1674 // Build the un-rotated constrained kernel design once, take its realized
1675 // Gram `G_c = (K·Z)ᵀ(K·Z)`, and solve `Ω_c v = μ G_c v` with
1676 // `Ω_c = α²·ZᵀK_CC Z`.
1677 let raw = build_duchon_basis_designwithworkspace(
1678 data,
1679 centers,
1680 spec.length_scale,
1681 spec.power,
1682 effective_nullspace_order,
1683 aniso,
1684 None,
1685 None,
1686 workspace,
1687 )?;
1688 let kernel_cols = kernel_transform.ncols();
1689 if kernel_cols == 0 {
1690 return Ok(DuchonResolvedRadialChart {
1691 reparam: None,
1692 basis: raw.basis,
1693 });
1694 }
1695 let kernel_block = raw.basis.slice(s![.., 0..kernel_cols]);
1696 // Canonical row order so the realized Gram (and the near-degenerate reparam
1697 // it feeds) is bit-identical under a pure row permutation (#1378).
1698 let design_gram = data_metric_design_gram(kernel_block);
1699 let omega_constrained = duchon_constrained_bending_penalty(
1700 centers,
1701 spec.length_scale,
1702 spec.power,
1703 effective_nullspace_order,
1704 aniso,
1705 kernel_transform,
1706 )?;
1707 let (v, _mu) = thin_plate_radial_reparam_data_metric(&omega_constrained, &design_gram)?;
1708 // A degenerate reparam (no retained modes) would gut the basis; only adopt
1709 // `V` when it preserves at least one radial column.
1710 if v.ncols() == 0 {
1711 // No reparam adopted: `raw` already IS the fit-time design.
1712 return Ok(DuchonResolvedRadialChart {
1713 reparam: None,
1714 basis: raw.basis,
1715 });
1716 }
1717 // The fit-time design is `[K·Z·V | P] = [(K·Z)·V | P]`, where `K·Z` and `P`
1718 // are exactly the kernel/poly blocks of `raw` (the reparam only
1719 // right-multiplies the constrained kernel columns; the poly block is
1720 // reparam-independent). So rotate `raw`'s kernel block by `V` in place
1721 // rather than re-evaluating the kernel — the same model space the un-fused
1722 // rebuild would produce.
1723 let rotated_kernel = fast_ab(&raw.basis.slice(s![.., 0..kernel_cols]), &v);
1724 let poly_block = raw.basis.slice(s![.., kernel_cols..]);
1725 let mut fused = Array2::<f64>::zeros((
1726 raw.basis.nrows(),
1727 rotated_kernel.ncols() + poly_block.ncols(),
1728 ));
1729 fused
1730 .slice_mut(s![.., 0..rotated_kernel.ncols()])
1731 .assign(&rotated_kernel);
1732 if poly_block.ncols() > 0 {
1733 fused
1734 .slice_mut(s![.., rotated_kernel.ncols()..])
1735 .assign(&poly_block);
1736 }
1737 Ok(DuchonResolvedRadialChart {
1738 reparam: Some(v),
1739 basis: fused,
1740 })
1741}
1742
1743/// A `DuchonBasisSpec` with every ψ-invariant chart decision resolved against
1744/// the data, plus the artifacts those decisions produced.
1745///
1746/// See [`duchon_resolve_chart`].
1747#[derive(Clone, Debug)]
1748pub struct ResolvedDuchonChart {
1749 /// The input spec with `center_strategy` realized to `UserProvided`,
1750 /// `nullspace_order` degraded to the effective order, `aniso_log_scales`
1751 /// auto-seeded, `radial_reparam` set to the adopted `V`, and
1752 /// `identifiability` frozen to the realized transform.
1753 ///
1754 /// `build_duchon_basis(data, &resolved.spec)` reproduces
1755 /// `build_duchon_basis(data, spec)` — same design, same penalties, same
1756 /// metadata — because every decision the second build would re-make is
1757 /// already pinned in the first.
1758 pub spec: DuchonBasisSpec,
1759 /// The realized centers (periodic images expanded).
1760 pub centers: Array2<f64>,
1761 /// The realized identifiability transform, `None` when the spec asks for
1762 /// no constraint.
1763 pub identifiability_transform: Option<Array2<f64>>,
1764}
1765
1766/// Resolve every ψ-invariant chart decision a Duchon build makes, so a caller
1767/// can hand the SAME chart to the forward and to its ψ-derivative.
1768///
1769/// # The contract this exists to make expressible
1770///
1771/// A Duchon basis is not determined by `spec` alone. Four decisions are taken
1772/// against the data at build time and then baked into `BasisMetadata::Duchon`:
1773///
1774/// | decision | forward site |
1775/// |---|---|
1776/// | realized centers | `select_centers_by_strategy` |
1777/// | effective null-space order (degraded when the centers cannot span the requested polynomial block) | `duchon_effective_nullspace_order` |
1778/// | auto-seeded anisotropy contrasts | `auto_seed_aniso_contrasts` |
1779/// | data-metric radial reparam `V` (#1355) | `duchon_resolve_radial_chart` |
1780/// | identifiability transform `T`, derived from the **`V`-rotated** design | `spatial_identifiability_transform_from_design` |
1781///
1782/// Until #2638 the log-κ derivative context re-made only the first of these and
1783/// took the raw spec value for the rest: it passed `spec.nullspace_order`
1784/// (not the effective order), `spec.aniso_log_scales` (not the seeded
1785/// contrasts), `None` for the reparam, and derived `T` from the **un**-rotated
1786/// design. So `build_duchon_basis_log_kappa_derivatives(data, spec)` returned
1787/// the ψ-jet of a basis `build_duchon_basis(data, spec)` does not build. The
1788/// dominant term was the reparam: measured on a 1-D `power=1`, `Linear`
1789/// fixture, the returned Primary jet was 32× the true jet and the OperatorMass
1790/// jet 242× too small, each in the wrong coefficient frame.
1791///
1792/// Resolving once and passing the resolved spec everywhere makes that class of
1793/// desync unrepresentable rather than merely fixed: a builder that reads
1794/// `spec.radial_reparam` from a resolved spec cannot see `None` where the
1795/// forward saw a `V`.
1796///
1797/// # Cost
1798///
1799/// One dense `n×k` kernel materialization (the design that feeds the `V`
1800/// eigenproblem and the identifiability test). This is the same pass the
1801/// derivative context already paid; the resolver reuses it for both.
1802pub fn duchon_resolve_chart(
1803 data: ArrayView2<'_, f64>,
1804 spec: &DuchonBasisSpec,
1805 workspace: &mut BasisWorkspace,
1806) -> Result<ResolvedDuchonChart, BasisError> {
1807 let original_centers = select_centers_by_strategy(data, &spec.center_strategy)?;
1808 let centers = expand_periodic_centers(&original_centers, spec.periodic.as_deref())?;
1809 assert_spatial_centers_below_large_scale_cap(data.ncols(), centers.view())?;
1810 let effective_nullspace_order =
1811 duchon_effective_nullspace_order(centers.view(), spec.nullspace_order);
1812 let aniso = auto_seed_aniso_contrasts(centers.view(), spec.aniso_log_scales.as_deref());
1813
1814 let mut resolved = spec.clone();
1815 resolved.center_strategy = CenterStrategy::UserProvided(centers.clone());
1816 resolved.nullspace_order = effective_nullspace_order;
1817 resolved.aniso_log_scales = aniso.clone();
1818
1819 // The pre-identifiability design IN the resolved radial chart. On a replay
1820 // spec the chart is handed in; on a cold spec it is solved for here by the
1821 // same helper the forward uses, so the two frames agree by construction.
1822 let basis = match spec.radial_reparam.as_ref() {
1823 Some(v) => {
1824 build_duchon_basis_designwithworkspace(
1825 data,
1826 centers.view(),
1827 spec.length_scale,
1828 spec.power,
1829 effective_nullspace_order,
1830 aniso.as_deref(),
1831 Some(v),
1832 None,
1833 workspace,
1834 )?
1835 .basis
1836 }
1837 None => {
1838 let kernel_transform = kernel_constraint_nullspace(
1839 centers.view(),
1840 effective_nullspace_order,
1841 &mut workspace.cache,
1842 )?;
1843 if kernel_transform.ncols() == 0 {
1844 build_duchon_basis_designwithworkspace(
1845 data,
1846 centers.view(),
1847 spec.length_scale,
1848 spec.power,
1849 effective_nullspace_order,
1850 aniso.as_deref(),
1851 None,
1852 None,
1853 workspace,
1854 )?
1855 .basis
1856 } else {
1857 let chart = duchon_resolve_radial_chart(
1858 data,
1859 centers.view(),
1860 spec,
1861 effective_nullspace_order,
1862 aniso.as_deref(),
1863 &kernel_transform,
1864 workspace,
1865 )?;
1866 resolved.radial_reparam = chart.reparam;
1867 chart.basis
1868 }
1869 }
1870 };
1871
1872 // `T` is a property of the REALIZED design, so it must be read off the
1873 // rotated basis — the forward derives it after folding `V`, and a `T` built
1874 // on the un-rotated columns constrains a different function space.
1875 let identifiability_transform = spatial_identifiability_transform_from_design(
1876 data,
1877 basis.view(),
1878 &spec.identifiability,
1879 "Duchon",
1880 )?;
1881 resolved.identifiability = match identifiability_transform.as_ref() {
1882 Some(transform) => SpatialIdentifiability::FrozenTransform {
1883 transform: transform.clone(),
1884 },
1885 None => SpatialIdentifiability::None,
1886 };
1887
1888 Ok(ResolvedDuchonChart {
1889 spec: resolved,
1890 centers,
1891 identifiability_transform,
1892 })
1893}
1894
1895pub(crate) fn thin_plate_radial_reparam_from_centers(
1896 centers: ArrayView2<'_, f64>,
1897 length_scale: f64,
1898 kernel_transform: &Array2<f64>,
1899) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
1900 let k = centers.nrows();
1901 let d = centers.ncols();
1902 let mut omega = Array2::<f64>::zeros((k, k));
1903 let length_scale_sq = length_scale * length_scale;
1904 fill_symmetric_from_row_kernel(&mut omega, |i, j| {
1905 let mut dist2 = 0.0;
1906 for c in 0..d {
1907 let delta = centers[[i, c]] - centers[[j, c]];
1908 dist2 += delta * delta;
1909 }
1910 thin_plate_kernel_from_dist2(dist2 / length_scale_sq, d)
1911 })?;
1912 let kernel_gauge = gam_problem::Gauge::from_block_transforms(&[kernel_transform.clone()]);
1913 let omega_constrained = symmetrize_penalty(&kernel_gauge.restrict_penalty(&omega));
1914 thin_plate_radial_reparam_from_constrained_penalty(&omega_constrained)
1915}
1916
1917pub(crate) fn kernel_constraint_nullspace_from_matrix(
1918 constraint_matrix: ArrayView2<'_, f64>,
1919) -> Result<Array2<f64>, BasisError> {
1920 let k = constraint_matrix.nrows();
1921 let q = constraint_matrix.ncols();
1922 if q == 0 {
1923 return Ok(Array2::<f64>::eye(k));
1924 }
1925 // Constraint system Q^T alpha = 0. The trailing columns of the orthogonal
1926 // factor in a column-pivoted QR of Q span null(Q^T).
1927 let (z, _) = rrqr_nullspace_basis(&constraint_matrix, default_rrqr_rank_alpha())
1928 .map_err(BasisError::LinalgError)?;
1929 Ok(z)
1930}
1931
1932/// Relative tolerance (against the data's squared radius) below which two
1933/// farthest-point candidates' maximin — or centroid — distances are treated as
1934/// *tied* and resolved by the rotation/permutation-invariant support-distance
1935/// profile rather than by their exact floating-point ordering.
1936///
1937/// A generic (non-90°) rigid rotation of the covariates re-expresses every
1938/// coordinate with ~1 ulp of round-off, so the squared distances that drive the
1939/// farthest-point recursion differ from their exact rotation-invariant values by
1940/// ~`ε·‖x‖²`. This tolerance is set several orders of magnitude above that
1941/// round-off floor yet far below any genuine gap between geometrically-distinct
1942/// candidates, so it absorbs the sub-ulp perturbation without altering the
1943/// selection on data whose maximin values are genuinely separated.
1944const KNOT_MAXIMIN_TIE_REL_TOL: f64 = 1e-9;
1945
1946/// Deterministically selects thin-plate knots via farthest-point sampling.
1947///
1948/// This produces a space-filling subset without introducing RNG/state coupling.
1949///
1950/// Each step minimizes its composite key in extremum-then-refine order rather
1951/// than by carrying a running incumbent: the two `O(1)` keys first, then — only
1952/// over the rows that attain them, and only if there is more than one — the
1953/// `O(n·d + n log n)` sorted support-distance profile. Lexicographic
1954/// minimization is associative, so this is the same total preorder the incumbent
1955/// scan applied; what changes is that the profile key is charged where it can
1956/// still decide something instead of four times per outer iteration whether or
1957/// not anything is tied. On data with no exact symmetry it is never built at all
1958/// (#2420, Euclidean twin of the spherical selector's fix).
1959pub fn select_thin_plate_knots(
1960 data: ArrayView2<f64>,
1961 num_knots: usize,
1962) -> Result<Array2<f64>, BasisError> {
1963 let d = data.ncols();
1964 let (selected, _profile_builds) = select_thin_plate_knot_rows(data, num_knots)?;
1965 let mut knots = Array2::<f64>::zeros((selected.len(), d));
1966 for (r, &idx) in selected.iter().enumerate() {
1967 knots.row_mut(r).assign(&data.row(idx));
1968 }
1969 Ok(knots)
1970}
1971
1972/// [`select_thin_plate_knots`] as the row indices it selects, paired with the
1973/// number of `O(n·d + n log n)` support-distance profiles the shared tie-break
1974/// actually built getting there.
1975///
1976/// The count is a plain second return value rather than an observer callback:
1977/// production ignores it, and tests read it to state the tie-break's cost
1978/// contract in operation counts rather than in wall-clock noise — over the same
1979/// production code path either way.
1980fn select_thin_plate_knot_rows(
1981 data: ArrayView2<f64>,
1982 num_knots: usize,
1983) -> Result<(Vec<usize>, usize), BasisError> {
1984 let mut profile_builds = 0usize;
1985 let n = data.nrows();
1986 let d = data.ncols();
1987 if d == 0 {
1988 crate::bail_invalid_basis!("thin-plate spline requires at least one covariate dimension");
1989 }
1990 if n == 0 {
1991 crate::bail_invalid_basis!("cannot select thin-plate knots from empty data");
1992 }
1993 if data.iter().any(|v| !v.is_finite()) {
1994 crate::bail_invalid_basis!("thin-plate spline knot selection requires finite data");
1995 }
1996 if num_knots == 0 {
1997 crate::bail_invalid_basis!("thin-plate spline knot count must be positive");
1998 }
1999 if num_knots > n {
2000 crate::bail_invalid_basis!(
2001 "requested {} knots but only {} rows are available",
2002 num_knots,
2003 n
2004 );
2005 }
2006
2007 // Rotation-equivariant maximin seed. The greedy farthest-point recursion
2008 // below uses ONLY Euclidean distances, which are invariant under any rigid
2009 // rotation of the covariates, so the only frame-dependent ingredients of
2010 // the selected knot set are the seed point and the tie-break. A thin-plate
2011 // spline is mathematically *exactly* rotation-invariant — its `r^{2m-d}`
2012 // (log r) kernel depends only on the pairwise distance `r`, and its
2013 // polynomial null space `span{1, x, …}` is mapped onto itself by any
2014 // orthogonal map — so rotating the data must leave the fitted surface
2015 // unchanged, which requires the knot SET to be rotation-invariant. The old
2016 // lexicographically-smallest-coordinate seed broke exactly this: a rigid
2017 // rotation changes which row is "lexicographically smallest", reseeding the
2018 // recursion at a different physical point and selecting a genuinely
2019 // different knot set — a 90° rotation about the centroid drifted the
2020 // default `thinplate(x, z)` surface by ~2% of its range while a pure row
2021 // permutation was bit-stable.
2022 //
2023 // Seed at the row nearest the data centroid instead. The centroid is
2024 // rotation-EQUIVARIANT (it rotates rigidly with the data) and the
2025 // nearest-row test is a Euclidean distance, so the SAME physical row is
2026 // chosen in every rotated frame; both are pure functions of the unordered
2027 // value set, so the seed also stays row-permutation invariant (gam#1378).
2028 //
2029 // The column sum is taken in CANONICAL (value-sorted) order rather than row
2030 // order. A plain `for i in 0..n { s += data[[i, c]] }` accumulates in the
2031 // data's ROW order, so floating-point round-off makes the result depend on
2032 // that order: a pure row permutation re-sequences the additions and shifts
2033 // the mean by an ulp. That ulp is enough to break the EXACT equidistance of
2034 // points that are symmetric about the mean (the common 1-D case), so the
2035 // `dist2_to_centroid` comparisons below stop reducing to the
2036 // value-lexicographic tie-break and the seed — and hence the whole knot set
2037 // — flips with row order. That is the residual ~1e-7 `s(x, bs="tp")`
2038 // row-permutation drift owed under gam#1378 (value-anchored `bs="cr"/"ps"`
2039 // stayed bit-stable because they never seed off this centroid). Sorting the
2040 // column values yields the identical addition sequence for every permutation
2041 // of the same data — all values are finite (guarded above), so `total_cmp`
2042 // is a total order — restoring a bit-identical, order-independent centroid.
2043 let centroid: Vec<f64> = (0..d)
2044 .map(|c| {
2045 let mut col: Vec<f64> = (0..n).map(|i| data[[i, c]]).collect();
2046 col.sort_by(|a, b| a.total_cmp(b));
2047 let s: f64 = col.iter().sum();
2048 s / n as f64
2049 })
2050 .collect();
2051 let dist2_to_centroid: Vec<f64> = (0..n)
2052 .into_par_iter()
2053 .map(|i| {
2054 let mut d2 = 0.0;
2055 for c in 0..d {
2056 let delta = data[[i, c]] - centroid[c];
2057 d2 += delta * delta;
2058 }
2059 d2
2060 })
2061 .collect();
2062
2063 // Rotation- and permutation-invariant tie-break on a candidate's distance
2064 // profile to the whole support. Lexicographic coordinate order is
2065 // permutation-invariant, but it is NOT rotation-invariant: on symmetric
2066 // clouds (regular grids, rings, centred designs) the centroid/fill-distance
2067 // keys often tie exactly, and a rigid rotation can change which coordinate
2068 // tuple is lexicographically smallest. That reseeds the farthest-point
2069 // recursion with a different physical row and breaks the isotropic Duchon /
2070 // thin-plate equivariance contract. The sorted multiset
2071 // `{‖x_i - x_l‖² : l=1..n}` is a pure function of the unordered Euclidean
2072 // geometry, so it survives both row permutations and rigid rotations. Only
2073 // A complete tie after this key is a nontrivial symmetry orbit. No
2074 // permutation-equivariant rule can choose one distinct member of that
2075 // orbit, so callers below retain the whole class atomically *when it fits the
2076 // knot budget*; when a strict subset is unavoidable they cap it to the budget
2077 // deterministically rather than refusing the fit (see the seed/loop notes).
2078 // Only coincident rows are collapsed, because they generate the same kernel
2079 // column.
2080 // The profile's pairwise scalar: the squared Euclidean distance between two
2081 // rows. It is a pure function of the unordered geometry, so the multiset it
2082 // generates over the whole support survives both a rigid motion and a row
2083 // permutation.
2084 let pair_dist2 = |i: usize, j: usize| -> f64 {
2085 let mut distance2 = 0.0;
2086 for c in 0..d {
2087 let delta = data[[i, c]] - data[[j, c]];
2088 distance2 += delta * delta;
2089 }
2090 distance2
2091 };
2092 // Reduce an already-`O(1)`-tied candidate list to the rows attaining the
2093 // lexicographically least sorted support-distance profile. The `O(n log n)`
2094 // key is built once per candidate and serves both the choice and the class
2095 // filter; a lone candidate — the common case, and every case on data without
2096 // an exact symmetry — builds none at all. See
2097 // [`crate::basis::invariant_tie_break`] for why this is the same total
2098 // preorder the two-profile comparator scan applied.
2099 let resolve_profile_tie = |tied: &[usize], builds: &mut usize| -> Vec<usize> {
2100 resolve_sorted_profile_tie(n, tied, &pair_dist2, &mut |built: usize| *builds += built)
2101 };
2102
2103 let distinct_orbit = |candidates: &[usize], already_selected: &[usize]| -> Vec<usize> {
2104 let mut distinct = Vec::with_capacity(candidates.len());
2105 'candidate: for &candidate in candidates {
2106 for &selected in already_selected.iter().chain(distinct.iter()) {
2107 let mut distance2 = 0.0;
2108 for c in 0..d {
2109 let delta = data[[candidate, c]] - data[[selected, c]];
2110 distance2 += delta * delta;
2111 }
2112 if distance2 == 0.0 {
2113 continue 'candidate;
2114 }
2115 }
2116 distinct.push(candidate);
2117 }
2118 distinct
2119 };
2120
2121 // Round-off-robust tie tolerance (#1818). The data's squared radius sets the
2122 // scale of the maximin/centroid distances; a generic rigid rotation perturbs
2123 // each of them by ~`ε·radius²`, so exact-equality tie-break gates let that
2124 // round-off — rather than the intended rotation-invariant key — decide
2125 // near-equidistant candidates, and a single flip cascades into a materially
2126 // different knot set. `tie_tol` sits well above that round-off floor and far
2127 // below any genuine maximin gap, so near-ties are consistently resolved by
2128 // the invariant support-distance profile in every rotated frame.
2129 //
2130 // The scale is the squared radius ITSELF, with no floor (gam#2750). It used
2131 // to be `.max(1.0)`, which compares a squared LENGTH against the
2132 // dimensionless number one and therefore turns the tolerance ABSOLUTE for
2133 // every cloud smaller than unit radius — breaking both halves of the
2134 // contract above at once. Measured on a 240-row 1-D chart scaled by `c`:
2135 // at `c = 1e-3` the squared radius is `2.7e-7`, so the floor holds `tie_tol`
2136 // at `1e-9` while the genuine maximin gap between neighbouring candidates is
2137 // `~6e-10` — the tolerance is LARGER than the gap it was required to sit far
2138 // below, every candidate ties, and the support-distance profile decides a
2139 // selection it was only supposed to referee. The selected knots then stop
2140 // being equivariant: the same configuration in metres and in millimetres
2141 // yields different knots, and hence a different median nearest-node spacing,
2142 // a different auto range, and a different basis.
2143 //
2144 // Without the floor every ingredient scales as `c²` — the squared distances,
2145 // the squared radius, and the tolerance — so the comparisons are exactly
2146 // invariant. A degenerate cloud (all rows coincident) gives `radius² = 0` and
2147 // `tie_tol = 0`, which is the right test there: every squared distance is
2148 // exactly zero, so exact equality already ties everything, and the previous
2149 // `1e-9` tied exactly the same set.
2150 let knot_scale2 = dist2_to_centroid.iter().copied().fold(0.0_f64, f64::max);
2151 let tie_tol = KNOT_MAXIMIN_TIE_REL_TOL * knot_scale2;
2152
2153 // Seed = centroid-nearest row; near-equidistant rows (within `tie_tol`) are
2154 // resolved by the invariant support-distance profile so the seed is a
2155 // deterministic, rotation- and permutation-invariant function of the data.
2156 let seed_min = dist2_to_centroid
2157 .iter()
2158 .copied()
2159 .fold(f64::INFINITY, f64::min);
2160 let seed_tied: Vec<usize> = (0..n)
2161 .filter(|&i| dist2_to_centroid[i] <= seed_min + tie_tol)
2162 .collect();
2163 let seed_class = resolve_profile_tie(&seed_tied, &mut profile_builds);
2164 // When an indivisible symmetry orbit is larger than the entire knot budget,
2165 // no rule can pick an *equivariant* strict subset of it — the orbit's members
2166 // are interchangeable under the data's symmetry group (#2319). The previous
2167 // behaviour refused the fit outright, which bricks the single most common
2168 // gridded/lattice spatial input (an integer raster or designed grid has
2169 // exactly-representable coordinates, so its corner/edge orbits tie exactly and
2170 // exceed typical `k`). Refusing is strictly worse than a deterministic subset,
2171 // so we cap the orbit to the budget by taking its lowest-row members. That
2172 // choice is still rotation-equivariant — a rigid rotation preserves each row's
2173 // identity, so the base and rotated fits select corresponding physical points
2174 // and the knot set rotates with the data. Permutation-invariance is provably
2175 // unattainable for a strict subset of an exact orbit, and is knowingly traded
2176 // away only in that measure-zero case. Orbits that fit the budget are still
2177 // taken atomically (whole), preserving both invariants exactly.
2178 let seed_orbit: Vec<usize> = distinct_orbit(&seed_class, &[])
2179 .into_iter()
2180 .take(num_knots)
2181 .collect();
2182
2183 let mut selected = Vec::with_capacity(num_knots);
2184 let mut chosen = vec![false; n];
2185 let mut min_dist2 = vec![f64::INFINITY; n];
2186
2187 for &i in &seed_class {
2188 chosen[i] = true;
2189 }
2190 selected.extend(seed_orbit.iter().copied());
2191
2192 min_dist2.par_iter_mut().enumerate().for_each(|(i, slot)| {
2193 *slot = seed_orbit
2194 .iter()
2195 .map(|¢er| {
2196 let mut d2 = 0.0;
2197 for c in 0..d {
2198 let delta = data[[i, c]] - data[[center, c]];
2199 d2 += delta * delta;
2200 }
2201 d2
2202 })
2203 .fold(f64::INFINITY, f64::min);
2204 });
2205 for &i in &seed_class {
2206 min_dist2[i] = 0.0;
2207 }
2208
2209 while selected.len() < num_knots {
2210 // Maximin: take the larger min-distance to the chosen set. Exact
2211 // `min_dist2` ties — common on regular grids and, under a generic
2212 // rotation, wherever round-off perturbs two near-equidistant candidates —
2213 // are resolved by a rotation-invariant key first (the larger distance to
2214 // the centroid, which spreads knots outward and is a pure function of the
2215 // unordered value set), and only by the invariant support-distance profile
2216 // for points that also tie there. Both the maximin and the centroid keys
2217 // use `tie_tol` (not exact equality) so sub-ulp coordinate perturbation
2218 // can never decide the selection; this keeps the knot SET invariant under
2219 // both rigid rotation and row permutation of the data.
2220 let max_val = min_dist2
2221 .par_iter()
2222 .enumerate()
2223 .filter(|(i, _)| !chosen[*i])
2224 .map(|(_, &cand)| cand)
2225 .reduce(|| f64::NEG_INFINITY, f64::max);
2226 if !max_val.is_finite() {
2227 break;
2228 }
2229 // Candidates within round-off tolerance of the maximin extremum, in
2230 // canonical (ascending) row order (parallel collect is index-ordered).
2231 let mut candidates: Vec<usize> = (0..n)
2232 .into_par_iter()
2233 .filter(|&i| !chosen[i] && min_dist2[i] >= max_val - tie_tol)
2234 .collect();
2235 if candidates.is_empty() {
2236 break;
2237 }
2238 // Secondary invariant key: farthest from the centroid, round-off-robust.
2239 let cand_max_centroid = candidates
2240 .iter()
2241 .map(|&i| dist2_to_centroid[i])
2242 .fold(f64::NEG_INFINITY, f64::max);
2243 candidates.retain(|&i| dist2_to_centroid[i] >= cand_max_centroid - tie_tol);
2244 // Tertiary invariant key: smallest support-distance profile. A tie
2245 // after every intrinsic key is an indivisible symmetry orbit. A single
2246 // surviving candidate has already won every refinement of the keys it
2247 // attained, so the profile is not built at all there.
2248 let candidates = resolve_profile_tie(&candidates, &mut profile_builds);
2249 let remaining = num_knots - selected.len();
2250 // Cap an oversized indivisible orbit to the remaining budget rather than
2251 // refusing the fit (see the seed-orbit note above): take its lowest-row
2252 // members, which keeps the selection rotation-equivariant and always
2253 // yields a fittable `num_knots`-center design. An orbit that fits is still
2254 // completed atomically.
2255 let orbit: Vec<usize> = distinct_orbit(&candidates, &selected)
2256 .into_iter()
2257 .take(remaining)
2258 .collect();
2259 for &i in &candidates {
2260 chosen[i] = true;
2261 min_dist2[i] = 0.0;
2262 }
2263 if orbit.is_empty() {
2264 continue;
2265 }
2266 selected.extend(orbit.iter().copied());
2267
2268 min_dist2.par_iter_mut().enumerate().for_each(|(i, slot)| {
2269 if chosen[i] {
2270 return;
2271 }
2272 for ¢er in &orbit {
2273 let mut d2 = 0.0;
2274 for c in 0..d {
2275 let delta = data[[i, c]] - data[[center, c]];
2276 d2 += delta * delta;
2277 }
2278 if d2 < *slot {
2279 *slot = d2;
2280 }
2281 }
2282 });
2283 }
2284
2285 // A request for more knots than the data has geometrically distinct points
2286 // is not a malformed request — it is arithmetically unsatisfiable, and the
2287 // largest satisfiable answer is every distinct point there is. Refusing here
2288 // made a duplicate-heavy covariate a hard failure at BASIS CONSTRUCTION,
2289 // before the rank-reduction machinery that exists precisely to handle it
2290 // could see the design: `binary_outcome_shape_bms_matern_centers60_are_rank_reduced`
2291 // asks for 60 centers from a fixture whose PC cloud is 4 points cycled over
2292 // 160 rows, and its contract is that redundant centers are "rank-reduced
2293 // before the joint audit" — its error arm explicitly excludes the joint
2294 // audit's own `joint rank` / `dropped column` text.
2295 //
2296 // Clamping is what the surrounding code already assumes: `select_thin_plate_knots`
2297 // sizes its returned matrix by `selected.len()`, never by `num_knots`. It is
2298 // also the established convention for this family of libraries — mgcv reduces
2299 // `k` to the number of unique covariate values and warns rather than erroring.
2300 //
2301 // The diagnostic is kept, as a warning: silently handing back a smaller basis
2302 // than asked for would hide a genuine `centers=` typo, which is the one thing
2303 // the old refusal was good at.
2304 if selected.is_empty() {
2305 crate::bail_invalid_basis!(
2306 "thin-plate knot selection found no geometrically distinct selectable points in {} rows",
2307 data.nrows()
2308 );
2309 }
2310 if selected.len() < num_knots {
2311 log::warn!(
2312 "[thin-plate] requested {num_knots} distinct knots but the data contain only {} \
2313 geometrically distinct selectable points; reducing the basis to {} knots",
2314 selected.len(),
2315 selected.len()
2316 );
2317 }
2318
2319 Ok((selected, profile_builds))
2320}
2321
2322#[inline(always)]
2323pub(crate) fn thin_plate_kernel_from_dist2(
2324 dist2: f64,
2325 dimension: usize,
2326) -> Result<f64, BasisError> {
2327 if !dist2.is_finite() || dist2 < 0.0 {
2328 crate::bail_invalid_basis!("thin-plate kernel distance must be finite and non-negative");
2329 }
2330 if dist2 == 0.0 {
2331 return Ok(0.0);
2332 }
2333 match dimension {
2334 // For d≤3, the minimum penalty order m=2 (biharmonic) suffices.
2335 // Hand-optimized closed forms avoid the overhead of the general evaluator.
2336 // d=1: r^3
2337 // d=2: r^2 log(r)
2338 // d=3: -r
2339 1 => Ok(dist2 * dist2.sqrt()),
2340 2 => Ok(0.5 * dist2 * dist2.ln()),
2341 3 => Ok(-dist2.sqrt()),
2342 _ => {
2343 // General case: choose the smallest penalty order m with 2m > d,
2344 // i.e. m = floor(d/2) + 1, and evaluate via the Duchon polyharmonic
2345 // kernel which handles arbitrary (m, d) combinations.
2346 let m = dimension / 2 + 1;
2347 let r = dist2.sqrt();
2348 Ok(polyharmonic_kernel(r, (m) as f64, dimension))
2349 }
2350 }
2351}
2352
2353#[inline(always)]
2354pub(crate) fn thin_plate_penalty_order(dimension: usize) -> usize {
2355 match dimension {
2356 1..=3 => 2,
2357 _ => dimension / 2 + 1,
2358 }
2359}
2360
2361/// True when canonical TPS is mathematically infeasible at this (d, k) — the
2362/// polynomial nullspace P(C) has more columns than centers, so the side
2363/// constraint `P(C)^T α = 0` is overdetermined and the basis collapses.
2364#[inline(always)]
2365pub(crate) fn d_canonical_tps_infeasible(dimension: usize, num_centers: usize) -> bool {
2366 num_centers < thin_plate_polynomial_basis_dimension(dimension)
2367}
2368
2369/// Whether canonical thin-plate splines are infeasible at THESE specific
2370/// centers — the single governing feasibility test for the auto-promotion gate.
2371///
2372/// Canonical TPS requires the polynomial nullspace block `P(C)` (`k × M(d)`) to
2373/// have full column rank `M(d)`; otherwise the side constraint `P(C)ᵀα = 0` is
2374/// overdetermined (count-short) or rank-deficient (degenerate geometry), and
2375/// `thin_plate_kernel_constraint_nullspace` hard-errors. There are two failure
2376/// modes and rank subsumes both:
2377/// * too few centers — `k < M(d)` (the cheap count short-circuit, which also
2378/// avoids materialising an oversized `k × M(d)` block when `M(d)` explodes
2379/// in high dimension, e.g. `M(16) = 735_471`); and
2380/// * enough centers but geometrically DEGENERATE — the selected centers are
2381/// affinely/polynomially dependent, so `rank P(C) < M(d)` even though
2382/// `k ≥ M(d)` (e.g. coplanar points in 3-D).
2383///
2384/// The prior gate tested only the count, so a degenerate-but-sufficient center
2385/// set slipped past it into canonical TPS and hard-errored instead of promoting
2386/// to the Duchon generalisation (which handles a rank-deficient nullspace
2387/// gracefully — it takes the RRQR nullspace at the *actual* rank and downgrades
2388/// the effective nullspace order). Making rank the test keeps the promotion gate
2389/// from drifting out of sync with the linear-algebra feasibility the builder
2390/// enforces downstream.
2391pub(crate) fn thin_plate_canonical_infeasible_at_centers(centers: ArrayView2<'_, f64>) -> bool {
2392 let dimension = centers.ncols();
2393 // Cheap count short-circuit; also guards high `d`, where forming the
2394 // `k × M(d)` polynomial block is itself intractable.
2395 if d_canonical_tps_infeasible(dimension, centers.nrows()) {
2396 return true;
2397 }
2398 // Enough centers by count (`M(d) ≤ k`), so the block is small enough to
2399 // form: check the ACTUAL rank so a degenerate center geometry promotes to
2400 // Duchon rather than hard-erroring in canonical TPS.
2401 let poly_block = thin_plate_polynomial_block(centers);
2402 let poly_cols = poly_block.ncols();
2403 match rrqr_nullspace_basis(&poly_block, default_rrqr_rank_alpha()) {
2404 Ok((_, rank)) => rank < poly_cols,
2405 // If the rank probe itself fails, defer to the canonical path, which
2406 // surfaces a precise error rather than silently promoting.
2407 Err(_) => false,
2408 }
2409}
2410
2411/// Pick Duchon parameters for the TPS auto-promotion at infeasible (d, k).
2412/// Returns `Some((nullspace_order, power))` when a hybrid-Duchon spec exists
2413/// satisfying the collocation gate `2(p + s) > d + max_op` for max_op = 2
2414/// (default operator penalties: mass + tension + stiffness). The hybrid
2415/// kernel (Matern-blended) sidesteps the pure-Duchon `2s < d` gate, leaving
2416/// only the collocation/spectral-existence condition.
2417///
2418/// Strategy: prefer Linear nullspace (M' = d+1) so the polynomial trend
2419/// retains the affine span; fall back to Zero (M' = 1) when k < d+1. The
2420/// smallest admissible s in each case gives the most TPS-like behavior
2421/// (largest spectral roughness for a given polynomial nullspace).
2422pub(crate) fn duchon_thin_plate_fallback_params(
2423 dimension: usize,
2424 num_centers: usize,
2425) -> Option<(DuchonNullspaceOrder, usize)> {
2426 let d = dimension;
2427 let max_op = 2usize; // mass + tension + stiffness collocation
2428 for (order, p, m_poly) in [
2429 (DuchonNullspaceOrder::Linear, 2usize, d + 1),
2430 (DuchonNullspaceOrder::Zero, 1usize, 1usize),
2431 ] {
2432 if num_centers < m_poly {
2433 continue;
2434 }
2435 // Smallest integer s with 2(p + s) > d + max_op.
2436 let target = d + max_op;
2437 let s_min = if 2 * p > target {
2438 0
2439 } else {
2440 (target - 2 * p) / 2 + 1
2441 };
2442 return Some((order, s_min));
2443 }
2444 None
2445}
2446
2447/// Length scale at which the auto-promoted hybrid-Duchon kernel is well
2448/// conditioned: the typical separation between centers.
2449///
2450/// The hybrid spectrum `||w||^(2p)·(kappa²+||w||²)^s` produces real-space
2451/// partial-fraction coefficients that scale as `length_scale^(2(p+s-n))`
2452/// (`duchon_partial_fraction_coeffs`). To keep every block O(1), `kappa·r`
2453/// must be O(1) at the center separations the kernel actually evaluates on,
2454/// i.e. `length_scale ≈ typical center distance`. We use the geometric mean
2455/// of the min and max pairwise center distances — robust to a few clustered
2456/// or far-flung centers and exactly the scale where the kernel's smooth and
2457/// Matern-tail parts are both resolved. Falls back to the requested length
2458/// scale when fewer than two distinct centers exist (no pairwise distance).
2459pub(crate) fn hybrid_duchon_promotion_length_scale(
2460 centers: ArrayView2<'_, f64>,
2461 requested_length_scale: f64,
2462) -> f64 {
2463 match pairwise_distance_bounds_sampled(centers) {
2464 Some((r_min, r_max)) => {
2465 // Geometric mean keeps the scale between the tightest and widest
2466 // center pairs; both are positive and finite by construction.
2467 (r_min * r_max).sqrt()
2468 }
2469 None => {
2470 if requested_length_scale.is_finite() && requested_length_scale > 0.0 {
2471 requested_length_scale
2472 } else {
2473 1.0
2474 }
2475 }
2476 }
2477}
2478
2479#[inline(always)]
2480pub(crate) fn thin_plate_kernel_triplet_from_scaled_distance(
2481 scaled_distance: f64,
2482 dimension: usize,
2483) -> Result<(f64, f64, f64), BasisError> {
2484 if !scaled_distance.is_finite() || scaled_distance < 0.0 {
2485 crate::bail_invalid_basis!("thin-plate scaled distance must be finite and non-negative");
2486 }
2487 if scaled_distance == 0.0 {
2488 return Ok((0.0, 0.0, 0.0));
2489 }
2490
2491 match dimension {
2492 1 => {
2493 let value = scaled_distance.powi(3);
2494 let first = 3.0 * scaled_distance.powi(2);
2495 let second = 6.0 * scaled_distance;
2496 Ok((value, first, second))
2497 }
2498 2 => {
2499 let log_r = scaled_distance.max(1e-300).ln();
2500 let value = scaled_distance.powi(2) * log_r;
2501 let first = 2.0 * scaled_distance * log_r + scaled_distance;
2502 let second = 2.0 * log_r + 3.0;
2503 Ok((value, first, second))
2504 }
2505 3 => Ok((-scaled_distance, -1.0, 0.0)),
2506 _ => polyharmonic_kernel_triplet(
2507 scaled_distance,
2508 thin_plate_penalty_order(dimension) as f64,
2509 dimension,
2510 ),
2511 }
2512}
2513
2514#[inline(always)]
2515pub(crate) fn thin_plate_kernel_psi_triplet_from_distance(
2516 distance: f64,
2517 length_scale: f64,
2518 dimension: usize,
2519) -> Result<(f64, f64, f64), BasisError> {
2520 if !distance.is_finite() || distance < 0.0 {
2521 crate::bail_invalid_basis!("thin-plate kernel distance must be finite and non-negative");
2522 }
2523 if !length_scale.is_finite() || length_scale <= 0.0 {
2524 crate::bail_invalid_basis!("thin-plate length_scale must be finite and positive");
2525 }
2526
2527 // ThinPlate psi-derivative convention:
2528 // the optimizer uses psi = log(kappa) = -log(length_scale), so the scaled
2529 // radial argument is
2530 // r(psi) = ||x - c|| / length_scale = ||x - c|| * exp(psi).
2531 //
2532 // Therefore
2533 // dr/dpsi = r
2534 // d²r/dpsi² = r
2535 //
2536 // and for any TPS radial kernel phi(r),
2537 // d phi / dpsi = phi_r(r) * r
2538 // d²phi / dpsi² = phi_rr(r) * r² + phi_r(r) * r.
2539 //
2540 // This is exactly the chain rule requested by the math spec, translated to
2541 // the code's stored inverse-length-scale parameterization.
2542 let scaled_distance = distance / length_scale;
2543 let (value, radial_first, radial_second) =
2544 thin_plate_kernel_triplet_from_scaled_distance(scaled_distance, dimension)?;
2545 let psi = radial_first * scaled_distance;
2546 let psi_psi = radial_second * scaled_distance * scaled_distance + psi;
2547 Ok((value, psi, psi_psi))
2548}
2549
2550/// Creates a thin-plate regression spline basis from data and knot locations.
2551///
2552/// # Arguments
2553/// * `data` - `n x d` matrix of evaluation points
2554/// * `knots` - `k x d` matrix of knot locations
2555///
2556/// # Returns
2557/// `ThinPlateSplineBasis` containing:
2558/// - `basis`: `n x (k_c + M)` matrix (`[K_c | P]`) where `M` is the TPS
2559/// polynomial null-space dimension for the selected ambient dimension
2560/// - `penalty_bending`: constrained TPS curvature penalty
2561/// - `penalty_ridge`: center-metric penalty for null-function shrinkage
2562pub fn create_thin_plate_spline_basis(
2563 data: ArrayView2<f64>,
2564 knots: ArrayView2<f64>,
2565) -> Result<ThinPlateSplineBasis, BasisError> {
2566 let mut workspace = BasisWorkspace::default();
2567 create_thin_plate_spline_basiswithworkspace(data, knots, &mut workspace)
2568}
2569
2570pub fn create_thin_plate_spline_basiswithworkspace(
2571 data: ArrayView2<f64>,
2572 knots: ArrayView2<f64>,
2573 workspace: &mut BasisWorkspace,
2574) -> Result<ThinPlateSplineBasis, BasisError> {
2575 create_thin_plate_spline_basis_scaledwithworkspace(data, knots, 1.0, None, workspace)
2576}
2577
2578/// Evaluates a thin-plate basis at `data` in a radial chart supplied by the
2579/// caller, rather than one chosen from `data` itself.
2580///
2581/// [`create_thin_plate_spline_basis`] selects its radial chart `V` from the rows
2582/// it is handed: since #1347 the reparameterization is taken in the *realized
2583/// data metric* `G_c = (K Z)ᵀ (K Z)`, so two different row sets over the same
2584/// knots yield two different `V`, and the design columns `Φ Z V` are two
2585/// different coordinate systems for the same model space. A coefficient vector
2586/// fitted against one therefore does not describe the same function against the
2587/// other — silently, since both designs have the same shape.
2588///
2589/// Scoring a fit on rows it was not fitted to must consequently replay the
2590/// training chart: pass the [`ThinPlateSplineBasis::radial_reparam`] of the
2591/// basis the coefficients were fitted against. The knots must be the same ones,
2592/// as usual; the chart is checked against the side-constrained radial dimension
2593/// and a mismatch is a typed error rather than a wrong answer.
2594pub fn create_thin_plate_spline_basis_in_chart(
2595 data: ArrayView2<f64>,
2596 knots: ArrayView2<f64>,
2597 radial_reparam: &Array2<f64>,
2598) -> Result<ThinPlateSplineBasis, BasisError> {
2599 let mut workspace = BasisWorkspace::default();
2600 create_thin_plate_spline_basis_scaledwithworkspace(
2601 data,
2602 knots,
2603 1.0,
2604 Some(radial_reparam),
2605 &mut workspace,
2606 )
2607}
2608
2609pub(crate) fn create_thin_plate_spline_basis_scaledwithworkspace(
2610 data: ArrayView2<f64>,
2611 knots: ArrayView2<f64>,
2612 length_scale: f64,
2613 frozen_radial_reparam: Option<&Array2<f64>>,
2614 workspace: &mut BasisWorkspace,
2615) -> Result<ThinPlateSplineBasis, BasisError> {
2616 let n = data.nrows();
2617 let k = knots.nrows();
2618 let d = data.ncols();
2619
2620 if d == 0 {
2621 crate::bail_invalid_basis!("thin-plate spline requires at least one covariate dimension");
2622 }
2623 if d != knots.ncols() {
2624 crate::bail_dim_basis!(
2625 "thin-plate spline dimension mismatch: data has {} columns, knots have {} columns",
2626 d,
2627 knots.ncols()
2628 );
2629 }
2630 let poly_cols = thin_plate_polynomial_basis_dimension(d);
2631 if k < poly_cols {
2632 crate::bail_invalid_basis!(
2633 "thin-plate spline requires at least {} knots to span the degree-{} polynomial null space in dimension {}; got {}",
2634 poly_cols,
2635 thin_plate_polynomial_degree(d),
2636 d,
2637 k
2638 );
2639 }
2640 if data.iter().any(|v| !v.is_finite()) || knots.iter().any(|v| !v.is_finite()) {
2641 crate::bail_invalid_basis!("thin-plate spline requires finite data and knot values");
2642 }
2643 if !length_scale.is_finite() || length_scale <= 0.0 {
2644 crate::bail_invalid_basis!("thin-plate length_scale must be finite and positive");
2645 }
2646
2647 // Translation-invariant frame (#1269). The thin-plate kernel reads only
2648 // coordinate *differences* `data − knots`, so it is already invariant to a
2649 // covariate translation `x → x + c`; the polynomial null-space block
2650 // `P = {1, x, x², …}` and the side-constraint nullspace `P(knots)ᵀα = 0`,
2651 // however, are assembled at the *absolute* coordinate. When the covariate is
2652 // offset (e.g. a centred-vs-raw "year", or this term's standardized axis
2653 // carrying a large mean), the `{1, x}` columns become near-collinear, the
2654 // design ill-conditions, and REML λ-selection lands in a slightly different
2655 // basin — moving the fit by ~1% of signal range even though the model space
2656 // is identical (`{1, x − x̄}` spans the same null space). Subtract the knot
2657 // cloud's per-axis mean from both `data` and `knots` so the polynomial block
2658 // is built in a location-standardized, well-conditioned frame. The knots are
2659 // frozen (`UserProvided`) after fit, so this offset is identical at predict;
2660 // and under `x → x + c` the knots (selected from the data) shift by the same
2661 // `c`, so the centred coordinate — hence the whole basis — is invariant.
2662 let knot_mean: Vec<f64> = (0..d)
2663 .map(|c| knots.column(c).sum() / (k.max(1) as f64))
2664 .collect();
2665 let mut data_centered = data.to_owned();
2666 let mut knots_centered = knots.to_owned();
2667 for c in 0..d {
2668 let mu = knot_mean[c];
2669 data_centered.column_mut(c).mapv_inplace(|v| v - mu);
2670 knots_centered.column_mut(c).mapv_inplace(|v| v - mu);
2671 }
2672 let data = data_centered.view();
2673 let knots = knots_centered.view();
2674
2675 // K block: radial basis evaluations data -> knots
2676 let mut kernel_block = Array2::<f64>::zeros((n, k));
2677 let kernel_result: Result<(), BasisError> = kernel_block
2678 .axis_iter_mut(Axis(0))
2679 .into_par_iter()
2680 .enumerate()
2681 .try_for_each(|(i, mut row)| {
2682 for j in 0..k {
2683 let mut dist2 = 0.0;
2684 for c in 0..d {
2685 let delta = data[[i, c]] - knots[[j, c]];
2686 dist2 += delta * delta;
2687 }
2688 row[j] = thin_plate_kernel_from_dist2(dist2 / (length_scale * length_scale), d)?;
2689 }
2690 Ok(())
2691 });
2692 kernel_result?;
2693
2694 // P block: all TPS null-space monomials of total degree < m.
2695 let poly_block = thin_plate_polynomial_block(data);
2696
2697 // Omega block on knots
2698 let mut omega = Array2::<f64>::zeros((k, k));
2699 let length_scale_sq = length_scale * length_scale;
2700 fill_symmetric_from_row_kernel(&mut omega, |i, j| {
2701 let mut dist2 = 0.0;
2702 for c in 0..d {
2703 let delta = knots[[i, c]] - knots[[j, c]];
2704 dist2 += delta * delta;
2705 }
2706 thin_plate_kernel_from_dist2(dist2 / length_scale_sq, d)
2707 })?;
2708
2709 // Enforce TPS side-constraint P(knots)^T α = 0 by projecting onto
2710 // the nullspace of P(knots)^T.
2711 let z = thin_plate_kernel_constraint_nullspace(knots, &mut workspace.cache)?;
2712 let kernel_constrained = fast_ab(&kernel_block, &z);
2713 let omega_constrained = {
2714 let zt_o = fast_atb(&z, &omega);
2715 symmetrize_penalty(&fast_ab(&zt_o, &z))
2716 };
2717 let omega_psd = validate_psd_penalty(
2718 &omega_constrained,
2719 &format!("thin_plate bending penalty (dimension={d})"),
2720 "thin-plate kernel and side-constraint assembly must yield a PSD penalty on the constrained subspace",
2721 )?;
2722 assert!(
2723 omega_psd.min_eigenvalue >= -omega_psd.tolerance,
2724 "thin-plate constrained penalty PSD validation violated tolerance after validation: min_eigenvalue={}, tolerance={}",
2725 omega_psd.min_eigenvalue,
2726 omega_psd.tolerance
2727 );
2728 assert!(
2729 omega_psd.max_abs_eigenvalue.is_finite(),
2730 "thin-plate constrained penalty has non-finite max eigenvalue after validation: max_abs_eigenvalue={}",
2731 omega_psd.max_abs_eigenvalue
2732 );
2733 assert!(
2734 omega_psd.effective_rank <= omega_constrained.nrows(),
2735 "thin-plate constrained penalty rank exceeds constrained rows: effective_rank={}, rows={}",
2736 omega_psd.effective_rank,
2737 omega_constrained.nrows()
2738 );
2739
2740 let constrained_kernel_cols = kernel_constrained.ncols();
2741
2742 // Radial penalty eigenspace reparameterization. Eigendecompose
2743 // Ω_constrained = V Λ V' and rotate the radial design columns into the
2744 // same basis. This preserves the TPS model space while making the bending
2745 // block diagonal. Numerically near-null radial directions are not part of
2746 // the polynomial null space; keeping them as almost-free columns lets REML
2747 // spend EDF on wiggle with effectively zero curvature cost (#1271). Drop
2748 // them from the exposed basis so only genuinely penalized radial directions
2749 // remain.
2750 let (radial_reparam, radial_eigvals): (Array2<f64>, Array1<f64>) = if let Some(frozen) =
2751 frozen_radial_reparam
2752 {
2753 if frozen.nrows() != constrained_kernel_cols {
2754 crate::bail_dim_basis!(
2755 "thin-plate frozen radial reparam shape {:?} does not match constrained radial dimension {}",
2756 frozen.dim(),
2757 constrained_kernel_cols
2758 );
2759 }
2760 let v = frozen.to_owned();
2761 let vt_omega_v = fast_atb(&v, &omega_constrained);
2762 let lambda_diag = fast_ab(&vt_omega_v, &v);
2763 let mut evals = Array1::<f64>::zeros(v.ncols());
2764 for i in 0..v.ncols() {
2765 evals[i] = lambda_diag[[i, i]].max(0.0);
2766 }
2767 (v, evals)
2768 } else if constrained_kernel_cols == 0 {
2769 (Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0))
2770 } else {
2771 // #1347: reparameterize in the realized data metric so the bending
2772 // spectrum acquires mgcv's cliff (curvature per unit data-variance),
2773 // rather than the cliff-less raw knot-Gram spectrum that lets REML buy
2774 // near-free wiggle on near-linear data. G_c = (K Z)ᵀ (K Z).
2775 // Canonical row order so the Gram is row-permutation invariant (#1378).
2776 let design_gram = data_metric_design_gram(kernel_constrained.view());
2777 thin_plate_radial_reparam_data_metric(&omega_constrained, &design_gram)?
2778 };
2779 let kernel_cols = radial_eigvals.len();
2780 let total_cols = kernel_cols + poly_cols;
2781
2782 let kernel_rotated = if kernel_cols == 0 {
2783 Array2::<f64>::zeros((n, 0))
2784 } else {
2785 fast_ab(&kernel_constrained, &radial_reparam)
2786 };
2787
2788 let mut basis = Array2::<f64>::zeros((n, total_cols));
2789 basis
2790 .slice_mut(s![.., 0..kernel_cols])
2791 .assign(&kernel_rotated);
2792 basis.slice_mut(s![.., kernel_cols..]).assign(&poly_block);
2793
2794 let mut penalty_bending = Array2::<f64>::zeros((total_cols, total_cols));
2795 for i in 0..kernel_cols {
2796 penalty_bending[[i, i]] = radial_eigvals[i];
2797 }
2798 // Evaluate the active raw chart on its frozen knot support. The resulting
2799 // Gram is a compact domain quadrature for the represented function, so the
2800 // double penalty measures the L2 size of the polynomial/null component
2801 // instead of the arbitrary Euclidean size of its coefficient vector.
2802 let center_kernel_rotated = if kernel_cols == 0 {
2803 Array2::<f64>::zeros((k, 0))
2804 } else {
2805 fast_ab(&fast_ab(&omega, &z), &radial_reparam)
2806 };
2807 let center_poly = thin_plate_polynomial_block(knots);
2808 let mut center_design = Array2::<f64>::zeros((k, total_cols));
2809 center_design
2810 .slice_mut(s![.., 0..kernel_cols])
2811 .assign(¢er_kernel_rotated);
2812 center_design
2813 .slice_mut(s![.., kernel_cols..])
2814 .assign(¢er_poly);
2815 let function_gram = symmetrize_penalty(&fast_ata(¢er_design));
2816 let penalty_ridge = function_space_nullspace_shrinkage(&penalty_bending, &function_gram)?
2817 .unwrap_or_else(|| Array2::<f64>::zeros((total_cols, total_cols)));
2818
2819 Ok(ThinPlateSplineBasis {
2820 basis,
2821 penalty_bending,
2822 penalty_ridge,
2823 num_kernel_basis: kernel_cols,
2824 num_polynomial_basis: poly_cols,
2825 dimension: d,
2826 radial_reparam,
2827 })
2828}
2829
2830pub(crate) fn active_thin_plate_penalty_derivatives(
2831 penalties: &[ActivePenalty],
2832 primary_derivative: &Array2<f64>,
2833 nullspace_derivative: &Array2<f64>,
2834) -> Result<Vec<Array2<f64>>, BasisError> {
2835 penalties
2836 .iter()
2837 .map(|penalty| match &penalty.info.source {
2838 PenaltySource::Primary => Ok(primary_derivative.clone()),
2839 PenaltySource::DoublePenaltyNullspace => Ok(nullspace_derivative.clone()),
2840 other => Err(BasisError::InvalidInput(format!(
2841 "unexpected ThinPlate penalty source in psi-derivative path: {other:?}"
2842 ))),
2843 })
2844 .collect()
2845}
2846
2847// The dense per-pair ThinPlate ψ-derivative builder used to live here. It has
2848// been replaced by `build_thin_plate_scalar_design_psi_derivatives`, which
2849// drives the same math through the shared scalar streaming infrastructure
2850// (`build_scalar_design_psi_derivatives_shared`) so large-scale TPS terms no
2851// longer materialize dense `(n × p)` first/second derivative arrays.
2852
2853pub fn build_thin_plate_penalty_psi_derivativeswithworkspace(
2854 centers: ArrayView2<'_, f64>,
2855 spec: &ThinPlateBasisSpec,
2856 identifiability_transform: Option<&Array2<f64>>,
2857 workspace: &mut BasisWorkspace,
2858) -> Result<(Array2<f64>, Array2<f64>, Array2<f64>, Array2<f64>), BasisError> {
2859 // Match build_thin_plate_basis exactly (Wood-TPRS path):
2860 //
2861 // M(ψ) = Z_kernel^T Ω(ψ) Z_kernel
2862 // V, Λ(ψ) = eigh(M) (or V from spec.radial_reparam, frozen)
2863 // S_raw(ψ) = pad(diag(Λ(ψ)), total_cols) // kernel block + zero poly
2864 // S_norm(ψ) = S_raw(ψ) / ||S_raw(ψ)||_F
2865 // S_final(ψ) = Z_id^T S_norm(ψ) Z_id // identifiability transform
2866 //
2867 // where Ω_ij(ψ) = φ(r_ij(ψ)), r_ij(ψ) = ||c_i - c_j|| · exp(ψ).
2868 //
2869 // We need d/dψ S_final and d²/dψ² S_final, applied in the same composition
2870 // order as the build path so the analytic derivative is of the exact
2871 // materialized penalty surface.
2872 let z_kernel = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
2873 let constrained_kernel_cols = z_kernel.ncols();
2874 let poly_cols = thin_plate_polynomial_basis_dimension(centers.ncols());
2875 let k = centers.nrows();
2876 let d = centers.ncols();
2877
2878 // 1) Build Ω, Ω_ψ, Ω_ψψ on centers (k × k). Ω is needed to recover Λ when
2879 // V is frozen and to apply Hellmann-Feynman in the fresh-V path.
2880 let mut omega = Array2::<f64>::zeros((k, k));
2881 let mut omega_psi = Array2::<f64>::zeros((k, k));
2882 let mut omega_psi_psi = Array2::<f64>::zeros((k, k));
2883
2884 // Evaluate the dense symmetric center-pair kernel blocks in independent
2885 // lower-triangular row tiles. Each rayon worker owns its tile-local entry
2886 // buffer (scratch workspace) and returns immutable results; the serial
2887 // assembly below is the only place that writes to the dense output arrays,
2888 // so no mutable ndarray storage is shared across workers.
2889 struct ThinPlatePsiTileEntry {
2890 pub(crate) i: usize,
2891 pub(crate) j: usize,
2892 pub(crate) phi: f64,
2893 pub(crate) phi_psi: f64,
2894 pub(crate) phi_psi_psi: f64,
2895 }
2896
2897 let n_tiles = k.div_ceil(THIN_PLATE_PENALTY_PSI_TILE_ROWS);
2898 let omega_tiles: Result<Vec<Vec<ThinPlatePsiTileEntry>>, BasisError> = (0..n_tiles)
2899 .into_par_iter()
2900 .map(|tile_idx| {
2901 let row_start = tile_idx * THIN_PLATE_PENALTY_PSI_TILE_ROWS;
2902 let row_end = (row_start + THIN_PLATE_PENALTY_PSI_TILE_ROWS).min(k);
2903 let tile_pairs = (row_start..row_end).map(|i| i + 1).sum::<usize>();
2904 let mut entries = Vec::with_capacity(tile_pairs);
2905 for i in row_start..row_end {
2906 for j in 0..=i {
2907 let mut dist2 = 0.0;
2908 for axis in 0..d {
2909 let delta = centers[[i, axis]] - centers[[j, axis]];
2910 dist2 += delta * delta;
2911 }
2912 let (phi, phi_psi, phi_psi_psi) = thin_plate_kernel_psi_triplet_from_distance(
2913 dist2.sqrt(),
2914 spec.length_scale,
2915 d,
2916 )?;
2917 entries.push(ThinPlatePsiTileEntry {
2918 i,
2919 j,
2920 phi,
2921 phi_psi,
2922 phi_psi_psi,
2923 });
2924 }
2925 }
2926 Ok(entries)
2927 })
2928 .collect();
2929
2930 for tile in omega_tiles? {
2931 for entry in tile {
2932 omega[[entry.i, entry.j]] = entry.phi;
2933 omega_psi[[entry.i, entry.j]] = entry.phi_psi;
2934 omega_psi_psi[[entry.i, entry.j]] = entry.phi_psi_psi;
2935 if entry.i != entry.j {
2936 omega[[entry.j, entry.i]] = entry.phi;
2937 omega_psi[[entry.j, entry.i]] = entry.phi_psi;
2938 omega_psi_psi[[entry.j, entry.i]] = entry.phi_psi_psi;
2939 }
2940 }
2941 }
2942
2943 // 2) Project to the constrained kernel space.
2944 let m_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega).dot(&z_kernel));
2945 let m_psi_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega_psi).dot(&z_kernel));
2946 let m_pp_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega_psi_psi).dot(&z_kernel));
2947
2948 // 3) Get V (frozen or fresh from eigh).
2949 let (v, lambda) = if let Some(frozen) = spec.radial_reparam.as_ref() {
2950 if frozen.nrows() != constrained_kernel_cols {
2951 crate::bail_dim_basis!(
2952 "thin-plate frozen radial reparam shape {:?} does not match constrained radial dimension {}",
2953 frozen.dim(),
2954 constrained_kernel_cols
2955 );
2956 }
2957 let v_owned = frozen.to_owned();
2958 let lambda_diag = fast_ab(&fast_atb(&v_owned, &m_constrained), &v_owned);
2959 let mut evals = Array1::<f64>::zeros(v_owned.ncols());
2960 for i in 0..v_owned.ncols() {
2961 evals[i] = lambda_diag[[i, i]].max(0.0);
2962 }
2963 (v_owned, evals)
2964 } else if constrained_kernel_cols == 0 {
2965 (Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0))
2966 } else {
2967 let (mut evals, evecs) =
2968 FaerEigh::eigh(&m_constrained, Side::Lower).map_err(BasisError::LinalgError)?;
2969 for ev in evals.iter_mut() {
2970 if *ev < 0.0 {
2971 *ev = 0.0;
2972 }
2973 }
2974 let keep = thin_plate_retained_radial_indices(&evals);
2975 (evecs.select(Axis(1), &keep), evals.select(Axis(0), &keep))
2976 };
2977 let kernel_cols = lambda.len();
2978 let total_cols = kernel_cols + poly_cols;
2979 let v_is_frozen = spec.radial_reparam.is_some();
2980
2981 // 4) Rotate the constrained-space derivatives into V's basis. These are the
2982 // coefficients used by Hellmann-Feynman / standard perturbation theory:
2983 // A_ψ[i,j] = v_i^T M_ψ v_j
2984 // A_ψψ[i,j] = v_i^T M_ψψ v_j.
2985 let a_psi = if kernel_cols > 0 {
2986 v.t().dot(&m_psi_constrained).dot(&v)
2987 } else {
2988 Array2::<f64>::zeros((0, 0))
2989 };
2990 let a_pp = if kernel_cols > 0 {
2991 v.t().dot(&m_pp_constrained).dot(&v)
2992 } else {
2993 Array2::<f64>::zeros((0, 0))
2994 };
2995
2996 // 5) Build the un-normalized rotated penalty and its ψ-derivatives.
2997 //
2998 // Frozen V (predict-time): the penalty is V^T M(ψ) V — a full kc×kc
2999 // matrix that equals diag(Λ_0) only at fit-time ψ_0. Its ψ-derivatives
3000 // are simply A_ψ and A_ψψ (full matrices).
3001 //
3002 // Fresh V (fit-time, no frozen reparam): V(ψ) re-diagonalizes M(ψ) at
3003 // each ψ, so the penalty is identically diag(Λ(ψ)). Off-diagonals
3004 // vanish at every ψ; on-diagonals follow from non-degenerate eigenvalue
3005 // perturbation:
3006 // dΛ_i/dψ = A_ψ[i,i]
3007 // d²Λ_i/dψ² = A_ψψ[i,i] + 2 Σ_{k ≠ i} A_ψ[i,k]² / (Λ_i − Λ_k)
3008 // For degenerate eigenvalues the off-diagonal correction is dropped on
3009 // the offending pairs (their contribution is encoded in subspace
3010 // rotations rather than scalar eigenvalue motion).
3011 let s_raw_kernel = Array2::from_diag(&lambda);
3012 let s_raw_psi_kernel = if v_is_frozen {
3013 a_psi.clone()
3014 } else {
3015 let mut diag = Array2::<f64>::zeros((kernel_cols, kernel_cols));
3016 for i in 0..kernel_cols {
3017 diag[[i, i]] = a_psi[[i, i]];
3018 }
3019 diag
3020 };
3021 let s_raw_pp_kernel = if v_is_frozen {
3022 a_pp.clone()
3023 } else {
3024 let mut diag = Array2::<f64>::zeros((kernel_cols, kernel_cols));
3025 for i in 0..kernel_cols {
3026 let mut acc = a_pp[[i, i]];
3027 for k_idx in 0..kernel_cols {
3028 if k_idx == i {
3029 continue;
3030 }
3031 let denom = lambda[i] - lambda[k_idx];
3032 if denom.abs() > 1e-14 {
3033 acc += 2.0 * a_psi[[i, k_idx]].powi(2) / denom;
3034 }
3035 }
3036 diag[[i, i]] = acc;
3037 }
3038 diag
3039 };
3040
3041 // 6) Pad to total_cols (poly block has zero penalty).
3042 let pad = |kernel_block: &Array2<f64>| -> Array2<f64> {
3043 let mut s = Array2::<f64>::zeros((total_cols, total_cols));
3044 if kernel_cols > 0 {
3045 s.slice_mut(s![0..kernel_cols, 0..kernel_cols])
3046 .assign(kernel_block);
3047 }
3048 s
3049 };
3050 let s_raw = pad(&s_raw_kernel);
3051 let s_raw_psi = pad(&s_raw_psi_kernel);
3052 let s_raw_pp = pad(&s_raw_pp_kernel);
3053
3054 // 7) Apply the Frobenius normalization chain rule. The build path divides
3055 // by c(ψ)=||S_raw(ψ)||_F before applying the identifiability transform.
3056 // Therefore:
3057 // S_norm' = S_raw'/c - c' S_raw/c²
3058 // S_norm'' = S_raw''/c - 2c' S_raw'/c²
3059 // + (2(c')²/c³ - c''/c²) S_raw,
3060 // exactly as implemented by `normalize_penaltywith_psi_derivatives`.
3061 let (_, s_norm_psi, s_norm_pp, _c) =
3062 normalize_penaltywith_psi_derivatives(&s_raw, &s_raw_psi, &s_raw_pp);
3063
3064 // 8) Apply the identifiability transform last (matches build path order:
3065 // `if let Some(z) = ... { Z^T penalty_norm Z }`).
3066 let s_psi_out = project_penalty_matrix(&s_norm_psi, identifiability_transform);
3067 let s_psi_psi_out = project_penalty_matrix(&s_norm_pp, identifiability_transform);
3068
3069 // 9) Differentiate the double penalty in the same compact function metric
3070 // used by the value path. The frozen center support is an n-independent
3071 // quadrature for this regression-spline chart. With V and the outer
3072 // identifiability chart frozen at the base point, its value design and
3073 // derivatives are
3074 //
3075 // B = [Omega Z V | P(C)] T,
3076 // B_p = [Omega_p Z V | 0] T,
3077 // B_pp = [Omega_pp Z V | 0] T.
3078 //
3079 // Therefore G=B'B follows the exact product rule. The target frame is
3080 // structural: coefficients whose kernel coordinates vanish after T, i.e.
3081 // the surviving polynomial-function subspace. Differentiating
3082 // G N (N' G N)^-1 N' G then gives the analytic ridge derivatives; no
3083 // eigenspace derivative, finite difference, or coefficient-space projector
3084 // enters this path.
3085 let kernel_transform = fast_ab(&z_kernel, &v);
3086 let center_kernel = fast_ab(&omega, &kernel_transform);
3087 let center_kernel_psi = fast_ab(&omega_psi, &kernel_transform);
3088 let center_kernel_pp = fast_ab(&omega_psi_psi, &kernel_transform);
3089 let center_mean: Vec<f64> = (0..d)
3090 .map(|axis| centers.column(axis).sum() / k.max(1) as f64)
3091 .collect();
3092 let mut centered = centers.to_owned();
3093 for axis in 0..d {
3094 let mean = center_mean[axis];
3095 centered.column_mut(axis).mapv_inplace(|value| value - mean);
3096 }
3097 let center_poly = thin_plate_polynomial_block(centered.view());
3098 let mut center_design = Array2::<f64>::zeros((k, total_cols));
3099 let mut center_design_psi = Array2::<f64>::zeros((k, total_cols));
3100 let mut center_design_pp = Array2::<f64>::zeros((k, total_cols));
3101 center_design
3102 .slice_mut(s![.., 0..kernel_cols])
3103 .assign(¢er_kernel);
3104 center_design
3105 .slice_mut(s![.., kernel_cols..])
3106 .assign(¢er_poly);
3107 center_design_psi
3108 .slice_mut(s![.., 0..kernel_cols])
3109 .assign(¢er_kernel_psi);
3110 center_design_pp
3111 .slice_mut(s![.., 0..kernel_cols])
3112 .assign(¢er_kernel_pp);
3113
3114 let (center_design, center_design_psi, center_design_pp, null_frame) =
3115 if let Some(transform) = identifiability_transform {
3116 if transform.nrows() != total_cols {
3117 crate::bail_dim_basis!(
3118 "thin-plate identifiability transform has {} rows, expected {}",
3119 transform.nrows(),
3120 total_cols
3121 );
3122 }
3123 let kernel_coordinate_map = transform.slice(s![0..kernel_cols, ..]).to_owned();
3124 let (frame, _) = rrqr_nullspace_basis(
3125 &kernel_coordinate_map.t().to_owned(),
3126 default_rrqr_rank_alpha(),
3127 )
3128 .map_err(BasisError::LinalgError)?;
3129 (
3130 fast_ab(¢er_design, transform),
3131 fast_ab(¢er_design_psi, transform),
3132 fast_ab(¢er_design_pp, transform),
3133 frame,
3134 )
3135 } else {
3136 let mut frame = Array2::<f64>::zeros((total_cols, poly_cols));
3137 for column in 0..poly_cols {
3138 frame[[kernel_cols + column, column]] = 1.0;
3139 }
3140 (center_design, center_design_psi, center_design_pp, frame)
3141 };
3142 let gram = symmetrize_penalty(&fast_ata(¢er_design));
3143 let gram_psi = symmetrize_penalty(
3144 &(fast_atb(¢er_design_psi, ¢er_design)
3145 + fast_atb(¢er_design, ¢er_design_psi)),
3146 );
3147 let gram_pp = symmetrize_penalty(
3148 &(fast_atb(¢er_design_pp, ¢er_design)
3149 + fast_atb(¢er_design_psi, ¢er_design_psi).mapv(|value| 2.0 * value)
3150 + fast_atb(¢er_design, ¢er_design_pp)),
3151 );
3152 let ridge_jet = function_space_subspace_shrinkage_derivatives(
3153 &null_frame,
3154 &gram,
3155 &gram_psi,
3156 &gram_psi,
3157 &gram_pp,
3158 )?;
3159 let (_, ridge_psi, ridge_pp, _) = normalize_penaltywith_psi_derivatives(
3160 &ridge_jet.value,
3161 &ridge_jet.first_a,
3162 &ridge_jet.mixed,
3163 );
3164
3165 Ok((s_psi_out, s_psi_psi_out, ridge_psi, ridge_pp))
3166}
3167
3168/// Build the design ψ-derivatives for a Thin-Plate Spline term via the shared
3169/// scalar streaming infrastructure that Duchon already uses at large scale.
3170///
3171/// At small `n` this materializes both the first and second derivative arrays
3172/// just like the legacy dense path; at large scale the policy elects
3173/// streaming and both arrays come back as zero-sized — only an
3174/// `ImplicitDesignPsiDerivative` is returned, and downstream consumers
3175/// (`spatial_log_kappa_hyper_dirs_frominfo_list`) dispatch matvecs through it
3176/// instead of materializing dense `(n × p)` arrays per axis.
3177pub(crate) fn build_thin_plate_scalar_design_psi_derivatives(
3178 data: ArrayView2<'_, f64>,
3179 centers: ArrayView2<'_, f64>,
3180 spec: &ThinPlateBasisSpec,
3181 identifiability_transform: Option<&Array2<f64>>,
3182 workspace: &mut BasisWorkspace,
3183) -> Result<ScalarDesignPsiDerivatives, BasisError> {
3184 let z_kernel = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
3185 let constrained_kernel_cols = z_kernel.ncols();
3186 let kernel_transform = if let Some(v) = spec.radial_reparam.as_ref() {
3187 if v.nrows() != constrained_kernel_cols {
3188 crate::bail_dim_basis!(
3189 "thin-plate radial reparam shape {:?} does not match constrained radial dimension {}",
3190 v.dim(),
3191 constrained_kernel_cols
3192 );
3193 }
3194 fast_ab(&z_kernel, v)
3195 } else {
3196 z_kernel
3197 };
3198 let kernel_cols = kernel_transform.ncols();
3199 let poly_cols = thin_plate_polynomial_basis_dimension(data.ncols());
3200 let p_after_pad = kernel_cols + poly_cols;
3201 let p_final = identifiability_transform
3202 .map(|zf| zf.ncols())
3203 .unwrap_or(p_after_pad);
3204 build_scalar_design_psi_derivatives_shared(
3205 data,
3206 centers,
3207 None,
3208 p_final,
3209 Some(kernel_transform),
3210 identifiability_transform.cloned(),
3211 poly_cols,
3212 RadialScalarKind::ThinPlate {
3213 length_scale: spec.length_scale,
3214 dim: data.ncols(),
3215 },
3216 0.0,
3217 )
3218}
3219
3220pub fn build_thin_plate_basis_log_kappa_derivative(
3221 data: ArrayView2<'_, f64>,
3222 spec: &ThinPlateBasisSpec,
3223) -> Result<BasisPsiDerivativeResult, BasisError> {
3224 let mut workspace = BasisWorkspace::default();
3225 build_thin_plate_basis_log_kappa_derivativewithworkspace(data, spec, &mut workspace)
3226}
3227
3228pub fn build_thin_plate_basis_log_kappa_derivativewithworkspace(
3229 data: ArrayView2<'_, f64>,
3230 spec: &ThinPlateBasisSpec,
3231 workspace: &mut BasisWorkspace,
3232) -> Result<BasisPsiDerivativeResult, BasisError> {
3233 let mut bundle =
3234 build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, workspace)?;
3235 bundle.first.implicit_operator = bundle.implicit_operator;
3236 Ok(bundle.first)
3237}
3238
3239pub fn build_thin_plate_basis_log_kappa_derivatives(
3240 data: ArrayView2<'_, f64>,
3241 spec: &ThinPlateBasisSpec,
3242) -> Result<BasisPsiDerivativeBundle, BasisError> {
3243 let mut workspace = BasisWorkspace::default();
3244 build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, &mut workspace)
3245}
3246
3247pub fn build_thin_plate_basis_log_kappa_derivativeswithworkspace(
3248 data: ArrayView2<'_, f64>,
3249 spec: &ThinPlateBasisSpec,
3250 workspace: &mut BasisWorkspace,
3251) -> Result<BasisPsiDerivativeBundle, BasisError> {
3252 let base = build_thin_plate_basiswithworkspace(data, spec, workspace)?;
3253 let (centers, identifiability_transform, radial_reparam) = match &base.metadata {
3254 BasisMetadata::ThinPlate {
3255 centers,
3256 identifiability_transform,
3257 radial_reparam,
3258 ..
3259 } => (
3260 centers.clone(),
3261 identifiability_transform.clone(),
3262 radial_reparam.clone(),
3263 ),
3264 _ => {
3265 crate::bail_invalid_basis!("ThinPlate derivative path expected ThinPlate metadata");
3266 }
3267 };
3268 let mut derivative_spec = spec.clone();
3269 if derivative_spec.radial_reparam.is_none() {
3270 derivative_spec.radial_reparam = radial_reparam;
3271 }
3272 let scalar = build_thin_plate_scalar_design_psi_derivatives(
3273 data,
3274 centers.view(),
3275 &derivative_spec,
3276 identifiability_transform.as_ref(),
3277 workspace,
3278 )?;
3279 let (
3280 primary_derivative_opt,
3281 primarysecond_derivative_opt,
3282 nullspace_derivative_opt,
3283 nullspacesecond_derivative_opt,
3284 ) = build_thin_plate_penalty_psi_derivativeswithworkspace(
3285 centers.view(),
3286 &derivative_spec,
3287 identifiability_transform.as_ref(),
3288 workspace,
3289 )?;
3290 let primary_derivative = primary_derivative_opt;
3291 let primarysecond_derivative = primarysecond_derivative_opt;
3292 let nullspace_derivative = nullspace_derivative_opt;
3293 let nullspacesecond_derivative = nullspacesecond_derivative_opt;
3294 let penalties_derivative = active_thin_plate_penalty_derivatives(
3295 &base.active_penalties,
3296 &primary_derivative,
3297 &nullspace_derivative,
3298 )?;
3299 let penaltiessecond_derivative = active_thin_plate_penalty_derivatives(
3300 &base.active_penalties,
3301 &primarysecond_derivative,
3302 &nullspacesecond_derivative,
3303 )?;
3304 Ok(BasisPsiDerivativeBundle {
3305 first: BasisPsiDerivativeResult {
3306 design_derivative: scalar.design_first,
3307 penalties_derivative,
3308 implicit_operator: None,
3309 },
3310 second: BasisPsiSecondDerivativeResult {
3311 designsecond_derivative: scalar.design_second_diag,
3312 penaltiessecond_derivative,
3313 implicit_operator: None,
3314 },
3315 implicit_operator: scalar.implicit_operator,
3316 })
3317}
3318
3319pub fn build_thin_plate_basis_log_kappasecond_derivative(
3320 data: ArrayView2<'_, f64>,
3321 spec: &ThinPlateBasisSpec,
3322) -> Result<BasisPsiSecondDerivativeResult, BasisError> {
3323 let mut workspace = BasisWorkspace::default();
3324 build_thin_plate_basis_log_kappasecond_derivativewithworkspace(data, spec, &mut workspace)
3325}
3326
3327pub fn build_thin_plate_basis_log_kappasecond_derivativewithworkspace(
3328 data: ArrayView2<'_, f64>,
3329 spec: &ThinPlateBasisSpec,
3330 workspace: &mut BasisWorkspace,
3331) -> Result<BasisPsiSecondDerivativeResult, BasisError> {
3332 let mut bundle =
3333 build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, workspace)?;
3334 bundle.second.implicit_operator = bundle.implicit_operator;
3335 Ok(bundle.second)
3336}
3337
3338/// High-level TPS constructor: selects knots from data, then builds basis+penalty.
3339pub fn create_thin_plate_spline_basis_with_knot_count(
3340 data: ArrayView2<f64>,
3341 num_knots: usize,
3342) -> Result<(ThinPlateSplineBasis, Array2<f64>), BasisError> {
3343 let mut workspace = BasisWorkspace::default();
3344 create_thin_plate_spline_basis_with_knot_count_andworkspace(data, num_knots, &mut workspace)
3345}
3346
3347pub fn create_thin_plate_spline_basis_with_knot_count_andworkspace(
3348 data: ArrayView2<f64>,
3349 num_knots: usize,
3350 workspace: &mut BasisWorkspace,
3351) -> Result<(ThinPlateSplineBasis, Array2<f64>), BasisError> {
3352 let knots = select_thin_plate_knots(data, num_knots)?;
3353 let basis = create_thin_plate_spline_basiswithworkspace(data, knots.view(), workspace)?;
3354 Ok((basis, knots))
3355}
3356
3357/// Applies a sum-to-zero constraint to a basis matrix for model identifiability.
3358///
3359/// This is achieved by reparameterizing the basis to be orthogonal to the weighted intercept.
3360/// In GAMs, this constraint removes the confounding between the intercept and smooth functions.
3361/// For weighted models (e.g., GLM-IRLS), the constraint is B^T W 1 = 0 instead of B^T 1 = 0.
3362///
3363/// # Arguments
3364/// * `basis_matrix`: An `ArrayView2<f64>` of the original, unconstrained basis matrix.
3365/// * `weights`: Optional weights for the constraint. If None, uses unweighted constraint.
3366///
3367/// # Returns
3368/// A tuple containing:
3369/// - The new, constrained basis matrix (with `k - rank(c)` columns).
3370/// - The transformation matrix `Z` used to create it.
3371pub fn apply_sum_to_zero_constraint(
3372 basis_matrix: ArrayView2<f64>,
3373 weights: Option<ArrayView1<f64>>,
3374) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
3375 let n = basis_matrix.nrows();
3376 let k = basis_matrix.ncols();
3377 if k < 2 {
3378 return Err(BasisError::InsufficientColumnsForConstraint { found: k });
3379 }
3380
3381 // c = B^T w (weighted constraint) or B^T 1 (unweighted constraint)
3382 let constraintvector = match weights {
3383 Some(w) => {
3384 if w.len() != n {
3385 return Err(BasisError::WeightsDimensionMismatch {
3386 expected: n,
3387 found: w.len(),
3388 });
3389 }
3390 w.to_owned()
3391 }
3392 None => Array1::<f64>::ones(n),
3393 };
3394 let c = basis_matrix.t().dot(&constraintvector); // shape k
3395
3396 // Orthonormal basis for nullspace of c^T from a pivoted QR of the k×1
3397 // constraint matrix.
3398 let mut c_mat = Array2::<f64>::zeros((k, 1));
3399 c_mat.column_mut(0).assign(&c);
3400 let (z, rank) =
3401 rrqr_nullspace_basis(&c_mat, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
3402 if rank >= k {
3403 return Err(BasisError::ConstraintNullspaceCollapsed {
3404 site: "apply_sum_to_zero_constraint",
3405 cross_rank: rank,
3406 coeff_dim: k,
3407 cross_frobenius: c.iter().map(|v| v * v).sum::<f64>().sqrt(),
3408 gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
3409 .to_string(),
3410 });
3411 }
3412 if rank == 0 {
3413 // Already orthogonal to the intercept constraint; keep full basis unchanged.
3414 return Ok((basis_matrix.to_owned(), Array2::eye(k)));
3415 }
3416
3417 let gauge = gam_problem::Gauge::sum_to_zero(z);
3418 let constrained = gauge.restrict_design(&basis_matrix);
3419 let z = gauge.block_transform(0);
3420 Ok((constrained, z))
3421}
3422
3423/// Build a sum-to-zero reparametrization for a sparse basis.
3424///
3425/// Returns `(B_c, Z)` where `Z` is an **orthonormal** basis for `null(c^T)`
3426/// with `c = B^T w` (the weighted column sums of `B`), and
3427/// `B_c = B Z` is the constrained design matrix.
3428///
3429/// Because `Z` has orthonormal columns, `Z Zᵀ` is the canonical
3430/// orthogonal projector onto `null(cᵀ)` — i.e. it is idempotent and
3431/// `cᵀ Z Zᵀ = 0`, so any vector projected by `Z Zᵀ` still satisfies the
3432/// sum-to-zero constraint. The previous "drop the pivot column" trick
3433/// produced a valid null-space basis but with non-orthogonal, non-unit
3434/// columns, breaking the projector identities downstream code may rely on.
3435///
3436/// `Z` is dense `(k × (k-1))`; consequently `B_c = B Z` is returned as a
3437/// dense matrix even when `B` is sparse. Callers that previously relied on
3438/// the constrained basis being sparse should wrap the result in
3439/// `DenseDesignMatrix`.
3440pub fn apply_sum_to_zero_constraint_sparse(
3441 basis_matrix: &SparseColMat<usize, f64>,
3442 weights: Option<ArrayView1<f64>>,
3443) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
3444 let n = basis_matrix.nrows();
3445 let k = basis_matrix.ncols();
3446 if k < 2 {
3447 return Err(BasisError::InsufficientColumnsForConstraint { found: k });
3448 }
3449
3450 let constraint_weights = match weights {
3451 Some(w) => {
3452 if w.len() != n {
3453 return Err(BasisError::WeightsDimensionMismatch {
3454 expected: n,
3455 found: w.len(),
3456 });
3457 }
3458 w.to_owned()
3459 }
3460 None => Array1::<f64>::ones(n),
3461 };
3462
3463 // c = Bᵀ w (k-vector of weighted column sums) computed directly from the
3464 // CSC storage.
3465 let mut c = Array1::<f64>::zeros(k);
3466 let (symbolic, values) = basis_matrix.parts();
3467 let col_ptr = symbolic.col_ptr();
3468 let row_idx = symbolic.row_idx();
3469 for col in 0..k {
3470 let mut sum = 0.0;
3471 for idx in col_ptr[col]..col_ptr[col + 1] {
3472 sum += values[idx] * constraint_weights[row_idx[idx]];
3473 }
3474 c[col] = sum;
3475 }
3476
3477 // Orthonormal basis for null(cᵀ) via a column-pivoted QR of the k×1
3478 // constraint matrix — exactly the same construction used by the dense
3479 // path `apply_sum_to_zero_constraint`. This guarantees ZᵀZ = I and hence
3480 // that ZZᵀ is the canonical orthogonal projector onto null(cᵀ).
3481 let mut c_mat = Array2::<f64>::zeros((k, 1));
3482 c_mat.column_mut(0).assign(&c);
3483 let (z, rank) =
3484 rrqr_nullspace_basis(&c_mat, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
3485 if rank >= k {
3486 return Err(BasisError::ConstraintNullspaceCollapsed {
3487 site: "apply_sum_to_zero_constraint_sparse",
3488 cross_rank: rank,
3489 coeff_dim: k,
3490 cross_frobenius: c.iter().map(|v| v * v).sum::<f64>().sqrt(),
3491 gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
3492 .to_string(),
3493 });
3494 }
3495 if rank == 0 {
3496 // Constraint is numerically zero (e.g. weights produced cᵀ ≈ 0):
3497 // the basis already lies in null(cᵀ), so the constrained basis is
3498 // the dense materialization of B with Z = I.
3499 let mut dense_b = Array2::<f64>::zeros((n, k));
3500 for col in 0..k {
3501 for idx in col_ptr[col]..col_ptr[col + 1] {
3502 dense_b[[row_idx[idx], col]] = values[idx];
3503 }
3504 }
3505 return Ok((dense_b, Array2::eye(k)));
3506 }
3507
3508 // Constrained basis B_c = B Z. Iterate columns of Z and apply B as a
3509 // sparse-times-dense-vector product per column. Result is dense
3510 // `(n × (k-1))` since Z is dense.
3511 let kc = z.ncols();
3512 let mut constrained = Array2::<f64>::zeros((n, kc));
3513 for out_col in 0..kc {
3514 let z_col = z.column(out_col);
3515 let mut dst = constrained.column_mut(out_col);
3516 for src_col in 0..k {
3517 let coeff = z_col[src_col];
3518 if coeff == 0.0 {
3519 continue;
3520 }
3521 for idx in col_ptr[src_col]..col_ptr[src_col + 1] {
3522 dst[row_idx[idx]] += coeff * values[idx];
3523 }
3524 }
3525 }
3526
3527 Ok((constrained, z))
3528}
3529
3530/// Reparameterizes a basis matrix so its columns are orthogonal (with optional weights)
3531/// to a supplied constraint matrix.
3532///
3533/// Let:
3534/// - `B` be the raw basis (`n x k`)
3535/// - `C` be the constraint matrix (`n x q`)
3536/// - `W` be diagonal weights (`n x n`), or identity when `weights=None`
3537///
3538/// We seek a transformed basis `B_c = B K` (`n x k_c`) such that:
3539/// `B_c^T W C = 0`.
3540///
3541/// Expanding:
3542/// `B_c^T W C = (B K)^T W C = K^T (B^T W C)`.
3543///
3544/// So it is enough to choose columns of `K` in `null((B^T W C)^T)`.
3545/// This implementation computes:
3546/// `M = B^T W C` (`k x q`)
3547/// and extracts a basis for `null(M^T)` via column-pivoted Householder QR.
3548///
3549/// The result enforces orthogonality by construction while retaining the largest possible
3550/// smooth subspace under the given constraints.
3551pub fn applyweighted_orthogonality_constraint(
3552 basis_matrix: ArrayView2<f64>,
3553 constraint_matrix: ArrayView2<f64>,
3554 weights: Option<ArrayView1<f64>>,
3555) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
3556 let n = basis_matrix.nrows();
3557 let k = basis_matrix.ncols();
3558 if constraint_matrix.nrows() != n {
3559 return Err(BasisError::ConstraintMatrixRowMismatch {
3560 basisrows: n,
3561 constraintrows: constraint_matrix.nrows(),
3562 });
3563 }
3564 if k == 0 {
3565 return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
3566 }
3567 let q = constraint_matrix.ncols();
3568 if q == 0 {
3569 return Ok((basis_matrix.to_owned(), Array2::eye(k)));
3570 }
3571
3572 // Form W*C by row scaling because W is diagonal.
3573 let mut weighted_constraints = constraint_matrix.to_owned();
3574 if let Some(w) = weights {
3575 if w.len() != n {
3576 return Err(BasisError::WeightsDimensionMismatch {
3577 expected: n,
3578 found: w.len(),
3579 });
3580 }
3581 for (mut row, &weight) in weighted_constraints.axis_iter_mut(Axis(0)).zip(w.iter()) {
3582 row *= weight;
3583 }
3584 }
3585
3586 // M = B^T W C. Its transpose M^T has nullspace directions in coefficient space
3587 // that produce basis columns orthogonal to C under the W-inner product.
3588 let constraint_cross = basis_matrix.t().dot(&weighted_constraints); // k×q
3589 let gram = fast_ata(&basis_matrix);
3590 let transform = orthogonality_transform_from_cross_and_gram(&constraint_cross, &gram)?;
3591 let basis_orthonormal = fast_ab(&basis_matrix, &transform);
3592 Ok((basis_orthonormal, transform))
3593}
3594
3595/// Compute Greville abscissae for a B-spline basis.
3596///
3597/// The Greville abscissa for basis function j is defined as:
3598/// G_j = (1/d) × Σ_{k=1}^{d} t_{j+k}
3599///
3600/// These provide the "center" of support for each basis function and are used
3601/// for geometric constraints that don't depend on observed data. A key property
3602/// is that a linear function f(x) = a + bx has B-spline coefficients c_j = a + b·G_j,
3603/// so constraining coefficients to be orthogonal to [1, G] removes linear functions
3604/// from the representable space.
3605///
3606/// # Arguments
3607/// * `knot_vector` - Full knot vector including boundary repetitions
3608/// * `degree` - B-spline degree (typically 3 for cubic)
3609///
3610/// # Returns
3611/// Array of Greville abscissae, one per basis function (length = n_knots - degree - 1)
3612///
3613/// # Errors
3614/// Returns error if knot vector is too short or Greville abscissae are degenerate.
3615pub fn compute_greville_abscissae(
3616 knot_vector: &Array1<f64>,
3617 degree: usize,
3618) -> Result<Array1<f64>, BasisError> {
3619 let n_knots = knot_vector.len();
3620 if degree == 0 {
3621 // For degree 0, Greville abscissae are knot midpoints
3622 let n_basis = n_knots.saturating_sub(1);
3623 if n_basis == 0 {
3624 return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
3625 }
3626 let mut g = Array1::<f64>::zeros(n_basis);
3627 for j in 0..n_basis {
3628 g[j] = 0.5 * (knot_vector[j] + knot_vector[j + 1]);
3629 }
3630 return Ok(g);
3631 }
3632
3633 // Number of basis functions: k = n_knots - degree - 1
3634 if n_knots <= degree + 1 {
3635 return Err(BasisError::InsufficientColumnsForConstraint {
3636 found: n_knots.saturating_sub(degree + 1),
3637 });
3638 }
3639 let n_basis = n_knots - degree - 1;
3640
3641 let mut g = Array1::<f64>::zeros(n_basis);
3642 let d_inv = 1.0 / (degree as f64);
3643
3644 for j in 0..n_basis {
3645 // G_j = (1/d) × Σ_{k=1}^{d} t_{j+k}
3646 let mut sum = 0.0;
3647 for k in 1..=degree {
3648 sum += knot_vector[j + k];
3649 }
3650 g[j] = sum * d_inv;
3651 }
3652
3653 // Check for degeneracy (all Greville abscissae equal)
3654 let g_min = g.iter().cloned().fold(f64::INFINITY, f64::min);
3655 let g_max = g.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
3656 if (g_max - g_min) < 1e-10 {
3657 return Err(BasisError::DegenerateKnots);
3658 }
3659
3660 Ok(g)
3661}
3662
3663/// Compute the constraint transform Z using Greville abscissae (geometric constraints).
3664///
3665/// This creates a transform that removes constant and linear trends from spline
3666/// coefficients based purely on knot geometry, without reference to observed data.
3667/// This makes Z constant w.r.t. model parameters β, ensuring dZ/dβ = 0 exactly,
3668/// which enables exact analytic gradients.
3669///
3670/// # Mathematical Background
3671/// For B-splines, a linear function f(x) = a + bx has coefficients c_j = a + b·G_j
3672/// where G_j are the Greville abscissae. Therefore, constraining the coefficient
3673/// vector θ to satisfy:
3674/// - Σ θ_j = 0 (orthogonal to constants)
3675/// - Σ θ_j·G_j = 0 (orthogonal to linear in Greville coordinates)
3676/// removes the ability to represent any linear function.
3677///
3678/// # Arguments
3679/// * `knot_vector` - Full knot vector
3680/// * `degree` - B-spline degree
3681/// * `penalty_order` - Order of difference penalty (typically 2)
3682///
3683/// # Returns
3684/// Tuple of (transform Z, projected_penalty Z'SZ) where:
3685/// - Z: k × (k-2) matrix mapping raw coefficients to constrained space
3686/// - S_constrained: (k-2) × (k-2) projected second-difference penalty
3687pub fn compute_geometric_constraint_transform(
3688 knot_vector: &Array1<f64>,
3689 degree: usize,
3690 penalty_order: usize,
3691) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
3692 // 1. Compute Greville abscissae
3693 let g = compute_greville_abscissae(knot_vector, degree)?;
3694 let k = g.len();
3695
3696 if k < 3 {
3697 return Err(BasisError::InsufficientColumnsForConstraint { found: k });
3698 }
3699
3700 // 2. Build constraint matrix C_geom (2 × k)
3701 // Row 0: all ones (intercept constraint)
3702 // Row 1: Greville abscissae (linear constraint)
3703 let mut c_geom = Array2::<f64>::zeros((2, k));
3704 for j in 0..k {
3705 c_geom[[0, j]] = 1.0;
3706 c_geom[[1, j]] = g[j];
3707 }
3708
3709 // 3. Standardize linear row for numerical conditioning
3710 let g_mean = g.mean().unwrap_or(0.0);
3711 let gvar = g.iter().map(|&x| (x - g_mean).powi(2)).sum::<f64>() / (k as f64);
3712 let g_std = gvar.sqrt().max(1e-10);
3713 for j in 0..k {
3714 c_geom[[1, j]] = (c_geom[[1, j]] - g_mean) / g_std;
3715 }
3716
3717 // 4. Column-pivoted QR on C_geom^T; the trailing Q columns span null(C_geom).
3718 let (z, rank) = rrqr_nullspace_basis(&c_geom.t(), default_rrqr_rank_alpha())
3719 .map_err(BasisError::LinalgError)?;
3720 if rank >= k {
3721 return Err(BasisError::ConstraintNullspaceCollapsed {
3722 site: "compute_geometric_constraint_transform",
3723 cross_rank: rank,
3724 coeff_dim: k,
3725 cross_frobenius: f64::NAN,
3726 gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
3727 .to_string(),
3728 });
3729 }
3730
3731 if z.ncols() == 0 {
3732 return Err(BasisError::ConstraintNullspaceCollapsed {
3733 site: "compute_geometric_constraint_transform",
3734 cross_rank: 0,
3735 coeff_dim: k,
3736 cross_frobenius: f64::NAN,
3737 gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
3738 .to_string(),
3739 });
3740 }
3741
3742 // 5. Build raw penalty and project: S_c = Z' S Z
3743 let s_raw = create_difference_penalty_matrix(k, penalty_order, Some(g.view()))?;
3744 let s_constrained = {
3745 let zt_s = fast_atb(&z, &s_raw);
3746 fast_ab(&zt_s, &z)
3747 };
3748
3749 Ok((z, s_constrained))
3750}
3751
3752/// Result of auto-deriving a clamped B-spline knot vector from 1-D data.
3753///
3754/// The `degree` / `num_internal_knots` fields report the **effective** values
3755/// that were actually used to build `knots`. They may differ from the
3756/// requested values when the engine had to auto-shrink the configuration
3757/// (issue #340): with small `n`, cubic-by-default gracefully degrades to
3758/// quadratic / linear, and the interior-knot count shrinks toward zero.
3759///
3760/// `shrunk` is `true` iff at least one of the two parameters was reduced
3761/// relative to the request, so callers can surface the decision in model
3762/// summaries / logs without recomputing it.
3763#[derive(Debug, Clone)]
3764pub struct AutoBSplineKnots {
3765 pub knots: Array1<f64>,
3766 pub degree: usize,
3767 pub num_internal_knots: usize,
3768 pub shrunk: bool,
3769}
3770
3771/// Build a clamped B-spline full knot vector from 1-D data.
3772///
3773/// Thin public wrapper around
3774/// `internal::generate_full_knot_vector_quantile` so external crates can
3775/// request auto-derived knots without reimplementing the placement logic.
3776///
3777/// When `n = data.len()` is too small to support the requested
3778/// `(num_internal_knots, degree)` combination, this function auto-shrinks the
3779/// configuration to the largest feasible one (see `auto_shrink_bspline_config`):
3780/// * `num_internal_knots` is capped at `n - 2`.
3781/// * `degree` is reduced (cubic → quadratic → linear) until `n >= degree + 1`.
3782///
3783/// Only when even linear placement is impossible (`n < 2` or the data range is
3784/// degenerate) does this raise an error. The returned [`AutoBSplineKnots`]
3785/// records the effective configuration so downstream evaluators stay in sync.
3786pub fn auto_knot_vector_1d_quantile(
3787 data: ArrayView1<'_, f64>,
3788 num_internal_knots: usize,
3789 degree: usize,
3790) -> Result<AutoBSplineKnots, BasisError> {
3791 let n = data.len();
3792 let Some((eff_knots, eff_degree, shrunk)) =
3793 auto_shrink_bspline_config(n, num_internal_knots, degree)
3794 else {
3795 crate::bail_invalid_basis!(
3796 "auto-knot placement needs at least 2 finite evaluation points (got n={n}); \
3797 cannot fit even a linear B-spline",
3798 );
3799 };
3800 let knots = internal::generate_full_knot_vector_quantile(data, eff_knots, eff_degree)?;
3801 Ok(AutoBSplineKnots {
3802 knots,
3803 degree: eff_degree,
3804 num_internal_knots: eff_knots,
3805 shrunk,
3806 })
3807}
3808
3809/// Build a clamped full B-spline knot vector from explicit *internal* knot
3810/// positions (mgcv `knots=` semantics).
3811///
3812/// The user supplies the interior knots (those strictly between the data
3813/// endpoints). This wraps them in the standard clamped boundary stencil:
3814/// `data_range.0` repeated `degree + 1` times, the sorted distinct internal
3815/// positions, then `data_range.1` repeated `degree + 1` times — matching the
3816/// layout produced by `internal::generate_full_knot_vector` for the uniform
3817/// case, except the interior positions are taken verbatim from the caller.
3818///
3819/// Internal positions must lie strictly inside `(data_range.0, data_range.1)`,
3820/// be finite, and be strictly increasing after sorting (no duplicates, which
3821/// would create a degenerate knot span). The data range itself is derived from
3822/// the covariate so the spline domain still spans the observed data even when
3823/// the user only pins a few interior knots.
3824pub fn clamped_knot_vector_from_internal_positions(
3825 data_range: (f64, f64),
3826 internal_positions: &[f64],
3827 degree: usize,
3828) -> Result<Array1<f64>, BasisError> {
3829 let (minval, maxval) = data_range;
3830 if !(minval.is_finite() && maxval.is_finite()) {
3831 crate::bail_invalid_basis!(
3832 "explicit knots require a finite data range, got ({minval:.6e}, {maxval:.6e})"
3833 );
3834 }
3835 if minval >= maxval {
3836 return Err(BasisError::InvalidRange(minval, maxval));
3837 }
3838 let scale = (maxval - minval).abs().max(1.0);
3839 let tol = 1e-12 * scale;
3840
3841 let mut interior: Vec<f64> = Vec::with_capacity(internal_positions.len());
3842 for &k in internal_positions {
3843 if !k.is_finite() {
3844 crate::bail_invalid_basis!("explicit knot position {k:.6e} is not finite");
3845 }
3846 if k <= minval + tol || k >= maxval - tol {
3847 crate::bail_invalid_basis!(
3848 "explicit internal knot {k:.6e} must lie strictly inside the data range \
3849 ({minval:.6e}, {maxval:.6e}); boundary knots are added automatically"
3850 );
3851 }
3852 interior.push(k);
3853 }
3854 interior.sort_by(f64::total_cmp);
3855 for w in interior.windows(2) {
3856 if (w[1] - w[0]).abs() <= tol {
3857 crate::bail_invalid_basis!(
3858 "explicit internal knots must be strictly increasing; \
3859 found a duplicate/near-duplicate near {:.6e}",
3860 w[0]
3861 );
3862 }
3863 }
3864
3865 let total_knots = interior.len() + 2 * (degree + 1);
3866 let mut knots = Vec::with_capacity(total_knots);
3867 for _ in 0..=degree {
3868 knots.push(minval);
3869 }
3870 knots.extend_from_slice(&interior);
3871 for _ in 0..=degree {
3872 knots.push(maxval);
3873 }
3874 Ok(Array::from_vec(knots))
3875}
3876
3877/// Place `num_centers` Duchon centers on 1-D data via the equal-mass strategy.
3878///
3879/// Thin public wrapper around `select_equal_mass_centers` specialised to a
3880/// single covariate dimension. The returned vector is sorted.
3881pub fn auto_centers_1d_equal_mass(
3882 data: ArrayView1<'_, f64>,
3883 num_centers: usize,
3884) -> Result<Array1<f64>, BasisError> {
3885 let column = data.to_owned().insert_axis(Axis(1));
3886 let centers = select_equal_mass_centers(column.view(), num_centers)?;
3887 let mut flat: Vec<f64> = centers.column(0).iter().copied().collect();
3888 flat.sort_by(f64::total_cmp);
3889 Ok(Array1::from_vec(flat))
3890}
3891
3892#[cfg(test)]
3893mod knot_selection_tie_break_cost_tests {
3894 use super::{select_thin_plate_knot_rows, select_thin_plate_knots};
3895 use ndarray::Array2;
3896
3897 /// The knot rows the production selector picks, together with the number of
3898 /// `O(n·d + n log n)` support-distance profiles the shared invariant
3899 /// tie-break built getting there.
3900 struct KnotSelection {
3901 rows: Vec<usize>,
3902 profile_builds: usize,
3903 }
3904
3905 fn select_with_profile_count(data: &Array2<f64>, num_knots: usize) -> KnotSelection {
3906 let (rows, profile_builds) = select_thin_plate_knot_rows(data.view(), num_knots)
3907 .expect("fixture admits the requested knot budget");
3908 KnotSelection {
3909 rows,
3910 profile_builds,
3911 }
3912 }
3913
3914 /// Deterministic unit draws (SplitMix64 finalizer): no RNG state, no seed
3915 /// coupling between rows.
3916 fn hashed_unit(index: u64) -> f64 {
3917 let mut z = index.wrapping_add(0x9E37_79B9_7F4A_7C15);
3918 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
3919 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
3920 ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64
3921 }
3922
3923 /// A cloud with no exact symmetry: no two rows can tie the maximin key.
3924 fn asymmetric_cloud(n: usize, d: usize) -> Array2<f64> {
3925 Array2::from_shape_fn((n, d), |(row, col)| hashed_unit((row * d + col) as u64))
3926 }
3927
3928 /// An exactly-representable integer lattice — the canonical gridded spatial
3929 /// input, whose corner and edge classes tie every `O(1)` key exactly.
3930 fn integer_grid(side: usize) -> Array2<f64> {
3931 Array2::from_shape_fn((side * side, 2), |(row, col)| {
3932 if col == 0 {
3933 (row / side) as f64
3934 } else {
3935 (row % side) as f64
3936 }
3937 })
3938 }
3939
3940 /// The sorted support-distance profile is a tie-break, and a tie-break must
3941 /// only be paid for where something is actually tied. A cloud with no exact
3942 /// symmetry has a unique maximin winner at every step, so the selection must
3943 /// complete having built NO profile at all — at any `n`, any dimension, and
3944 /// any knot budget.
3945 ///
3946 /// The two-profile comparator scan this replaced (#2420) built exactly
3947 /// `2·num_knots` profiles on precisely this input: the `reduce` over a
3948 /// one-element candidate list compares nothing, and the `retain` that follows
3949 /// it still sorted two length-`n` profiles to establish that a row equals
3950 /// itself. At `n = 200_000`, `d = 8`, `k = 300` that is ~3e9 wasted
3951 /// single-threaded operations before any knot is chosen.
3952 #[test]
3953 fn thin_plate_knots_cost_no_profile_without_an_exact_tie() {
3954 for (n, d) in [(2_000_usize, 2_usize), (2_000, 8), (8_000, 3)] {
3955 for k in [20_usize, 100] {
3956 let data = asymmetric_cloud(n, d);
3957 let chosen = select_with_profile_count(&data, k);
3958 assert_eq!(chosen.rows.len(), k, "knot budget (n={n}, d={d}, k={k})");
3959 assert_eq!(
3960 chosen.profile_builds,
3961 0,
3962 "no row ties the maximin key on an asymmetric cloud, so the profile \
3963 tie-break must never be built (n={n}, d={d}, k={k}); the replaced \
3964 comparator scan built {}",
3965 2 * k
3966 );
3967 }
3968 }
3969 }
3970
3971 /// On an exact integer lattice the tie-break IS reached — a corner class is
3972 /// genuinely related by a symmetry of the square. The cost of reaching it
3973 /// must still be a property of the symmetry, not of the row count: the
3974 /// profile key may only be built for rows that tie every `O(1)` key at the
3975 /// maximin extremum, so the count stays at one profile per selected knot even
3976 /// as `n` grows nine-fold.
3977 #[test]
3978 fn thin_plate_knot_profile_cost_does_not_scale_with_the_row_count() {
3979 for side in [20_usize, 60] {
3980 for k in [20_usize, 100] {
3981 let data = integer_grid(side);
3982 let n = data.nrows();
3983 let chosen = select_with_profile_count(&data, k);
3984 assert_eq!(chosen.rows.len(), k, "knot budget (n={n}, k={k})");
3985 assert!(
3986 chosen.profile_builds <= 2 * k,
3987 "profile-key builds must stay proportional to the knot budget, not to \
3988 the row count; got {} at n={n} k={k}",
3989 chosen.profile_builds
3990 );
3991 }
3992 }
3993 }
3994
3995 /// The gate above must not be satisfiable by deleting the tie-break. On a
3996 /// square's four corners plus its center, the corner class is an indivisible
3997 /// symmetry orbit, and the profile key is what proves it — so it must
3998 /// genuinely be built there.
3999 #[test]
4000 fn thin_plate_knot_profile_key_is_still_built_where_it_decides_an_orbit() {
4001 let data = ndarray::array![
4002 [-1.0_f64, -1.0],
4003 [-1.0, 1.0],
4004 [1.0, -1.0],
4005 [1.0, 1.0],
4006 [0.0, 0.0]
4007 ];
4008 let chosen = select_with_profile_count(&data, 5);
4009 assert_eq!(chosen.rows.len(), 5);
4010 assert!(
4011 chosen.profile_builds > 0,
4012 "the four-corner orbit is only provable through the invariant profile key"
4013 );
4014 }
4015
4016 /// The public `Array2` surface must be exactly the rows the observer-carrying
4017 /// path selects, in the same order — the observer variant is the production
4018 /// code, not a parallel implementation.
4019 #[test]
4020 fn the_public_knot_matrix_is_the_selected_rows_verbatim() {
4021 for (n, d, k) in [(500_usize, 2_usize, 17_usize), (441, 2, 40)] {
4022 let data = if d == 2 && n == 441 {
4023 integer_grid(21)
4024 } else {
4025 asymmetric_cloud(n, d)
4026 };
4027 let chosen = select_with_profile_count(&data, k);
4028 let knots = select_thin_plate_knots(data.view(), k).expect("same budget");
4029 assert_eq!(knots.nrows(), chosen.rows.len());
4030 for (r, &row) in chosen.rows.iter().enumerate() {
4031 for c in 0..d {
4032 assert_eq!(
4033 knots[[r, c]].to_bits(),
4034 data[[row, c]].to_bits(),
4035 "knot {r} column {c} is not data row {row} verbatim"
4036 );
4037 }
4038 }
4039 }
4040 }
4041}
4042
4043#[cfg(test)]
4044mod knot_selection_invariance_tests {
4045 // Regression tests for the knot-selector invariance defects fixed by the
4046 // rotation-equivariant maximin seed (gam#1456 rotation, gam#1378 row
4047 // permutation). Both would FAIL on the OLD seed, which started the greedy
4048 // farthest-point recursion at the lexicographically-smallest-coordinate row:
4049 // * a 90 degree rotation about the centroid changes which row is
4050 // lexicographically smallest, reseeding at a different physical point and
4051 // selecting a different knot SET (rotation leak, #1456);
4052 // * a row permutation changes the row index of that smallest row only when
4053 // two rows tie, but more fundamentally the index-based tie-breaks made the
4054 // selected set order-dependent (#1378).
4055 // The fix seeds at the centroid-nearest row (rotation-equivariant, a pure
4056 // function of the unordered value set) with value-lexicographic tie-breaks, so
4057 // the selected SET is invariant under both transforms to machine precision.
4058 use super::select_thin_plate_knots;
4059 use ndarray::Array2;
4060
4061 /// A deterministic, asymmetric 2-D point cloud. It is deliberately NOT a
4062 /// rotation-symmetric grid: the points have distinct distances to the
4063 /// centroid and distinct coordinate orderings, so the centroid-nearest seed
4064 /// is unique and the OLD lexicographic seed lands on a different physical
4065 /// point after a 90 degree rotation.
4066 fn sample_cloud() -> Array2<f64> {
4067 // 12 scattered points in the plane.
4068 let pts: Vec<[f64; 2]> = vec![
4069 [0.10, 0.20],
4070 [1.30, 0.05],
4071 [2.10, 1.40],
4072 [0.40, 2.30],
4073 [1.90, 2.80],
4074 [3.20, 0.70],
4075 [2.70, 3.10],
4076 [0.90, 1.10],
4077 [3.50, 2.20],
4078 [1.60, 3.60],
4079 [0.05, 3.05],
4080 [2.40, 0.30],
4081 ];
4082 let mut a = Array2::<f64>::zeros((pts.len(), 2));
4083 for (i, p) in pts.iter().enumerate() {
4084 a[[i, 0]] = p[0];
4085 a[[i, 1]] = p[1];
4086 }
4087 a
4088 }
4089
4090 /// Canonicalise a knot set into a sorted multiset of (bit-pattern) coordinate
4091 /// tuples so two selections can be compared as SETS, independent of the order
4092 /// in which the rows were emitted. Using the IEEE-754 bit pattern makes the
4093 /// comparison exact (machine precision) and is valid here because the 90
4094 /// degree rotation `(x,z)->(-z,x)` about the centroid is built from exact
4095 /// f64 additions/negations of the same operands, so equal physical points
4096 /// have bit-identical coordinates.
4097 fn canonical(knots: &Array2<f64>) -> Vec<(u64, u64)> {
4098 let mut rows: Vec<(u64, u64)> = (0..knots.nrows())
4099 .map(|r| (knots[[r, 0]].to_bits(), knots[[r, 1]].to_bits()))
4100 .collect();
4101 rows.sort_unstable();
4102 rows
4103 }
4104
4105 /// Centroid of a 2-D point set, as the rigid-rotation pivot.
4106 fn data_centroid_2d(data: &Array2<f64>) -> (f64, f64) {
4107 let n = data.nrows();
4108 let cx = (0..n).map(|i| data[[i, 0]]).sum::<f64>() / n as f64;
4109 let cz = (0..n).map(|i| data[[i, 1]]).sum::<f64>() / n as f64;
4110 (cx, cz)
4111 }
4112
4113 /// Exact 90 degree rotation of every row about an EXPLICIT center
4114 /// `(cx, cz)`: `(x, z) -> (cx - (z - cz), cz + (x - cx))`. Built from f64
4115 /// add/sub only, so it introduces no rounding beyond the operands
4116 /// themselves. The center is passed in (rather than recomputed per array)
4117 /// so the data and a selected subset can be rotated about the SAME pivot —
4118 /// rotation invariance of the knot SET is `select(R·data) == R·select(data)`
4119 /// for one fixed `R`, which only holds bit-for-bit when both sides rotate
4120 /// about the identical center.
4121 fn rotate_90_about(data: &Array2<f64>, cx: f64, cz: f64) -> Array2<f64> {
4122 let n = data.nrows();
4123 let mut out = Array2::<f64>::zeros((n, 2));
4124 for i in 0..n {
4125 let dx = data[[i, 0]] - cx;
4126 let dz = data[[i, 1]] - cz;
4127 out[[i, 0]] = cx - dz;
4128 out[[i, 1]] = cz + dx;
4129 }
4130 out
4131 }
4132
4133 #[test]
4134 fn knot_set_is_rotation_invariant_gh1456() {
4135 let data = sample_cloud();
4136 let n = data.nrows();
4137 // FarthestPoint path: strictly fewer knots than rows (centers != n).
4138 let num_knots = 5;
4139 assert!(num_knots < n, "must exercise the farthest-point selector");
4140
4141 let knots = select_thin_plate_knots(data.view(), num_knots).expect("select knots");
4142 assert_eq!(knots.nrows(), num_knots);
4143
4144 // ONE rigid rotation R about the fixed data centroid, applied to both
4145 // the full data and the selected subset. Rotating the knots about their
4146 // OWN centroid instead would be a different map and could never match
4147 // bit-for-bit even under perfect invariance.
4148 let (cx, cz) = data_centroid_2d(&data);
4149 let rotated = rotate_90_about(&data, cx, cz);
4150 let knots_rot = select_thin_plate_knots(rotated.view(), num_knots).expect("select rotated");
4151
4152 // The invariant: selecting in the rotated frame yields the SAME physical
4153 // points as rotating the originally-selected set. With an exact 90 degree
4154 // rotation this holds to machine precision (bit-identical coordinates).
4155 let knots_then_rotate = rotate_90_about(&knots, cx, cz);
4156 assert_eq!(
4157 canonical(&knots_then_rotate),
4158 canonical(&knots_rot),
4159 "rotating-then-selecting must equal selecting-then-rotating (gh#1456); \
4160 the OLD lexicographic seed picks a different physical point after rotation"
4161 );
4162 }
4163
4164 #[test]
4165 fn knot_set_is_row_permutation_invariant_gh1378() {
4166 let data = sample_cloud();
4167 let n = data.nrows();
4168 let num_knots = 5;
4169 assert!(num_knots < n, "must exercise the farthest-point selector");
4170
4171 let knots = select_thin_plate_knots(data.view(), num_knots).expect("select knots");
4172
4173 // A non-trivial permutation of the rows (a fixed derangement-ish shuffle).
4174 let perm: Vec<usize> = vec![7, 0, 11, 3, 9, 1, 5, 10, 2, 8, 4, 6];
4175 assert_eq!(perm.len(), n);
4176 let mut permuted = Array2::<f64>::zeros((n, 2));
4177 for (new_row, &old_row) in perm.iter().enumerate() {
4178 permuted[[new_row, 0]] = data[[old_row, 0]];
4179 permuted[[new_row, 1]] = data[[old_row, 1]];
4180 }
4181
4182 let knots_perm =
4183 select_thin_plate_knots(permuted.view(), num_knots).expect("select permuted");
4184
4185 // The selected SET (as physical coordinate tuples) must be bit-identical
4186 // regardless of input row order (gh#1378).
4187 assert_eq!(
4188 canonical(&knots),
4189 canonical(&knots_perm),
4190 "reordering rows must not change the selected knot set (gh#1378)"
4191 );
4192 }
4193
4194 #[test]
4195 fn symmetric_nonseed_orbit_is_completed_atomically() {
4196 let data = ndarray::array![[0.0, 0.0], [0.0, 0.0], [0.0, 1.0], [0.0, -1.0]];
4197 let permutations = [[0_usize, 1, 2, 3], [0, 1, 3, 2], [2, 0, 3, 1], [3, 1, 2, 0]];
4198 let mut reference = None;
4199
4200 for order in permutations {
4201 let permuted = Array2::from_shape_fn((4, 2), |(row, col)| data[[order[row], col]]);
4202 let knots = select_thin_plate_knots(permuted.view(), 3)
4203 .expect("origin plus the complete endpoint orbit fits the budget");
4204 let center_set = canonical(&knots);
4205 if let Some(expected) = reference.as_ref() {
4206 assert_eq!(¢er_set, expected);
4207 } else {
4208 reference = Some(center_set);
4209 }
4210 }
4211 }
4212
4213 #[test]
4214 fn incomplete_nonseed_orbit_is_capped_not_refused() {
4215 // origin (coincident pair, one distinct seed) plus the endpoint orbit
4216 // {(0,1),(0,-1)}. With one slot left after the seed, the endpoint orbit
4217 // cannot be split equivariantly — but refusing the fit is worse than
4218 // taking a deterministic member. The selection must succeed with exactly
4219 // `num_knots` distinct centers: the seed plus the lowest-row endpoint.
4220 let data = ndarray::array![[0.0, 0.0], [0.0, 0.0], [0.0, 1.0], [0.0, -1.0]];
4221 let knots = select_thin_plate_knots(data.view(), 2)
4222 .expect("an oversized orbit must be capped, never refused");
4223 assert_eq!(knots.nrows(), 2, "capped selection must honour the budget");
4224 assert_eq!(
4225 canonical(&knots),
4226 canonical(&ndarray::array![[0.0, 0.0], [0.0, 1.0]]),
4227 "seed plus the lowest-row endpoint of the tied orbit"
4228 );
4229 }
4230
4231 #[test]
4232 fn seed_orbit_larger_than_budget_is_capped_not_refused() {
4233 // Two antipodal points form a single indivisible seed orbit; a one-knot
4234 // budget cannot represent both. The selection must still succeed, taking
4235 // the lowest-row member deterministically rather than refusing.
4236 let data = ndarray::array![[-1.0, 0.0], [1.0, 0.0]];
4237 let knots = select_thin_plate_knots(data.view(), 1)
4238 .expect("an antipodal seed orbit must be capped, never refused");
4239 assert_eq!(knots.nrows(), 1, "capped selection must honour the budget");
4240 assert_eq!(
4241 canonical(&knots),
4242 canonical(&ndarray::array![[-1.0, 0.0]]),
4243 "lowest-row member of the antipodal seed orbit"
4244 );
4245 }
4246
4247 #[test]
4248 fn regular_grid_fits_every_budget_with_distinct_centers() {
4249 // #2319 regression guard: a regular integer grid has exactly-representable
4250 // coordinates, so its corner/edge maximin orbits tie EXACTLY and typically
4251 // exceed the requested budget. The atomic-orbit rule used to refuse the
4252 // fit for common budgets (e.g. `k=15` on a 7x7 grid); it must instead cap
4253 // each oversized orbit and return exactly `k` geometrically distinct
4254 // centers for every in-range budget.
4255 let side = 7usize;
4256 let grid = Array2::from_shape_fn((side * side, 2), |(row, col)| {
4257 let (ix, iy) = (row % side, row / side);
4258 if col == 0 { ix as f64 } else { iy as f64 }
4259 });
4260 for k in 1..=side * side {
4261 let knots = select_thin_plate_knots(grid.view(), k)
4262 .unwrap_or_else(|e| panic!("grid must fit k={k}, got: {e}"));
4263 assert_eq!(knots.nrows(), k, "grid selection must honour budget k={k}");
4264 // Centers must be geometrically distinct (no coincident rows), or the
4265 // thin-plate Gram is singular.
4266 let mut set = canonical(&knots);
4267 let full = set.len();
4268 set.dedup();
4269 assert_eq!(set.len(), full, "duplicate centers at k={k}");
4270 }
4271 }
4272
4273 #[test]
4274 fn capping_preserves_rotation_equivariance_on_generic_cloud() {
4275 // The #2319 contract lives on ISOTROPIC data, where generic coordinates
4276 // never tie exactly, so the capping path is not even entered and every
4277 // maximin/tie-break key is exactly rotation-invariant. Verify a budget
4278 // large enough to exercise many selection steps stays equivariant under
4279 // an exact 90-degree rotation (bit-preserving), so the fix did not perturb
4280 // the property the issue is actually about.
4281 let data = sample_cloud();
4282 let num_knots = 9.min(data.nrows() - 1);
4283 let (cx, cz) = data_centroid_2d(&data);
4284 let knots = select_thin_plate_knots(data.view(), num_knots).expect("base select");
4285 let rotated = rotate_90_about(&data, cx, cz);
4286 let knots_rot = select_thin_plate_knots(rotated.view(), num_knots).expect("rotated select");
4287 assert_eq!(
4288 canonical(&rotate_90_about(&knots, cx, cz)),
4289 canonical(&knots_rot),
4290 "capping change must not break rotation equivariance on generic data"
4291 );
4292 }
4293}
4294
4295#[cfg(test)]
4296mod duchon_operator_gate_tests {
4297 use super::{DuchonOperatorPenaltySpec, OperatorPenaltySpec};
4298
4299 #[test]
4300 fn default_duchon_operator_penalties_are_active() {
4301 let default_spec = DuchonOperatorPenaltySpec::default();
4302
4303 assert!(
4304 default_spec.has_active_operator_penalty(),
4305 "default Duchon terms must bypass the native-only fused radial path"
4306 );
4307 assert!(
4308 matches!(default_spec.mass, OperatorPenaltySpec::Active { .. })
4309 && matches!(default_spec.tension, OperatorPenaltySpec::Active { .. })
4310 && matches!(default_spec.stiffness, OperatorPenaltySpec::Disabled),
4311 "the default is mass+tension active with stiffness disabled"
4312 );
4313 }
4314
4315 #[test]
4316 fn all_disabled_duchon_operator_penalties_are_native_only() {
4317 let native_only = DuchonOperatorPenaltySpec::all_disabled();
4318
4319 assert!(
4320 !native_only.has_active_operator_penalty(),
4321 "all_disabled() is the explicit native-Gram-only configuration"
4322 );
4323 }
4324}
4325
4326#[cfg(test)]
4327mod retained_radial_indices_tests {
4328 use super::thin_plate_retained_radial_indices;
4329 use ndarray::Array1;
4330
4331 // The eigenvalue spectra below were captured from the live thin-plate
4332 // builder (`s(x, bs="tp", k=20)`) on the #1271 regression data. They lock
4333 // in the derived selection behaviour: keep EVERY numerically-real bending
4334 // mode (matching mgcv, which truncates only at the numerical-rank floor),
4335 // dropping only sub-floor roundoff dust — no tuned magnitude cutoff.
4336
4337 #[test]
4338 fn linear_data_spectrum_keeps_every_mode() {
4339 // Purely linear DGP: every eigenvalue is far above the numerical floor,
4340 // so all are genuine curvature directions and must be kept. REML (not
4341 // basis truncation) is responsible for the EDF on linear data.
4342 let evals = Array1::from_vec(vec![
4343 885.4, 119.98, 26.287, 10.030, 5.066, 2.330, 1.3953, 0.67709, 0.46814, 0.34210,
4344 0.26488, 0.17895, 0.14514,
4345 ]);
4346 let keep = thin_plate_retained_radial_indices(&evals);
4347 assert_eq!(
4348 keep.len(),
4349 evals.len(),
4350 "all numerically real modes must be retained"
4351 );
4352 }
4353
4354 #[test]
4355 fn lidar_spectrum_keeps_every_real_mode() {
4356 // Real lidar fit: the smallest eigenvalues (~0.04) are still ~12 orders
4357 // of magnitude above the numerical floor (K*eps*lambda_max ~ 5e-12), so
4358 // they are real bending modes and are kept — pruning them by magnitude
4359 // was the #1271 over-prune that collapsed the nonlinear truth recovery.
4360 let evals = Array1::from_vec(vec![
4361 1212.2, 144.94, 37.270, 15.529, 6.0768, 3.5845, 1.8094, 1.1058, 0.73002, 0.43701,
4362 0.33814, 0.23136, 0.18267, 0.15702, 0.13654, 0.044936, 0.041844, 0.038235,
4363 ]);
4364 let keep = thin_plate_retained_radial_indices(&evals);
4365 assert_eq!(keep.len(), evals.len(), "every above-floor mode is kept");
4366 }
4367
4368 #[test]
4369 fn pure_roundoff_modes_are_dropped() {
4370 // A mode below the K*eps*lambda_max numerical floor is roundoff dust.
4371 // Here K=5, lambda_max=1e3 => floor = 5*eps*1e3; put the dust an order
4372 // of magnitude below that floor.
4373 let big = 1.0e3;
4374 let dust = 0.1 * 5.0 * f64::EPSILON * big; // well below the K*eps*max floor
4375 let evals = Array1::from_vec(vec![big, 100.0, 10.0, 1.0, dust]);
4376 let keep = thin_plate_retained_radial_indices(&evals);
4377 assert_eq!(keep.len(), 4, "the sub-floor roundoff mode must be pruned");
4378 assert!(!keep.contains(&4));
4379 }
4380
4381 #[test]
4382 fn empty_and_singleton_spectra_are_handled() {
4383 assert!(thin_plate_retained_radial_indices(&Array1::from_vec(vec![])).is_empty());
4384 assert_eq!(
4385 thin_plate_retained_radial_indices(&Array1::from_vec(vec![5.0])),
4386 vec![0]
4387 );
4388 }
4389}
4390
4391#[cfg(test)]
4392mod gc_spectrum_diag_1757_tests {
4393 // ROOT-2 measurement for the perf cluster (#1757 duchon / #1689 thin-plate):
4394 // does the design Gram Gc = KᵀK (radial kernel evaluated at the selected
4395 // knots) have a REDUNDANCY CLIFF — a capacity-preserving low-rank truncation
4396 // à la Wood-2003, where dropping near-duplicate radial columns shrinks the
4397 // final basis dimension p WITHOUT removing function-space capacity — or only
4398 // a smooth power-law tail, in which case no magic-free p-reduction exists and
4399 // the current machine-eps whitening floor already keeps everything meaningful.
4400 //
4401 // This is a DIAGNOSTIC (no behavioural assertion beyond "it ran"): it prints
4402 // the Gc spectrum for the #1757/#1689 repro sizes so CI can grep the shard
4403 // log. It uses the PRODUCTION knot selector (`select_thin_plate_knots`,
4404 // farthest-point) and the PRODUCTION thin-plate kernel
4405 // (`thin_plate_kernel_from_dist2`), so the spectrum matches what the real
4406 // basis builder forms (the polynomial-null constraint Z removes only 3 dims
4407 // and cannot create or erase a spectral cliff, so the raw KᵀK Gram answers
4408 // the redundancy-tail question).
4409 use super::{select_thin_plate_knots, thin_plate_kernel_from_dist2};
4410 use crate::basis::default_num_centers;
4411 use faer::Side;
4412 use gam_linalg::faer_ndarray::FaerEigh;
4413 use ndarray::Array2;
4414
4415 // Deterministic uniform scatter in [-1, 1]^2 (SplitMix64; no `rand`
4416 // dependency, so the printed spectrum is reproducible across machines).
4417 fn scatter(n: usize, seed: u64) -> Array2<f64> {
4418 let mut s = seed ^ 0x9e37_79b9_7f4a_7c15;
4419 let mut next = || {
4420 s = s.wrapping_add(0x9e37_79b9_7f4a_7c15);
4421 let mut z = s;
4422 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
4423 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
4424 z ^= z >> 31;
4425 ((z >> 11) as f64) / ((1u64 << 53) as f64) // in [0, 1)
4426 };
4427 let mut x = Array2::<f64>::zeros((n, 2));
4428 for i in 0..n {
4429 x[[i, 0]] = 2.0 * next() - 1.0;
4430 x[[i, 1]] = 2.0 * next() - 1.0;
4431 }
4432 x
4433 }
4434
4435 /// Returns `(p, kk, cond)`: number of positive Gram eigenvalues, knot count,
4436 /// and condition number `λ_max / λ_min⁺`. The caller asserts the design-Gram
4437 /// invariants (`1 ≤ p ≤ kk`, `cond` finite and `≥ 1`); the printed spectrum is
4438 /// the diagnostic signal.
4439 fn report(label: &str, n: usize, seed: u64) -> (usize, usize, f64) {
4440 let x = scatter(n, seed);
4441 let k = default_num_centers(n, 2);
4442 let knots = select_thin_plate_knots(x.view(), k).expect("knot selection");
4443 let kk = knots.nrows();
4444 // Design K (n x kk): K[i,c] = phi(||x_i - knot_c||^2), thin-plate d=2.
4445 let mut kdes = Array2::<f64>::zeros((n, kk));
4446 for i in 0..n {
4447 for c in 0..kk {
4448 let dx = x[[i, 0]] - knots[[c, 0]];
4449 let dy = x[[i, 1]] - knots[[c, 1]];
4450 let d2 = dx * dx + dy * dy;
4451 kdes[[i, c]] = thin_plate_kernel_from_dist2(d2, 2).expect("kernel");
4452 }
4453 }
4454 let gc = kdes.t().dot(&kdes); // kk x kk design Gram
4455 let (evals, _evecs) = FaerEigh::eigh(&gc, Side::Lower).expect("eigh");
4456 let mut ev: Vec<f64> = evals.iter().copied().filter(|v| *v > 0.0).collect();
4457 ev.sort_by(|a, b| b.partial_cmp(a).unwrap()); // descending
4458 let m = ev.len();
4459 if m == 0 {
4460 eprintln!("[GC-DIAG-1757] {label} n={n}: empty spectrum");
4461 return (0, kk, f64::INFINITY);
4462 }
4463 let lam_max = ev[0];
4464 let eps_floor = (kk as f64) * f64::EPSILON * lam_max; // current whitening floor
4465 let eps_kept = ev.iter().filter(|v| **v > eps_floor).count();
4466 let count_rel = |t: f64| ev.iter().filter(|v| **v / lam_max > t).count();
4467 // Largest multiplicative gap in the sorted spectrum (eigengap estimator).
4468 let mut best_gap = 0.0_f64;
4469 let mut gap_keep = m;
4470 for j in 0..m - 1 {
4471 let g = (ev[j] / ev[j + 1]).ln();
4472 if g > best_gap {
4473 best_gap = g;
4474 gap_keep = j + 1;
4475 }
4476 }
4477 eprintln!(
4478 "[GC-DIAG-1757] {label} n={n} k_req={k} kk={kk} p={m} cond={:.2e} eps_kept={eps_kept} eigengap_keep={gap_keep} log_gap={:.2} | #rel> 1e-2:{} 1e-4:{} 1e-6:{} 1e-8:{} 1e-10:{}",
4479 lam_max / ev[m - 1],
4480 best_gap,
4481 count_rel(1e-2),
4482 count_rel(1e-4),
4483 count_rel(1e-6),
4484 count_rel(1e-8),
4485 count_rel(1e-10),
4486 );
4487 let sampled: Vec<String> = (0..m)
4488 .step_by((m / 20).max(1))
4489 .map(|i| format!("{:.1}", (ev[i] / lam_max).log10()))
4490 .collect();
4491 eprintln!(
4492 "[GC-DIAG-1757] {label} log10(rel eigenvalue) sampled: {}",
4493 sampled.join(" ")
4494 );
4495 (m, kk, lam_max / ev[m - 1])
4496 }
4497
4498 #[test]
4499 fn gc_spectrum_duchon_thinplate_repro_sizes() {
4500 // The redundancy-tail answer is left to the printed spectrum; these are
4501 // structural design-Gram invariants a broken Gram/knot/kernel would
4502 // violate (they do NOT presuppose the cliff-vs-power-law verdict).
4503 for (label, n, seed) in [
4504 ("duchon_n500", 500usize, 42u64),
4505 ("duchon_n1220", 1220, 43),
4506 ("thinplate_n1200", 1200, 7),
4507 ] {
4508 let (p, kk, cond) = report(label, n, seed);
4509 assert!(
4510 p >= 1 && p <= kk,
4511 "{label}: positive-eigenvalue count {p} must be in 1..={kk}"
4512 );
4513 assert!(
4514 cond.is_finite() && cond >= 1.0,
4515 "{label}: condition number {cond} must be finite and >= 1"
4516 );
4517 }
4518 }
4519}
4520
4521#[cfg(test)]
4522mod range_floor_psi_jet_tests {
4523 use super::*;
4524 use ndarray::Array2;
4525
4526 // Build a symmetric matrix from a lower-triangular seed so Ω(ψ) stays
4527 // symmetric for every ψ.
4528 fn sym_from(seed: &[f64], n: usize) -> Array2<f64> {
4529 let mut m = Array2::<f64>::zeros((n, n));
4530 let mut k = 0usize;
4531 for i in 0..n {
4532 for j in 0..=i {
4533 m[[i, j]] = seed[k];
4534 m[[j, i]] = seed[k];
4535 k += 1;
4536 }
4537 }
4538 m
4539 }
4540
4541 // Controlled model: Ω(ψ) = Ω0 + ψ·B + ½ψ²·C with a WELL-SEPARATED base
4542 // spectrum whose two smallest modes sit ~100× below the range floor and a
4543 // deliberately SMALL non-commuting perturbation, so (a) the clamped set is
4544 // stable across ±eps (the clamp is only C⁰ where a mode crosses the floor,
4545 // which would corrupt a finite difference) while (b) B does not commute with
4546 // Ω0, rotating the eigenvectors so the off-diagonal Daleckii–Krein terms are
4547 // genuinely exercised. Ω0 = U diag(d) Uᵀ with U the eigenvectors of a fixed
4548 // symmetric seed and d spanning the floor boundary.
4549 fn omega_at(psi: f64) -> (Array2<f64>, Array2<f64>, Array2<f64>) {
4550 let n = 5usize;
4551 let seed = sym_from(
4552 &[
4553 1.0, 0.3, 0.9, -0.2, 0.4, 1.1, 0.15, -0.25, 0.35, 0.8, 0.05, 0.2, -0.1, 0.3, 0.95,
4554 ],
4555 n,
4556 );
4557 let (_evals, u) = FaerEigh::eigh(&seed, Side::Lower).expect("seed eigh");
4558 // Target spectrum: three modes well above the floor (8e-8·λmax = 8e-8),
4559 // two modes ~100× below it and mutually separated by 10×.
4560 let d = [1.0_f64, 0.08, 0.006, 5.0e-10, 5.0e-11];
4561 let mut base = Array2::<f64>::zeros((n, n));
4562 for i in 0..n {
4563 for j in 0..n {
4564 let mut acc = 0.0;
4565 for k in 0..n {
4566 acc += u[[i, k]] * d[k] * u[[j, k]];
4567 }
4568 base[[i, j]] = acc;
4569 }
4570 }
4571 let scale = 1.0e-3;
4572 let b = sym_from(
4573 &[
4574 0.7, -0.2, 0.5, 0.1, -0.3, 0.4, 0.05, 0.2, -0.1, 0.6, 0.02, -0.04, 0.03, 0.08,
4575 -0.05,
4576 ],
4577 n,
4578 )
4579 .mapv(|v| v * scale);
4580 let c = sym_from(
4581 &[
4582 0.2, 0.1, -0.15, 0.05, 0.2, -0.1, 0.03, -0.02, 0.04, 0.1, 0.01, 0.02, -0.03, 0.05,
4583 0.02,
4584 ],
4585 n,
4586 )
4587 .mapv(|v| v * scale);
4588 let omega = &base + &b.mapv(|v| v * psi) + &c.mapv(|v| v * 0.5 * psi * psi);
4589 let omega_psi = &b + &c.mapv(|v| v * psi);
4590 (omega, omega_psi, c)
4591 }
4592
4593 #[test]
4594 fn range_floor_psi_jet_matches_central_differences() {
4595 let dim = 8usize; // embedded_penalty_dim > n so the floor is active
4596 let (o0, b0, c0) = omega_at(0.0);
4597 let jet =
4598 duchon_range_floor_curvature_psi_jet(&o0, &b0, &c0, dim).expect("range-floor psi jet");
4599
4600 // The floored value must equal the standalone range-floor.
4601 let direct = duchon_range_floor_curvature(&o0, dim).expect("range floor");
4602 let val_err = (&jet.value - &direct)
4603 .iter()
4604 .map(|v| v * v)
4605 .sum::<f64>()
4606 .sqrt();
4607 assert!(
4608 val_err < 1e-10,
4609 "range-floor value mismatch vs standalone: {val_err:.3e}"
4610 );
4611 // The floor must actually be biting (otherwise the test is vacuous).
4612 let floor_gap = (&jet.value - &symmetrize_penalty(&o0))
4613 .iter()
4614 .map(|v| v.abs())
4615 .fold(0.0_f64, f64::max);
4616 assert!(
4617 floor_gap > 0.0,
4618 "range floor is not active — test is vacuous"
4619 );
4620
4621 let eps = 1e-6;
4622 let (op, _, _) = omega_at(eps);
4623 let (om, _, _) = omega_at(-eps);
4624 let vp = duchon_range_floor_curvature_psi_jet(&op, &b0, &c0, dim)
4625 .unwrap()
4626 .value;
4627 let vm = duchon_range_floor_curvature_psi_jet(&om, &b0, &c0, dim)
4628 .unwrap()
4629 .value;
4630 let fd_first = (&vp - &vm).mapv(|v| v / (2.0 * eps));
4631 let first_err = (&jet.first - &fd_first)
4632 .iter()
4633 .map(|v| v * v)
4634 .sum::<f64>()
4635 .sqrt();
4636 let first_scale = jet
4637 .first
4638 .iter()
4639 .map(|v| v * v)
4640 .sum::<f64>()
4641 .sqrt()
4642 .max(1e-9);
4643 assert!(
4644 first_err / first_scale < 1e-4,
4645 "range-floor first derivative mismatch: rel={:.3e} (err={first_err:.3e})",
4646 first_err / first_scale
4647 );
4648
4649 // FD of the analytic FIRST derivative gives the second.
4650 let (op2, bp2, cp2) = omega_at(eps);
4651 let (om2, bm2, cm2) = omega_at(-eps);
4652 let fp = duchon_range_floor_curvature_psi_jet(&op2, &bp2, &cp2, dim)
4653 .unwrap()
4654 .first;
4655 let fm = duchon_range_floor_curvature_psi_jet(&om2, &bm2, &cm2, dim)
4656 .unwrap()
4657 .first;
4658 let fd_second = (&fp - &fm).mapv(|v| v / (2.0 * eps));
4659 let second_err = (&jet.second - &fd_second)
4660 .iter()
4661 .map(|v| v * v)
4662 .sum::<f64>()
4663 .sqrt();
4664 let second_scale = jet
4665 .second
4666 .iter()
4667 .map(|v| v * v)
4668 .sum::<f64>()
4669 .sqrt()
4670 .max(1e-9);
4671 assert!(
4672 second_err / second_scale < 1e-3,
4673 "range-floor second derivative mismatch: rel={:.3e} (err={second_err:.3e})",
4674 second_err / second_scale
4675 );
4676 }
4677
4678 /// The range floor is a MARGIN on the spectral rank cutoff, not a magnitude
4679 /// of its own, and the margin is scored at the EMBEDDED dimension.
4680 ///
4681 /// Both halves were held in prose before, and the prose had already drifted:
4682 /// the doc comment said "one decade above (`nrows·1e-9·λmax`)" while the code
4683 /// wrote `1e-8`. A relation stated in two literals cannot be checked, so pin
4684 /// it executably — this fails if either the cutoff or the margin moves alone.
4685 #[test]
4686 fn duchon_range_floor_sits_the_stated_margin_above_the_embedded_rank_cutoff() {
4687 // A spectrum that straddles the cutoff: one strong curvature mode and
4688 // two low-curvature modes far beneath it.
4689 let n = 3usize;
4690 // The assembled kernel+poly dimension the block is finally scored at,
4691 // deliberately larger than the block handed in.
4692 let embedded = 100usize;
4693 let mut omega = Array2::<f64>::zeros((n, n));
4694 omega[[0, 0]] = 1.0;
4695 omega[[1, 1]] = 1.0e-14;
4696 omega[[2, 2]] = 1.0e-15;
4697
4698 let floored = duchon_range_floor_curvature(&omega, embedded)
4699 .expect("a finite PSD diagonal must range-floor");
4700 let (evals, _) =
4701 FaerEigh::eigh(&floored, Side::Lower).expect("floored block must eigendecompose");
4702
4703 let cutoff = spectral_tolerance_for_dim(embedded.max(n), &evals);
4704 let expected = RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF * cutoff;
4705 let lam_max = evals.iter().copied().fold(0.0_f64, |a, v| a.max(v.abs()));
4706 let min_eval = evals.iter().copied().fold(f64::INFINITY, f64::min);
4707 // The symmetric eigensolve and the `U diag(λ) Uᵀ` reconstruction are each
4708 // backward stable at `n·ε·λmax`; their sum is the entire error budget
4709 // between the floor written and the floor observed.
4710 let envelope = 2.0 * (n as f64) * f64::EPSILON * lam_max;
4711
4712 assert!(
4713 (min_eval - expected).abs() <= envelope,
4714 "range floor {min_eval:.17e} is not {RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF}x the \
4715 embedded-dimension rank cutoff {cutoff:.17e} (expected {expected:.17e}, \
4716 envelope {envelope:.3e})"
4717 );
4718 // The point of the floor: nothing is left below the cutoff the block's
4719 // rank is scored against, so no genuine low-curvature mode is read as
4720 // unpenalized null.
4721 assert!(
4722 evals.iter().all(|&v| v > cutoff),
4723 "range-floored spectrum still holds a sub-cutoff (null-classified) mode: {evals:?}"
4724 );
4725 }
4726}