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