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