Skip to main content

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(&center_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(|&center| {
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 &center 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(&center_kernel_rotated);
2628    center_design
2629        .slice_mut(s![.., kernel_cols..])
2630        .assign(&center_poly);
2631    let function_gram = symmetrize_penalty(&fast_ata(&center_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(&center_kernel);
2920    center_design
2921        .slice_mut(s![.., kernel_cols..])
2922        .assign(&center_poly);
2923    center_design_psi
2924        .slice_mut(s![.., 0..kernel_cols])
2925        .assign(&center_kernel_psi);
2926    center_design_pp
2927        .slice_mut(s![.., 0..kernel_cols])
2928        .assign(&center_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(&center_design, transform),
2947                fast_ab(&center_design_psi, transform),
2948                fast_ab(&center_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(&center_design));
2959    let gram_psi = symmetrize_penalty(
2960        &(fast_atb(&center_design_psi, &center_design)
2961            + fast_atb(&center_design, &center_design_psi)),
2962    );
2963    let gram_pp = symmetrize_penalty(
2964        &(fast_atb(&center_design_pp, &center_design)
2965            + fast_atb(&center_design_psi, &center_design_psi).mapv(|value| 2.0 * value)
2966            + fast_atb(&center_design, &center_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!(&center_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}