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