gam_terms/basis/measure_jet_smooth.rs
1//! Measure-jet spline smooth: multiscale local-jet-residual energy of the
2//! empirical measure (center-quadratured current implementation).
3//!
4//! The term penalizes, at every quadrature point and every scale, the failure
5//! of `f` to be locally affine *in the measure*:
6//!
7//! ```text
8//! Q = Σ_ℓ w_ℓ · Σ_i mass_i · q_i(ε_ℓ)^(1−2α) · R_{i,ℓ},
9//! w_ℓ = log_step · ε_ℓ^(−η), η = 2s + d(2−2α),
10//! ```
11//!
12//! where `R_{i,ℓ}` is the residual quadratic form of the exact weighted
13//! local affine projection at center `i` and scale `ε_ℓ`: kernel weights
14//! `w_j = mass_j · exp(−d_ij²/(2ε_ℓ²))`, kernel mass `q_i = Σ_j w_j`, and the
15//! fit `min_b ‖Cv − Φ̃b‖²_W` over weighted-centered values
16//! `Cv = v − (uᵀv)·1` (`u = w/q`) and weighted-centered scaled features
17//! `Φ̃` (rows `(c_j − c_i)/ε`, column means removed under `u`). Rank-deficient
18//! cells use the machine-precision pseudo-inverse of `Φ̃ᵀWΦ̃/q`, so ambient
19//! affine values are projected away exactly instead of paying a ridge toll.
20//!
21//! # Contracts (each is load-bearing; tests pin them)
22//!
23//! - **Exact constant annihilation.** The constant is removed by the weighted
24//! mean projection `C`, never ridged: `Q·1 = 0` to machine precision at
25//! every scale, so the penalty carries NO mass term and the fit has no
26//! prior mean to revert to. This is the no-mean-reversion contract of the
27//! measure-jet design; ridging the constant would silently reintroduce
28//! mean reversion.
29//! - **Exact affine projection / rank adaptation.** The slope block uses the
30//! rank-revealing pseudo-inverse of the dimensionless local Gram
31//! `G = Φ̃ᵀWΦ̃/q`, not a Tikhonov ridge. On a 1-D filament in ambient
32//! dimension d the resolved tangent slope is absorbed (not penalized);
33//! unresolved directions have no variation after weighted centering and do
34//! not create an affine toll. The retained rank is a numerical property of
35//! the weighted cell, not a smoothing dial.
36//! - **Mellin band.** Scales form a geometric grid from the center-spacing
37//! floor to the half-diameter; `w_ℓ = log_step · ε_ℓ^(−η)`, with
38//! `η = 2s + d(2−2α)`, is the fixed-order quadrature weight used by this
39//! implementation. It keeps the advertised continuous smoothness order
40//! `s ∈ (0, 2)` from silently changing when `α` changes.
41//! - **Density normalization.** The outer quadrature weight
42//! `mass_i · q_i^(1−2α)` realizes `dμ(x)/q_ε(x)^(2α−1)`. On a p-dimensional
43//! stratum with sampling density `ρ`, `q_ε ~ Cρ ε^p` and the local residual
44//! contributes an extra `ε^{p(2−2α)}` factor. The fixed-order scale weight
45//! cancels that factor using the available dimension parameter; without that
46//! correction, the symbol exponent would be `2s + 2p(α−1)`.
47//! - **Frozen-quadrature replay.** The penalty and extrapolation diagnostic
48//! depend on the FIT data through center masses, the realized band, on-web
49//! support anchors, and penalty normalization scales. The freeze step
50//! persists all of them ([`MeasureJetFrozenQuadrature`]) so predict-time
51//! rebuilds replay the exact fit-time penalty instead of recomputing it from
52//! predict rows.
53//! - **Single assembly source.** Every quadratic form this module emits —
54//! the energy, its (s, α) jets, the per-scale spectrum — is produced by
55//! ONE workhorse ([`assemble_weighted_forms`]) that walks the local
56//! residual blocks exactly once per request and differs only in the
57//! scalar weights applied per block. Criterion value and criterion
58//! derivatives cannot drift apart (the objective↔gradient desync class is
59//! structurally excluded).
60//! - **single-scale/multiscale opt-in (#1039/#1116).** The per-scale spectrum
61//! and the `(α, ln τ)` ψ dials are the multiscale-mode realization, engaged
62//! ONLY when the spec opts in (`MeasureJetBasisSpec::multiscale = true`, the
63//! DSL `mjs(…, multiscale=true)`); see [`measure_jet_multiscale_mode`]. There
64//! is NO center-count auto-gate: at ANY center count the default is
65//! single-scale — one jet-energy Primary at the auto order with no energy ψ
66//! dials. The independent function-space null-component candidate requested
67//! by `double_penalty` is present in either mode and has its own REML λ. The
68//! flag is persisted on the spec, so freeze→replay re-enters the same mode
69//! verbatim.
70//!
71//! # ψ-differentiability contract (what the ψ-channel stage consumes)
72//!
73//! Mirroring the constant-curvature κ-contract (#944): centers, masses, the
74//! band are deliberately hyperparameter-FIXED at build time; the representer
75//! range ℓ is the ONE design-moving dial, and it is REML-selected by default
76//! (#1116, restored in #2761) — λ shrinks inside a span and cannot move one, so
77//! the range that decides WHICH span the representers occupy has to be chosen
78//! by the criterion, exactly as the Matérn κ is. Consequences:
79//!
80//! - **Penalty-dial design drift is identically zero**: the (s, α, τ) dials
81//! reweight only the jet-energy penalty, never the Gaussian representer
82//! design (`∂X/∂{s,α,τ} ≡ 0`), so those channels are penalty-only
83//! (`is_penalty_like` auto-derives true in the outer engine's
84//! `DirectionalHyperParam`).
85//! - **The representer range ℓ is a design-and-pullback-moving dial** (matérn's
86//! `log_kappa` analog, #1116): `X = K(data, centers; ℓ)·z` and the center
87//! evaluation map `E = K(centers, centers; ℓ)·z` both depend on ℓ. The
88//! center-value forms `Q` and `H₀` are ℓ-invariant, but their coefficient
89//! pullbacks `EᵀQE` and `EᵀH₀E` are not; exact product-rule jets are shipped
90//! alongside the design jets. ℓ rebuilds the design per outer trial; it does
91//! not change the frozen basis rank. FD-gated by
92//! `psi_producer_matches_fd_length_scale`. Frozen only where a design-moving
93//! kernel scale on covariates SHARED by two coupled blocks is an
94//! identifiability hazard — the BMS marginal/slope pair, at its own entry
95//! point (`freeze_measure_jet_length_scale_learning`, #1116/`a3afd17a2`) —
96//! and where the user pins `length_scale=` outright.
97//! - **Exact (s, α) penalty jets are shipped**:
98//! [`measure_jet_energy_form_with_jets`] returns `∂Q/∂s`, `∂²Q/∂s²`,
99//! `∂Q/∂α`, `∂²Q/∂α²`, `∂²Q/∂s∂α` in closed form — both dials enter only
100//! through the per-block log-weights (`∂ln w/∂s = −2 ln ε`,
101//! `∂ln w/∂α = −2 ln q`), so the jets are reweighted re-scatters of the
102//! SAME residual blocks, FD-gated in this module's tests.
103//! The retained τ coordinate is inert under the exact projection, so its
104//! derivative slots are identically zero.
105//!
106//! # Cost shape (and the upgrade ladder above it)
107//!
108//! The outer sum is coarsened per scale to a deterministic ε/2-net (the
109//! outer Riemann sum needs resolution ε, not the center-spacing floor), so
110//! the band totals ~O(m²·d) instead of O(L·m³) — the current realization of
111//! the pyramid principle that each scale interacts at its own level. This is
112//! mass-lumped quadrature of the displayed outer integral; it is first-
113//! moment exact for the cell locations and carries the usual
114//! `O(diam²/ε²)` relative scale for smooth Gaussian-weighted functionals,
115//! not an estimand-preserving identity.
116//! The long-form home for the ladder and the substrate contracts is the
117//! frame notes (`docs/measure_jet_frame.md`); its §2 moment substrate is
118//! `measure_jet_moments.rs`, its §5 extrapolation pricing
119//! `measure_jet_predict.rs`.
120
121use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
122use rayon::prelude::*;
123use serde::{Deserialize, Serialize};
124
125use faer::Side;
126
127use gam_linalg::faer_ndarray::{
128 FaerEigh, FaerSvd, default_rrqr_rank_alpha, rrqr_nullspace_basis,
129};
130
131use super::{
132 AnisoBasisPsiDerivatives, AnisoPenaltyCrossProvider, BasisBuildResult, BasisError,
133 BasisMetadata, CenterStrategy, ConstructiveQuadratic, PenaltyCandidate, PenaltySource,
134 filter_penalty_candidates, normalize_penalty, normalize_penalty_cross_psi_derivative,
135 normalize_penaltywith_psi_derivatives, select_centers_by_strategy, trace_of_product,
136};
137
138/// Truncation radius of the Gaussian profile in units of the scale ε: weights
139/// beyond `3ε` are below `e^{-4.5} ≈ 1.1e-2` of the peak and are dropped from
140/// both the local fit and the `q^(1−2α)` outer weight. This is an absolute
141/// kernel-weight cutoff; using the same truncated q keeps the discrete
142/// functional self-consistent, but it is not a relative tail-error bound.
143pub(crate) const MEASURE_JET_PROFILE_CUTOFF: f64 = 3.0;
144
145/// Relative eigenvalue threshold for rank-revealing pseudo-inverses of local
146/// Gram matrices. Directions at the roundoff floor are treated as unresolved
147/// and excluded from the affine fit.
148pub(crate) const MEASURE_JET_PSEUDOINVERSE_RTOL: f64 = 64.0 * f64::EPSILON;
149
150/// Default continuous smoothness order `s` realized by the `0.0` auto
151/// sentinel. Sits mid-band in the admissible `(0, 2)` for the affine-jet
152/// (r = 2) energy: rough enough to stay pointwise-defined on filaments and
153/// sheets (`s > p/2` for intrinsic `p ≤ 2`), smooth enough to bridge gaps
154/// with attested trends.
155pub(crate) const MEASURE_JET_DEFAULT_ORDER_S: f64 = 1.5;
156
157/// Auto-band scale-count clamp: at least 3 octave-ish nodes so the energy is
158/// genuinely multiscale, at most 8 so degenerate spacing cannot explode the
159/// build.
160pub(crate) const MEASURE_JET_MIN_AUTO_SCALES: usize = 3;
161pub(crate) const MEASURE_JET_MAX_AUTO_SCALES: usize = 8;
162
163/// Representer-range multiple of the median nearest-center spacing used by the
164/// `0.0` auto sentinel.
165///
166/// **This is the SEED of the ℓ outer coordinate, not the realized range.**
167/// [`MeasureJetBasisSpec::learn_length_scale`] is on by default, so what a fit
168/// ships is whatever REML certifies from here; this constant only has to put
169/// the optimizer somewhere feasible and well-conditioned. It is a starting
170/// point in exactly the sense the Matérn `MaternLengthScale::Auto` seed is one.
171///
172/// Set to ×1: a Gaussian representer of range `ℓ = h` (the median
173/// nearest-center spacing) already overlaps its neighbors at
174/// `exp(−h²/(2ℓ²)) = exp(−1/2) ≈ 0.61`, so adjacent bumps blend smoothly while
175/// each center keeps a *distinct* response and the design Gram is far from
176/// rank-deficient. The old ×2 seed made every column `exp(−1/8) ≈ 0.88` at its
177/// neighbor, driving the Gram toward rank deficiency so the inner PIRLS / outer
178/// REML conditioning degraded and the search cycled for hundreds of seconds
179/// (the #1116 timeout). ×1 is the resolving, well-conditioned end of the range
180/// axis — the right side to *start* a search from.
181///
182/// It is emphatically NOT the right place to *stop*. #1041 changed the factor
183/// ×2 → ×1 as a replacement for the ℓ dial it was turning off, on the argument
184/// that a spacing-width kernel "fixes both at the root". Measured on
185/// `measure_jet_perf_parity` at ×1, the design's least-squares span floor
186/// against the noiseless truth is `0.152` and REML moves ℓ 7.5× longer to a
187/// floor of `1.4e-5` (#2761). No fixed multiple of the center spacing is the
188/// answer, because the answer depends on the target's smoothness relative to
189/// the center layout — which is data, not geometry.
190pub(crate) const MEASURE_JET_AUTO_LENGTH_SCALE_FACTOR: f64 = 1.0;
191
192/// Memory budget (in f64 entries) above which the multi-form assembly stops
193/// parallelizing over scales: parallel scale partials cost
194/// `L · n_forms · m²` doubles; past this budget the scales run sequentially
195/// (same numbers — the per-scale loop and the ordered sum are deterministic
196/// either way).
197pub(crate) const MEASURE_JET_PARALLEL_FORM_BUDGET_DOUBLES: usize = 1 << 26;
198
199/// Realized-design identifiability policy for the measure-jet smooth.
200/// Mirrors [`super::ConstantCurvatureIdentifiability`] (#532): the fit-time
201/// section gets the parametric orthogonalization composed onto it by the global
202/// identifiability pipeline, and the composed transform is frozen so
203/// predict-time (and per-ψ-trial) rebuilds replay it verbatim.
204#[derive(Debug, Clone, Serialize, Deserialize, Default)]
205pub enum MeasureJetIdentifiability {
206 /// Fit-time default. With the single-scale affine head, the RBF center
207 /// values are mass-orthogonalized against the affine value space and the
208 /// head passes through exactly; without a head, the representer coefficient
209 /// sum-to-zero section is used. Global parametric residualization follows.
210 #[default]
211 CenterSumToZero,
212 /// Predict-time replay: the frozen composed transform captured at fit
213 /// time. `transform.nrows()` equals representer count plus affine-head width.
214 FrozenTransform { transform: Array2<f64> },
215}
216
217/// Fit-time quadrature of the empirical measure (center masses + realized
218/// scale band), frozen onto the spec so predict-time rebuilds replay the
219/// exact fit-time penalty. Recomputing either from predict rows would
220/// silently change the penalty the coefficients were estimated under.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct MeasureJetFrozenQuadrature {
223 /// Per-center masses `m_i` (nearest-center fractions of the FIT rows).
224 pub masses: Array1<f64>,
225 /// Realized geometric scale band `ε_0 < … < ε_{L−1}`.
226 pub eps_band: Vec<f64>,
227 /// Per-scale on-web support anchor
228 /// `q̄_ℓ = (Σ_i m_i q_ℓ(c_i)) / (Σ_i m_i)`.
229 pub support_means: Vec<f64>,
230 /// Frobenius scales of the emitted per-level normalized penalties. Empty in
231 /// fused mode, where the band emits one primary penalty instead.
232 pub penalty_normalization_scales: Vec<f64>,
233 /// Frobenius scales of the raw per-level forms before the arbitrary Mellin
234 /// `log_step · ε_ℓ^(-2s0)` gauge is folded in.
235 pub raw_penalty_normalization_scales: Vec<f64>,
236 /// Frobenius scale of the single fused primary penalty. `None` in per-level
237 /// mode.
238 pub fused_penalty_normalization_scale: Option<f64>,
239 /// Ambient input-measurement-error scale `σ_coord` (issue #2225): the
240 /// perpendicular off-manifold residual spread of the fit-time empirical
241 /// measure, in the frozen centers' (standardized) coordinate frame. Frozen
242 /// so the predict-time errors-in-variables variance term
243 /// `Var_input = σ_coord²·‖∇f̂‖²` uses the same input-noise scale the fit
244 /// saw. `None` when it could not be estimated (no cell spanned a tangent),
245 /// leaving `Var_input` disabled. Defaults to `None` for models persisted
246 /// before the term existed.
247 #[serde(default)]
248 pub sigma_coord: Option<f64>,
249}
250
251/// Serde default for [`MeasureJetBasisSpec::learn_length_scale`]: REML-select
252/// ℓ, the same standing the Matérn κ has. A function (not a literal) because
253/// `#[serde(default)]` on a `bool` deserializes a missing field as `false`,
254/// which would silently freeze ℓ on every spec that predates the field — the
255/// opposite of the default. See the field's own docs for the measurement.
256fn measure_jet_learn_length_scale_default() -> bool {
257 true
258}
259
260/// Measure-jet smooth configuration (`mjs(x0, …, xd)`).
261///
262/// The feature columns are ambient coordinates of data concentrated near an
263/// unknown low-dimensional (possibly stratified) set; the term learns the
264/// geometry from the empirical measure itself — centers as quadrature nodes,
265/// masses as μ-weights, local jet residuals as the roughness carrier — with
266/// no graph, mesh, or neighbor-set inside the statistical object.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct MeasureJetBasisSpec {
269 /// Center/knot selection strategy (deterministic; quadrature of μ).
270 pub center_strategy: CenterStrategy,
271 /// Continuous smoothness order `s ∈ (0, 2)`; `0.0` sentinel = auto
272 /// (`MEASURE_JET_DEFAULT_ORDER_S`).
273 pub order_s: f64,
274 /// Density-normalization exponent α (outer weight `q^{1−2α}`).
275 pub alpha: f64,
276 /// Historical τ coordinate retained for frozen specs and ψ layout. The
277 /// measure-jet energy itself uses the exact weighted affine projection and
278 /// is independent of τ; the τ ψ derivatives are therefore zero.
279 pub tau0: f64,
280 /// Number of scale nodes; `0` sentinel = auto dyadic band.
281 pub num_scales: usize,
282 /// Representer (Gaussian RBF) range ℓ; `0.0` sentinel = auto
283 /// (median nearest-center spacing × `MEASURE_JET_AUTO_LENGTH_SCALE_FACTOR`).
284 pub length_scale: f64,
285 /// Add a separate function-space affine/null-component penalty alongside
286 /// the jet-energy penalty. Its strength is independently REML-selected.
287 pub double_penalty: bool,
288 /// REML-select the representer range ℓ as a design-moving outer dial,
289 /// mirroring Matérn's `log_kappa`. **Default `true`** — ℓ is a basis
290 /// coordinate of the same kind as the Matérn κ, not a smoothing parameter,
291 /// and a fitted model must choose it.
292 ///
293 /// # Why it cannot be a frozen geometric value (#2761)
294 ///
295 /// The Gaussian kernel is strictly PD for every ℓ > 0, so ℓ does NOT change
296 /// the basis rank (always `m` centers) — but it changes WHICH `m`-dim
297 /// subspace the representers span. λ can only shrink inside a span; it
298 /// cannot move one. So a mis-set ℓ is an error no smoothing parameter can
299 /// repair, and the size of that error is not small. Measured on
300 /// `measure_jet_perf_parity`'s 1-D-curve-in-3-D Gaussian fixture
301 /// (`n = 1500`, σ = 0.10, 16 centers, `p = 15`), where `span floor` is the
302 /// least-squares projection residual of the NOISELESS truth onto the
303 /// realized design's column span — the bound no λ can beat:
304 ///
305 /// ```text
306 /// arm ell edf span floor unpen. LS held-out
307 /// frozen (auto ell) 0.5144 14.684 0.152488 0.155484 0.155584
308 /// REML-selected ell 3.8813 14.006 0.000014 0.008155 0.009642
309 /// matern(k=16) - 14.619 0.006077 0.011989 0.011639
310 /// duchon(k=16) - 15.016 0.002443 0.011308 0.010521
311 /// ```
312 ///
313 /// At the frozen range the fit is already at `edf/p = 0.98` and its held-out
314 /// RMSE *is* the span floor: unpenalized least squares on the same design
315 /// gives 0.1555, and dropping the null-component penalty moves the fourth
316 /// decimal. Freeing ℓ drops the floor by four orders and the held-out RMSE
317 /// by 16x, past both comparators, at LOWER edf — nothing is traded for it.
318 ///
319 /// # History (so a fourth flip needs new evidence)
320 ///
321 /// `299c83ffc` (#1116) introduced this dial default-ON precisely to remove
322 /// this fixture's 13x. `a3afd17a2` then found the one place it is unsafe —
323 /// a BMS fit shares ONE mjs basis between the marginal mean and the
324 /// slope surface, and a design-moving kernel scale on shared covariates
325 /// is an identifiability hazard that reached a separation runaway — and
326 /// contained it AT THE BMS ENTRY POINT with
327 /// [`crate::smooth::freeze_measure_jet_length_scale_learning`], which is
328 /// still what runs there. `b1d94d1a5` (#1041) nevertheless flipped the
329 /// GLOBAL default off, and the 13x returned as #2761. The scoped freeze is
330 /// the correct containment; the global one buys nothing it does not already
331 /// buy and costs every single-surface fit its span alignment.
332 ///
333 /// `false` freezes ℓ at the auto (or explicit) value with no outer
334 /// enrollment. The term builder selects that automatically when the user
335 /// pins `length_scale=` — an explicit range is a request, not a seed —
336 /// mirroring the Matérn `all_spatial_terms_kappa_fixed` short-circuit.
337 #[serde(default = "measure_jet_learn_length_scale_default")]
338 pub learn_length_scale: bool,
339 /// Explicit opt-in for multiscale mode: the per-scale spectral penalty
340 /// split plus the `(α, ln τ)` outer ψ dials. `false` (default) keeps the
341 /// energy in single-scale mode at ANY center count. The separate
342 /// `double_penalty` null component is available in both modes. There is no
343 /// center-count auto-gate; the user opts in via
344 /// `mjs(…, multiscale=true)`. Persisted on the spec so freeze→replay enters
345 /// the same mode.
346 #[serde(default)]
347 pub multiscale: bool,
348 /// Realized-design identifiability policy (see type docs).
349 #[serde(default)]
350 pub identifiability: MeasureJetIdentifiability,
351 /// Fit-time quadrature replay (see type docs). `None` at fit time;
352 /// `Some` on the frozen predict/ψ-trial path.
353 #[serde(default)]
354 pub frozen_quadrature: Option<MeasureJetFrozenQuadrature>,
355}
356
357impl Default for MeasureJetBasisSpec {
358 fn default() -> Self {
359 Self {
360 center_strategy: CenterStrategy::FarthestPoint { num_centers: 50 },
361 order_s: 0.0,
362 // Density-WEIGHTED Hessian energy (the module-header default): the
363 // outer weight is q^{1−2α} = q^{−1} at α = 1. The density-free
364 // variant α = 3/2 gives q^{−2}, which on a low-intrinsic-dimension
365 // stratum (data on a 1-D/2-D manifold embedded in higher ambient d)
366 // makes the local kernel mass q tiny AND spatially varying along
367 // the manifold, so q^{−2} amplifies the penalty unevenly and
368 // over-smooths the high-frequency signal there (MEASURED #1116: on
369 // the 1-D-curve-in-3-D fixture α = 3/2 left mjs ~13× worse than
370 // matérn). α = 1's q^{−1} weighting is far gentler and is the
371 // header-derived default; an explicit `alpha=` still overrides for
372 // genuinely density-free use on a full-dimensional stratum.
373 alpha: 1.0,
374 tau0: 1e-3,
375 num_scales: 0,
376 length_scale: 0.0,
377 double_penalty: true,
378 learn_length_scale: true,
379 multiscale: false,
380 identifiability: MeasureJetIdentifiability::CenterSumToZero,
381 frozen_quadrature: None,
382 }
383 }
384}
385
386/// Realized geometric scale band: `eps` ascending, `log_step` the constant
387/// log-spacing `ln(eps[ℓ+1]/eps[ℓ])` used as the Mellin quadrature weight.
388pub struct MeasureJetBand {
389 pub eps: Vec<f64>,
390 pub log_step: f64,
391}
392
393/// The energy and its exact hyperparameter jets in the live dials. `s` and
394/// `α` enter only through per-block log-weights. The retained `ln τ` slots
395/// are zero because the local fit is the exact weighted affine projection
396/// and no longer depends on τ. All forms are scattered from the SAME local
397/// residual blocks, and the ψ-channel consumes them with zero design drift.
398pub struct MeasureJetEnergyJets {
399 pub q: Array2<f64>,
400 pub dq_ds: Array2<f64>,
401 pub d2q_ds2: Array2<f64>,
402 pub dq_dalpha: Array2<f64>,
403 pub d2q_dalpha2: Array2<f64>,
404 pub d2q_ds_dalpha: Array2<f64>,
405 pub dq_dlogtau: Array2<f64>,
406 pub d2q_dlogtau2: Array2<f64>,
407 pub d2q_ds_dlogtau: Array2<f64>,
408 pub d2q_dalpha_dlogtau: Array2<f64>,
409}
410
411/// Householder vector `u` for the uniform sum-to-zero constraint: the
412/// reflection `H = I − 2uuᵀ` maps `c̄ = 1/√m·1` onto `e₁`, so columns 2..m
413/// of `H` are an orthonormal basis of `1⊥` — the same model space as the
414/// generic RRQR nullspace basis, but with O(rows·m) STRUCTURED application
415/// (`X·z = (X − 2(Xu)uᵀ) minus column 1`) instead of the O(rows·m²)
416/// constraint GEMM that the scale-smoke gate identified as the dominant
417/// build cost.
418pub(crate) fn householder_sum_to_zero_u(m: usize) -> Array1<f64> {
419 let c = 1.0 / (m as f64).sqrt();
420 let mut u = Array1::<f64>::from_elem(m, c);
421 u[0] -= 1.0;
422 let norm = u.dot(&u).sqrt();
423 u.mapv_inplace(|v| v / norm);
424 u
425}
426
427/// Materialize the Householder sum-to-zero basis `z` (m × (m−1)) — columns
428/// 2..m of `H = I − 2uuᵀ` — for the frozen-replay metadata. O(m²), built
429/// once per fit.
430pub(crate) fn householder_sum_to_zero_z(u: &Array1<f64>) -> Array2<f64> {
431 let m = u.len();
432 let mut z = Array2::<f64>::zeros((m, m - 1));
433 for j in 0..(m - 1) {
434 for i in 0..m {
435 let h = if i == j + 1 { 1.0 } else { 0.0 } - 2.0 * u[i] * u[j + 1];
436 z[(i, j)] = h;
437 }
438 }
439 z
440}
441
442pub(crate) fn symmetric_pseudoinverse(
443 a: &Array2<f64>,
444 label: &str,
445) -> Result<Array2<f64>, BasisError> {
446 let n = a.nrows();
447 if a.ncols() != n {
448 crate::bail_dim_basis!(
449 "measure-jet pseudo-inverse `{label}` needs a square matrix, got {:?}",
450 a.dim()
451 );
452 }
453 let (evals, evecs) = a.eigh(Side::Lower).map_err(|e| {
454 BasisError::InvalidInput(format!(
455 "measure-jet pseudo-inverse `{label}` eigendecomposition failed: {e}"
456 ))
457 })?;
458 let lam_max = evals.iter().fold(0.0_f64, |acc, v| acc.max((*v).max(0.0)));
459 let rank_tol = MEASURE_JET_PSEUDOINVERSE_RTOL * (n.max(1) as f64) * lam_max;
460 let mut scaled = evecs.clone();
461 for (k, mut col) in scaled.axis_iter_mut(Axis(1)).enumerate() {
462 let lam = evals[k].max(0.0);
463 let inv = if lam > rank_tol { 1.0 / lam } else { 0.0 };
464 col.mapv_inplace(|v| v * inv);
465 }
466 Ok(scaled.dot(&evecs.t()))
467}
468
469/// Rank-adapted center values of the measure-jet energy's affine null space.
470///
471/// The first column is the constant. The remaining columns are the supported
472/// ambient-linear directions returned by [`measure_jet_affine_head_transform`].
473/// Using that transform makes the basis full-column-rank even when the centers
474/// lie on a lower-dimensional affine stratum of the ambient coordinates.
475fn measure_jet_affine_value_basis(
476 centers: ArrayView2<'_, f64>,
477 masses: ArrayView1<'_, f64>,
478) -> Array2<f64> {
479 // The SAME object the design's head block is built from, evaluated at the
480 // centers. Sharing one construction is what makes "the head spans exactly
481 // the energy's null space" a property of the code rather than a comment
482 // two call sites have to keep agreeing on (#2751).
483 let lift = measure_jet_affine_head_lift(centers, masses);
484 measure_jet_affine_head_block(centers, lift.view())
485}
486
487/// Mass-metric quadratic form selecting the affine/null component of center
488/// function values:
489///
490/// `H₀ = W A (Aᵀ W A)⁺ Aᵀ W`.
491///
492/// This is a function-space object: `vᵀH₀v` is the squared mass norm of the
493/// affine projection of the center values `v`. No coefficient metric enters.
494fn affine_function_nullspace_form(
495 centers: ArrayView2<'_, f64>,
496 masses: ArrayView1<'_, f64>,
497) -> Result<Array2<f64>, BasisError> {
498 let m = centers.nrows();
499 if masses.len() != m {
500 crate::bail_dim_basis!(
501 "measure-jet affine function-space form shape mismatch: centers {:?}, masses {}",
502 centers.dim(),
503 masses.len()
504 );
505 }
506 let affine = measure_jet_affine_value_basis(centers, masses);
507 let mut weighted_affine = affine.clone();
508 for (i, mut row) in weighted_affine.outer_iter_mut().enumerate() {
509 row.mapv_inplace(|v| v * masses[i]);
510 }
511 let affine_gram = affine.t().dot(&weighted_affine);
512 let affine_gram_pinv = symmetric_pseudoinverse(&affine_gram, "affine function-space Gram")?;
513 let form = weighted_affine
514 .dot(&affine_gram_pinv)
515 .dot(&weighted_affine.t());
516 Ok((&form + &form.t()) * 0.5)
517}
518
519/// Pull a center-value quadratic form back through an evaluation map.
520fn pullback_center_form(evaluation: &Array2<f64>, form: &Array2<f64>) -> Array2<f64> {
521 let pulled = evaluation.t().dot(form).dot(evaluation);
522 (&pulled + &pulled.t()) * 0.5
523}
524
525/// Energy factor `F` of a symmetric PSD center-value form: `FᵀF = H` on `H`'s
526/// positive part, rows `√λ_k · u_kᵀ`.
527///
528/// Every eigenvalue that is positive is kept — no rank tolerance enters, so
529/// this is an exact factorization of `H⁺` rather than a truncation. `H` here is
530/// always the output of [`measure_jet_energy_form`] or the affine projector,
531/// both of which are PSD by construction, so the dropped part is roundoff.
532fn psd_energy_factor(form: &Array2<f64>, context: &str) -> Result<Array2<f64>, BasisError> {
533 let m = form.nrows();
534 if m == 0 {
535 return Ok(Array2::<f64>::zeros((0, 0)));
536 }
537 let sym = (form + &form.t()) * 0.5;
538 let (evals, evecs) = sym.eigh(Side::Lower).map_err(|e| {
539 BasisError::InvalidInput(format!(
540 "measure-jet energy factorization `{context}` eigendecomposition failed: {e}"
541 ))
542 })?;
543 let kept: Vec<usize> = evals
544 .iter()
545 .enumerate()
546 .filter_map(|(index, &value)| (value > 0.0).then_some(index))
547 .collect();
548 let mut factor = Array2::<f64>::zeros((kept.len(), m));
549 for (row, index) in kept.into_iter().enumerate() {
550 let scale = evals[index].sqrt();
551 for column in 0..m {
552 factor[[row, column]] = scale * evecs[[column, index]];
553 }
554 }
555 Ok(factor)
556}
557
558/// Pull a PSD center-value form back through an evaluation map CONSTRUCTIVELY:
559/// `EᵀHE = (F E)ᵀ (F E)` for `FᵀF = H`.
560///
561/// The dense route (`pullback_center_form` + `try_from_dense_psd`) is a false
562/// refusal waiting to happen and #2761 measured it firing: the Gaussian
563/// representers go collinear as the range ℓ grows, `E`'s condition number blows
564/// up, and the triple product loses exactly the digits that keep the smallest
565/// eigenvalue non-negative. On `measure_jet_perf_parity` the whole design then
566/// refuses to build for every `ℓ ≳ 2.8` —
567///
568/// ```text
569/// ell 2.15059 builds reml -1279.00091366 (still descending)
570/// ell 2.79577 REFUSED min eigenvalue -9.266e-9
571/// ell 7.98500 REFUSED min eigenvalue -2.864e-5
572/// ```
573///
574/// — while the criterion is still descending at the last `ℓ` that builds, which
575/// is what the outer search reports as `StepSizeTooSmall after 50 attempt(s)`:
576/// its descent direction points into a region where the objective cannot be
577/// EVALUATED. `-4e-5` relative on a Frobenius-normalized matrix is cancellation,
578/// not negative curvature, and the refusal's own guidance says so ("supply the
579/// native energy factor for a PSD function penalty").
580///
581/// Going through the factor makes PSD-ness structural instead of a numerical
582/// accident, and never squares `E`'s condition number in the middle product.
583/// The module's null-component penalty already worked this way
584/// ([`affine_function_nullspace_quadratic`]); the energy Primary was the
585/// sibling that did not.
586fn constructive_pullback_center_form(
587 evaluation: &Array2<f64>,
588 form: &Array2<f64>,
589 context: &str,
590) -> Result<ConstructiveQuadratic, BasisError> {
591 let factor = psd_energy_factor(form, context)?;
592 ConstructiveQuadratic::from_energy_factor(factor.dot(evaluation), context)
593}
594
595/// Congruence of a jet onto a frame: `F (Fᵀ J F) Fᵀ`.
596///
597/// This is the exact derivative of `R(ψ) = N M(ψ) Nᵀ`, `M = Nᵀ S(ψ) N`, for a
598/// ψ-FIXED frame `N` — which is what a declared structural null frame is. An
599/// empty frame gives an exact zero, matching a rebuild that declined.
600fn restrict_jet_to_frame(jet: &Array2<f64>, frame: &Array2<f64>) -> Array2<f64> {
601 if frame.ncols() == 0 {
602 return Array2::<f64>::zeros(jet.dim());
603 }
604 let inner = frame.t().dot(jet).dot(frame);
605 let restricted = frame.dot(&inner).dot(&frame.t());
606 (&restricted + &restricted.t()) * 0.5
607}
608
609/// Frobenius scale of a constructive quadratic, with the same degenerate
610/// convention `normalize_penalty` uses: a scale at or below `1e-12` reports
611/// `1e-12` so the division can never blow up.
612fn constructive_frobenius_scale(quadratic: &ConstructiveQuadratic) -> f64 {
613 quadratic
614 .dense()
615 .iter()
616 .map(|value| value * value)
617 .sum::<f64>()
618 .sqrt()
619 .max(1e-12)
620}
621
622/// First and diagonal-second `u = ln ℓ` derivatives of `E(u)ᵀ H E(u)` for a
623/// `u`-invariant center-value form `H`.
624fn pullback_center_form_log_length_jets(
625 evaluation: &Array2<f64>,
626 evaluation_first: &Array2<f64>,
627 evaluation_second: &Array2<f64>,
628 form: &Array2<f64>,
629) -> (Array2<f64>, Array2<f64>) {
630 let h_e = form.dot(evaluation);
631 let h_e_first = form.dot(evaluation_first);
632 let h_e_second = form.dot(evaluation_second);
633 let first_raw = evaluation_first.t().dot(&h_e) + evaluation.t().dot(&h_e_first);
634 let second_raw = evaluation_second.t().dot(&h_e)
635 + evaluation.t().dot(&h_e_second)
636 + evaluation_first.t().dot(&h_e_first) * 2.0;
637 (
638 (&first_raw + &first_raw.t()) * 0.5,
639 (&second_raw + &second_raw.t()) * 0.5,
640 )
641}
642
643/// Mixed derivative `∂²(EᵀH(ψ)E)/(∂lnℓ ∂ψ)` when only `E` depends on `ℓ`.
644fn pullback_center_form_log_length_cross(
645 evaluation: &Array2<f64>,
646 evaluation_first: &Array2<f64>,
647 form_first: &Array2<f64>,
648) -> Array2<f64> {
649 let h_e = form_first.dot(evaluation);
650 let h_e_first = form_first.dot(evaluation_first);
651 let cross_raw = evaluation_first.t().dot(&h_e) + evaluation.t().dot(&h_e_first);
652 (&cross_raw + &cross_raw.t()) * 0.5
653}
654
655/// The Primary energy's structural null frame in whatever coefficient chart
656/// `z` realizes: the coefficients whose REPRESENTER block vanishes, i.e. the
657/// pure ambient-affine-head directions.
658///
659/// This is a theorem of the construction, not a measurement, and it is the
660/// reason the double-penalty topology can be ψ-invariant (#2445's mechanism,
661/// applied here for #2761):
662///
663/// * the energy annihilates ambient-affine center values EXACTLY (the module's
664/// no-mass / exact-affine-projection contract), so a coefficient whose center
665/// values are `head_cc·b_head` — pure affine — is annihilated for EVERY `ℓ`;
666/// * the single-scale gauge restricts the representer block to `null(AᵀW K_cc)`,
667/// so its center values are mass-orthogonal to the affine space. A nonzero
668/// representer part therefore cannot land in the energy's null space:
669/// `K_cc z_rbf b_rbf ∈ A ∩ A^⊥ = {0}` forces `z_rbf b_rbf = 0`.
670///
671/// So `null(Primary) = { b : (z·b)|representer rows = 0 }` exactly, in the
672/// fit-time chart AND in any composed frozen chart, because both statements are
673/// about `z` alone. Nothing here reads `ℓ`.
674///
675/// Without the declaration the topology is decided by a rank test on the
676/// pullback `kzᵀ Q kz`, whose numerical rank falls as the range grows and the
677/// representers go collinear (measured on a 1-D 50-center term: rank 47 of 48 at
678/// the auto seed, 14 of 48 at 8x it). A design-moving `ℓ` then adds or removes
679/// the double-penalty ridge between outer trials — the `incremental realizer
680/// topology changed` abort, and the `#860` penalty-count desync class.
681///
682/// Returns `None` in multiscale mode, where there is no affine head: the
683/// energy's null space is then a genuinely `ℓ`-dependent subspace of the
684/// representer block, and declaring an EMPTY frame would assert the opposite of
685/// the truth (that the Primary has no null space at all) and silently delete the
686/// null component. Measuring is the honest fallback there.
687fn measure_jet_primary_structural_null_frame(
688 z: &Array2<f64>,
689 representer_count: usize,
690 head_rank: usize,
691) -> Result<Option<Array2<f64>>, BasisError> {
692 if head_rank == 0 || representer_count == 0 || z.ncols() == 0 {
693 return Ok(None);
694 }
695 // `rrqr_nullspace_basis(B)` returns `null(Bᵀ)`, so pass the transpose of the
696 // representer rows to get the coefficient-space null vectors.
697 let representer_rows = z.slice(ndarray::s![..representer_count, ..]).to_owned();
698 let (frame, _) =
699 rrqr_nullspace_basis(&representer_rows.t().to_owned(), default_rrqr_rank_alpha())
700 .map_err(BasisError::LinalgError)?;
701 // An EMPTY frame is a declaration, not a missing one: it says the chart has
702 // absorbed every affine-head direction, so the Primary has no null space
703 // here. That is the answer in a composed frozen chart, where the global
704 // parametric orthogonalization has already taken the head — and it is
705 // exactly the chart every ψ trial rebuilds in. Returning `None` there would
706 // send the topology decision back to the rank test the declaration exists
707 // to replace, reinstating the ℓ-dependence on the one path that cannot
708 // tolerate it. `None` is reserved for "this construction does not apply"
709 // (multiscale, no head).
710 Ok(Some(frame))
711}
712
713/// Fixed-rank constructive witness for the affine/null quadratic in center-value
714/// space. Rank is decided here, where `H₀` is independent of `ℓ`, rather than
715/// after its coefficient pullback has acquired `ℓ`-dependent roundoff modes.
716fn affine_function_nullspace_center_quadratic(
717 centers: ArrayView2<'_, f64>,
718 masses: ArrayView1<'_, f64>,
719) -> Result<ConstructiveQuadratic, BasisError> {
720 ConstructiveQuadratic::try_from_dense_psd(
721 affine_function_nullspace_form(centers, masses)?,
722 "measure-jet affine center-value form",
723 )
724}
725
726fn affine_function_nullspace_quadratic(
727 evaluation: &Array2<f64>,
728 centers: ArrayView2<'_, f64>,
729 masses: ArrayView1<'_, f64>,
730) -> Result<ConstructiveQuadratic, BasisError> {
731 if evaluation.nrows() != centers.nrows() {
732 crate::bail_dim_basis!(
733 "measure-jet affine function-space penalty shape mismatch: evaluation {:?}, centers {:?}",
734 evaluation.dim(),
735 centers.dim()
736 );
737 }
738 let center_quadratic = affine_function_nullspace_center_quadratic(centers, masses)?;
739 ConstructiveQuadratic::from_energy_factor(
740 center_quadratic.factor().dot(evaluation),
741 "measure-jet affine/null coefficient penalty",
742 )
743}
744
745/// Pairwise squared distances `‖a_i − b_j‖²` via the GEMM identity
746/// `‖a − b‖² = ‖a‖² + ‖b‖² − 2·aᵀb`: one (n×d)·(d×m) matrix product carries
747/// every FMA at tile speed instead of n·m scalar distance loops — the
748/// machine-native form of this kernel, and the module's ONLY distance
749/// source (representer design, support curve, and the center-pair geometry:
750/// band floor, median spacing, ε/2-net, neighbor cutoffs). The cancellation
751/// error near-coincident points pay is O(ε_f64·‖x‖²) ABSOLUTE, harmless
752/// under a Gaussian profile (the kernel is flat at d ≈ 0); clamped at zero
753/// so roundoff cannot emit tiny negatives (the a = b diagonal therefore
754/// lands at roundoff scale, not an exact 0 — no caller pins it).
755pub(crate) fn pairwise_sq_dists(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Array2<f64> {
756 let an: Vec<f64> = a.outer_iter().map(|r| r.dot(&r)).collect();
757 let bn: Vec<f64> = b.outer_iter().map(|r| r.dot(&r)).collect();
758 let mut g = a.dot(&b.t());
759 g.axis_iter_mut(Axis(0))
760 .into_par_iter()
761 .enumerate()
762 .for_each(|(i, mut row)| {
763 for (j, v) in row.iter_mut().enumerate() {
764 *v = (an[i] + bn[j] - 2.0 * *v).max(0.0);
765 }
766 });
767 g
768}
769
770/// Row-block size for streaming GEMM passes that must not materialize the
771/// full n×m distance matrix (nearest-node assignment): 64Ki rows × m ≤ a
772/// few hundred MB of transient per block, GEMM-speed throughout.
773pub(crate) const MEASURE_JET_ASSIGN_BLOCK_ROWS: usize = 65_536;
774
775pub(crate) fn validate_finite_points(
776 points: ArrayView2<'_, f64>,
777 what: &str,
778) -> Result<(), BasisError> {
779 for (i, row) in points.outer_iter().enumerate() {
780 if row.iter().any(|v| !v.is_finite()) {
781 crate::bail_invalid_basis!("measure-jet {what} row {i} has a non-finite coordinate");
782 }
783 }
784 Ok(())
785}
786
787/// Median nearest-OTHER-center distance — the resolution floor of the center
788/// quadrature, used for the band floor and the auto representer range.
789pub(crate) fn median_nearest_center_spacing(dist2: &Array2<f64>) -> Result<f64, BasisError> {
790 let m = dist2.nrows();
791 if m < 2 {
792 return Err(BasisError::InsufficientColumnsForConstraint { found: m });
793 }
794 let mut nearest: Vec<f64> = Vec::with_capacity(m);
795 for i in 0..m {
796 let mut best = f64::INFINITY;
797 for j in 0..m {
798 if j != i && dist2[(i, j)] < best {
799 best = dist2[(i, j)];
800 }
801 }
802 nearest.push(best.sqrt());
803 }
804 nearest.sort_by(|a, b| a.partial_cmp(b).expect("finite center spacings"));
805 let median = nearest[nearest.len() / 2];
806 if !(median.is_finite() && median > 0.0) {
807 crate::bail_invalid_basis!(
808 "measure-jet centers are degenerate (median nearest-center spacing = {median}); \
809 duplicate centers cannot carry a scale band"
810 );
811 }
812 Ok(median)
813}
814
815/// Make the representer coefficient section a BASIS of its span at the realized
816/// range, not merely a spanning set (gam#2750).
817///
818/// ## What goes wrong without this
819///
820/// A Gaussian kernel's spectrum decays super-exponentially in `ℓ/spacing`, so
821/// the center evaluation map `E = K_cc · Z_rbf` — through which BOTH the design
822/// and every penalty pullback pass — loses conditioning fast as the representer
823/// range grows. The raw section is chosen for head-orthogonality alone and
824/// keeps every direction, so the shipped chart carries directions the criterion
825/// cannot resolve, and the criterion's own Occam term is the first casualty:
826///
827/// ```text
828/// ell cond(S) d log|S|+/d ln ell: analytic FD gap
829/// 1.21 1.2e10 -109.843 -109.829 -0.014
830/// 1.57 3.0e11 -107.924 -107.919 -0.005
831/// 2.04 3.7e13 -97.252 -96.331 -0.922
832/// 2.65 3.8e13 -51.836 -64.765 +12.930
833/// 3.45 6.5e12 (rank 14 -> 13) -26.621 -40.633 +14.012
834/// ```
835///
836/// (16 centers on the `measure_jet_perf_parity` geometry, chart frozen so the
837/// finite difference and the analytic jet differentiate the same object; the
838/// jet itself agrees with a central difference of the shipped penalty to
839/// `1e-8` throughout, so the producer is exact and it is `log|S|₊` that stops
840/// being a function.) Once `cond(S)` reaches the rank-classification floor the
841/// kept spectrum bottoms out in roundoff: `log|S|₊` is then a sum over
842/// directions whose logs are noise, `tr(S⁺Ṡ)` divides by them, and the outer
843/// search is handed a direction that is not a descent direction of its own
844/// objective. That is the wall the `ln ℓ` coordinate has been hitting.
845///
846/// ## The section
847///
848/// Whiten against the section's own center evaluation map,
849/// `E = K_cc Z = U Σ Vᵀ`: take `Z ← Z · V · diag(1/max(σ, √ε·‖K_cc‖))`, so the
850/// realized section satisfies `EᵀE = I` wherever `E` is resolvable and is
851/// *damped rather than deleted* below that. Two questions, two bars, and they
852/// are not the same question — but they share one anchor, and the anchor is
853/// `‖K_cc‖₂`, the norm of the operator whose product formed `E`, never `E`'s
854/// own largest singular value (which collapses; see the table at the bars):
855///
856/// * **Does the direction exist?** `σ > ε·‖K_cc‖·max(dim)` — the backward-error
857/// bar of forming `E = K_cc·Z`. Below it there is no direction, only the
858/// roundoff of that product.
859/// * **How far may it be amplified?** `1/σ` is also the factor by which
860/// direction `i` amplifies the design's own roundoff, and the criterion
861/// squares the design into `XᵀWX`, so the amplification survives that Gram
862/// with significant digits exactly when `ε·(‖K_cc‖/σ_i)² < 1`. Hence the
863/// scaling — not the membership — is floored at `√ε·‖K_cc‖`.
864///
865/// **Damping instead of deleting is what makes the width `ℓ`-invariant.** A
866/// span is invariant under ANY invertible diagonal rescaling, so a damped
867/// direction contributes exactly the same column space as an undamped one; it
868/// simply enters the design with a small norm instead of an amplified one, and
869/// carries its own roundoff in at that same small norm. The realized chart
870/// therefore keeps every direction `E` has, at every `ℓ`, while
871/// `cond(E·W) ≤ √ε·cond(E)` is bounded by construction.
872///
873/// * The rescaling is a pure change of coefficient chart. The profiled
874/// criterion is invariant under an invertible reparameterization (`X → XT`,
875/// `S → TᵀST` moves `log|XᵀWX + λS|` and `log|λS|₊` by the same
876/// `2 ln|det T|`), so it changes no estimate — only the arithmetic.
877/// * **The whitening removes the squaring from the PENALTY, exactly.** With
878/// `EᵀE = I` the energy pullback is `S = UᵀQU` with `U` orthonormal, whose
879/// spectrum is bounded by `Q`'s; and `Q` is `ℓ`-INVARIANT (a form on center
880/// VALUES), so `cond(S)` stops being a function of the range at all.
881/// Measured, re-realizing the chart at each `ℓ` on the #2761 fixture:
882/// `cond(E) = 1.0`, `cond(S₊) = 21.1`, `log|S|₊ = 0.800` from `1×` to `16×`
883/// the seed range. So "the energy pullback squares `E`" — the reason the
884/// previous cut gave for being where it was — does not survive its own
885/// remedy.
886/// * **What the whitening does NOT remove is the squaring in `XᵀWX`.** The
887/// chart lives at the CENTERS; the design lives at the DATA, and `1/σ_i` is
888/// also the factor by which direction `i` amplifies the design's own
889/// roundoff. The criterion squares the design, so a retained direction
890/// survives with significant digits exactly when `ε·(σ_max/σ_i)² < 1`. That
891/// is where the half-mantissa belongs, and it is one half-mantissa, not two.
892/// * **The previous cut spent the half-mantissa twice, on an already-squared
893/// quantity — and spent it by DELETING.** It whitened against `G = EᵀE` and
894/// cut at `√ε·λ_max(G)`; since `λ = σ²` that is `σ > ε^{1/4}·σ_max`, i.e.
895/// `cond(E) ≤ ε^{-1/4} ≈ 8·10³`. Measured on the #2761 fixture at 16 centers,
896/// that deleted most of the span at the ranges REML actively selects:
897///
898/// ```text
899/// ℓ/ℓ_seed cond(E) ε^{1/4} DELETE: p span floor √ε DAMP: p span floor
900/// 1 3.0e+01 12 6.11e-2 12 6.11e-2
901/// 2 2.8e+04 11 2.43e-2 12 1.94e-2
902/// 4 4.2e+07 8 1.50e-2 12 2.10e-3
903/// 8 9.1e+09 6 1.67e-2 12 1.81e-4 <- 92x
904/// 16 2.7e+11 4 8.92e-2 12 3.54e-5
905/// ```
906///
907/// `span floor` is the least-squares residual RMSE of the NOISELESS truth on
908/// the realized design's own column span — the bound no `λ` can beat, since
909/// `λ` shrinks inside a span and never moves one. Note the `ε^{1/4}` floor
910/// going UP past `4×`: that chart is worse than the seed range it was meant
911/// to improve on. The damped column keeps the whole span (`1.81e-4` at `8×`
912/// reproduces an 80-digit projection of the same span to every digit) at a
913/// width that does not move with the dial, which a DELETING bar cannot do at
914/// any threshold — `12,11,8,6,4` for `ε^{1/4}` and `12,12,12,11,10` even for
915/// `√ε`.
916///
917/// Realized per cold build from `K_cc(ℓ)`, so the chart tracks the dial it is a
918/// chart for; a frozen-quadrature replay reuses the composed transform verbatim.
919fn condition_representer_section(
920 k_cc: &Array2<f64>,
921 z_rbf: &Array2<f64>,
922) -> Result<Array2<f64>, BasisError> {
923 if z_rbf.ncols() == 0 {
924 return Ok(z_rbf.clone());
925 }
926 let evaluation = k_cc.dot(z_rbf);
927 let (_, singular, right) = evaluation.svd(false, true).map_err(BasisError::LinalgError)?;
928 let leading = singular.iter().copied().fold(0.0_f64, f64::max);
929 if !(leading.is_finite() && leading > 0.0) {
930 return Ok(z_rbf.clone());
931 }
932 // `right` is `Vᵀ`: row `i` is `σ_i`'s right singular vector, in the
933 // coefficient coordinates of `z_rbf`.
934 let right = right.ok_or_else(|| {
935 BasisError::LinalgError(gam_linalg::faer_ndarray::FaerLinalgError::SvdNoConvergence {
936 context: "measure-jet representer section: right singular vectors were not returned",
937 })
938 })?;
939 // Both bars are anchored on `‖K_cc‖₂`, NOT on `σ_max(E)`. `E = K_cc·Z` with
940 // orthonormal `Z`, so the computed `E` carries a backward error
941 // `O(ε·‖K_cc‖₂)` whatever `E`'s own size turns out to be — and `σ_max(E)`
942 // is not a fixed fraction of `‖K_cc‖₂`: it COLLAPSES as `ℓ` grows, because
943 // the constraint that makes `Z` head-orthogonal is exactly what annihilates
944 // the flat limit `K_cc → 𝟙𝟙ᵀ`. Measured on the 1-D sweep fixture (50
945 // centers, standardized `x`):
946 //
947 // ```text
948 // ℓ ‖K_cc‖₂ σ_max(E) σ_max/‖K‖ σ_min(E)
949 // 0.05 2.4 2.29e+0 9.6e-1 5.5e-2
950 // 0.55 17.2 8.79e+0 5.1e-1 3.2e-16 <- σ_min is the roundoff
951 // 6.09 48.4 1.49e-2 3.1e-4 4.5e-17 floor, and it is FLAT
952 // 49.73 50.0 3.48e-6 7.0e-8 3.5e-17
953 // ```
954 //
955 // A bar relative to `σ_max(E)` therefore falls BELOW that flat roundoff
956 // floor once the range is long, and admits the floor itself as signal: at
957 // `ℓ = 11` a `ε·σ_max·dim` bar is `1.5e-17` against entries of `1.7e-17`,
958 // so all 48 directions "pass" and the chart hands the fit 48 columns of
959 // pure rounding noise. Those columns fit anything, so the criterion has a
960 // spurious minimum out there — measured at `V = −447.8` on this fixture's
961 // sweep case 1 against `≈ −44` in the honest region, which is precisely
962 // where its outer search was terminating.
963 let dimension_factor = evaluation.nrows().max(evaluation.ncols()) as f64;
964 let (_, kernel_singular, _) = k_cc.svd(false, false).map_err(BasisError::LinalgError)?;
965 let anchor = kernel_singular
966 .iter()
967 .copied()
968 .fold(0.0_f64, f64::max)
969 .max(leading);
970 // Membership: the backward-error bar of the product that formed `E`. Below
971 // it there is no direction, only the roundoff of `K_cc·Z`.
972 let existence = anchor * f64::EPSILON * dimension_factor;
973 // Amplification: ONE half-mantissa, spent ONCE. `1/σ_i` is the factor by
974 // which direction `i` lifts the design's own roundoff — which is set by
975 // `‖K_xc‖ ∼ ‖K_cc‖`, not by `σ_max(E)` — and the criterion squares the
976 // design into `XᵀWX`, so the lift survives that Gram with significant
977 // digits exactly when `ε·(‖K_cc‖/σ_i)² < 1`. Below `√ε·‖K_cc‖` the
978 // direction is DAMPED to that scaling rather than dropped: the span is
979 // unchanged (any invertible rescaling spans the same columns) and the
980 // roundoff comes in at the damped norm instead of an amplified one.
981 //
982 // This also reproduces the range bracket's own physics for free. As `ℓ`
983 // passes the node diameter, `σ_max(E)` falls under the floor and the WHOLE
984 // representer block damps smoothly toward zero, leaving the affine head —
985 // which is exactly `MeasureJetRangeBracket::ceiling`'s statement that past
986 // there "the block is numerically one function plus the affine head and
987 // there is no distinct model past it", now realized by the arithmetic
988 // rather than asserted next to it.
989 let amplification_floor = anchor * f64::EPSILON.sqrt();
990 // Visibility: a damped direction enters the energy pullback
991 // `S = (E·W)ᵀQ(E·W)` with squared weight `(σ_i/floor)²`, so if that weight
992 // drops below the canonical penalty-spectrum rank cutoff the direction is
993 // classified UNPENALIZED — an accidentally free design direction, which is
994 // the opposite of conservative. It also makes `log|S|₊` a step function of
995 // `ℓ`: measured on the 1-D sweep fixture, the primary's nullity flapping by
996 // one moved the profiled criterion by `8.5` at fixed `λ`, which is what a
997 // `ln ℓ` line search cannot cross.
998 //
999 // So the chart's retention bar is at least as strict as the penalty's own:
1000 // keep direction `i` only while `(σ_i/floor)² > tol`, with `tol` the same
1001 // `spectral_tolerance` convention (#1425's single classifier) every other
1002 // penalty-spectrum consumer reads. No second constant, and the two
1003 // decisions can no longer disagree about which directions are penalized.
1004 let rank_tolerance =
1005 z_rbf.ncols().max(1) as f64 * super::bspline_build::SPECTRAL_RANK_RELATIVE_TOLERANCE;
1006 let visibility = amplification_floor * rank_tolerance.sqrt();
1007 let retention = existence.max(visibility);
1008 let kept: Vec<usize> = (0..singular.len())
1009 .filter(|&i| singular[i] > retention)
1010 .collect();
1011 let kept = if kept.is_empty() {
1012 // Every representer direction is below the resolvable floor. Keep the
1013 // single strongest one rather than emitting an empty block: a term with
1014 // no representer columns is a different model, and that decision
1015 // belongs to the range screen, not to a conditioning step.
1016 vec![(0..singular.len()).fold(0usize, |best, i| {
1017 if singular[i] > singular[best] { i } else { best }
1018 })]
1019 } else {
1020 kept
1021 };
1022 let mut transform = Array2::<f64>::zeros((z_rbf.ncols(), kept.len()));
1023 for (column, &index) in kept.iter().enumerate() {
1024 let inverse = singular[index]
1025 .max(amplification_floor)
1026 .max(f64::MIN_POSITIVE)
1027 .recip();
1028 // Sign gauge: a singular vector is defined up to sign, and the sign a
1029 // decomposition happens to return is not a property of the geometry.
1030 // Pin it on the entry of largest magnitude so the realized chart is
1031 // reproducible across faer revisions and platforms.
1032 let mut pivot = 0usize;
1033 for row in 1..z_rbf.ncols() {
1034 if right[(index, row)].abs() > right[(index, pivot)].abs() {
1035 pivot = row;
1036 }
1037 }
1038 let sign = if right[(index, pivot)] < 0.0 { -1.0 } else { 1.0 };
1039 for row in 0..z_rbf.ncols() {
1040 transform[(row, column)] = sign * right[(index, row)] * inverse;
1041 }
1042 }
1043 Ok(z_rbf.dot(&transform))
1044}
1045
1046/// Axis-aligned bounding-box diagonal of a point set — the deterministic
1047/// diameter proxy the scale band and the range bracket both measure the
1048/// configuration's extent with. `O(m·d)` and permutation-invariant, unlike a
1049/// max-pairwise-distance scan.
1050pub(crate) fn bounding_box_diagonal(points: ArrayView2<'_, f64>) -> f64 {
1051 let mut diag2 = 0.0_f64;
1052 for k in 0..points.ncols() {
1053 let col = points.column(k);
1054 let mut lo = f64::INFINITY;
1055 let mut hi = f64::NEG_INFINITY;
1056 for &v in col.iter() {
1057 lo = lo.min(v);
1058 hi = hi.max(v);
1059 }
1060 if lo.is_finite() && hi.is_finite() {
1061 diag2 += (hi - lo) * (hi - lo);
1062 }
1063 }
1064 diag2.sqrt()
1065}
1066
1067/// The deterministic bracket the representer range `ℓ` is SCREENED over before
1068/// the outer ψ search refines it, in the STANDARDIZED frame the basis is
1069/// realized in (gam#2750).
1070///
1071/// ## Why a bracket exists at all
1072///
1073/// `ℓ` is a design-moving coordinate: it decides WHICH span the representers
1074/// occupy, and the outer search reaches it by local descent from the seed. The
1075/// profiled criterion in `ln ℓ` is not unimodal — as `ℓ` grows past a few
1076/// center spacings the Gaussian columns become collinear, the rank-revealing
1077/// identifiability section drops columns, and the criterion steps. Measured on
1078/// `measure_jet_formula_fit_robustness_sweep` seed 1 (n = 200, one sine cycle):
1079/// a local minimum at the auto range `ℓ = 0.020` (`V = −234.5`), a barrier at
1080/// `ℓ = 0.035` (`V = −231.1`), and the GLOBAL minimum at `ℓ = 0.80`
1081/// (`V = −256.3`) — 21.7 log units deeper, with held-out RMSE `0.0084` against
1082/// `0.0175`, i.e. the same basis fitting 2.1× better and beating `tp` on both
1083/// the criterion and the truth instead of losing on both. A local descent
1084/// seeded inside the first basin cannot cross that barrier, and the frozen
1085/// coefficient chart the ψ trials rebuild in stops being evaluable ~1.6× past
1086/// the seed, so the search terminates essentially where it started. The λ that
1087/// comes back is then a faithful readout of a range nothing could move — which
1088/// is the "1-D fits select a too-large λ" this bracket exists to end.
1089///
1090/// ## Why THIS bracket
1091///
1092/// The nodes are the term's own realized scale band, verbatim. That is not a
1093/// coincidence of convenience: the energy's `ε` and the representer range `ℓ`
1094/// are the same physical quantity — a length in the chart — and the band is
1095/// already derived, not chosen: its floor is the median nearest-node spacing
1096/// (below it neighbouring representers stop overlapping and the design is a
1097/// bump-per-node indicator with no partition of unity) and its ceiling is half
1098/// the node bounding-box diagonal, at the band's own auto-clamped resolution.
1099/// So the screen introduces no length, no count and no step of its own.
1100///
1101/// ## Where a walk past the top node stops (#2761)
1102///
1103/// [`MeasureJetRangeBracket::feasibility_ceiling`] — the range at which the
1104/// closest node pair stops being distinguishable in the chart's own arithmetic,
1105/// [`measure_jet_range_feasibility_ceiling`]. It is the SAME wall
1106/// [`measure_jet_ln_range_window`] gives the outer search, and it is the same
1107/// wall for the same reason: a stopping rule may not be tighter than the model.
1108///
1109/// It used to be [`MeasureJetRangeBracket::node_diameter`], on the argument
1110/// that at `ℓ` that long every pair of representers overlaps at `≥ exp(−1/2)`
1111/// so "there is no distinct model past it". That argument is measurably wrong,
1112/// and the tree said so in two places before this: `measure_jet_ln_range_window`
1113/// records that *"the profiled criterion genuinely prefers a range AT or ABOVE
1114/// the node diameter"* on three fixtures, and
1115/// `the_search_window_reaches_past_where_the_screen_stops_walking` pins the
1116/// search window as strictly wider. Those reconcile only while something else
1117/// keeps searching past the stopping rule. On a term whose `ℓ` dial is FROZEN —
1118/// the BMS marginal/slope pair, or any `learn_length_scale=false` — nothing
1119/// does, and the stopping rule becomes the wall.
1120///
1121/// Measured on the #1041 parity fixture (`m = 10`, extent `[2.671, 2.726]`):
1122/// band `[1.08074, 1.43607, 1.90823]`, `log_step = 0.284265`, node diameter
1123/// `3.81645`. The screen's chosen range for the marginal surface was `3.36930`
1124/// — walk node 2 to every printed digit, with walk node 3 at `4.47708` past the
1125/// diameter. The walk pushes a node and only then breaks if it failed to
1126/// improve, so an argmin that IS the last pushed node improved: the walk left
1127/// through the ceiling test with the criterion still descending.
1128///
1129/// **What raising the stop was actually worth here, measured after the change,
1130/// because it is less than the shape of the defect suggests.** The walk now
1131/// scores `4.47708`, which does NOT improve — so the criterion has an interior
1132/// optimum on this fixture and the old ceiling happened to cut just past it.
1133/// What the extra node buys is the PARABOLIC REFINEMENT, which cannot fire on
1134/// an argmin that is the last element: with a neighbour on both sides the
1135/// refinement lands at `ℓ = 3.10543` with a better criterion value, and
1136/// held-out marginal RMSE goes `0.04185 → 0.04179`. A rule that cannot be
1137/// stepped past also cannot be refined at, and that is the part of the cost
1138/// that was invisible.
1139///
1140/// The larger held-out number on the same sweep — `0.03788` at `ℓ = 68.5`,
1141/// where the block still carries `edf = 7.47` and is not degenerate — is NOT
1142/// what this change recovers, and attributing it to the ceiling would be wrong:
1143/// the criterion does not want to go there. That gap is a statement about the
1144/// screening CRITERION (a profiled Gaussian REML of the term alone against a
1145/// binary response) disagreeing with held-out truth at long ranges, and it
1146/// belongs to whoever takes that question next.
1147///
1148/// Raising the stop is safe by the walk's own rule, which only continues while
1149/// the criterion improves: on the gam#2750 fixture, where the criterion drops
1150/// from `−256.3` to `−198.5` just past the diameter, the walk stops on the
1151/// first non-improving node exactly as it does today. The ceiling only ever
1152/// binds where the criterion is still descending — which is precisely the case
1153/// where stopping is wrong.
1154#[derive(Clone, Debug)]
1155pub struct MeasureJetRangeBracket {
1156 /// Geometric grid of candidate ranges, ascending. The realized scale band.
1157 pub nodes: Vec<f64>,
1158 /// The band's own log step, so an endpoint walk keeps its resolution.
1159 pub log_step: f64,
1160 /// The node bounding-box diagonal. A geometric fact about the cloud,
1161 /// reported because the band's own ceiling is half of it; NOT a stopping
1162 /// rule (see the type docs).
1163 pub node_diameter: f64,
1164 /// Hard upper end for any walk past the top node: the feasibility wall
1165 /// [`measure_jet_range_feasibility_ceiling`], the same one the outer
1166 /// search's [`measure_jet_ln_range_window`] stops at.
1167 pub feasibility_ceiling: f64,
1168}
1169
1170/// The range at which a node pair separated by `spacing` stops being
1171/// DISTINGUISHABLE from a coincident one in `f64`.
1172///
1173/// `exp(−spacing²/2ℓ²)` has come within `√ε` of 1 at
1174/// `ℓ = spacing/√(2√ε)`; past it `K_cc` is the all-ones matrix to working
1175/// precision and the gauge annihilates exactly that (the affine span, constant
1176/// included), so no distinct model survives. `√ε` is the chart's own bar — the
1177/// same half-mantissa `condition_representer_section` spends, and for the same
1178/// reason: it is the point past which a direction cannot survive being squared
1179/// into a Gram and inverted back out.
1180///
1181/// ONE definition, used by both the outer search's window
1182/// ([`measure_jet_ln_range_window`]) and the response screen's walk stop
1183/// ([`MeasureJetRangeBracket::feasibility_ceiling`]), so the two cannot drift
1184/// into disagreeing about where the model ends (#2761).
1185pub fn measure_jet_range_feasibility_ceiling(spacing: f64) -> f64 {
1186 spacing / (2.0 * f64::EPSILON.sqrt()).sqrt()
1187}
1188
1189/// Realize [`MeasureJetRangeBracket`] for a fresh (unfrozen) measure-jet spec.
1190///
1191/// `data` must already be in the standardized frame the basis is built in, and
1192/// the spec must be the FRESH one (no frozen quadrature): the bracket is a
1193/// seeding device and a frozen term has nothing left to seed.
1194pub fn measure_jet_range_bracket(
1195 data: ArrayView2<'_, f64>,
1196 spec: &MeasureJetBasisSpec,
1197) -> Result<MeasureJetRangeBracket, BasisError> {
1198 if spec.frozen_quadrature.is_some() {
1199 crate::bail_invalid_basis!(
1200 "measure-jet range bracket is a seeding device; a frozen-quadrature spec has no seed left to choose"
1201 );
1202 }
1203 if data.ncols() == 0 {
1204 crate::bail_invalid_basis!("measure-jet range bracket needs at least one feature column");
1205 }
1206 validate_finite_points(data, "data")?;
1207 let seed_centers = select_centers_by_strategy(data, &spec.center_strategy)?;
1208 if seed_centers.nrows() < 3 {
1209 return Err(BasisError::InsufficientColumnsForConstraint {
1210 found: seed_centers.nrows(),
1211 });
1212 }
1213 let (nodes, _masses) = measure_jet_quadrature_nodes(data, seed_centers.view())?;
1214 let band = measure_jet_band(nodes.view(), spec.num_scales)?;
1215 // The band floor IS the median nearest-node spacing, so the feasibility
1216 // wall is read off the bracket's own first node rather than remeasured.
1217 let feasibility_ceiling = measure_jet_range_feasibility_ceiling(band.eps[0]);
1218 Ok(MeasureJetRangeBracket {
1219 nodes: band.eps,
1220 log_step: band.log_step,
1221 node_diameter: bounding_box_diagonal(nodes.view()),
1222 feasibility_ceiling,
1223 })
1224}
1225
1226/// The `ln ℓ` SEARCH WINDOW for the design-moving representer range, in the
1227/// frame `spec.length_scale` is expressed in (gam#2750).
1228///
1229/// ## Why this is not an absolute interval
1230///
1231/// `ℓ` is a LENGTH in the chart the basis is realized in, so a window for it is
1232/// a statement about the node cloud, not about `f64`. Both ends here are the
1233/// same measured length — the median nearest-node spacing `s`, which is also
1234/// [`realized_measure_jet_length_scale`]'s auto value and
1235/// [`MeasureJetBand::eps`]'s floor — read at the two ranges where the kernel
1236/// stops saying anything about the pair it separates:
1237///
1238/// * **floor `ℓ = s`.** Neighbouring representers overlap at exactly
1239/// `exp(−1/2)`. Below it they stop overlapping, the design degenerates from a
1240/// partition of unity into a bump-per-node indicator, and rows between nodes
1241/// fall outside every representer's support.
1242/// * **ceiling `ℓ = s/√(2√ε)`.** The same neighbouring pair's kernel value
1243/// `exp(−s²/2ℓ²)` has come within `√ε` of 1, so the pair is no longer
1244/// DISTINGUISHABLE from a coincident one in the arithmetic the chart is built
1245/// in. Past it `K_cc` is the all-ones matrix to working precision, and the
1246/// gauge annihilates exactly that (the affine span, constant included), so
1247/// there is no distinct model left. `√ε` is the chart's own bar — the same
1248/// half-mantissa `condition_representer_section` spends, and for the same
1249/// reason: it is the point past which a direction cannot survive being
1250/// squared into a Gram and inverted back out.
1251///
1252/// The window is therefore `[ln s, ln s − ½ln(2√ε)]`: it TRANSLATES with the
1253/// chart (both ends are proportional to a measured length, so an isotropic
1254/// rescale by `c` shifts them both by `ln c`) and its WIDTH is `8.664`, a pure
1255/// function of `f64::EPSILON` rather than a number anybody picked.
1256///
1257/// ## What this deliberately is NOT
1258///
1259/// It is **not** [`MeasureJetRangeBracket::node_diameter`], the node bounding-box
1260/// diagonal. That is where the response SCREEN stops walking, which is a
1261/// stopping rule for a search over nodes, not a wall in the model: measured on
1262/// three fixtures (`measure_jet_formula_fit_robustness_sweep` seed 1,
1263/// `measure_jet_web_quality`, and the two probes that score them), the profiled
1264/// criterion genuinely prefers a range AT or ABOVE the node diameter, and a box
1265/// that stopped there railed the outer search and refused the fit. A long range
1266/// is a legitimate model — as `ℓ` grows the gauge-quotiented representer span
1267/// tends to a polynomial one, which is exactly the right basis for a smooth
1268/// target — so the upper end has to be a feasibility statement and nothing
1269/// weaker.
1270pub fn measure_jet_ln_range_window(
1271 data: ArrayView2<'_, f64>,
1272 spec: &MeasureJetBasisSpec,
1273) -> Result<(f64, f64), BasisError> {
1274 let spacing = match (&spec.center_strategy, &spec.frozen_quadrature) {
1275 (CenterStrategy::UserProvided(_), Some(frozen)) if !frozen.eps_band.is_empty() => {
1276 frozen.eps_band[0]
1277 }
1278 _ => measure_jet_range_bracket(data, spec)?.nodes[0],
1279 };
1280 if !(spacing.is_finite() && spacing > 0.0) {
1281 crate::bail_invalid_basis!(
1282 "measure-jet ln-range window is degenerate: the node cloud reports a nearest-node \
1283 spacing of {spacing}, so it has no range scale to search over"
1284 );
1285 }
1286 // The range at which `1 - exp(-s^2/2l^2)` reaches the chart's half-mantissa
1287 // bar, from the single definition the screen's walk stop also reads.
1288 let ceiling = measure_jet_range_feasibility_ceiling(spacing);
1289 Ok((spacing.ln(), ceiling.ln()))
1290}
1291
1292/// Build the realized geometric scale band from the center set: floor at the
1293/// median nearest-center spacing (below it the quadrature resolves nothing),
1294/// ceiling at half the bounding-box diagonal (a deterministic diameter-scale
1295/// cap; local fits remain center-weighted and distinct there).
1296/// `num_scales == 0` requests the auto count `clamp(⌈log2(ε_max/ε_min)⌉ + 1,
1297/// 3, 8)`; a degenerate band (ceiling ≤ floor) collapses to the single floor
1298/// scale with `log_step = ln 2`.
1299pub fn measure_jet_band(
1300 centers: ArrayView2<'_, f64>,
1301 num_scales: usize,
1302) -> Result<MeasureJetBand, BasisError> {
1303 validate_finite_points(centers, "centers")?;
1304 let dist2 = pairwise_sq_dists(centers, centers);
1305 let eps_min = median_nearest_center_spacing(&dist2)?;
1306 // Half the bounding-box diagonal: a cheap, deterministic diameter proxy.
1307 let eps_max = 0.5 * bounding_box_diagonal(centers);
1308 if !(eps_max.is_finite() && eps_max > eps_min) {
1309 return Ok(MeasureJetBand {
1310 eps: vec![eps_min],
1311 log_step: std::f64::consts::LN_2,
1312 });
1313 }
1314 let auto = ((eps_max / eps_min).log2().ceil() as usize + 1)
1315 .clamp(MEASURE_JET_MIN_AUTO_SCALES, MEASURE_JET_MAX_AUTO_SCALES);
1316 let count = if num_scales == 0 { auto } else { num_scales };
1317 if count == 1 {
1318 return Ok(MeasureJetBand {
1319 eps: vec![eps_min],
1320 log_step: std::f64::consts::LN_2,
1321 });
1322 }
1323 let ratio = (eps_max / eps_min).powf(1.0 / (count as f64 - 1.0));
1324 let mut eps = Vec::with_capacity(count);
1325 let mut e = eps_min;
1326 for _ in 0..count {
1327 eps.push(e);
1328 e *= ratio;
1329 }
1330 Ok(MeasureJetBand {
1331 eps,
1332 log_step: ratio.ln(),
1333 })
1334}
1335
1336/// First-moment-exact quadrature of the empirical measure on the cell partition
1337/// induced by the seed centers: nearest-center assignment (deterministic
1338/// tie-break: lowest center index) yields per-cell masses, and each non-empty
1339/// cell's quadrature node is its mass-weighted barycenter. Empty cells keep
1340/// their seed coordinates with zero mass (the assembly skips them; their
1341/// representer columns remain valid).
1342pub fn measure_jet_quadrature_nodes(
1343 data: ArrayView2<'_, f64>,
1344 centers: ArrayView2<'_, f64>,
1345) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
1346 if data.ncols() != centers.ncols() {
1347 crate::bail_dim_basis!(
1348 "measure-jet mass assignment dimension mismatch: data d={} centers d={}",
1349 data.ncols(),
1350 centers.ncols()
1351 );
1352 }
1353 validate_finite_points(data, "data")?;
1354 validate_finite_points(centers, "centers")?;
1355 let n = data.nrows();
1356 let m = centers.nrows();
1357 let d = centers.ncols();
1358 if n == 0 || m == 0 {
1359 crate::bail_invalid_basis!("measure-jet mass assignment needs nonempty data and centers");
1360 }
1361 // Nearest-node assignment in streamed GEMM blocks: argmin_j ‖x−c_j‖² =
1362 // argmin_j (‖c_j‖² − 2·xᵀc_j), so each block is one (rows×d)·(d×m)
1363 // product plus a row-wise argmin — tile-speed FMAs, O(block·m) transient
1364 // memory, deterministic ties to the lowest center index.
1365 let cn: Vec<f64> = centers.outer_iter().map(|r| r.dot(&r)).collect();
1366 let assignments: Vec<usize> = (0..n)
1367 .step_by(MEASURE_JET_ASSIGN_BLOCK_ROWS)
1368 .flat_map(|start| {
1369 let end = (start + MEASURE_JET_ASSIGN_BLOCK_ROWS).min(n);
1370 let g = data.slice(ndarray::s![start..end, ..]).dot(¢ers.t());
1371 let block: Vec<usize> = g
1372 .axis_iter(Axis(0))
1373 .into_par_iter()
1374 .map(|row| {
1375 let mut best_j = 0usize;
1376 let mut best = f64::INFINITY;
1377 for (j, &gij) in row.iter().enumerate() {
1378 let s = cn[j] - 2.0 * gij;
1379 if s < best {
1380 best = s;
1381 best_j = j;
1382 }
1383 }
1384 best_j
1385 })
1386 .collect();
1387 block
1388 })
1389 .collect();
1390 let mut masses = Array1::<f64>::zeros(m);
1391 let mut nodes = centers.to_owned();
1392 let mut sums = Array2::<f64>::zeros((m, d));
1393 let unit = 1.0 / n as f64;
1394 for (i, &j) in assignments.iter().enumerate() {
1395 masses[j] += unit;
1396 for k in 0..d {
1397 sums[(j, k)] += data[(i, k)];
1398 }
1399 }
1400 // Cell barycenters: the first moment of μ on each cell. These are the
1401 // realized nodes for first-moment-exact lumping.
1402 let mut barycenter = sums;
1403 for j in 0..m {
1404 let count = masses[j] * n as f64;
1405 if count > 0.0 {
1406 for k in 0..d {
1407 barycenter[(j, k)] /= count;
1408 nodes[(j, k)] = barycenter[(j, k)];
1409 }
1410 }
1411 }
1412 Ok((nodes, masses))
1413}
1414
1415/// THE single assembly source: walk every (scale, outer-net center) local
1416/// residual block exactly once and scatter it into `n_forms` accumulators
1417/// with caller-chosen scalar weights. The energy, its (s, α) jets, and the
1418/// per-scale spectrum are all this routine with different weight closures,
1419/// so a value/derivative desync is structurally impossible.
1420///
1421/// Per block the closure receives `(scale_idx, eps, q, base)` where `q` is
1422/// the truncated kernel sum used by the local residual and `base`
1423/// is the fully-assembled outer weight
1424/// `log_step · ε^(−η) · net_mass_i · q^(1−2α)`, with
1425/// `η = 2s + d(2−2α)` for the available dimension parameter, and writes, per requested
1426/// form, one weight triple `[w_R, w_2, w_3]`. Only `w_R` is live:
1427/// `R = CᵀWC − B·G⁺·Bᵀ/q`, with `G⁺` the rank-revealing pseudo-inverse.
1428/// The extra slots are retained for the ψ layout and receive zero local
1429/// channels because τ no longer changes the energy.
1430///
1431/// The outer sum over centers is coarsened per scale to a deterministic
1432/// ε/2-net with nearest-member mass aggregation (the outer Riemann sum needs
1433/// resolution ε, not the center-spacing floor), so each scale's cost sits at
1434/// its own level and the band totals ~O(m²·d) instead of O(L·m³). The inner
1435/// (local-fit) quadrature always uses the full center set, so the local
1436/// residual identities (exact constant annihilation, PSD) are untouched.
1437pub(crate) fn assemble_weighted_forms<F>(
1438 centers: ArrayView2<'_, f64>,
1439 masses: ArrayView1<'_, f64>,
1440 band: &MeasureJetBand,
1441 order_s: f64,
1442 alpha: f64,
1443 tau0: f64,
1444 n_forms: usize,
1445 channels: usize,
1446 weights: &F,
1447) -> Result<Vec<Array2<f64>>, BasisError>
1448where
1449 F: Fn(usize, f64, f64, f64, &mut [[f64; 3]]) + Sync,
1450{
1451 let m = centers.nrows();
1452 let d = centers.ncols();
1453 if n_forms == 0 || !(1..=3).contains(&channels) {
1454 crate::bail_invalid_basis!(
1455 "measure-jet assembly needs at least one output form and 1..=3 block channels"
1456 );
1457 }
1458 if masses.len() != m {
1459 crate::bail_dim_basis!(
1460 "measure-jet energy mass/center mismatch: {} masses for {} centers",
1461 masses.len(),
1462 m
1463 );
1464 }
1465 if band.eps.is_empty() || band.eps.iter().any(|e| !(e.is_finite() && *e > 0.0)) {
1466 crate::bail_invalid_basis!("measure-jet energy needs a nonempty positive scale band");
1467 }
1468 if !(order_s.is_finite() && order_s > 0.0 && order_s < 2.0) {
1469 crate::bail_invalid_basis!(
1470 "measure-jet order s must lie in (0, 2) for the affine-jet energy; got {order_s}"
1471 );
1472 }
1473 if !(alpha.is_finite() && tau0.is_finite() && tau0 >= 0.0) {
1474 crate::bail_invalid_basis!(
1475 "measure-jet energy needs finite alpha and finite tau0 >= 0; got alpha={alpha}, tau0={tau0}"
1476 );
1477 }
1478 if masses.iter().any(|v| !(v.is_finite() && *v >= 0.0)) {
1479 crate::bail_invalid_basis!("measure-jet energy needs finite nonnegative center masses");
1480 }
1481 let dist2 = pairwise_sq_dists(centers, centers);
1482
1483 // One block of `n_forms` m×m accumulators per scale. Each scale's center
1484 // loop is sequential and the cross-scale sum below runs in band order,
1485 // so the result is bit-deterministic whether or not the scales
1486 // themselves run in parallel.
1487 let assemble_scale = |scale_idx: usize, eps: f64| -> Result<Vec<Array2<f64>>, BasisError> {
1488 let mut out: Vec<Array2<f64>> =
1489 (0..n_forms).map(|_| Array2::<f64>::zeros((m, m))).collect();
1490 let cutoff2 = (MEASURE_JET_PROFILE_CUTOFF * eps) * (MEASURE_JET_PROFILE_CUTOFF * eps);
1491 let inv_two_eps2 = 1.0 / (2.0 * eps * eps);
1492 let eta = 2.0 * order_s + (d as f64) * (2.0 - 2.0 * alpha);
1493 let scale_weight = band.log_step * eps.powf(-eta);
1494 // Outer-quadrature coarsening: greedy ε/2-net over the centers in
1495 // fixed index order (deterministic), with every center's mass
1496 // aggregated to its nearest net member (lowest-index tie break).
1497 let net_radius2 = 0.25 * eps * eps;
1498 let mut outer: Vec<usize> = Vec::new();
1499 for i in 0..m {
1500 if masses[i] <= 0.0 {
1501 continue;
1502 }
1503 let covered = outer.iter().any(|&o| dist2[(i, o)] <= net_radius2);
1504 if !covered {
1505 outer.push(i);
1506 }
1507 }
1508 let mut net_mass = vec![0.0_f64; m];
1509 for i in 0..m {
1510 if masses[i] <= 0.0 {
1511 continue;
1512 }
1513 let mut best = f64::INFINITY;
1514 let mut best_o = usize::MAX;
1515 for &o in &outer {
1516 if dist2[(i, o)] < best {
1517 best = dist2[(i, o)];
1518 best_o = o;
1519 }
1520 }
1521 if best_o != usize::MAX {
1522 net_mass[best_o] += masses[i];
1523 }
1524 }
1525 let mut wbuf = vec![[0.0_f64; 3]; n_forms];
1526 for &i in &outer {
1527 // Local neighbor set (always includes i itself).
1528 let mut idx: Vec<usize> = Vec::new();
1529 for j in 0..m {
1530 if dist2[(i, j)] <= cutoff2 {
1531 idx.push(j);
1532 }
1533 }
1534 let ml = idx.len();
1535 // Kernel weights and mass.
1536 let mut w = Array1::<f64>::zeros(ml);
1537 let mut q = 0.0_f64;
1538 for (a, &j) in idx.iter().enumerate() {
1539 let wj = masses[j] * (-dist2[(i, j)] * inv_two_eps2).exp();
1540 w[a] = wj;
1541 q += wj;
1542 }
1543 if !(q > 0.0) {
1544 continue;
1545 }
1546 // Scaled local features Φ (ml × d) and weighted column means a.
1547 let mut phi = Array2::<f64>::zeros((ml, d));
1548 for (a, &j) in idx.iter().enumerate() {
1549 for k in 0..d {
1550 phi[(a, k)] = (centers[(j, k)] - centers[(i, k)]) / eps;
1551 }
1552 }
1553 let a_mean = phi.t().dot(&w) / q;
1554 // B = WΦ − w·aᵀ and G = (ΦᵀWΦ)/q − a·aᵀ.
1555 let mut wphi = phi.clone();
1556 for (a, mut row) in wphi.outer_iter_mut().enumerate() {
1557 row.mapv_inplace(|v| v * w[a]);
1558 }
1559 let mut b = wphi.clone();
1560 for (a, mut row) in b.outer_iter_mut().enumerate() {
1561 for k in 0..d {
1562 row[k] -= w[a] * a_mean[k];
1563 }
1564 }
1565 let mut g = phi.t().dot(&wphi);
1566 g.mapv_inplace(|v| v / q);
1567 for r in 0..d {
1568 for c in 0..d {
1569 g[(r, c)] -= a_mean[r] * a_mean[c];
1570 }
1571 }
1572 let g_pinv = symmetric_pseudoinverse(&g, "local affine Gram")?;
1573 let bm = b.dot(&g_pinv);
1574 let base = scale_weight * net_mass[i] * q.powf(1.0 - 2.0 * alpha);
1575 weights(scale_idx, eps, q, base, &mut wbuf);
1576 // Scatter-add Σ_k wbuf[k]·R into each form. The τ channels are
1577 // zero because the exact projection is τ-independent.
1578 for (a, &ja) in idx.iter().enumerate() {
1579 let bma = bm.row(a);
1580 for (c, &jc) in idx.iter().enumerate() {
1581 let b_c = b.row(c);
1582 let mut val_r = -w[a] * w[c] / q - bma.dot(&b_c) / q;
1583 if a == c {
1584 val_r += w[a];
1585 }
1586 for (k, out_k) in out.iter_mut().enumerate() {
1587 let wk = wbuf[k];
1588 out_k[(ja, jc)] += wk[0] * val_r;
1589 }
1590 }
1591 }
1592 }
1593 Ok(out)
1594 };
1595
1596 let n_scales = band.eps.len();
1597 let parallel_ok = m
1598 .saturating_mul(m)
1599 .saturating_mul(n_scales)
1600 .saturating_mul(n_forms)
1601 <= MEASURE_JET_PARALLEL_FORM_BUDGET_DOUBLES;
1602 let per_scale: Vec<Vec<Array2<f64>>> = if parallel_ok {
1603 band.eps
1604 .par_iter()
1605 .enumerate()
1606 .map(|(scale_idx, &eps)| assemble_scale(scale_idx, eps))
1607 .collect::<Result<Vec<_>, BasisError>>()?
1608 } else {
1609 band.eps
1610 .iter()
1611 .enumerate()
1612 .map(|(scale_idx, &eps)| assemble_scale(scale_idx, eps))
1613 .collect::<Result<Vec<_>, BasisError>>()?
1614 };
1615
1616 let mut totals: Vec<Array2<f64>> = (0..n_forms).map(|_| Array2::<f64>::zeros((m, m))).collect();
1617 for scale_forms in per_scale {
1618 for (total, part) in totals.iter_mut().zip(scale_forms) {
1619 *total += ∂
1620 }
1621 }
1622 // Numerical symmetrization (every analytic form here is symmetric).
1623 Ok(totals.into_iter().map(|t| (&t + &t.t()) * 0.5).collect())
1624}
1625
1626/// The multiscale jet-residual energy `Q` (m × m, symmetric PSD) on the
1627/// center set. See the module docs for the formula and contracts; the local
1628/// residual form is assembled through the closed-form identities
1629///
1630/// ```text
1631/// CᵀWC = W − w·wᵀ/q,
1632/// B = CᵀWΦ̃ = WΦ − w·aᵀ (a = Φᵀw/q),
1633/// G = Φ̃ᵀWΦ̃/q = (ΦᵀWΦ)/q − a·aᵀ,
1634/// R_loc = CᵀWC − B·G⁺·Bᵀ/q,
1635/// ```
1636///
1637/// with `G⁺` realized through the symmetric eigendecomposition and a
1638/// machine-precision rank cutoff. One walk of `assemble_weighted_forms`
1639/// with the unit weight.
1640pub fn measure_jet_energy_form(
1641 centers: ArrayView2<'_, f64>,
1642 masses: ArrayView1<'_, f64>,
1643 band: &MeasureJetBand,
1644 order_s: f64,
1645 alpha: f64,
1646 tau0: f64,
1647) -> Result<Array2<f64>, BasisError> {
1648 let mut forms = assemble_weighted_forms(
1649 centers,
1650 masses,
1651 band,
1652 order_s,
1653 alpha,
1654 tau0,
1655 1,
1656 1,
1657 &|_, _, _, base, out: &mut [[f64; 3]]| out[0] = [base, 0.0, 0.0],
1658 )?;
1659 let q = forms.swap_remove(0);
1660 // The energy `Q = Σ wᵢ Rᵢ` is a nonnegative combination of analytically
1661 // PSD local residual forms, so it is PSD in exact arithmetic. The affine
1662 // span is annihilated to machine zero, where roundoff in the per-block
1663 // pseudo-inverse and the centering cancellation leaves the smallest
1664 // eigenvalue at ±ε_mach·‖Q‖. Project onto the PSD cone (floor negative
1665 // eigenvalues at 0) so `vᵀQv ≥ 0` holds exactly for every `v`, including
1666 // the affine directions the energy must annihilate.
1667 project_symmetric_psd(q, "measure-jet energy form")
1668}
1669
1670/// Project a symmetric matrix onto the PSD cone by flooring its negative
1671/// eigenvalues at 0. Only sub-machine-precision negative eigenvalues are
1672/// expected here (the form is analytically PSD); a meaningfully negative
1673/// eigenvalue would indicate an assembly bug, so it is floored but the
1674/// reconstruction otherwise preserves the spectrum exactly.
1675pub(crate) fn project_symmetric_psd(
1676 a: Array2<f64>,
1677 label: &str,
1678) -> Result<Array2<f64>, BasisError> {
1679 let n = a.nrows();
1680 if n == 0 {
1681 return Ok(a);
1682 }
1683 let (evals, evecs) = a.eigh(Side::Lower).map_err(|e| {
1684 BasisError::InvalidInput(format!(
1685 "measure-jet PSD projection `{label}` eigendecomposition failed: {e}"
1686 ))
1687 })?;
1688 if evals.iter().all(|&lam| lam >= 0.0) {
1689 return Ok(a);
1690 }
1691 let mut scaled = evecs.clone();
1692 for (k, mut col) in scaled.axis_iter_mut(Axis(1)).enumerate() {
1693 let lam = evals[k].max(0.0);
1694 col.mapv_inplace(|v| v * lam);
1695 }
1696 let psd = scaled.dot(&evecs.t());
1697 Ok((&psd + &psd.t()) * 0.5)
1698}
1699
1700/// The per-scale energy forms `Q_ℓ` (each m × m, symmetric PSD), with
1701/// `Σ_ℓ Q_ℓ = Q` to the PSD-projection floor (same blocks, one-hot weights).
1702/// These are the spectral-split carriers: emitted as separate penalty
1703/// candidates they let the multi-penalty REML engine learn per-level amplitudes
1704/// λ_ℓ directly — scale adaptivity at ρ-speed with no rebuild and no new
1705/// optimizer code.
1706///
1707/// Each level is projected onto the PSD cone for the same reason the fused
1708/// [`measure_jet_energy_form`] is, and the projection is load-bearing HERE in a
1709/// way it is not there — see the note at the return.
1710pub fn measure_jet_energy_forms_per_scale(
1711 centers: ArrayView2<'_, f64>,
1712 masses: ArrayView1<'_, f64>,
1713 band: &MeasureJetBand,
1714 order_s: f64,
1715 alpha: f64,
1716 tau0: f64,
1717) -> Result<Vec<Array2<f64>>, BasisError> {
1718 let n_scales = band.eps.len();
1719 let forms = assemble_weighted_forms(
1720 centers,
1721 masses,
1722 band,
1723 order_s,
1724 alpha,
1725 tau0,
1726 n_scales,
1727 1,
1728 &|scale_idx, _, _, base, out: &mut [[f64; 3]]| {
1729 for (k, slot) in out.iter_mut().enumerate() {
1730 *slot = if k == scale_idx {
1731 [base, 0.0, 0.0]
1732 } else {
1733 [0.0, 0.0, 0.0]
1734 };
1735 }
1736 },
1737 )?;
1738 // PSD cone projection, per level. Every `Q_ℓ` is a NONNEGATIVE combination
1739 // of the same analytically-PSD local residual blocks the fused energy sums,
1740 // so it is PSD in exact arithmetic and only the per-block pseudo-inverse and
1741 // centering cancellation put a ±ε_mach·‖Q_ℓ‖ negative in the spectrum —
1742 // the identical situation `measure_jet_energy_form` floors on the cone.
1743 //
1744 // Skipping it here was NOT symmetric with the fused path, because the fused
1745 // path normalizes ONE matrix while the builder normalizes EVERY LEVEL BY ITS
1746 // OWN Frobenius scale. A level whose detail energy is numerically dead
1747 // carries only that roundoff, and dividing roundoff by its own tiny norm
1748 // rescales it to unit norm: a ±ε_mach relative negative becomes an O(1)
1749 // absolute one. `ConstructiveQuadratic::try_from_dense_psd` then rejects the
1750 // candidate and the whole multiscale BUILD fails — measured as
1751 // `IndefinitePenalty { context: "measure-jet scale penalty",
1752 // min_eigenvalue: -0.3039, tolerance: 1.486e-8 }`, where the tolerance is
1753 // √ε_mach against a max |λ| of ~1, i.e. the certified matrix is already
1754 // unit-normalized and the negative is 2e7× tolerance. No real detail
1755 // spectrum is 30% negative; that is normalized roundoff.
1756 //
1757 // Flooring restores the invariant the signature documents, leaves a dead
1758 // level as an exact-zero candidate for `filter_penalty_candidates`/REML to
1759 // deselect rather than a fatal build error, and preserves `Σ_ℓ Q_ℓ = Q` to
1760 // the same machine-precision floor the fused projection already accepts. It
1761 // also stops `measure_jet_scale_spectrum` from being able to report a
1762 // NEGATIVE detail energy `vᵀQ_ℓv`.
1763 forms
1764 .into_iter()
1765 .enumerate()
1766 .map(|(level, q_l)| {
1767 project_symmetric_psd(q_l, &format!("measure-jet per-scale energy form {level}"))
1768 })
1769 .collect()
1770}
1771
1772/// The support diagnostic `ε ↦ q_ε(x★)`: kernel mass of the (frozen) center
1773/// quadrature seen from each query point at every band scale (n_query × L).
1774/// A query ON the web sees its strand's mass already at fine scales; a query
1775/// OFF the web accumulates mass only once ε reaches its distance to the
1776/// support. This is the on-web-ness statistic shipped alongside predictions
1777/// — smooth, multiresolution, derived from the measure with no neighbor
1778/// sets.
1779pub fn measure_jet_support_curve(
1780 queries: ArrayView2<'_, f64>,
1781 centers: ArrayView2<'_, f64>,
1782 masses: ArrayView1<'_, f64>,
1783 eps_band: &[f64],
1784) -> Result<Array2<f64>, BasisError> {
1785 if queries.ncols() != centers.ncols() {
1786 crate::bail_dim_basis!(
1787 "measure-jet support curve dimension mismatch: queries d={} centers d={}",
1788 queries.ncols(),
1789 centers.ncols()
1790 );
1791 }
1792 if masses.len() != centers.nrows() {
1793 crate::bail_dim_basis!(
1794 "measure-jet support curve mass/center mismatch: {} masses for {} centers",
1795 masses.len(),
1796 centers.nrows()
1797 );
1798 }
1799 if eps_band.is_empty() || eps_band.iter().any(|e| !(e.is_finite() && *e > 0.0)) {
1800 crate::bail_invalid_basis!("measure-jet support curve needs a nonempty positive band");
1801 }
1802 validate_finite_points(queries, "queries")?;
1803 validate_finite_points(centers, "centers")?;
1804 let nq = queries.nrows();
1805 let nl = eps_band.len();
1806 // Distances once (GEMM), then every band scale reads the same d² row —
1807 // an L-fold saving over per-scale distance recomputation.
1808 let d2 = pairwise_sq_dists(queries, centers);
1809 let mut out = Array2::<f64>::zeros((nq, nl));
1810 out.axis_iter_mut(Axis(0))
1811 .into_par_iter()
1812 .enumerate()
1813 .for_each(|(qi, mut row)| {
1814 let d2_row = d2.row(qi);
1815 for (li, &eps) in eps_band.iter().enumerate() {
1816 let inv_two_eps2 = 1.0 / (2.0 * eps * eps);
1817 let mut acc = 0.0_f64;
1818 for (j, &dd) in d2_row.iter().enumerate() {
1819 acc += masses[j] * (-dd * inv_two_eps2).exp();
1820 }
1821 row[li] = acc;
1822 }
1823 });
1824 Ok(out)
1825}
1826
1827pub(crate) fn measure_jet_support_means(
1828 centers: ArrayView2<'_, f64>,
1829 masses: ArrayView1<'_, f64>,
1830 eps_band: &[f64],
1831) -> Result<Vec<f64>, BasisError> {
1832 let total_mass = masses.sum();
1833 if !(total_mass.is_finite() && total_mass > 0.0) {
1834 crate::bail_invalid_basis!(
1835 "measure-jet support means need positive finite total mass; got {total_mass}"
1836 );
1837 }
1838 let support = measure_jet_support_curve(centers, centers, masses, eps_band)?;
1839 let mut means = vec![0.0_f64; eps_band.len()];
1840 for (i, row) in support.rows().into_iter().enumerate() {
1841 let mass = masses[i];
1842 for (mean, &q) in means.iter_mut().zip(row.iter()) {
1843 *mean += mass * q;
1844 }
1845 }
1846 for mean in &mut means {
1847 *mean /= total_mass;
1848 if !(*mean).is_finite() || *mean <= 0.0 {
1849 crate::bail_invalid_basis!(
1850 "measure-jet support mean must be positive and finite; got {mean}"
1851 );
1852 }
1853 }
1854 Ok(means)
1855}
1856
1857/// Gaussian representer features `exp(−‖x − c‖²/(2ℓ²))` (n × m).
1858pub fn measure_jet_design_matrix(
1859 data: ArrayView2<'_, f64>,
1860 centers: ArrayView2<'_, f64>,
1861 length_scale: f64,
1862) -> Result<Array2<f64>, BasisError> {
1863 if data.ncols() != centers.ncols() {
1864 crate::bail_dim_basis!(
1865 "measure-jet design dimension mismatch: data d={} centers d={}",
1866 data.ncols(),
1867 centers.ncols()
1868 );
1869 }
1870 if !(length_scale.is_finite() && length_scale > 0.0) {
1871 crate::bail_invalid_basis!(
1872 "measure-jet design needs a positive finite length_scale; got {length_scale}"
1873 );
1874 }
1875 validate_finite_points(data, "data")?;
1876 validate_finite_points(centers, "centers")?;
1877 let inv_two_l2 = 1.0 / (2.0 * length_scale * length_scale);
1878 // One GEMM for every distance, then the Gaussian applied in place — the
1879 // n×m allocation IS the output, no transient copy.
1880 let mut out = pairwise_sq_dists(data, centers);
1881 out.axis_iter_mut(Axis(0))
1882 .into_par_iter()
1883 .for_each(|mut row| {
1884 row.mapv_inplace(|d2| (-d2 * inv_two_l2).exp());
1885 });
1886 Ok(out)
1887}
1888
1889/// Exact first and diagonal-second derivatives of the Gaussian representer
1890/// design with respect to `u = ln ℓ`.
1891fn measure_jet_design_log_length_jets(
1892 data: ArrayView2<'_, f64>,
1893 centers: ArrayView2<'_, f64>,
1894 length_scale: f64,
1895) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
1896 let kernel = measure_jet_design_matrix(data, centers, length_scale)?;
1897 let squared_distances = pairwise_sq_dists(data, centers);
1898 let inv_l2 = 1.0 / (length_scale * length_scale);
1899 let mut first = kernel.clone();
1900 let mut second = kernel;
1901 for ((first_value, second_value), &distance_squared) in first
1902 .iter_mut()
1903 .zip(second.iter_mut())
1904 .zip(squared_distances.iter())
1905 {
1906 let a = distance_squared * inv_l2;
1907 let kernel_value = *first_value;
1908 *first_value = kernel_value * a;
1909 *second_value = kernel_value * (a * a - 2.0 * a);
1910 }
1911 Ok((first, second))
1912}
1913
1914/// Rank-revealing ambient-linear head lift `T` (d × head_rank) for the
1915/// extrapolation null space (#1845).
1916///
1917/// The measure-jet energy annihilates ambient-affine functions EXACTLY (the
1918/// no-mass contract), so the affine functions are the penalty's null space —
1919/// the directions the fit is free to extend across a training gap. But the
1920/// Gaussian representer design cannot REPRESENT a global affine function off
1921/// its support: a finite sum of decaying bumps reverts to the parametric
1922/// backbone away from the centers, so in a gap the fit collapses toward the
1923/// training mean instead of carrying the flank-attested trend. Completing the
1924/// smoothing-spline structure, the builder appends this ambient-linear null
1925/// space to the design as an UNPENALIZED head (the `{x_1..x_d}` head the frame
1926/// notes §1 pin as the property the representer basis lacked).
1927///
1928/// The head is data-derived and magic-free. Ambient coordinates of data on a
1929/// low intrinsic-dimension stratum are rank-deficient as linear trends, so the
1930/// coordinate columns are orthonormalized on the centers and the
1931/// numerically-degenerate directions dropped. Working in the mean-CENTERED
1932/// coordinate columns (the mass-weighted mean is the intercept's, not the
1933/// head's) makes the rank test measure the genuine spread of the centers along
1934/// each direction rather than its offset; the relative floor
1935/// `MEASURE_JET_PSEUDOINVERSE_RTOL` is the module's own numerical rank
1936/// tolerance (the same one the local Gram pseudo-inverses use). The returned
1937/// `T` satisfies `linear_head(points) = points · T` (the mean-centering only
1938/// informs the keep/drop decision). `T` is a deterministic function of the
1939/// frozen centers + masses, so the frozen replay path reconstructs the
1940/// identical head with no persisted state.
1941///
1942/// This is the LINEAR half of the null space. The realized head block is the
1943/// whole affine null space `[1 | points·T]`; build it through
1944/// [`measure_jet_affine_head_lift`] + [`measure_jet_affine_head_block`], which
1945/// is what the design, the gauge and the null-component penalty all use. A
1946/// linear-only head is a defect, not an economy: the global parametric
1947/// orthogonalization removes ONE design direction, and if the term's null space
1948/// has no constant to give up, the direction it takes comes out of the null
1949/// space itself, leaving `d − 1` free linear directions instead of `d` (#2751).
1950pub fn measure_jet_affine_head_transform(
1951 centers: ArrayView2<'_, f64>,
1952 masses: ArrayView1<'_, f64>,
1953) -> Array2<f64> {
1954 let m = centers.nrows();
1955 let d = centers.ncols();
1956 let total_mass = masses.sum();
1957 // Mass inner product on center values.
1958 let mdot = |u: &Array1<f64>, v: &Array1<f64>| -> f64 {
1959 let mut acc = 0.0;
1960 for i in 0..m {
1961 acc += masses[i] * u[i] * v[i];
1962 }
1963 acc
1964 };
1965 // Mean-centered coordinate columns: the mass-weighted mean is removed so the
1966 // residual mass-norm is the genuine spread of the centers along a direction,
1967 // not dominated by the coordinate's offset (which the intercept owns).
1968 let cols: Vec<Array1<f64>> = (0..d)
1969 .map(|k| {
1970 let col = centers.column(k).to_owned();
1971 let mean = if total_mass > 0.0 {
1972 mdot(&col, &Array1::ones(m)) / total_mass
1973 } else {
1974 0.0
1975 };
1976 col.mapv(|x| x - mean)
1977 })
1978 .collect();
1979 // Relative numerical rank floor from the centered coordinate-column scale.
1980 let max_norm = cols
1981 .iter()
1982 .fold(0.0_f64, |acc, c| acc.max(mdot(c, c).sqrt()));
1983 let drop_below =
1984 (MEASURE_JET_PSEUDOINVERSE_RTOL * (d.max(1) as f64) * max_norm).max(f64::MIN_POSITIVE);
1985 // Mass-weighted modified Gram–Schmidt on the centered columns; `t`
1986 // accumulates the lift in the ORIGINAL coordinate basis, so every kept head
1987 // column is `points · t_r` (up to the intercept-owned constant).
1988 let mut q_cols: Vec<Array1<f64>> = Vec::new();
1989 let mut t_cols: Vec<Array1<f64>> = Vec::new();
1990 for k in 0..d {
1991 let mut v = cols[k].clone();
1992 let mut t = Array1::<f64>::zeros(d);
1993 t[k] = 1.0;
1994 for (q, tq) in q_cols.iter().zip(t_cols.iter()) {
1995 let proj = mdot(q, &v);
1996 v.scaled_add(-proj, q);
1997 t.scaled_add(-proj, tq);
1998 }
1999 let norm = mdot(&v, &v).sqrt();
2000 if norm > drop_below {
2001 v.mapv_inplace(|x| x / norm);
2002 t.mapv_inplace(|x| x / norm);
2003 q_cols.push(v);
2004 t_cols.push(t);
2005 }
2006 }
2007 let head_rank = t_cols.len();
2008 let mut t_mat = Array2::<f64>::zeros((d, head_rank));
2009 for (r, t) in t_cols.into_iter().enumerate() {
2010 t_mat.column_mut(r).assign(&t);
2011 }
2012 t_mat
2013}
2014
2015/// Affine head lift `T_aff` (`(d+1) × (1 + head_rank)`) acting on the augmented
2016/// point rows `[1 | x]`: column 0 is the constant, the rest are the supported
2017/// ambient-linear directions of [`measure_jet_affine_head_transform`].
2018///
2019/// This — not the linear lift alone — is the energy's null space. The energy
2020/// annihilates every AFFINE function of the centers exactly, constant included
2021/// (`affine_function_nullspace_form` projects onto exactly this span), so the
2022/// design block that carries the null space has to span the same thing.
2023///
2024/// The constant column looks redundant against the model intercept and is not.
2025/// The term-collection chokepoint residualizes every measure-jet design against
2026/// the parametric block and reparameterizes to `Z = null(1ᵀX)`, which removes
2027/// exactly one coefficient direction. The null space of the constrained penalty
2028/// is `{γ : Zγ ∈ null(S)}`, so that removal is charged to the null space unless
2029/// the null space contains the constraint's own direction. With a linear-only
2030/// head the term's null space is `span{x·T}`, the constant is nowhere in it,
2031/// and the centering deletes a LINEAR direction: on a 2-D fixture the surviving
2032/// direction is the accidental one with zero data-mean, and every REML fit that
2033/// selects a large energy λ collapses onto it (#2751, measured at Pearson
2034/// 0.705 = |cos 45°| against a planted `x1` plane). With the constant present
2035/// the centering consumes the constant — which the intercept re-supplies —
2036/// and all `head_rank` linear directions stay free. That is exactly how the
2037/// thin-plate/Duchon null space `{1, x_1..x_d}` behaves at the same chokepoint.
2038pub fn measure_jet_affine_head_lift(
2039 centers: ArrayView2<'_, f64>,
2040 masses: ArrayView1<'_, f64>,
2041) -> Array2<f64> {
2042 let linear = measure_jet_affine_head_transform(centers, masses);
2043 let d = centers.ncols();
2044 let mut lift = Array2::<f64>::zeros((d + 1, linear.ncols() + 1));
2045 lift[(0, 0)] = 1.0;
2046 lift.slice_mut(ndarray::s![1.., 1..]).assign(&linear);
2047 lift
2048}
2049
2050/// Realize the affine head block `[1 | points] · T_aff` for the lift returned
2051/// by [`measure_jet_affine_head_lift`]. A zero-column lift (multiscale mode,
2052/// which carries no head) yields a zero-column block.
2053pub fn measure_jet_affine_head_block(
2054 points: ArrayView2<'_, f64>,
2055 lift: ArrayView2<'_, f64>,
2056) -> Array2<f64> {
2057 let n = points.nrows();
2058 let width = lift.ncols();
2059 if width == 0 {
2060 return Array2::<f64>::zeros((n, 0));
2061 }
2062 let d = points.ncols();
2063 assert_eq!(
2064 lift.nrows(),
2065 d + 1,
2066 "affine head lift must have d+1 rows for d ambient coordinates"
2067 );
2068 let mut augmented = Array2::<f64>::ones((n, d + 1));
2069 augmented.slice_mut(ndarray::s![.., 1..]).assign(&points);
2070 augmented.dot(&lift)
2071}
2072
2073/// Resolve the realized representer range ℓ. An explicit positive
2074/// `spec_length_scale` is used verbatim; the `0.0` sentinel auto-initializes
2075/// from the median nearest-center spacing (one spacing width: neighbors
2076/// overlap at exp(−1/2) ≈ 0.61, smooth blend without collinearity).
2077pub fn realized_measure_jet_length_scale(
2078 centers: ArrayView2<'_, f64>,
2079 spec_length_scale: f64,
2080) -> Result<f64, BasisError> {
2081 if spec_length_scale.is_finite() && spec_length_scale > 0.0 {
2082 return Ok(spec_length_scale);
2083 }
2084 if spec_length_scale != 0.0 {
2085 crate::bail_invalid_basis!(
2086 "measure-jet length_scale must be positive (or 0.0 for auto); got {spec_length_scale}"
2087 );
2088 }
2089 let dist2 = pairwise_sq_dists(centers, centers);
2090 let spacing = median_nearest_center_spacing(&dist2)?;
2091 Ok(MEASURE_JET_AUTO_LENGTH_SCALE_FACTOR * spacing)
2092}
2093
2094/// The realized, ψ-FIXED geometry shared by the basis builder and the
2095/// ψ-derivative producer — ONE realization source, so the penalty the fit
2096/// uses and the penalty the ψ-channel differentiates can never drift apart
2097/// (the #901 desync class, excluded structurally).
2098pub(crate) struct RealizedMeasureJetGeometry {
2099 pub(crate) centers: Array2<f64>,
2100 pub(crate) masses: Array1<f64>,
2101 pub(crate) eps_band: Vec<f64>,
2102 pub(crate) log_step: f64,
2103 pub(crate) length_scale: f64,
2104 /// Assembly order for the energy weights: the realized default in
2105 /// per-level mode (absorbed per candidate by normalization), the
2106 /// explicit value in fused mode.
2107 pub(crate) order_s_eval: f64,
2108 /// Spectral-split mode marker (`order_s == 0.0` sentinel).
2109 pub(crate) per_level: bool,
2110 pub(crate) z: Array2<f64>,
2111 pub(crate) coefficient_gauge: gam_problem::Gauge,
2112 pub(crate) kz: Array2<f64>,
2113 /// Affine head lift `T_aff` ((d+1) × head_width): the energy's null space
2114 /// appended to the representer design (#1845), constant included (#2751).
2115 /// The head columns evaluate as `[1 | points] · T_aff`; empty
2116 /// (`(d+1) × 0`) in multiscale mode, which carries no head. Deterministic
2117 /// in the frozen centers + masses, so predict-time replay rebuilds it
2118 /// verbatim.
2119 pub(crate) head_lift: Array2<f64>,
2120}
2121
2122pub(crate) fn realize_measure_jet_geometry(
2123 data: ArrayView2<'_, f64>,
2124 spec: &MeasureJetBasisSpec,
2125) -> Result<RealizedMeasureJetGeometry, BasisError> {
2126 if data.ncols() == 0 {
2127 crate::bail_invalid_basis!("measure-jet smooth needs at least one feature column");
2128 }
2129 validate_finite_points(data, "data")?;
2130 let seed_centers = select_centers_by_strategy(data, &spec.center_strategy)?;
2131 let m = seed_centers.nrows();
2132 if m < 3 {
2133 return Err(BasisError::InsufficientColumnsForConstraint { found: m });
2134 }
2135 let order_s = if spec.order_s == 0.0 {
2136 MEASURE_JET_DEFAULT_ORDER_S
2137 } else {
2138 spec.order_s
2139 };
2140 // Quadrature realization. Fit path: the realized nodes are the cell
2141 // BARYCENTERS of the seed partition (first-moment-exact lumping of μ —
2142 // see `measure_jet_quadrature_nodes`), so the metadata's `centers` are
2143 // already the realized nodes and the frozen path (predict / ψ-trial,
2144 // `CenterStrategy::UserProvided`) replays them verbatim with the frozen
2145 // masses, band, support anchors, and normalization scales.
2146 let (centers, masses, eps_band, log_step) = match &spec.frozen_quadrature {
2147 Some(frozen) => {
2148 if frozen.masses.len() != m {
2149 crate::bail_dim_basis!(
2150 "frozen measure-jet quadrature mismatch: {} masses for {} centers",
2151 frozen.masses.len(),
2152 m
2153 );
2154 }
2155 if frozen.eps_band.is_empty() {
2156 crate::bail_invalid_basis!("frozen measure-jet quadrature has an empty band");
2157 }
2158 let log_step = if frozen.eps_band.len() >= 2 {
2159 (frozen.eps_band[1] / frozen.eps_band[0]).ln()
2160 } else {
2161 std::f64::consts::LN_2
2162 };
2163 (
2164 seed_centers,
2165 frozen.masses.clone(),
2166 frozen.eps_band.clone(),
2167 log_step,
2168 )
2169 }
2170 None => {
2171 let (nodes, masses) = measure_jet_quadrature_nodes(data, seed_centers.view())?;
2172 let band = measure_jet_band(nodes.view(), spec.num_scales)?;
2173 (nodes, masses, band.eps, band.log_step)
2174 }
2175 };
2176 let length_scale = realized_measure_jet_length_scale(centers.view(), spec.length_scale)?;
2177 // Affine extrapolation head (#1845): the raw center space becomes
2178 // `[ m Gaussian representers | head_width affine columns ]`. The head
2179 // carries the penalty's affine null space explicitly — constant included
2180 // (#2751) — so the fit no longer reverts to the parametric backbone (the
2181 // training mean) across an unsupported gap, and so the collection's
2182 // parametric orthogonalization has the constant to consume instead of a
2183 // linear direction.
2184 // The extrapolation head is the single-scale (fused) gap-bridge path. In
2185 // multiscale mode the per-scale spectral penalties carry their own
2186 // structure and the design stays the pure representer basis (the per-level
2187 // replay + width contracts pin `m − 1` columns), so the head is added only
2188 // when the term is single-scale.
2189 let head_lift = if spec.multiscale {
2190 Array2::<f64>::zeros((centers.ncols() + 1, 0))
2191 } else {
2192 measure_jet_affine_head_lift(centers.view(), masses.view())
2193 };
2194 let head_width = head_lift.ncols();
2195 let m_aug = m + head_width;
2196 let k_cc = measure_jet_design_matrix(centers.view(), centers.view(), length_scale)?;
2197 let head_cc = measure_jet_affine_head_block(centers.view(), head_lift.view());
2198 // Realized-design constraint transform. In single-scale mode the explicit
2199 // affine head and Gaussian representers can otherwise carry the same affine
2200 // CENTER values in two different ways. That is a genuine gauge redundancy,
2201 // not a reason to ridge either coefficient block. At fit time remove it
2202 // exactly by restricting the RBF center values to the mass-orthogonal
2203 // complement of the supported affine space:
2204 //
2205 // C = A^T W K_cc, Z_rbf = null(C).
2206 //
2207 // The head then passes through as an identity block. The frozen composed
2208 // `z · z_parametric` is replayed verbatim at prediction/ψ trials (#532), so
2209 // the rank-revealed section never changes after fit-time realization. In
2210 // multiscale mode there is no explicit head, hence no affine duplication;
2211 // retain the existing representer sum-to-zero section there.
2212 let (z, coefficient_gauge) = match &spec.identifiability {
2213 MeasureJetIdentifiability::FrozenTransform { transform } => {
2214 if transform.nrows() != m_aug {
2215 crate::bail_dim_basis!(
2216 "frozen measure-jet identifiability transform mismatch: {} representers + {} head columns but transform has {} rows",
2217 m,
2218 head_width,
2219 transform.nrows()
2220 );
2221 }
2222 (
2223 transform.clone(),
2224 gam_problem::Gauge::from_block_transforms(&[transform.clone()]),
2225 )
2226 }
2227 MeasureJetIdentifiability::CenterSumToZero => {
2228 let z_rbf = if head_width > 0 {
2229 // `head_cc` IS the affine value basis A at the centers, by
2230 // construction (both come from `measure_jet_affine_head_lift`),
2231 // so the gauge constrains the representers against exactly the
2232 // span the head carries.
2233 let mut weighted_affine = head_cc.clone();
2234 for (i, mut row) in weighted_affine.outer_iter_mut().enumerate() {
2235 row.mapv_inplace(|v| v * masses[i]);
2236 }
2237 // `rrqr_nullspace_basis(B)` returns null(B^T). Here
2238 // `B = K_cc^T W A = C^T`, hence the returned columns span
2239 // null(C), exactly the required RBF coefficient section.
2240 let constraint_cross = k_cc.t().dot(&weighted_affine);
2241 rrqr_nullspace_basis(&constraint_cross, default_rrqr_rank_alpha())
2242 .map_err(BasisError::LinalgError)?
2243 .0
2244 } else {
2245 let u = householder_sum_to_zero_u(m);
2246 householder_sum_to_zero_z(&u)
2247 };
2248 let z_rbf = condition_representer_section(&k_cc, &z_rbf)?;
2249 let rbf_rank = z_rbf.ncols();
2250 let mut z_block = Array2::<f64>::zeros((m_aug, rbf_rank + head_width));
2251 z_block
2252 .slice_mut(ndarray::s![..m, ..rbf_rank])
2253 .assign(&z_rbf);
2254 for r in 0..head_width {
2255 z_block[(m + r, rbf_rank + r)] = 1.0;
2256 }
2257 (
2258 z_block.clone(),
2259 gam_problem::Gauge::from_block_transforms(&[z_block]),
2260 )
2261 }
2262 };
2263 // Augmented raw center matrix `[K(centers, centers) | A]`, so the
2264 // restricted `kz` maps constrained coefficients to center nodal values for
2265 // BOTH the representers and the head; the energy annihilates the head block
2266 // (affine) to machine precision, so it stays the unpenalized null space.
2267 let mut k_aug = Array2::<f64>::zeros((m, m_aug));
2268 k_aug.slice_mut(ndarray::s![.., ..m]).assign(&k_cc);
2269 if head_width > 0 {
2270 k_aug.slice_mut(ndarray::s![.., m..]).assign(&head_cc);
2271 }
2272 let kz = coefficient_gauge.restrict_design(&k_aug);
2273 Ok(RealizedMeasureJetGeometry {
2274 centers,
2275 masses,
2276 eps_band,
2277 log_step,
2278 length_scale,
2279 order_s_eval: order_s,
2280 // Multiscale (per-scale spectral) energy is an EXPLICIT opt-in (#1116):
2281 // one Primary energy at any center count unless the spec asks for the
2282 // scale split. The independent null-component candidate is orthogonal
2283 // to this mode decision. No center-count auto-gate.
2284 per_level: spec.multiscale,
2285 z,
2286 coefficient_gauge,
2287 kz,
2288 head_lift,
2289 })
2290}
2291
2292/// Estimate the ambient input-measurement-error scale `σ_coord` — the
2293/// perpendicular off-manifold residual spread of the empirical measure — for
2294/// the errors-in-variables predictive-variance term `Var_input = ∇f̂ᵀΣ_x∇f̂`,
2295/// `Σ_x = σ_coord²·I` (issue #2225).
2296///
2297/// The measure-jet models data concentrated near an unknown low-intrinsic-
2298/// dimension set sampled with isotropic ambient coordinate noise. In a
2299/// neighborhood the set is locally affine, so the noise lives in the ambient
2300/// directions ORTHOGONAL to the local tangent — exactly the smallest principal
2301/// directions of the local data covariance. This is the standard local-PCA
2302/// noise floor: for each center's nearest-assignment cell with enough points to
2303/// span a tangent (`≥ d + 1`, the linear-algebra rank requirement — not a tuned
2304/// knob), the smallest eigenvalue of the cell-local covariance estimates the
2305/// perpendicular variance `σ_coord²`; averaging over cells (weighted by the
2306/// cell count) pools the estimate. No response values, no smoothing dial, and
2307/// no magic constant enter — it is a pure function of the ambient point cloud
2308/// and the frozen centers, in the centers' (standardized) coordinate frame.
2309///
2310/// Returns `None` when no cell can span a tangent (e.g. `d`-dimensional data
2311/// with fewer than `d + 1` points per cell, or a full-dimensional stratum with
2312/// no separable perpendicular direction) — the caller then leaves `Var_input`
2313/// disabled rather than invent a scale.
2314pub fn measure_jet_input_noise_scale(
2315 data: ArrayView2<'_, f64>,
2316 centers: ArrayView2<'_, f64>,
2317) -> Result<Option<f64>, BasisError> {
2318 let d = data.ncols();
2319 let m = centers.nrows();
2320 if d == 0 || m == 0 || data.nrows() == 0 {
2321 return Ok(None);
2322 }
2323 if centers.ncols() != d {
2324 crate::bail_dim_basis!(
2325 "measure-jet input-noise estimate: data d={d} disagrees with centers d={}",
2326 centers.ncols()
2327 );
2328 }
2329 validate_finite_points(data, "data")?;
2330 validate_finite_points(centers, "centers")?;
2331 // Nearest-center assignment (the same rule that lumps the quadrature
2332 // masses): the squared-distance Gram, argmin per row.
2333 let sq = pairwise_sq_dists(data, centers);
2334 let mut members: Vec<Vec<usize>> = vec![Vec::new(); m];
2335 for (j, row) in sq.axis_iter(Axis(0)).enumerate() {
2336 let mut best = 0usize;
2337 let mut best_d = f64::INFINITY;
2338 for (i, &dij) in row.iter().enumerate() {
2339 if dij < best_d {
2340 best_d = dij;
2341 best = i;
2342 }
2343 }
2344 members[best].push(j);
2345 }
2346 let mut weighted_sum = 0.0_f64;
2347 let mut weight = 0.0_f64;
2348 for cell in &members {
2349 let n_i = cell.len();
2350 // A cell needs at least d + 1 points to define a full-rank local
2351 // covariance; otherwise its smallest eigenvalue is a spurious zero.
2352 if n_i < d + 1 {
2353 continue;
2354 }
2355 // Cell-local mean and covariance in ambient coordinates.
2356 let mut mean = Array1::<f64>::zeros(d);
2357 for &j in cell {
2358 mean += &data.row(j);
2359 }
2360 mean /= n_i as f64;
2361 let mut cov = Array2::<f64>::zeros((d, d));
2362 for &j in cell {
2363 let mut centered = data.row(j).to_owned();
2364 centered -= &mean;
2365 for a in 0..d {
2366 for b in 0..d {
2367 cov[(a, b)] += centered[a] * centered[b];
2368 }
2369 }
2370 }
2371 cov /= n_i as f64;
2372 // Symmetrize against accumulation asymmetry, then read the smallest
2373 // eigenvalue = the perpendicular (noise) principal variance.
2374 let cov_sym = (&cov + &cov.t()) * 0.5;
2375 let (evals, _) = cov_sym.eigh(Side::Lower).map_err(|e| {
2376 BasisError::InvalidInput(format!(
2377 "measure-jet input-noise estimate: local covariance eigendecomposition failed: {e}"
2378 ))
2379 })?;
2380 let smallest = evals
2381 .iter()
2382 .copied()
2383 .fold(f64::INFINITY, |acc, v| acc.min(v))
2384 .max(0.0);
2385 if smallest.is_finite() {
2386 weighted_sum += n_i as f64 * smallest;
2387 weight += n_i as f64;
2388 }
2389 }
2390 if weight <= 0.0 {
2391 return Ok(None);
2392 }
2393 let sigma2 = weighted_sum / weight;
2394 if !(sigma2.is_finite() && sigma2 > 0.0) {
2395 return Ok(None);
2396 }
2397 Ok(Some(sigma2.sqrt()))
2398}
2399
2400/// Whether a measure-jet spec runs in multiscale mode (per-scale spectral
2401/// energies + `(α, ln τ)` ψ dials). The separate `double_penalty`
2402/// affine/null-component candidate is available in both modes. This is the
2403/// single source of truth shared by the builder and outer enrollment predicates,
2404/// so the energy layout and ψ dimension cannot disagree. Multiscale is an
2405/// explicit opt-in (`spec.multiscale`); there is no center-count auto-gate
2406/// (#1116).
2407pub fn measure_jet_multiscale_mode(spec: &MeasureJetBasisSpec) -> bool {
2408 spec.multiscale
2409}
2410
2411/// Build the measure-jet smooth: Gaussian representer design `K(data,
2412/// centers)·z`, multiscale jet-residual penalty (one candidate per scale in
2413/// spectral mode, one Primary in pinned-order mode), an optional separate
2414/// function-space null-component candidate, and the replayable
2415/// [`BasisMetadata::MeasureJet`]. The geometry comes from the
2416/// empirical measure (centers + masses + band) through the shared
2417/// realization helper — the same source the ψ-derivative producer uses.
2418pub fn build_measure_jet_basis(
2419 data: ArrayView2<'_, f64>,
2420 spec: &MeasureJetBasisSpec,
2421) -> Result<BasisBuildResult, BasisError> {
2422 let RealizedMeasureJetGeometry {
2423 centers,
2424 masses,
2425 eps_band,
2426 log_step,
2427 length_scale,
2428 order_s_eval: order_s,
2429 per_level,
2430 z,
2431 coefficient_gauge,
2432 kz,
2433 head_lift,
2434 } = realize_measure_jet_geometry(data, spec)?;
2435 let band = MeasureJetBand {
2436 eps: eps_band.clone(),
2437 log_step,
2438 };
2439 let m = centers.nrows();
2440 let head_width = head_lift.ncols();
2441 let m_aug = m + head_width;
2442 // Augmented raw design `[K(data, centers) | [1 | data]·T_aff]` (#1845): the
2443 // head columns are the AFFINE extrapolation basis, which is the energy's
2444 // whole null space (#2751). The gauge restricts BOTH blocks together, so
2445 // the frozen composed transform replays the head verbatim at predict time.
2446 let kernel_design = measure_jet_design_matrix(data, centers.view(), length_scale)?;
2447 let mut raw_design = Array2::<f64>::zeros((data.nrows(), m_aug));
2448 raw_design
2449 .slice_mut(ndarray::s![.., ..m])
2450 .assign(&kernel_design);
2451 if head_width > 0 {
2452 let head_design = measure_jet_affine_head_block(data, head_lift.view());
2453 raw_design
2454 .slice_mut(ndarray::s![.., m..])
2455 .assign(&head_design);
2456 }
2457 let constrained_design = coefficient_gauge.restrict_design(&raw_design);
2458 let design = gam_linalg::matrix::DesignMatrix::Dense(
2459 gam_linalg::matrix::DenseDesignMatrix::from(constrained_design),
2460 );
2461 let support_means = measure_jet_support_means(centers.view(), masses.view(), &eps_band)?;
2462 // Spectral/geometric split. With the auto order sentinel (order_s == 0.0)
2463 // the term emits one candidate PER scale: the multi-penalty REML engine
2464 // then learns the level amplitudes λ_ℓ directly — scale adaptivity at
2465 // ρ-speed, dead scales REML-deselected (the Duchon-ARD pattern) — and the
2466 // fitted order is read off the spectrum (ŝ = −½ · slope of ln λ̂_ℓ on
2467 // ln ε_ℓ) instead of being optimized. An explicit s > 0 pins the Mellin
2468 // weights and fuses the band into one candidate. The Mellin prefactor
2469 // ε^(−η)·log_step inside each per-scale form is absorbed by the
2470 // per-candidate Frobenius normalization, so REML owns the amplitudes
2471 // outright. The sentinel itself is persisted in the metadata as the mode
2472 // marker: a replay MUST re-enter the same mode or the penalty count
2473 // desyncs (the gam#860 trap class).
2474 let mut candidates = Vec::new();
2475 let mut penalty_normalization_scales = Vec::new();
2476 let mut raw_penalty_normalization_scales = Vec::new();
2477 let mut fused_penalty_normalization_scale = None;
2478 if per_level {
2479 let forms = measure_jet_energy_forms_per_scale(
2480 centers.view(),
2481 masses.view(),
2482 &band,
2483 order_s,
2484 spec.alpha,
2485 spec.tau0,
2486 )?;
2487 for (level, q_l) in forms.into_iter().enumerate() {
2488 // Constructive pullback, not a dense triple product: the per-scale
2489 // form is PSD by construction and so is its pullback, and only the
2490 // arithmetic can lose that (#2761).
2491 let s_l = constructive_pullback_center_form(&kz, &q_l, "measure-jet scale penalty")?;
2492 let c_l = constructive_frobenius_scale(&s_l);
2493 let intrinsic_dim = centers.ncols() as f64;
2494 let eta = 2.0 * order_s + intrinsic_dim * (2.0 - 2.0 * spec.alpha);
2495 let scale_weight = log_step * eps_band[level].powf(-eta);
2496 penalty_normalization_scales.push(c_l);
2497 raw_penalty_normalization_scales.push(c_l / scale_weight);
2498 candidates.push(PenaltyCandidate {
2499 matrix: s_l.scaled(1.0 / c_l, "normalized measure-jet scale penalty")?,
2500 source: PenaltySource::Other(format!("measure_jet_scale_{level}")),
2501 normalization_scale: c_l,
2502 kronecker_factors: None,
2503 op: None,
2504 });
2505 }
2506 } else {
2507 let q_form = measure_jet_energy_form(
2508 centers.view(),
2509 masses.view(),
2510 &band,
2511 order_s,
2512 spec.alpha,
2513 spec.tau0,
2514 )?;
2515 // The Primary is exactly the jet-energy functional pulled back through
2516 // the center evaluation map. It is independent of `double_penalty`:
2517 // statistical selection is a distinct REML component below, never a
2518 // fixed coefficient toll fused into this estimand.
2519 let penalty =
2520 constructive_pullback_center_form(&kz, &q_form, "measure-jet primary penalty")?;
2521 let c_primary = constructive_frobenius_scale(&penalty);
2522 fused_penalty_normalization_scale = Some(c_primary);
2523 // Declare the energy's structural null frame on the shipped Primary
2524 // (#2761, the #2445 mechanism): the affine head is null by theorem, and
2525 // the pullback's NUMERICAL rank falls as the representer range grows, so
2526 // a rank test on the shipped matrix would let a design-moving ℓ decide
2527 // the double-penalty topology between outer trials.
2528 let mut primary =
2529 penalty.scaled(1.0 / c_primary, "normalized measure-jet primary penalty")?;
2530 if let Some(frame) = measure_jet_primary_structural_null_frame(&z, m, head_width)? {
2531 primary = primary.with_structural_null_frame(
2532 frame,
2533 "measure-jet primary structural null declaration",
2534 )?;
2535 }
2536 candidates.push(PenaltyCandidate {
2537 matrix: primary,
2538 source: PenaltySource::Primary,
2539 normalization_scale: c_primary,
2540 kronecker_factors: None,
2541 op: None,
2542 });
2543 }
2544 // Explicit null recovery is a genuine statistical component: penalize the
2545 // affine/null FUNCTION projection under the empirical-measure mass metric,
2546 // and let REML select its strength independently in both modes. This is the
2547 // standard double-penalty decomposition (roughness + null component); no
2548 // coefficient identity and no hard-coded mixture changes the Primary.
2549 if spec.double_penalty {
2550 let null_penalty = affine_function_nullspace_quadratic(&kz, centers.view(), masses.view())?;
2551 let (_, c_null) = normalize_penalty(null_penalty.dense());
2552 candidates.push(PenaltyCandidate {
2553 matrix: null_penalty
2554 .scaled(1.0 / c_null, "normalized measure-jet null-function penalty")?,
2555 source: PenaltySource::DoublePenaltyNullspace,
2556 normalization_scale: c_null,
2557 kronecker_factors: None,
2558 op: None,
2559 });
2560 // Decide the ridge's fate in THIS chart, the way the term-collection
2561 // chokepoint decides it in its own (#2433's repair, which periodic
2562 // Duchon already carries verbatim, extended here for #2761).
2563 //
2564 // The collection applies its global gauge and then rebuilds the ridge
2565 // from `null(Primary_constrained)`; a chart that has taken the last
2566 // structural null direction leaves nothing for the ridge to shrink and
2567 // the collection drops it. A frozen composed chart — which is what
2568 // every outer ψ trial and every predict-time replay rebuilds in — is
2569 // exactly such a chart for a 1-D measure-jet term, where the parametric
2570 // orthogonalization absorbs the whole affine head. Emitting the raw
2571 // ridge there produces a LOCAL topology of 2 against the collection's
2572 // cached 1, and the incremental realizer aborts the outer search with
2573 // `topology changed ... active_penalties=2, cached_penalties=1`.
2574 //
2575 // Running the same rebuild locally makes the two layers agree by
2576 // construction instead of by coincidence. In the cold chart the head is
2577 // still present, the rebuild keeps the ridge, and this is a no-op on
2578 // the shipped topology.
2579 let primary_physical = candidates
2580 .iter()
2581 .find(|candidate| matches!(candidate.source, PenaltySource::Primary))
2582 .map(|candidate| {
2583 candidate.matrix.scaled(
2584 candidate.normalization_scale,
2585 "physical measure-jet primary",
2586 )
2587 })
2588 .transpose()?;
2589 if let Some(primary_physical) = primary_physical {
2590 let width = primary_physical.nrows();
2591 for candidate in &mut candidates {
2592 if !matches!(candidate.source, PenaltySource::DoublePenaltyNullspace) {
2593 continue;
2594 }
2595 let ridge_physical = candidate.matrix.scaled(
2596 candidate.normalization_scale,
2597 "physical measure-jet null-function penalty",
2598 )?;
2599 match super::rebuild_metric_consistent_ridge(&primary_physical, &ridge_physical)? {
2600 Some(rebuilt) => {
2601 let normalized = super::normalize_constructive_penalty_candidate(
2602 rebuilt,
2603 PenaltySource::DoublePenaltyNullspace,
2604 )?;
2605 candidate.matrix = normalized.matrix;
2606 candidate.normalization_scale = normalized.normalization_scale;
2607 }
2608 None => {
2609 candidate.matrix = ConstructiveQuadratic::zero(width);
2610 candidate.normalization_scale = 1.0;
2611 }
2612 }
2613 candidate.kronecker_factors = None;
2614 candidate.op = None;
2615 }
2616 }
2617 }
2618 let filtered = filter_penalty_candidates(candidates)?;
2619 // #2225: compute the errors-in-variables input-noise scale while `centers`
2620 // is still owned; it is moved into the metadata `centers` field below.
2621 let sigma_coord = measure_jet_input_noise_scale(data, centers.view())?;
2622 Ok(BasisBuildResult {
2623 design,
2624 affine_offset: None,
2625 active_penalties: filtered.active,
2626 dropped_penalties: filtered.dropped,
2627 metadata: BasisMetadata::MeasureJet {
2628 centers,
2629 input_scale: crate::IsotropicScale::ONE,
2630 // The realized range from `realize_measure_jet_geometry`, in the
2631 // same frame as `centers` and `eps_band`. Unlike the other three
2632 // Euclidean families the term-collection wrapper does NOT restore
2633 // an original-units value over this, so the standardized tag
2634 // survives to every consumer (#2636).
2635 length_scale: crate::StandardizedUnits::new(length_scale),
2636 eps_band,
2637 // The SPEC's order field, sentinel included: 0.0 marks per-level
2638 // (spectral) mode and must replay as per-level — persisting the
2639 // realized default here would silently flip the rebuild into
2640 // fused mode and desync the penalty count.
2641 order_s: spec.order_s,
2642 alpha: spec.alpha,
2643 tau0: spec.tau0,
2644 masses,
2645 support_means,
2646 penalty_normalization_scales,
2647 raw_penalty_normalization_scales,
2648 fused_penalty_normalization_scale,
2649 constraint_transform: Some(z),
2650 // Perpendicular off-manifold residual scale of the fit rows in the
2651 // centers' frame — the errors-in-variables input-noise scale (#2225).
2652 sigma_coord,
2653 },
2654 kronecker_factored: None,
2655 joint_null_rotation: None,
2656 })
2657}
2658
2659/// Exact ψ-jets of the REALIZED measure-jet penalty candidates, adapted to
2660/// the anisotropic group-ψ carrier the spatial optimizer consumes.
2661///
2662/// Coordinates (the layout contract for the registration arm):
2663/// - per-level (spectral) mode: `[ln ℓ?, α, ln τ]` — order is absorbed by the
2664/// REML-learned scale amplitudes; `ln τ` is retained as an inert coordinate;
2665/// - single-scale mode: `[ln ℓ?]`, because its energy dials are fixed.
2666///
2667/// Only `ln ℓ` moves the design. It also moves every coefficient-space penalty
2668/// pullback through the center evaluation map `E(ℓ)`; `(α, ln τ)` move only the
2669/// per-scale center-value forms. Exact diagonal and mixed product-rule jets are
2670/// emitted before Frobenius normalization.
2671/// Penalty derivatives are routed through the SAME constrained Frobenius
2672/// normalization as the fit-time candidates
2673/// (`normalize_penaltywith_psi_derivatives` + the cross rule), so criterion
2674/// value and criterion derivative share one normalization — the #901 lesson
2675/// made structural. The function-space null candidate has nonzero `ln ℓ` jets
2676/// and zero `(α, ln τ)` jets. The per-candidate layout follows the builder's
2677/// ORIGINAL order (scale candidates or Primary, then null component); consumers
2678/// align to the FITTED penalty list via
2679/// `ActivePenaltyInfo.original_index` when the candidate filter dropped
2680/// any.
2681pub fn build_measure_jet_basis_psi_derivatives(
2682 data: ArrayView2<'_, f64>,
2683 spec: &MeasureJetBasisSpec,
2684) -> Result<AnisoBasisPsiDerivatives, BasisError> {
2685 if !(spec.tau0.is_finite() && spec.tau0 > 0.0) {
2686 crate::bail_invalid_basis!(
2687 "measure-jet ψ derivatives need tau0 > 0 because the retained τ coordinate is ln τ; got {}",
2688 spec.tau0
2689 );
2690 }
2691 let geom = realize_measure_jet_geometry(data, spec)?;
2692 let band = MeasureJetBand {
2693 eps: geom.eps_band.clone(),
2694 log_step: geom.log_step,
2695 };
2696 let n = data.nrows();
2697 let p = geom.kz.ncols();
2698 let m = geom.centers.nrows();
2699 let m_aug = m + geom.head_lift.ncols();
2700
2701 struct LengthScaleJets {
2702 evaluation_first: Array2<f64>,
2703 evaluation_second: Array2<f64>,
2704 design_first: Array2<f64>,
2705 design_second: Array2<f64>,
2706 }
2707
2708 // The Gaussian representer range moves both the FIT design and the center
2709 // evaluation map `E = [K_cc | A_head] Z`. The affine head is ℓ-invariant,
2710 // so its raw derivative columns are exactly zero before applying the frozen
2711 // Gauge section. Keeping `Z` frozen is the replay contract: rank/gauge
2712 // realization happens once at fit time, then every ψ trial differentiates
2713 // the same coefficient chart.
2714 let length_scale_jets = if spec.learn_length_scale {
2715 let (dk_data, d2k_data) =
2716 measure_jet_design_log_length_jets(data, geom.centers.view(), geom.length_scale)?;
2717 let mut dk_data_aug = Array2::<f64>::zeros((n, m_aug));
2718 let mut d2k_data_aug = Array2::<f64>::zeros((n, m_aug));
2719 dk_data_aug.slice_mut(ndarray::s![.., ..m]).assign(&dk_data);
2720 d2k_data_aug
2721 .slice_mut(ndarray::s![.., ..m])
2722 .assign(&d2k_data);
2723
2724 let (dk_centers, d2k_centers) = measure_jet_design_log_length_jets(
2725 geom.centers.view(),
2726 geom.centers.view(),
2727 geom.length_scale,
2728 )?;
2729 let mut dk_centers_aug = Array2::<f64>::zeros((m, m_aug));
2730 let mut d2k_centers_aug = Array2::<f64>::zeros((m, m_aug));
2731 dk_centers_aug
2732 .slice_mut(ndarray::s![.., ..m])
2733 .assign(&dk_centers);
2734 d2k_centers_aug
2735 .slice_mut(ndarray::s![.., ..m])
2736 .assign(&d2k_centers);
2737
2738 Some(LengthScaleJets {
2739 evaluation_first: geom.coefficient_gauge.restrict_design(&dk_centers_aug),
2740 evaluation_second: geom.coefficient_gauge.restrict_design(&d2k_centers_aug),
2741 design_first: geom.coefficient_gauge.restrict_design(&dk_data_aug),
2742 design_second: geom.coefficient_gauge.restrict_design(&d2k_data_aug),
2743 })
2744 } else {
2745 None
2746 };
2747
2748 let coord_offset = usize::from(length_scale_jets.is_some());
2749 let n_coords = coord_offset + if geom.per_level { 2 } else { 0 };
2750 let pairs: Vec<(usize, usize)> = (0..n_coords)
2751 .flat_map(|a| ((a + 1)..n_coords).map(move |b| (a, b)))
2752 .collect();
2753 let zero_p = || Array2::<f64>::zeros((p, p));
2754
2755 struct RawPenaltyJets {
2756 value: Array2<f64>,
2757 first: Vec<Array2<f64>>,
2758 second_diag: Vec<Array2<f64>>,
2759 cross: Vec<Array2<f64>>,
2760 }
2761
2762 let sandwich = |form: &Array2<f64>| pullback_center_form(&geom.kz, form);
2763 let length_diag = |form: &Array2<f64>| {
2764 let jets = length_scale_jets
2765 .as_ref()
2766 .expect("length-scale form jets require an enrolled length coordinate");
2767 pullback_center_form_log_length_jets(
2768 &geom.kz,
2769 &jets.evaluation_first,
2770 &jets.evaluation_second,
2771 form,
2772 )
2773 };
2774 let length_cross = |form_first: &Array2<f64>| {
2775 let jets = length_scale_jets
2776 .as_ref()
2777 .expect("length-scale cross jets require an enrolled length coordinate");
2778 pullback_center_form_log_length_cross(&geom.kz, &jets.evaluation_first, form_first)
2779 };
2780
2781 // Raw (pre-normalization) value + exact jet stacks per ORIGINAL candidate.
2782 // Coordinate order is `[lnℓ?, α, lnτ]` in multiscale mode and `[lnℓ?]`
2783 // in single-scale mode. Candidate order exactly mirrors the value builder:
2784 // scale candidates or Primary first, then the optional null-component
2785 // candidate. Active filtering aligns through `ActivePenaltyInfo::original_index`.
2786 // The single-scale Primary, when there is one. `None` in per-level mode,
2787 // which emits scale candidates instead and therefore never rebuilds the
2788 // null component.
2789 let mut single_scale_primary: Option<ConstructiveQuadratic> = None;
2790 let mut raw: Vec<RawPenaltyJets> = if geom.per_level {
2791 let l_count = band.eps.len();
2792 // Six forms per scale: value, ∂α, ∂α², and zero τ slots — same
2793 // blocks, one walk (single-source rule).
2794 let forms = assemble_weighted_forms(
2795 geom.centers.view(),
2796 geom.masses.view(),
2797 &band,
2798 geom.order_s_eval,
2799 spec.alpha,
2800 spec.tau0,
2801 6 * l_count,
2802 3,
2803 &|scale_idx, eps: f64, q: f64, base: f64, out: &mut [[f64; 3]]| {
2804 for slot in out.iter_mut() {
2805 *slot = [0.0, 0.0, 0.0];
2806 }
2807 let intrinsic_dim = geom.centers.ncols() as f64;
2808 let ga = 2.0 * intrinsic_dim * eps.ln() - 2.0 * q.max(f64::MIN_POSITIVE).ln();
2809 let k0 = 6 * scale_idx;
2810 out[k0] = [base, 0.0, 0.0];
2811 out[k0 + 1] = [ga * base, 0.0, 0.0];
2812 out[k0 + 2] = [ga * ga * base, 0.0, 0.0];
2813 out[k0 + 3] = [0.0, 0.0, 0.0];
2814 out[k0 + 4] = [0.0, 0.0, 0.0];
2815 out[k0 + 5] = [0.0, 0.0, 0.0];
2816 },
2817 )?;
2818 let alpha_coord = coord_offset;
2819 let tau_coord = coord_offset + 1;
2820 let mut raw = Vec::with_capacity(l_count + usize::from(spec.double_penalty));
2821 for level in 0..l_count {
2822 let chunk = &forms[6 * level..6 * level + 6];
2823 let mut first: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2824 let mut second_diag: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2825 first[alpha_coord] = sandwich(&chunk[1]);
2826 first[tau_coord] = sandwich(&chunk[3]);
2827 second_diag[alpha_coord] = sandwich(&chunk[2]);
2828 second_diag[tau_coord] = sandwich(&chunk[4]);
2829 if coord_offset == 1 {
2830 let (ell_first, ell_second) = length_diag(&chunk[0]);
2831 first[0] = ell_first;
2832 second_diag[0] = ell_second;
2833 }
2834 let mut cross: Vec<Array2<f64>> = (0..pairs.len()).map(|_| zero_p()).collect();
2835 for (pair_idx, &(a, b)) in pairs.iter().enumerate() {
2836 cross[pair_idx] = if coord_offset == 1 && a == 0 && b == alpha_coord {
2837 length_cross(&chunk[1])
2838 } else if coord_offset == 1 && a == 0 && b == tau_coord {
2839 length_cross(&chunk[3])
2840 } else if a == alpha_coord && b == tau_coord {
2841 sandwich(&chunk[5])
2842 } else {
2843 zero_p()
2844 };
2845 }
2846 raw.push(RawPenaltyJets {
2847 value: sandwich(&chunk[0]),
2848 first,
2849 second_diag,
2850 cross,
2851 });
2852 }
2853 raw
2854 } else {
2855 // Single-scale mode enrolls no `(s, α, lnτ)` penalty dials. It still
2856 // emits the pure Primary and, when requested, a separate REML null
2857 // component; an opt-in `lnℓ` coordinate differentiates both pullbacks.
2858 let q_form = measure_jet_energy_form(
2859 geom.centers.view(),
2860 geom.masses.view(),
2861 &band,
2862 geom.order_s_eval,
2863 spec.alpha,
2864 spec.tau0,
2865 )?;
2866 let mut first: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2867 let mut second_diag: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2868 if coord_offset == 1 {
2869 let (ell_first, ell_second) = length_diag(&q_form);
2870 first[0] = ell_first;
2871 second_diag[0] = ell_second;
2872 }
2873 // Keep the Primary the builder would emit: the null component's shipped
2874 // matrix is a REBUILD off it, so the producer needs the same object to
2875 // differentiate the same thing (see below).
2876 single_scale_primary = Some(constructive_pullback_center_form(
2877 &geom.kz,
2878 &q_form,
2879 "measure-jet primary penalty",
2880 )?);
2881 if let (Some(primary), Some(frame)) = (
2882 single_scale_primary.as_mut(),
2883 measure_jet_primary_structural_null_frame(&geom.z, m, geom.head_lift.ncols())?,
2884 ) {
2885 *primary = primary.clone().with_structural_null_frame(
2886 frame,
2887 "measure-jet primary structural null declaration",
2888 )?;
2889 }
2890 vec![RawPenaltyJets {
2891 value: sandwich(&q_form),
2892 first,
2893 second_diag,
2894 cross: Vec::new(),
2895 }]
2896 };
2897
2898 if spec.double_penalty {
2899 let null_center =
2900 affine_function_nullspace_center_quadratic(geom.centers.view(), geom.masses.view())?;
2901 let null_form = null_center.dense();
2902 let mut first: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2903 let mut second_diag: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2904 if coord_offset == 1 {
2905 let (ell_first, ell_second) = length_diag(null_form);
2906 first[0] = ell_first;
2907 second_diag[0] = ell_second;
2908 }
2909 let mut value = sandwich(null_form);
2910 // The builder does NOT ship this raw pullback when a Primary exists: it
2911 // ships `rebuild_metric_consistent_ridge`'s output, `R = N M Nᵀ` with
2912 // `M = Nᵀ (EᵀH₀E) N` and `N` the Primary's declared structural null
2913 // frame. Differentiating the raw pullback instead is an
2914 // objective↔gradient desync on the `ln ℓ` coordinate, and #2761
2915 // measured it as the WHOLE of that coordinate's gradient error:
2916 //
2917 // arm analytic Ridders FD rel
2918 // double_penalty = true -1.124278e1 -1.149171e1 2.2e-2
2919 // double_penalty = false -1.1255398e1 -1.1255398e1 9e-10
2920 //
2921 // with the total's per-atom breakdown putting it in `logdet_S`
2922 // (−0.2369) and `fixed_beta` (+0.4858). λ_null being tiny does not
2923 // shrink it: on the directions the Primary annihilates, `S_λ` IS
2924 // `λ_null·S_null`, so `λ_null` cancels out of
2925 // `tr(S_λ⁺ ∂S_λ/∂ψ)` and a wrong `∂S_null/∂ψ` lands at full size.
2926 //
2927 // The exact jets of the rebuilt object are `N (Nᵀ ∂S_raw N) Nᵀ`, since
2928 // `N` is ψ-fixed by construction (it is a declaration, not a rank
2929 // test). They are numerically ZERO here — `N`'s columns carry no
2930 // representer coefficients, so `E·N` is ℓ-invariant and `M` cannot
2931 // move — but computing them rather than asserting them keeps the
2932 // producer correct if a future frame does move.
2933 if let Some(primary) = single_scale_primary.as_ref() {
2934 let ridge_physical = ConstructiveQuadratic::from_energy_factor(
2935 null_center.factor().dot(&geom.kz),
2936 "measure-jet affine/null coefficient penalty",
2937 )?;
2938 match super::rebuild_metric_consistent_ridge(primary, &ridge_physical)? {
2939 Some(rebuilt) => {
2940 let frame = primary
2941 .structural_null_frame()
2942 .cloned()
2943 .unwrap_or_else(|| Array2::<f64>::zeros((p, 0)));
2944 for coord in 0..n_coords {
2945 first[coord] = restrict_jet_to_frame(&first[coord], &frame);
2946 second_diag[coord] = restrict_jet_to_frame(&second_diag[coord], &frame);
2947 }
2948 value = rebuilt.dense().clone();
2949 }
2950 None => {
2951 // The rebuild declined, so the builder ships an exact zero
2952 // and the candidate is dropped. A dropped candidate has no
2953 // derivative.
2954 for coord in 0..n_coords {
2955 first[coord] = zero_p();
2956 second_diag[coord] = zero_p();
2957 }
2958 value = zero_p();
2959 }
2960 }
2961 }
2962 raw.push(RawPenaltyJets {
2963 value,
2964 first,
2965 second_diag,
2966 // H₀ is independent of α and τ; its only moving object is E(ℓ),
2967 // so every mixed coordinate derivative is zero.
2968 cross: (0..pairs.len()).map(|_| zero_p()).collect(),
2969 });
2970 }
2971
2972 let n_cands = raw.len();
2973 let mut penalties_first: Vec<Vec<Array2<f64>>> =
2974 (0..n_coords).map(|_| Vec::with_capacity(n_cands)).collect();
2975 let mut penalties_second_diag: Vec<Vec<Array2<f64>>> =
2976 (0..n_coords).map(|_| Vec::with_capacity(n_cands)).collect();
2977 // Cross matrices per pair per candidate, precomputed eagerly (the
2978 // candidate count is the band length, not the data size) and served
2979 // through the on-demand provider.
2980 let mut crosses: Vec<Vec<Array2<f64>>> = (0..pairs.len()).map(|_| Vec::new()).collect();
2981 for candidate in &raw {
2982 let s_raw = &candidate.value;
2983 // ONE Frobenius scale per candidate, fixed up front from `s_raw`
2984 // alone: c anchors the value and every derivative of this candidate.
2985 // `normalize_penaltywith_psi_derivatives` recomputes the identical c
2986 // per coordinate (same trace_of_product + sqrt on the same `s_raw`),
2987 // and its degenerate convention is mirrored here: ‖S‖_F ≤ 1e-12 (or
2988 // non-finite) reports scale 1.0 — the value passes through unscaled,
2989 // and the cross helper receives that same 1.0, never a collapsed
2990 // near-zero scale.
2991 let fro = trace_of_product(s_raw, s_raw).sqrt();
2992 let c = if fro.is_finite() && fro > 1e-12 {
2993 fro
2994 } else {
2995 1.0
2996 };
2997 for coord in 0..n_coords {
2998 let (_, s_first, s_second, _) = normalize_penaltywith_psi_derivatives(
2999 s_raw,
3000 &candidate.first[coord],
3001 &candidate.second_diag[coord],
3002 );
3003 penalties_first[coord].push(s_first);
3004 penalties_second_diag[coord].push(s_second);
3005 }
3006 for (pair_idx, &(a, b)) in pairs.iter().enumerate() {
3007 let cross_raw_mat = normalize_penalty_cross_psi_derivative(
3008 s_raw,
3009 &candidate.first[a],
3010 &candidate.first[b],
3011 &candidate.cross[pair_idx],
3012 c,
3013 );
3014 crosses[pair_idx].push(cross_raw_mat);
3015 }
3016 }
3017
3018 let pair_index: Vec<((usize, usize), Vec<Array2<f64>>)> =
3019 pairs.iter().copied().zip(crosses.into_iter()).collect();
3020 let provider = AnisoPenaltyCrossProvider::new(move |a, b| {
3021 pair_index
3022 .iter()
3023 .find(|((pa, pb), _)| (*pa, *pb) == (a, b) || (*pa, *pb) == (b, a))
3024 .map(|(_, mats)| mats.clone())
3025 .ok_or_else(|| {
3026 BasisError::InvalidInput(format!(
3027 "measure-jet ψ cross derivative requested for unknown pair ({a}, {b})"
3028 ))
3029 })
3030 });
3031 let mut design_first: Vec<Array2<f64>> = (0..n_coords)
3032 .map(|_| Array2::<f64>::zeros((n, p)))
3033 .collect();
3034 let mut design_second_diag: Vec<Array2<f64>> = (0..n_coords)
3035 .map(|_| Array2::<f64>::zeros((n, p)))
3036 .collect();
3037 if let Some(jets) = &length_scale_jets {
3038 design_first[0] = jets.design_first.clone();
3039 design_second_diag[0] = jets.design_second.clone();
3040 }
3041 Ok(AnisoBasisPsiDerivatives {
3042 design_first,
3043 design_second_diag,
3044 design_second_cross: Vec::new(),
3045 design_second_cross_pairs: Vec::new(),
3046 penalties_first,
3047 penalties_second_diag,
3048 penalties_cross_pairs: pairs,
3049 penalties_cross_provider: Some(provider),
3050 implicit_operator: None,
3051 })
3052}
3053
3054#[cfg(test)]
3055mod tests {
3056 use super::*;
3057
3058 /// Deterministic Box–Muller standard normal from a 64-bit LCG state — a
3059 /// self-contained noise generator (no external RNG dependency).
3060 fn lcg_normal(state: &mut u64) -> f64 {
3061 let mut next = || {
3062 *state = state
3063 .wrapping_mul(6364136223846793005)
3064 .wrapping_add(1442695040888963407);
3065 // Top 53 bits → uniform (0, 1).
3066 (((*state >> 11) as f64) + 0.5) / (1u64 << 53) as f64
3067 };
3068 let u1 = next();
3069 let u2 = next();
3070 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
3071 }
3072
3073 /// The perpendicular off-manifold residual estimator recovers a KNOWN
3074 /// ambient noise scale on a 1-D manifold (a line) embedded in 2-D: points
3075 /// sampled along the tangent with isotropic-perpendicular Gaussian noise of
3076 /// scale σ, centers spaced along the line. The local-PCA smallest-eigenvalue
3077 /// floor must return ≈ σ (#2225).
3078 #[test]
3079 pub(crate) fn input_noise_scale_recovers_known_perpendicular_sigma() {
3080 // Line direction (unit) and its perpendicular in 2-D.
3081 let tang = [1.0 / 5f64.sqrt(), 2.0 / 5f64.sqrt()];
3082 let perp = [2.0 / 5f64.sqrt(), -1.0 / 5f64.sqrt()];
3083 let sigma = 0.05_f64;
3084 let n = 600usize;
3085 let mut state = 0x1234_5678_9abc_def0u64;
3086 let mut data = Array2::<f64>::zeros((n, 2));
3087 for j in 0..n {
3088 // Tangential coordinate marches deterministically over [0, 3].
3089 let t = 3.0 * (j as f64) / (n as f64 - 1.0);
3090 let noise = sigma * lcg_normal(&mut state);
3091 for a in 0..2 {
3092 data[(j, a)] = t * tang[a] + noise * perp[a];
3093 }
3094 }
3095 // Centers along the line (on the noiseless manifold): plenty of points
3096 // per cell to span the tangent.
3097 let n_centers = 8usize;
3098 let mut centers = Array2::<f64>::zeros((n_centers, 2));
3099 for i in 0..n_centers {
3100 let t = 3.0 * (i as f64 + 0.5) / (n_centers as f64);
3101 for a in 0..2 {
3102 centers[(i, a)] = t * tang[a];
3103 }
3104 }
3105 let est = measure_jet_input_noise_scale(data.view(), centers.view())
3106 .expect("estimate ok")
3107 .expect("noise scale present");
3108 // Sample smallest-eigenvalue floor is mildly downward-biased; require it
3109 // within 40% of the truth (central estimate, not a tuned tolerance).
3110 assert!(
3111 (est - sigma).abs() <= 0.4 * sigma,
3112 "estimated σ_coord {est} far from true {sigma}"
3113 );
3114 }
3115
3116 /// Too few points per cell (cannot span a d-dim tangent) ⇒ no estimate,
3117 /// so the caller leaves Var_input disabled rather than invent a scale.
3118 #[test]
3119 pub(crate) fn input_noise_scale_none_when_cells_too_small() {
3120 let data = array![[0.0, 0.0], [1.0, 2.0], [2.0, 4.0]];
3121 let centers = array![[0.0, 0.0], [1.0, 2.0], [2.0, 4.0]];
3122 // Each point is its own nearest center (1 point per cell < d + 1 = 3).
3123 assert!(
3124 measure_jet_input_noise_scale(data.view(), centers.view())
3125 .expect("estimate ok")
3126 .is_none()
3127 );
3128 }
3129
3130 pub(crate) fn two_cluster_centers() -> (ndarray::Array2<f64>, ndarray::Array1<f64>) {
3131 let centers = array![
3132 [0.00, 0.00],
3133 [0.31, 0.05],
3134 [0.58, -0.07],
3135 [0.93, 0.11],
3136 [1.22, 0.02],
3137 [1.49, -0.04],
3138 [3.10, 2.00],
3139 [3.42, 2.13],
3140 [3.71, 1.91],
3141 [4.05, 2.07],
3142 [4.33, 1.96],
3143 [4.61, 2.12],
3144 ];
3145 let m = centers.nrows();
3146 let masses = ndarray::Array1::<f64>::from_elem(m, 1.0 / m as f64);
3147 (centers, masses)
3148 }
3149 use ndarray::array;
3150
3151 pub(crate) fn band_for(centers: &Array2<f64>) -> MeasureJetBand {
3152 measure_jet_band(centers.view(), 0).expect("band")
3153 }
3154
3155 /// The no-mass contract: constants must be annihilated to machine
3156 /// precision at every scale (the constant is projected, never ridged).
3157 #[test]
3158 pub(crate) fn energy_form_annihilates_constants_exactly() {
3159 let (centers, masses) = two_cluster_centers();
3160 let band = band_for(¢ers);
3161 let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
3162 .expect("energy form");
3163 let m = q.nrows();
3164 let ones = Array1::<f64>::ones(m);
3165 let qv = q.dot(&ones);
3166 let scale = q.iter().fold(0.0_f64, |acc, v| acc.max(v.abs()));
3167 assert!(scale > 0.0, "energy form is identically zero");
3168 for (i, v) in qv.iter().enumerate() {
3169 assert!(
3170 v.abs() <= 1e-12 * scale,
3171 "Q·1 leak at row {i}: {v:.3e} vs scale {scale:.3e}"
3172 );
3173 }
3174 let vqv = ones.dot(&qv);
3175 assert!(
3176 vqv.abs() <= 1e-12 * scale,
3177 "constant carries energy: 1ᵀQ1 = {vqv:.3e}"
3178 );
3179 }
3180
3181 /// The default local projection annihilates ambient affine functions
3182 /// exactly; τ is retained for ψ layout but no longer adds an affine toll.
3183 #[test]
3184 pub(crate) fn energy_form_annihilates_affine_at_default_tau() {
3185 let (centers, masses) = two_cluster_centers();
3186 let band = band_for(¢ers);
3187 let m = centers.nrows();
3188 // Affine values v = 0.7 + 1.3·x − 0.4·y, and a rough ±1 checkerboard.
3189 let mut affine = Array1::<f64>::zeros(m);
3190 let mut rough = Array1::<f64>::zeros(m);
3191 for i in 0..m {
3192 affine[i] = 0.7 + 1.3 * centers[(i, 0)] - 0.4 * centers[(i, 1)];
3193 rough[i] = if i % 2 == 0 { 1.0 } else { -1.0 };
3194 }
3195 let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
3196 .expect("energy form");
3197 let e_affine = affine.dot(&q.dot(&affine));
3198 let e_rough = rough.dot(&q.dot(&rough));
3199 assert!(e_rough > 0.0, "rough vector must pay energy");
3200 assert!(
3201 e_affine.abs() <= 1e-12 * e_rough,
3202 "default affine energy {e_affine:.3e} vs rough {e_rough:.3e}"
3203 );
3204 }
3205
3206 /// PSD: the energy is a sum of weighted least-squares residuals.
3207 #[test]
3208 pub(crate) fn energy_form_is_psd() {
3209 let (centers, masses) = two_cluster_centers();
3210 let band = band_for(¢ers);
3211 let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
3212 .expect("energy form");
3213 let m = q.nrows();
3214 for trial in 0..5usize {
3215 let v = Array1::<f64>::from_shape_fn(m, |i| {
3216 ((i * 7 + trial * 13) % 11) as f64 / 11.0 - 0.5
3217 });
3218 let e = v.dot(&q.dot(&v));
3219 assert!(e >= -1e-10, "vᵀQv = {e:.3e} < 0 on trial {trial}");
3220 }
3221 }
3222
3223 /// A 1-D filament embedded in 2-D: high-frequency center values along the
3224 /// strand pay strictly more energy than a slow trend.
3225 #[test]
3226 pub(crate) fn rough_vector_pays_more_than_smooth() {
3227 let m = 24usize;
3228 let centers = Array2::<f64>::from_shape_fn((m, 2), |(i, k)| {
3229 let t = i as f64 / (m as f64 - 1.0);
3230 if k == 0 {
3231 t * 4.0
3232 } else {
3233 0.3 * (t * 4.0).sin()
3234 }
3235 });
3236 let masses = Array1::<f64>::from_elem(m, 1.0 / m as f64);
3237 let band = band_for(¢ers);
3238 let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
3239 .expect("energy form");
3240 let slow = Array1::<f64>::from_shape_fn(m, |i| (i as f64 / (m as f64 - 1.0)).powi(2));
3241 let fast = Array1::<f64>::from_shape_fn(m, |i| if i % 2 == 0 { 0.5 } else { -0.5 });
3242 let e_slow = slow.dot(&q.dot(&slow));
3243 let e_fast = fast.dot(&q.dot(&fast));
3244 assert!(
3245 e_fast > 10.0 * e_slow,
3246 "alternating values must pay >> a slow trend: fast {e_fast:.3e} vs slow {e_slow:.3e}"
3247 );
3248 }
3249
3250 /// The support curve separates on-web from off-web queries at fine
3251 /// scales and grows monotonically in ε for any query.
3252 #[test]
3253 pub(crate) fn support_curve_separates_on_web_from_off_web() {
3254 let m = 24usize;
3255 let centers = Array2::<f64>::from_shape_fn((m, 2), |(i, k)| {
3256 let t = i as f64 / (m as f64 - 1.0);
3257 if k == 0 { t * 4.0 } else { 0.0 }
3258 });
3259 let masses = Array1::<f64>::from_elem(m, 1.0 / m as f64);
3260 let band = band_for(¢ers);
3261 let queries = array![[2.0, 0.0], [2.0, 1.5]];
3262 let curves =
3263 measure_jet_support_curve(queries.view(), centers.view(), masses.view(), &band.eps)
3264 .expect("support curve");
3265 // On-web sees strictly more mass than off-web at the finest scale.
3266 assert!(
3267 curves[(0, 0)] > 10.0 * curves[(1, 0)],
3268 "fine-scale support must separate web from void: on {:.3e} vs off {:.3e}",
3269 curves[(0, 0)],
3270 curves[(1, 0)]
3271 );
3272 // Kernel mass is monotone in ε for every query.
3273 for qi in 0..2 {
3274 for li in 1..band.eps.len() {
3275 assert!(
3276 curves[(qi, li)] >= curves[(qi, li - 1)] - 1e-15,
3277 "support curve must be monotone in scale (query {qi}, level {li})"
3278 );
3279 }
3280 }
3281 }
3282
3283 /// The default is single-scale mode at ANY center count: one Primary
3284 /// jet-energy candidate plus the independently REML-selected affine/null
3285 /// component requested by the default `double_penalty`. Multiscale (the
3286 /// per-scale spectral split + ψ dials) is an EXPLICIT opt-in
3287 /// (`spec.multiscale`, the DSL `mjs(…, multiscale=true)`) — there is no
3288 /// center-count auto-gate (#1116). `measure_jet_multiscale_mode` is the
3289 /// single source for this decision.
3290 #[test]
3291 pub(crate) fn default_stays_single_scale_until_multiscale_opt_in() {
3292 let n = 200usize;
3293 let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
3294 let t = i as f64 / (n as f64 - 1.0);
3295 if k == 0 {
3296 t * 3.0
3297 } else {
3298 0.4 * (t * 3.0).sin()
3299 }
3300 });
3301 // Default (multiscale = false) stays single-scale even at a LARGE center
3302 // count that, under the deleted auto-gate, would have flipped to
3303 // multiscale: one pure Primary plus one function-space null component.
3304 let single = MeasureJetBasisSpec {
3305 center_strategy: CenterStrategy::FarthestPoint { num_centers: 80 },
3306 ..MeasureJetBasisSpec::default()
3307 };
3308 assert!(
3309 !measure_jet_multiscale_mode(&single),
3310 "default must resolve to single-scale at any center count"
3311 );
3312 let built_single =
3313 build_measure_jet_basis(data.view(), &single).expect("single-scale build");
3314 assert_eq!(
3315 built_single.active_penalties.len(),
3316 2,
3317 "single-scale double-penalty mode emits Primary + affine/null component"
3318 );
3319 assert!(matches!(
3320 built_single.active_penalties[0].info.source,
3321 PenaltySource::Primary
3322 ));
3323 assert!(matches!(
3324 built_single.active_penalties[1].info.source,
3325 PenaltySource::DoublePenaltyNullspace
3326 ));
3327 // The explicit opt-in flips to multiscale at the SAME center count: the
3328 // per-scale spectral split (several candidates) plus the same explicit
3329 // null-component candidate, strictly more candidates than single-scale.
3330 let multi = MeasureJetBasisSpec {
3331 center_strategy: CenterStrategy::FarthestPoint { num_centers: 80 },
3332 multiscale: true,
3333 ..MeasureJetBasisSpec::default()
3334 };
3335 assert!(
3336 measure_jet_multiscale_mode(&multi),
3337 "multiscale=true must resolve to multiscale mode"
3338 );
3339 let built_multi = build_measure_jet_basis(data.view(), &multi).expect("multiscale build");
3340 assert!(
3341 built_multi.active_penalties.len() > built_single.active_penalties.len(),
3342 "multiscale mode emits the per-scale spectral split plus null selection, got {} (vs single-scale {})",
3343 built_multi.active_penalties.len(),
3344 built_single.active_penalties.len()
3345 );
3346 }
3347
3348 /// An explicit order pins the Mellin weights and fuses the band into a
3349 /// single Primary candidate. Disabling explicit null recovery leaves exactly
3350 /// that candidate; enabling it must never alter the Primary itself.
3351 #[test]
3352 pub(crate) fn fused_mode_without_double_penalty_emits_single_primary_candidate() {
3353 let n = 40usize;
3354 let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
3355 let t = i as f64 / (n as f64 - 1.0);
3356 if k == 0 {
3357 t * 3.0
3358 } else {
3359 0.4 * (t * 3.0).sin()
3360 }
3361 });
3362 let spec = MeasureJetBasisSpec {
3363 center_strategy: CenterStrategy::FarthestPoint { num_centers: 14 },
3364 order_s: 1.3,
3365 double_penalty: false,
3366 ..MeasureJetBasisSpec::default()
3367 };
3368 let built = build_measure_jet_basis(data.view(), &spec).expect("fused build");
3369 assert_eq!(
3370 built.active_penalties.len(),
3371 1,
3372 "single-scale mode without null recovery emits exactly one Primary"
3373 );
3374 assert!(matches!(
3375 built.active_penalties[0].info.source,
3376 PenaltySource::Primary
3377 ));
3378 let BasisMetadata::MeasureJet { order_s, .. } = &built.metadata else {
3379 panic!("measure-jet build must return MeasureJet metadata");
3380 };
3381 assert_eq!(*order_s, 1.3, "explicit order must persist verbatim");
3382 }
3383
3384 /// The single-scale affine head is a gauge-fixed decomposition, not a
3385 /// coefficient ridge: RBF center values are exactly mass-orthogonal to the
3386 /// supported affine space, and replacing those directions with the head
3387 /// keeps the RAW chart exactly `m` wide. The collection's parametric
3388 /// orthogonalization then removes the head's constant, landing the FIT
3389 /// chart at `m - 1` — the width this test asserted directly before #2751,
3390 /// when the head omitted the constant and the centering took a linear
3391 /// direction instead.
3392 #[test]
3393 pub(crate) fn single_scale_affine_head_gauge_annihilates_center_cross() {
3394 let n = 90usize;
3395 let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
3396 let t = i as f64 / (n as f64 - 1.0);
3397 if k == 0 {
3398 3.0 * t
3399 } else {
3400 (2.0 * std::f64::consts::PI * t).sin() + 0.2 * t
3401 }
3402 });
3403 let spec = MeasureJetBasisSpec {
3404 center_strategy: CenterStrategy::FarthestPoint { num_centers: 18 },
3405 double_penalty: false,
3406 multiscale: false,
3407 ..MeasureJetBasisSpec::default()
3408 };
3409 let geom = realize_measure_jet_geometry(data.view(), &spec).expect("realized geometry");
3410 let m = geom.centers.nrows();
3411 let head_width = geom.head_lift.ncols();
3412 assert!(head_width > 0, "fixture must realize an affine head");
3413 assert_eq!(
3414 geom.z.ncols(),
3415 m,
3416 "the affine head replaces the RBF block's affine directions one for one: the RAW \
3417 chart is exactly m wide (m - head_width representers + head_width head columns). \
3418 The collection's parametric orthogonalization then removes the constant, landing \
3419 the FIT chart at m - 1 (#2751)"
3420 );
3421 let rbf_rank = m - head_width;
3422 let z_rbf = geom.z.slice(ndarray::s![..m, ..rbf_rank]).to_owned();
3423 let k_cc =
3424 measure_jet_design_matrix(geom.centers.view(), geom.centers.view(), geom.length_scale)
3425 .expect("center kernel");
3426 let affine = measure_jet_affine_value_basis(geom.centers.view(), geom.masses.view());
3427 assert_eq!(affine.ncols(), head_width);
3428 let mut weighted_affine = affine.clone();
3429 for (i, mut row) in weighted_affine.outer_iter_mut().enumerate() {
3430 row.mapv_inplace(|v| v * geom.masses[i]);
3431 }
3432 let constraint_cross = k_cc.t().dot(&weighted_affine);
3433 let residual = constraint_cross.t().dot(&z_rbf);
3434 let scale = constraint_cross
3435 .iter()
3436 .fold(1.0_f64, |acc, value| acc.max(value.abs()));
3437 assert!(
3438 residual.iter().all(|value| value.abs() <= 1e-10 * scale),
3439 "A^T W Kcc Z_rbf must vanish; max residual {:.3e}",
3440 residual
3441 .iter()
3442 .fold(0.0_f64, |acc, value| acc.max(value.abs()))
3443 );
3444 }
3445
3446 /// `double_penalty` adds a distinct evidence-selected component and cannot
3447 /// mutate the jet-energy estimand carried by Primary.
3448 #[test]
3449 pub(crate) fn double_penalty_leaves_primary_matrix_unchanged() {
3450 let n = 64usize;
3451 let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
3452 let t = i as f64 / (n as f64 - 1.0);
3453 if k == 0 { 2.5 * t } else { (4.0 * t).cos() }
3454 });
3455 let base = MeasureJetBasisSpec {
3456 center_strategy: CenterStrategy::FarthestPoint { num_centers: 16 },
3457 order_s: 1.25,
3458 double_penalty: false,
3459 ..MeasureJetBasisSpec::default()
3460 };
3461 let without = build_measure_jet_basis(data.view(), &base).expect("primary-only build");
3462 let with = build_measure_jet_basis(
3463 data.view(),
3464 &MeasureJetBasisSpec {
3465 double_penalty: true,
3466 ..base.clone()
3467 },
3468 )
3469 .expect("double-penalty build");
3470 assert_eq!(without.active_penalties.len(), 1);
3471 assert_eq!(with.active_penalties.len(), 2);
3472 assert!(matches!(
3473 without.active_penalties[0].info.source,
3474 PenaltySource::Primary
3475 ));
3476 assert!(matches!(
3477 with.active_penalties[0].info.source,
3478 PenaltySource::Primary
3479 ));
3480 assert!(matches!(
3481 with.active_penalties[1].info.source,
3482 PenaltySource::DoublePenaltyNullspace
3483 ));
3484 assert!(
3485 without.active_penalties[0]
3486 .matrix
3487 .iter()
3488 .zip(with.active_penalties[0].matrix.iter())
3489 .all(|(a, b)| (a - b).abs() <= 1e-13),
3490 "turning on null recovery must not modify Primary"
3491 );
3492 }
3493
3494 /// The Householder basis must be orthonormal with sum-to-zero columns.
3495 #[test]
3496 pub(crate) fn householder_sum_to_zero_basis_is_orthonormal() {
3497 let m = 9usize;
3498 let u = householder_sum_to_zero_u(m);
3499 let z = householder_sum_to_zero_z(&u);
3500 for j in 0..(m - 1) {
3501 let col_j = z.column(j);
3502 assert!(col_j.sum().abs() <= 1e-12, "column {j} must sum to zero");
3503 for j2 in j..(m - 1) {
3504 let dot = col_j.dot(&z.column(j2));
3505 let want = if j == j2 { 1.0 } else { 0.0 };
3506 assert!(
3507 (dot - want).abs() <= 1e-12,
3508 "orthonormality failure at ({j}, {j2}): {dot}"
3509 );
3510 }
3511 }
3512 }
3513
3514 /// Frozen-geometry fixture shared by the ψ-producer FD gates: build
3515 /// once, pin everything (nodes, masses, band, transform, realized ℓ),
3516 /// and return the pinned spec so dial-perturbed rebuilds move ONLY the
3517 /// dials — the per-trial contract the optimizer relies on.
3518 pub(crate) fn frozen_spec_fixture(
3519 order_s: f64,
3520 multiscale: bool,
3521 ) -> (Array2<f64>, MeasureJetBasisSpec) {
3522 // Multiscale (per-scale + ψ) mode is the explicit opt-in (#1116); the
3523 // per-level fixture passes `multiscale = true`, the fused fixture
3524 // `false`. A large center count is kept so the multiscale spectrum is
3525 // identifiable when opted in.
3526 let n = 140usize;
3527 let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
3528 let t = i as f64 / (n as f64 - 1.0);
3529 if k == 0 {
3530 t * 3.0
3531 } else {
3532 0.5 * (t * 3.0).cos() + if i % 9 == 0 { 0.8 } else { 0.0 }
3533 }
3534 });
3535 let spec = MeasureJetBasisSpec {
3536 center_strategy: CenterStrategy::FarthestPoint { num_centers: 70 },
3537 order_s,
3538 multiscale,
3539 // These fixtures gate the PENALTY-dial derivatives; freeze ℓ so the
3540 // coordinate layout is exactly the penalty dials (the design-moving
3541 // ℓ dial has its own FD gate, `psi_producer_matches_fd_length_scale`).
3542 learn_length_scale: false,
3543 ..MeasureJetBasisSpec::default()
3544 };
3545 let first = build_measure_jet_basis(data.view(), &spec).expect("fixture build");
3546 let BasisMetadata::MeasureJet {
3547 centers,
3548 length_scale,
3549 eps_band,
3550 masses,
3551 support_means,
3552 penalty_normalization_scales,
3553 raw_penalty_normalization_scales,
3554 fused_penalty_normalization_scale,
3555 constraint_transform,
3556 ..
3557 } = &first.metadata
3558 else {
3559 panic!("measure-jet build must return MeasureJet metadata");
3560 };
3561 let frozen = MeasureJetBasisSpec {
3562 center_strategy: CenterStrategy::UserProvided(centers.clone()),
3563 order_s,
3564 alpha: spec.alpha,
3565 tau0: spec.tau0,
3566 num_scales: eps_band.len(),
3567 // MeasureJet freezes its range STANDARDIZED and replays it
3568 // verbatim; the tag is what records that it is the odd family out.
3569 length_scale: length_scale.standardized_value(),
3570 double_penalty: spec.double_penalty,
3571 learn_length_scale: false,
3572 multiscale,
3573 identifiability: MeasureJetIdentifiability::FrozenTransform {
3574 transform: constraint_transform.clone().expect("fit-time z"),
3575 },
3576 frozen_quadrature: Some(MeasureJetFrozenQuadrature {
3577 masses: masses.clone(),
3578 eps_band: eps_band.clone(),
3579 support_means: support_means.clone(),
3580 penalty_normalization_scales: penalty_normalization_scales.clone(),
3581 raw_penalty_normalization_scales: raw_penalty_normalization_scales.clone(),
3582 fused_penalty_normalization_scale: *fused_penalty_normalization_scale,
3583 sigma_coord: None,
3584 }),
3585 };
3586 (data, frozen)
3587 }
3588
3589 /// ψ-producer vs central finite differences of the NORMALIZED fit-time
3590 /// candidates under frozen geometry — per-level mode (coords α, lnτ).
3591 /// This is the end-to-end gate #901 never had: the derivative is checked
3592 /// against the exact object the optimizer consumes.
3593 #[test]
3594 pub(crate) fn psi_producer_matches_fd_per_level_mode() {
3595 let (data, frozen) = frozen_spec_fixture(0.0, true);
3596 let derivs =
3597 build_measure_jet_basis_psi_derivatives(data.view(), &frozen).expect("psi derivatives");
3598 let l_count = frozen
3599 .frozen_quadrature
3600 .as_ref()
3601 .expect("frozen quadrature")
3602 .eps_band
3603 .len();
3604 assert_eq!(
3605 derivs.penalties_first.len(),
3606 2,
3607 "per-level coords are (α, lnτ)"
3608 );
3609 assert_eq!(derivs.penalties_first[0].len(), l_count + 1);
3610 assert_eq!(derivs.penalties_cross_pairs, vec![(0, 1)]);
3611 let pen_at = |alpha: f64, tau0: f64| {
3612 let trial = MeasureJetBasisSpec {
3613 alpha,
3614 tau0,
3615 ..frozen.clone()
3616 };
3617 build_measure_jet_basis(data.view(), &trial)
3618 .expect("trial build")
3619 .active_penalties
3620 .into_iter()
3621 .map(|penalty| penalty.matrix)
3622 .collect::<Vec<_>>()
3623 };
3624 // Second-difference-optimal step (see the jets FD test): the 4-point
3625 // cross stencil shares the ~ε·scale/h² roundoff floor.
3626 let h = 1e-4;
3627 let (a0, t0) = (frozen.alpha, frozen.tau0);
3628 let ap = pen_at(a0 + h, t0);
3629 let am = pen_at(a0 - h, t0);
3630 let tp = pen_at(a0, t0 * h.exp());
3631 let tm = pen_at(a0, t0 * (-h).exp());
3632 assert_eq!(
3633 ap.len(),
3634 l_count + 1,
3635 "fixture must keep every scale active"
3636 );
3637 for level in 0..l_count {
3638 let fd_alpha = (&ap[level] - &am[level]) / (2.0 * h);
3639 let fd_tau = (&tp[level] - &tm[level]) / (2.0 * h);
3640 for (name, analytic, fd) in [
3641 ("alpha", &derivs.penalties_first[0][level], fd_alpha),
3642 ("ln_tau", &derivs.penalties_first[1][level], fd_tau),
3643 ] {
3644 let scale = fd.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
3645 for (x, y) in analytic.iter().zip(fd.iter()) {
3646 assert!(
3647 (x - y).abs() <= 5e-5 * scale,
3648 "{name} jet of scale-candidate {level}: analytic {x:.6e} vs FD {y:.6e}"
3649 );
3650 }
3651 }
3652 }
3653 // The function-space null candidate is independent of α and τ.
3654 for coord in 0..2 {
3655 assert!(
3656 derivs.penalties_first[coord][l_count]
3657 .iter()
3658 .all(|v| *v == 0.0),
3659 "null-component candidate must have zero (α, lnτ) drift"
3660 );
3661 }
3662 // Cross derivative through the provider, against a 4-point FD.
3663 let provider = derivs
3664 .penalties_cross_provider
3665 .as_ref()
3666 .expect("cross provider");
3667 let cross = provider.evaluate(0, 1).expect("cross pair (α, lnτ)");
3668 let pp = pen_at(a0 + h, t0 * h.exp());
3669 let pm = pen_at(a0 + h, t0 * (-h).exp());
3670 let mp = pen_at(a0 - h, t0 * h.exp());
3671 let mm = pen_at(a0 - h, t0 * (-h).exp());
3672 for level in 0..l_count {
3673 let fd = (&(&pp[level] - &pm[level]) - &(&mp[level] - &mm[level])) / (4.0 * h * h);
3674 let scale = fd.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
3675 for (x, y) in cross[level].iter().zip(fd.iter()) {
3676 assert!(
3677 (x - y).abs() <= 5e-4 * scale,
3678 "cross (α, lnτ) jet of scale-candidate {level}: analytic {x:.6e} vs FD {y:.6e}"
3679 );
3680 }
3681 }
3682 }
3683
3684 /// Design-moving ℓ dial (#1116): the producer's design jets and every
3685 /// normalized penalty candidate's jets must match central differences of the
3686 /// REBUILT objects under frozen geometry. Although the center-value forms
3687 /// `Q` and `H₀` are ℓ-invariant, their coefficient pullbacks `E(ℓ)ᵀQ E(ℓ)`
3688 /// and `E(ℓ)ᵀH₀E(ℓ)` are not.
3689 #[test]
3690 pub(crate) fn psi_producer_matches_fd_length_scale() {
3691 // Single-scale with opt-in ℓ learning; frozen geometry so only ℓ moves
3692 // across the FD trials.
3693 let (data, mut frozen) = frozen_spec_fixture(0.0, false);
3694 frozen.learn_length_scale = true;
3695 let derivs =
3696 build_measure_jet_basis_psi_derivatives(data.view(), &frozen).expect("psi derivatives");
3697 // ℓ is the only coordinate in single-scale + learn_length_scale.
3698 assert_eq!(
3699 derivs.design_first.len(),
3700 1,
3701 "single-scale + learn_length_scale enrolls exactly the ℓ coordinate"
3702 );
3703 assert_eq!(
3704 derivs.penalties_first[0].len(),
3705 2,
3706 "single-scale double penalty carries Primary + affine/null component"
3707 );
3708 // Rebuild design and normalized penalties at ℓ·e^{±h}; the explicit
3709 // positive length_scale is honored verbatim while the frozen transform
3710 // keeps the coefficient chart fixed.
3711 let ell0 = frozen.length_scale;
3712 let build_at = |ell: f64| {
3713 let trial = MeasureJetBasisSpec {
3714 length_scale: ell,
3715 ..frozen.clone()
3716 };
3717 build_measure_jet_basis(data.view(), &trial).expect("trial build")
3718 };
3719 let h: f64 = 1e-4;
3720 let plus = build_at(ell0 * h.exp());
3721 let minus = build_at(ell0 * (-h).exp());
3722 let at = build_at(ell0);
3723 assert_eq!(
3724 plus.active_penalties.len(),
3725 2,
3726 "fixture must keep both candidates active"
3727 );
3728 assert_eq!(
3729 minus.active_penalties.len(),
3730 2,
3731 "fixture must keep both candidates active"
3732 );
3733 assert_eq!(
3734 at.active_penalties.len(),
3735 2,
3736 "fixture must keep both candidates active"
3737 );
3738
3739 let x_plus = plus.design.to_dense();
3740 let x_minus = minus.design.to_dense();
3741 let x_0 = at.design.to_dense();
3742 let fd_first = (&x_plus - &x_minus) / (2.0 * h);
3743 let fd_second = (&x_plus - &(&x_0 * 2.0) + &x_minus) / (h * h);
3744 let scale1 = fd_first.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
3745 for (x, y) in derivs.design_first[0].iter().zip(fd_first.iter()) {
3746 assert!(
3747 (x - y).abs() <= 5e-5 * scale1,
3748 "∂X/∂lnℓ: analytic {x:.6e} vs FD {y:.6e}"
3749 );
3750 }
3751 let scale2 = fd_second.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
3752 for (x, y) in derivs.design_second_diag[0].iter().zip(fd_second.iter()) {
3753 assert!(
3754 (x - y).abs() <= 1e-3 * scale2,
3755 "∂²X/∂lnℓ²: analytic {x:.6e} vs FD {y:.6e}"
3756 );
3757 }
3758
3759 for candidate in 0..2 {
3760 let fd_penalty_first = (&plus.active_penalties[candidate].matrix
3761 - &minus.active_penalties[candidate].matrix)
3762 / (2.0 * h);
3763 let fd_penalty_second = (&plus.active_penalties[candidate].matrix
3764 - &(&at.active_penalties[candidate].matrix * 2.0)
3765 + &minus.active_penalties[candidate].matrix)
3766 / (h * h);
3767 // A central difference cannot resolve a derivative below its own
3768 // cancellation noise: differencing entries of size `E` at step `h`
3769 // leaves `~ε·E/h` in the first difference and `~ε·E/h²` in the
3770 // second, whatever the true derivative is. The null component's
3771 // shipped matrix (the rebuilt metric-consistent ridge) is EXACTLY
3772 // ℓ-invariant, so its analytic jets are exactly zero and its FD is
3773 // pure noise — measured at 3.5e-13 against a `1e-12` scale floor
3774 // that predates the exact answer. Grading that against a relative
3775 // tolerance alone asserts the ORACLE is exact, which it is not.
3776 // The factor 8 covers the handful of roundings between the two
3777 // rebuilds; it is not a fudge on the gradient, which is still
3778 // graded relatively wherever the FD resolves anything.
3779 let entry_scale = [&plus, &minus, &at]
3780 .iter()
3781 .flat_map(|built| built.active_penalties[candidate].matrix.iter())
3782 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
3783 let first_floor = 8.0 * f64::EPSILON * entry_scale / h;
3784 let second_floor = 8.0 * f64::EPSILON * entry_scale / (h * h);
3785 let first_scale = fd_penalty_first
3786 .iter()
3787 .fold(1e-12_f64, |acc, value| acc.max(value.abs()));
3788 let second_scale = fd_penalty_second
3789 .iter()
3790 .fold(1e-10_f64, |acc, value| acc.max(value.abs()));
3791 for (analytic, finite_difference) in derivs.penalties_first[0][candidate]
3792 .iter()
3793 .zip(fd_penalty_first.iter())
3794 {
3795 assert!(
3796 (analytic - finite_difference).abs() <= 1e-4 * first_scale + first_floor,
3797 "candidate {candidate} ∂S~/∂lnℓ: analytic {analytic:.6e} vs FD \
3798 {finite_difference:.6e} (rel budget {:.3e}, oracle floor {first_floor:.3e})",
3799 1e-4 * first_scale
3800 );
3801 }
3802 for (analytic, finite_difference) in derivs.penalties_second_diag[0][candidate]
3803 .iter()
3804 .zip(fd_penalty_second.iter())
3805 {
3806 assert!(
3807 (analytic - finite_difference).abs() <= 5e-3 * second_scale + second_floor,
3808 "candidate {candidate} ∂²S~/∂lnℓ²: analytic {analytic:.6e} vs FD \
3809 {finite_difference:.6e} (rel budget {:.3e}, oracle floor {second_floor:.3e})",
3810 5e-3 * second_scale
3811 );
3812 }
3813 }
3814 }
3815
3816 /// Quadrature nodes must be the mass-weighted cell barycenters
3817 /// (first-moment-exact lumping), with empty cells keeping their seed
3818 /// coordinates at zero mass.
3819 #[test]
3820 pub(crate) fn quadrature_nodes_are_cell_barycenters() {
3821 // Two tight groups around (0,0) and (10,10); a third seed far away
3822 // captures nothing.
3823 let data = array![
3824 [0.0, 0.2],
3825 [0.4, -0.2],
3826 [0.2, 0.0],
3827 [9.8, 10.1],
3828 [10.2, 9.9],
3829 ];
3830 let seeds = array![[0.1, 0.1], [10.0, 10.0], [-50.0, -50.0]];
3831 let (nodes, masses) =
3832 measure_jet_quadrature_nodes(data.view(), seeds.view()).expect("quadrature nodes");
3833 assert!((masses.sum() - 1.0).abs() <= 1e-15, "masses must sum to 1");
3834 assert!((masses[0] - 0.6).abs() <= 1e-15);
3835 assert!((masses[1] - 0.4).abs() <= 1e-15);
3836 assert_eq!(masses[2], 0.0);
3837 // Cell 0 barycenter = (0.2, 0.0).
3838 assert_eq!(nodes[(0, 0)], 0.2);
3839 assert_eq!(nodes[(0, 1)], 0.0);
3840 // Cell 1 barycenter = (10.0, 10.0), which is not a sampled row.
3841 assert_eq!(nodes[(1, 0)], 10.0);
3842 assert_eq!(nodes[(1, 1)], 10.0);
3843 // Empty cell keeps its seed coordinates.
3844 assert_eq!(nodes[(2, 0)], -50.0);
3845 assert_eq!(nodes[(2, 1)], -50.0);
3846 }
3847
3848 /// Freeze→replay: rebuilding from the first build's frozen transform and
3849 /// frozen quadrature must reproduce design and penalty bit-for-bit (the
3850 /// predict-path contract).
3851 #[test]
3852 pub(crate) fn build_replay_roundtrip_reproduces_design_and_penalty() {
3853 // A bent filament with a side cluster; multiscale opt-in so this
3854 // exercises the per-scale (spectral) replay path (#1116).
3855 let n = 140usize;
3856 let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
3857 let t = i as f64 / (n as f64 - 1.0);
3858 if k == 0 {
3859 t * 3.0
3860 } else {
3861 0.5 * (t * 3.0).cos() + if i % 9 == 0 { 0.8 } else { 0.0 }
3862 }
3863 });
3864 let spec = MeasureJetBasisSpec {
3865 center_strategy: CenterStrategy::FarthestPoint { num_centers: 70 },
3866 multiscale: true,
3867 ..MeasureJetBasisSpec::default()
3868 };
3869 let first = build_measure_jet_basis(data.view(), &spec).expect("first build");
3870 let BasisMetadata::MeasureJet {
3871 centers,
3872 length_scale,
3873 eps_band,
3874 order_s,
3875 alpha,
3876 tau0,
3877 masses,
3878 support_means,
3879 penalty_normalization_scales,
3880 raw_penalty_normalization_scales,
3881 fused_penalty_normalization_scale,
3882 constraint_transform,
3883 ..
3884 } = &first.metadata
3885 else {
3886 panic!("measure-jet build must return MeasureJet metadata");
3887 };
3888 let replay_spec = MeasureJetBasisSpec {
3889 center_strategy: CenterStrategy::UserProvided(centers.clone()),
3890 order_s: *order_s,
3891 alpha: *alpha,
3892 tau0: *tau0,
3893 num_scales: eps_band.len(),
3894 // MeasureJet freezes its range STANDARDIZED and replays it
3895 // verbatim; the tag is what records that it is the odd family out.
3896 length_scale: length_scale.standardized_value(),
3897 double_penalty: spec.double_penalty,
3898 learn_length_scale: spec.learn_length_scale,
3899 multiscale: spec.multiscale,
3900 identifiability: MeasureJetIdentifiability::FrozenTransform {
3901 transform: constraint_transform.clone().expect("fit-time z"),
3902 },
3903 frozen_quadrature: Some(MeasureJetFrozenQuadrature {
3904 masses: masses.clone(),
3905 eps_band: eps_band.clone(),
3906 support_means: support_means.clone(),
3907 penalty_normalization_scales: penalty_normalization_scales.clone(),
3908 raw_penalty_normalization_scales: raw_penalty_normalization_scales.clone(),
3909 fused_penalty_normalization_scale: *fused_penalty_normalization_scale,
3910 sigma_coord: None,
3911 }),
3912 };
3913 // Per-level mode: one candidate per band scale plus the function-space
3914 // null component, and the count must survive replay bit-for-bit.
3915 assert_eq!(
3916 first.active_penalties.len(),
3917 eps_band.len() + 1,
3918 "per-level mode must emit one candidate per scale + null component"
3919 );
3920 let second = build_measure_jet_basis(data.view(), &replay_spec).expect("replay build");
3921 let x1 = first.design.to_dense();
3922 let x2 = second.design.to_dense();
3923 assert_eq!(x1.shape(), x2.shape());
3924 for (a, b) in x1.iter().zip(x2.iter()) {
3925 assert!((a - b).abs() <= 1e-12, "design replay drift: {a} vs {b}");
3926 }
3927 assert_eq!(first.active_penalties.len(), second.active_penalties.len());
3928 for (p1, p2) in first
3929 .active_penalties
3930 .iter()
3931 .zip(second.active_penalties.iter())
3932 {
3933 for (a, b) in p1.matrix.iter().zip(p2.matrix.iter()) {
3934 assert!((a - b).abs() <= 1e-12, "penalty replay drift: {a} vs {b}");
3935 }
3936 }
3937 }
3938}