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