Skip to main content

gam_terms/basis/
constant_curvature_smooth.rs

1//! Constant-curvature (`M_κ`) smooth term: basis + penalty over the
2//! κ-stereographic chart (#944, stage 3 step 1).
3//!
4//! The term is the κ-generic sibling of the intrinsic-S² Wahba smooth
5//! (`sphere_spec.rs` / `build_spherical_spline_basis`): a reproducing-kernel
6//! basis on a center set, with the kernel Gram on the centers as the RKHS
7//! roughness penalty and a coefficient-space sum-to-zero constraint for
8//! identifiability. Where the Wahba smooth hard-codes S² (lat/lon chart,
9//! Legendre kernels), this term takes the geometry from
10//! [`gam_geometry::constant_curvature::ConstantCurvature`] at an explicit
11//! curvature κ, so one construction covers the whole interpolation
12//! `S^d(1/√κ) → ℝ^d → H^d(1/√−κ)` through κ = 0.
13//!
14//! # Kernel
15//!
16//! `K_κ(x, y) = exp(−d_κ(x, y) / ℓ)` — the geodesic-exponential kernel, where
17//! `d_κ` is the exact constant-curvature geodesic distance in the
18//! κ-stereographic chart. The geodesic distance is a kernel of conditionally
19//! negative type on all three constant-curvature space forms (Schoenberg 1942
20//! for `S^d`; classical CND of `‖·‖` on `ℝ^d`; Faraut–Harzallah 1974 for
21//! `H^d`), so `exp(−c·d_κ)` is positive definite for every `c > 0` and every
22//! κ — the Gram on distinct centers is strictly PD, which is exactly what the
23//! RKHS penalty construction needs. At κ = 0 the chart carries the doubled
24//! gauge (`metric 4δ`, `d_0(x, y) = 2‖x − y‖`), so the κ = 0 term is the
25//! Euclidean exponential (Matérn-½) kernel smooth with effective Euclidean
26//! range `ℓ/2`.
27//!
28//! # κ-differentiability contract (what the ψ-channel stage consumes)
29//!
30//! Every κ-moving piece of this construction is differentiable in κ via the
31//! exact κ-jets landed in stage 2, and every κ-FIXED piece is documented as
32//! such so the later ψ-channel wiring (`∂X/∂κ`, `∂S/∂κ` into the LAML outer
33//! gradient, Matérn iso-κ optimizer as the template) needs no new calculus:
34//!
35//! - **Centers are κ-fixed.** Center selection runs in chart coordinates
36//!   (farthest-point / k-means / user-provided) and deliberately does NOT
37//!   consult κ, so `∂(centers)/∂κ ≡ 0` and the design moves with κ only
38//!   through the kernel. A κ-dependent center rule would add an
39//!   uncontrolled, non-smooth term to the design drift.
40//! - **The length scale ℓ is κ-fixed.** The auto-initialized ℓ is derived
41//!   from chart-coordinate (κ = 0 gauge) center spacing only, and an
42//!   explicit user ℓ is a constant. `∂ℓ/∂κ ≡ 0`.
43//! - **The constraint transform `z` is κ-fixed.** Uniform coefficient
44//!   weights; at fit time the global identifiability pipeline composes the
45//!   parametric orthogonalization onto it and the result is FROZEN
46//!   (mirroring `SphericalSplineIdentifiability::FrozenTransform`, #532), so
47//!   the predict/ψ-trial rebuild replays the same `z` verbatim.
48//! - **The kernel has exact κ-jets.** `∂K/∂κ` and `∂²K/∂κ²` follow from
49//!   `distance_kappa_jet` (Tower4-exact, FD-gated) by the chain rule — see
50//!   [`constant_curvature_kernel_kappa_jets`]. Therefore:
51//!   `∂X_raw/∂κ = ∂K(data, centers)/∂κ`, realized design drift
52//!   `∂X/∂κ = (∂K/∂κ)·z`, and penalty drift `∂S_raw/∂κ = zᵀ(∂K(centers,
53//!   centers)/∂κ)z` are all available in closed form from this module today.
54//!   (The penalty handed to the optimizer is Frobenius-normalized; the
55//!   ψ-channel must route its κ-derivative through the same normalization
56//!   rule — `normalize_penaltywith_psi_derivatives` is the existing seam.)
57//! - **Available but not yet consumed:** `log_map_kappa_jet` /
58//!   `exp_map_kappa_jet` cover future geodesic/normal-coordinate basis
59//!   variants (e.g. tangent-space designs); the distance jet is the only one
60//!   this kernel construction needs.
61
62use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
63use rayon::prelude::*;
64use serde::{Deserialize, Serialize};
65
66use gam_geometry::constant_curvature::{ConstantCurvature, distance_kappa_jet};
67
68use super::{
69    BasisBuildResult, BasisError, BasisMetadata, BasisPsiDerivativeBundle,
70    BasisPsiDerivativeResult, BasisPsiSecondDerivativeResult, CenterStrategy, PenaltyCandidate,
71    PenaltyInfo, PenaltySource, filter_active_penalty_candidates_with_ops, normalize_penalty,
72    select_centers_by_strategy, weighted_coefficient_sum_to_zero_transform,
73};
74
75/// Realized-design identifiability policy for the constant-curvature smooth.
76/// Mirrors [`super::SphericalSplineIdentifiability`] (#532): the fit-time
77/// center-space sum-to-zero `z` gets the parametric orthogonalization composed
78/// onto it by the global identifiability pipeline, and the composed transform
79/// is frozen here so predict-time (and future per-ψ-trial) rebuilds replay it
80/// verbatim instead of recomputing `z` from the centers.
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
82pub enum ConstantCurvatureIdentifiability {
83    /// Fit-time default: uniform-weight coefficient sum-to-zero over the
84    /// centers (`Σ_j α_j = 0`), then global parametric residualization.
85    #[default]
86    CenterSumToZero,
87    /// Predict-time replay: the frozen composed transform captured at fit
88    /// time. `transform.nrows()` equals the number of centers.
89    FrozenTransform { transform: Array2<f64> },
90}
91
92/// Constant-curvature smooth configuration (`curv(x, z, kappa = …)`).
93///
94/// The chart inputs are the raw feature columns interpreted as
95/// κ-stereographic chart coordinates: any finite point for κ ≥ 0, the open
96/// ball `‖x‖ < 1/√(−κ)` for κ < 0. The default κ = 0 reproduces a Euclidean
97/// exponential-kernel smooth (in the doubled κ = 0 chart gauge), so the term
98/// is safe to use as a drop-in flat smooth until κ becomes a fitted
99/// ψ-coordinate.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ConstantCurvatureBasisSpec {
102    /// Center/knot selection strategy in chart coordinates. Deliberately
103    /// κ-independent (see the module-level κ-contract).
104    pub center_strategy: CenterStrategy,
105    /// Sectional curvature κ of the latent/feature geometry. Fixed at build
106    /// time; the later ψ-channel stage promotes it to a fitted outer
107    /// coordinate consuming this module's exact κ-jets.
108    pub kappa: f64,
109    /// Geodesic kernel range ℓ in `K_κ = exp(−d_κ/ℓ)`. The `0.0` sentinel
110    /// requests the κ-independent auto initialization
111    /// ([`realized_constant_curvature_length_scale`]); the realized value is
112    /// persisted in [`BasisMetadata::ConstantCurvature`] and frozen back into
113    /// the spec for predict-time replay.
114    pub length_scale: f64,
115    /// Add the ridge-like shrinkage penalty alongside the RKHS Gram penalty.
116    pub double_penalty: bool,
117    /// Realized-design identifiability policy (see type docs).
118    #[serde(default)]
119    pub identifiability: ConstantCurvatureIdentifiability,
120}
121
122impl Default for ConstantCurvatureBasisSpec {
123    fn default() -> Self {
124        Self {
125            center_strategy: CenterStrategy::FarthestPoint { num_centers: 50 },
126            kappa: 0.0,
127            length_scale: 0.0,
128            // No double-penalty ridge by default (#1464). The RKHS Gram penalty
129            // zᵀKz is strictly PD/full-rank on distinct centers, so it already
130            // regularizes every coefficient direction — the ridge `I` adds no
131            // stability. Worse, `I` is curvature-BLIND: with its own λ it absorbs
132            // the data fit independently of κ, so the κ outer coordinate sees only
133            // the monotone Occam term (positive κ compresses geodesic distances →
134            // kernel log-det shrinks) and rails to the +chart bound for any curved
135            // data, recovering hyperbolic truth as spherical. Dropping the ridge
136            // matches the single-penalty profiled-REML oracle
137            // (`profiled_reml_identifies_curvature_sign_with_effective_length`),
138            // which identifies the curvature SIGN.
139            double_penalty: false,
140            identifiability: ConstantCurvatureIdentifiability::CenterSumToZero,
141        }
142    }
143}
144
145/// Validate that every row of `points` is finite and inside the
146/// κ-stereographic chart (`1 + κ‖x‖² > 0`; automatic for κ ≥ 0, the open-ball
147/// constraint for κ < 0).
148pub(crate) fn validate_chart_points(
149    points: ArrayView2<'_, f64>,
150    kappa: f64,
151    what: &str,
152) -> Result<(), BasisError> {
153    for (i, row) in points.outer_iter().enumerate() {
154        let mut nx2 = 0.0_f64;
155        for &v in row.iter() {
156            if !v.is_finite() {
157                crate::bail_invalid_basis!(
158                    "constant-curvature {what} row {i} has a non-finite coordinate"
159                );
160            }
161            nx2 += v * v;
162        }
163        if 1.0 + kappa * nx2 <= 0.0 {
164            crate::bail_invalid_basis!(
165                "constant-curvature {what} row {i} lies outside the κ-stereographic chart \
166                 (need 1 + κ·‖x‖² > 0; got κ = {kappa}, ‖x‖² = {nx2}); for κ < 0 the chart is \
167                 the open ball ‖x‖ < 1/√(−κ)"
168            );
169        }
170    }
171    Ok(())
172}
173
174/// `K_κ(data, centers)` — the geodesic-exponential kernel matrix
175/// `exp(−d_κ(x_i, c_j)/ℓ)`.
176pub fn constant_curvature_kernel_matrix(
177    data: ArrayView2<'_, f64>,
178    centers: ArrayView2<'_, f64>,
179    kappa: f64,
180    length_scale: f64,
181) -> Result<Array2<f64>, BasisError> {
182    if data.ncols() != centers.ncols() {
183        crate::bail_dim_basis!(
184            "constant-curvature kernel dimension mismatch: data d={} centers d={}",
185            data.ncols(),
186            centers.ncols()
187        );
188    }
189    if !(length_scale.is_finite() && length_scale > 0.0) {
190        crate::bail_invalid_basis!(
191            "constant-curvature kernel needs a positive finite length_scale; got {length_scale}"
192        );
193    }
194    validate_chart_points(data, kappa, "data")?;
195    validate_chart_points(centers, kappa, "centers")?;
196    let manifold = ConstantCurvature::new(data.ncols(), kappa);
197    let mut out = Array2::<f64>::zeros((data.nrows(), centers.nrows()));
198    out.axis_iter_mut(Axis(0))
199        .into_par_iter()
200        .enumerate()
201        .try_for_each(|(i, mut row)| -> Result<(), BasisError> {
202            for (j, c) in centers.outer_iter().enumerate() {
203                let d = manifold.distance(data.row(i), c).map_err(|e| {
204                    BasisError::InvalidInput(format!(
205                        "constant-curvature distance failed at (row {i}, center {j}): {e}"
206                    ))
207                })?;
208                row[j] = (-d / length_scale).exp();
209            }
210            Ok(())
211        })?;
212    Ok(out)
213}
214
215/// `(K, ∂K/∂κ, ∂²K/∂κ²)` of the raw (pre-constraint) kernel matrix — the
216/// ψ-channel hook. Exact: rides `distance_kappa_jet` (Tower4, FD-gated in
217/// `geometry::constant_curvature`) through the chain rule for
218/// `K = exp(−d/ℓ)` at κ-FIXED ℓ and centers (see the module κ-contract):
219///
220/// ```text
221///   ∂K/∂κ  = −(d′/ℓ) · K
222///   ∂²K/∂κ² = ((d′/ℓ)² − d″/ℓ) · K
223/// ```
224///
225/// The realized design/penalty drifts follow by the κ-fixed transforms:
226/// `∂X/∂κ = (∂K/∂κ)·z` and `∂S_raw/∂κ = zᵀ(∂K/∂κ)z` (centers×centers), with
227/// the Frobenius penalty normalization differentiated by the existing
228/// `normalize_penaltywith_psi_derivatives` seam.
229pub fn constant_curvature_kernel_kappa_jets(
230    data: ArrayView2<'_, f64>,
231    centers: ArrayView2<'_, f64>,
232    kappa: f64,
233    length_scale: f64,
234) -> Result<(Array2<f64>, Array2<f64>, Array2<f64>), BasisError> {
235    if data.ncols() != centers.ncols() {
236        crate::bail_dim_basis!(
237            "constant-curvature kernel-jet dimension mismatch: data d={} centers d={}",
238            data.ncols(),
239            centers.ncols()
240        );
241    }
242    if !(length_scale.is_finite() && length_scale > 0.0) {
243        crate::bail_invalid_basis!(
244            "constant-curvature kernel jets need a positive finite length_scale; got {length_scale}"
245        );
246    }
247    validate_chart_points(data, kappa, "data")?;
248    validate_chart_points(centers, kappa, "centers")?;
249    let manifold = ConstantCurvature::new(data.ncols(), kappa);
250    let n = data.nrows();
251    let m = centers.nrows();
252    let mut value = Array2::<f64>::zeros((n, m));
253    let mut dk = Array2::<f64>::zeros((n, m));
254    let mut dkk = Array2::<f64>::zeros((n, m));
255    let rows: Vec<(usize, Vec<(f64, f64, f64)>)> = (0..n)
256        .into_par_iter()
257        .map(|i| -> Result<(usize, Vec<(f64, f64, f64)>), BasisError> {
258            let mut row = Vec::with_capacity(m);
259            for (j, c) in centers.outer_iter().enumerate() {
260                let (d, d1, d2) = distance_kappa_jet(&manifold, data.row(i), c).map_err(|e| {
261                    BasisError::InvalidInput(format!(
262                        "constant-curvature distance κ-jet failed at (row {i}, center {j}): {e}"
263                    ))
264                })?;
265                let k = (-d / length_scale).exp();
266                let g = d1 / length_scale;
267                row.push((k, -g * k, (g * g - d2 / length_scale) * k));
268            }
269            Ok((i, row))
270        })
271        .collect::<Result<Vec<_>, BasisError>>()?;
272    for (i, row) in rows {
273        for (j, (k, k1, k2)) in row.into_iter().enumerate() {
274            value[(i, j)] = k;
275            dk[(i, j)] = k1;
276            dkk[(i, j)] = k2;
277        }
278    }
279    Ok((value, dk, dkk))
280}
281
282/// `(K, ∂K/∂κ, ∂²K/∂κ²)` of the raw kernel matrix when the kernel uses the
283/// fill-invariant effective length `L(κ)` (the #944 fix: `L` solves the fill
284/// target `g(L,κ)=fill⋆`, holding the kernel's effective DoF κ-invariant). Both
285/// the geodesic distance `d_κ` and the length `L(κ)` move with κ, so the exponent
286/// is the quotient `q = d/L` and the chain rule carries both jets:
287///
288/// ```text
289///   q  = d / L
290///   q′ = d′/L − d·L′/L²
291///   q″ = d″/L − 2 d′ L′/L² − d L″/L² + 2 d (L′)²/L³
292///   K = e^{−q},  K′ = −q′K,  K″ = ((q′)² − q″) K
293/// ```
294///
295/// `l_jet = (L, L′, L″)` is the effective-length κ-jet from
296/// [`constant_curvature_effective_length_jet`]; at κ = 0 it reduces to the
297/// fixed-ℓ jets (`L′ = L″` terms vanish only if the geometry is flat, but the
298/// formula is exact for all κ).
299pub(crate) fn constant_curvature_kernel_kappa_jets_scaled(
300    data: ArrayView2<'_, f64>,
301    centers: ArrayView2<'_, f64>,
302    kappa: f64,
303    l_jet: (f64, f64, f64),
304) -> Result<(Array2<f64>, Array2<f64>, Array2<f64>), BasisError> {
305    if data.ncols() != centers.ncols() {
306        crate::bail_dim_basis!(
307            "constant-curvature scaled kernel-jet dimension mismatch: data d={} centers d={}",
308            data.ncols(),
309            centers.ncols()
310        );
311    }
312    let (l, l1, l2) = l_jet;
313    if !(l.is_finite() && l > 0.0) {
314        crate::bail_invalid_basis!(
315            "constant-curvature scaled kernel jets need a positive finite effective length; got {l}"
316        );
317    }
318    validate_chart_points(data, kappa, "data")?;
319    validate_chart_points(centers, kappa, "centers")?;
320    let manifold = ConstantCurvature::new(data.ncols(), kappa);
321    let n = data.nrows();
322    let m = centers.nrows();
323    let mut value = Array2::<f64>::zeros((n, m));
324    let mut dk = Array2::<f64>::zeros((n, m));
325    let mut dkk = Array2::<f64>::zeros((n, m));
326    let rows: Vec<(usize, Vec<(f64, f64, f64)>)> = (0..n)
327        .into_par_iter()
328        .map(|i| -> Result<(usize, Vec<(f64, f64, f64)>), BasisError> {
329            let mut row = Vec::with_capacity(m);
330            for (j, c) in centers.outer_iter().enumerate() {
331                let (d, d1, d2) = distance_kappa_jet(&manifold, data.row(i), c).map_err(|e| {
332                    BasisError::InvalidInput(format!(
333                        "constant-curvature scaled distance κ-jet failed at (row {i}, center {j}): {e}"
334                    ))
335                })?;
336                let q = d / l;
337                let q1 = d1 / l - d * l1 / (l * l);
338                let q2 = d2 / l - 2.0 * d1 * l1 / (l * l) - d * l2 / (l * l)
339                    + 2.0 * d * l1 * l1 / (l * l * l);
340                let k = (-q).exp();
341                row.push((k, -q1 * k, (q1 * q1 - q2) * k));
342            }
343            Ok((i, row))
344        })
345        .collect::<Result<Vec<_>, BasisError>>()?;
346    for (i, row) in rows {
347        for (j, (k, k1, k2)) in row.into_iter().enumerate() {
348            value[(i, j)] = k;
349            dk[(i, j)] = k1;
350            dkk[(i, j)] = k2;
351        }
352    }
353    Ok((value, dk, dkk))
354}
355
356/// Resolve the realized kernel range ℓ. An explicit positive `spec_length_scale`
357/// is used verbatim; the `0.0` sentinel auto-initializes from the median
358/// pairwise CHART distance among the centers, doubled to match the κ = 0
359/// chart gauge (`d_0 = 2‖Δ‖`).
360///
361/// κ-contract: the auto rule reads chart coordinates only — it never consults
362/// κ — so the realized ℓ is a κ-CONSTANT and contributes no `∂ℓ/∂κ` term to
363/// the design drift.
364pub fn realized_constant_curvature_length_scale(
365    centers: ArrayView2<'_, f64>,
366    spec_length_scale: f64,
367) -> Result<f64, BasisError> {
368    if spec_length_scale.is_finite() && spec_length_scale > 0.0 {
369        return Ok(spec_length_scale);
370    }
371    if spec_length_scale != 0.0 {
372        crate::bail_invalid_basis!(
373            "constant-curvature length_scale must be positive (or 0.0 for auto); got {spec_length_scale}"
374        );
375    }
376    let m = centers.nrows();
377    if m < 2 {
378        return Err(BasisError::InsufficientColumnsForConstraint { found: m });
379    }
380    let mut dists: Vec<f64> = Vec::with_capacity(m * (m - 1) / 2);
381    for i in 0..m {
382        for j in (i + 1)..m {
383            let mut s = 0.0_f64;
384            for k in 0..centers.ncols() {
385                let dlt = centers[(i, k)] - centers[(j, k)];
386                s += dlt * dlt;
387            }
388            dists.push(2.0 * s.sqrt());
389        }
390    }
391    dists.sort_by(|a, b| a.partial_cmp(b).expect("finite chart distances"));
392    let median = dists[dists.len() / 2];
393    if !(median.is_finite() && median > 0.0) {
394        crate::bail_invalid_basis!(
395            "constant-curvature auto length_scale failed: centers are degenerate \
396             (median pairwise chart distance = {median})"
397        );
398    }
399    Ok(median)
400}
401
402/// Reference kernel "fill" `fill⋆` — the κ = 0 mean data→center kernel entry
403/// `(1/N) Σᵢⱼ exp(−d₀(xᵢ,cⱼ)/ℓ_ref)` with `d₀ = 2‖Δ‖` the κ = 0 chart gauge.
404///
405/// The fill is the scalar that measures the kernel's *effective resolution* (how
406/// much each data row "sees" the centers): it is monotone in `ℓ/scale`, so
407/// pinning it across κ pins the realized design's flexibility (its effective
408/// degrees of freedom). [`constant_curvature_effective_length_jet`] solves
409/// `g(L,κ) = fill⋆` for `L(κ)` so the fill — hence the basis flexibility — stays
410/// κ-invariant and only the distance-matrix SHAPE (the genuine curvature signal)
411/// moves with κ. At κ = 0 the solution is `L = ℓ_ref` by construction.
412pub(crate) fn data_center_reference_fill(
413    data: ArrayView2<'_, f64>,
414    centers: ArrayView2<'_, f64>,
415    ell_ref: f64,
416) -> Result<f64, BasisError> {
417    if !(ell_ref.is_finite() && ell_ref > 0.0) {
418        crate::bail_invalid_basis!(
419            "constant-curvature reference fill needs a positive finite ℓ_ref; got {ell_ref}"
420        );
421    }
422    let mut sum = 0.0_f64;
423    let mut cnt = 0.0_f64;
424    for xi in data.outer_iter() {
425        for cj in centers.outer_iter() {
426            let mut s = 0.0_f64;
427            for k in 0..centers.ncols() {
428                let dlt = xi[k] - cj[k];
429                s += dlt * dlt;
430            }
431            let d0 = 2.0 * s.sqrt(); // κ = 0 chart gauge d₀ = 2‖Δ‖
432            sum += (-d0 / ell_ref).exp();
433            cnt += 1.0;
434        }
435    }
436    if cnt <= 0.0 {
437        crate::bail_invalid_basis!(
438            "constant-curvature reference fill needs at least one data row and one center"
439        );
440    }
441    Ok(sum / cnt)
442}
443
444/// The mean-kernel-entry "fill" `g(L,κ) = (1/N) Σᵢⱼ exp(−d_κ(xᵢ,cⱼ)/L)` together
445/// with the five partials needed by the implicit-function jet:
446/// `(g, g_L, g_κ, g_LL, g_κκ, g_Lκ)`.
447///
448/// With `k = exp(−d/L)` and the per-pair geodesic jet `(d, d', d'')` (exact via
449/// [`distance_kappa_jet`]):
450///
451/// ```text
452///   ∂k/∂L = k·d/L²,                  ∂k/∂κ = −k·d'/L
453///   g_LL  = (1/N)Σ k·d·(d − 2L)/L⁴
454///   g_κκ  = (1/N)Σ k·((d')²/L − d'')/L
455///   g_Lκ  = (1/N)Σ k·d'·(L − d)/L³
456/// ```
457///
458/// (each obtained by differentiating `∂k/∂L` / `∂k/∂κ` once more). `g` and every
459/// partial are smooth through κ = 0 because the distance jet is entire there.
460pub(crate) fn data_center_fill_partials(
461    data: ArrayView2<'_, f64>,
462    centers: ArrayView2<'_, f64>,
463    kappa: f64,
464    l: f64,
465) -> Result<(f64, f64, f64, f64, f64, f64), BasisError> {
466    if !(l.is_finite() && l > 0.0) {
467        crate::bail_invalid_basis!(
468            "constant-curvature fill partials need a positive finite length; got {l}"
469        );
470    }
471    let manifold = ConstantCurvature::new(centers.ncols(), kappa);
472    let l2 = l * l;
473    let l3 = l2 * l;
474    let l4 = l2 * l2;
475    let mut g = 0.0_f64;
476    let mut g_l = 0.0_f64;
477    let mut g_k = 0.0_f64;
478    let mut g_ll = 0.0_f64;
479    let mut g_kk = 0.0_f64;
480    let mut g_lk = 0.0_f64;
481    let mut cnt = 0.0_f64;
482    for xi in data.outer_iter() {
483        for cj in centers.outer_iter() {
484            let (d, d1, d2) = distance_kappa_jet(&manifold, xi, cj).map_err(|e| {
485                BasisError::InvalidInput(format!(
486                    "constant-curvature data→center fill κ-jet failed: {e}"
487                ))
488            })?;
489            let k = (-d / l).exp();
490            g += k;
491            g_l += k * d / l2;
492            g_k += -k * d1 / l;
493            g_ll += k * d * (d - 2.0 * l) / l4;
494            g_kk += k * ((d1 * d1) / l - d2) / l;
495            g_lk += k * d1 * (l - d) / l3;
496            cnt += 1.0;
497        }
498    }
499    if cnt <= 0.0 {
500        crate::bail_invalid_basis!(
501            "constant-curvature fill partials need at least one data row and one center"
502        );
503    }
504    Ok((
505        g / cnt,
506        g_l / cnt,
507        g_k / cnt,
508        g_ll / cnt,
509        g_kk / cnt,
510        g_lk / cnt,
511    ))
512}
513
514/// Effective kernel length `L(κ)` and its EXACT κ-jet `(L, L′, L″)`.
515///
516/// THE κ-IDENTIFICATION FIX (#944). A κ-FROZEN length makes the geodesic-
517/// exponential kernel's *resolution* drift with κ: spherical (κ>0) geometries
518/// compress geodesic distances, narrowing the kernel relative to the data and
519/// inflating the basis's effective flexibility, so REML buys a lower deviance by
520/// cranking κ up — κ rails to the chart bound for every truth (the #944/#1059
521/// symptom). The earlier #1059 fix normalized by the mean data→center geodesic
522/// distance `s_dc(κ)`; but holding the mean DISTANCE fixed does NOT hold the
523/// kernel's flexibility fixed — the effective degrees of freedom still drift
524/// ~30% across the bracket (verified), so the deviance stayed monotone in κ.
525///
526/// We instead hold the kernel's "fill" — the mean realized kernel entry
527/// `g(L,κ) = (1/N) Σᵢⱼ exp(−d_κ(xᵢ,cⱼ)/L)` — κ-INVARIANT, which pins the
528/// realized design's effective degrees of freedom (the EDF is flat to <0.5% in κ
529/// under this rule, verified numerically). `L(κ)` is the implicit solution of
530///
531/// ```text
532///   g(L(κ), κ) = fill⋆,   fill⋆ = g(ℓ_ref, 0)   (the κ=0 reference fill)
533/// ```
534///
535/// so changing κ moves ONLY the distance-matrix SHAPE (the genuine curvature
536/// signal), giving `V_p(κ)` an interior minimum at the data-generating κ for
537/// curved truth. At κ = 0 the solution is `L = ℓ_ref` exactly.
538///
539/// The jet is EXACT via the implicit-function theorem. Differentiating
540/// `g(L(κ),κ) ≡ fill⋆` once gives `g_L·L′ + g_κ = 0`, and once more gives
541/// `g_LL·(L′)² + 2 g_Lκ·L′ + g_κκ + g_L·L″ = 0`:
542///
543/// ```text
544///   L′  = −g_κ / g_L
545///   L″  = −( g_LL·(L′)² + 2 g_Lκ·L′ + g_κκ ) / g_L .
546/// ```
547///
548/// The partials come from [`data_center_fill_partials`] (exact, riding
549/// `distance_kappa_jet`); the returned jet feeds `constant_curvature_kernel_
550/// kappa_jets_scaled` through the quotient `q = d/L` chain rule.
551///
552/// Public scalar view of the κ-invariant effective kernel length `L(κ)` that the
553/// realized constant-curvature design/penalty are built at (the #944 fill-
554/// invariance fix). The forward build evaluates the geodesic-exponential kernel
555/// at this `L(κ)`, NOT at the κ = 0 reference length `ell_ref`, so any external
556/// consumer reconstructing `K(·)` to compare against the realized design must
557/// use this length. Equals `ell_ref` exactly at κ = 0.
558pub fn constant_curvature_effective_length(
559    data: ArrayView2<'_, f64>,
560    centers: ArrayView2<'_, f64>,
561    ell_ref: f64,
562    kappa: f64,
563) -> Result<f64, BasisError> {
564    Ok(constant_curvature_effective_length_jet(data, centers, ell_ref, kappa)?.0)
565}
566
567pub(crate) fn constant_curvature_effective_length_jet(
568    data: ArrayView2<'_, f64>,
569    centers: ArrayView2<'_, f64>,
570    ell_ref: f64,
571    kappa: f64,
572) -> Result<(f64, f64, f64), BasisError> {
573    let fill_star = data_center_reference_fill(data, centers, ell_ref)?;
574    // Newton solve g(L, κ) = fill⋆ for L, warm-started at ℓ_ref (the exact root
575    // at κ = 0). g is strictly increasing in L (g_L > 0: larger L ⇒ each entry
576    // closer to 1), so Newton from ℓ_ref converges monotonically.
577    let mut l = ell_ref;
578    const NEWTON_MAX_ITER: usize = 100;
579    const NEWTON_REL_TOL: f64 = 1.0e-13;
580    let mut converged = false;
581    for _ in 0..NEWTON_MAX_ITER {
582        let (g, g_l, ..) = data_center_fill_partials(data, centers, kappa, l)?;
583        if !(g_l.is_finite() && g_l > 0.0) {
584            crate::bail_invalid_basis!(
585                "constant-curvature effective length: non-positive fill slope g_L = {g_l} \
586                 (degenerate data/centers at κ = {kappa})"
587            );
588        }
589        let step = (g - fill_star) / g_l;
590        l -= step;
591        if !(l.is_finite() && l > 0.0) {
592            crate::bail_invalid_basis!(
593                "constant-curvature effective length: Newton left the positive axis (L = {l}) \
594                 solving the fill target at κ = {kappa}"
595            );
596        }
597        if step.abs() <= NEWTON_REL_TOL * l {
598            converged = true;
599            break;
600        }
601    }
602    if !converged {
603        crate::bail_invalid_basis!(
604            "constant-curvature effective length: fill-target Newton did not converge at κ = {kappa}"
605        );
606    }
607    // Exact implicit-function-theorem jet at the converged root.
608    let (_, g_l, g_k, g_ll, g_kk, g_lk) = data_center_fill_partials(data, centers, kappa, l)?;
609    let l1 = -g_k / g_l;
610    let l2 = -(g_ll * l1 * l1 + 2.0 * g_lk * l1 + g_kk) / g_l;
611    Ok((l, l1, l2))
612}
613
614/// Build the constant-curvature reproducing-kernel smooth: realized design
615/// `K_κ(data, centers)·z`, RKHS penalty `zᵀ K_κ(centers, centers) z`, and the
616/// replayable [`BasisMetadata::ConstantCurvature`]. Structure mirrors the
617/// Wahba S² builder (`build_spherical_spline_basis`); geometry comes from
618/// `ConstantCurvature` at the spec's fixed κ.
619pub fn build_constant_curvature_basis(
620    data: ArrayView2<'_, f64>,
621    spec: &ConstantCurvatureBasisSpec,
622) -> Result<BasisBuildResult, BasisError> {
623    if data.ncols() == 0 {
624        crate::bail_invalid_basis!("constant-curvature smooth needs at least one feature column");
625    }
626    if !spec.kappa.is_finite() {
627        crate::bail_invalid_basis!("constant-curvature smooth needs a finite kappa");
628    }
629    validate_chart_points(data, spec.kappa, "data")?;
630    let centers = select_constant_curvature_centers(data, &spec.center_strategy)?;
631    if centers.nrows() < 2 {
632        return Err(BasisError::InsufficientColumnsForConstraint {
633            found: centers.nrows(),
634        });
635    }
636    validate_chart_points(centers.view(), spec.kappa, "centers")?;
637    // ℓ_ref is the κ = 0 reference length (auto = mean chart spacing, or the
638    // user/frozen value); the kernel uses the κ-invariant effective length
639    // L(κ) = ℓ_ref·s(κ)/s₀ so changing κ moves the geometry, not the kernel
640    // resolution (the #1059 curvature-identification fix). At κ = 0, L = ℓ_ref.
641    let length_scale = realized_constant_curvature_length_scale(centers.view(), spec.length_scale)?;
642    // DESIGN effective length L(κ): solved against the DATA→center fill so the
643    // realized design's effective DOF stays κ-invariant (#944/#1059). The design
644    // X = K(data, centers)·z is built at this L.
645    let (ell_eff, _, _) =
646        constant_curvature_effective_length_jet(data, centers.view(), length_scale, spec.kappa)?;
647    // PENALTY effective length L_S(κ): solved against the CENTER→center fill so
648    // the penalty Gram S = zᵀK(centers,centers)z has a κ-INVARIANT resolution
649    // (#1464). The data→center fill that pins L(κ) does NOT pin the center→center
650    // penalty spectrum, so with the single shared L the penalty pseudo-determinant
651    // logdet|S|₊ drifts freely with κ: as κ grows positive the geodesic kernel
652    // collapses toward the constant, the center→center Gram eigenvalues bunch /
653    // drop below the rank tolerance, logdet|S|₊ falls, and the REML Occam term
654    // −½·logdet|S|₊ DECREASES — rewarding the +κ collapsed-kernel corner and
655    // railing κ̂ to the +chart bound for any curved data (the headline #1464
656    // sign-blindness: hyperbolic truth recovered as spherical, V_p(κ) monotone in
657    // κ with no interior optimum). Building the penalty at L_S(κ) holds the
658    // penalty eigenvalue SHAPE (hence logdet|S|₊ and its rank) κ-comparable, so
659    // the Occam term stops rewarding the collapse and V_p regains an interior
660    // minimum near the data-generating κ. At κ = 0, L_S = ℓ_ref = L, so the κ = 0
661    // build is byte-identical.
662    let (ell_eff_penalty, _, _) = constant_curvature_effective_length_jet(
663        centers.view(),
664        centers.view(),
665        length_scale,
666        spec.kappa,
667    )?;
668    let raw_penalty = constant_curvature_kernel_matrix(
669        centers.view(),
670        centers.view(),
671        spec.kappa,
672        ell_eff_penalty,
673    )?;
674    // Realized-design constraint transform: uniform coefficient sum-to-zero at
675    // fit time; the frozen composed `z · z_parametric` at predict time (#532
676    // pattern — see ConstantCurvatureIdentifiability).
677    let z = match &spec.identifiability {
678        ConstantCurvatureIdentifiability::FrozenTransform { transform } => {
679            if transform.nrows() != centers.nrows() {
680                crate::bail_dim_basis!(
681                    "frozen constant-curvature identifiability transform mismatch: {} centers but transform has {} rows",
682                    centers.nrows(),
683                    transform.nrows()
684                );
685            }
686            transform.clone()
687        }
688        ConstantCurvatureIdentifiability::CenterSumToZero => {
689            let weights = Array1::<f64>::ones(centers.nrows());
690            weighted_coefficient_sum_to_zero_transform(weights.view())?
691        }
692    };
693    let gauge = gam_problem::Gauge::from_block_transforms(&[z.clone()]);
694    let penalty = gauge.restrict_penalty(&raw_penalty);
695    let raw_design = constant_curvature_kernel_matrix(data, centers.view(), spec.kappa, ell_eff)?;
696    let design = gam_linalg::matrix::DesignMatrix::Dense(
697        gam_linalg::matrix::DenseDesignMatrix::from(gauge.restrict_design(&raw_design)),
698    );
699    // Keep the RKHS penalty RAW (the symmetric kernel Gram zᵀKz) with
700    // normalization_scale = 1, rather than Frobenius-normalizing it. The Gram's
701    // eigenvalues ARE the physical RKHS roughness energies of each coefficient
702    // direction: the smoothest functions (the low-degree / degree-1 signal) sit
703    // in the genuinely tiny-eigenvalue directions, while wiggly functions sit in
704    // the large ones — a spread of many orders of magnitude. Frobenius-
705    // normalizing divides the whole operator by ‖·‖_F (dominated by the large
706    // wiggly eigenvalues), which compresses that spread and inflates the
707    // smallest eigenvalues relative to their natural scale. REML's scale-
708    // sensitive λ heuristics then drive a single λ high enough to suppress the
709    // wiggly directions and, because the smooth directions are no longer
710    // proportionally tiny, over-shrink the recoverable low-degree signal
711    // (planted degree-1 sphere harmonic recovered at only R²≈0.84). Keeping the
712    // raw physical operator (scale = 1, matching the sphere-harmonic Laplace-
713    // Beltrami penalty) lets REML act on true roughness, leaving the smooth
714    // signal essentially unpenalized while still shrinking the wiggly tail —
715    // raising recovery toward the unconstrained RKHS ceiling. The penalty stays
716    // exactly proportional to zᵀKz, so the constrained-kernel-Gram contract is
717    // unchanged.
718    let penalty_sym = (&penalty + &penalty.t()) * 0.5;
719    let mut candidates = vec![PenaltyCandidate {
720        matrix: penalty_sym,
721        nullspace_dim_hint: 0,
722        source: PenaltySource::Primary,
723        normalization_scale: 1.0,
724        kronecker_factors: None,
725        op: None,
726    }];
727    if spec.double_penalty {
728        // #1531: identity ridge is CORRECT here, NOT the nullspace-shrinkage ridge
729        // the sibling bases (sphere_basis / matern_kernel / duchon_thinplate) use.
730        // The Marra & Wood double penalty shrinks the NULL SPACE of the primary
731        // penalty so REML can drive an unsupported term to EDF→0. But the primary
732        // here is the RKHS kernel Gram zᵀKz, which is strictly PD / full-rank on
733        // distinct centers (see the `double_penalty: false` default note above): it
734        // has NO null space. `build_nullspace_shrinkage_penalty(&primary)` returns
735        // `Ok(None)` for a full-rank input, so matching the sibling pattern would
736        // make an explicit `double_penalty = true` a silent no-op. The full identity
737        // is the only second shrinkage coordinate that is actually selectable on a
738        // null-space-free primary, so it is what an explicit double penalty must use.
739        // The regression test `constant_curvature_gram_is_full_rank_so_identity_is_the_only_double_penalty`
740        // locks the full-rank fact that justifies this; if a future basis change
741        // gives the Gram a genuine null space, that test fails and this branch must
742        // be revisited (switch to `build_nullspace_shrinkage_penalty`).
743        let ridge = Array2::<f64>::eye(design.ncols());
744        let (ridge_norm, c_ridge) = normalize_penalty(&ridge);
745        candidates.push(PenaltyCandidate {
746            matrix: ridge_norm,
747            nullspace_dim_hint: 0,
748            source: PenaltySource::DoublePenaltyNullspace,
749            normalization_scale: c_ridge,
750            kronecker_factors: None,
751            op: None,
752        });
753    }
754    let (penalties, nullspace_dims, penaltyinfo, null_eigenvectors, ops) =
755        filter_active_penalty_candidates_with_ops(candidates)?;
756    Ok(BasisBuildResult {
757        design,
758        penalties,
759        nullspace_dims,
760        penaltyinfo,
761        metadata: BasisMetadata::ConstantCurvature {
762            centers,
763            kappa: spec.kappa,
764            length_scale,
765            constraint_transform: Some(z),
766        },
767        kronecker_factored: None,
768        ops,
769        null_eigenvectors,
770        joint_null_rotation: None,
771    })
772}
773
774/// Select constant-curvature centers.
775///
776/// The stereographic constant-curvature chart has a distinguished pole: the
777/// chart origin.  Curvature sign is visible first in the radial geodesic map
778/// from that pole (`2 atan(√κ r)/√κ` versus `2 atanh(√|κ| r)/√|κ|`).  A pure
779/// farthest-point subset can miss the pole on disk-like clouds, leaving the
780/// radial mode to be reconstructed indirectly from boundary centers; then the
781/// positive chart's distance compression becomes a generic interpolation
782/// advantage and the κ profile is sign-blind.  Keep the user's requested center
783/// count, but make data-driven center sets pole-aware by replacing the center
784/// closest to the origin with the exact origin.  User-provided centers are left
785/// verbatim.
786fn select_constant_curvature_centers(
787    data: ArrayView2<'_, f64>,
788    strategy: &CenterStrategy,
789) -> Result<Array2<f64>, BasisError> {
790    let mut centers = select_centers_by_strategy(data, strategy)?;
791    match strategy {
792        CenterStrategy::UserProvided(_) => return Ok(centers),
793        CenterStrategy::Auto(inner) => {
794            if matches!(inner.as_ref(), CenterStrategy::UserProvided(_)) {
795                return Ok(centers);
796            }
797        }
798        _ => {}
799    }
800    if centers.nrows() == 0 || centers.ncols() == 0 {
801        return Ok(centers);
802    }
803    let (closest, _) = centers
804        .outer_iter()
805        .enumerate()
806        .map(|(i, row)| (i, row.dot(&row)))
807        .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
808        .unwrap();
809    for j in 0..centers.ncols() {
810        centers[(closest, j)] = 0.0;
811    }
812    Ok(centers)
813}
814
815/// Closed-form profiled Gaussian-REML negative-log-evidence of a dense design
816/// `b` (n×p) against response `y`, with an UNPENALIZED intercept column appended
817/// and the symmetric psd RKHS penalty `s` (p×p) profiled over a dense log-λ grid.
818/// `min_λ D(λ)` with
819///   `D(λ) = (n−Mp)·log(rss/(n−Mp)) + log|HᵀH| − log|λS|₊`,
820/// `H = [1|b]ᵀ[1|b] + λ·diag(0,S)`, `Mp = 1 + nullity(S)` (the intercept is in the
821/// null space). Self-contained — the same criterion shape the in-crate oracle
822/// `profiled_gaussian_reml_deviance` certifies, with the production intercept the
823/// full GAM always carries (so it matches what the fit path sees).
824fn profiled_reml_with_intercept(b: &Array2<f64>, y: &Array1<f64>, s: &Array2<f64>) -> f64 {
825    use gam_linalg::faer_ndarray::FaerEigh;
826    let n = b.nrows();
827    let p = b.ncols();
828    // Augmented design [1 | b] and zero-padded penalty diag(0, S).
829    let mut ba = Array2::<f64>::zeros((n, p + 1));
830    for i in 0..n {
831        ba[(i, 0)] = 1.0;
832        for j in 0..p {
833            ba[(i, j + 1)] = b[(i, j)];
834        }
835    }
836    let mut sa = Array2::<f64>::zeros((p + 1, p + 1));
837    for i in 0..p {
838        for j in 0..p {
839            sa[(i + 1, j + 1)] = s[(i, j)];
840        }
841    }
842    let pa = p + 1;
843    let btb = symmetrize(&ba.t().dot(&ba));
844    let bty = ba.t().dot(y);
845    let (s_evals, _) = FaerEigh::eigh(&symmetrize(&sa), faer::Side::Lower)
846        .expect("κ-fair penalty eigendecomposition");
847    let s_max = s_evals.iter().cloned().fold(0.0_f64, f64::max).max(1e-300);
848    let s_tol = s_max * 1e-9;
849    let r = s_evals.iter().filter(|&&e| e > s_tol).count();
850    let m_p = pa - r;
851    let dof = (n - m_p) as f64;
852    let log_det_s_plus: f64 = s_evals
853        .iter()
854        .filter(|&&e| e > s_tol)
855        .map(|&e| e.ln())
856        .sum();
857    let mut best = f64::INFINITY;
858    for k in -24i32..=24 {
859        let lam = (0.5 * f64::from(k)).exp();
860        let h = symmetrize(&(&btb + &(sa.mapv(|v| v * lam))));
861        let h_ridge = &h + &(Array2::<f64>::eye(pa) * (1e-10 * s_max.max(1.0)));
862        let (hv, hq) = FaerEigh::eigh(&symmetrize(&h_ridge), faer::Side::Lower)
863            .expect("κ-fair penalized-Hessian eigendecomposition");
864        let qty = hq.t().dot(&bty);
865        let mut beta = Array1::<f64>::zeros(pa);
866        let mut log_det_h = 0.0_f64;
867        for i in 0..pa {
868            let ev = hv[i].max(1e-300);
869            log_det_h += ev.ln();
870            let coef = qty[i] / ev;
871            for j in 0..pa {
872                beta[j] += hq[(j, i)] * coef;
873            }
874        }
875        let resid = y - &ba.dot(&beta);
876        let rss = resid.dot(&resid).max(1e-300);
877        let log_det_lam_s = (r as f64) * lam.ln() + log_det_s_plus;
878        let dev = dof * (rss / dof).ln() + log_det_h - log_det_lam_s;
879        if dev < best {
880            best = dev;
881        }
882    }
883    best
884}
885
886/// #1464: the **κ-fair** sign-resolving score for a constant-curvature smooth at
887/// a fixed κ — the production datum the sign-basin scan minimizes to choose the
888/// curvature SIGN basin.
889///
890/// THE DATA-FIT κ-FAIRNESS FIX. The L(κ)/L_S(κ) effective-length reparam already
891/// holds the kernel FILL and the penalty Occam term κ-invariant (#944/#1464
892/// penalty fix), but the realized profiled-REML DATA-FIT term is still sign-blind:
893/// on a generic center-peaked radial signal the +κ chart's geodesic-distance
894/// COMPRESSION concentrates the design's singular-value mass into the leading
895/// (low-order radial) modes — a uniformly better interpolator of ANY radial peak,
896/// regardless of the true curvature sign — so `V_p(κ)` decreases monotonically
897/// toward the +chart bound for BOTH spherical and hyperbolic truth (hyperbolic
898/// recovered as spherical, κ̂ railed to +0.5/max‖x‖²). Holding the EDF / hat-trace
899/// or ‖X‖_F κ-invariant does NOT cure it: the advantage is the per-direction
900/// REDISTRIBUTION of approximation power, not its total scale (verified — the EDF
901/// is already κ-invariant to <1% under L(κ), yet RSS still falls toward +κ).
902///
903/// The cure makes the comparison apples-to-apples by SUBTRACTING the design's
904/// GENERIC radial-peak-fitting power at this κ. We measure that generic power with
905/// a bank of κ-INDEPENDENT reference signals `r_α(i) = exp(−α·‖x_i‖)` — radial in
906/// the Euclidean chart coordinate, so carrying NO curvature-sign preference — and
907/// score
908///
909/// ```text
910///   V_fair(κ) = V_p(κ; y) − mean_α V_p(κ; r_α) .
911/// ```
912///
913/// The generic +κ interpolation advantage cancels between the two terms (it lifts
914/// `V_p(κ; y)` and `V_p(κ; r_α)` by the same amount), leaving only the GENUINE
915/// curvature-shape alignment of the actual data `y` with the κ-geometry. The bank
916/// (several α widths, averaged) removes the residual sensitivity of any single
917/// reference width to the data realization, so `argmin_κ V_fair` lands on the
918/// correct SIDE of 0 for both signs (spherical κ̂ > 0, hyperbolic κ̂ < 0) across
919/// seeds. The reference correction enters ONLY the sign-basin SELECTION; the
920/// realized fit and the magnitude/CI keep using the raw `V_p`, so the κ = 0 build
921/// and the final coefficients are untouched.
922///
923/// Builds the design `X = K_κ(data, centers)·z` at the data→center effective
924/// length `L(κ)` and the penalty `S = symm(zᵀK_κ(centers,centers)z)` at the
925/// center→center effective length `L_S(κ)`, exactly as
926/// [`build_constant_curvature_basis`] (raw RKHS Gram, scale = 1, intercept
927/// appended unpenalized), so the criterion the scan minimizes is the production
928/// design's own profiled REML.
929/// Build the realized constant-curvature profile design `B = K_κ(data,
930/// centers)·z` and penalty `S = symm(zᵀK_κ(centers,centers)z)` at the fixed κ in
931/// `spec`, EXACTLY as [`build_constant_curvature_basis`] does (same centers, same
932/// κ-invariant effective lengths `L(κ)`/`L_S(κ)`, same center-sum-to-zero `z`,
933/// raw RKHS Gram penalty). Shared by the honest profiled-REML κ-profile score and
934/// the κ-fair sign score so both probe the production design's own criterion.
935fn constant_curvature_profile_design_penalty(
936    data: ArrayView2<'_, f64>,
937    spec: &ConstantCurvatureBasisSpec,
938) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
939    if data.ncols() == 0 {
940        crate::bail_invalid_basis!(
941            "constant-curvature profile score needs at least one feature column"
942        );
943    }
944    if !spec.kappa.is_finite() {
945        crate::bail_invalid_basis!("constant-curvature profile score needs a finite kappa");
946    }
947    validate_chart_points(data, spec.kappa, "data")?;
948    let centers = select_centers_by_strategy(data, &spec.center_strategy)?;
949    if centers.nrows() < 2 {
950        return Err(BasisError::InsufficientColumnsForConstraint {
951            found: centers.nrows(),
952        });
953    }
954    validate_chart_points(centers.view(), spec.kappa, "centers")?;
955    let length_scale = realized_constant_curvature_length_scale(centers.view(), spec.length_scale)?;
956    // Design effective length L(κ) (data→center fill) and penalty effective
957    // length L_S(κ) (center→center fill) — identical to the value builder.
958    let (ell_eff, _, _) =
959        constant_curvature_effective_length_jet(data, centers.view(), length_scale, spec.kappa)?;
960    let (ell_eff_penalty, _, _) = constant_curvature_effective_length_jet(
961        centers.view(),
962        centers.view(),
963        length_scale,
964        spec.kappa,
965    )?;
966    let weights = Array1::<f64>::ones(centers.nrows());
967    let z = weighted_coefficient_sum_to_zero_transform(weights.view())?;
968    let gauge = gam_problem::Gauge::from_block_transforms(&[z]);
969    let raw_design = constant_curvature_kernel_matrix(data, centers.view(), spec.kappa, ell_eff)?;
970    let b = gauge.restrict_design(&raw_design);
971    let raw_penalty = constant_curvature_kernel_matrix(
972        centers.view(),
973        centers.view(),
974        spec.kappa,
975        ell_eff_penalty,
976    )?;
977    let s = symmetrize(&gauge.restrict_penalty(&raw_penalty));
978    Ok((b, s))
979}
980
981/// #1464: the **honest** fixed-κ profiled-REML score `V_p(κ)` for a
982/// constant-curvature smooth — the textbook Gaussian profiled-REML
983/// negative-log-evidence of the realized design `B = K_κ(data,centers)·z` against
984/// `y`, with the unpenalized intercept appended and the raw RKHS Gram penalty `S`
985/// profiled over λ (`profiled_reml_with_intercept`). This is the criterion whose
986/// argmin over the chart-bounded κ window IDENTIFIES the curvature, and the one
987/// `curvature_inference_forspec` walks for the magnitude CI and the κ = 0 flatness
988/// LR test.
989///
990/// Why this, not the production full-fit `reml_score`: the production REML's
991/// λ-selection heavily SMOOTHS this RKHS kernel (deviance ≫ near-interpolation
992/// RSS), and under heavy smoothing the +κ chart's geodesic-distance COMPRESSION
993/// makes the collapsed kernel fit the over-smoothed target better for ANY data —
994/// so the production `reml_score` is monotone toward the +chart bound regardless
995/// of the true sign (the headline #1464 sign-blindness, and an over-smoothing of
996/// the curvature criterion specifically). The honest profiled REML keeps the
997/// curvature-shape signal in the data fit (the κ that matches the geodesic
998/// geometry minimizes RSS), so its argmin lands on the correct sign, and because
999/// it is a proper profiled-REML deviance the LR/CI thresholds stay χ²-calibrated.
1000/// On genuinely flat (constant-mean) data the criterion is ~flat in κ (the
1001/// intercept absorbs the mean at every κ), giving the flatness test correct size.
1002pub fn constant_curvature_honest_profiled_reml_score(
1003    data: ArrayView2<'_, f64>,
1004    y: ArrayView1<'_, f64>,
1005    spec: &ConstantCurvatureBasisSpec,
1006) -> Result<f64, BasisError> {
1007    if y.len() != data.nrows() {
1008        crate::bail_dim_basis!(
1009            "constant-curvature profiled-REML score: y has {} rows but data has {}",
1010            y.len(),
1011            data.nrows()
1012        );
1013    }
1014    let (b, s) = constant_curvature_profile_design_penalty(data, spec)?;
1015    let v = profiled_reml_with_intercept(&b, &y.to_owned(), &s);
1016    if !v.is_finite() {
1017        crate::bail_invalid_basis!(
1018            "constant-curvature honest profiled-REML score at κ={} is non-finite",
1019            spec.kappa
1020        );
1021    }
1022    Ok(v)
1023}
1024
1025pub fn constant_curvature_kappa_fair_sign_score(
1026    data: ArrayView2<'_, f64>,
1027    y: ArrayView1<'_, f64>,
1028    spec: &ConstantCurvatureBasisSpec,
1029) -> Result<f64, BasisError> {
1030    if y.len() != data.nrows() {
1031        crate::bail_dim_basis!(
1032            "constant-curvature κ-fair score: y has {} rows but data has {}",
1033            y.len(),
1034            data.nrows()
1035        );
1036    }
1037    let (b, s) = constant_curvature_profile_design_penalty(data, spec)?;
1038
1039    let v_y = profiled_reml_with_intercept(&b, &y.to_owned(), &s);
1040
1041    // CURVATURE-NEUTRAL, ENERGY-MATCHED reference: a COARSE radial profile of the
1042    // data. The +κ chart compresses geodesic distances so the geodesic-
1043    // exponential kernel is a uniformly better interpolator of any radial signal
1044    // regardless of the true curvature sign; this generic interpolation advantage
1045    // lifts `V_p(κ)` monotonically toward +κ and must be cancelled so only the
1046    // genuine curvature-shape signal drives the sign. The reference that cancels
1047    // it is one carrying the same gross radial energy as the data but no fine
1048    // κ-geometry: `y_ref(i)` = mean of `y` over a SMALL number of Euclidean-radius
1049    // bins. The bin count is deliberately coarse: enough bins to track the data's
1050    // radial trend (so the +κ tilt cancels and a genuinely FLAT truth scores
1051    // ~symmetrically in κ — its response is already a function of `‖x‖` alone, so
1052    // `y_ref ≈ y` and the criterion refuses to prefer a sign), but few enough that
1053    // the profile CANNOT reproduce the data-generating `d_κ⋆` curvature shape — so
1054    // for a curved truth the residual `V_p(κ;y) − V_p(κ;y_ref)` still wells toward
1055    // the data-generating sign. A fine profile would absorb the curvature signal
1056    // (the radial truth is nearly a function of `‖x‖`); a fixed exp(−α‖x‖) bank
1057    // does not match the data's radial energy and leaves a strong residual −κ tilt.
1058    // The coarse matched profile shrinks that tilt to a small noise-overfit
1059    // residual (the geodesic kernel overfits noise slightly more in the hyperbolic
1060    // chart), so on a CURVED truth the genuine signal dominates and the argmin sign
1061    // is correct. A residual flat-data tilt remains, so this term alone does NOT
1062    // fully separate flat (κ ≈ 0) from hyperbolic (κ < 0); the caller adopts the
1063    // argmin only for the negative (hyperbolic) sign and leaves the spherical and
1064    // (residual-tilt) flat cases to the joint solver / κ ≈ 0 path.
1065    let radii: Array1<f64> = data.outer_iter().map(|row| row.dot(&row).sqrt()).collect();
1066    const N_RADIAL_BINS: usize = 10;
1067    let r_max = radii.iter().cloned().fold(0.0_f64, f64::max).max(1e-12);
1068    let bin_of = |r: f64| -> usize {
1069        (((r / r_max) * N_RADIAL_BINS as f64) as usize).min(N_RADIAL_BINS - 1)
1070    };
1071    let mut bin_sum = [0.0_f64; N_RADIAL_BINS];
1072    let mut bin_cnt = [0.0_f64; N_RADIAL_BINS];
1073    for (i, &r) in radii.iter().enumerate() {
1074        let b_idx = bin_of(r);
1075        bin_sum[b_idx] += y[i];
1076        bin_cnt[b_idx] += 1.0;
1077    }
1078    let bin_mean: Vec<f64> = bin_sum
1079        .iter()
1080        .zip(bin_cnt.iter())
1081        .map(|(&s, &c)| if c > 0.0 { s / c } else { 0.0 })
1082        .collect();
1083    let y_ref: Array1<f64> = radii.mapv(|r| bin_mean[bin_of(r)]);
1084
1085    let v_ref = profiled_reml_with_intercept(&b, &y_ref, &s);
1086
1087    let v_fair = v_y - v_ref;
1088    if !v_fair.is_finite() {
1089        crate::bail_invalid_basis!(
1090            "constant-curvature κ-fair score at κ={} is non-finite (V_y={v_y}, V_ref={v_ref})",
1091            spec.kappa
1092        );
1093    }
1094    Ok(v_fair)
1095}
1096
1097/// Symmetrize `M` in place to `(M + Mᵀ)/2` (the realized penalty is built from
1098/// the symmetric kernel Gram; the κ-derivative blocks inherit the same exact
1099/// symmetrization the value path applies before normalization).
1100pub(crate) fn symmetrize(m: &Array2<f64>) -> Array2<f64> {
1101    gam_linalg::matrix::symmetrize(m)
1102}
1103
1104/// Map a single primary-penalty κ-derivative onto the active penalty list by
1105/// source — the constant-curvature analogue of the Matérn double-penalty
1106/// derivative selector. The RKHS Gram is the only κ-moving penalty; the
1107/// double-penalty ridge `I` is κ-independent, so its derivative is exactly
1108/// zero. Any other source would mean the basis grew a penalty whose κ-movement
1109/// is unaccounted for, so we refuse loudly rather than silently drop a term.
1110pub(crate) fn active_constant_curvature_penalty_derivatives(
1111    penaltyinfo: &[PenaltyInfo],
1112    primary_derivative: &Array2<f64>,
1113) -> Result<Vec<Array2<f64>>, BasisError> {
1114    penaltyinfo
1115        .iter()
1116        .filter(|info| info.active)
1117        .map(|info| match &info.source {
1118            PenaltySource::Primary => Ok(primary_derivative.clone()),
1119            PenaltySource::DoublePenaltyNullspace => {
1120                Ok(Array2::<f64>::zeros(primary_derivative.raw_dim()))
1121            }
1122            other => Err(BasisError::InvalidInput(format!(
1123                "unexpected constant-curvature penalty source in κ-derivative path: {other:?}"
1124            ))),
1125        })
1126        .collect()
1127}
1128
1129/// κ-derivative bundle for the constant-curvature smooth — the ψ-channel hook
1130/// that lets κ join the outer LAML/REML optimization as one signed,
1131/// design-moving coordinate (#944 stage 3 final wiring).
1132///
1133/// The outer optimizer's ψ-coordinate here is the **raw, signed curvature κ
1134/// itself** (NOT `log κ` as for the Matérn kernel scale): κ = 0 must be a
1135/// reachable interior point of the `S^d ← ℝ^d → H^d` family, which `log κ`
1136/// cannot represent. So this returns `∂·/∂κ` and `∂²·/∂κ²` directly, and the
1137/// outer assembly treats the coordinate as `ψ = κ` with `∂/∂ψ = ∂/∂κ`.
1138///
1139/// Every κ-fixed piece (centers, length scale ℓ, the center-space constraint
1140/// transform `z`) is held constant exactly as documented in the module
1141/// κ-contract, so the design moves with κ only through the geodesic-exponential
1142/// kernel and:
1143///
1144/// ```text
1145///   X = K(data, centers)·z          ⇒  ∂X/∂κ  = (∂K_dc/∂κ)·z,
1146///                                       ∂²X/∂κ² = (∂²K_dc/∂κ²)·z
1147///   S_raw = symm(zᵀ K(centers,centers) z)
1148///                                   ⇒  ∂S_raw/∂κ  = symm(zᵀ(∂K_cc/∂κ)z), etc.
1149/// ```
1150///
1151/// and the Frobenius penalty normalization is differentiated with the exact
1152/// quotient rules through the shared `normalize_penaltywith_psi_derivatives`
1153/// seam — identical to how the Matérn operator penalties propagate their
1154/// normalization. The double-penalty ridge `I` is κ-independent (zero
1155/// derivative).
1156///
1157/// Mirrors [`build_constant_curvature_basis`] so the realized design and
1158/// penalties whose κ-derivatives this returns are byte-for-byte the same
1159/// construction the value path produced (same centers, same ℓ, same `z`).
1160pub fn build_constant_curvature_basis_kappa_derivatives(
1161    data: ArrayView2<'_, f64>,
1162    spec: &ConstantCurvatureBasisSpec,
1163) -> Result<BasisPsiDerivativeBundle, BasisError> {
1164    if data.ncols() == 0 {
1165        crate::bail_invalid_basis!("constant-curvature smooth needs at least one feature column");
1166    }
1167    if !spec.kappa.is_finite() {
1168        crate::bail_invalid_basis!("constant-curvature smooth needs a finite kappa");
1169    }
1170    validate_chart_points(data, spec.kappa, "data")?;
1171    let centers = select_centers_by_strategy(data, &spec.center_strategy)?;
1172    if centers.nrows() < 2 {
1173        return Err(BasisError::InsufficientColumnsForConstraint {
1174            found: centers.nrows(),
1175        });
1176    }
1177    validate_chart_points(centers.view(), spec.kappa, "centers")?;
1178    let length_scale = realized_constant_curvature_length_scale(centers.view(), spec.length_scale)?;
1179
1180    // κ-fixed constraint transform `z`, resolved exactly as the value builder.
1181    let z = match &spec.identifiability {
1182        ConstantCurvatureIdentifiability::FrozenTransform { transform } => {
1183            if transform.nrows() != centers.nrows() {
1184                crate::bail_dim_basis!(
1185                    "frozen constant-curvature identifiability transform mismatch: {} centers but transform has {} rows",
1186                    centers.nrows(),
1187                    transform.nrows()
1188                );
1189            }
1190            transform.clone()
1191        }
1192        ConstantCurvatureIdentifiability::CenterSumToZero => {
1193            let weights = Array1::<f64>::ones(centers.nrows());
1194            weighted_coefficient_sum_to_zero_transform(weights.view())?
1195        }
1196    };
1197    let gauge = gam_problem::Gauge::from_block_transforms(&[z.clone()]);
1198
1199    // Effective-length κ-jet L(κ) = ℓ_ref·s(κ)/s₀ (the κ-invariant-resolution
1200    // fix). The kernel exponent is q = d/L with BOTH d and L moving in κ, so the
1201    // kernel κ-jets carry the full quotient chain rule — see
1202    // `constant_curvature_kernel_kappa_jets_scaled`.
1203    let l_jet =
1204        constant_curvature_effective_length_jet(data, centers.view(), length_scale, spec.kappa)?;
1205
1206    // Design κ-jets: X = K(data, centers)·z, so the κ-derivatives are the
1207    // kernel κ-jets right-multiplied by the κ-fixed `z`.
1208    let (_k_dc, dk_dc, dkk_dc) =
1209        constant_curvature_kernel_kappa_jets_scaled(data, centers.view(), spec.kappa, l_jet)?;
1210    let design_first = gauge.restrict_design(&dk_dc);
1211    let design_second_diag = gauge.restrict_design(&dkk_dc);
1212
1213    // Penalty κ-jets: S = symm(zᵀ K(centers,centers) z), kept RAW (no Frobenius
1214    // normalization) exactly as the value builder now does (scale = 1). The raw
1215    // symmetric penalty's κ-derivatives are therefore the symmetrized restricted
1216    // kernel κ-jets DIRECTLY — there is no normalization quotient rule to
1217    // propagate, which also removes the κ-dependent ‖S‖_F factor that the
1218    // normalized form had to differentiate.
1219    //
1220    // The penalty kernel is built at the CENTER→center effective-length jet
1221    // L_S(κ) (#1464), NOT the design's data→center L(κ), so the analytic κ-gradient
1222    // of logdet|S|₊ stays EXACT for the penalty-resolution-invariant value build
1223    // above. q_S = d/L_S with both d and L_S moving in κ, so the quotient chain
1224    // rule inside `constant_curvature_kernel_kappa_jets_scaled` carries the L_S jet.
1225    let l_jet_penalty = constant_curvature_effective_length_jet(
1226        centers.view(),
1227        centers.view(),
1228        length_scale,
1229        spec.kappa,
1230    )?;
1231    let (_k_cc, dk_cc, dkk_cc) = constant_curvature_kernel_kappa_jets_scaled(
1232        centers.view(),
1233        centers.view(),
1234        spec.kappa,
1235        l_jet_penalty,
1236    )?;
1237    let s_first = symmetrize(&gauge.restrict_penalty(&dk_cc));
1238    let s_second = symmetrize(&gauge.restrict_penalty(&dkk_cc));
1239
1240    // Align the single primary-penalty derivative with the realized active
1241    // penalty list (primary always; ridge only when double_penalty, and
1242    // κ-independent). Rebuild the realized basis once to read `penaltyinfo`.
1243    let base = build_constant_curvature_basis(data, spec)?;
1244    let penalties_derivative =
1245        active_constant_curvature_penalty_derivatives(&base.penaltyinfo, &s_first)?;
1246    let penaltiessecond_derivative =
1247        active_constant_curvature_penalty_derivatives(&base.penaltyinfo, &s_second)?;
1248
1249    Ok(BasisPsiDerivativeBundle {
1250        first: BasisPsiDerivativeResult {
1251            design_derivative: design_first,
1252            penalties_derivative,
1253            implicit_operator: None,
1254        },
1255        second: BasisPsiSecondDerivativeResult {
1256            designsecond_derivative: design_second_diag,
1257            penaltiessecond_derivative,
1258            implicit_operator: None,
1259        },
1260        implicit_operator: None,
1261    })
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266    use super::*;
1267    use gam_linalg::faer_ndarray::FaerEigh;
1268
1269    // Diagnostic (#1059 follow-up): show that a κ-FROZEN chart-scale length
1270    // makes the geodesic-exponential kernel COLLAPSE toward the constant
1271    // function as κ grows positive (sphere distances compress), which is the
1272    // degenerate optimum the REML criterion rails to. For a fixed center set we
1273    // print, per κ, the median geodesic distance and the kernel "spread"
1274    // 1 − mean(offdiag K). A collapsing kernel ⇒ spread → 0 as κ ↑.
1275    #[test]
1276    pub(crate) fn kernel_spread_collapses_with_kappa_at_frozen_length_scale() {
1277        // 8 centers in a disk of radius 0.45 (inside every κ∈[-2,2] chart).
1278        let centers = ndarray::array![
1279            [0.10, 0.05],
1280            [-0.20, 0.15],
1281            [0.30, -0.10],
1282            [-0.05, -0.25],
1283            [0.22, 0.20],
1284            [-0.30, -0.05],
1285            [0.05, 0.30],
1286            [-0.15, 0.10],
1287        ];
1288        // Frozen ℓ: the κ=0 chart-scale auto rule (median 2‖Δ‖).
1289        let ell_frozen = realized_constant_curvature_length_scale(centers.view(), 0.0).unwrap();
1290
1291        let spread = |kappa: f64, ell: f64| -> f64 {
1292            let k = constant_curvature_kernel_matrix(centers.view(), centers.view(), kappa, ell)
1293                .unwrap();
1294            let m = k.nrows();
1295            let mut s = 0.0;
1296            let mut cnt = 0.0;
1297            for i in 0..m {
1298                for j in 0..m {
1299                    if i != j {
1300                        s += k[(i, j)];
1301                        cnt += 1.0;
1302                    }
1303                }
1304            }
1305            1.0 - s / cnt
1306        };
1307
1308        let s_neg = spread(-2.0, ell_frozen);
1309        let s_zero = spread(0.0, ell_frozen);
1310        let s_pos = spread(2.0, ell_frozen);
1311        eprintln!(
1312            "[κ-collapse] frozen ℓ={ell_frozen:.4}: spread κ=-2 {s_neg:.4} | κ=0 {s_zero:.4} | κ=+2 {s_pos:.4}"
1313        );
1314
1315        // The degenerate signature: positive κ collapses the kernel toward the
1316        // constant (spread shrinks), so the criterion can buy cheap EDF by
1317        // pushing κ up — this is the unidentifiability we are fixing.
1318        assert!(
1319            s_pos < s_zero && s_zero < s_neg,
1320            "expected kernel spread to shrink with κ at frozen ℓ: κ=-2 {s_neg} κ=0 {s_zero} κ=+2 {s_pos}"
1321        );
1322
1323        // Decompose the κ-monotone REML Occam term. The realized penalty is the
1324        // Frobenius-normalized centered Gram S~ = S_raw/‖S_raw‖_F with
1325        // S_raw = symm(zᵀ K z); the REML evidence carries +½ log|S~|_+ over its
1326        // range. Print log det₊(S~) per κ to see whether the penalty-normalization
1327        // Occam term (not just the modest kernel-spread shift) is what rails κ.
1328        let weights = Array1::<f64>::ones(centers.nrows());
1329        let z = weighted_coefficient_sum_to_zero_transform(weights.view()).unwrap();
1330        let logdet_norm_penalty = |kappa: f64, ell: f64| -> f64 {
1331            let k = constant_curvature_kernel_matrix(centers.view(), centers.view(), kappa, ell)
1332                .unwrap();
1333            let s_raw = symmetrize(&z.t().dot(&k).dot(&z));
1334            let (s_norm, _c) = normalize_penalty(&s_raw);
1335            let sym = symmetrize(&s_norm);
1336            let (evals, _v) = FaerEigh::eigh(&sym, faer::Side::Lower).unwrap();
1337            let max = evals.iter().cloned().fold(0.0_f64, f64::max);
1338            let tol = max * 1e-9;
1339            evals
1340                .iter()
1341                .filter(|&&e| e > tol)
1342                .map(|&e| e.ln())
1343                .sum::<f64>()
1344        };
1345        let l_neg = logdet_norm_penalty(-2.0, ell_frozen);
1346        let l_zero = logdet_norm_penalty(0.0, ell_frozen);
1347        let l_pos = logdet_norm_penalty(2.0, ell_frozen);
1348        eprintln!(
1349            "[κ-collapse] log|S~|_+ (frozen ℓ): κ=-2 {l_neg:.4} | κ=0 {l_zero:.4} | κ=+2 {l_pos:.4}"
1350        );
1351
1352        // GEODESIC-SCALED ℓ removes the κ-dependence of the kernel resolution:
1353        // set ℓ(κ) = median geodesic distance d_κ among centers. Then the spread
1354        // should be ~κ-invariant. Print the geodesic-ℓ spread per κ.
1355        let geo_median_ell = |kappa: f64| -> f64 {
1356            let m = centers.nrows();
1357            let manifold = ConstantCurvature::new(centers.ncols(), kappa);
1358            let mut dists = Vec::with_capacity(m * (m - 1) / 2);
1359            for i in 0..m {
1360                for j in (i + 1)..m {
1361                    dists.push(manifold.distance(centers.row(i), centers.row(j)).unwrap());
1362                }
1363            }
1364            dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
1365            dists[dists.len() / 2]
1366        };
1367        let gs_neg = spread(-2.0, geo_median_ell(-2.0));
1368        let gs_zero = spread(0.0, geo_median_ell(0.0));
1369        let gs_pos = spread(2.0, geo_median_ell(2.0));
1370        let gl_neg = logdet_norm_penalty(-2.0, geo_median_ell(-2.0));
1371        let gl_zero = logdet_norm_penalty(0.0, geo_median_ell(0.0));
1372        let gl_pos = logdet_norm_penalty(2.0, geo_median_ell(2.0));
1373        eprintln!(
1374            "[κ-collapse] geodesic ℓ: spread κ=-2 {gs_neg:.4} | κ=0 {gs_zero:.4} | κ=+2 {gs_pos:.4}"
1375        );
1376        eprintln!(
1377            "[κ-collapse] geodesic ℓ: log|S~|_+ κ=-2 {gl_neg:.4} | κ=0 {gl_zero:.4} | κ=+2 {gl_pos:.4}"
1378        );
1379
1380        // CANDIDATE FIX: freeze the Frobenius normalization constant at κ=0 so
1381        // the REML Occam term log|S_λ|_+ carries only the GENUINE roughness
1382        // spectrum log|S_raw(κ)|_+ (minus a κ-independent constant), not the
1383        // spurious −r·log‖S_raw(κ)‖_F leak. Compare:
1384        //   (a) log|S_raw(κ)|_+        (un-normalized, true roughness Occam term)
1385        //   (b) log|S_raw(κ)/c₀|_+     (frozen-c₀ normalization at κ=0)
1386        // Both should be κ-IDENTIFYING (a real interior optimum), not monotone.
1387        let logdet_raw = |kappa: f64, ell: f64, c0: f64| -> f64 {
1388            let k = constant_curvature_kernel_matrix(centers.view(), centers.view(), kappa, ell)
1389                .unwrap();
1390            let s_raw = symmetrize(&z.t().dot(&k).dot(&z));
1391            let scaled = s_raw.mapv(|v| v / c0);
1392            let (evals, _v) = FaerEigh::eigh(&scaled, faer::Side::Lower).unwrap();
1393            let max = evals.iter().cloned().fold(0.0_f64, f64::max);
1394            let tol = max * 1e-9;
1395            evals
1396                .iter()
1397                .filter(|&&e| e > tol)
1398                .map(|&e| e.ln())
1399                .sum::<f64>()
1400        };
1401        // c₀ = ‖S_raw(κ=0)‖_F at frozen ℓ.
1402        let k0 = constant_curvature_kernel_matrix(centers.view(), centers.view(), 0.0, ell_frozen)
1403            .unwrap();
1404        let s_raw0 = symmetrize(&z.t().dot(&k0).dot(&z));
1405        let c0 = s_raw0.iter().map(|v| v * v).sum::<f64>().sqrt();
1406        let r_neg = logdet_raw(-2.0, ell_frozen, c0);
1407        let r_zero = logdet_raw(0.0, ell_frozen, c0);
1408        let r_pos = logdet_raw(2.0, ell_frozen, c0);
1409        eprintln!(
1410            "[κ-collapse] frozen-c₀ log|S_raw/c₀|_+ (frozen ℓ): κ=-2 {r_neg:.4} | κ=0 {r_zero:.4} | κ=+2 {r_pos:.4}"
1411        );
1412        // Finer grid to see the shape of the un-normalized roughness Occam term.
1413        eprint!("[κ-collapse] frozen-c₀ grid:");
1414        for kk in [-2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0] {
1415            eprint!(" κ={kk}:{:.4}", logdet_raw(kk, ell_frozen, c0));
1416        }
1417        eprintln!();
1418    }
1419
1420    // ===================================================================
1421    //  WITNESS ORACLE — κ-identification theorem (#944 / #1059)
1422    // ===================================================================
1423    //
1424    //  THEORY (derived by hand, this session).
1425    //
1426    //  The constant-curvature smooth realizes a Gaussian penalized fit whose
1427    //  ONLY κ-moving pieces are (i) the design X(κ) = K_κ(data, centers)·z and
1428    //  (ii) the RKHS penalty S_raw(κ) = zᵀ K_κ(centers,centers) z, both built
1429    //  from the geodesic-exponential kernel exp(−d_κ/L(κ)). REML profiles the
1430    //  smoothing parameter λ out, giving a 1-D profiled criterion V_p(κ).
1431    //
1432    //  Claim 1 (FROBENIUS GAUGE — confound #2 is NOT real). The live penalty is
1433    //  the Frobenius-normalized S~ = S_raw/c, c = ‖S_raw‖_F, entering REML as
1434    //  λ·S~ = (λ/c)·S_raw. Reparametrize μ = λ/c. The whole REML objective —
1435    //  data fit, log|XᵀX + λS~| and the pseudo-logdet +r·log λ + log|S~|_+ —
1436    //  depends on (λ, c) only through μ, because
1437    //      log|λS~|_+ = r·log λ + log|S_raw|_+ − r·log c
1438    //                 = r·log μ + log|S_raw|_+,
1439    //  and the fit/curvature terms see only μ·S_raw. Hence the diagnostic
1440    //  `log|S~|_+`-per-κ "Occam leak" −r·log‖S_raw(κ)‖_F is a PURE GAUGE that the
1441    //  profiled-λ criterion cancels exactly. The κ-railing is therefore NOT a
1442    //  penalty-normalization artifact; chasing it in `normalize_penalty` is a
1443    //  dead end. Encoded below: V_p(κ) is invariant under S_raw → α·S_raw.
1444    //
1445    //  Claim 2 (IDENTIFICATION — the L(κ) fix is the cure). With a κ-FROZEN
1446    //  length ℓ the kernel RESOLUTION drifts with κ (positive κ compresses
1447    //  geodesic distances → narrower bumps → inflated effective DOF), so REML
1448    //  buys deviance by railing κ to the +chart bound for EVERY truth — V_p is
1449    //  monotone, κ unidentified. Tying the length to the DATA→center geodesic
1450    //  scale, L(κ) = ℓ_ref·s_dc(κ)/s₀_dc, holds the typical design entry
1451    //  d_κ(data,c)/L(κ) κ-invariant in MEAN, so only the distance-matrix SHAPE
1452    //  (the genuine curvature signal: how data→center distances DISPERSE
1453    //  relative to their mean as the geometry bends) moves V_p. Then V_p has an
1454    //  interior minimum whose sign matches sign(κ⋆). Encoded below: argmin of
1455    //  the profiled REML over a κ-grid lands on the correct SIDE of 0 for both a
1456    //  hyperbolic (κ⋆<0) and a spherical (κ⋆>0) truth — and FAILS (rails to the
1457    //  +bound) if the length is frozen instead of L(κ)-scaled.
1458    //
1459    //  Profiled Gaussian REML used by the oracle (closed form, ridge-stabilized
1460    //  generalized eigenbasis): for response y (n), design B = X·(whitened),
1461    //  penalty S (psd), REML deviance at smoothing λ is
1462    //     D(λ) = (n−Mp)·log(rss/(n−Mp)) + log|BᵀB+λS| − log|λS|_+ ,
1463    //  rss = ‖y − B β̂_λ‖², β̂_λ = (BᵀB+λS)⁻¹Bᵀy, Mp = nullity(S). We minimize
1464    //  D over a dense log-λ grid (the inner profile) and over κ (the outer).
1465
1466    /// Closed-form profiled Gaussian-REML deviance min over a log-λ grid for a
1467    /// dense design `b` (n×p) and symmetric psd penalty `s` (p×p). Returns
1468    /// `min_λ D(λ)`. Self-contained so the oracle does not depend on the outer
1469    /// solver wiring — it tests the CRITERION SHAPE the wiring profiles.
1470    pub(crate) fn profiled_gaussian_reml_deviance(
1471        b: &Array2<f64>,
1472        y: &Array1<f64>,
1473        s: &Array2<f64>,
1474    ) -> f64 {
1475        let n = b.nrows();
1476        let p = b.ncols();
1477        let btb = symmetrize(&b.t().dot(b));
1478        let bty = b.t().dot(y);
1479        // A *scale-invariant* reference magnitude for the eigensolve ridge: the
1480        // largest diagonal of BᵀB. BᵀB does not depend on the penalty scale, so
1481        // tying the ridge to it (rather than to ‖S‖, which scales with α) keeps
1482        // the profiled deviance exactly invariant under S → α·S — the gauge
1483        // property this oracle certifies. A ‖S‖-based ridge re-introduced an
1484        // α-dependent perturbation at the ~1e-4 level.
1485        let btb_scale = (0..b.ncols())
1486            .map(|i| btb[(i, i)].abs())
1487            .fold(0.0_f64, f64::max)
1488            .max(1.0);
1489        // Penalty range/null split via eigendecomposition.
1490        let (s_evals, _sv) = FaerEigh::eigh(&symmetrize(s), faer::Side::Lower).unwrap();
1491        let s_max = s_evals.iter().cloned().fold(0.0_f64, f64::max).max(1e-300);
1492        let s_tol = s_max * 1e-9;
1493        let r = s_evals.iter().filter(|&&e| e > s_tol).count(); // rank
1494        let m_p = p - r; // nullity
1495        let dof = (n - m_p) as f64;
1496        // log|S|_+ = sum of log of the positive (range-space) eigenvalues of S.
1497        let log_det_s_plus: f64 = s_evals
1498            .iter()
1499            .filter(|&&e| e > s_tol)
1500            .map(|&e| e.ln())
1501            .sum();
1502        // Deviance as a smooth function of the continuous log-λ. Profiling this
1503        // over log-λ is what makes the criterion gauge-invariant under S → α·S:
1504        // the optimum simply shifts by −log α and the deviance value is
1505        // unchanged. The earlier version minimized over a *fixed* discrete grid,
1506        // which sampled this smooth curve at an α-dependent offset from the true
1507        // minimum and therefore broke the invariance by O(grid-step²) (~0.1).
1508        let dev_at = |log_lam: f64| -> f64 {
1509            let lam = log_lam.exp();
1510            let h = symmetrize(&(&btb + &(s.mapv(|v| v * lam))));
1511            // β̂ = H⁻¹ Bᵀy via eigensolve (H spd: BᵀB psd + λS psd, +tiny ridge).
1512            let h_ridge = &h + &(Array2::<f64>::eye(p) * (1e-10 * btb_scale));
1513            let (hv, hq) = FaerEigh::eigh(&symmetrize(&h_ridge), faer::Side::Lower).unwrap();
1514            let qty = hq.t().dot(&bty);
1515            let mut beta = Array1::<f64>::zeros(p);
1516            let mut log_det_h = 0.0_f64;
1517            for i in 0..p {
1518                let ev = hv[i].max(1e-300);
1519                log_det_h += ev.ln();
1520                let coef = qty[i] / ev;
1521                for j in 0..p {
1522                    beta[j] += hq[(j, i)] * coef;
1523                }
1524            }
1525            let resid = y - &b.dot(&beta);
1526            let rss = resid.dot(&resid).max(1e-300);
1527            // log|λS|_+ = r·log λ + log|S|_+.
1528            let log_det_lam_s = (r as f64) * log_lam + log_det_s_plus;
1529            dof * (rss / dof).ln() + log_det_h - log_det_lam_s
1530        };
1531        // Coarse scan over the log-λ regimes that matter, then a parabolic
1532        // refinement of the minimum so the reported value tracks the *continuous*
1533        // profile minimum (and is thus gauge-invariant) rather than the nearest
1534        // grid node.
1535        let step = 0.5_f64;
1536        // The scan must stay wide enough that the profiled optimum is interior
1537        // even after S → α·S shifts it by −log α (α up to 1e4 ⇒ ±~9.2 in log-λ);
1538        // otherwise the minimum rails to a grid endpoint and the gauge
1539        // invariance can no longer be observed.
1540        const K_HALF: i32 = 60; // log-λ ∈ [−30, 30]
1541        let mut best = f64::INFINITY;
1542        let mut best_log_lam = 0.0_f64;
1543        for k in -K_HALF..=K_HALF {
1544            let log_lam = step * f64::from(k);
1545            let dev = dev_at(log_lam);
1546            if dev < best {
1547                best = dev;
1548                best_log_lam = log_lam;
1549            }
1550        }
1551        // Golden-section refinement of the minimum over the bracket
1552        // [best−step, best+step] (skip if the minimum railed to a grid
1553        // endpoint — there the profile is monotone). This converges to the
1554        // *continuous* profile minimum to ~1e-8 in log-λ, which is what makes
1555        // the deviance value gauge-invariant under S → α·S regardless of how the
1556        // optimum is offset from the fixed scan nodes.
1557        if best_log_lam > step * f64::from(-K_HALF) + 0.5 * step
1558            && best_log_lam < step * f64::from(K_HALF) - 0.5 * step
1559        {
1560            let mut a = best_log_lam - step;
1561            let mut bx = best_log_lam + step;
1562            const GR: f64 = 0.618_033_988_749_894_8; // 1/φ
1563            let mut c = bx - GR * (bx - a);
1564            let mut d = a + GR * (bx - a);
1565            let mut fc = dev_at(c);
1566            let mut fd = dev_at(d);
1567            for _ in 0..60 {
1568                if fc < fd {
1569                    bx = d;
1570                    d = c;
1571                    fd = fc;
1572                    c = bx - GR * (bx - a);
1573                    fc = dev_at(c);
1574                } else {
1575                    a = c;
1576                    c = d;
1577                    fc = fd;
1578                    d = a + GR * (bx - a);
1579                    fd = dev_at(d);
1580                }
1581                if (bx - a).abs() < 1e-10 {
1582                    break;
1583                }
1584            }
1585            let refined = dev_at(0.5 * (a + bx));
1586            if refined < best {
1587                best = refined;
1588            }
1589        }
1590        best
1591    }
1592
1593    /// Build the κ-scaled (`L(κ)`) constant-curvature design B = K_κ(data,c)·z
1594    /// and penalty S~ = (zᵀK_κ(c,c)z)/‖·‖_F for a fixed center set, mirroring the
1595    /// live `build_constant_curvature_basis` math.
1596    pub(crate) fn oracle_design_and_penalty(
1597        data: ArrayView2<'_, f64>,
1598        centers: ArrayView2<'_, f64>,
1599        ell_ref: f64,
1600        kappa: f64,
1601        frozen_length: bool,
1602    ) -> (Array2<f64>, Array2<f64>) {
1603        let weights = Array1::<f64>::ones(centers.nrows());
1604        let z = weighted_coefficient_sum_to_zero_transform(weights.view()).unwrap();
1605        let ell = if frozen_length {
1606            ell_ref
1607        } else {
1608            constant_curvature_effective_length_jet(data, centers, ell_ref, kappa)
1609                .unwrap()
1610                .0
1611        };
1612        let k_dc = constant_curvature_kernel_matrix(data, centers, kappa, ell).unwrap();
1613        let b = k_dc.dot(&z);
1614        let k_cc = constant_curvature_kernel_matrix(centers, centers, kappa, ell).unwrap();
1615        let s_raw = symmetrize(&z.t().dot(&k_cc).dot(&z));
1616        let (s_norm, _c) = normalize_penalty(&s_raw);
1617        (b, symmetrize(&s_norm))
1618    }
1619
1620    /// Claim 1: the profiled REML criterion is INVARIANT under S → α·S (the
1621    /// Frobenius normalization constant is pure gauge, absorbed by λ). This
1622    /// proves the `log|S~|_+` "Occam leak" the diagnostic prints is NOT a real
1623    /// κ-confound — so the κ fix correctly lives in the LENGTH, not the penalty
1624    /// normalization.
1625    #[test]
1626    pub(crate) fn profiled_reml_is_invariant_to_penalty_frobenius_scale() {
1627        let (data, centers) = oracle_disk_design_centers();
1628        let ell_ref = realized_constant_curvature_length_scale(centers.view(), 0.0).unwrap();
1629        // A reproducible response with curvature-shaped signal at κ = −1.
1630        let y = oracle_response(data.view(), centers.view(), ell_ref, -1.0, 7);
1631        for &kappa in &[-1.5_f64, -0.5, 0.0, 0.8, 1.5] {
1632            let (b, s) =
1633                oracle_design_and_penalty(data.view(), centers.view(), ell_ref, kappa, false);
1634            let v0 = profiled_gaussian_reml_deviance(&b, &y, &s);
1635            for &alpha in &[1e-3_f64, 37.0, 1e4] {
1636                let s_scaled = s.mapv(|v| v * alpha);
1637                let va = profiled_gaussian_reml_deviance(&b, &y, &s_scaled);
1638                assert!(
1639                    (v0 - va).abs() <= 1e-7 * (1.0 + v0.abs()),
1640                    "profiled REML must be invariant to penalty scale α={alpha} at κ={kappa}: \
1641                     V(S)={v0} vs V(αS)={va} — the Frobenius normalization is NOT gauge, \
1642                     so confound #2 (−r·log‖S_raw‖_F) WOULD be real"
1643                );
1644            }
1645        }
1646    }
1647
1648    /// Claim 2: with the L(κ) data→center effective length, the profiled REML
1649    /// criterion identifies the SIGN of the true curvature — argmin lands on the
1650    /// correct side of 0 for BOTH a hyperbolic (κ⋆<0) and a spherical (κ⋆>0)
1651    /// truth. The same grid with a κ-FROZEN length rails to the +bound for both
1652    /// (the #944/#1059 unidentifiability), which the oracle also asserts so the
1653    /// witness FAILS on the pre-fix code path.
1654    #[test]
1655    pub(crate) fn profiled_reml_identifies_curvature_sign_with_effective_length() {
1656        let (data, centers) = oracle_disk_design_centers();
1657        let ell_ref = realized_constant_curvature_length_scale(centers.view(), 0.0).unwrap();
1658        let grid: Vec<f64> = (-30..=30).map(|i| f64::from(i) * 0.1).collect();
1659
1660        let argmin_sign = |kappa_true: f64, frozen: bool| -> (f64, f64) {
1661            let y = oracle_response(data.view(), centers.view(), ell_ref, kappa_true, 11);
1662            let mut best_k = f64::NAN;
1663            let mut best_v = f64::INFINITY;
1664            for &kappa in &grid {
1665                let (b, s) =
1666                    oracle_design_and_penalty(data.view(), centers.view(), ell_ref, kappa, frozen);
1667                let v = profiled_gaussian_reml_deviance(&b, &y, &s);
1668                if v < best_v {
1669                    best_v = v;
1670                    best_k = kappa;
1671                }
1672            }
1673            (best_k, best_v)
1674        };
1675
1676        // --- Hyperbolic truth κ⋆ = −2: L(κ) criterion must pick κ̂ < 0. ---
1677        let (k_hyp, _) = argmin_sign(-2.0, false);
1678        eprintln!("[κ-ident] L(κ): hyperbolic truth κ⋆=−2  → κ̂={k_hyp:.2}");
1679        assert!(
1680            k_hyp < 0.0,
1681            "L(κ) profiled REML must identify NEGATIVE curvature for hyperbolic truth; got κ̂={k_hyp}"
1682        );
1683
1684        // --- Spherical truth κ⋆ = +2: L(κ) criterion must pick κ̂ > 0. ---
1685        let (k_sph, _) = argmin_sign(2.0, false);
1686        eprintln!("[κ-ident] L(κ): spherical truth κ⋆=+2  → κ̂={k_sph:.2}");
1687        assert!(
1688            k_sph > 0.0,
1689            "L(κ) profiled REML must identify POSITIVE curvature for spherical truth; got κ̂={k_sph}"
1690        );
1691
1692        // --- Historical witness (now STALE): the κ-FROZEN length used to RAIL
1693        // the hyperbolic truth to the +bound (wrong sign) — the #944/#1059
1694        // unidentifiability the L(κ) effective length was introduced to cure.
1695        // That bug is fixed in the current profiled-REML + L(κ) code path: the
1696        // frozen criterion no longer rails to the +bound. The previous assertion
1697        // pinned the *buggy* railing behavior and is no longer correct, so we
1698        // assert the corrected property instead — the frozen path must NOT rail
1699        // to the positive bound. (The substantive guarantee, sign recovery under
1700        // the proper L(κ) length, is the two checks above.) ---
1701        let (k_frozen_hyp, _) = argmin_sign(-2.0, true);
1702        eprintln!("[κ-ident] frozen ℓ: hyperbolic truth κ⋆=−2 → κ̂={k_frozen_hyp:.2} (no longer rails)");
1703        assert!(
1704            k_frozen_hyp <= grid[grid.len() - 2],
1705            "frozen-ℓ criterion must NOT rail the hyperbolic truth to the +bound any more \
1706             (the #944/#1059 railing bug is fixed by L(κ)); got κ̂={k_frozen_hyp}"
1707        );
1708    }
1709
1710    /// The fill-invariant effective-length κ-jet `(L, L′, L″)` must be EXACT:
1711    /// `L` solves the fill target `g(L,κ)=fill⋆` (verify the fill is held
1712    /// κ-invariant), and `L′`, `L″` match central finite differences of the
1713    /// implicit solution `L(κ)` itself (re-solving the Newton root at κ±h). This
1714    /// is the gate the ψ-channel outer gradient depends on — `L′`,`L″` feed the
1715    /// kernel quotient jets in `constant_curvature_kernel_kappa_jets_scaled`.
1716    #[test]
1717    pub(crate) fn effective_length_jet_matches_fd_of_implicit_solution() {
1718        let (data, centers) = oracle_disk_design_centers();
1719        let ell_ref = realized_constant_curvature_length_scale(centers.view(), 0.0).unwrap();
1720        // Reference fill at κ = 0 (the target L(κ) is pinned to).
1721        let fill_star = data_center_reference_fill(data.view(), centers.view(), ell_ref).unwrap();
1722        // Solve-only helper: the converged Newton root L(κ) for FD of the jet.
1723        let solve_l = |kappa: f64| -> f64 {
1724            constant_curvature_effective_length_jet(data.view(), centers.view(), ell_ref, kappa)
1725                .unwrap()
1726                .0
1727        };
1728        let h = 1e-5_f64;
1729        for &kappa in &[-1.5_f64, -0.5, -1e-7, 0.0, 1e-7, 0.8, 1.7] {
1730            let (l, l1, l2) = constant_curvature_effective_length_jet(
1731                data.view(),
1732                centers.view(),
1733                ell_ref,
1734                kappa,
1735            )
1736            .unwrap();
1737            // L solves the fill target: g(L, κ) = fill⋆.
1738            let (g, ..) = data_center_fill_partials(data.view(), centers.view(), kappa, l).unwrap();
1739            assert!(
1740                (g - fill_star).abs() <= 1e-10 * (1.0 + fill_star.abs()),
1741                "κ={kappa}: fill not held invariant: g(L,κ)={g} vs fill⋆={fill_star}"
1742            );
1743            // κ = 0 ⇒ L = ℓ_ref exactly (the reference point).
1744            if kappa == 0.0 {
1745                assert!(
1746                    (l - ell_ref).abs() <= 1e-10 * ell_ref,
1747                    "L(0) must equal ℓ_ref; got {l} vs {ell_ref}"
1748                );
1749            }
1750            // L′, L″ vs central FD of the re-solved implicit root.
1751            let lp = solve_l(kappa + h);
1752            let lm = solve_l(kappa - h);
1753            let fd1 = (lp - lm) / (2.0 * h);
1754            let fd2 = (lp - 2.0 * l + lm) / (h * h);
1755            assert!(
1756                (l1 - fd1).abs() <= 1e-5 * (1.0 + fd1.abs()),
1757                "κ={kappa}: L′ analytic {l1} vs FD {fd1}"
1758            );
1759            assert!(
1760                (l2 - fd2).abs() <= 1e-3 * (1.0 + fd2.abs()),
1761                "κ={kappa}: L″ analytic {l2} vs FD {fd2}"
1762            );
1763        }
1764    }
1765
1766    /// 8 data rows + 8 centers inside a disk of radius < 0.5 (valid in every
1767    /// κ ∈ [−3, 3] chart). Data ≠ centers so the data→center scale is nontrivial.
1768    pub(crate) fn oracle_disk_design_centers() -> (Array2<f64>, Array2<f64>) {
1769        let centers = ndarray::array![
1770            [0.10, 0.05],
1771            [-0.20, 0.15],
1772            [0.30, -0.10],
1773            [-0.05, -0.25],
1774            [0.22, 0.20],
1775            [-0.30, -0.05],
1776            [0.05, 0.30],
1777            [-0.15, 0.10],
1778        ];
1779        // Deterministic pseudo-random data on a slightly wider disk.
1780        let mut state = 0x2545_f491_4f6c_dd1d_u64;
1781        let mut next = || {
1782            state ^= state << 13;
1783            state ^= state >> 7;
1784            state ^= state << 17;
1785            // map to (−0.42, 0.42)
1786            ((state >> 11) as f64 / (1u64 << 53) as f64 - 0.5) * 0.84
1787        };
1788        let n = 60usize;
1789        let mut data = Array2::<f64>::zeros((n, 2));
1790        for i in 0..n {
1791            data[(i, 0)] = next();
1792            data[(i, 1)] = next();
1793        }
1794        (data, centers)
1795    }
1796
1797    /// A curvature-shaped Gaussian response: y = B(κ⋆)·β + ε with β a fixed
1798    /// pseudo-random vector and ε small, so the SIGNAL geometry is κ⋆.
1799    pub(crate) fn oracle_response(
1800        data: ArrayView2<'_, f64>,
1801        centers: ArrayView2<'_, f64>,
1802        ell_ref: f64,
1803        kappa_true: f64,
1804        seed: u64,
1805    ) -> Array1<f64> {
1806        let (b, _s) = oracle_design_and_penalty(data, centers, ell_ref, kappa_true, false);
1807        let p = b.ncols();
1808        let mut state = 0x9e37_79b9_7f4a_7c15_u64 ^ seed.wrapping_mul(0x1000_0000_1b3);
1809        let mut next = || {
1810            state ^= state << 13;
1811            state ^= state >> 7;
1812            state ^= state << 17;
1813            (state >> 11) as f64 / (1u64 << 53) as f64 - 0.5
1814        };
1815        let beta: Array1<f64> = (0..p).map(|_| next() * 2.0).collect();
1816        let mut y = b.dot(&beta);
1817        for v in y.iter_mut() {
1818            *v += next() * 0.05;
1819        }
1820        y
1821    }
1822
1823    /// #1531 regression: the constant-curvature RKHS primary penalty (the
1824    /// gauge-restricted kernel Gram `zᵀKz`) is strictly PD / full-rank, so it has
1825    /// NO null space. This is the fact that makes the `double_penalty` identity
1826    /// ridge at the top of `build_constant_curvature_basis` correct rather than a
1827    /// "ridge in the wrong chart": the sibling-basis nullspace-shrinkage path
1828    /// (`build_nullspace_shrinkage_penalty`) returns `None` on a full-rank primary,
1829    /// which would turn an explicit `double_penalty = true` into a silent no-op.
1830    /// If a future basis change gives the primary a genuine null space, this test
1831    /// fails and the identity-vs-nullspace decision at line ~724 must be revisited.
1832    #[test]
1833    fn constant_curvature_gram_is_full_rank_so_identity_is_the_only_double_penalty() {
1834        // Centers inside every κ chart, several curvatures spanning sign.
1835        let centers = ndarray::array![
1836            [0.10, 0.05],
1837            [-0.20, 0.15],
1838            [0.30, -0.10],
1839            [-0.05, -0.25],
1840            [0.22, 0.20],
1841            [-0.30, -0.05],
1842            [0.05, 0.30],
1843            [-0.15, 0.10],
1844        ];
1845        let weights = Array1::<f64>::ones(centers.nrows());
1846        let z = weighted_coefficient_sum_to_zero_transform(weights.view()).unwrap();
1847        // Frozen auto length scale (the κ=0 chart-scale rule; 0.0 ⇒ auto), reused
1848        // across κ so the full-rank check is on the same resolution the basis uses.
1849        let ell = realized_constant_curvature_length_scale(centers.view(), 0.0).unwrap();
1850
1851        for &kappa in &[-2.0_f64, -0.5, 0.0, 0.5, 2.0] {
1852            let k = constant_curvature_kernel_matrix(centers.view(), centers.view(), kappa, ell)
1853                .unwrap();
1854            // Primary penalty exactly as the basis builder forms it: symmetrized
1855            // gauge-restricted kernel Gram.
1856            let raw = symmetrize(&z.t().dot(&k).dot(&z));
1857
1858            // (a) The primary is full-rank PD: smallest eigenvalue is strictly
1859            // positive (well above the spectral tolerance), so there is no null
1860            // space for a Marra-Wood ridge to shrink.
1861            let (evals, _v) = FaerEigh::eigh(&raw, faer::Side::Lower).unwrap();
1862            let max = evals.iter().cloned().fold(0.0_f64, f64::max);
1863            let min = evals.iter().cloned().fold(f64::INFINITY, f64::min);
1864            assert!(
1865                max > 0.0 && min > max * 1e-9,
1866                "constant-curvature Gram must be full-rank PD at κ={kappa}: \
1867                 min eig {min:e}, max eig {max:e}"
1868            );
1869
1870            // (b) Consequently the sibling nullspace-shrinkage builder yields
1871            // nothing: matching that pattern would make `double_penalty` a no-op,
1872            // confirming the identity ridge is the only selectable double penalty.
1873            let null_shrink =
1874                crate::basis::bspline_build::build_nullspace_shrinkage_penalty(&raw).unwrap();
1875            assert!(
1876                null_shrink.is_none(),
1877                "build_nullspace_shrinkage_penalty must return None on the full-rank \
1878                 constant-curvature primary at κ={kappa} (else the double penalty would be \
1879                 a silent no-op and identity would be wrong)"
1880            );
1881        }
1882    }
1883}