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}