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}