Skip to main content

gam_terms/structure/
anova_atom.rs

1//! Post-fit functional-ANOVA carve of a fitted product-manifold atom (#975).
2//!
3//! # The carving problem
4//!
5//! Two circular attributes in superposition (weekday θ₁, month θ₂) trace a
6//! torus in activation space. Is that ONE T² atom or TWO superposed S¹
7//! atoms? Reconstruction cannot tell — same surface — so a learner without
8//! a principled criterion carves arbitrarily and "the dictionary" is an
9//! artifact of the carve. The GAM-native answer is functional ANOVA over
10//! the product manifold:
11//!
12//! ```text
13//!   g(θ₁, θ₂) = g₀ + f₁(θ₁) + f₂(θ₂) + f₁₂(θ₁, θ₂)
14//! ```
15//!
16//! with sum-to-zero centering against the EMPIRICAL CODE MEASURE (the
17//! averaging measure is itself a gauge choice; we pin it to the code
18//! sample and say so). Then **superposition = additivity** (`f₁₂ ≡ 0` ⇔
19//! the torus IS two superposed circles, and fission along ANOVA lines is
20//! lossless) and **binding = interaction** (`f₁₂ ≠ 0` is genuine joint
21//! structure; the atom is irreducible).
22//!
23//! # Why not just covariance in activations?
24//!
25//! Covariance is a second-moment statistic of the POINT CLOUD; the carve
26//! question is about the FUNCTIONAL FACTORIZATION of the surface. A bound
27//! torus and two superposed circles can trace the same point set with the
28//! same second moments — covariance sees the embedding, not whether the
29//! decoder map factors additively through the two angles. Independence of
30//! the codes (θ₁ ⫫ θ₂) is a third, separate property: codes can be
31//! dependent while the decoder is perfectly additive, and vice versa. Only
32//! the ANOVA interaction block answers "one atom or two".
33//!
34//! # Two inequivalent binding notions (both first-class here)
35//!
36//! - **Representational** binding: non-additivity of the DECODER `g` —
37//!   does the surface embed as two superposed atoms?
38//! - **Computational** binding: non-additivity of the pulled-back READOUT
39//!   `h(θ₁,θ₂) = F(g(θ₁,θ₂))` (logit jets through the forward map, #980) —
40//!   does the model USE the two angles jointly?
41//!
42//! All four quadrants occur. Independent steerability ("turn the weekday
43//! knob without dragging month behavior") requires additivity in BOTH
44//! senses, so the carve decision distinguishes them explicitly
45//! ([`FissionDecision`]): the same machinery runs twice — once on the
46//! decoder coefficients, once on readout-pulled-back coefficients — and
47//! choosing with only the representational arm is reported as such, never
48//! silently.
49//!
50//! # Not everything is clean — the quantitative dial
51//!
52//! A real model can be sort-of-bound: `f₁₂` small but nonzero, or binding
53//! present in the readout but not the embedding. The carve therefore never
54//! emits a bare verdict: [`CarveReport::interaction_fraction`] is the
55//! fraction of (centered) surface energy carried by the interaction — a
56//! continuous "how bound" number — and the planted-partial-binding power
57//! curve lives on exactly this dial. The binding test rejects when the
58//! data PROVES `f₁₂ ≠ 0`; fission additionally demands the interaction be
59//! energetically negligible, because absence of evidence is not evidence
60//! of absence. Atoms failing both stay whole and CONTESTED — the
61//! demote-never-reject philosophy: the claim goes to the evidence ledger
62//! (`structure_evidence::ClaimKind::BindingEdge`, p-value calibrated via
63//! `structure_evidence::log_e_from_p_calibrator`) and earns a probe
64//! budget, instead of a silent carve either way.
65//!
66//! # Post-fit by design
67//!
68//! This module is a PURE READ of a fitted tensor-product decoder: the
69//! caller supplies the factor bases evaluated on the code sample and the
70//! per-output-dim coefficient matrices (plus, optionally, their posterior
71//! covariance for the Wald test). It deliberately does NOT add an
72//! in-fit ANOVA basis kind: two independent circles are just two atoms
73//! summing — ordinary superposition, the default multi-atom model — so
74//! the product machinery is only ever needed at the moment a fitted pair
75//! shows dependent codes and the structure search must adjudicate
76//! merge-vs-keep. That adjudication consumes this carve.
77//!
78//! # The gauge inside the test (load-bearing)
79//!
80//! On a partition-of-unity factor basis (B-splines: `Σ_j φ_j ≡ 1`) the
81//! empirically centered basis functions `φ̃_j = φ_j − mean_n φ_j(θ_n)`
82//! carry one exact linear dependence per factor: `Σ_j φ̃_j ≡ 0`. The
83//! coefficient directions `u vᵀ + w uᵀ` (u the dependence vector) change
84//! NOTHING about `f₁₂` — they are pure gauge, their posterior values are
85//! penalty-set noise, and a Wald statistic that includes them is wrong.
86//! The binding test therefore projects the interaction block onto the
87//! gauge quotient (`C ↦ P₁ C P₂`, `P_i = I − û_i û_iᵀ`) before testing;
88//! the quotient dimension `(M₁−1)(M₂−1)` is the test's honest rank.
89
90use ndarray::{Array1, Array2, ArrayView1, ArrayView2, s};
91
92use crate::grid_spline_2d::{GridSpline2dDesign, axis_basis_at};
93use crate::inference::smooth_test::{
94    SmoothTestInput, SmoothTestResult, SmoothTestScale, wood_smooth_test,
95};
96use gam_linalg::faer_ndarray::FaerEigh;
97use gam_math::score_opt::{
98    AffineRemlProfile, ClosedInterval, ScoreOptimumLocation, certified_exp_representative,
99    certified_ln_positive,
100};
101
102/// Interaction energy fraction at or below which the interaction block is
103/// energetically negligible and lossless fission is on the table. The bar is
104/// the finite-sample NOISE FLOOR of the interaction estimate, not exact
105/// algebraic zero. A planted, exactly-additive coefficient matrix carves to
106/// numerical zero (≈ f64 roundoff), but a real REML fit of a genuinely
107/// separable surface over noisy scattered codes cannot drive its penalized
108/// interaction block below the variance its own estimator injects: a 5%-noise
109/// pair fit lands at ~`1e-4` of centered surface energy (a relative amplitude of
110/// `1e-2`, ≈ √fraction). `1e-4` sits just above that estimator floor so a
111/// separable atom actually fissions end to end (the production
112/// `fit_pair_surface → carve` path, which the planted in-module tests do not
113/// exercise), while staying far below any genuine interaction — the bound
114/// panels carry fractions orders of magnitude larger, and the companion binding
115/// Wald test resolves small-but-real interactions besides. Auto-applied — no
116/// knob.
117pub const FISSION_MAX_INTERACTION_FRACTION: f64 = 1e-4;
118
119/// Interaction energy fraction at or below which the gauge-projected
120/// interaction block is f64 roundoff rather than signal, so the binding Wald
121/// test cannot constitute proof of binding. An exactly-additive surface fits to
122/// machine precision; its scale-included posterior covariance collapses
123/// (`σ̂² → 0`) while the projected interaction coefficients are pure centering
124/// roundoff, so the Wald statistic degenerates into a `0/0` ratio — roundoff
125/// coefficients divided by a vanishing covariance — that can read as
126/// overwhelmingly significant (`p ≈ 0`). At or below this floor (a relative
127/// amplitude of `1e-6`, far above the ~`1e-30` roundoff an exactly-additive
128/// carve actually lands at, yet far below any interaction a finite-sample fit
129/// can statistically resolve) the surface is additive by construction and no
130/// such statistic counts as binding: absence of an interaction is not evidence
131/// of one. This keeps a numerically-additive atom from being held whole on a
132/// phantom edge. Auto-applied — no knob.
133const INTERACTION_NUMERICAL_FLOOR: f64 = 1e-12;
134
135/// Which binding notion a carve report speaks about (see module docs).
136///
137/// The two are independent, and which of them a given adjudication ran is
138/// carried in the answer rather than assumed: `fission_decision` returns
139/// [`FissionDecision::SplitReconstructionOnly`] exactly when only the
140/// representational carve was supplied, and
141/// [`FissionDecision::SplitCertifiedJoint`] only when both ran and both
142/// allow the split. So a caller that has no pulled-back readout coefficients
143/// still gets a correct, self-describing verdict — it just is not the joint
144/// one, and the enum says so.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum BindingNotion {
147    /// Decoder non-additivity: does the surface EMBED as two atoms?
148    Representational,
149    /// Pulled-back readout non-additivity: does the model USE the two
150    /// coordinates jointly? (Coefficients come from fitting the same
151    /// tensor basis to `h = F(g)` via the #980 output-Fisher harvest.)
152    Computational,
153}
154
155/// The exact ANOVA reparameterization of one output dimension's tensor
156/// coefficient matrix `C` (`M₁ × M₂`) under empirical-measure centering.
157/// With `m_i` the empirical mean of factor `i`'s basis over the code
158/// sample and `φ̃ = φ − m`, the surface decomposes EXACTLY (an identity,
159/// not an approximation):
160///
161/// ```text
162///   φ¹ᵀ C φ² = mean + φ̃¹ᵀ·main_a + φ̃²ᵀ·main_b + φ̃¹ᵀ C φ̃²
163/// ```
164///
165/// so `mean = m₁ᵀ C m₂`, `main_a = C m₂`, `main_b = Cᵀ m₁`, and the
166/// interaction block on the centered tensor basis is `C` itself (tested
167/// in its gauge quotient, see module docs).
168#[derive(Clone, Debug)]
169pub struct AnovaBlocks {
170    pub mean: f64,
171    pub main_a: Array1<f64>,
172    pub main_b: Array1<f64>,
173}
174
175/// Empirical mean of each basis column over the code sample — the
176/// centering vector `m` that pins the ANOVA gauge to the empirical code
177/// measure.
178pub fn basis_means(phi: ArrayView2<'_, f64>) -> Array1<f64> {
179    let n = phi.nrows().max(1) as f64;
180    let mut m = Array1::<f64>::zeros(phi.ncols());
181    for row in phi.rows() {
182        for (j, &v) in row.iter().enumerate() {
183            m[j] += v;
184        }
185    }
186    m.mapv_inplace(|v| v / n);
187    m
188}
189
190/// The exact reparameterization (see [`AnovaBlocks`]).
191pub fn anova_blocks(
192    c: ArrayView2<'_, f64>,
193    mean_a: ArrayView1<'_, f64>,
194    mean_b: ArrayView1<'_, f64>,
195) -> Result<AnovaBlocks, String> {
196    let (m1, m2) = c.dim();
197    if mean_a.len() != m1 || mean_b.len() != m2 {
198        return Err(format!(
199            "anova_blocks: coefficient matrix is {m1}×{m2} but centering means have lengths {} and {}",
200            mean_a.len(),
201            mean_b.len()
202        ));
203    }
204    let main_a = c.dot(&mean_b);
205    let main_b = c.t().dot(&mean_a);
206    let mean = mean_a.dot(&main_a);
207    Ok(AnovaBlocks {
208        mean,
209        main_a,
210        main_b,
211    })
212}
213
214/// One child atom's 1-D decoder for one output dimension, expressed on
215/// the CENTERED factor basis plus an explicit constant — basis-agnostic,
216/// no partition-of-unity assumption baked in. The child surface is
217/// `constant + φ̃(θ)ᵀ·centered_coeffs`.
218#[derive(Clone, Debug)]
219pub struct ChildDecoder {
220    pub constant: f64,
221    pub centered_coeffs: Array1<f64>,
222}
223
224impl ChildDecoder {
225    /// Fold the constant back into raw basis coefficients for a
226    /// partition-of-unity basis (`Σ_j φ_j ≡ 1`, e.g. B-splines):
227    /// `constant + φ̃ᵀa = φᵀ(a + (constant − mᵀa)·1)`. For non-PoU bases
228    /// keep the explicit-constant form instead.
229    pub fn raw_coeffs_partition_of_unity(&self, means: ArrayView1<'_, f64>) -> Array1<f64> {
230        let shift = self.constant - means.dot(&self.centered_coeffs);
231        self.centered_coeffs.mapv(|v| v) + Array1::from_elem(self.centered_coeffs.len(), shift)
232    }
233}
234
235/// The lossless-on-the-additive-part split: child atoms inheriting the
236/// main-effect blocks. Gauge choice (documented, fixed): the grand mean
237/// `g₀` rides with child A; child B is centered. The interaction energy
238/// the split discards is DECLARED in `reconstruction_defect` — by the
239/// fission rule it is ≤ [`FISSION_MAX_INTERACTION_FRACTION`], but it is
240/// never silently zero.
241#[derive(Clone, Debug)]
242pub struct FissionPlan {
243    /// Per output dimension: child atom on factor A (`g₀ + f₁`).
244    pub child_a: Vec<ChildDecoder>,
245    /// Per output dimension: child atom on factor B (`f₂`).
246    pub child_b: Vec<ChildDecoder>,
247    /// Interaction energy fraction the split throws away.
248    pub reconstruction_defect: f64,
249}
250
251/// What the carve concluded for one binding notion.
252#[derive(Clone, Debug)]
253pub struct CarveReport {
254    pub notion: BindingNotion,
255    /// Wood-style Wald test of the gauge-projected interaction block, one
256    /// per output dimension (`None` where covariance was unavailable or
257    /// the test degenerated).
258    pub binding_tests: Vec<Option<SmoothTestResult>>,
259    /// Edge-level binding p-value: Bonferroni min-p across output
260    /// dimensions (conservative under arbitrary cross-dimension
261    /// dependence — the dimensions share every code). `None` when no
262    /// per-dimension test ran. This is the number that feeds
263    /// `structure_evidence::ClaimKind::BindingEdge` through
264    /// `log_e_from_p_calibrator`.
265    pub edge_p_value: Option<f64>,
266    /// Fraction of centered surface energy carried by the interaction,
267    /// aggregated over output dimensions — the continuous "how bound"
268    /// dial (0 = perfectly additive, 1 = pure interaction).
269    pub interaction_fraction: f64,
270    /// The lossless split, present iff this notion's carve allows it:
271    /// interaction energetically negligible AND not proven present.
272    pub fission: Option<FissionPlan>,
273}
274
275/// The joint adjudication over both notions — three-valued on purpose:
276/// the representational and computational carves differ exactly on the
277/// off-diagonal quadrants, so collapsing them silently is the one
278/// forbidden move.
279#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280pub enum FissionDecision {
281    /// Both notions additive: the split is safe for every downstream use,
282    /// including independent-knob steering.
283    SplitCertifiedJoint,
284    /// Decoder additive but the computational arm was NOT run (no readout
285    /// coefficients supplied): the split is certified for reconstruction
286    /// only — steering independence is unverified.
287    SplitReconstructionOnly,
288    /// At least one ran notion refuses (binding proven or interaction
289    /// non-negligible): the atom stays whole and contested.
290    Keep,
291}
292
293/// A penalized tensor-surface fit over the code sample: the producer of
294/// [`CarveInput`]s for BOTH binding notions (#993 items 1–2).
295///
296/// `coeffs[d]` is the fitted `M₁ × M₂` coefficient matrix for response
297/// dimension `d`; `coeff_covariance[d]` is the matching SCALE-INCLUDED
298/// posterior covariance of its row-major vec (the mgcv-`Vb` object
299/// [`wood_smooth_test`] contracts for); `joint_covariance()` assembles
300/// the cross-dimension covariance for the joint binding test. The fit is
301/// evaluated against the SAME empirical code measure the carve centers
302/// against — the test and its covariance live on one measure by
303/// construction, which is the coherence the production fit's own Hessian
304/// (a different parameterization: tangent frames, not tensor
305/// coefficients) cannot offer the carve.
306#[derive(Clone, Debug)]
307pub struct TensorSurfaceFit {
308    /// Per response dimension, `M₁ × M₂`.
309    pub coeffs: Vec<Array2<f64>>,
310    /// Per response dimension, scale-included `Vb` of the row-major vec.
311    pub coeff_covariance: Vec<Array2<f64>>,
312    /// Scale-included residual cross-covariance between response
313    /// dimensions (`D × D`, entries `r_dᵀ r_e / (n − edf)`). Diagonal
314    /// entries are the per-dimension scales the `Vb`s carry.
315    pub residual_cross_cov: Array2<f64>,
316    /// Scale-FREE coefficient covariance shared by all dimensions
317    /// (`V (Λ+λI)⁻¹ Vᵀ`, `M₁M₂ × M₁M₂`); `coeff_covariance[d]` is this
318    /// times `residual_cross_cov[d,d]`.
319    pub unit_covariance: Array2<f64>,
320    /// REML-selected ridge strength.
321    pub lambda: f64,
322    /// Effective degrees of freedom `Σ dᵢ/(dᵢ+λ)` (per dimension; the
323    /// design and λ are shared).
324    pub edf: f64,
325    /// Residual degrees of freedom `n − edf` (the denominator d.f. for
326    /// the `Estimated`-scale F branch).
327    pub residual_df: f64,
328}
329
330impl TensorSurfaceFit {
331    /// Joint covariance of the dimension-major stacked coefficient vector
332    /// `[vec(C₀); vec(C₁); …]`: with a shared design and shared λ the
333    /// posterior is the Kronecker product
334    /// `residual_cross_cov ⊗ unit_covariance` — index `(d·M + i, e·M + j)
335    /// = S[d,e]·U[i,j]`. Feed to [`CarveInput::joint_coeff_covariance`].
336    pub fn joint_covariance(&self) -> Array2<f64> {
337        let d_dims = self.residual_cross_cov.nrows();
338        let m = self.unit_covariance.nrows();
339        let mut joint = Array2::<f64>::zeros((d_dims * m, d_dims * m));
340        for d in 0..d_dims {
341            for e in 0..d_dims {
342                let s_de = self.residual_cross_cov[[d, e]];
343                if s_de == 0.0 {
344                    continue;
345                }
346                for i in 0..m {
347                    for j in 0..m {
348                        joint[[d * m + i, e * m + j]] = s_de * self.unit_covariance[[i, j]];
349                    }
350                }
351            }
352        }
353        joint
354    }
355}
356
357/// Fit the tensor-product surface `y_d(θ₁,θ₂) ≈ φ¹(θ₁)ᵀ C_d φ²(θ₂)` to
358/// sampled responses by ridge-penalized least squares with the ridge
359/// strength chosen by GAUSSIAN REML (profiled σ², exact 1-D criterion on
360/// the design's eigenbasis — no GCV, per policy), returning coefficients
361/// AND their scale-included posterior covariance.
362///
363/// This is the missing producer #993 names for both carve arms:
364/// - **representational**: `responses` = the atom's activation
365///   contributions over the code sample (its reconstruction targets);
366/// - **computational**: `responses` = the pulled-back readout
367///   `h(θ₁,θ₂) = F(g(θ))` rows from the #980 output-Fisher harvest.
368///
369/// `phi_a`/`phi_b` are the factor bases on the code sample (`n × M_i`,
370/// the same matrices the carve consumes — one measure end to end);
371/// `responses` is `n × D`. The design column for `(j, k)` is
372/// `φ¹_j·φ²_k` at row-major index `j·M₂+k`, matching the carve's vec
373/// convention exactly. One λ is shared across response dimensions (one
374/// surface smoothness), chosen by the pooled REML criterion; per-dim
375/// scales are estimated from residuals at `n − edf`.
376pub fn fit_tensor_surface(
377    phi_a: ArrayView2<'_, f64>,
378    phi_b: ArrayView2<'_, f64>,
379    responses: ArrayView2<'_, f64>,
380) -> Result<TensorSurfaceFit, String> {
381    let n = phi_a.nrows();
382    let m1 = phi_a.ncols();
383    let m2 = phi_b.ncols();
384    let mm = m1 * m2;
385    let d_dims = responses.ncols();
386    if phi_b.nrows() != n || responses.nrows() != n {
387        return Err(format!(
388            "fit_tensor_surface: sample sizes disagree (phi_a {n}, phi_b {}, responses {})",
389            phi_b.nrows(),
390            responses.nrows()
391        ));
392    }
393    if mm == 0 || d_dims == 0 || n < 2 {
394        return Err(format!(
395            "fit_tensor_surface: degenerate problem (n={n}, M₁M₂={mm}, D={d_dims})"
396        ));
397    }
398
399    // Design X (n × M₁M₂), row-major column convention j·M₂+k.
400    let mut x = Array2::<f64>::zeros((n, mm));
401    for r in 0..n {
402        for j in 0..m1 {
403            let pa = phi_a[[r, j]];
404            if pa == 0.0 {
405                continue;
406            }
407            for k in 0..m2 {
408                x[[r, j * m2 + k]] = pa * phi_b[[r, k]];
409            }
410        }
411    }
412    let xtx = x.t().dot(&x);
413    let xty = x.t().dot(&responses); // mm × D
414    let (evals, evecs) = xtx
415        .eigh(faer::Side::Lower)
416        .map_err(|e| format!("fit_tensor_surface: design eigendecomposition failed: {e:?}"))?;
417    let spectral_radius = evals
418        .iter()
419        .map(|value| value.abs())
420        .fold(0.0_f64, f64::max);
421    if !spectral_radius.is_finite() {
422        return Err("fit_tensor_surface: design eigendecomposition is non-finite".to_string());
423    }
424    // XᵀX is positive semidefinite.  Permit projection to the PSD cone only
425    // inside the eigensolver's dimension-scaled backward-error band; a mode
426    // below that band is evidence of invalid arithmetic, not a zero mode.
427    let spectral_roundoff = f64::EPSILON * mm as f64 * spectral_radius;
428    let mut gram_modes = Vec::with_capacity(mm);
429    for (index, &value) in evals.iter().enumerate() {
430        if value < -spectral_roundoff {
431            return Err(format!(
432                "fit_tensor_surface: Gram eigenvalue {index} is {value}, below the PSD \
433                 roundoff band -{spectral_roundoff}"
434            ));
435        }
436        gram_modes.push(value.max(0.0));
437    }
438    let d_max = gram_modes.iter().copied().fold(0.0f64, f64::max);
439    if !(d_max > 0.0) {
440        return Err("fit_tensor_surface: design is identically zero".to_string());
441    }
442    let b = evecs.t().dot(&xty); // mm × D, rotated cross-products
443    let yty: Vec<f64> = (0..d_dims)
444        .map(|d| responses.column(d).dot(&responses.column(d)))
445        .collect();
446    let log_n = certified_ln_positive(n as f64)
447        .ok_or_else(|| "fit_tensor_surface: could not enclose log(n)".to_string())?;
448    let mut null_score_enclosure = ClosedInterval::point(0.0);
449    for (output, &energy) in yty.iter().enumerate() {
450        if !(energy.is_finite() && energy > 0.0) {
451            return Err(format!(
452                "fit_tensor_surface: response {output} has non-positive energy {energy}; \
453                 its profiled Gaussian scale has no finite REML optimum"
454            ));
455        }
456        null_score_enclosure = null_score_enclosure.add(
457            certified_ln_positive(energy)
458                .ok_or_else(|| {
459                    format!(
460                        "fit_tensor_surface: could not enclose response {output} energy log"
461                    )
462                })?
463                .sub(log_n),
464        );
465    }
466    null_score_enclosure = null_score_enclosure.scale(-0.5 * n as f64);
467
468    // Pooled Gaussian REML in the eigensystem.  For h_i(λ) = d_i + λ,
469    // the profiled score is
470    //
471    //   -1/2 { n Σ_d log(PRSS_d/n)
472    //          + D [Σ_i log h_i - M log λ] },
473    //   PRSS_d = y_dᵀy_d - Σ_i b_id²/h_i.
474    //
475    // `AffineRemlProfile` evaluates this expression together with its exact
476    // first two log-λ derivatives and rigorous derivative enclosures.  The
477    // global search can therefore discard an interval only after proving that
478    // it contains no stationary point; every isolated stationary point and
479    // both finite boundaries participate in the final comparison.
480    // Normalize the pencil by its largest Gram eigenvalue.  This is an exact
481    // change of smoothing-parameter coordinates, λ = d_max·exp(ρ): every
482    // `log(d_i + λ) - log(λ)` contribution is invariant, while exponentiating
483    // ρ cannot underflow merely because the input basis carries extreme units.
484    let profile_gram_modes: Vec<f64> = gram_modes.iter().map(|&value| value / d_max).collect();
485    let penalty_modes = vec![1.0; mm];
486    let rhs_scale = d_max.sqrt();
487    let mut projected_rhs_squared = Vec::with_capacity(mm * d_dims);
488    for d in 0..d_dims {
489        for i in 0..mm {
490            let normalized_rhs = b[[i, d]] / rhs_scale;
491            projected_rhs_squared.push(normalized_rhs * normalized_rhs);
492        }
493    }
494    let profile = AffineRemlProfile::new(
495        &profile_gram_modes,
496        &penalty_modes,
497        &projected_rhs_squared,
498        &yty,
499        n as f64,
500        mm,
501        0.0,
502    )
503    .map_err(|error| format!("fit_tensor_surface: invalid REML profile: {error}"))?;
504
505    // Cover every spectral transition without a user- or lattice-resolution
506    // knob. At the lower bound λ/d_min = sqrt(machine epsilon), so every
507    // positive Gram mode is numerically at its λ→0 limit; at the upper
508    // bound d_max/λ has the same relation and every mode is at its null-fit
509    // limit. The true λ=∞ null is compared analytically below instead of
510    // being approximated by that finite upper bound.
511    let d_min_relative = profile_gram_modes
512        .iter()
513        .copied()
514        .filter(|&value| value > 0.0)
515        .fold(f64::INFINITY, f64::min);
516    let relative_resolution = f64::EPSILON.sqrt();
517    let log_relative_resolution = certified_ln_positive(relative_resolution).ok_or_else(|| {
518        "fit_tensor_surface: could not enclose the relative-resolution logarithm".to_string()
519    })?;
520    let log_d_min = certified_ln_positive(d_min_relative).ok_or_else(|| {
521        "fit_tensor_surface: could not enclose the smallest spectral transition".to_string()
522    })?;
523    let log_minimum_normal = certified_ln_positive(f64::MIN_POSITIVE).ok_or_else(|| {
524        "fit_tensor_surface: could not enclose the minimum-normal logarithm".to_string()
525    })?;
526    let log_lambda_lo = log_d_min
527        .add(log_relative_resolution)
528        .lo
529        .max(log_minimum_normal.lo);
530    let log_lambda_hi = log_relative_resolution.neg().hi;
531    let search = profile
532        .maximize_value_ordered(log_lambda_lo, log_lambda_hi, relative_resolution)
533        .map_err(|error| {
534            format!("fit_tensor_surface: REML stationary isolation failed: {error}")
535        })?;
536
537    // Exact full-shrinkage boundary. As λ→∞ the determinant correction
538    // is identically zero and PRSS_d→y_dᵀy_d. Choosing infinity is safe for
539    // the algebra below (coefficients, EDF, and covariance all become zero) and
540    // makes null recovery exact rather than a large-finite-λ approximation.
541    let lambda = if search.value_certificate.maximum.lo <= null_score_enclosure.hi {
542        f64::INFINITY
543    } else {
544        if search.value_certificate.maximum_excess
545            > search.value_certificate.comparison_resolution
546        {
547            return Err(format!(
548                "fit_tensor_surface: finite REML candidates are not globally ordered \
549                 (maximum excess {}, comparison resolution {})",
550                search.value_certificate.maximum_excess,
551                search.value_certificate.comparison_resolution
552            ));
553        }
554        // A boundary optimum is an ANSWER, not a failure. The search window is
555        // placed (see the domain comment above) so its lower end already IS the
556        // lambda->0 limit of every positive Gram mode, so a response the tensor
557        // basis interpolates -- the carve re-fit is one by construction -- puts
558        // the profiled maximum exactly there. Refusing it refuses the fit.
559        //
560        // `ScoreOptimumLocation` is initialised to a boundary and only upgraded
561        // to `Stationary` when a stationary point strictly beats it, so
562        // demanding `Stationary` demands that an interior point win. The sibling
563        // REML routine in this crate (`GridSpline2dDesign::fit_reml`) handles all
564        // four arms -- "Both boundaries compete directly with all isolated
565        // optima" -- and so does every other consumer in the workspace. This
566        // call site was the only one refusing them: an incomplete port, not a
567        // designed constraint.
568        //
569        // Every certificate is kept. A boundary is proved with the one-sided KKT
570        // condition, an interior point with the two-sided one, and a
571        // resolution-flat window is still refused outright.
572        enum KktKind {
573            LowerBoundary,
574            UpperBoundary,
575            Stationary,
576        }
577        let (bracket, kkt_kind) = match search.location {
578            ScoreOptimumLocation::LowerBoundary => (
579                ClosedInterval::point(search.lower_boundary.x),
580                KktKind::LowerBoundary,
581            ),
582            ScoreOptimumLocation::UpperBoundary => (
583                ClosedInterval::point(search.upper_boundary.x),
584                KktKind::UpperBoundary,
585            ),
586            ScoreOptimumLocation::Stationary(index) => (
587                search
588                    .stationary_points
589                    .get(index)
590                    .ok_or_else(|| {
591                        "fit_tensor_surface: optimizer returned an invalid stationary index"
592                            .to_string()
593                    })?
594                    .bracket,
595                KktKind::Stationary,
596            ),
597            ScoreOptimumLocation::ResolutionFlat(index) => {
598                let flat = search.resolution_flat_regions.get(index).ok_or_else(|| {
599                    "fit_tensor_surface: optimizer returned an invalid resolution-flat index"
600                        .to_string()
601                })?;
602                return Err(format!(
603                    "fit_tensor_surface: finite REML optimum is value-resolved but not \
604                     stationary on {:?} (gap {}, resolution {})",
605                    flat.bracket, flat.max_score_gap, flat.score_resolution
606                ));
607            }
608        };
609        let kkt = profile
610            .enclose(bracket.lo, bracket.hi)
611            .map_err(|error| format!("fit_tensor_surface: {error}"))?;
612        let kkt_holds = match kkt_kind {
613            KktKind::LowerBoundary => kkt.derivative.hi <= 0.0,
614            KktKind::UpperBoundary => kkt.derivative.lo >= 0.0,
615            KktKind::Stationary => kkt.derivative.contains_zero() && kkt.curvature.hi < 0.0,
616        };
617        if !kkt_holds {
618            return Err(format!(
619                "fit_tensor_surface: exact-real REML KKT certificate failed on {bracket:?}: {kkt:?}"
620            ));
621        }
622        let relative_lambda = certified_exp_representative(search.optimum.x).ok_or_else(|| {
623            "fit_tensor_surface: could not construct the certified finite REML representative"
624                .to_string()
625        })?;
626        let lambda = d_max * relative_lambda;
627        if !(lambda.is_finite() && lambda > 0.0) {
628            return Err(format!(
629                "fit_tensor_surface: selected finite REML strength is not representable \
630                 after restoring the Gram scale ({d_max} * {relative_lambda})"
631            ));
632        }
633        lambda
634    };
635
636    // Coefficients, EDF, residuals, covariances at the selected λ.
637    let mut edf = 0.0f64;
638    for i in 0..mm {
639        let d_i = gram_modes[i];
640        edf += d_i / (d_i + lambda);
641    }
642    let residual_df = n as f64 - edf;
643    if residual_df < 1.0 {
644        return Err(format!(
645            "fit_tensor_surface: too few samples for the surface (n={n}, edf={edf:.2}); \
646             the scale estimate needs n − edf ≥ 1"
647        ));
648    }
649    // β̂ in the eigenbasis, then rotate back: beta = V (Λ+λ)⁻¹ b.
650    let mut beta_rot = Array2::<f64>::zeros((mm, d_dims));
651    for i in 0..mm {
652        let denom = gram_modes[i] + lambda;
653        for d in 0..d_dims {
654            beta_rot[[i, d]] = b[[i, d]] / denom;
655        }
656    }
657    let beta = evecs.dot(&beta_rot); // mm × D
658    let fitted = x.dot(&beta); // n × D
659    let mut residual_cross_cov = Array2::<f64>::zeros((d_dims, d_dims));
660    for d in 0..d_dims {
661        for e in d..d_dims {
662            let mut acc = 0.0f64;
663            for r in 0..n {
664                acc += (responses[[r, d]] - fitted[[r, d]]) * (responses[[r, e]] - fitted[[r, e]]);
665            }
666            let v = acc / residual_df;
667            residual_cross_cov[[d, e]] = v;
668            residual_cross_cov[[e, d]] = v;
669        }
670    }
671    // Scale-free V (Λ+λ)⁻¹ Vᵀ.
672    let mut scaled_evecs = evecs.clone();
673    for i in 0..mm {
674        let denom = gram_modes[i] + lambda;
675        for row in 0..mm {
676            scaled_evecs[[row, i]] = evecs[[row, i]] / denom;
677        }
678    }
679    let unit_covariance = scaled_evecs.dot(&evecs.t());
680
681    let mut coeffs = Vec::with_capacity(d_dims);
682    let mut coeff_covariance = Vec::with_capacity(d_dims);
683    for d in 0..d_dims {
684        let mut c = Array2::<f64>::zeros((m1, m2));
685        for j in 0..m1 {
686            for k in 0..m2 {
687                c[[j, k]] = beta[[j * m2 + k, d]];
688            }
689        }
690        coeffs.push(c);
691        coeff_covariance.push(&unit_covariance * residual_cross_cov[[d, d]]);
692    }
693
694    Ok(TensorSurfaceFit {
695        coeffs,
696        coeff_covariance,
697        residual_cross_cov,
698        unit_covariance,
699        lambda,
700        edf,
701        residual_df,
702    })
703}
704
705/// The real-fit producer of a representational [`CarveInput`] from a fitted
706/// `d = 2` product atom (#993).
707///
708/// Holds the two factor bases the carve consumes plus the
709/// [`TensorSurfaceFit`] re-fit of the atom's own ambient reconstruction. The
710/// fit is what supplies the scale-included decoder-coefficient covariance
711/// (`coeff_covariance` / `joint_covariance`) — the production inner Hessian is
712/// a DIFFERENT parameterization (tangent frames, not tensor coefficients) and
713/// cannot offer the carve a coefficient-space `Vb`, so the carve's covariance
714/// is re-derived here on the same empirical code measure the test centers
715/// against (the coherence the module docs require). Owns its arrays so the
716/// borrowed [`CarveInput`] built via [`Self::representational_carve_input`] can
717/// reference them for the lifetime of the carve call.
718#[derive(Clone, Debug)]
719pub struct FittedAtomCarveInput {
720    /// Factor-A basis on the code sample, `n × M₁`.
721    pub phi_a: Array2<f64>,
722    /// Factor-B basis on the code sample, `n × M₂`.
723    pub phi_b: Array2<f64>,
724    /// REML re-fit of the atom's ambient reconstruction onto the tensor basis,
725    /// carrying the per-channel coefficient matrices and their scale-included
726    /// covariance.
727    pub surface: TensorSurfaceFit,
728    /// Cross-dimension joint covariance of the stacked coefficient vector
729    /// (`TensorSurfaceFit::joint_covariance`), materialized once so the
730    /// borrowed [`CarveInput`] can reference it.
731    pub joint_covariance: Array2<f64>,
732}
733
734impl FittedAtomCarveInput {
735    /// Borrow this bundle as a representational [`CarveInput`] ready for
736    /// [`carve`]. The coefficient covariance and the joint covariance come
737    /// from the REML re-fit; the gauge kernels default to the
738    /// partition-of-unity convention (`u = 1`), which is the correct centered-
739    /// basis null direction for the constant-leading harmonic factor bases.
740    pub fn representational_carve_input(&self) -> CarveInput<'_> {
741        CarveInput {
742            phi_a: self.phi_a.view(),
743            phi_b: self.phi_b.view(),
744            coeffs: self.surface.coeffs.as_slice(),
745            coeff_covariance: Some(self.surface.coeff_covariance.as_slice()),
746            joint_coeff_covariance: Some(&self.joint_covariance),
747            kernel_a: None,
748            kernel_b: None,
749            edf: Some(self.surface.edf),
750            residual_df: self.surface.residual_df,
751            scale: SmoothTestScale::Estimated,
752            notion: BindingNotion::Representational,
753        }
754    }
755}
756
757/// Build the representational carve inputs for a fitted `d = 2` product atom
758/// directly from its FUSED tensor basis and decoder (#993).
759///
760/// `basis_values` is the atom's `Φ_k` on the code sample (`n × M₁M₂`), laid
761/// out as the Kronecker product of the two per-axis factor bases in row-major
762/// column order `flat = j·M₂ + k` (the convention every product evaluator —
763/// `TorusHarmonicEvaluator`, `CylinderHarmonicEvaluator` — emits, with the
764/// per-axis CONSTANT column at axis-index 0). `decoder_coefficients` is `B_k`
765/// (`M₁M₂ × p`). `m_a`/`m_b` are the two factor basis sizes (`m_a·m_b` must
766/// equal the fused width).
767///
768/// The factor bases are recovered exactly from the fused basis using the
769/// constant-leading-column property: with `φ²₀ ≡ 1`, column `j·M₂` is
770/// `φ¹_j·φ²₀ = φ¹_j`, and with `φ¹₀ ≡ 1`, column `k` is `φ¹₀·φ²_k = φ²_k`. The
771/// recovered factorization is then VERIFIED against every fused column
772/// (`Φ[:, j·M₂+k] = φ¹_j·φ²_k` to a tight tolerance) so a non-separable basis
773/// (a wrong split, or a kind whose leading column is not the unit constant) is
774/// rejected loudly rather than silently mis-carved.
775///
776/// The carve responses are the atom's own ambient reconstruction
777/// `m_k(t) = Φ_k(t)·B_k` (`n × p`); fitting the tensor surface to it on the
778/// same code measure yields the scale-included coefficient covariance the
779/// binding Wald test needs. The reconstruction is an exact linear image of the
780/// decoder, so the re-fit recovers the decoder's own ANOVA structure (the
781/// representational binding question) with a covariance that is honest about
782/// the finite code sample.
783pub fn carve_input_from_fitted_atom(
784    basis_values: ArrayView2<'_, f64>,
785    decoder_coefficients: ArrayView2<'_, f64>,
786    m_a: usize,
787    m_b: usize,
788) -> Result<FittedAtomCarveInput, String> {
789    let n = basis_values.nrows();
790    let fused = basis_values.ncols();
791    let p = decoder_coefficients.ncols();
792    if m_a == 0 || m_b == 0 {
793        return Err(format!(
794            "carve_input_from_fitted_atom: degenerate factor sizes (m_a={m_a}, m_b={m_b})"
795        ));
796    }
797    if m_a.checked_mul(m_b) != Some(fused) {
798        return Err(format!(
799            "carve_input_from_fitted_atom: factor sizes {m_a}×{m_b} do not multiply to the \
800             fused basis width {fused}"
801        ));
802    }
803    if decoder_coefficients.nrows() != fused {
804        return Err(format!(
805            "carve_input_from_fitted_atom: decoder has {} rows but the fused basis is width {fused}",
806            decoder_coefficients.nrows()
807        ));
808    }
809    if n < 2 || p == 0 {
810        return Err(format!(
811            "carve_input_from_fitted_atom: degenerate sample (n={n}, p={p})"
812        ));
813    }
814
815    // Recover the factor bases from the constant-leading Kronecker layout:
816    // φ¹_j = Φ[:, j·M₂ + 0]  (φ²₀ ≡ 1),   φ²_k = Φ[:, 0·M₂ + k]  (φ¹₀ ≡ 1).
817    let mut phi_a = Array2::<f64>::zeros((n, m_a));
818    for j in 0..m_a {
819        let col = j * m_b;
820        for row in 0..n {
821            phi_a[[row, j]] = basis_values[[row, col]];
822        }
823    }
824    let mut phi_b = Array2::<f64>::zeros((n, m_b));
825    for k in 0..m_b {
826        for row in 0..n {
827            phi_b[[row, k]] = basis_values[[row, k]];
828        }
829    }
830
831    // Verify the fused basis really is the Kronecker product of the recovered
832    // factors (separability + constant-leading-column assumption). The check is
833    // relative to the fused magnitude so it is scale-honest; a non-product atom
834    // or a wrong split fails here instead of being silently mis-carved.
835    let mut max_abs = 0.0_f64;
836    for &v in basis_values.iter() {
837        max_abs = max_abs.max(v.abs());
838    }
839    let tol = 1e-9 * (1.0 + max_abs);
840    for j in 0..m_a {
841        for k in 0..m_b {
842            let col = j * m_b + k;
843            for row in 0..n {
844                let recon = phi_a[[row, j]] * phi_b[[row, k]];
845                if (recon - basis_values[[row, col]]).abs() > tol {
846                    return Err(format!(
847                        "carve_input_from_fitted_atom: fused basis is not the Kronecker product \
848                         of the {m_a}×{m_b} factor split (entry [{row},{col}] = {} vs φ¹·φ² = {recon}); \
849                         the atom is not a constant-leading product basis",
850                        basis_values[[row, col]]
851                    ));
852                }
853            }
854        }
855    }
856
857    // Carve responses = the atom's ambient reconstruction m_k = Φ_k · B_k.
858    let reconstruction = basis_values.dot(&decoder_coefficients);
859
860    // REML re-fit of the reconstruction onto the SAME tensor basis: supplies the
861    // scale-included decoder-coefficient covariance the binding Wald test reads.
862    let surface = fit_tensor_surface(phi_a.view(), phi_b.view(), reconstruction.view())?;
863    let joint_covariance = surface.joint_covariance();
864
865    Ok(FittedAtomCarveInput {
866        phi_a,
867        phi_b,
868        surface,
869        joint_covariance,
870    })
871}
872
873/// Engine cap on knot cells per axis (the grid engine's dense-Cholesky
874/// sizing contract: `p = (K+3)² ≤ 1225`).
875const PAIR_COMPONENT_MAX_CELLS: usize = 32;
876/// Floor on knot cells per axis — below 4 cells the cubic tensor basis has
877/// too little resolution to carry a pair interaction worth carving.
878const PAIR_COMPONENT_MIN_CELLS: usize = 4;
879
880/// Knot cells per axis for the raw-coordinate pair component, chosen from
881/// the sample size alone (magic by default — no knob): `K ≈ n^(1/3)`
882/// clamped to `[4, 32]`. The cube-root growth keeps the basis comfortably
883/// inside the data's resolution (p = (K+3)² ≪ n for all n ≥ ~300) while
884/// REML owns the actual smoothness; the cap is the engine's sizing contract.
885fn pair_component_cells(n: usize) -> usize {
886    ((n as f64).cbrt().ceil() as usize).clamp(PAIR_COMPONENT_MIN_CELLS, PAIR_COMPONENT_MAX_CELLS)
887}
888
889/// Which estimator produced a [`PairSurfaceFit`].
890#[derive(Clone, Copy, Debug, PartialEq, Eq)]
891pub enum PairSurfaceBackend {
892    /// The streaming 2-D grid engine: exact REML on the full anisotropic
893    /// biharmonic penalty (mixed `f_{x1x2}` term included), O(n) assembly,
894    /// exact log-determinants — the first-class pair-component estimator.
895    GridExact,
896    /// The dense ridge fallback ([`fit_tensor_surface`]) on the SAME
897    /// B-spline tensor basis, used only when the grid solve degenerates
898    /// (e.g. a non-positive-definite penalized system or `n − edf < 1`).
899    DenseRidge,
900}
901
902/// A pair-component fit from RAW coordinates: the factor bases it was fit
903/// on (the grid engine's per-axis uniform cubic B-splines, evaluated on the
904/// sample — exactly what [`CarveInput`] consumes, one measure end to end)
905/// plus the [`TensorSurfaceFit`] carve product and which backend produced it.
906#[derive(Clone, Debug)]
907pub struct PairSurfaceFit {
908    /// Axis-1 basis on the sample (`n × (K+3)`, partition of unity).
909    pub phi_a: Array2<f64>,
910    /// Axis-2 basis on the sample (`n × (K+3)`, partition of unity).
911    pub phi_b: Array2<f64>,
912    /// The carve product: coefficients, covariances, λ, EDF.
913    pub surface: TensorSurfaceFit,
914    pub backend: PairSurfaceBackend,
915    /// Lower corner of the per-axis uniform knot range (the data's
916    /// bounding box) — with [`Self::cell_widths`], everything needed to
917    /// rebuild a basis row at an arbitrary point.
918    pub lower_corner: [f64; 2],
919    /// Knot-cell width per axis.
920    pub cell_widths: [f64; 2],
921}
922
923impl PairSurfaceFit {
924    /// Posterior `(mean, variance)` of response dimension `dim` at an
925    /// arbitrary point, through the carve-facing posterior objects — valid
926    /// for BOTH backends, since both populate the same surface contract:
927    /// `mean = b₁ᵀ C_d b₂` and `variance = σ̂²_d · xᵀUx` with `U` the shared
928    /// scale-free coefficient covariance, `σ̂²_d` the residual variance at
929    /// `n − edf`, and `x` the 16-entry tensor basis row. Outside the data
930    /// bounding box the boundary cell's cubic polynomial extends (the grid
931    /// engine's convention).
932    pub fn predict(&self, dim: usize, x1: f64, x2: f64) -> Result<(f64, f64), String> {
933        let d_dims = self.surface.coeffs.len();
934        if dim >= d_dims {
935            return Err(format!(
936                "pair surface: response dimension {dim} out of range (D = {d_dims})"
937            ));
938        }
939        if !(x1.is_finite() && x2.is_finite()) {
940            return Err(format!(
941                "pair surface: non-finite prediction point ({x1}, {x2})"
942            ));
943        }
944        let m = self.phi_a.ncols();
945        let cells = m - 3;
946        let (j1, b1) = axis_basis_at(self.lower_corner[0], self.cell_widths[0], cells, x1);
947        let (j2, b2) = axis_basis_at(self.lower_corner[1], self.cell_widths[1], cells, x2);
948        let c = &self.surface.coeffs[dim];
949        let u = &self.surface.unit_covariance;
950        let mut mean = 0.0;
951        let mut quad = 0.0;
952        for i in 0..4 {
953            for j in 0..4 {
954                let v_ij = b1[i] * b2[j];
955                mean += v_ij * c[[j1 + i, j2 + j]];
956                let g_ij = (j1 + i) * m + (j2 + j);
957                for a in 0..4 {
958                    for b in 0..4 {
959                        quad += v_ij * b1[a] * b2[b] * u[[g_ij, (j1 + a) * m + (j2 + b)]];
960                    }
961                }
962            }
963        }
964        Ok((mean, self.surface.residual_cross_cov[[dim, dim]] * quad))
965    }
966}
967
968/// THE pair-component estimator (#1031): fit the Layer-B ANOVA pair
969/// interaction surface from RAW coordinates, auto-routed with no knobs.
970///
971/// Estimator. A `(K+3)²` tensor of uniform cubic B-splines over the data's
972/// bounding box, penalized by the FULL anisotropic biharmonic energy
973/// `∫∫ a₁²f₁₁² + 2a₁a₂f₁₂² + a₂²f₂₂²` (mixed term included — the roughness
974/// functional no `te()` Kronecker-marginal penalty matches, which is
975/// exactly why this is exposed as its own estimator instead of an
976/// auto-route through the formula smooths: routing `te`/Duchon through it
977/// would silently change their posteriors). One λ is shared across response
978/// dimensions (one surface smoothness), selected by the pooled exact REML
979/// criterion. `K` grows as `n^(1/3)` (capped by the engine's sizing
980/// contract); the metric is pinned to `a_i = L_i²` (squared bounding-box
981/// span per axis), which makes the penalty — and hence the estimator —
982/// invariant to per-axis rescaling of the coordinates, with the leftover
983/// global constant absorbed by λ.
984///
985/// Route. The streaming grid engine evaluates this estimator EXACTLY in
986/// O(n): one scatter-add pass, banded sufficient statistics, exact
987/// log-determinant REML, exact posterior summary. When its solve
988/// degenerates (non-PD penalized system, `n − edf < 1`), the same basis
989/// falls back to the dense ridge path ([`fit_tensor_surface`]) — a
990/// different (heavier, isotropic-in-coefficients) penalty but the same
991/// surface class, so every input that admits a pair component gets one.
992///
993/// The returned bases and fit feed [`CarveInput`] directly (B-splines are
994/// partition-of-unity, so the default `kernel_a`/`kernel_b` gauge applies).
995pub fn fit_pair_surface(
996    x1: &[f64],
997    x2: &[f64],
998    responses: ArrayView2<'_, f64>,
999) -> Result<PairSurfaceFit, String> {
1000    let n = x1.len();
1001    let d_dims = responses.ncols();
1002    if x2.len() != n || responses.nrows() != n {
1003        return Err(format!(
1004            "fit_pair_surface: sample sizes disagree (x1 {n}, x2 {}, responses {})",
1005            x2.len(),
1006            responses.nrows()
1007        ));
1008    }
1009    if d_dims == 0 {
1010        return Err("fit_pair_surface: no response dimensions".to_string());
1011    }
1012    // Axis-rescaling-invariant metric a_i = L_i² (see the doc comment).
1013    let mut span = [0.0_f64; 2];
1014    for (ax, xs) in [x1, x2].into_iter().enumerate() {
1015        let mut lo = f64::INFINITY;
1016        let mut hi = f64::NEG_INFINITY;
1017        for &v in xs {
1018            lo = lo.min(v);
1019            hi = hi.max(v);
1020        }
1021        if !(hi > lo && hi.is_finite() && lo.is_finite()) {
1022            return Err(format!(
1023                "fit_pair_surface: axis {} is degenerate or non-finite ([{lo}, {hi}]); \
1024                 no pair surface exists over a collapsed axis",
1025                ax + 1
1026            ));
1027        }
1028        span[ax] = hi - lo;
1029    }
1030    let metric = [span[0] * span[0], span[1] * span[1]];
1031    let k = pair_component_cells(n);
1032
1033    let columns: Vec<Vec<f64>> = (0..d_dims).map(|d| responses.column(d).to_vec()).collect();
1034    let column_refs: Vec<&[f64]> = columns.iter().map(Vec::as_slice).collect();
1035    let weights = vec![1.0_f64; n];
1036    let design = GridSpline2dDesign::build_multi(x1, x2, &column_refs, &weights, k, metric)?;
1037
1038    // The factor bases on the sample — shared by both routes and by the
1039    // carve (one empirical measure end to end).
1040    let lower_corner = design.lower_corner();
1041    let cell_widths = design.cell_widths();
1042    let m = design.basis_per_axis();
1043    let mut phi_a = Array2::<f64>::zeros((n, m));
1044    let mut phi_b = Array2::<f64>::zeros((n, m));
1045    for r in 0..n {
1046        let (j0, vals) = design.axis_basis(0, x1[r])?;
1047        for (i, &v) in vals.iter().enumerate() {
1048            phi_a[[r, j0 + i]] = v;
1049        }
1050        let (j0, vals) = design.axis_basis(1, x2[r])?;
1051        for (i, &v) in vals.iter().enumerate() {
1052            phi_b[[r, j0 + i]] = v;
1053        }
1054    }
1055
1056    match design
1057        .fit_reml()
1058        .and_then(|fit| design.posterior(&fit).map(|post| (fit, post)))
1059    {
1060        Ok((fit, post)) => {
1061            let mm = m * m;
1062            let mut unit_covariance = Array2::<f64>::zeros((mm, mm));
1063            for i in 0..mm {
1064                for j in 0..mm {
1065                    unit_covariance[[i, j]] = post.unit_covariance[i * mm + j];
1066                }
1067            }
1068            let mut residual_cross_cov = Array2::<f64>::zeros((d_dims, d_dims));
1069            for d in 0..d_dims {
1070                for e in 0..d_dims {
1071                    residual_cross_cov[[d, e]] = post.residual_cross_cov[d * d_dims + e];
1072                }
1073            }
1074            let mut coeffs = Vec::with_capacity(d_dims);
1075            let mut coeff_covariance = Vec::with_capacity(d_dims);
1076            for d in 0..d_dims {
1077                // Engine flat order g = j1·(K+3) + j2 IS the carve's
1078                // row-major (j·M₂ + k) vec convention.
1079                let mut c = Array2::<f64>::zeros((m, m));
1080                for j in 0..m {
1081                    for kk in 0..m {
1082                        c[[j, kk]] = fit.coeffs[d][j * m + kk];
1083                    }
1084                }
1085                coeffs.push(c);
1086                coeff_covariance.push(&unit_covariance * residual_cross_cov[[d, d]]);
1087            }
1088            Ok(PairSurfaceFit {
1089                phi_a,
1090                phi_b,
1091                surface: TensorSurfaceFit {
1092                    coeffs,
1093                    coeff_covariance,
1094                    residual_cross_cov,
1095                    unit_covariance,
1096                    lambda: gam_problem::checked_exp_log_strength(fit.log_lambda)
1097                        .map_err(|error| format!("ANOVA pair-surface log strength: {error}"))?,
1098                    edf: post.edf,
1099                    residual_df: post.residual_df,
1100                },
1101                backend: PairSurfaceBackend::GridExact,
1102                lower_corner,
1103                cell_widths,
1104            })
1105        }
1106        Err(grid_err) => {
1107            let surface =
1108                fit_tensor_surface(phi_a.view(), phi_b.view(), responses).map_err(|dense_err| {
1109                    format!(
1110                        "fit_pair_surface: grid engine degenerated ({grid_err}) and the dense \
1111                         ridge fallback failed too ({dense_err})"
1112                    )
1113                })?;
1114            Ok(PairSurfaceFit {
1115                phi_a,
1116                phi_b,
1117                surface,
1118                backend: PairSurfaceBackend::DenseRidge,
1119                lower_corner,
1120                cell_widths,
1121            })
1122        }
1123    }
1124}
1125
1126/// Inputs for one notion's carve over one fitted product atom.
1127///
1128/// `phi_a`/`phi_b`: factor bases evaluated on the code sample (`n × M_i`).
1129/// `coeffs`: per-output-dim coefficient matrices (`M₁ × M₂` each); for the
1130/// representational notion these are the decoder's, for the computational
1131/// notion they come from fitting the same tensor basis to the pulled-back
1132/// readout. `coeff_covariance`: matching scale-included posterior
1133/// covariance of the ROW-MAJOR vec of each `C` (`M₁M₂ × M₁M₂` per output
1134/// dim) — optional; without it the carve still reports the energy
1135/// fraction but runs no Wald test. `kernel_a`/`kernel_b`: the per-factor
1136/// coefficient direction along which the centered basis is degenerate
1137/// (`Σ_j u_j φ̃_j ≡ 0`); `None` selects the partition-of-unity convention
1138/// `u = 1` (B-splines). `edf`: fitted EDF of the interaction block when
1139/// the fit tracked one; `None` uses the full quotient rank
1140/// `(M₁−1)(M₂−1)`.
1141pub struct CarveInput<'a> {
1142    pub phi_a: ArrayView2<'a, f64>,
1143    pub phi_b: ArrayView2<'a, f64>,
1144    pub coeffs: &'a [Array2<f64>],
1145    pub coeff_covariance: Option<&'a [Array2<f64>]>,
1146    /// Covariance of the dimension-major STACKED coefficient vector
1147    /// `[vec(C₀); vec(C₁); …]` (`D·M₁M₂` square, scale-included), e.g.
1148    /// [`TensorSurfaceFit::joint_covariance`]. When present, the
1149    /// edge-level binding p-value comes from ONE joint Wald over the
1150    /// stacked gauge-projected blocks at rank `D·(M₁−1)(M₂−1)` instead of
1151    /// the conservative Bonferroni min-p across dimensions (the per-dim
1152    /// tests share every code row, so Bonferroni over-corrects).
1153    pub joint_coeff_covariance: Option<&'a Array2<f64>>,
1154    pub kernel_a: Option<Array1<f64>>,
1155    pub kernel_b: Option<Array1<f64>>,
1156    pub edf: Option<f64>,
1157    pub residual_df: f64,
1158    pub scale: SmoothTestScale,
1159    pub notion: BindingNotion,
1160}
1161
1162/// The carve: exact ANOVA split, interaction energy, gauge-projected
1163/// binding test, and the fission plan when this notion permits one.
1164///
1165/// Fission rule (asymmetric on purpose): the test REJECTING proves
1166/// binding and always blocks the split; the test NOT rejecting is only
1167/// absence of evidence, so the split additionally requires the
1168/// interaction to be energetically negligible
1169/// ([`FISSION_MAX_INTERACTION_FRACTION`]). An atom with a fat but
1170/// unproven interaction stays whole and contested — route its
1171/// `edge_p_value` into the evidence ledger and let the probe loop earn
1172/// the verdict.
1173pub fn carve(input: &CarveInput<'_>, alpha: f64) -> Result<CarveReport, String> {
1174    let n = input.phi_a.nrows();
1175    if input.phi_b.nrows() != n {
1176        return Err(format!(
1177            "carve: factor bases disagree on sample size ({n} vs {})",
1178            input.phi_b.nrows()
1179        ));
1180    }
1181    if input.coeffs.is_empty() {
1182        return Err("carve: no coefficient matrices supplied".to_string());
1183    }
1184    let m1 = input.phi_a.ncols();
1185    let m2 = input.phi_b.ncols();
1186    if let Some(covs) = input.coeff_covariance
1187        && covs.len() != input.coeffs.len()
1188    {
1189        return Err(format!(
1190            "carve: {} coefficient matrices but {} covariance blocks",
1191            input.coeffs.len(),
1192            covs.len()
1193        ));
1194    }
1195    if !(alpha > 0.0 && alpha < 1.0) {
1196        return Err(format!("carve: alpha must be in (0,1), got {alpha}"));
1197    }
1198
1199    let mean_a = basis_means(input.phi_a);
1200    let mean_b = basis_means(input.phi_b);
1201    // Centered factor evaluations φ̃ = φ − m (n × M_i).
1202    let phi_a_c = {
1203        let mut p = input.phi_a.to_owned();
1204        for mut row in p.rows_mut() {
1205            for j in 0..m1 {
1206                row[j] -= mean_a[j];
1207            }
1208        }
1209        p
1210    };
1211    let phi_b_c = {
1212        let mut p = input.phi_b.to_owned();
1213        for mut row in p.rows_mut() {
1214            for j in 0..m2 {
1215                row[j] -= mean_b[j];
1216            }
1217        }
1218        p
1219    };
1220
1221    // Gauge projectors P_i = I − û ûᵀ for the centered-basis dependence,
1222    // and their Kronecker product (the row-major-vec transform shared by
1223    // the per-dimension and joint Wald tests).
1224    let proj_a = gauge_projector(m1, input.kernel_a.as_ref())?;
1225    let proj_b = gauge_projector(m2, input.kernel_b.as_ref())?;
1226    let gauge_kron = gauge_kron_rowmajor(&proj_a, &proj_b);
1227
1228    let mut child_a: Vec<ChildDecoder> = Vec::with_capacity(input.coeffs.len());
1229    let mut child_b: Vec<ChildDecoder> = Vec::with_capacity(input.coeffs.len());
1230    let mut binding_tests: Vec<Option<SmoothTestResult>> = Vec::with_capacity(input.coeffs.len());
1231    let mut interaction_energy = 0.0f64;
1232    let mut centered_energy = 0.0f64;
1233
1234    for (dim, c) in input.coeffs.iter().enumerate() {
1235        if c.dim() != (m1, m2) {
1236            return Err(format!(
1237                "carve: coefficient matrix {dim} is {:?}, bases say ({m1}, {m2})",
1238                c.dim()
1239            ));
1240        }
1241        let blocks = anova_blocks(c.view(), mean_a.view(), mean_b.view())?;
1242
1243        // Interaction values on the sample: f₁₂(θ_n) = φ̃¹_n ᵀ C φ̃²_n,
1244        // computed as the row-wise dot of (Φ̃₁ C) with Φ̃₂.
1245        let phi_a_c_c = phi_a_c.dot(c);
1246        let main_a_vals = phi_a_c.dot(&blocks.main_a);
1247        let main_b_vals = phi_b_c.dot(&blocks.main_b);
1248        for row in 0..n {
1249            let mut f12 = 0.0f64;
1250            for k in 0..m2 {
1251                f12 += phi_a_c_c[[row, k]] * phi_b_c[[row, k]];
1252            }
1253            interaction_energy += f12 * f12;
1254            let centered = main_a_vals[row] + main_b_vals[row] + f12;
1255            centered_energy += centered * centered;
1256        }
1257
1258        // Gauge-projected Wald test of the interaction block.
1259        let test = match input.coeff_covariance {
1260            None => None,
1261            Some(covs) => binding_wald_test(
1262                c,
1263                &covs[dim],
1264                &proj_a,
1265                &proj_b,
1266                &gauge_kron,
1267                input.edf,
1268                input.residual_df,
1269                input.scale,
1270            ),
1271        };
1272        binding_tests.push(test);
1273
1274        child_a.push(ChildDecoder {
1275            constant: blocks.mean,
1276            centered_coeffs: blocks.main_a,
1277        });
1278        child_b.push(ChildDecoder {
1279            constant: 0.0,
1280            centered_coeffs: blocks.main_b,
1281        });
1282    }
1283
1284    let interaction_fraction = if centered_energy > 0.0 {
1285        interaction_energy / centered_energy
1286    } else {
1287        0.0
1288    };
1289    // Edge-level p: the joint Wald over the stacked gauge-projected
1290    // blocks when the cross-dimension covariance is available (exact
1291    // rank, no Bonferroni slack), else Bonferroni min-p across the
1292    // per-dimension tests (valid under their arbitrary dependence,
1293    // conservative).
1294    let edge_p_value = match input.joint_coeff_covariance {
1295        Some(joint_cov) => joint_binding_wald_test(
1296            input.coeffs,
1297            joint_cov,
1298            &proj_a,
1299            &proj_b,
1300            &gauge_kron,
1301            input.edf,
1302            input.residual_df,
1303            input.scale,
1304        )
1305        .map(|t| t.p_value),
1306        None => {
1307            let ran: Vec<f64> = binding_tests.iter().flatten().map(|t| t.p_value).collect();
1308            ran.iter()
1309                .cloned()
1310                .fold(None, |acc: Option<f64>, p| {
1311                    Some(acc.map_or(p, |a| a.min(p)))
1312                })
1313                .map(|min_p| (min_p * ran.len() as f64).min(1.0))
1314        }
1315    };
1316
1317    // A Wald test cannot prove the PRESENCE of an interaction whose energy is
1318    // numerically indistinguishable from zero. When the interaction block is at
1319    // the f64 roundoff floor (an exactly-additive surface fit to machine
1320    // precision), the scale-included posterior collapses with it and the Wald
1321    // statistic becomes a 0/0 artifact that can read as overwhelmingly
1322    // significant (p ≈ 0). Below the floor the surface is additive by
1323    // construction, so no statistic counts as binding and the atom is free to
1324    // fission — see `INTERACTION_NUMERICAL_FLOOR`.
1325    let numerically_additive = interaction_fraction <= INTERACTION_NUMERICAL_FLOOR;
1326    let binding_proven = !numerically_additive && edge_p_value.is_some_and(|p| p <= alpha);
1327    let negligible = interaction_fraction <= FISSION_MAX_INTERACTION_FRACTION;
1328    let fission = if negligible && !binding_proven {
1329        Some(FissionPlan {
1330            child_a,
1331            child_b,
1332            reconstruction_defect: interaction_fraction,
1333        })
1334    } else {
1335        None
1336    };
1337
1338    Ok(CarveReport {
1339        notion: input.notion,
1340        binding_tests,
1341        edge_p_value,
1342        interaction_fraction,
1343        fission,
1344    })
1345}
1346
1347/// Joint adjudication across the two binding notions (see
1348/// [`FissionDecision`]). `representational` must be a
1349/// [`BindingNotion::Representational`] report; `computational`, when the
1350/// #980 pulled-back coefficients were available, the matching
1351/// [`BindingNotion::Computational`] one.
1352pub fn fission_decision(
1353    representational: &CarveReport,
1354    computational: Option<&CarveReport>,
1355) -> FissionDecision {
1356    if representational.fission.is_none() {
1357        return FissionDecision::Keep;
1358    }
1359    match computational {
1360        Some(comp) => {
1361            if comp.fission.is_some() {
1362                FissionDecision::SplitCertifiedJoint
1363            } else {
1364                FissionDecision::Keep
1365            }
1366        }
1367        None => FissionDecision::SplitReconstructionOnly,
1368    }
1369}
1370
1371/// `P = I − û ûᵀ` for the factor's centered-basis kernel direction
1372/// (default: the partition-of-unity vector of ones). Projecting the
1373/// interaction block with these on both sides picks the unique gauge
1374/// representative with no component along the directions that do not
1375/// change `f₁₂`.
1376fn gauge_projector(m: usize, kernel: Option<&Array1<f64>>) -> Result<Array2<f64>, String> {
1377    let u = match kernel {
1378        Some(k) => {
1379            if k.len() != m {
1380                return Err(format!(
1381                    "gauge_projector: kernel length {} != basis size {m}",
1382                    k.len()
1383                ));
1384            }
1385            k.clone()
1386        }
1387        None => Array1::<f64>::ones(m),
1388    };
1389    let norm_sq: f64 = u.dot(&u);
1390    let mut p = Array2::<f64>::eye(m);
1391    if norm_sq > 0.0 {
1392        for i in 0..m {
1393            for j in 0..m {
1394                p[[i, j]] -= u[i] * u[j] / norm_sq;
1395            }
1396        }
1397    }
1398    Ok(p)
1399}
1400
1401/// `K = P₁ ⊗ P₂` under the row-major vec convention
1402/// (`vec(A X B)[a·M₂+c] = Σ A[a,j]·B[k,c]·vec(X)[j·M₂+k]`; `P₂`
1403/// symmetric) — the coefficient-space transform realizing the gauge
1404/// projection `C ↦ P₁ C P₂` on row-major vecs. Built once per carve and
1405/// shared by the per-dimension and joint Wald tests.
1406fn gauge_kron_rowmajor(proj_a: &Array2<f64>, proj_b: &Array2<f64>) -> Array2<f64> {
1407    let m1 = proj_a.nrows();
1408    let m2 = proj_b.nrows();
1409    let mm = m1 * m2;
1410    let mut kron = Array2::<f64>::zeros((mm, mm));
1411    for a in 0..m1 {
1412        for j in 0..m1 {
1413            let pa = proj_a[[a, j]];
1414            if pa == 0.0 {
1415                continue;
1416            }
1417            for cc in 0..m2 {
1418                for k in 0..m2 {
1419                    kron[[a * m2 + cc, j * m2 + k]] = pa * proj_b[[k, cc]];
1420                }
1421            }
1422        }
1423    }
1424    kron
1425}
1426
1427/// Wald test of `f₁₂ ≡ 0` for one output dimension: transform the raw
1428/// interaction coefficients to the gauge quotient (`z = vec(P₁ C P₂)`,
1429/// row-major; `Σ_z = K Σ Kᵀ` with `K = P₁ ⊗ P₂`) and hand the projected
1430/// block to [`wood_smooth_test`] at the quotient rank. Returns `None`
1431/// when the test degenerates (the caller records "not tested", which is
1432/// not "additive").
1433fn binding_wald_test(
1434    c: &Array2<f64>,
1435    cov: &Array2<f64>,
1436    proj_a: &Array2<f64>,
1437    proj_b: &Array2<f64>,
1438    gauge_kron: &Array2<f64>,
1439    edf: Option<f64>,
1440    residual_df: f64,
1441    scale: SmoothTestScale,
1442) -> Option<SmoothTestResult> {
1443    let (m1, m2) = c.dim();
1444    let mm = m1 * m2;
1445    if cov.dim() != (mm, mm) {
1446        return None;
1447    }
1448    // z = vec(P₁ C P₂), row-major.
1449    let projected = proj_a.dot(c).dot(proj_b);
1450    let mut z = Array1::<f64>::zeros(mm);
1451    for j in 0..m1 {
1452        for k in 0..m2 {
1453            z[j * m2 + k] = projected[[j, k]];
1454        }
1455    }
1456    let cov_z = gauge_kron.dot(cov).dot(&gauge_kron.t());
1457    let quotient_rank = ((m1.saturating_sub(1)) * (m2.saturating_sub(1))).max(1) as f64;
1458    let edf = edf.unwrap_or(quotient_rank).min(quotient_rank);
1459    wood_smooth_test(SmoothTestInput {
1460        beta: z.view(),
1461        covariance: &cov_z,
1462        influence_matrix: None,
1463        whitening_gram: None,
1464        coeff_range: 0..mm,
1465        edf,
1466        nullspace_dim: 0,
1467        residual_df: Some(residual_df),
1468        scale,
1469    })
1470}
1471
1472/// ONE Wald test of `f₁₂ ≡ 0 across all output dimensions jointly` (#993
1473/// item 4): stack the gauge-projected interaction vecs dimension-major,
1474/// transform the supplied joint covariance by the block-diagonal
1475/// `I_D ⊗ K`, and test at the joint quotient rank `D·(M₁−1)(M₂−1)`. This
1476/// replaces the Bonferroni combination exactly where Bonferroni is
1477/// loosest — strongly cross-correlated output dimensions (they share
1478/// every code row).
1479fn joint_binding_wald_test(
1480    coeffs: &[Array2<f64>],
1481    joint_cov: &Array2<f64>,
1482    proj_a: &Array2<f64>,
1483    proj_b: &Array2<f64>,
1484    gauge_kron: &Array2<f64>,
1485    edf: Option<f64>,
1486    residual_df: f64,
1487    scale: SmoothTestScale,
1488) -> Option<SmoothTestResult> {
1489    let d_dims = coeffs.len();
1490    if d_dims == 0 {
1491        return None;
1492    }
1493    let (m1, m2) = coeffs[0].dim();
1494    let mm = m1 * m2;
1495    let total = d_dims * mm;
1496    if joint_cov.dim() != (total, total) {
1497        return None;
1498    }
1499    // Stacked z: dimension-major [vec(P₁C₀P₂); vec(P₁C₁P₂); …].
1500    let mut z = Array1::<f64>::zeros(total);
1501    for (d, c) in coeffs.iter().enumerate() {
1502        let projected = proj_a.dot(c).dot(proj_b);
1503        for j in 0..m1 {
1504            for k in 0..m2 {
1505                z[d * mm + j * m2 + k] = projected[[j, k]];
1506            }
1507        }
1508    }
1509    // Σ_z = (I_D ⊗ K) · J · (I_D ⊗ K)ᵀ, computed blockwise.
1510    let mut cov_z = Array2::<f64>::zeros((total, total));
1511    for d in 0..d_dims {
1512        for e in 0..d_dims {
1513            let block = joint_cov.slice(s![d * mm..(d + 1) * mm, e * mm..(e + 1) * mm]);
1514            let transformed = gauge_kron.dot(&block).dot(&gauge_kron.t());
1515            cov_z
1516                .slice_mut(s![d * mm..(d + 1) * mm, e * mm..(e + 1) * mm])
1517                .assign(&transformed);
1518        }
1519    }
1520    let quotient_rank = ((m1.saturating_sub(1)) * (m2.saturating_sub(1))).max(1) as f64;
1521    let per_dim_edf = edf.unwrap_or(quotient_rank).min(quotient_rank);
1522    wood_smooth_test(SmoothTestInput {
1523        beta: z.view(),
1524        covariance: &cov_z,
1525        influence_matrix: None,
1526        whitening_gram: None,
1527        coeff_range: 0..total,
1528        edf: per_dim_edf * d_dims as f64,
1529        nullspace_dim: 0,
1530        residual_df: Some(residual_df),
1531        scale,
1532    })
1533}
1534
1535#[cfg(test)]
1536mod tests {
1537    use super::*;
1538    use ndarray::array;
1539
1540    /// A tiny partition-of-unity "hat" basis on a 3-point sample: rows sum
1541    /// to 1, columns are linearly independent over the sample.
1542    fn pou_basis() -> Array2<f64> {
1543        array![
1544            [0.7, 0.2, 0.1],
1545            [0.2, 0.6, 0.2],
1546            [0.1, 0.3, 0.6],
1547            [0.5, 0.4, 0.1],
1548            [0.1, 0.2, 0.7],
1549        ]
1550    }
1551
1552    fn pou_basis_b() -> Array2<f64> {
1553        array![
1554            [0.6, 0.3, 0.1],
1555            [0.1, 0.8, 0.1],
1556            [0.3, 0.3, 0.4],
1557            [0.2, 0.5, 0.3],
1558            [0.4, 0.1, 0.5],
1559        ]
1560    }
1561
1562    /// The reparameterization is an identity: blocks + interaction values
1563    /// reassemble the raw surface exactly, sample point by sample point.
1564    #[test]
1565    fn anova_reparameterization_is_exact() {
1566        let phi_a = pou_basis();
1567        let phi_b = pou_basis_b();
1568        let c = array![[1.3, -0.4, 0.2], [0.0, 0.8, -1.1], [2.0, 0.5, 0.3]];
1569        let mean_a = basis_means(phi_a.view());
1570        let mean_b = basis_means(phi_b.view());
1571        let blocks = anova_blocks(c.view(), mean_a.view(), mean_b.view()).expect("blocks");
1572
1573        for row in 0..phi_a.nrows() {
1574            let pa = phi_a.row(row);
1575            let pb = phi_b.row(row);
1576            let raw = pa.dot(&c.dot(&pb.to_owned()));
1577            let pa_c: Array1<f64> = &pa.to_owned() - &mean_a;
1578            let pb_c: Array1<f64> = &pb.to_owned() - &mean_b;
1579            let f12 = pa_c.dot(&c.dot(&pb_c));
1580            let rebuilt = blocks.mean + pa_c.dot(&blocks.main_a) + pb_c.dot(&blocks.main_b) + f12;
1581            assert!(
1582                (raw - rebuilt).abs() < 1e-12,
1583                "row {row}: raw {raw} vs rebuilt {rebuilt}"
1584            );
1585        }
1586    }
1587
1588    /// A planted ADDITIVE surface (`C = a·1ᵀ + 1·bᵀ` on partition-of-unity
1589    /// bases) has identically zero interaction, fissions, and the children
1590    /// reassemble the parent exactly (lossless split, defect 0).
1591    #[test]
1592    fn planted_additive_torus_fissions_losslessly() {
1593        let phi_a = pou_basis();
1594        let phi_b = pou_basis_b();
1595        let a = array![1.0, -0.5, 2.0];
1596        let b = array![0.3, 1.7, -1.0];
1597        let mut c = Array2::<f64>::zeros((3, 3));
1598        for j in 0..3 {
1599            for k in 0..3 {
1600                c[[j, k]] = a[j] + b[k];
1601            }
1602        }
1603        let input = CarveInput {
1604            phi_a: phi_a.view(),
1605            phi_b: phi_b.view(),
1606            coeffs: &[c.clone()],
1607            coeff_covariance: None,
1608            joint_coeff_covariance: None,
1609            kernel_a: None,
1610            kernel_b: None,
1611            edf: None,
1612            residual_df: 100.0,
1613            scale: SmoothTestScale::Known,
1614            notion: BindingNotion::Representational,
1615        };
1616        let report = carve(&input, 0.05).expect("carve");
1617        assert!(report.interaction_fraction < 1e-24);
1618        let plan = report
1619            .fission
1620            .as_ref()
1621            .expect("additive surface must fission");
1622        assert!(plan.reconstruction_defect < 1e-24);
1623
1624        // Children reassemble the parent surface exactly.
1625        let mean_a = basis_means(phi_a.view());
1626        let mean_b = basis_means(phi_b.view());
1627        for row in 0..phi_a.nrows() {
1628            let pa = phi_a.row(row);
1629            let pb = phi_b.row(row);
1630            let raw = pa.dot(&c.dot(&pb.to_owned()));
1631            let pa_c: Array1<f64> = &pa.to_owned() - &mean_a;
1632            let pb_c: Array1<f64> = &pb.to_owned() - &mean_b;
1633            let child_sum = plan.child_a[0].constant
1634                + pa_c.dot(&plan.child_a[0].centered_coeffs)
1635                + plan.child_b[0].constant
1636                + pb_c.dot(&plan.child_b[0].centered_coeffs);
1637            assert!((raw - child_sum).abs() < 1e-12);
1638        }
1639
1640        // Raw-coefficient form on the partition-of-unity basis agrees too.
1641        let raw_a = plan.child_a[0].raw_coeffs_partition_of_unity(mean_a.view());
1642        for row in 0..phi_a.nrows() {
1643            let pa = phi_a.row(row);
1644            let pa_c: Array1<f64> = &pa.to_owned() - &mean_a;
1645            let via_centered =
1646                plan.child_a[0].constant + pa_c.dot(&plan.child_a[0].centered_coeffs);
1647            assert!((pa.dot(&raw_a) - via_centered).abs() < 1e-12);
1648        }
1649    }
1650
1651    /// A planted BOUND surface (rank-1 centered interaction) must refuse
1652    /// to fission, and with a tight posterior the binding test must reject;
1653    /// the planted additive surface under the same covariance must NOT
1654    /// reject — the asymmetry that makes the test a test.
1655    #[test]
1656    fn planted_bound_torus_refuses_and_test_rejects() {
1657        let phi_a = pou_basis();
1658        let phi_b = pou_basis_b();
1659        // Centered directions (orthogonal to the PoU kernel = ones).
1660        let at = array![1.0, -1.0, 0.0];
1661        let bt = array![0.0, 1.0, -1.0];
1662        let mut c = Array2::<f64>::zeros((3, 3));
1663        for j in 0..3 {
1664            for k in 0..3 {
1665                c[[j, k]] = 2.0 * at[j] * bt[k];
1666            }
1667        }
1668        // Tight scale-included posterior: σ² = 1e-4 per coefficient.
1669        let cov = Array2::<f64>::eye(9) * 1e-4;
1670        let input = CarveInput {
1671            phi_a: phi_a.view(),
1672            phi_b: phi_b.view(),
1673            coeffs: &[c],
1674            coeff_covariance: Some(std::slice::from_ref(&cov)),
1675            joint_coeff_covariance: None,
1676            kernel_a: None,
1677            kernel_b: None,
1678            edf: None,
1679            residual_df: 100.0,
1680            scale: SmoothTestScale::Known,
1681            notion: BindingNotion::Representational,
1682        };
1683        let report = carve(&input, 0.05).expect("carve");
1684        assert!(report.fission.is_none(), "bound surface must not fission");
1685        assert!(report.interaction_fraction > 0.1);
1686        let p = report.edge_p_value.expect("test ran");
1687        assert!(p < 1e-6, "strong planted binding must reject, p = {p}");
1688
1689        // The additive surface, same covariance: no rejection.
1690        let a = array![1.0, -0.5, 2.0];
1691        let b = array![0.3, 1.7, -1.0];
1692        let mut c_add = Array2::<f64>::zeros((3, 3));
1693        for j in 0..3 {
1694            for k in 0..3 {
1695                c_add[[j, k]] = a[j] + b[k];
1696            }
1697        }
1698        let input_add = CarveInput {
1699            phi_a: phi_a.view(),
1700            phi_b: phi_b.view(),
1701            coeffs: &[c_add],
1702            coeff_covariance: Some(std::slice::from_ref(&cov)),
1703            joint_coeff_covariance: None,
1704            kernel_a: None,
1705            kernel_b: None,
1706            edf: None,
1707            residual_df: 100.0,
1708            scale: SmoothTestScale::Known,
1709            notion: BindingNotion::Representational,
1710        };
1711        let report_add = carve(&input_add, 0.05).expect("carve");
1712        let p_add = report_add.edge_p_value.expect("test ran");
1713        assert!(
1714            p_add > 0.99,
1715            "additive surface carries zero projected interaction, p = {p_add}"
1716        );
1717        assert!(report_add.fission.is_some());
1718    }
1719
1720    /// The gauge directions (`u vᵀ + w uᵀ`) contribute NOTHING to the test
1721    /// statistic: adding them to a planted-additive coefficient matrix
1722    /// leaves the projected interaction (and hence the p-value) unchanged.
1723    #[test]
1724    fn gauge_directions_do_not_enter_the_binding_test() {
1725        let phi_a = pou_basis();
1726        let phi_b = pou_basis_b();
1727        let mut c = Array2::<f64>::zeros((3, 3));
1728        // Pure gauge: u vᵀ + w uᵀ with u = ones.
1729        let v = array![0.4, -1.2, 0.7];
1730        let w = array![-0.9, 0.1, 0.5];
1731        for j in 0..3 {
1732            for k in 0..3 {
1733                c[[j, k]] = v[k] + w[j];
1734            }
1735        }
1736        let cov = Array2::<f64>::eye(9) * 1e-4;
1737        let input = CarveInput {
1738            phi_a: phi_a.view(),
1739            phi_b: phi_b.view(),
1740            coeffs: &[c],
1741            coeff_covariance: Some(std::slice::from_ref(&cov)),
1742            joint_coeff_covariance: None,
1743            kernel_a: None,
1744            kernel_b: None,
1745            edf: None,
1746            residual_df: 100.0,
1747            scale: SmoothTestScale::Known,
1748            notion: BindingNotion::Representational,
1749        };
1750        let report = carve(&input, 0.05).expect("carve");
1751        // u vᵀ + w uᵀ IS additive (it is f₁ + f₂ on a PoU basis), so the
1752        // projected interaction is exactly zero.
1753        assert!(report.interaction_fraction < 1e-24);
1754        let p = report.edge_p_value.expect("test ran");
1755        assert!(p > 0.99, "pure-gauge coefficients must not reject, p = {p}");
1756    }
1757
1758    /// A deterministic Bernstein (degree-2, partition-of-unity) basis
1759    /// evaluated on `n` scattered points, with two decorrelated sample
1760    /// mappings so the tensor design is well-conditioned.
1761    fn bernstein_pair(n: usize) -> (Array2<f64>, Array2<f64>) {
1762        let mut phi_a = Array2::<f64>::zeros((n, 3));
1763        let mut phi_b = Array2::<f64>::zeros((n, 3));
1764        for t in 0..n {
1765            let x = t as f64 / (n - 1) as f64;
1766            let z = ((t * 17) % n) as f64 / (n - 1) as f64;
1767            phi_a[[t, 0]] = (1.0 - x) * (1.0 - x);
1768            phi_a[[t, 1]] = 2.0 * x * (1.0 - x);
1769            phi_a[[t, 2]] = x * x;
1770            phi_b[[t, 0]] = (1.0 - z) * (1.0 - z);
1771            phi_b[[t, 1]] = 2.0 * z * (1.0 - z);
1772            phi_b[[t, 2]] = z * z;
1773        }
1774        (phi_a, phi_b)
1775    }
1776
1777    fn surface_values(phi_a: &Array2<f64>, phi_b: &Array2<f64>, c: &Array2<f64>) -> Array1<f64> {
1778        let n = phi_a.nrows();
1779        let mut y = Array1::<f64>::zeros(n);
1780        for r in 0..n {
1781            y[r] = phi_a.row(r).dot(&c.dot(&phi_b.row(r).to_owned()));
1782        }
1783        y
1784    }
1785
1786    /// END-TO-END (#993 items 1+2+4): fit_tensor_surface recovers a
1787    /// planted BOUND two-dimensional surface from noisy samples, its
1788    /// covariance feeds the carve, and the JOINT cross-dim Wald (via
1789    /// `joint_covariance`) proves the binding while fission refuses.
1790    #[test]
1791    fn tensor_surface_fit_to_carve_proves_planted_binding_jointly() {
1792        let n = 40usize;
1793        let (phi_a, phi_b) = bernstein_pair(n);
1794        // Two distinct bound surfaces (additive part + centered rank-1
1795        // interaction) so the residual cross-covariance is well-conditioned.
1796        let at = array![1.0, -1.0, 0.0];
1797        let bt = array![0.0, 1.0, -1.0];
1798        let mut c0 = Array2::<f64>::zeros((3, 3));
1799        let mut c1 = Array2::<f64>::zeros((3, 3));
1800        let a = array![1.0, -0.5, 2.0];
1801        let b = array![0.3, 1.7, -1.0];
1802        for j in 0..3 {
1803            for k in 0..3 {
1804                c0[[j, k]] = a[j] + b[k] + 2.0 * at[j] * bt[k];
1805                c1[[j, k]] = 0.5 * a[j] - b[k] - 1.5 * at[j] * bt[k];
1806            }
1807        }
1808        let y0 = surface_values(&phi_a, &phi_b, &c0);
1809        let y1 = surface_values(&phi_a, &phi_b, &c1);
1810        let mut responses = Array2::<f64>::zeros((n, 2));
1811        for t in 0..n {
1812            responses[[t, 0]] = y0[t] + 1e-3 * (1.3 * t as f64).sin();
1813            responses[[t, 1]] = y1[t] + 1e-3 * (2.1 * t as f64).cos();
1814        }
1815
1816        let fit = fit_tensor_surface(phi_a.view(), phi_b.view(), responses.view()).expect("fit");
1817        // Coefficient recovery within noise scale (ridge bias included).
1818        for j in 0..3 {
1819            for k in 0..3 {
1820                assert!(
1821                    (fit.coeffs[0][[j, k]] - c0[[j, k]]).abs() < 0.05,
1822                    "C₀[{j},{k}]: fit {} vs planted {}",
1823                    fit.coeffs[0][[j, k]],
1824                    c0[[j, k]]
1825                );
1826            }
1827        }
1828        // Kronecker consistency: the joint covariance's diagonal block d
1829        // equals the per-dimension Vb exactly.
1830        let joint = fit.joint_covariance();
1831        let mm = 9usize;
1832        for i in 0..mm {
1833            for j in 0..mm {
1834                assert!((joint[[i, j]] - fit.coeff_covariance[0][[i, j]]).abs() < 1e-15);
1835                assert!((joint[[mm + i, mm + j]] - fit.coeff_covariance[1][[i, j]]).abs() < 1e-15);
1836            }
1837        }
1838
1839        let input = CarveInput {
1840            phi_a: phi_a.view(),
1841            phi_b: phi_b.view(),
1842            coeffs: &fit.coeffs,
1843            coeff_covariance: Some(&fit.coeff_covariance),
1844            joint_coeff_covariance: Some(&joint),
1845            kernel_a: None,
1846            kernel_b: None,
1847            edf: None,
1848            residual_df: fit.residual_df,
1849            scale: SmoothTestScale::Estimated,
1850            notion: BindingNotion::Representational,
1851        };
1852        let report = carve(&input, 0.05).expect("carve");
1853        let p = report.edge_p_value.expect("joint test ran");
1854        assert!(p < 1e-3, "planted joint binding must reject, p = {p}");
1855        assert!(report.fission.is_none(), "bound surface must not fission");
1856        assert!(report.interaction_fraction > 0.05);
1857    }
1858
1859    /// END-TO-END, additive side: a planted ADDITIVE surface fit from
1860    /// near-noiseless samples carries negligible interaction energy and
1861    /// fissions (energy-only path — no covariance handed to the carve, so
1862    /// the decision rests on the dial alone).
1863    #[test]
1864    fn tensor_surface_fit_additive_surface_fissions() {
1865        let n = 40usize;
1866        let (phi_a, phi_b) = bernstein_pair(n);
1867        let a = array![1.0, -0.5, 2.0];
1868        let b = array![0.3, 1.7, -1.0];
1869        let mut c_add = Array2::<f64>::zeros((3, 3));
1870        for j in 0..3 {
1871            for k in 0..3 {
1872                c_add[[j, k]] = a[j] + b[k];
1873            }
1874        }
1875        let y = surface_values(&phi_a, &phi_b, &c_add);
1876        let mut responses = Array2::<f64>::zeros((n, 1));
1877        for t in 0..n {
1878            responses[[t, 0]] = y[t] + 1e-5 * (0.9 * t as f64).sin();
1879        }
1880        let fit = fit_tensor_surface(phi_a.view(), phi_b.view(), responses.view()).expect("fit");
1881        let input = CarveInput {
1882            phi_a: phi_a.view(),
1883            phi_b: phi_b.view(),
1884            coeffs: &fit.coeffs,
1885            coeff_covariance: None,
1886            joint_coeff_covariance: None,
1887            kernel_a: None,
1888            kernel_b: None,
1889            edf: None,
1890            residual_df: fit.residual_df,
1891            scale: SmoothTestScale::Estimated,
1892            notion: BindingNotion::Representational,
1893        };
1894        let report = carve(&input, 0.05).expect("carve");
1895        assert!(
1896            report.interaction_fraction < FISSION_MAX_INTERACTION_FRACTION,
1897            "additive surface fit must carry negligible interaction \
1898             (fraction = {})",
1899            report.interaction_fraction
1900        );
1901        assert!(report.fission.is_some());
1902    }
1903
1904    /// The three-valued joint decision: both arms additive → joint
1905    /// certificate; representational only → reconstruction-only; a bound
1906    /// computational arm vetoes a clean representational split (the
1907    /// off-diagonal quadrant that motivates the pair).
1908    #[test]
1909    fn fission_decision_distinguishes_the_quadrants() {
1910        let splittable = CarveReport {
1911            notion: BindingNotion::Representational,
1912            binding_tests: vec![],
1913            edge_p_value: None,
1914            interaction_fraction: 0.0,
1915            fission: Some(FissionPlan {
1916                child_a: vec![],
1917                child_b: vec![],
1918                reconstruction_defect: 0.0,
1919            }),
1920        };
1921        let mut comp_splittable = splittable.clone();
1922        comp_splittable.notion = BindingNotion::Computational;
1923        let comp_bound = CarveReport {
1924            notion: BindingNotion::Computational,
1925            binding_tests: vec![],
1926            edge_p_value: Some(1e-9),
1927            interaction_fraction: 0.4,
1928            fission: None,
1929        };
1930
1931        assert_eq!(
1932            fission_decision(&splittable, Some(&comp_splittable)),
1933            FissionDecision::SplitCertifiedJoint
1934        );
1935        assert_eq!(
1936            fission_decision(&splittable, None),
1937            FissionDecision::SplitReconstructionOnly
1938        );
1939        assert_eq!(
1940            fission_decision(&splittable, Some(&comp_bound)),
1941            FissionDecision::Keep
1942        );
1943        let kept = CarveReport {
1944            fission: None,
1945            ..splittable.clone()
1946        };
1947        assert_eq!(fission_decision(&kept, None), FissionDecision::Keep);
1948    }
1949
1950    /// A constant-leading factor basis (column 0 ≡ 1, like the harmonic
1951    /// factors' constant term) on a small sample.
1952    fn constant_leading_factor(n: usize, m: usize, seed: u64) -> Array2<f64> {
1953        let mut phi = Array2::<f64>::zeros((n, m));
1954        let mut s = seed;
1955        for row in 0..n {
1956            phi[[row, 0]] = 1.0;
1957            for col in 1..m {
1958                // Deterministic LCG in [-1, 1).
1959                s = s
1960                    .wrapping_mul(6364136223846793005)
1961                    .wrapping_add(1442695040888963407);
1962                let u = ((s >> 11) as f64) / ((1u64 << 53) as f64);
1963                phi[[row, col]] = 2.0 * u - 1.0;
1964            }
1965        }
1966        phi
1967    }
1968
1969    /// #993 producer: `carve_input_from_fitted_atom` recovers the two factor
1970    /// bases EXACTLY from the fused Kronecker basis (constant-leading column
1971    /// convention), and the re-fit surface reconstructs the decoder's own
1972    /// tensor coefficients — so a real fitted product atom feeds the carve.
1973    #[test]
1974    fn producer_recovers_factor_bases_and_surface_from_fused_atom() {
1975        let n = 40;
1976        let (m_a, m_b) = (3, 4);
1977        let p = 2;
1978        let phi_a = constant_leading_factor(n, m_a, 0xA993);
1979        let phi_b = constant_leading_factor(n, m_b, 0xB993);
1980
1981        // Fused Kronecker basis, row-major column flat = j*m_b + k.
1982        let mut fused = Array2::<f64>::zeros((n, m_a * m_b));
1983        for row in 0..n {
1984            for j in 0..m_a {
1985                for k in 0..m_b {
1986                    fused[[row, j * m_b + k]] = phi_a[[row, j]] * phi_b[[row, k]];
1987                }
1988            }
1989        }
1990        // An arbitrary decoder B_k (M₁M₂ × p).
1991        let mut decoder = Array2::<f64>::zeros((m_a * m_b, p));
1992        let mut s = 0xD00D_u64;
1993        for r in 0..(m_a * m_b) {
1994            for c in 0..p {
1995                s = s
1996                    .wrapping_mul(6364136223846793005)
1997                    .wrapping_add(1442695040888963407);
1998                let u = ((s >> 11) as f64) / ((1u64 << 53) as f64);
1999                decoder[[r, c]] = 2.0 * u - 1.0;
2000            }
2001        }
2002
2003        let bundle =
2004            carve_input_from_fitted_atom(fused.view(), decoder.view(), m_a, m_b).expect("producer");
2005
2006        // Factor bases recovered to machine precision.
2007        let mut max_a = 0.0_f64;
2008        for row in 0..n {
2009            for j in 0..m_a {
2010                max_a = max_a.max((bundle.phi_a[[row, j]] - phi_a[[row, j]]).abs());
2011            }
2012        }
2013        let mut max_b = 0.0_f64;
2014        for row in 0..n {
2015            for k in 0..m_b {
2016                max_b = max_b.max((bundle.phi_b[[row, k]] - phi_b[[row, k]]).abs());
2017            }
2018        }
2019        assert!(max_a < 1e-12, "phi_a recovery error {max_a:e}");
2020        assert!(max_b < 1e-12, "phi_b recovery error {max_b:e}");
2021
2022        // The carve input is well-formed: p coefficient matrices, each M₁×M₂,
2023        // with matching covariance blocks and the joint Kronecker covariance.
2024        let input = bundle.representational_carve_input();
2025        assert_eq!(input.coeffs.len(), p);
2026        for c in input.coeffs {
2027            assert_eq!(c.dim(), (m_a, m_b));
2028        }
2029        assert_eq!(
2030            bundle.joint_covariance.dim(),
2031            (p * m_a * m_b, p * m_a * m_b)
2032        );
2033
2034        // The carve runs end-to-end on the producer's output.
2035        let report = carve(&input, 0.05).expect("carve on producer output");
2036        assert_eq!(report.notion, BindingNotion::Representational);
2037        assert!(
2038            report.edge_p_value.is_some(),
2039            "binding p-value must be produced"
2040        );
2041    }
2042
2043    /// A non-separable fused basis (not a Kronecker product of two factors) is
2044    /// rejected loudly, not silently mis-carved.
2045    #[test]
2046    fn producer_rejects_non_separable_basis() {
2047        let n = 12;
2048        let (m_a, m_b) = (2, 2);
2049        let mut fused = Array2::<f64>::from_elem((n, m_a * m_b), 1.0);
2050        // Break separability in one entry only.
2051        fused[[3, 3]] = 7.0;
2052        let decoder = Array2::<f64>::ones((m_a * m_b, 1));
2053        let err = carve_input_from_fitted_atom(fused.view(), decoder.view(), m_a, m_b)
2054            .expect_err("non-separable basis must be rejected");
2055        assert!(
2056            err.contains("Kronecker product"),
2057            "rejection must name the separability failure; got: {err}"
2058        );
2059    }
2060}