Skip to main content

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 opt-in design-moving dial (#1116). Consequences:
76//!
77//! - **Penalty-dial design drift is identically zero**: the (s, α, τ) dials
78//!   reweight only the jet-energy penalty, never the Gaussian representer
79//!   design (`∂X/∂{s,α,τ} ≡ 0`), so those channels are penalty-only
80//!   (`is_penalty_like` auto-derives true in the outer engine's
81//!   `DirectionalHyperParam`).
82//! - **The representer range ℓ is a design-and-pullback-moving dial** (matérn's
83//!   `log_kappa` analog, #1116): `X = K(data, centers; ℓ)·z` and the center
84//!   evaluation map `E = K(centers, centers; ℓ)·z` both depend on ℓ. The
85//!   center-value forms `Q` and `H₀` are ℓ-invariant, but their coefficient
86//!   pullbacks `EᵀQE` and `EᵀH₀E` are not; exact product-rule jets are shipped
87//!   alongside the design jets. When explicitly enabled, ℓ rebuilds the design
88//!   per outer trial; it does not change the frozen basis rank. FD-gated by
89//!   `psi_producer_matches_fd_length_scale`.
90//! - **Exact (s, α) penalty jets are shipped**:
91//!   [`measure_jet_energy_form_with_jets`] returns `∂Q/∂s`, `∂²Q/∂s²`,
92//!   `∂Q/∂α`, `∂²Q/∂α²`, `∂²Q/∂s∂α` in closed form — both dials enter only
93//!   through the per-block log-weights (`∂ln w/∂s = −2 ln ε`,
94//!   `∂ln w/∂α = −2 ln q`), so the jets are reweighted re-scatters of the
95//!   SAME residual blocks, FD-gated in this module's tests.
96//!   The retained τ coordinate is inert under the exact projection, so its
97//!   derivative slots are identically zero.
98//!
99//! # Cost shape (and the upgrade ladder above it)
100//!
101//! The outer sum is coarsened per scale to a deterministic ε/2-net (the
102//! outer Riemann sum needs resolution ε, not the center-spacing floor), so
103//! the band totals ~O(m²·d) instead of O(L·m³) — the current realization of
104//! the pyramid principle that each scale interacts at its own level. This is
105//! mass-lumped quadrature of the displayed outer integral; it is first-
106//! moment exact for the cell locations and carries the usual
107//! `O(diam²/ε²)` relative scale for smooth Gaussian-weighted functionals,
108//! not an estimand-preserving identity.
109//! The long-form home for the ladder and the substrate contracts is the
110//! frame notes (`docs/measure_jet_frame.md`); its §2 moment substrate is
111//! `measure_jet_moments.rs`, its §5 extrapolation pricing
112//! `measure_jet_predict.rs`.
113
114use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
115use rayon::prelude::*;
116use serde::{Deserialize, Serialize};
117
118use faer::Side;
119
120use gam_linalg::faer_ndarray::{FaerEigh, default_rrqr_rank_alpha, rrqr_nullspace_basis};
121
122use super::{
123    AnisoBasisPsiDerivatives, AnisoPenaltyCrossProvider, BasisBuildResult, BasisError,
124    BasisMetadata, CenterStrategy, ConstructiveQuadratic, PenaltyCandidate, PenaltySource,
125    filter_penalty_candidates, normalize_penalty, normalize_penalty_cross_psi_derivative,
126    normalize_penaltywith_psi_derivatives, select_centers_by_strategy, trace_of_product,
127};
128
129/// Truncation radius of the Gaussian profile in units of the scale ε: weights
130/// beyond `3ε` are below `e^{-4.5} ≈ 1.1e-2` of the peak and are dropped from
131/// both the local fit and the `q^(1−2α)` outer weight. This is an absolute
132/// kernel-weight cutoff; using the same truncated q keeps the discrete
133/// functional self-consistent, but it is not a relative tail-error bound.
134pub(crate) const MEASURE_JET_PROFILE_CUTOFF: f64 = 3.0;
135
136/// Relative eigenvalue threshold for rank-revealing pseudo-inverses of local
137/// Gram matrices. Directions at the roundoff floor are treated as unresolved
138/// and excluded from the affine fit.
139pub(crate) const MEASURE_JET_PSEUDOINVERSE_RTOL: f64 = 64.0 * f64::EPSILON;
140
141/// Default continuous smoothness order `s` realized by the `0.0` auto
142/// sentinel. Sits mid-band in the admissible `(0, 2)` for the affine-jet
143/// (r = 2) energy: rough enough to stay pointwise-defined on filaments and
144/// sheets (`s > p/2` for intrinsic `p ≤ 2`), smooth enough to bridge gaps
145/// with attested trends.
146pub(crate) const MEASURE_JET_DEFAULT_ORDER_S: f64 = 1.5;
147
148/// Auto-band scale-count clamp: at least 3 octave-ish nodes so the energy is
149/// genuinely multiscale, at most 8 so degenerate spacing cannot explode the
150/// build.
151pub(crate) const MEASURE_JET_MIN_AUTO_SCALES: usize = 3;
152pub(crate) const MEASURE_JET_MAX_AUTO_SCALES: usize = 8;
153
154/// Representer-range multiple of the median nearest-center spacing used by
155/// the `0.0` auto sentinel.
156///
157/// Set to ×1: a Gaussian representer of range `ℓ = h` (the median
158/// nearest-center spacing) already overlaps its neighbors at
159/// `exp(−h²/(2ℓ²)) = exp(−1/2) ≈ 0.61`, so adjacent bumps blend smoothly while
160/// each center keeps a *distinct* response. The old ×2 made every column
161/// `exp(−1/8) ≈ 0.88` at its neighbor — the representers became nearly
162/// collinear, which (a) over-smoothed the fitted surface (the #1116/#1041
163/// accuracy deficit: measure-jet sat ~1.6× the matern/duchon truth-RMSE) and
164/// (b) drove the design Gram toward rank deficiency, so the inner PIRLS /
165/// outer REML conditioning degraded and the smoothing-parameter search cycled
166/// for hundreds of seconds (the #1116 timeout). One spacing-width kernel fixes
167/// both at the root without touching the energy penalty or the dials.
168pub(crate) const MEASURE_JET_AUTO_LENGTH_SCALE_FACTOR: f64 = 1.0;
169
170/// Memory budget (in f64 entries) above which the multi-form assembly stops
171/// parallelizing over scales: parallel scale partials cost
172/// `L · n_forms · m²` doubles; past this budget the scales run sequentially
173/// (same numbers — the per-scale loop and the ordered sum are deterministic
174/// either way).
175pub(crate) const MEASURE_JET_PARALLEL_FORM_BUDGET_DOUBLES: usize = 1 << 26;
176
177/// Realized-design identifiability policy for the measure-jet smooth.
178/// Mirrors [`super::ConstantCurvatureIdentifiability`] (#532): the fit-time
179/// section gets the parametric orthogonalization composed onto it by the global
180/// identifiability pipeline, and the composed transform is frozen so
181/// predict-time (and per-ψ-trial) rebuilds replay it verbatim.
182#[derive(Debug, Clone, Serialize, Deserialize, Default)]
183pub enum MeasureJetIdentifiability {
184    /// Fit-time default. With the single-scale affine head, the RBF center
185    /// values are mass-orthogonalized against the affine value space and the
186    /// head passes through exactly; without a head, the representer coefficient
187    /// sum-to-zero section is used. Global parametric residualization follows.
188    #[default]
189    CenterSumToZero,
190    /// Predict-time replay: the frozen composed transform captured at fit
191    /// time. `transform.nrows()` equals representer count plus affine-head width.
192    FrozenTransform { transform: Array2<f64> },
193}
194
195/// Fit-time quadrature of the empirical measure (center masses + realized
196/// scale band), frozen onto the spec so predict-time rebuilds replay the
197/// exact fit-time penalty. Recomputing either from predict rows would
198/// silently change the penalty the coefficients were estimated under.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct MeasureJetFrozenQuadrature {
201    /// Per-center masses `m_i` (nearest-center fractions of the FIT rows).
202    pub masses: Array1<f64>,
203    /// Realized geometric scale band `ε_0 < … < ε_{L−1}`.
204    pub eps_band: Vec<f64>,
205    /// Per-scale on-web support anchor
206    /// `q̄_ℓ = (Σ_i m_i q_ℓ(c_i)) / (Σ_i m_i)`.
207    pub support_means: Vec<f64>,
208    /// Frobenius scales of the emitted per-level normalized penalties. Empty in
209    /// fused mode, where the band emits one primary penalty instead.
210    pub penalty_normalization_scales: Vec<f64>,
211    /// Frobenius scales of the raw per-level forms before the arbitrary Mellin
212    /// `log_step · ε_ℓ^(-2s0)` gauge is folded in.
213    pub raw_penalty_normalization_scales: Vec<f64>,
214    /// Frobenius scale of the single fused primary penalty. `None` in per-level
215    /// mode.
216    pub fused_penalty_normalization_scale: Option<f64>,
217    /// Ambient input-measurement-error scale `σ_coord` (issue #2225): the
218    /// perpendicular off-manifold residual spread of the fit-time empirical
219    /// measure, in the frozen centers' (standardized) coordinate frame. Frozen
220    /// so the predict-time errors-in-variables variance term
221    /// `Var_input = σ_coord²·‖∇f̂‖²` uses the same input-noise scale the fit
222    /// saw. `None` when it could not be estimated (no cell spanned a tangent),
223    /// leaving `Var_input` disabled. Defaults to `None` for models persisted
224    /// before the term existed.
225    #[serde(default)]
226    pub sigma_coord: Option<f64>,
227}
228
229/// Serde default for [`MeasureJetBasisSpec::learn_length_scale`]: freeze ℓ at
230/// the realized auto/user value unless a fit explicitly opts into the
231/// design-moving outer coordinate.
232fn measure_jet_learn_length_scale_default() -> bool {
233    false
234}
235
236/// Measure-jet smooth configuration (`mjs(x0, …, xd)`).
237///
238/// The feature columns are ambient coordinates of data concentrated near an
239/// unknown low-dimensional (possibly stratified) set; the term learns the
240/// geometry from the empirical measure itself — centers as quadrature nodes,
241/// masses as μ-weights, local jet residuals as the roughness carrier — with
242/// no graph, mesh, or neighbor-set inside the statistical object.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct MeasureJetBasisSpec {
245    /// Center/knot selection strategy (deterministic; quadrature of μ).
246    pub center_strategy: CenterStrategy,
247    /// Continuous smoothness order `s ∈ (0, 2)`; `0.0` sentinel = auto
248    /// ([`MEASURE_JET_DEFAULT_ORDER_S`]).
249    pub order_s: f64,
250    /// Density-normalization exponent α (outer weight `q^{1−2α}`).
251    pub alpha: f64,
252    /// Historical τ coordinate retained for frozen specs and ψ layout. The
253    /// measure-jet energy itself uses the exact weighted affine projection and
254    /// is independent of τ; the τ ψ derivatives are therefore zero.
255    pub tau0: f64,
256    /// Number of scale nodes; `0` sentinel = auto dyadic band.
257    pub num_scales: usize,
258    /// Representer (Gaussian RBF) range ℓ; `0.0` sentinel = auto
259    /// (median nearest-center spacing × [`MEASURE_JET_AUTO_LENGTH_SCALE_FACTOR`]).
260    pub length_scale: f64,
261    /// Add a separate function-space affine/null-component penalty alongside
262    /// the jet-energy penalty. Its strength is independently REML-selected.
263    pub double_penalty: bool,
264    /// REML-learn the representer range ℓ as a design-moving outer dial
265    /// (opt-in), mirroring Matérn's `log_kappa`. The Gaussian kernel is
266    /// strictly PD for every ℓ > 0, so ℓ does NOT change the basis rank (always
267    /// `m` centers) — but it changes WHICH `m`-dim subspace the representers
268    /// span, i.e. the span alignment with the true surface. The stable default
269    /// freezes ℓ at the auto/user value; `true` enrolls the outer coordinate for
270    /// experiments that need REML-selected representer range.
271    #[serde(default = "measure_jet_learn_length_scale_default")]
272    pub learn_length_scale: bool,
273    /// Explicit opt-in for multiscale mode: the per-scale spectral penalty
274    /// split plus the `(α, ln τ)` outer ψ dials. `false` (default) keeps the
275    /// energy in single-scale mode at ANY center count. The separate
276    /// `double_penalty` null component is available in both modes. There is no
277    /// center-count auto-gate; the user opts in via
278    /// `mjs(…, multiscale=true)`. Persisted on the spec so freeze→replay enters
279    /// the same mode.
280    #[serde(default)]
281    pub multiscale: bool,
282    /// Realized-design identifiability policy (see type docs).
283    #[serde(default)]
284    pub identifiability: MeasureJetIdentifiability,
285    /// Fit-time quadrature replay (see type docs). `None` at fit time;
286    /// `Some` on the frozen predict/ψ-trial path.
287    #[serde(default)]
288    pub frozen_quadrature: Option<MeasureJetFrozenQuadrature>,
289}
290
291impl Default for MeasureJetBasisSpec {
292    fn default() -> Self {
293        Self {
294            center_strategy: CenterStrategy::FarthestPoint { num_centers: 50 },
295            order_s: 0.0,
296            // Density-WEIGHTED Hessian energy (the module-header default): the
297            // outer weight is q^{1−2α} = q^{−1} at α = 1. The density-free
298            // variant α = 3/2 gives q^{−2}, which on a low-intrinsic-dimension
299            // stratum (data on a 1-D/2-D manifold embedded in higher ambient d)
300            // makes the local kernel mass q tiny AND spatially varying along
301            // the manifold, so q^{−2} amplifies the penalty unevenly and
302            // over-smooths the high-frequency signal there (MEASURED #1116: on
303            // the 1-D-curve-in-3-D fixture α = 3/2 left mjs ~13× worse than
304            // matérn). α = 1's q^{−1} weighting is far gentler and is the
305            // header-derived default; an explicit `alpha=` still overrides for
306            // genuinely density-free use on a full-dimensional stratum.
307            alpha: 1.0,
308            tau0: 1e-3,
309            num_scales: 0,
310            length_scale: 0.0,
311            double_penalty: true,
312            learn_length_scale: false,
313            multiscale: false,
314            identifiability: MeasureJetIdentifiability::CenterSumToZero,
315            frozen_quadrature: None,
316        }
317    }
318}
319
320/// Realized geometric scale band: `eps` ascending, `log_step` the constant
321/// log-spacing `ln(eps[ℓ+1]/eps[ℓ])` used as the Mellin quadrature weight.
322pub struct MeasureJetBand {
323    pub eps: Vec<f64>,
324    pub log_step: f64,
325}
326
327/// The energy and its exact hyperparameter jets in the live dials. `s` and
328/// `α` enter only through per-block log-weights. The retained `ln τ` slots
329/// are zero because the local fit is the exact weighted affine projection
330/// and no longer depends on τ. All forms are scattered from the SAME local
331/// residual blocks, and the ψ-channel consumes them with zero design drift.
332pub struct MeasureJetEnergyJets {
333    pub q: Array2<f64>,
334    pub dq_ds: Array2<f64>,
335    pub d2q_ds2: Array2<f64>,
336    pub dq_dalpha: Array2<f64>,
337    pub d2q_dalpha2: Array2<f64>,
338    pub d2q_ds_dalpha: Array2<f64>,
339    pub dq_dlogtau: Array2<f64>,
340    pub d2q_dlogtau2: Array2<f64>,
341    pub d2q_ds_dlogtau: Array2<f64>,
342    pub d2q_dalpha_dlogtau: Array2<f64>,
343}
344
345/// Householder vector `u` for the uniform sum-to-zero constraint: the
346/// reflection `H = I − 2uuᵀ` maps `c̄ = 1/√m·1` onto `e₁`, so columns 2..m
347/// of `H` are an orthonormal basis of `1⊥` — the same model space as the
348/// generic RRQR nullspace basis, but with O(rows·m) STRUCTURED application
349/// (`X·z = (X − 2(Xu)uᵀ) minus column 1`) instead of the O(rows·m²)
350/// constraint GEMM that the scale-smoke gate identified as the dominant
351/// build cost.
352pub(crate) fn householder_sum_to_zero_u(m: usize) -> Array1<f64> {
353    let c = 1.0 / (m as f64).sqrt();
354    let mut u = Array1::<f64>::from_elem(m, c);
355    u[0] -= 1.0;
356    let norm = u.dot(&u).sqrt();
357    u.mapv_inplace(|v| v / norm);
358    u
359}
360
361/// Materialize the Householder sum-to-zero basis `z` (m × (m−1)) — columns
362/// 2..m of `H = I − 2uuᵀ` — for the frozen-replay metadata. O(m²), built
363/// once per fit.
364pub(crate) fn householder_sum_to_zero_z(u: &Array1<f64>) -> Array2<f64> {
365    let m = u.len();
366    let mut z = Array2::<f64>::zeros((m, m - 1));
367    for j in 0..(m - 1) {
368        for i in 0..m {
369            let h = if i == j + 1 { 1.0 } else { 0.0 } - 2.0 * u[i] * u[j + 1];
370            z[(i, j)] = h;
371        }
372    }
373    z
374}
375
376pub(crate) fn symmetric_pseudoinverse(
377    a: &Array2<f64>,
378    label: &str,
379) -> Result<Array2<f64>, BasisError> {
380    let n = a.nrows();
381    if a.ncols() != n {
382        crate::bail_dim_basis!(
383            "measure-jet pseudo-inverse `{label}` needs a square matrix, got {:?}",
384            a.dim()
385        );
386    }
387    let (evals, evecs) = a.eigh(Side::Lower).map_err(|e| {
388        BasisError::InvalidInput(format!(
389            "measure-jet pseudo-inverse `{label}` eigendecomposition failed: {e}"
390        ))
391    })?;
392    let lam_max = evals.iter().fold(0.0_f64, |acc, v| acc.max((*v).max(0.0)));
393    let rank_tol = MEASURE_JET_PSEUDOINVERSE_RTOL * (n.max(1) as f64) * lam_max;
394    let mut scaled = evecs.clone();
395    for (k, mut col) in scaled.axis_iter_mut(Axis(1)).enumerate() {
396        let lam = evals[k].max(0.0);
397        let inv = if lam > rank_tol { 1.0 / lam } else { 0.0 };
398        col.mapv_inplace(|v| v * inv);
399    }
400    Ok(scaled.dot(&evecs.t()))
401}
402
403/// Rank-adapted center values of the measure-jet energy's affine null space.
404///
405/// The first column is the constant. The remaining columns are the supported
406/// ambient-linear directions returned by [`measure_jet_affine_head_transform`].
407/// Using that transform makes the basis full-column-rank even when the centers
408/// lie on a lower-dimensional affine stratum of the ambient coordinates.
409fn measure_jet_affine_value_basis(
410    centers: ArrayView2<'_, f64>,
411    masses: ArrayView1<'_, f64>,
412) -> Array2<f64> {
413    let m = centers.nrows();
414    let head_transform = measure_jet_affine_head_transform(centers, masses);
415    let head_rank = head_transform.ncols();
416    let mut affine = Array2::<f64>::ones((m, head_rank + 1));
417    if head_rank > 0 {
418        affine
419            .slice_mut(ndarray::s![.., 1..])
420            .assign(&centers.dot(&head_transform));
421    }
422    affine
423}
424
425/// Mass-metric quadratic form selecting the affine/null component of center
426/// function values:
427///
428/// `H₀ = W A (Aᵀ W A)⁺ Aᵀ W`.
429///
430/// This is a function-space object: `vᵀH₀v` is the squared mass norm of the
431/// affine projection of the center values `v`. No coefficient metric enters.
432fn affine_function_nullspace_form(
433    centers: ArrayView2<'_, f64>,
434    masses: ArrayView1<'_, f64>,
435) -> Result<Array2<f64>, BasisError> {
436    let m = centers.nrows();
437    if masses.len() != m {
438        crate::bail_dim_basis!(
439            "measure-jet affine function-space form shape mismatch: centers {:?}, masses {}",
440            centers.dim(),
441            masses.len()
442        );
443    }
444    let affine = measure_jet_affine_value_basis(centers, masses);
445    let mut weighted_affine = affine.clone();
446    for (i, mut row) in weighted_affine.outer_iter_mut().enumerate() {
447        row.mapv_inplace(|v| v * masses[i]);
448    }
449    let affine_gram = affine.t().dot(&weighted_affine);
450    let affine_gram_pinv = symmetric_pseudoinverse(&affine_gram, "affine function-space Gram")?;
451    let form = weighted_affine
452        .dot(&affine_gram_pinv)
453        .dot(&weighted_affine.t());
454    Ok((&form + &form.t()) * 0.5)
455}
456
457/// Pull a center-value quadratic form back through an evaluation map.
458fn pullback_center_form(evaluation: &Array2<f64>, form: &Array2<f64>) -> Array2<f64> {
459    let pulled = evaluation.t().dot(form).dot(evaluation);
460    (&pulled + &pulled.t()) * 0.5
461}
462
463/// First and diagonal-second `u = ln ℓ` derivatives of `E(u)ᵀ H E(u)` for a
464/// `u`-invariant center-value form `H`.
465fn pullback_center_form_log_length_jets(
466    evaluation: &Array2<f64>,
467    evaluation_first: &Array2<f64>,
468    evaluation_second: &Array2<f64>,
469    form: &Array2<f64>,
470) -> (Array2<f64>, Array2<f64>) {
471    let h_e = form.dot(evaluation);
472    let h_e_first = form.dot(evaluation_first);
473    let h_e_second = form.dot(evaluation_second);
474    let first_raw = evaluation_first.t().dot(&h_e) + evaluation.t().dot(&h_e_first);
475    let second_raw = evaluation_second.t().dot(&h_e)
476        + evaluation.t().dot(&h_e_second)
477        + evaluation_first.t().dot(&h_e_first) * 2.0;
478    (
479        (&first_raw + &first_raw.t()) * 0.5,
480        (&second_raw + &second_raw.t()) * 0.5,
481    )
482}
483
484/// Mixed derivative `∂²(EᵀH(ψ)E)/(∂lnℓ ∂ψ)` when only `E` depends on `ℓ`.
485fn pullback_center_form_log_length_cross(
486    evaluation: &Array2<f64>,
487    evaluation_first: &Array2<f64>,
488    form_first: &Array2<f64>,
489) -> Array2<f64> {
490    let h_e = form_first.dot(evaluation);
491    let h_e_first = form_first.dot(evaluation_first);
492    let cross_raw = evaluation_first.t().dot(&h_e) + evaluation.t().dot(&h_e_first);
493    (&cross_raw + &cross_raw.t()) * 0.5
494}
495
496/// Function-space affine/null-component penalty in the current coefficient
497/// chart. Under a coefficient reparameterization `E -> E R`, this matrix
498/// transforms covariantly as `S₀ -> Rᵀ S₀ R`; the statistical functional is
499/// therefore independent of coefficient scaling.
500pub(crate) fn affine_function_nullspace_penalty(
501    evaluation: &Array2<f64>,
502    centers: ArrayView2<'_, f64>,
503    masses: ArrayView1<'_, f64>,
504) -> Result<Array2<f64>, BasisError> {
505    if evaluation.nrows() != centers.nrows() {
506        crate::bail_dim_basis!(
507            "measure-jet affine function-space penalty shape mismatch: evaluation {:?}, centers {:?}",
508            evaluation.dim(),
509            centers.dim()
510        );
511    }
512    let form = affine_function_nullspace_form(centers, masses)?;
513    Ok(pullback_center_form(evaluation, &form))
514}
515
516/// Pairwise squared distances `‖a_i − b_j‖²` via the GEMM identity
517/// `‖a − b‖² = ‖a‖² + ‖b‖² − 2·aᵀb`: one (n×d)·(d×m) matrix product carries
518/// every FMA at tile speed instead of n·m scalar distance loops — the
519/// machine-native form of this kernel, and the module's ONLY distance
520/// source (representer design, support curve, and the center-pair geometry:
521/// band floor, median spacing, ε/2-net, neighbor cutoffs). The cancellation
522/// error near-coincident points pay is O(ε_f64·‖x‖²) ABSOLUTE, harmless
523/// under a Gaussian profile (the kernel is flat at d ≈ 0); clamped at zero
524/// so roundoff cannot emit tiny negatives (the a = b diagonal therefore
525/// lands at roundoff scale, not an exact 0 — no caller pins it).
526pub(crate) fn pairwise_sq_dists(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Array2<f64> {
527    let an: Vec<f64> = a.outer_iter().map(|r| r.dot(&r)).collect();
528    let bn: Vec<f64> = b.outer_iter().map(|r| r.dot(&r)).collect();
529    let mut g = a.dot(&b.t());
530    g.axis_iter_mut(Axis(0))
531        .into_par_iter()
532        .enumerate()
533        .for_each(|(i, mut row)| {
534            for (j, v) in row.iter_mut().enumerate() {
535                *v = (an[i] + bn[j] - 2.0 * *v).max(0.0);
536            }
537        });
538    g
539}
540
541/// Row-block size for streaming GEMM passes that must not materialize the
542/// full n×m distance matrix (nearest-node assignment): 64Ki rows × m ≤ a
543/// few hundred MB of transient per block, GEMM-speed throughout.
544pub(crate) const MEASURE_JET_ASSIGN_BLOCK_ROWS: usize = 65_536;
545
546pub(crate) fn validate_finite_points(
547    points: ArrayView2<'_, f64>,
548    what: &str,
549) -> Result<(), BasisError> {
550    for (i, row) in points.outer_iter().enumerate() {
551        if row.iter().any(|v| !v.is_finite()) {
552            crate::bail_invalid_basis!("measure-jet {what} row {i} has a non-finite coordinate");
553        }
554    }
555    Ok(())
556}
557
558/// Median nearest-OTHER-center distance — the resolution floor of the center
559/// quadrature, used for the band floor and the auto representer range.
560pub(crate) fn median_nearest_center_spacing(dist2: &Array2<f64>) -> Result<f64, BasisError> {
561    let m = dist2.nrows();
562    if m < 2 {
563        return Err(BasisError::InsufficientColumnsForConstraint { found: m });
564    }
565    let mut nearest: Vec<f64> = Vec::with_capacity(m);
566    for i in 0..m {
567        let mut best = f64::INFINITY;
568        for j in 0..m {
569            if j != i && dist2[(i, j)] < best {
570                best = dist2[(i, j)];
571            }
572        }
573        nearest.push(best.sqrt());
574    }
575    nearest.sort_by(|a, b| a.partial_cmp(b).expect("finite center spacings"));
576    let median = nearest[nearest.len() / 2];
577    if !(median.is_finite() && median > 0.0) {
578        crate::bail_invalid_basis!(
579            "measure-jet centers are degenerate (median nearest-center spacing = {median}); \
580             duplicate centers cannot carry a scale band"
581        );
582    }
583    Ok(median)
584}
585
586/// Build the realized geometric scale band from the center set: floor at the
587/// median nearest-center spacing (below it the quadrature resolves nothing),
588/// ceiling at half the bounding-box diagonal (a deterministic diameter-scale
589/// cap; local fits remain center-weighted and distinct there).
590/// `num_scales == 0` requests the auto count `clamp(⌈log2(ε_max/ε_min)⌉ + 1,
591/// 3, 8)`; a degenerate band (ceiling ≤ floor) collapses to the single floor
592/// scale with `log_step = ln 2`.
593pub fn measure_jet_band(
594    centers: ArrayView2<'_, f64>,
595    num_scales: usize,
596) -> Result<MeasureJetBand, BasisError> {
597    validate_finite_points(centers, "centers")?;
598    let dist2 = pairwise_sq_dists(centers, centers);
599    let eps_min = median_nearest_center_spacing(&dist2)?;
600    // Half the bounding-box diagonal: a cheap, deterministic diameter proxy.
601    let d = centers.ncols();
602    let mut diag2 = 0.0_f64;
603    for k in 0..d {
604        let col = centers.column(k);
605        let mut lo = f64::INFINITY;
606        let mut hi = f64::NEG_INFINITY;
607        for &v in col.iter() {
608            lo = lo.min(v);
609            hi = hi.max(v);
610        }
611        diag2 += (hi - lo) * (hi - lo);
612    }
613    let eps_max = 0.5 * diag2.sqrt();
614    if !(eps_max.is_finite() && eps_max > eps_min) {
615        return Ok(MeasureJetBand {
616            eps: vec![eps_min],
617            log_step: std::f64::consts::LN_2,
618        });
619    }
620    let auto = ((eps_max / eps_min).log2().ceil() as usize + 1)
621        .clamp(MEASURE_JET_MIN_AUTO_SCALES, MEASURE_JET_MAX_AUTO_SCALES);
622    let count = if num_scales == 0 { auto } else { num_scales };
623    if count == 1 {
624        return Ok(MeasureJetBand {
625            eps: vec![eps_min],
626            log_step: std::f64::consts::LN_2,
627        });
628    }
629    let ratio = (eps_max / eps_min).powf(1.0 / (count as f64 - 1.0));
630    let mut eps = Vec::with_capacity(count);
631    let mut e = eps_min;
632    for _ in 0..count {
633        eps.push(e);
634        e *= ratio;
635    }
636    Ok(MeasureJetBand {
637        eps,
638        log_step: ratio.ln(),
639    })
640}
641
642/// First-moment-exact quadrature of the empirical measure on the cell partition
643/// induced by the seed centers: nearest-center assignment (deterministic
644/// tie-break: lowest center index) yields per-cell masses, and each non-empty
645/// cell's quadrature node is its mass-weighted barycenter. Empty cells keep
646/// their seed coordinates with zero mass (the assembly skips them; their
647/// representer columns remain valid).
648pub fn measure_jet_quadrature_nodes(
649    data: ArrayView2<'_, f64>,
650    centers: ArrayView2<'_, f64>,
651) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
652    if data.ncols() != centers.ncols() {
653        crate::bail_dim_basis!(
654            "measure-jet mass assignment dimension mismatch: data d={} centers d={}",
655            data.ncols(),
656            centers.ncols()
657        );
658    }
659    validate_finite_points(data, "data")?;
660    validate_finite_points(centers, "centers")?;
661    let n = data.nrows();
662    let m = centers.nrows();
663    let d = centers.ncols();
664    if n == 0 || m == 0 {
665        crate::bail_invalid_basis!("measure-jet mass assignment needs nonempty data and centers");
666    }
667    // Nearest-node assignment in streamed GEMM blocks: argmin_j ‖x−c_j‖² =
668    // argmin_j (‖c_j‖² − 2·xᵀc_j), so each block is one (rows×d)·(d×m)
669    // product plus a row-wise argmin — tile-speed FMAs, O(block·m) transient
670    // memory, deterministic ties to the lowest center index.
671    let cn: Vec<f64> = centers.outer_iter().map(|r| r.dot(&r)).collect();
672    let assignments: Vec<usize> = (0..n)
673        .step_by(MEASURE_JET_ASSIGN_BLOCK_ROWS)
674        .flat_map(|start| {
675            let end = (start + MEASURE_JET_ASSIGN_BLOCK_ROWS).min(n);
676            let g = data.slice(ndarray::s![start..end, ..]).dot(&centers.t());
677            let block: Vec<usize> = g
678                .axis_iter(Axis(0))
679                .into_par_iter()
680                .map(|row| {
681                    let mut best_j = 0usize;
682                    let mut best = f64::INFINITY;
683                    for (j, &gij) in row.iter().enumerate() {
684                        let s = cn[j] - 2.0 * gij;
685                        if s < best {
686                            best = s;
687                            best_j = j;
688                        }
689                    }
690                    best_j
691                })
692                .collect();
693            block
694        })
695        .collect();
696    let mut masses = Array1::<f64>::zeros(m);
697    let mut nodes = centers.to_owned();
698    let mut sums = Array2::<f64>::zeros((m, d));
699    let unit = 1.0 / n as f64;
700    for (i, &j) in assignments.iter().enumerate() {
701        masses[j] += unit;
702        for k in 0..d {
703            sums[(j, k)] += data[(i, k)];
704        }
705    }
706    // Cell barycenters: the first moment of μ on each cell. These are the
707    // realized nodes for first-moment-exact lumping.
708    let mut barycenter = sums;
709    for j in 0..m {
710        let count = masses[j] * n as f64;
711        if count > 0.0 {
712            for k in 0..d {
713                barycenter[(j, k)] /= count;
714                nodes[(j, k)] = barycenter[(j, k)];
715            }
716        }
717    }
718    Ok((nodes, masses))
719}
720
721/// Per-center masses of the empirical measure (the zeroth-moment half of
722/// [`measure_jet_quadrature_nodes`]; single assignment source).
723pub fn measure_jet_center_masses(
724    data: ArrayView2<'_, f64>,
725    centers: ArrayView2<'_, f64>,
726) -> Result<Array1<f64>, BasisError> {
727    measure_jet_quadrature_nodes(data, centers).map(|(_, masses)| masses)
728}
729
730/// THE single assembly source: walk every (scale, outer-net center) local
731/// residual block exactly once and scatter it into `n_forms` accumulators
732/// with caller-chosen scalar weights. The energy, its (s, α) jets, and the
733/// per-scale spectrum are all this routine with different weight closures,
734/// so a value/derivative desync is structurally impossible.
735///
736/// Per block the closure receives `(scale_idx, eps, q, base)` where `q` is
737/// the truncated kernel sum used by the local residual and `base`
738/// is the fully-assembled outer weight
739/// `log_step · ε^(−η) · net_mass_i · q^(1−2α)`, with
740/// `η = 2s + d(2−2α)` for the available dimension parameter, and writes, per requested
741/// form, one weight triple `[w_R, w_2, w_3]`. Only `w_R` is live:
742/// `R = CᵀWC − B·G⁺·Bᵀ/q`, with `G⁺` the rank-revealing pseudo-inverse.
743/// The extra slots are retained for the ψ layout and receive zero local
744/// channels because τ no longer changes the energy.
745///
746/// The outer sum over centers is coarsened per scale to a deterministic
747/// ε/2-net with nearest-member mass aggregation (the outer Riemann sum needs
748/// resolution ε, not the center-spacing floor), so each scale's cost sits at
749/// its own level and the band totals ~O(m²·d) instead of O(L·m³). The inner
750/// (local-fit) quadrature always uses the full center set, so the local
751/// residual identities (exact constant annihilation, PSD) are untouched.
752pub(crate) fn assemble_weighted_forms<F>(
753    centers: ArrayView2<'_, f64>,
754    masses: ArrayView1<'_, f64>,
755    band: &MeasureJetBand,
756    order_s: f64,
757    alpha: f64,
758    tau0: f64,
759    n_forms: usize,
760    channels: usize,
761    weights: &F,
762) -> Result<Vec<Array2<f64>>, BasisError>
763where
764    F: Fn(usize, f64, f64, f64, &mut [[f64; 3]]) + Sync,
765{
766    let m = centers.nrows();
767    let d = centers.ncols();
768    if n_forms == 0 || !(1..=3).contains(&channels) {
769        crate::bail_invalid_basis!(
770            "measure-jet assembly needs at least one output form and 1..=3 block channels"
771        );
772    }
773    if masses.len() != m {
774        crate::bail_dim_basis!(
775            "measure-jet energy mass/center mismatch: {} masses for {} centers",
776            masses.len(),
777            m
778        );
779    }
780    if band.eps.is_empty() || band.eps.iter().any(|e| !(e.is_finite() && *e > 0.0)) {
781        crate::bail_invalid_basis!("measure-jet energy needs a nonempty positive scale band");
782    }
783    if !(order_s.is_finite() && order_s > 0.0 && order_s < 2.0) {
784        crate::bail_invalid_basis!(
785            "measure-jet order s must lie in (0, 2) for the affine-jet energy; got {order_s}"
786        );
787    }
788    if !(alpha.is_finite() && tau0.is_finite() && tau0 >= 0.0) {
789        crate::bail_invalid_basis!(
790            "measure-jet energy needs finite alpha and finite tau0 >= 0; got alpha={alpha}, tau0={tau0}"
791        );
792    }
793    if masses.iter().any(|v| !(v.is_finite() && *v >= 0.0)) {
794        crate::bail_invalid_basis!("measure-jet energy needs finite nonnegative center masses");
795    }
796    let dist2 = pairwise_sq_dists(centers, centers);
797
798    // One block of `n_forms` m×m accumulators per scale. Each scale's center
799    // loop is sequential and the cross-scale sum below runs in band order,
800    // so the result is bit-deterministic whether or not the scales
801    // themselves run in parallel.
802    let assemble_scale = |scale_idx: usize, eps: f64| -> Result<Vec<Array2<f64>>, BasisError> {
803        let mut out: Vec<Array2<f64>> =
804            (0..n_forms).map(|_| Array2::<f64>::zeros((m, m))).collect();
805        let cutoff2 = (MEASURE_JET_PROFILE_CUTOFF * eps) * (MEASURE_JET_PROFILE_CUTOFF * eps);
806        let inv_two_eps2 = 1.0 / (2.0 * eps * eps);
807        let eta = 2.0 * order_s + (d as f64) * (2.0 - 2.0 * alpha);
808        let scale_weight = band.log_step * eps.powf(-eta);
809        // Outer-quadrature coarsening: greedy ε/2-net over the centers in
810        // fixed index order (deterministic), with every center's mass
811        // aggregated to its nearest net member (lowest-index tie break).
812        let net_radius2 = 0.25 * eps * eps;
813        let mut outer: Vec<usize> = Vec::new();
814        for i in 0..m {
815            if masses[i] <= 0.0 {
816                continue;
817            }
818            let covered = outer.iter().any(|&o| dist2[(i, o)] <= net_radius2);
819            if !covered {
820                outer.push(i);
821            }
822        }
823        let mut net_mass = vec![0.0_f64; m];
824        for i in 0..m {
825            if masses[i] <= 0.0 {
826                continue;
827            }
828            let mut best = f64::INFINITY;
829            let mut best_o = usize::MAX;
830            for &o in &outer {
831                if dist2[(i, o)] < best {
832                    best = dist2[(i, o)];
833                    best_o = o;
834                }
835            }
836            if best_o != usize::MAX {
837                net_mass[best_o] += masses[i];
838            }
839        }
840        let mut wbuf = vec![[0.0_f64; 3]; n_forms];
841        for &i in &outer {
842            // Local neighbor set (always includes i itself).
843            let mut idx: Vec<usize> = Vec::new();
844            for j in 0..m {
845                if dist2[(i, j)] <= cutoff2 {
846                    idx.push(j);
847                }
848            }
849            let ml = idx.len();
850            // Kernel weights and mass.
851            let mut w = Array1::<f64>::zeros(ml);
852            let mut q = 0.0_f64;
853            for (a, &j) in idx.iter().enumerate() {
854                let wj = masses[j] * (-dist2[(i, j)] * inv_two_eps2).exp();
855                w[a] = wj;
856                q += wj;
857            }
858            if !(q > 0.0) {
859                continue;
860            }
861            // Scaled local features Φ (ml × d) and weighted column means a.
862            let mut phi = Array2::<f64>::zeros((ml, d));
863            for (a, &j) in idx.iter().enumerate() {
864                for k in 0..d {
865                    phi[(a, k)] = (centers[(j, k)] - centers[(i, k)]) / eps;
866                }
867            }
868            let a_mean = phi.t().dot(&w) / q;
869            // B = WΦ − w·aᵀ and G = (ΦᵀWΦ)/q − a·aᵀ.
870            let mut wphi = phi.clone();
871            for (a, mut row) in wphi.outer_iter_mut().enumerate() {
872                row.mapv_inplace(|v| v * w[a]);
873            }
874            let mut b = wphi.clone();
875            for (a, mut row) in b.outer_iter_mut().enumerate() {
876                for k in 0..d {
877                    row[k] -= w[a] * a_mean[k];
878                }
879            }
880            let mut g = phi.t().dot(&wphi);
881            g.mapv_inplace(|v| v / q);
882            for r in 0..d {
883                for c in 0..d {
884                    g[(r, c)] -= a_mean[r] * a_mean[c];
885                }
886            }
887            let g_pinv = symmetric_pseudoinverse(&g, "local affine Gram")?;
888            let bm = b.dot(&g_pinv);
889            let base = scale_weight * net_mass[i] * q.powf(1.0 - 2.0 * alpha);
890            weights(scale_idx, eps, q, base, &mut wbuf);
891            // Scatter-add Σ_k wbuf[k]·R into each form. The τ channels are
892            // zero because the exact projection is τ-independent.
893            for (a, &ja) in idx.iter().enumerate() {
894                let bma = bm.row(a);
895                for (c, &jc) in idx.iter().enumerate() {
896                    let b_c = b.row(c);
897                    let mut val_r = -w[a] * w[c] / q - bma.dot(&b_c) / q;
898                    if a == c {
899                        val_r += w[a];
900                    }
901                    for (k, out_k) in out.iter_mut().enumerate() {
902                        let wk = wbuf[k];
903                        out_k[(ja, jc)] += wk[0] * val_r;
904                    }
905                }
906            }
907        }
908        Ok(out)
909    };
910
911    let n_scales = band.eps.len();
912    let parallel_ok = m
913        .saturating_mul(m)
914        .saturating_mul(n_scales)
915        .saturating_mul(n_forms)
916        <= MEASURE_JET_PARALLEL_FORM_BUDGET_DOUBLES;
917    let per_scale: Vec<Vec<Array2<f64>>> = if parallel_ok {
918        band.eps
919            .par_iter()
920            .enumerate()
921            .map(|(scale_idx, &eps)| assemble_scale(scale_idx, eps))
922            .collect::<Result<Vec<_>, BasisError>>()?
923    } else {
924        band.eps
925            .iter()
926            .enumerate()
927            .map(|(scale_idx, &eps)| assemble_scale(scale_idx, eps))
928            .collect::<Result<Vec<_>, BasisError>>()?
929    };
930
931    let mut totals: Vec<Array2<f64>> = (0..n_forms).map(|_| Array2::<f64>::zeros((m, m))).collect();
932    for scale_forms in per_scale {
933        for (total, part) in totals.iter_mut().zip(scale_forms) {
934            *total += &part;
935        }
936    }
937    // Numerical symmetrization (every analytic form here is symmetric).
938    Ok(totals.into_iter().map(|t| (&t + &t.t()) * 0.5).collect())
939}
940
941/// The multiscale jet-residual energy `Q` (m × m, symmetric PSD) on the
942/// center set. See the module docs for the formula and contracts; the local
943/// residual form is assembled through the closed-form identities
944///
945/// ```text
946///   CᵀWC          = W − w·wᵀ/q,
947///   B = CᵀWΦ̃     = WΦ − w·aᵀ          (a = Φᵀw/q),
948///   G = Φ̃ᵀWΦ̃/q  = (ΦᵀWΦ)/q − a·aᵀ,
949///   R_loc         = CᵀWC − B·G⁺·Bᵀ/q,
950/// ```
951///
952/// with `G⁺` realized through the symmetric eigendecomposition and a
953/// machine-precision rank cutoff. One walk of [`assemble_weighted_forms`]
954/// with the unit weight.
955pub fn measure_jet_energy_form(
956    centers: ArrayView2<'_, f64>,
957    masses: ArrayView1<'_, f64>,
958    band: &MeasureJetBand,
959    order_s: f64,
960    alpha: f64,
961    tau0: f64,
962) -> Result<Array2<f64>, BasisError> {
963    let mut forms = assemble_weighted_forms(
964        centers,
965        masses,
966        band,
967        order_s,
968        alpha,
969        tau0,
970        1,
971        1,
972        &|_, _, _, base, out: &mut [[f64; 3]]| out[0] = [base, 0.0, 0.0],
973    )?;
974    let q = forms.swap_remove(0);
975    // The energy `Q = Σ wᵢ Rᵢ` is a nonnegative combination of analytically
976    // PSD local residual forms, so it is PSD in exact arithmetic. The affine
977    // span is annihilated to machine zero, where roundoff in the per-block
978    // pseudo-inverse and the centering cancellation leaves the smallest
979    // eigenvalue at ±ε_mach·‖Q‖. Project onto the PSD cone (floor negative
980    // eigenvalues at 0) so `vᵀQv ≥ 0` holds exactly for every `v`, including
981    // the affine directions the energy must annihilate.
982    project_symmetric_psd(q, "measure-jet energy form")
983}
984
985/// Project a symmetric matrix onto the PSD cone by flooring its negative
986/// eigenvalues at 0. Only sub-machine-precision negative eigenvalues are
987/// expected here (the form is analytically PSD); a meaningfully negative
988/// eigenvalue would indicate an assembly bug, so it is floored but the
989/// reconstruction otherwise preserves the spectrum exactly.
990pub(crate) fn project_symmetric_psd(
991    a: Array2<f64>,
992    label: &str,
993) -> Result<Array2<f64>, BasisError> {
994    let n = a.nrows();
995    if n == 0 {
996        return Ok(a);
997    }
998    let (evals, evecs) = a.eigh(Side::Lower).map_err(|e| {
999        BasisError::InvalidInput(format!(
1000            "measure-jet PSD projection `{label}` eigendecomposition failed: {e}"
1001        ))
1002    })?;
1003    if evals.iter().all(|&lam| lam >= 0.0) {
1004        return Ok(a);
1005    }
1006    let mut scaled = evecs.clone();
1007    for (k, mut col) in scaled.axis_iter_mut(Axis(1)).enumerate() {
1008        let lam = evals[k].max(0.0);
1009        col.mapv_inplace(|v| v * lam);
1010    }
1011    let psd = scaled.dot(&evecs.t());
1012    Ok((&psd + &psd.t()) * 0.5)
1013}
1014
1015/// The energy together with its exact first and second jets in the live
1016/// dials, plus zero slots for the retained `ψ_τ = ln τ` coordinate. With
1017/// `g_s = −2 ln ε`, `g_α = −2 ln q`:
1018///
1019/// ```text
1020///   ∂Q/∂s   = Σ g_s·w·R,        ∂²Q/∂s²   = Σ g_s²·w·R,
1021///   ∂Q/∂α   = Σ g_α·w·R,        ∂²Q/∂α²   = Σ g_α²·w·R,
1022///   ∂²Q/∂s∂α = Σ g_s·g_α·w·R,
1023///   ∂Q/∂ψ_τ = ∂²Q/∂ψ_τ² = ∂²Q/∂s∂ψ_τ = ∂²Q/∂α∂ψ_τ = 0.
1024/// ```
1025///
1026/// all scattered from the SAME local blocks as `Q` in one pass (no second
1027/// assembly that could drift). FD-gated in this module's tests. Requires
1028/// `tau0 > 0` only because the retained coordinate is `ln τ`.
1029pub fn measure_jet_energy_form_with_jets(
1030    centers: ArrayView2<'_, f64>,
1031    masses: ArrayView1<'_, f64>,
1032    band: &MeasureJetBand,
1033    order_s: f64,
1034    alpha: f64,
1035    tau0: f64,
1036) -> Result<MeasureJetEnergyJets, BasisError> {
1037    if !(tau0.is_finite() && tau0 > 0.0) {
1038        crate::bail_invalid_basis!(
1039            "measure-jet jets need tau0 > 0 because the retained τ coordinate is ln τ; got {tau0}"
1040        );
1041    }
1042    let mut forms = assemble_weighted_forms(
1043        centers,
1044        masses,
1045        band,
1046        order_s,
1047        alpha,
1048        tau0,
1049        10,
1050        3,
1051        &|_, eps: f64, q: f64, base: f64, out: &mut [[f64; 3]]| {
1052            let gs = -2.0 * eps.ln();
1053            let intrinsic_dim = centers.ncols() as f64;
1054            let ga = 2.0 * intrinsic_dim * eps.ln() - 2.0 * q.max(f64::MIN_POSITIVE).ln();
1055            out[0] = [base, 0.0, 0.0];
1056            out[1] = [gs * base, 0.0, 0.0];
1057            out[2] = [gs * gs * base, 0.0, 0.0];
1058            out[3] = [ga * base, 0.0, 0.0];
1059            out[4] = [ga * ga * base, 0.0, 0.0];
1060            out[5] = [gs * ga * base, 0.0, 0.0];
1061            out[6] = [0.0, 0.0, 0.0];
1062            out[7] = [0.0, 0.0, 0.0];
1063            out[8] = [0.0, 0.0, 0.0];
1064            out[9] = [0.0, 0.0, 0.0];
1065        },
1066    )?;
1067    let d2q_dalpha_dlogtau = forms.pop().expect("ten assembled forms");
1068    let d2q_ds_dlogtau = forms.pop().expect("ten assembled forms");
1069    let d2q_dlogtau2 = forms.pop().expect("ten assembled forms");
1070    let dq_dlogtau = forms.pop().expect("ten assembled forms");
1071    let d2q_ds_dalpha = forms.pop().expect("ten assembled forms");
1072    let d2q_dalpha2 = forms.pop().expect("ten assembled forms");
1073    let dq_dalpha = forms.pop().expect("ten assembled forms");
1074    let d2q_ds2 = forms.pop().expect("ten assembled forms");
1075    let dq_ds = forms.pop().expect("ten assembled forms");
1076    let q = forms.pop().expect("ten assembled forms");
1077    Ok(MeasureJetEnergyJets {
1078        q,
1079        dq_ds,
1080        d2q_ds2,
1081        dq_dalpha,
1082        d2q_dalpha2,
1083        d2q_ds_dalpha,
1084        dq_dlogtau,
1085        d2q_dlogtau2,
1086        d2q_ds_dlogtau,
1087        d2q_dalpha_dlogtau,
1088    })
1089}
1090
1091/// Per-scale energy decomposition of center values `v`: element ℓ is
1092/// `vᵀ Q_ℓ v`, the detail energy charged at scale `ε_ℓ`. Sums exactly to
1093/// `vᵀQv` (same blocks, one-hot weights) and doubles as the scale spectrum
1094/// diagnostic of the fitted intensity field — where along the band the
1095/// signal lives, and the analytic carrier of `∂/∂s` reweightings.
1096pub fn measure_jet_scale_spectrum(
1097    centers: ArrayView2<'_, f64>,
1098    masses: ArrayView1<'_, f64>,
1099    band: &MeasureJetBand,
1100    order_s: f64,
1101    alpha: f64,
1102    tau0: f64,
1103    values: ArrayView1<'_, f64>,
1104) -> Result<Vec<f64>, BasisError> {
1105    if values.len() != centers.nrows() {
1106        crate::bail_dim_basis!(
1107            "measure-jet scale spectrum needs one value per center: {} values for {} centers",
1108            values.len(),
1109            centers.nrows()
1110        );
1111    }
1112    let forms = measure_jet_energy_forms_per_scale(centers, masses, band, order_s, alpha, tau0)?;
1113    Ok(forms
1114        .iter()
1115        .map(|q_l| values.dot(&q_l.dot(&values)))
1116        .collect())
1117}
1118
1119/// The per-scale energy forms `Q_ℓ` (each m × m, symmetric PSD), with
1120/// `Σ_ℓ Q_ℓ = Q` exactly (same blocks, one-hot weights). These are the
1121/// spectral-split carriers: emitted as separate penalty candidates they let
1122/// the multi-penalty REML engine learn per-level amplitudes λ_ℓ directly —
1123/// scale adaptivity at ρ-speed with no rebuild and no new optimizer code.
1124pub fn measure_jet_energy_forms_per_scale(
1125    centers: ArrayView2<'_, f64>,
1126    masses: ArrayView1<'_, f64>,
1127    band: &MeasureJetBand,
1128    order_s: f64,
1129    alpha: f64,
1130    tau0: f64,
1131) -> Result<Vec<Array2<f64>>, BasisError> {
1132    let n_scales = band.eps.len();
1133    assemble_weighted_forms(
1134        centers,
1135        masses,
1136        band,
1137        order_s,
1138        alpha,
1139        tau0,
1140        n_scales,
1141        1,
1142        &|scale_idx, _, _, base, out: &mut [[f64; 3]]| {
1143            for (k, slot) in out.iter_mut().enumerate() {
1144                *slot = if k == scale_idx {
1145                    [base, 0.0, 0.0]
1146                } else {
1147                    [0.0, 0.0, 0.0]
1148                };
1149            }
1150        },
1151    )
1152}
1153
1154/// The support diagnostic `ε ↦ q_ε(x★)`: kernel mass of the (frozen) center
1155/// quadrature seen from each query point at every band scale (n_query × L).
1156/// A query ON the web sees its strand's mass already at fine scales; a query
1157/// OFF the web accumulates mass only once ε reaches its distance to the
1158/// support. This is the on-web-ness statistic shipped alongside predictions
1159/// — smooth, multiresolution, derived from the measure with no neighbor
1160/// sets.
1161pub fn measure_jet_support_curve(
1162    queries: ArrayView2<'_, f64>,
1163    centers: ArrayView2<'_, f64>,
1164    masses: ArrayView1<'_, f64>,
1165    eps_band: &[f64],
1166) -> Result<Array2<f64>, BasisError> {
1167    if queries.ncols() != centers.ncols() {
1168        crate::bail_dim_basis!(
1169            "measure-jet support curve dimension mismatch: queries d={} centers d={}",
1170            queries.ncols(),
1171            centers.ncols()
1172        );
1173    }
1174    if masses.len() != centers.nrows() {
1175        crate::bail_dim_basis!(
1176            "measure-jet support curve mass/center mismatch: {} masses for {} centers",
1177            masses.len(),
1178            centers.nrows()
1179        );
1180    }
1181    if eps_band.is_empty() || eps_band.iter().any(|e| !(e.is_finite() && *e > 0.0)) {
1182        crate::bail_invalid_basis!("measure-jet support curve needs a nonempty positive band");
1183    }
1184    validate_finite_points(queries, "queries")?;
1185    validate_finite_points(centers, "centers")?;
1186    let nq = queries.nrows();
1187    let nl = eps_band.len();
1188    // Distances once (GEMM), then every band scale reads the same d² row —
1189    // an L-fold saving over per-scale distance recomputation.
1190    let d2 = pairwise_sq_dists(queries, centers);
1191    let mut out = Array2::<f64>::zeros((nq, nl));
1192    out.axis_iter_mut(Axis(0))
1193        .into_par_iter()
1194        .enumerate()
1195        .for_each(|(qi, mut row)| {
1196            let d2_row = d2.row(qi);
1197            for (li, &eps) in eps_band.iter().enumerate() {
1198                let inv_two_eps2 = 1.0 / (2.0 * eps * eps);
1199                let mut acc = 0.0_f64;
1200                for (j, &dd) in d2_row.iter().enumerate() {
1201                    acc += masses[j] * (-dd * inv_two_eps2).exp();
1202                }
1203                row[li] = acc;
1204            }
1205        });
1206    Ok(out)
1207}
1208
1209pub(crate) fn measure_jet_support_means(
1210    centers: ArrayView2<'_, f64>,
1211    masses: ArrayView1<'_, f64>,
1212    eps_band: &[f64],
1213) -> Result<Vec<f64>, BasisError> {
1214    let total_mass = masses.sum();
1215    if !(total_mass.is_finite() && total_mass > 0.0) {
1216        crate::bail_invalid_basis!(
1217            "measure-jet support means need positive finite total mass; got {total_mass}"
1218        );
1219    }
1220    let support = measure_jet_support_curve(centers, centers, masses, eps_band)?;
1221    let mut means = vec![0.0_f64; eps_band.len()];
1222    for (i, row) in support.rows().into_iter().enumerate() {
1223        let mass = masses[i];
1224        for (mean, &q) in means.iter_mut().zip(row.iter()) {
1225            *mean += mass * q;
1226        }
1227    }
1228    for mean in &mut means {
1229        *mean /= total_mass;
1230        if !(*mean).is_finite() || *mean <= 0.0 {
1231            crate::bail_invalid_basis!(
1232                "measure-jet support mean must be positive and finite; got {mean}"
1233            );
1234        }
1235    }
1236    Ok(means)
1237}
1238
1239/// Gaussian representer features `exp(−‖x − c‖²/(2ℓ²))` (n × m).
1240pub fn measure_jet_design_matrix(
1241    data: ArrayView2<'_, f64>,
1242    centers: ArrayView2<'_, f64>,
1243    length_scale: f64,
1244) -> Result<Array2<f64>, BasisError> {
1245    if data.ncols() != centers.ncols() {
1246        crate::bail_dim_basis!(
1247            "measure-jet design dimension mismatch: data d={} centers d={}",
1248            data.ncols(),
1249            centers.ncols()
1250        );
1251    }
1252    if !(length_scale.is_finite() && length_scale > 0.0) {
1253        crate::bail_invalid_basis!(
1254            "measure-jet design needs a positive finite length_scale; got {length_scale}"
1255        );
1256    }
1257    validate_finite_points(data, "data")?;
1258    validate_finite_points(centers, "centers")?;
1259    let inv_two_l2 = 1.0 / (2.0 * length_scale * length_scale);
1260    // One GEMM for every distance, then the Gaussian applied in place — the
1261    // n×m allocation IS the output, no transient copy.
1262    let mut out = pairwise_sq_dists(data, centers);
1263    out.axis_iter_mut(Axis(0))
1264        .into_par_iter()
1265        .for_each(|mut row| {
1266            row.mapv_inplace(|d2| (-d2 * inv_two_l2).exp());
1267        });
1268    Ok(out)
1269}
1270
1271/// Exact first and diagonal-second derivatives of the Gaussian representer
1272/// design with respect to `u = ln ℓ`.
1273fn measure_jet_design_log_length_jets(
1274    data: ArrayView2<'_, f64>,
1275    centers: ArrayView2<'_, f64>,
1276    length_scale: f64,
1277) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
1278    let kernel = measure_jet_design_matrix(data, centers, length_scale)?;
1279    let squared_distances = pairwise_sq_dists(data, centers);
1280    let inv_l2 = 1.0 / (length_scale * length_scale);
1281    let mut first = kernel.clone();
1282    let mut second = kernel;
1283    for ((first_value, second_value), &distance_squared) in first
1284        .iter_mut()
1285        .zip(second.iter_mut())
1286        .zip(squared_distances.iter())
1287    {
1288        let a = distance_squared * inv_l2;
1289        let kernel_value = *first_value;
1290        *first_value = kernel_value * a;
1291        *second_value = kernel_value * (a * a - 2.0 * a);
1292    }
1293    Ok((first, second))
1294}
1295
1296/// Rank-revealing ambient-linear head lift `T` (d × head_rank) for the
1297/// extrapolation null space (#1845).
1298///
1299/// The measure-jet energy annihilates ambient-affine functions EXACTLY (the
1300/// no-mass contract), so the affine functions are the penalty's null space —
1301/// the directions the fit is free to extend across a training gap. But the
1302/// Gaussian representer design cannot REPRESENT a global affine function off
1303/// its support: a finite sum of decaying bumps reverts to the parametric
1304/// backbone away from the centers, so in a gap the fit collapses toward the
1305/// training mean instead of carrying the flank-attested trend. Completing the
1306/// smoothing-spline structure, the builder appends this ambient-linear null
1307/// space to the design as an UNPENALIZED head (the `{x_1..x_d}` head the frame
1308/// notes §1 pin as the property the representer basis lacked).
1309///
1310/// The head is data-derived and magic-free. Ambient coordinates of data on a
1311/// low intrinsic-dimension stratum are rank-deficient as linear trends, so the
1312/// coordinate columns are orthonormalized on the centers and the
1313/// numerically-degenerate directions dropped. Working in the mean-CENTERED
1314/// coordinate columns (the mass-weighted mean is the intercept's, not the
1315/// head's) makes the rank test measure the genuine spread of the centers along
1316/// each direction rather than its offset; the relative floor
1317/// [`MEASURE_JET_PSEUDOINVERSE_RTOL`] is the module's own numerical rank
1318/// tolerance (the same one the local Gram pseudo-inverses use). The returned
1319/// `T` satisfies `head(points) = points · T` (the mean-centering only informs
1320/// the keep/drop decision; the constant component of `points · T` is removed
1321/// downstream by the global parametric orthogonalization). `T` is a
1322/// deterministic function of the frozen centers + masses, so the frozen replay
1323/// path reconstructs the identical head with no persisted state. Public so the
1324/// predict-side errors-in-variables gradient (#2225) can reconstruct the head
1325/// lift `T` from the frozen centers + masses to differentiate the head block.
1326pub fn measure_jet_affine_head_transform(
1327    centers: ArrayView2<'_, f64>,
1328    masses: ArrayView1<'_, f64>,
1329) -> Array2<f64> {
1330    let m = centers.nrows();
1331    let d = centers.ncols();
1332    let total_mass = masses.sum();
1333    // Mass inner product on center values.
1334    let mdot = |u: &Array1<f64>, v: &Array1<f64>| -> f64 {
1335        let mut acc = 0.0;
1336        for i in 0..m {
1337            acc += masses[i] * u[i] * v[i];
1338        }
1339        acc
1340    };
1341    // Mean-centered coordinate columns: the mass-weighted mean is removed so the
1342    // residual mass-norm is the genuine spread of the centers along a direction,
1343    // not dominated by the coordinate's offset (which the intercept owns).
1344    let cols: Vec<Array1<f64>> = (0..d)
1345        .map(|k| {
1346            let col = centers.column(k).to_owned();
1347            let mean = if total_mass > 0.0 {
1348                mdot(&col, &Array1::ones(m)) / total_mass
1349            } else {
1350                0.0
1351            };
1352            col.mapv(|x| x - mean)
1353        })
1354        .collect();
1355    // Relative numerical rank floor from the centered coordinate-column scale.
1356    let max_norm = cols
1357        .iter()
1358        .fold(0.0_f64, |acc, c| acc.max(mdot(c, c).sqrt()));
1359    let drop_below =
1360        (MEASURE_JET_PSEUDOINVERSE_RTOL * (d.max(1) as f64) * max_norm).max(f64::MIN_POSITIVE);
1361    // Mass-weighted modified Gram–Schmidt on the centered columns; `t`
1362    // accumulates the lift in the ORIGINAL coordinate basis, so every kept head
1363    // column is `points · t_r` (up to the intercept-owned constant).
1364    let mut q_cols: Vec<Array1<f64>> = Vec::new();
1365    let mut t_cols: Vec<Array1<f64>> = Vec::new();
1366    for k in 0..d {
1367        let mut v = cols[k].clone();
1368        let mut t = Array1::<f64>::zeros(d);
1369        t[k] = 1.0;
1370        for (q, tq) in q_cols.iter().zip(t_cols.iter()) {
1371            let proj = mdot(q, &v);
1372            v.scaled_add(-proj, q);
1373            t.scaled_add(-proj, tq);
1374        }
1375        let norm = mdot(&v, &v).sqrt();
1376        if norm > drop_below {
1377            v.mapv_inplace(|x| x / norm);
1378            t.mapv_inplace(|x| x / norm);
1379            q_cols.push(v);
1380            t_cols.push(t);
1381        }
1382    }
1383    let head_rank = t_cols.len();
1384    let mut t_mat = Array2::<f64>::zeros((d, head_rank));
1385    for (r, t) in t_cols.into_iter().enumerate() {
1386        t_mat.column_mut(r).assign(&t);
1387    }
1388    t_mat
1389}
1390
1391/// Resolve the realized representer range ℓ. An explicit positive
1392/// `spec_length_scale` is used verbatim; the `0.0` sentinel auto-initializes
1393/// from the median nearest-center spacing (one spacing width: neighbors
1394/// overlap at exp(−1/2) ≈ 0.61, smooth blend without collinearity).
1395pub fn realized_measure_jet_length_scale(
1396    centers: ArrayView2<'_, f64>,
1397    spec_length_scale: f64,
1398) -> Result<f64, BasisError> {
1399    if spec_length_scale.is_finite() && spec_length_scale > 0.0 {
1400        return Ok(spec_length_scale);
1401    }
1402    if spec_length_scale != 0.0 {
1403        crate::bail_invalid_basis!(
1404            "measure-jet length_scale must be positive (or 0.0 for auto); got {spec_length_scale}"
1405        );
1406    }
1407    let dist2 = pairwise_sq_dists(centers, centers);
1408    let spacing = median_nearest_center_spacing(&dist2)?;
1409    Ok(MEASURE_JET_AUTO_LENGTH_SCALE_FACTOR * spacing)
1410}
1411
1412/// The realized, ψ-FIXED geometry shared by the basis builder and the
1413/// ψ-derivative producer — ONE realization source, so the penalty the fit
1414/// uses and the penalty the ψ-channel differentiates can never drift apart
1415/// (the #901 desync class, excluded structurally).
1416pub(crate) struct RealizedMeasureJetGeometry {
1417    pub(crate) centers: Array2<f64>,
1418    pub(crate) masses: Array1<f64>,
1419    pub(crate) eps_band: Vec<f64>,
1420    pub(crate) log_step: f64,
1421    pub(crate) length_scale: f64,
1422    /// Assembly order for the energy weights: the realized default in
1423    /// per-level mode (absorbed per candidate by normalization), the
1424    /// explicit value in fused mode.
1425    pub(crate) order_s_eval: f64,
1426    /// Spectral-split mode marker (`order_s == 0.0` sentinel).
1427    pub(crate) per_level: bool,
1428    pub(crate) z: Array2<f64>,
1429    pub(crate) coefficient_gauge: gam_problem::Gauge,
1430    pub(crate) kz: Array2<f64>,
1431    /// Ambient-linear head lift `T` (d × head_rank): the extrapolation
1432    /// null-space basis appended to the representer design (#1845). The head
1433    /// columns evaluate as `points · T`; empty (`d × 0`) when the geometry
1434    /// resolves no supported linear direction. Deterministic in the frozen
1435    /// centers + masses, so predict-time replay rebuilds it verbatim.
1436    pub(crate) head_transform: Array2<f64>,
1437}
1438
1439pub(crate) fn realize_measure_jet_geometry(
1440    data: ArrayView2<'_, f64>,
1441    spec: &MeasureJetBasisSpec,
1442) -> Result<RealizedMeasureJetGeometry, BasisError> {
1443    if data.ncols() == 0 {
1444        crate::bail_invalid_basis!("measure-jet smooth needs at least one feature column");
1445    }
1446    validate_finite_points(data, "data")?;
1447    let seed_centers = select_centers_by_strategy(data, &spec.center_strategy)?;
1448    let m = seed_centers.nrows();
1449    if m < 3 {
1450        return Err(BasisError::InsufficientColumnsForConstraint { found: m });
1451    }
1452    let order_s = if spec.order_s == 0.0 {
1453        MEASURE_JET_DEFAULT_ORDER_S
1454    } else {
1455        spec.order_s
1456    };
1457    // Quadrature realization. Fit path: the realized nodes are the cell
1458    // BARYCENTERS of the seed partition (first-moment-exact lumping of μ —
1459    // see `measure_jet_quadrature_nodes`), so the metadata's `centers` are
1460    // already the realized nodes and the frozen path (predict / ψ-trial,
1461    // `CenterStrategy::UserProvided`) replays them verbatim with the frozen
1462    // masses, band, support anchors, and normalization scales.
1463    let (centers, masses, eps_band, log_step) = match &spec.frozen_quadrature {
1464        Some(frozen) => {
1465            if frozen.masses.len() != m {
1466                crate::bail_dim_basis!(
1467                    "frozen measure-jet quadrature mismatch: {} masses for {} centers",
1468                    frozen.masses.len(),
1469                    m
1470                );
1471            }
1472            if frozen.eps_band.is_empty() {
1473                crate::bail_invalid_basis!("frozen measure-jet quadrature has an empty band");
1474            }
1475            let log_step = if frozen.eps_band.len() >= 2 {
1476                (frozen.eps_band[1] / frozen.eps_band[0]).ln()
1477            } else {
1478                std::f64::consts::LN_2
1479            };
1480            (
1481                seed_centers,
1482                frozen.masses.clone(),
1483                frozen.eps_band.clone(),
1484                log_step,
1485            )
1486        }
1487        None => {
1488            let (nodes, masses) = measure_jet_quadrature_nodes(data, seed_centers.view())?;
1489            let band = measure_jet_band(nodes.view(), spec.num_scales)?;
1490            (nodes, masses, band.eps, band.log_step)
1491        }
1492    };
1493    let length_scale = realized_measure_jet_length_scale(centers.view(), spec.length_scale)?;
1494    // Ambient-linear extrapolation head (#1845): the raw center space becomes
1495    // `[ m Gaussian representers | head_rank ambient-linear columns ]`. The head
1496    // carries the penalty's affine null space explicitly so the fit no longer
1497    // reverts to the parametric backbone (the training mean) across an
1498    // unsupported gap.
1499    // The extrapolation head is the single-scale (fused) gap-bridge path. In
1500    // multiscale mode the per-scale spectral penalties carry their own
1501    // structure and the design stays the pure representer basis (the per-level
1502    // replay + width contracts pin `m − 1` columns), so the head is added only
1503    // when the term is single-scale.
1504    let head_transform = if spec.multiscale {
1505        Array2::<f64>::zeros((centers.ncols(), 0))
1506    } else {
1507        measure_jet_affine_head_transform(centers.view(), masses.view())
1508    };
1509    let head_rank = head_transform.ncols();
1510    let m_aug = m + head_rank;
1511    let k_cc = measure_jet_design_matrix(centers.view(), centers.view(), length_scale)?;
1512    let head_cc = centers.dot(&head_transform);
1513    // Realized-design constraint transform. In single-scale mode the explicit
1514    // affine head and Gaussian representers can otherwise carry the same affine
1515    // CENTER values in two different ways. That is a genuine gauge redundancy,
1516    // not a reason to ridge either coefficient block. At fit time remove it
1517    // exactly by restricting the RBF center values to the mass-orthogonal
1518    // complement of the supported affine space:
1519    //
1520    //   C = A^T W K_cc,       Z_rbf = null(C).
1521    //
1522    // The head then passes through as an identity block. The frozen composed
1523    // `z · z_parametric` is replayed verbatim at prediction/ψ trials (#532), so
1524    // the rank-revealed section never changes after fit-time realization. In
1525    // multiscale mode there is no explicit head, hence no affine duplication;
1526    // retain the existing representer sum-to-zero section there.
1527    let (z, coefficient_gauge) = match &spec.identifiability {
1528        MeasureJetIdentifiability::FrozenTransform { transform } => {
1529            if transform.nrows() != m_aug {
1530                crate::bail_dim_basis!(
1531                    "frozen measure-jet identifiability transform mismatch: {} representers + {} head columns but transform has {} rows",
1532                    m,
1533                    head_rank,
1534                    transform.nrows()
1535                );
1536            }
1537            (
1538                transform.clone(),
1539                gam_problem::Gauge::from_block_transforms(&[transform.clone()]),
1540            )
1541        }
1542        MeasureJetIdentifiability::CenterSumToZero => {
1543            let z_rbf = if head_rank > 0 {
1544                let affine = measure_jet_affine_value_basis(centers.view(), masses.view());
1545                let mut weighted_affine = affine.clone();
1546                for (i, mut row) in weighted_affine.outer_iter_mut().enumerate() {
1547                    row.mapv_inplace(|v| v * masses[i]);
1548                }
1549                // `rrqr_nullspace_basis(B)` returns null(B^T). Here
1550                // `B = K_cc^T W A = C^T`, hence the returned columns span
1551                // null(C), exactly the required RBF coefficient section.
1552                let constraint_cross = k_cc.t().dot(&weighted_affine);
1553                rrqr_nullspace_basis(&constraint_cross, default_rrqr_rank_alpha())
1554                    .map_err(BasisError::LinalgError)?
1555                    .0
1556            } else {
1557                let u = householder_sum_to_zero_u(m);
1558                householder_sum_to_zero_z(&u)
1559            };
1560            let rbf_rank = z_rbf.ncols();
1561            let mut z_block = Array2::<f64>::zeros((m_aug, rbf_rank + head_rank));
1562            z_block
1563                .slice_mut(ndarray::s![..m, ..rbf_rank])
1564                .assign(&z_rbf);
1565            for r in 0..head_rank {
1566                z_block[(m + r, rbf_rank + r)] = 1.0;
1567            }
1568            (
1569                z_block.clone(),
1570                gam_problem::Gauge::from_block_transforms(&[z_block]),
1571            )
1572        }
1573    };
1574    // Augmented raw center matrix `[K(centers, centers) | centers · T]`, so the
1575    // restricted `kz` maps constrained coefficients to center nodal values for
1576    // BOTH the representers and the head; the energy annihilates the head block
1577    // (affine) to machine precision, so it stays the unpenalized null space.
1578    let mut k_aug = Array2::<f64>::zeros((m, m_aug));
1579    k_aug.slice_mut(ndarray::s![.., ..m]).assign(&k_cc);
1580    if head_rank > 0 {
1581        k_aug.slice_mut(ndarray::s![.., m..]).assign(&head_cc);
1582    }
1583    let kz = coefficient_gauge.restrict_design(&k_aug);
1584    Ok(RealizedMeasureJetGeometry {
1585        centers,
1586        masses,
1587        eps_band,
1588        log_step,
1589        length_scale,
1590        order_s_eval: order_s,
1591        // Multiscale (per-scale spectral) energy is an EXPLICIT opt-in (#1116):
1592        // one Primary energy at any center count unless the spec asks for the
1593        // scale split. The independent null-component candidate is orthogonal
1594        // to this mode decision. No center-count auto-gate.
1595        per_level: spec.multiscale,
1596        z,
1597        coefficient_gauge,
1598        kz,
1599        head_transform,
1600    })
1601}
1602
1603/// Estimate the ambient input-measurement-error scale `σ_coord` — the
1604/// perpendicular off-manifold residual spread of the empirical measure — for
1605/// the errors-in-variables predictive-variance term `Var_input = ∇f̂ᵀΣ_x∇f̂`,
1606/// `Σ_x = σ_coord²·I` (issue #2225).
1607///
1608/// The measure-jet models data concentrated near an unknown low-intrinsic-
1609/// dimension set sampled with isotropic ambient coordinate noise. In a
1610/// neighborhood the set is locally affine, so the noise lives in the ambient
1611/// directions ORTHOGONAL to the local tangent — exactly the smallest principal
1612/// directions of the local data covariance. This is the standard local-PCA
1613/// noise floor: for each center's nearest-assignment cell with enough points to
1614/// span a tangent (`≥ d + 1`, the linear-algebra rank requirement — not a tuned
1615/// knob), the smallest eigenvalue of the cell-local covariance estimates the
1616/// perpendicular variance `σ_coord²`; averaging over cells (weighted by the
1617/// cell count) pools the estimate. No response values, no smoothing dial, and
1618/// no magic constant enter — it is a pure function of the ambient point cloud
1619/// and the frozen centers, in the centers' (standardized) coordinate frame.
1620///
1621/// Returns `None` when no cell can span a tangent (e.g. `d`-dimensional data
1622/// with fewer than `d + 1` points per cell, or a full-dimensional stratum with
1623/// no separable perpendicular direction) — the caller then leaves `Var_input`
1624/// disabled rather than invent a scale.
1625pub fn measure_jet_input_noise_scale(
1626    data: ArrayView2<'_, f64>,
1627    centers: ArrayView2<'_, f64>,
1628) -> Result<Option<f64>, BasisError> {
1629    let d = data.ncols();
1630    let m = centers.nrows();
1631    if d == 0 || m == 0 || data.nrows() == 0 {
1632        return Ok(None);
1633    }
1634    if centers.ncols() != d {
1635        crate::bail_dim_basis!(
1636            "measure-jet input-noise estimate: data d={d} disagrees with centers d={}",
1637            centers.ncols()
1638        );
1639    }
1640    validate_finite_points(data, "data")?;
1641    validate_finite_points(centers, "centers")?;
1642    // Nearest-center assignment (the same rule that lumps the quadrature
1643    // masses): the squared-distance Gram, argmin per row.
1644    let sq = pairwise_sq_dists(data, centers);
1645    let mut members: Vec<Vec<usize>> = vec![Vec::new(); m];
1646    for (j, row) in sq.axis_iter(Axis(0)).enumerate() {
1647        let mut best = 0usize;
1648        let mut best_d = f64::INFINITY;
1649        for (i, &dij) in row.iter().enumerate() {
1650            if dij < best_d {
1651                best_d = dij;
1652                best = i;
1653            }
1654        }
1655        members[best].push(j);
1656    }
1657    let mut weighted_sum = 0.0_f64;
1658    let mut weight = 0.0_f64;
1659    for cell in &members {
1660        let n_i = cell.len();
1661        // A cell needs at least d + 1 points to define a full-rank local
1662        // covariance; otherwise its smallest eigenvalue is a spurious zero.
1663        if n_i < d + 1 {
1664            continue;
1665        }
1666        // Cell-local mean and covariance in ambient coordinates.
1667        let mut mean = Array1::<f64>::zeros(d);
1668        for &j in cell {
1669            mean += &data.row(j);
1670        }
1671        mean /= n_i as f64;
1672        let mut cov = Array2::<f64>::zeros((d, d));
1673        for &j in cell {
1674            let mut centered = data.row(j).to_owned();
1675            centered -= &mean;
1676            for a in 0..d {
1677                for b in 0..d {
1678                    cov[(a, b)] += centered[a] * centered[b];
1679                }
1680            }
1681        }
1682        cov /= n_i as f64;
1683        // Symmetrize against accumulation asymmetry, then read the smallest
1684        // eigenvalue = the perpendicular (noise) principal variance.
1685        let cov_sym = (&cov + &cov.t()) * 0.5;
1686        let (evals, _) = cov_sym.eigh(Side::Lower).map_err(|e| {
1687            BasisError::InvalidInput(format!(
1688                "measure-jet input-noise estimate: local covariance eigendecomposition failed: {e}"
1689            ))
1690        })?;
1691        let smallest = evals
1692            .iter()
1693            .copied()
1694            .fold(f64::INFINITY, |acc, v| acc.min(v))
1695            .max(0.0);
1696        if smallest.is_finite() {
1697            weighted_sum += n_i as f64 * smallest;
1698            weight += n_i as f64;
1699        }
1700    }
1701    if weight <= 0.0 {
1702        return Ok(None);
1703    }
1704    let sigma2 = weighted_sum / weight;
1705    if !(sigma2.is_finite() && sigma2 > 0.0) {
1706        return Ok(None);
1707    }
1708    Ok(Some(sigma2.sqrt()))
1709}
1710
1711/// Whether a measure-jet spec runs in multiscale mode (per-scale spectral
1712/// energies + `(α, ln τ)` ψ dials). The separate `double_penalty`
1713/// affine/null-component candidate is available in both modes. This is the
1714/// single source of truth shared by the builder and outer enrollment predicates,
1715/// so the energy layout and ψ dimension cannot disagree. Multiscale is an
1716/// explicit opt-in (`spec.multiscale`); there is no center-count auto-gate
1717/// (#1116).
1718pub fn measure_jet_multiscale_mode(spec: &MeasureJetBasisSpec) -> bool {
1719    spec.multiscale
1720}
1721
1722/// Build the measure-jet smooth: Gaussian representer design `K(data,
1723/// centers)·z`, multiscale jet-residual penalty (one candidate per scale in
1724/// spectral mode, one Primary in pinned-order mode), an optional separate
1725/// function-space null-component candidate, and the replayable
1726/// [`BasisMetadata::MeasureJet`]. The geometry comes from the
1727/// empirical measure (centers + masses + band) through the shared
1728/// realization helper — the same source the ψ-derivative producer uses.
1729pub fn build_measure_jet_basis(
1730    data: ArrayView2<'_, f64>,
1731    spec: &MeasureJetBasisSpec,
1732) -> Result<BasisBuildResult, BasisError> {
1733    let RealizedMeasureJetGeometry {
1734        centers,
1735        masses,
1736        eps_band,
1737        log_step,
1738        length_scale,
1739        order_s_eval: order_s,
1740        per_level,
1741        z,
1742        coefficient_gauge,
1743        kz,
1744        head_transform,
1745    } = realize_measure_jet_geometry(data, spec)?;
1746    let band = MeasureJetBand {
1747        eps: eps_band.clone(),
1748        log_step,
1749    };
1750    let m = centers.nrows();
1751    let head_rank = head_transform.ncols();
1752    let m_aug = m + head_rank;
1753    // Augmented raw design `[K(data, centers) | data · T]` (#1845): the head
1754    // columns are the ambient-linear extrapolation basis. The gauge restricts
1755    // BOTH blocks together, so the frozen composed transform replays the head
1756    // verbatim at predict time.
1757    let kernel_design = measure_jet_design_matrix(data, centers.view(), length_scale)?;
1758    let mut raw_design = Array2::<f64>::zeros((data.nrows(), m_aug));
1759    raw_design
1760        .slice_mut(ndarray::s![.., ..m])
1761        .assign(&kernel_design);
1762    if head_rank > 0 {
1763        let head_design = data.dot(&head_transform);
1764        raw_design
1765            .slice_mut(ndarray::s![.., m..])
1766            .assign(&head_design);
1767    }
1768    let constrained_design = coefficient_gauge.restrict_design(&raw_design);
1769    let design = gam_linalg::matrix::DesignMatrix::Dense(
1770        gam_linalg::matrix::DenseDesignMatrix::from(constrained_design),
1771    );
1772    let support_means = measure_jet_support_means(centers.view(), masses.view(), &eps_band)?;
1773    // Spectral/geometric split. With the auto order sentinel (order_s == 0.0)
1774    // the term emits one candidate PER scale: the multi-penalty REML engine
1775    // then learns the level amplitudes λ_ℓ directly — scale adaptivity at
1776    // ρ-speed, dead scales REML-deselected (the Duchon-ARD pattern) — and the
1777    // fitted order is read off the spectrum (ŝ = −½ · slope of ln λ̂_ℓ on
1778    // ln ε_ℓ) instead of being optimized. An explicit s > 0 pins the Mellin
1779    // weights and fuses the band into one candidate. The Mellin prefactor
1780    // ε^(−η)·log_step inside each per-scale form is absorbed by the
1781    // per-candidate Frobenius normalization, so REML owns the amplitudes
1782    // outright. The sentinel itself is persisted in the metadata as the mode
1783    // marker: a replay MUST re-enter the same mode or the penalty count
1784    // desyncs (the gam#860 trap class).
1785    let mut candidates = Vec::new();
1786    let mut penalty_normalization_scales = Vec::new();
1787    let mut raw_penalty_normalization_scales = Vec::new();
1788    let mut fused_penalty_normalization_scale = None;
1789    if per_level {
1790        let forms = measure_jet_energy_forms_per_scale(
1791            centers.view(),
1792            masses.view(),
1793            &band,
1794            order_s,
1795            spec.alpha,
1796            spec.tau0,
1797        )?;
1798        for (level, q_l) in forms.into_iter().enumerate() {
1799            let s_l = kz.t().dot(&q_l).dot(&kz);
1800            let (s_norm, c_l) = normalize_penalty(&((&s_l + &s_l.t()) * 0.5));
1801            let intrinsic_dim = centers.ncols() as f64;
1802            let eta = 2.0 * order_s + intrinsic_dim * (2.0 - 2.0 * spec.alpha);
1803            let scale_weight = log_step * eps_band[level].powf(-eta);
1804            penalty_normalization_scales.push(c_l);
1805            raw_penalty_normalization_scales.push(c_l / scale_weight);
1806            candidates.push(PenaltyCandidate {
1807                matrix: ConstructiveQuadratic::try_from_dense_psd(
1808                    s_norm,
1809                    "measure-jet scale penalty",
1810                )?,
1811                source: PenaltySource::Other(format!("measure_jet_scale_{level}")),
1812                normalization_scale: c_l,
1813                kronecker_factors: None,
1814                op: None,
1815            });
1816        }
1817    } else {
1818        let q_form = measure_jet_energy_form(
1819            centers.view(),
1820            masses.view(),
1821            &band,
1822            order_s,
1823            spec.alpha,
1824            spec.tau0,
1825        )?;
1826        // The Primary is exactly the jet-energy functional pulled back through
1827        // the center evaluation map. It is independent of `double_penalty`:
1828        // statistical selection is a distinct REML component below, never a
1829        // fixed coefficient toll fused into this estimand.
1830        let penalty = pullback_center_form(&kz, &q_form);
1831        let (penalty_norm, c_primary) = normalize_penalty(&penalty);
1832        fused_penalty_normalization_scale = Some(c_primary);
1833        candidates.push(PenaltyCandidate {
1834            matrix: ConstructiveQuadratic::try_from_dense_psd(
1835                penalty_norm,
1836                "measure-jet primary penalty",
1837            )?,
1838            source: PenaltySource::Primary,
1839            normalization_scale: c_primary,
1840            kronecker_factors: None,
1841            op: None,
1842        });
1843    }
1844    // Explicit null recovery is a genuine statistical component: penalize the
1845    // affine/null FUNCTION projection under the empirical-measure mass metric,
1846    // and let REML select its strength independently in both modes. This is the
1847    // standard double-penalty decomposition (roughness + null component); no
1848    // coefficient identity and no hard-coded mixture changes the Primary.
1849    if spec.double_penalty {
1850        let null_penalty = affine_function_nullspace_penalty(&kz, centers.view(), masses.view())?;
1851        let (null_penalty_norm, c_null) = normalize_penalty(&null_penalty);
1852        candidates.push(PenaltyCandidate {
1853            matrix: ConstructiveQuadratic::try_from_dense_psd(
1854                null_penalty_norm,
1855                "measure-jet null-function penalty",
1856            )?,
1857            source: PenaltySource::DoublePenaltyNullspace,
1858            normalization_scale: c_null,
1859            kronecker_factors: None,
1860            op: None,
1861        });
1862    }
1863    let filtered = filter_penalty_candidates(candidates)?;
1864    // #2225: compute the errors-in-variables input-noise scale while `centers`
1865    // is still owned; it is moved into the metadata `centers` field below.
1866    let sigma_coord = measure_jet_input_noise_scale(data, centers.view())?;
1867    Ok(BasisBuildResult {
1868        design,
1869        affine_offset: None,
1870        active_penalties: filtered.active,
1871        dropped_penalties: filtered.dropped,
1872        metadata: BasisMetadata::MeasureJet {
1873            centers,
1874            input_scale: crate::IsotropicScale::ONE,
1875            length_scale,
1876            eps_band,
1877            // The SPEC's order field, sentinel included: 0.0 marks per-level
1878            // (spectral) mode and must replay as per-level — persisting the
1879            // realized default here would silently flip the rebuild into
1880            // fused mode and desync the penalty count.
1881            order_s: spec.order_s,
1882            alpha: spec.alpha,
1883            tau0: spec.tau0,
1884            masses,
1885            support_means,
1886            penalty_normalization_scales,
1887            raw_penalty_normalization_scales,
1888            fused_penalty_normalization_scale,
1889            constraint_transform: Some(z),
1890            // Perpendicular off-manifold residual scale of the fit rows in the
1891            // centers' frame — the errors-in-variables input-noise scale (#2225).
1892            sigma_coord,
1893        },
1894        kronecker_factored: None,
1895        joint_null_rotation: None,
1896    })
1897}
1898
1899/// Exact ψ-jets of the REALIZED measure-jet penalty candidates, adapted to
1900/// the anisotropic group-ψ carrier the spatial optimizer consumes.
1901///
1902/// Coordinates (the layout contract for the registration arm):
1903/// - per-level (spectral) mode: `[ln ℓ?, α, ln τ]` — order is absorbed by the
1904///   REML-learned scale amplitudes; `ln τ` is retained as an inert coordinate;
1905/// - single-scale mode: `[ln ℓ?]`, because its energy dials are fixed.
1906///
1907/// Only `ln ℓ` moves the design. It also moves every coefficient-space penalty
1908/// pullback through the center evaluation map `E(ℓ)`; `(α, ln τ)` move only the
1909/// per-scale center-value forms. Exact diagonal and mixed product-rule jets are
1910/// emitted before Frobenius normalization.
1911/// Penalty derivatives are routed through the SAME constrained Frobenius
1912/// normalization as the fit-time candidates
1913/// (`normalize_penaltywith_psi_derivatives` + the cross rule), so criterion
1914/// value and criterion derivative share one normalization — the #901 lesson
1915/// made structural. The function-space null candidate has nonzero `ln ℓ` jets
1916/// and zero `(α, ln τ)` jets. The per-candidate layout follows the builder's
1917/// ORIGINAL order (scale candidates or Primary, then null component); consumers
1918/// align to the FITTED penalty list via
1919/// `ActivePenaltyInfo.original_index` when the candidate filter dropped
1920/// any.
1921pub fn build_measure_jet_basis_psi_derivatives(
1922    data: ArrayView2<'_, f64>,
1923    spec: &MeasureJetBasisSpec,
1924) -> Result<AnisoBasisPsiDerivatives, BasisError> {
1925    if !(spec.tau0.is_finite() && spec.tau0 > 0.0) {
1926        crate::bail_invalid_basis!(
1927            "measure-jet ψ derivatives need tau0 > 0 because the retained τ coordinate is ln τ; got {}",
1928            spec.tau0
1929        );
1930    }
1931    let geom = realize_measure_jet_geometry(data, spec)?;
1932    let band = MeasureJetBand {
1933        eps: geom.eps_band.clone(),
1934        log_step: geom.log_step,
1935    };
1936    let n = data.nrows();
1937    let p = geom.kz.ncols();
1938    let m = geom.centers.nrows();
1939    let m_aug = m + geom.head_transform.ncols();
1940
1941    struct LengthScaleJets {
1942        evaluation_first: Array2<f64>,
1943        evaluation_second: Array2<f64>,
1944        design_first: Array2<f64>,
1945        design_second: Array2<f64>,
1946    }
1947
1948    // The Gaussian representer range moves both the FIT design and the center
1949    // evaluation map `E = [K_cc | A_head] Z`. The affine head is ℓ-invariant,
1950    // so its raw derivative columns are exactly zero before applying the frozen
1951    // Gauge section. Keeping `Z` frozen is the replay contract: rank/gauge
1952    // realization happens once at fit time, then every ψ trial differentiates
1953    // the same coefficient chart.
1954    let length_scale_jets = if spec.learn_length_scale {
1955        let (dk_data, d2k_data) =
1956            measure_jet_design_log_length_jets(data, geom.centers.view(), geom.length_scale)?;
1957        let mut dk_data_aug = Array2::<f64>::zeros((n, m_aug));
1958        let mut d2k_data_aug = Array2::<f64>::zeros((n, m_aug));
1959        dk_data_aug.slice_mut(ndarray::s![.., ..m]).assign(&dk_data);
1960        d2k_data_aug
1961            .slice_mut(ndarray::s![.., ..m])
1962            .assign(&d2k_data);
1963
1964        let (dk_centers, d2k_centers) = measure_jet_design_log_length_jets(
1965            geom.centers.view(),
1966            geom.centers.view(),
1967            geom.length_scale,
1968        )?;
1969        let mut dk_centers_aug = Array2::<f64>::zeros((m, m_aug));
1970        let mut d2k_centers_aug = Array2::<f64>::zeros((m, m_aug));
1971        dk_centers_aug
1972            .slice_mut(ndarray::s![.., ..m])
1973            .assign(&dk_centers);
1974        d2k_centers_aug
1975            .slice_mut(ndarray::s![.., ..m])
1976            .assign(&d2k_centers);
1977
1978        Some(LengthScaleJets {
1979            evaluation_first: geom.coefficient_gauge.restrict_design(&dk_centers_aug),
1980            evaluation_second: geom.coefficient_gauge.restrict_design(&d2k_centers_aug),
1981            design_first: geom.coefficient_gauge.restrict_design(&dk_data_aug),
1982            design_second: geom.coefficient_gauge.restrict_design(&d2k_data_aug),
1983        })
1984    } else {
1985        None
1986    };
1987
1988    let coord_offset = usize::from(length_scale_jets.is_some());
1989    let n_coords = coord_offset + if geom.per_level { 2 } else { 0 };
1990    let pairs: Vec<(usize, usize)> = (0..n_coords)
1991        .flat_map(|a| ((a + 1)..n_coords).map(move |b| (a, b)))
1992        .collect();
1993    let zero_p = || Array2::<f64>::zeros((p, p));
1994
1995    struct RawPenaltyJets {
1996        value: Array2<f64>,
1997        first: Vec<Array2<f64>>,
1998        second_diag: Vec<Array2<f64>>,
1999        cross: Vec<Array2<f64>>,
2000    }
2001
2002    let sandwich = |form: &Array2<f64>| pullback_center_form(&geom.kz, form);
2003    let length_diag = |form: &Array2<f64>| {
2004        let jets = length_scale_jets
2005            .as_ref()
2006            .expect("length-scale form jets require an enrolled length coordinate");
2007        pullback_center_form_log_length_jets(
2008            &geom.kz,
2009            &jets.evaluation_first,
2010            &jets.evaluation_second,
2011            form,
2012        )
2013    };
2014    let length_cross = |form_first: &Array2<f64>| {
2015        let jets = length_scale_jets
2016            .as_ref()
2017            .expect("length-scale cross jets require an enrolled length coordinate");
2018        pullback_center_form_log_length_cross(&geom.kz, &jets.evaluation_first, form_first)
2019    };
2020
2021    // Raw (pre-normalization) value + exact jet stacks per ORIGINAL candidate.
2022    // Coordinate order is `[lnℓ?, α, lnτ]` in multiscale mode and `[lnℓ?]`
2023    // in single-scale mode. Candidate order exactly mirrors the value builder:
2024    // scale candidates or Primary first, then the optional null-component
2025    // candidate. Active filtering aligns through `ActivePenaltyInfo::original_index`.
2026    let mut raw: Vec<RawPenaltyJets> = if geom.per_level {
2027        let l_count = band.eps.len();
2028        // Six forms per scale: value, ∂α, ∂α², and zero τ slots — same
2029        // blocks, one walk (single-source rule).
2030        let forms = assemble_weighted_forms(
2031            geom.centers.view(),
2032            geom.masses.view(),
2033            &band,
2034            geom.order_s_eval,
2035            spec.alpha,
2036            spec.tau0,
2037            6 * l_count,
2038            3,
2039            &|scale_idx, eps: f64, q: f64, base: f64, out: &mut [[f64; 3]]| {
2040                for slot in out.iter_mut() {
2041                    *slot = [0.0, 0.0, 0.0];
2042                }
2043                let intrinsic_dim = geom.centers.ncols() as f64;
2044                let ga = 2.0 * intrinsic_dim * eps.ln() - 2.0 * q.max(f64::MIN_POSITIVE).ln();
2045                let k0 = 6 * scale_idx;
2046                out[k0] = [base, 0.0, 0.0];
2047                out[k0 + 1] = [ga * base, 0.0, 0.0];
2048                out[k0 + 2] = [ga * ga * base, 0.0, 0.0];
2049                out[k0 + 3] = [0.0, 0.0, 0.0];
2050                out[k0 + 4] = [0.0, 0.0, 0.0];
2051                out[k0 + 5] = [0.0, 0.0, 0.0];
2052            },
2053        )?;
2054        let alpha_coord = coord_offset;
2055        let tau_coord = coord_offset + 1;
2056        let mut raw = Vec::with_capacity(l_count + usize::from(spec.double_penalty));
2057        for level in 0..l_count {
2058            let chunk = &forms[6 * level..6 * level + 6];
2059            let mut first: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2060            let mut second_diag: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2061            first[alpha_coord] = sandwich(&chunk[1]);
2062            first[tau_coord] = sandwich(&chunk[3]);
2063            second_diag[alpha_coord] = sandwich(&chunk[2]);
2064            second_diag[tau_coord] = sandwich(&chunk[4]);
2065            if coord_offset == 1 {
2066                let (ell_first, ell_second) = length_diag(&chunk[0]);
2067                first[0] = ell_first;
2068                second_diag[0] = ell_second;
2069            }
2070            let mut cross: Vec<Array2<f64>> = (0..pairs.len()).map(|_| zero_p()).collect();
2071            for (pair_idx, &(a, b)) in pairs.iter().enumerate() {
2072                cross[pair_idx] = if coord_offset == 1 && a == 0 && b == alpha_coord {
2073                    length_cross(&chunk[1])
2074                } else if coord_offset == 1 && a == 0 && b == tau_coord {
2075                    length_cross(&chunk[3])
2076                } else if a == alpha_coord && b == tau_coord {
2077                    sandwich(&chunk[5])
2078                } else {
2079                    zero_p()
2080                };
2081            }
2082            raw.push(RawPenaltyJets {
2083                value: sandwich(&chunk[0]),
2084                first,
2085                second_diag,
2086                cross,
2087            });
2088        }
2089        raw
2090    } else {
2091        // Single-scale mode enrolls no `(s, α, lnτ)` penalty dials. It still
2092        // emits the pure Primary and, when requested, a separate REML null
2093        // component; an opt-in `lnℓ` coordinate differentiates both pullbacks.
2094        let q_form = measure_jet_energy_form(
2095            geom.centers.view(),
2096            geom.masses.view(),
2097            &band,
2098            geom.order_s_eval,
2099            spec.alpha,
2100            spec.tau0,
2101        )?;
2102        let mut first: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2103        let mut second_diag: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2104        if coord_offset == 1 {
2105            let (ell_first, ell_second) = length_diag(&q_form);
2106            first[0] = ell_first;
2107            second_diag[0] = ell_second;
2108        }
2109        vec![RawPenaltyJets {
2110            value: sandwich(&q_form),
2111            first,
2112            second_diag,
2113            cross: Vec::new(),
2114        }]
2115    };
2116
2117    if spec.double_penalty {
2118        let null_form = affine_function_nullspace_form(geom.centers.view(), geom.masses.view())?;
2119        let mut first: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2120        let mut second_diag: Vec<Array2<f64>> = (0..n_coords).map(|_| zero_p()).collect();
2121        if coord_offset == 1 {
2122            let (ell_first, ell_second) = length_diag(&null_form);
2123            first[0] = ell_first;
2124            second_diag[0] = ell_second;
2125        }
2126        raw.push(RawPenaltyJets {
2127            value: sandwich(&null_form),
2128            first,
2129            second_diag,
2130            // H₀ is independent of α and τ; its only moving object is E(ℓ),
2131            // so every mixed coordinate derivative is zero.
2132            cross: (0..pairs.len()).map(|_| zero_p()).collect(),
2133        });
2134    }
2135
2136    let n_cands = raw.len();
2137    let mut penalties_first: Vec<Vec<Array2<f64>>> =
2138        (0..n_coords).map(|_| Vec::with_capacity(n_cands)).collect();
2139    let mut penalties_second_diag: Vec<Vec<Array2<f64>>> =
2140        (0..n_coords).map(|_| Vec::with_capacity(n_cands)).collect();
2141    // Cross matrices per pair per candidate, precomputed eagerly (the
2142    // candidate count is the band length, not the data size) and served
2143    // through the on-demand provider.
2144    let mut crosses: Vec<Vec<Array2<f64>>> = (0..pairs.len()).map(|_| Vec::new()).collect();
2145    for candidate in &raw {
2146        let s_raw = &candidate.value;
2147        // ONE Frobenius scale per candidate, fixed up front from `s_raw`
2148        // alone: c anchors the value and every derivative of this candidate.
2149        // `normalize_penaltywith_psi_derivatives` recomputes the identical c
2150        // per coordinate (same trace_of_product + sqrt on the same `s_raw`),
2151        // and its degenerate convention is mirrored here: ‖S‖_F ≤ 1e-12 (or
2152        // non-finite) reports scale 1.0 — the value passes through unscaled,
2153        // and the cross helper receives that same 1.0, never a collapsed
2154        // near-zero scale.
2155        let fro = trace_of_product(s_raw, s_raw).sqrt();
2156        let c = if fro.is_finite() && fro > 1e-12 {
2157            fro
2158        } else {
2159            1.0
2160        };
2161        for coord in 0..n_coords {
2162            let (_, s_first, s_second, _) = normalize_penaltywith_psi_derivatives(
2163                s_raw,
2164                &candidate.first[coord],
2165                &candidate.second_diag[coord],
2166            );
2167            penalties_first[coord].push(s_first);
2168            penalties_second_diag[coord].push(s_second);
2169        }
2170        for (pair_idx, &(a, b)) in pairs.iter().enumerate() {
2171            let cross_raw_mat = normalize_penalty_cross_psi_derivative(
2172                s_raw,
2173                &candidate.first[a],
2174                &candidate.first[b],
2175                &candidate.cross[pair_idx],
2176                c,
2177            );
2178            crosses[pair_idx].push(cross_raw_mat);
2179        }
2180    }
2181
2182    let pair_index: Vec<((usize, usize), Vec<Array2<f64>>)> =
2183        pairs.iter().copied().zip(crosses.into_iter()).collect();
2184    let provider = AnisoPenaltyCrossProvider::new(move |a, b| {
2185        pair_index
2186            .iter()
2187            .find(|((pa, pb), _)| (*pa, *pb) == (a, b) || (*pa, *pb) == (b, a))
2188            .map(|(_, mats)| mats.clone())
2189            .ok_or_else(|| {
2190                BasisError::InvalidInput(format!(
2191                    "measure-jet ψ cross derivative requested for unknown pair ({a}, {b})"
2192                ))
2193            })
2194    });
2195    let mut design_first: Vec<Array2<f64>> = (0..n_coords)
2196        .map(|_| Array2::<f64>::zeros((n, p)))
2197        .collect();
2198    let mut design_second_diag: Vec<Array2<f64>> = (0..n_coords)
2199        .map(|_| Array2::<f64>::zeros((n, p)))
2200        .collect();
2201    if let Some(jets) = &length_scale_jets {
2202        design_first[0] = jets.design_first.clone();
2203        design_second_diag[0] = jets.design_second.clone();
2204    }
2205    Ok(AnisoBasisPsiDerivatives {
2206        design_first,
2207        design_second_diag,
2208        design_second_cross: Vec::new(),
2209        design_second_cross_pairs: Vec::new(),
2210        penalties_first,
2211        penalties_second_diag,
2212        penalties_cross_pairs: pairs,
2213        penalties_cross_provider: Some(provider),
2214        implicit_operator: None,
2215    })
2216}
2217
2218#[cfg(test)]
2219mod tests {
2220    use super::*;
2221
2222    /// Deterministic Box–Muller standard normal from a 64-bit LCG state — a
2223    /// self-contained noise generator (no external RNG dependency).
2224    fn lcg_normal(state: &mut u64) -> f64 {
2225        let mut next = || {
2226            *state = state
2227                .wrapping_mul(6364136223846793005)
2228                .wrapping_add(1442695040888963407);
2229            // Top 53 bits → uniform (0, 1).
2230            (((*state >> 11) as f64) + 0.5) / (1u64 << 53) as f64
2231        };
2232        let u1 = next();
2233        let u2 = next();
2234        (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
2235    }
2236
2237    /// The perpendicular off-manifold residual estimator recovers a KNOWN
2238    /// ambient noise scale on a 1-D manifold (a line) embedded in 2-D: points
2239    /// sampled along the tangent with isotropic-perpendicular Gaussian noise of
2240    /// scale σ, centers spaced along the line. The local-PCA smallest-eigenvalue
2241    /// floor must return ≈ σ (#2225).
2242    #[test]
2243    pub(crate) fn input_noise_scale_recovers_known_perpendicular_sigma() {
2244        // Line direction (unit) and its perpendicular in 2-D.
2245        let tang = [1.0 / 5f64.sqrt(), 2.0 / 5f64.sqrt()];
2246        let perp = [2.0 / 5f64.sqrt(), -1.0 / 5f64.sqrt()];
2247        let sigma = 0.05_f64;
2248        let n = 600usize;
2249        let mut state = 0x1234_5678_9abc_def0u64;
2250        let mut data = Array2::<f64>::zeros((n, 2));
2251        for j in 0..n {
2252            // Tangential coordinate marches deterministically over [0, 3].
2253            let t = 3.0 * (j as f64) / (n as f64 - 1.0);
2254            let noise = sigma * lcg_normal(&mut state);
2255            for a in 0..2 {
2256                data[(j, a)] = t * tang[a] + noise * perp[a];
2257            }
2258        }
2259        // Centers along the line (on the noiseless manifold): plenty of points
2260        // per cell to span the tangent.
2261        let n_centers = 8usize;
2262        let mut centers = Array2::<f64>::zeros((n_centers, 2));
2263        for i in 0..n_centers {
2264            let t = 3.0 * (i as f64 + 0.5) / (n_centers as f64);
2265            for a in 0..2 {
2266                centers[(i, a)] = t * tang[a];
2267            }
2268        }
2269        let est = measure_jet_input_noise_scale(data.view(), centers.view())
2270            .expect("estimate ok")
2271            .expect("noise scale present");
2272        // Sample smallest-eigenvalue floor is mildly downward-biased; require it
2273        // within 40% of the truth (central estimate, not a tuned tolerance).
2274        assert!(
2275            (est - sigma).abs() <= 0.4 * sigma,
2276            "estimated σ_coord {est} far from true {sigma}"
2277        );
2278    }
2279
2280    /// Too few points per cell (cannot span a d-dim tangent) ⇒ no estimate,
2281    /// so the caller leaves Var_input disabled rather than invent a scale.
2282    #[test]
2283    pub(crate) fn input_noise_scale_none_when_cells_too_small() {
2284        let data = array![[0.0, 0.0], [1.0, 2.0], [2.0, 4.0]];
2285        let centers = array![[0.0, 0.0], [1.0, 2.0], [2.0, 4.0]];
2286        // Each point is its own nearest center (1 point per cell < d + 1 = 3).
2287        assert!(
2288            measure_jet_input_noise_scale(data.view(), centers.view())
2289                .expect("estimate ok")
2290                .is_none()
2291        );
2292    }
2293
2294    pub(crate) fn two_cluster_centers() -> (ndarray::Array2<f64>, ndarray::Array1<f64>) {
2295        let centers = array![
2296            [0.00, 0.00],
2297            [0.31, 0.05],
2298            [0.58, -0.07],
2299            [0.93, 0.11],
2300            [1.22, 0.02],
2301            [1.49, -0.04],
2302            [3.10, 2.00],
2303            [3.42, 2.13],
2304            [3.71, 1.91],
2305            [4.05, 2.07],
2306            [4.33, 1.96],
2307            [4.61, 2.12],
2308        ];
2309        let m = centers.nrows();
2310        let masses = ndarray::Array1::<f64>::from_elem(m, 1.0 / m as f64);
2311        (centers, masses)
2312    }
2313    use ndarray::array;
2314
2315    pub(crate) fn band_for(centers: &Array2<f64>) -> MeasureJetBand {
2316        measure_jet_band(centers.view(), 0).expect("band")
2317    }
2318
2319    /// The no-mass contract: constants must be annihilated to machine
2320    /// precision at every scale (the constant is projected, never ridged).
2321    #[test]
2322    pub(crate) fn energy_form_annihilates_constants_exactly() {
2323        let (centers, masses) = two_cluster_centers();
2324        let band = band_for(&centers);
2325        let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
2326            .expect("energy form");
2327        let m = q.nrows();
2328        let ones = Array1::<f64>::ones(m);
2329        let qv = q.dot(&ones);
2330        let scale = q.iter().fold(0.0_f64, |acc, v| acc.max(v.abs()));
2331        assert!(scale > 0.0, "energy form is identically zero");
2332        for (i, v) in qv.iter().enumerate() {
2333            assert!(
2334                v.abs() <= 1e-12 * scale,
2335                "Q·1 leak at row {i}: {v:.3e} vs scale {scale:.3e}"
2336            );
2337        }
2338        let vqv = ones.dot(&qv);
2339        assert!(
2340            vqv.abs() <= 1e-12 * scale,
2341            "constant carries energy: 1ᵀQ1 = {vqv:.3e}"
2342        );
2343    }
2344
2345    /// The default local projection annihilates ambient affine functions
2346    /// exactly; τ is retained for ψ layout but no longer adds an affine toll.
2347    #[test]
2348    pub(crate) fn energy_form_annihilates_affine_at_default_tau() {
2349        let (centers, masses) = two_cluster_centers();
2350        let band = band_for(&centers);
2351        let m = centers.nrows();
2352        // Affine values v = 0.7 + 1.3·x − 0.4·y, and a rough ±1 checkerboard.
2353        let mut affine = Array1::<f64>::zeros(m);
2354        let mut rough = Array1::<f64>::zeros(m);
2355        for i in 0..m {
2356            affine[i] = 0.7 + 1.3 * centers[(i, 0)] - 0.4 * centers[(i, 1)];
2357            rough[i] = if i % 2 == 0 { 1.0 } else { -1.0 };
2358        }
2359        let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
2360            .expect("energy form");
2361        let e_affine = affine.dot(&q.dot(&affine));
2362        let e_rough = rough.dot(&q.dot(&rough));
2363        assert!(e_rough > 0.0, "rough vector must pay energy");
2364        assert!(
2365            e_affine.abs() <= 1e-12 * e_rough,
2366            "default affine energy {e_affine:.3e} vs rough {e_rough:.3e}"
2367        );
2368    }
2369
2370    /// PSD: the energy is a sum of weighted least-squares residuals.
2371    #[test]
2372    pub(crate) fn energy_form_is_psd() {
2373        let (centers, masses) = two_cluster_centers();
2374        let band = band_for(&centers);
2375        let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
2376            .expect("energy form");
2377        let m = q.nrows();
2378        for trial in 0..5usize {
2379            let v = Array1::<f64>::from_shape_fn(m, |i| {
2380                ((i * 7 + trial * 13) % 11) as f64 / 11.0 - 0.5
2381            });
2382            let e = v.dot(&q.dot(&v));
2383            assert!(e >= -1e-10, "vᵀQv = {e:.3e} < 0 on trial {trial}");
2384        }
2385    }
2386
2387    /// A 1-D filament embedded in 2-D: high-frequency center values along the
2388    /// strand pay strictly more energy than a slow trend.
2389    #[test]
2390    pub(crate) fn rough_vector_pays_more_than_smooth() {
2391        let m = 24usize;
2392        let centers = Array2::<f64>::from_shape_fn((m, 2), |(i, k)| {
2393            let t = i as f64 / (m as f64 - 1.0);
2394            if k == 0 {
2395                t * 4.0
2396            } else {
2397                0.3 * (t * 4.0).sin()
2398            }
2399        });
2400        let masses = Array1::<f64>::from_elem(m, 1.0 / m as f64);
2401        let band = band_for(&centers);
2402        let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
2403            .expect("energy form");
2404        let slow = Array1::<f64>::from_shape_fn(m, |i| (i as f64 / (m as f64 - 1.0)).powi(2));
2405        let fast = Array1::<f64>::from_shape_fn(m, |i| if i % 2 == 0 { 0.5 } else { -0.5 });
2406        let e_slow = slow.dot(&q.dot(&slow));
2407        let e_fast = fast.dot(&q.dot(&fast));
2408        assert!(
2409            e_fast > 10.0 * e_slow,
2410            "alternating values must pay >> a slow trend: fast {e_fast:.3e} vs slow {e_slow:.3e}"
2411        );
2412    }
2413
2414    /// The exact (s, α) jets and zero τ slots must match central finite
2415    /// differences of the energy — the FD gate the ψ-channel stage will
2416    /// inherit (the discipline whose absence is exactly the
2417    /// objective↔gradient desync bug class).
2418    #[test]
2419    pub(crate) fn energy_jets_match_finite_differences() {
2420        let (centers, masses) = two_cluster_centers();
2421        let band = band_for(&centers);
2422        let (s0, a0, tau) = (1.3, 0.8, 1e-3);
2423        let jets =
2424            measure_jet_energy_form_with_jets(centers.view(), masses.view(), &band, s0, a0, tau)
2425                .expect("jets");
2426        let q_at = |s: f64, a: f64| {
2427            measure_jet_energy_form(centers.view(), masses.view(), &band, s, a, tau)
2428                .expect("energy form")
2429        };
2430        // Base form must equal the plain assembly bit-for-bit (same walk).
2431        let q_plain = q_at(s0, a0);
2432        for (a, b) in jets.q.iter().zip(q_plain.iter()) {
2433            assert!(
2434                (a - b).abs() <= 1e-14 * (1.0 + b.abs()),
2435                "Q drift {a} vs {b}"
2436            );
2437        }
2438        let lt0 = tau.ln();
2439        let q_at_lt = |lt: f64| {
2440            measure_jet_energy_form(centers.view(), masses.view(), &band, s0, a0, lt.exp())
2441                .expect("energy form")
2442        };
2443        // FD step calibrated for the SECOND differences: their roundoff
2444        // floor is ~4·ε_f64·scale/h² (assembly noise amplified by 1/h²), so
2445        // h = 1e-4 ≈ ε^(1/4) balances it against the O(h²) truncation —
2446        // both land ≥3 orders below the unchanged 5e-5·scale gate. h = 1e-5
2447        // sits ON the roundoff floor and fails spuriously.
2448        let h = 1e-4;
2449        let checks: [(&str, &Array2<f64>, Array2<f64>); 9] = [
2450            ("dq_ds", &jets.dq_ds, {
2451                let (p, m_) = (q_at(s0 + h, a0), q_at(s0 - h, a0));
2452                (&p - &m_) / (2.0 * h)
2453            }),
2454            ("d2q_ds2", &jets.d2q_ds2, {
2455                let (p, c, m_) = (q_at(s0 + h, a0), q_at(s0, a0), q_at(s0 - h, a0));
2456                (&(&p + &m_) - &(&c * 2.0)) / (h * h)
2457            }),
2458            ("dq_dalpha", &jets.dq_dalpha, {
2459                let (p, m_) = (q_at(s0, a0 + h), q_at(s0, a0 - h));
2460                (&p - &m_) / (2.0 * h)
2461            }),
2462            ("d2q_dalpha2", &jets.d2q_dalpha2, {
2463                let (p, c, m_) = (q_at(s0, a0 + h), q_at(s0, a0), q_at(s0, a0 - h));
2464                (&(&p + &m_) - &(&c * 2.0)) / (h * h)
2465            }),
2466            ("d2q_ds_dalpha", &jets.d2q_ds_dalpha, {
2467                let pp = q_at(s0 + h, a0 + h);
2468                let pm = q_at(s0 + h, a0 - h);
2469                let mp = q_at(s0 - h, a0 + h);
2470                let mm = q_at(s0 - h, a0 - h);
2471                (&(&pp - &pm) - &(&mp - &mm)) / (4.0 * h * h)
2472            }),
2473            ("dq_dlogtau", &jets.dq_dlogtau, {
2474                let (p, m_) = (q_at_lt(lt0 + h), q_at_lt(lt0 - h));
2475                (&p - &m_) / (2.0 * h)
2476            }),
2477            ("d2q_dlogtau2", &jets.d2q_dlogtau2, {
2478                let (p, c, m_) = (q_at_lt(lt0 + h), q_at_lt(lt0), q_at_lt(lt0 - h));
2479                (&(&p + &m_) - &(&c * 2.0)) / (h * h)
2480            }),
2481            ("d2q_ds_dlogtau", &jets.d2q_ds_dlogtau, {
2482                let f = |s: f64, lt: f64| {
2483                    measure_jet_energy_form(centers.view(), masses.view(), &band, s, a0, lt.exp())
2484                        .expect("energy form")
2485                };
2486                let pp = f(s0 + h, lt0 + h);
2487                let pm = f(s0 + h, lt0 - h);
2488                let mp = f(s0 - h, lt0 + h);
2489                let mm = f(s0 - h, lt0 - h);
2490                (&(&pp - &pm) - &(&mp - &mm)) / (4.0 * h * h)
2491            }),
2492            ("d2q_dalpha_dlogtau", &jets.d2q_dalpha_dlogtau, {
2493                let f = |a: f64, lt: f64| {
2494                    measure_jet_energy_form(centers.view(), masses.view(), &band, s0, a, lt.exp())
2495                        .expect("energy form")
2496                };
2497                let pp = f(a0 + h, lt0 + h);
2498                let pm = f(a0 + h, lt0 - h);
2499                let mp = f(a0 - h, lt0 + h);
2500                let mm = f(a0 - h, lt0 - h);
2501                (&(&pp - &pm) - &(&mp - &mm)) / (4.0 * h * h)
2502            }),
2503        ];
2504        for (name, analytic, fd) in checks.iter() {
2505            let scale = fd.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
2506            for (a, b) in analytic.iter().zip(fd.iter()) {
2507                assert!(
2508                    (a - b).abs() <= 5e-5 * scale,
2509                    "{name} jet mismatch: analytic {a:.6e} vs FD {b:.6e} (scale {scale:.3e})"
2510                );
2511            }
2512        }
2513    }
2514
2515    /// The per-scale spectrum must sum exactly to the total energy (same
2516    /// blocks, one-hot weights) and concentrate rough content at fine
2517    /// scales.
2518    #[test]
2519    pub(crate) fn scale_spectrum_sums_to_total_and_localizes_roughness() {
2520        let m = 24usize;
2521        let centers = Array2::<f64>::from_shape_fn((m, 2), |(i, k)| {
2522            let t = i as f64 / (m as f64 - 1.0);
2523            if k == 0 { t * 4.0 } else { 0.0 }
2524        });
2525        let masses = Array1::<f64>::from_elem(m, 1.0 / m as f64);
2526        let band = band_for(&centers);
2527        let q = measure_jet_energy_form(centers.view(), masses.view(), &band, 1.5, 1.0, 1e-3)
2528            .expect("energy form");
2529        let fast = Array1::<f64>::from_shape_fn(m, |i| if i % 2 == 0 { 0.5 } else { -0.5 });
2530        let spec = measure_jet_scale_spectrum(
2531            centers.view(),
2532            masses.view(),
2533            &band,
2534            1.5,
2535            1.0,
2536            1e-3,
2537            fast.view(),
2538        )
2539        .expect("spectrum");
2540        assert_eq!(spec.len(), band.eps.len());
2541        let total = fast.dot(&q.dot(&fast));
2542        let sum: f64 = spec.iter().sum();
2543        assert!(
2544            (sum - total).abs() <= 1e-10 * total.abs().max(1e-30),
2545            "spectrum must sum to vᵀQv: {sum:.6e} vs {total:.6e}"
2546        );
2547        // Alternating-sign content lives at the finest scale of the band.
2548        let finest = spec[0];
2549        let coarsest = *spec.last().expect("nonempty spectrum");
2550        assert!(
2551            finest > coarsest,
2552            "alternating values must charge fine scales hardest: fine {finest:.3e} vs coarse {coarsest:.3e}"
2553        );
2554    }
2555
2556    /// The support curve separates on-web from off-web queries at fine
2557    /// scales and grows monotonically in ε for any query.
2558    #[test]
2559    pub(crate) fn support_curve_separates_on_web_from_off_web() {
2560        let m = 24usize;
2561        let centers = Array2::<f64>::from_shape_fn((m, 2), |(i, k)| {
2562            let t = i as f64 / (m as f64 - 1.0);
2563            if k == 0 { t * 4.0 } else { 0.0 }
2564        });
2565        let masses = Array1::<f64>::from_elem(m, 1.0 / m as f64);
2566        let band = band_for(&centers);
2567        let queries = array![[2.0, 0.0], [2.0, 1.5]];
2568        let curves =
2569            measure_jet_support_curve(queries.view(), centers.view(), masses.view(), &band.eps)
2570                .expect("support curve");
2571        // On-web sees strictly more mass than off-web at the finest scale.
2572        assert!(
2573            curves[(0, 0)] > 10.0 * curves[(1, 0)],
2574            "fine-scale support must separate web from void: on {:.3e} vs off {:.3e}",
2575            curves[(0, 0)],
2576            curves[(1, 0)]
2577        );
2578        // Kernel mass is monotone in ε for every query.
2579        for qi in 0..2 {
2580            for li in 1..band.eps.len() {
2581                assert!(
2582                    curves[(qi, li)] >= curves[(qi, li - 1)] - 1e-15,
2583                    "support curve must be monotone in scale (query {qi}, level {li})"
2584                );
2585            }
2586        }
2587    }
2588
2589    /// The default is single-scale mode at ANY center count: one Primary
2590    /// jet-energy candidate plus the independently REML-selected affine/null
2591    /// component requested by the default `double_penalty`. Multiscale (the
2592    /// per-scale spectral split + ψ dials) is an EXPLICIT opt-in
2593    /// (`spec.multiscale`, the DSL `mjs(…, multiscale=true)`) — there is no
2594    /// center-count auto-gate (#1116). `measure_jet_multiscale_mode` is the
2595    /// single source for this decision.
2596    #[test]
2597    pub(crate) fn default_stays_single_scale_until_multiscale_opt_in() {
2598        let n = 200usize;
2599        let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
2600            let t = i as f64 / (n as f64 - 1.0);
2601            if k == 0 {
2602                t * 3.0
2603            } else {
2604                0.4 * (t * 3.0).sin()
2605            }
2606        });
2607        // Default (multiscale = false) stays single-scale even at a LARGE center
2608        // count that, under the deleted auto-gate, would have flipped to
2609        // multiscale: one pure Primary plus one function-space null component.
2610        let single = MeasureJetBasisSpec {
2611            center_strategy: CenterStrategy::FarthestPoint { num_centers: 80 },
2612            ..MeasureJetBasisSpec::default()
2613        };
2614        assert!(
2615            !measure_jet_multiscale_mode(&single),
2616            "default must resolve to single-scale at any center count"
2617        );
2618        let built_single =
2619            build_measure_jet_basis(data.view(), &single).expect("single-scale build");
2620        assert_eq!(
2621            built_single.active_penalties.len(),
2622            2,
2623            "single-scale double-penalty mode emits Primary + affine/null component"
2624        );
2625        assert!(matches!(
2626            built_single.active_penalties[0].info.source,
2627            PenaltySource::Primary
2628        ));
2629        assert!(matches!(
2630            built_single.active_penalties[1].info.source,
2631            PenaltySource::DoublePenaltyNullspace
2632        ));
2633        // The explicit opt-in flips to multiscale at the SAME center count: the
2634        // per-scale spectral split (several candidates) plus the same explicit
2635        // null-component candidate, strictly more candidates than single-scale.
2636        let multi = MeasureJetBasisSpec {
2637            center_strategy: CenterStrategy::FarthestPoint { num_centers: 80 },
2638            multiscale: true,
2639            ..MeasureJetBasisSpec::default()
2640        };
2641        assert!(
2642            measure_jet_multiscale_mode(&multi),
2643            "multiscale=true must resolve to multiscale mode"
2644        );
2645        let built_multi = build_measure_jet_basis(data.view(), &multi).expect("multiscale build");
2646        assert!(
2647            built_multi.active_penalties.len() > built_single.active_penalties.len(),
2648            "multiscale mode emits the per-scale spectral split plus null selection, got {} (vs single-scale {})",
2649            built_multi.active_penalties.len(),
2650            built_single.active_penalties.len()
2651        );
2652    }
2653
2654    /// An explicit order pins the Mellin weights and fuses the band into a
2655    /// single Primary candidate. Disabling explicit null recovery leaves exactly
2656    /// that candidate; enabling it must never alter the Primary itself.
2657    #[test]
2658    pub(crate) fn fused_mode_without_double_penalty_emits_single_primary_candidate() {
2659        let n = 40usize;
2660        let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
2661            let t = i as f64 / (n as f64 - 1.0);
2662            if k == 0 {
2663                t * 3.0
2664            } else {
2665                0.4 * (t * 3.0).sin()
2666            }
2667        });
2668        let spec = MeasureJetBasisSpec {
2669            center_strategy: CenterStrategy::FarthestPoint { num_centers: 14 },
2670            order_s: 1.3,
2671            double_penalty: false,
2672            ..MeasureJetBasisSpec::default()
2673        };
2674        let built = build_measure_jet_basis(data.view(), &spec).expect("fused build");
2675        assert_eq!(
2676            built.active_penalties.len(),
2677            1,
2678            "single-scale mode without null recovery emits exactly one Primary"
2679        );
2680        assert!(matches!(
2681            built.active_penalties[0].info.source,
2682            PenaltySource::Primary
2683        ));
2684        let BasisMetadata::MeasureJet { order_s, .. } = &built.metadata else {
2685            panic!("measure-jet build must return MeasureJet metadata");
2686        };
2687        assert_eq!(*order_s, 1.3, "explicit order must persist verbatim");
2688    }
2689
2690    /// The single-scale affine head is a gauge-fixed decomposition, not a
2691    /// coefficient ridge: RBF center values are exactly mass-orthogonal to the
2692    /// supported affine space and replacing those directions with the head keeps
2693    /// the total reduced width at `m - 1`.
2694    #[test]
2695    pub(crate) fn single_scale_affine_head_gauge_annihilates_center_cross() {
2696        let n = 90usize;
2697        let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
2698            let t = i as f64 / (n as f64 - 1.0);
2699            if k == 0 {
2700                3.0 * t
2701            } else {
2702                (2.0 * std::f64::consts::PI * t).sin() + 0.2 * t
2703            }
2704        });
2705        let spec = MeasureJetBasisSpec {
2706            center_strategy: CenterStrategy::FarthestPoint { num_centers: 18 },
2707            double_penalty: false,
2708            multiscale: false,
2709            ..MeasureJetBasisSpec::default()
2710        };
2711        let geom = realize_measure_jet_geometry(data.view(), &spec).expect("realized geometry");
2712        let m = geom.centers.nrows();
2713        let head_rank = geom.head_transform.ncols();
2714        assert!(head_rank > 0, "fixture must realize an affine head");
2715        assert_eq!(
2716            geom.z.ncols(),
2717            m - 1,
2718            "affine gauge replaces duplicated RBF directions without widening the smooth"
2719        );
2720        let rbf_rank = m - (head_rank + 1);
2721        let z_rbf = geom.z.slice(ndarray::s![..m, ..rbf_rank]).to_owned();
2722        let k_cc =
2723            measure_jet_design_matrix(geom.centers.view(), geom.centers.view(), geom.length_scale)
2724                .expect("center kernel");
2725        let affine = measure_jet_affine_value_basis(geom.centers.view(), geom.masses.view());
2726        assert_eq!(affine.ncols(), head_rank + 1);
2727        let mut weighted_affine = affine.clone();
2728        for (i, mut row) in weighted_affine.outer_iter_mut().enumerate() {
2729            row.mapv_inplace(|v| v * geom.masses[i]);
2730        }
2731        let constraint_cross = k_cc.t().dot(&weighted_affine);
2732        let residual = constraint_cross.t().dot(&z_rbf);
2733        let scale = constraint_cross
2734            .iter()
2735            .fold(1.0_f64, |acc, value| acc.max(value.abs()));
2736        assert!(
2737            residual.iter().all(|value| value.abs() <= 1e-10 * scale),
2738            "A^T W Kcc Z_rbf must vanish; max residual {:.3e}",
2739            residual
2740                .iter()
2741                .fold(0.0_f64, |acc, value| acc.max(value.abs()))
2742        );
2743    }
2744
2745    /// A function-space penalty must transform covariantly with its evaluation
2746    /// map. This directly excludes any hidden Euclidean coefficient projector.
2747    #[test]
2748    pub(crate) fn affine_null_penalty_is_covariant_under_coefficient_reparameterization() {
2749        let centers = array![
2750            [-1.0, 0.2],
2751            [-0.4, -0.3],
2752            [0.1, 0.5],
2753            [0.7, -0.2],
2754            [1.2, 0.4],
2755            [1.8, -0.1],
2756        ];
2757        let masses = array![0.08, 0.12, 0.18, 0.22, 0.17, 0.23];
2758        let evaluation = Array2::<f64>::from_shape_fn((centers.nrows(), 3), |(i, j)| {
2759            ((i + 2 * j + 1) as f64).sin() + 0.15 * (i * (j + 1)) as f64
2760        });
2761        let reparameterization = array![[1.7, 0.2, -0.1], [0.0, 0.6, 0.3], [0.0, 0.0, 1.3]];
2762        let base = affine_function_nullspace_penalty(&evaluation, centers.view(), masses.view())
2763            .expect("base function-space penalty");
2764        let transformed_evaluation = evaluation.dot(&reparameterization);
2765        let transformed = affine_function_nullspace_penalty(
2766            &transformed_evaluation,
2767            centers.view(),
2768            masses.view(),
2769        )
2770        .expect("reparameterized function-space penalty");
2771        let expected = reparameterization.t().dot(&base).dot(&reparameterization);
2772        let scale = expected
2773            .iter()
2774            .fold(1.0_f64, |acc, value| acc.max(value.abs()));
2775        assert!(
2776            transformed
2777                .iter()
2778                .zip(expected.iter())
2779                .all(|(actual, want)| (actual - want).abs() <= 1e-11 * scale),
2780            "S(E R) must equal R^T S(E) R"
2781        );
2782    }
2783
2784    /// `double_penalty` adds a distinct evidence-selected component and cannot
2785    /// mutate the jet-energy estimand carried by Primary.
2786    #[test]
2787    pub(crate) fn double_penalty_leaves_primary_matrix_unchanged() {
2788        let n = 64usize;
2789        let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
2790            let t = i as f64 / (n as f64 - 1.0);
2791            if k == 0 { 2.5 * t } else { (4.0 * t).cos() }
2792        });
2793        let base = MeasureJetBasisSpec {
2794            center_strategy: CenterStrategy::FarthestPoint { num_centers: 16 },
2795            order_s: 1.25,
2796            double_penalty: false,
2797            ..MeasureJetBasisSpec::default()
2798        };
2799        let without = build_measure_jet_basis(data.view(), &base).expect("primary-only build");
2800        let with = build_measure_jet_basis(
2801            data.view(),
2802            &MeasureJetBasisSpec {
2803                double_penalty: true,
2804                ..base.clone()
2805            },
2806        )
2807        .expect("double-penalty build");
2808        assert_eq!(without.active_penalties.len(), 1);
2809        assert_eq!(with.active_penalties.len(), 2);
2810        assert!(matches!(
2811            without.active_penalties[0].info.source,
2812            PenaltySource::Primary
2813        ));
2814        assert!(matches!(
2815            with.active_penalties[0].info.source,
2816            PenaltySource::Primary
2817        ));
2818        assert!(matches!(
2819            with.active_penalties[1].info.source,
2820            PenaltySource::DoublePenaltyNullspace
2821        ));
2822        assert!(
2823            without.active_penalties[0]
2824                .matrix
2825                .iter()
2826                .zip(with.active_penalties[0].matrix.iter())
2827                .all(|(a, b)| (a - b).abs() <= 1e-13),
2828            "turning on null recovery must not modify Primary"
2829        );
2830    }
2831
2832    /// The Householder basis must be orthonormal with sum-to-zero columns.
2833    #[test]
2834    pub(crate) fn householder_sum_to_zero_basis_is_orthonormal() {
2835        let m = 9usize;
2836        let u = householder_sum_to_zero_u(m);
2837        let z = householder_sum_to_zero_z(&u);
2838        for j in 0..(m - 1) {
2839            let col_j = z.column(j);
2840            assert!(col_j.sum().abs() <= 1e-12, "column {j} must sum to zero");
2841            for j2 in j..(m - 1) {
2842                let dot = col_j.dot(&z.column(j2));
2843                let want = if j == j2 { 1.0 } else { 0.0 };
2844                assert!(
2845                    (dot - want).abs() <= 1e-12,
2846                    "orthonormality failure at ({j}, {j2}): {dot}"
2847                );
2848            }
2849        }
2850    }
2851
2852    /// Frozen-geometry fixture shared by the ψ-producer FD gates: build
2853    /// once, pin everything (nodes, masses, band, transform, realized ℓ),
2854    /// and return the pinned spec so dial-perturbed rebuilds move ONLY the
2855    /// dials — the per-trial contract the optimizer relies on.
2856    pub(crate) fn frozen_spec_fixture(
2857        order_s: f64,
2858        multiscale: bool,
2859    ) -> (Array2<f64>, MeasureJetBasisSpec) {
2860        // Multiscale (per-scale + ψ) mode is the explicit opt-in (#1116); the
2861        // per-level fixture passes `multiscale = true`, the fused fixture
2862        // `false`. A large center count is kept so the multiscale spectrum is
2863        // identifiable when opted in.
2864        let n = 140usize;
2865        let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
2866            let t = i as f64 / (n as f64 - 1.0);
2867            if k == 0 {
2868                t * 3.0
2869            } else {
2870                0.5 * (t * 3.0).cos() + if i % 9 == 0 { 0.8 } else { 0.0 }
2871            }
2872        });
2873        let spec = MeasureJetBasisSpec {
2874            center_strategy: CenterStrategy::FarthestPoint { num_centers: 70 },
2875            order_s,
2876            multiscale,
2877            // These fixtures gate the PENALTY-dial derivatives; freeze ℓ so the
2878            // coordinate layout is exactly the penalty dials (the design-moving
2879            // ℓ dial has its own FD gate, `psi_producer_matches_fd_length_scale`).
2880            learn_length_scale: false,
2881            ..MeasureJetBasisSpec::default()
2882        };
2883        let first = build_measure_jet_basis(data.view(), &spec).expect("fixture build");
2884        let BasisMetadata::MeasureJet {
2885            centers,
2886            length_scale,
2887            eps_band,
2888            masses,
2889            support_means,
2890            penalty_normalization_scales,
2891            raw_penalty_normalization_scales,
2892            fused_penalty_normalization_scale,
2893            constraint_transform,
2894            ..
2895        } = &first.metadata
2896        else {
2897            panic!("measure-jet build must return MeasureJet metadata");
2898        };
2899        let frozen = MeasureJetBasisSpec {
2900            center_strategy: CenterStrategy::UserProvided(centers.clone()),
2901            order_s,
2902            alpha: spec.alpha,
2903            tau0: spec.tau0,
2904            num_scales: eps_band.len(),
2905            length_scale: *length_scale,
2906            double_penalty: spec.double_penalty,
2907            learn_length_scale: false,
2908            multiscale,
2909            identifiability: MeasureJetIdentifiability::FrozenTransform {
2910                transform: constraint_transform.clone().expect("fit-time z"),
2911            },
2912            frozen_quadrature: Some(MeasureJetFrozenQuadrature {
2913                masses: masses.clone(),
2914                eps_band: eps_band.clone(),
2915                support_means: support_means.clone(),
2916                penalty_normalization_scales: penalty_normalization_scales.clone(),
2917                raw_penalty_normalization_scales: raw_penalty_normalization_scales.clone(),
2918                fused_penalty_normalization_scale: *fused_penalty_normalization_scale,
2919                sigma_coord: None,
2920            }),
2921        };
2922        (data, frozen)
2923    }
2924
2925    /// ψ-producer vs central finite differences of the NORMALIZED fit-time
2926    /// candidates under frozen geometry — per-level mode (coords α, lnτ).
2927    /// This is the end-to-end gate #901 never had: the derivative is checked
2928    /// against the exact object the optimizer consumes.
2929    #[test]
2930    pub(crate) fn psi_producer_matches_fd_per_level_mode() {
2931        let (data, frozen) = frozen_spec_fixture(0.0, true);
2932        let derivs =
2933            build_measure_jet_basis_psi_derivatives(data.view(), &frozen).expect("psi derivatives");
2934        let l_count = frozen
2935            .frozen_quadrature
2936            .as_ref()
2937            .expect("frozen quadrature")
2938            .eps_band
2939            .len();
2940        assert_eq!(
2941            derivs.penalties_first.len(),
2942            2,
2943            "per-level coords are (α, lnτ)"
2944        );
2945        assert_eq!(derivs.penalties_first[0].len(), l_count + 1);
2946        assert_eq!(derivs.penalties_cross_pairs, vec![(0, 1)]);
2947        let pen_at = |alpha: f64, tau0: f64| {
2948            let trial = MeasureJetBasisSpec {
2949                alpha,
2950                tau0,
2951                ..frozen.clone()
2952            };
2953            build_measure_jet_basis(data.view(), &trial)
2954                .expect("trial build")
2955                .active_penalties
2956                .into_iter()
2957                .map(|penalty| penalty.matrix)
2958                .collect::<Vec<_>>()
2959        };
2960        // Second-difference-optimal step (see the jets FD test): the 4-point
2961        // cross stencil shares the ~ε·scale/h² roundoff floor.
2962        let h = 1e-4;
2963        let (a0, t0) = (frozen.alpha, frozen.tau0);
2964        let ap = pen_at(a0 + h, t0);
2965        let am = pen_at(a0 - h, t0);
2966        let tp = pen_at(a0, t0 * h.exp());
2967        let tm = pen_at(a0, t0 * (-h).exp());
2968        assert_eq!(
2969            ap.len(),
2970            l_count + 1,
2971            "fixture must keep every scale active"
2972        );
2973        for level in 0..l_count {
2974            let fd_alpha = (&ap[level] - &am[level]) / (2.0 * h);
2975            let fd_tau = (&tp[level] - &tm[level]) / (2.0 * h);
2976            for (name, analytic, fd) in [
2977                ("alpha", &derivs.penalties_first[0][level], fd_alpha),
2978                ("ln_tau", &derivs.penalties_first[1][level], fd_tau),
2979            ] {
2980                let scale = fd.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
2981                for (x, y) in analytic.iter().zip(fd.iter()) {
2982                    assert!(
2983                        (x - y).abs() <= 5e-5 * scale,
2984                        "{name} jet of scale-candidate {level}: analytic {x:.6e} vs FD {y:.6e}"
2985                    );
2986                }
2987            }
2988        }
2989        // The function-space null candidate is independent of α and τ.
2990        for coord in 0..2 {
2991            assert!(
2992                derivs.penalties_first[coord][l_count]
2993                    .iter()
2994                    .all(|v| *v == 0.0),
2995                "null-component candidate must have zero (α, lnτ) drift"
2996            );
2997        }
2998        // Cross derivative through the provider, against a 4-point FD.
2999        let provider = derivs
3000            .penalties_cross_provider
3001            .as_ref()
3002            .expect("cross provider");
3003        let cross = provider.evaluate(0, 1).expect("cross pair (α, lnτ)");
3004        let pp = pen_at(a0 + h, t0 * h.exp());
3005        let pm = pen_at(a0 + h, t0 * (-h).exp());
3006        let mp = pen_at(a0 - h, t0 * h.exp());
3007        let mm = pen_at(a0 - h, t0 * (-h).exp());
3008        for level in 0..l_count {
3009            let fd = (&(&pp[level] - &pm[level]) - &(&mp[level] - &mm[level])) / (4.0 * h * h);
3010            let scale = fd.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
3011            for (x, y) in cross[level].iter().zip(fd.iter()) {
3012                assert!(
3013                    (x - y).abs() <= 5e-4 * scale,
3014                    "cross (α, lnτ) jet of scale-candidate {level}: analytic {x:.6e} vs FD {y:.6e}"
3015                );
3016            }
3017        }
3018    }
3019
3020    /// Design-moving ℓ dial (#1116): the producer's design jets and every
3021    /// normalized penalty candidate's jets must match central differences of the
3022    /// REBUILT objects under frozen geometry. Although the center-value forms
3023    /// `Q` and `H₀` are ℓ-invariant, their coefficient pullbacks `E(ℓ)ᵀQ E(ℓ)`
3024    /// and `E(ℓ)ᵀH₀E(ℓ)` are not.
3025    #[test]
3026    pub(crate) fn psi_producer_matches_fd_length_scale() {
3027        // Single-scale with opt-in ℓ learning; frozen geometry so only ℓ moves
3028        // across the FD trials.
3029        let (data, mut frozen) = frozen_spec_fixture(0.0, false);
3030        frozen.learn_length_scale = true;
3031        let derivs =
3032            build_measure_jet_basis_psi_derivatives(data.view(), &frozen).expect("psi derivatives");
3033        // ℓ is the only coordinate in single-scale + learn_length_scale.
3034        assert_eq!(
3035            derivs.design_first.len(),
3036            1,
3037            "single-scale + learn_length_scale enrolls exactly the ℓ coordinate"
3038        );
3039        assert_eq!(
3040            derivs.penalties_first[0].len(),
3041            2,
3042            "single-scale double penalty carries Primary + affine/null component"
3043        );
3044        // Rebuild design and normalized penalties at ℓ·e^{±h}; the explicit
3045        // positive length_scale is honored verbatim while the frozen transform
3046        // keeps the coefficient chart fixed.
3047        let ell0 = frozen.length_scale;
3048        let build_at = |ell: f64| {
3049            let trial = MeasureJetBasisSpec {
3050                length_scale: ell,
3051                ..frozen.clone()
3052            };
3053            build_measure_jet_basis(data.view(), &trial).expect("trial build")
3054        };
3055        let h: f64 = 1e-4;
3056        let plus = build_at(ell0 * h.exp());
3057        let minus = build_at(ell0 * (-h).exp());
3058        let at = build_at(ell0);
3059        assert_eq!(
3060            plus.active_penalties.len(),
3061            2,
3062            "fixture must keep both candidates active"
3063        );
3064        assert_eq!(
3065            minus.active_penalties.len(),
3066            2,
3067            "fixture must keep both candidates active"
3068        );
3069        assert_eq!(
3070            at.active_penalties.len(),
3071            2,
3072            "fixture must keep both candidates active"
3073        );
3074
3075        let x_plus = plus.design.to_dense();
3076        let x_minus = minus.design.to_dense();
3077        let x_0 = at.design.to_dense();
3078        let fd_first = (&x_plus - &x_minus) / (2.0 * h);
3079        let fd_second = (&x_plus - &(&x_0 * 2.0) + &x_minus) / (h * h);
3080        let scale1 = fd_first.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
3081        for (x, y) in derivs.design_first[0].iter().zip(fd_first.iter()) {
3082            assert!(
3083                (x - y).abs() <= 5e-5 * scale1,
3084                "∂X/∂lnℓ: analytic {x:.6e} vs FD {y:.6e}"
3085            );
3086        }
3087        let scale2 = fd_second.iter().fold(1e-30_f64, |acc, v| acc.max(v.abs()));
3088        for (x, y) in derivs.design_second_diag[0].iter().zip(fd_second.iter()) {
3089            assert!(
3090                (x - y).abs() <= 1e-3 * scale2,
3091                "∂²X/∂lnℓ²: analytic {x:.6e} vs FD {y:.6e}"
3092            );
3093        }
3094
3095        for candidate in 0..2 {
3096            let fd_penalty_first = (&plus.active_penalties[candidate].matrix
3097                - &minus.active_penalties[candidate].matrix)
3098                / (2.0 * h);
3099            let fd_penalty_second = (&plus.active_penalties[candidate].matrix
3100                - &(&at.active_penalties[candidate].matrix * 2.0)
3101                + &minus.active_penalties[candidate].matrix)
3102                / (h * h);
3103            let first_scale = fd_penalty_first
3104                .iter()
3105                .fold(1e-12_f64, |acc, value| acc.max(value.abs()));
3106            let second_scale = fd_penalty_second
3107                .iter()
3108                .fold(1e-10_f64, |acc, value| acc.max(value.abs()));
3109            for (analytic, finite_difference) in derivs.penalties_first[0][candidate]
3110                .iter()
3111                .zip(fd_penalty_first.iter())
3112            {
3113                assert!(
3114                    (analytic - finite_difference).abs() <= 1e-4 * first_scale,
3115                    "candidate {candidate} ∂S~/∂lnℓ: analytic {analytic:.6e} vs FD {finite_difference:.6e}"
3116                );
3117            }
3118            for (analytic, finite_difference) in derivs.penalties_second_diag[0][candidate]
3119                .iter()
3120                .zip(fd_penalty_second.iter())
3121            {
3122                assert!(
3123                    (analytic - finite_difference).abs() <= 5e-3 * second_scale,
3124                    "candidate {candidate} ∂²S~/∂lnℓ²: analytic {analytic:.6e} vs FD {finite_difference:.6e}"
3125                );
3126            }
3127        }
3128    }
3129
3130    /// Quadrature nodes must be the mass-weighted cell barycenters
3131    /// (first-moment-exact lumping), with empty cells keeping their seed
3132    /// coordinates at zero mass.
3133    #[test]
3134    pub(crate) fn quadrature_nodes_are_cell_barycenters() {
3135        // Two tight groups around (0,0) and (10,10); a third seed far away
3136        // captures nothing.
3137        let data = array![
3138            [0.0, 0.2],
3139            [0.4, -0.2],
3140            [0.2, 0.0],
3141            [9.8, 10.1],
3142            [10.2, 9.9],
3143        ];
3144        let seeds = array![[0.1, 0.1], [10.0, 10.0], [-50.0, -50.0]];
3145        let (nodes, masses) =
3146            measure_jet_quadrature_nodes(data.view(), seeds.view()).expect("quadrature nodes");
3147        assert!((masses.sum() - 1.0).abs() <= 1e-15, "masses must sum to 1");
3148        assert!((masses[0] - 0.6).abs() <= 1e-15);
3149        assert!((masses[1] - 0.4).abs() <= 1e-15);
3150        assert_eq!(masses[2], 0.0);
3151        // Cell 0 barycenter = (0.2, 0.0).
3152        assert_eq!(nodes[(0, 0)], 0.2);
3153        assert_eq!(nodes[(0, 1)], 0.0);
3154        // Cell 1 barycenter = (10.0, 10.0), which is not a sampled row.
3155        assert_eq!(nodes[(1, 0)], 10.0);
3156        assert_eq!(nodes[(1, 1)], 10.0);
3157        // Empty cell keeps its seed coordinates.
3158        assert_eq!(nodes[(2, 0)], -50.0);
3159        assert_eq!(nodes[(2, 1)], -50.0);
3160    }
3161
3162    /// Freeze→replay: rebuilding from the first build's frozen transform and
3163    /// frozen quadrature must reproduce design and penalty bit-for-bit (the
3164    /// predict-path contract).
3165    #[test]
3166    pub(crate) fn build_replay_roundtrip_reproduces_design_and_penalty() {
3167        // A bent filament with a side cluster; multiscale opt-in so this
3168        // exercises the per-scale (spectral) replay path (#1116).
3169        let n = 140usize;
3170        let data = Array2::<f64>::from_shape_fn((n, 2), |(i, k)| {
3171            let t = i as f64 / (n as f64 - 1.0);
3172            if k == 0 {
3173                t * 3.0
3174            } else {
3175                0.5 * (t * 3.0).cos() + if i % 9 == 0 { 0.8 } else { 0.0 }
3176            }
3177        });
3178        let spec = MeasureJetBasisSpec {
3179            center_strategy: CenterStrategy::FarthestPoint { num_centers: 70 },
3180            multiscale: true,
3181            ..MeasureJetBasisSpec::default()
3182        };
3183        let first = build_measure_jet_basis(data.view(), &spec).expect("first build");
3184        let BasisMetadata::MeasureJet {
3185            centers,
3186            length_scale,
3187            eps_band,
3188            order_s,
3189            alpha,
3190            tau0,
3191            masses,
3192            support_means,
3193            penalty_normalization_scales,
3194            raw_penalty_normalization_scales,
3195            fused_penalty_normalization_scale,
3196            constraint_transform,
3197            ..
3198        } = &first.metadata
3199        else {
3200            panic!("measure-jet build must return MeasureJet metadata");
3201        };
3202        let replay_spec = MeasureJetBasisSpec {
3203            center_strategy: CenterStrategy::UserProvided(centers.clone()),
3204            order_s: *order_s,
3205            alpha: *alpha,
3206            tau0: *tau0,
3207            num_scales: eps_band.len(),
3208            length_scale: *length_scale,
3209            double_penalty: spec.double_penalty,
3210            learn_length_scale: spec.learn_length_scale,
3211            multiscale: spec.multiscale,
3212            identifiability: MeasureJetIdentifiability::FrozenTransform {
3213                transform: constraint_transform.clone().expect("fit-time z"),
3214            },
3215            frozen_quadrature: Some(MeasureJetFrozenQuadrature {
3216                masses: masses.clone(),
3217                eps_band: eps_band.clone(),
3218                support_means: support_means.clone(),
3219                penalty_normalization_scales: penalty_normalization_scales.clone(),
3220                raw_penalty_normalization_scales: raw_penalty_normalization_scales.clone(),
3221                fused_penalty_normalization_scale: *fused_penalty_normalization_scale,
3222                sigma_coord: None,
3223            }),
3224        };
3225        // Per-level mode: one candidate per band scale plus the function-space
3226        // null component, and the count must survive replay bit-for-bit.
3227        assert_eq!(
3228            first.active_penalties.len(),
3229            eps_band.len() + 1,
3230            "per-level mode must emit one candidate per scale + null component"
3231        );
3232        let second = build_measure_jet_basis(data.view(), &replay_spec).expect("replay build");
3233        let x1 = first.design.to_dense();
3234        let x2 = second.design.to_dense();
3235        assert_eq!(x1.shape(), x2.shape());
3236        for (a, b) in x1.iter().zip(x2.iter()) {
3237            assert!((a - b).abs() <= 1e-12, "design replay drift: {a} vs {b}");
3238        }
3239        assert_eq!(first.active_penalties.len(), second.active_penalties.len());
3240        for (p1, p2) in first
3241            .active_penalties
3242            .iter()
3243            .zip(second.active_penalties.iter())
3244        {
3245            for (a, b) in p1.matrix.iter().zip(p2.matrix.iter()) {
3246                assert!((a - b).abs() <= 1e-12, "penalty replay drift: {a} vs {b}");
3247            }
3248        }
3249    }
3250}