Skip to main content

gam_solve/inference/
residual_factor.rs

1//! #974 — the structured-residual covariance estimator and the single producer
2//! of [`MetricProvenance::WhitenedStructured`](gam_problem::MetricProvenance::WhitenedStructured).
3//!
4//! # What this estimates
5//!
6//! Given a residual matrix `R ∈ ℝ^{n×p}` (one `p`-dimensional reconstruction
7//! residual per row) and a smooth *activity coordinate* `z ∈ ℝ^n`, this fits the
8//! **structured residual-covariance model**
9//!
10//! ```text
11//!     Cov(r_n) = Σ_n = Λ · c(z_n) · Λᵀ + D ,
12//! ```
13//!
14//! where
15//!
16//! * `Λ ∈ ℝ^{p×r}` is a **low-rank interference factor** (the shared
17//!   off-isotropic subspace the residuals correlate along — e.g. a planted
18//!   interference subspace or a topology-race confound),
19//! * `D = diag(d) ≻ 0` is the **idiosyncratic diagonal** (per-channel
20//!   independent noise), and
21//! * `c(z) > 0` is the **smooth activity-scale law**: a strictly-positive scalar
22//!   that modulates the factor energy with the activity coordinate, recovered as
23//!   a binned-then-smoothed function of `z`.
24//!
25//! The fit is a deterministic, fixed-iteration **alternation** (no clock, no
26//! RNG; any tie is broken by index): it alternates
27//!
28//! 1. *(scale | Λ, D)* — re-estimate the per-row factor activity `c(z_n)` and
29//!    smooth it across `z`, holding the factor model fixed; and
30//! 2. *(Λ, D | scale)* — re-estimate the factor and diagonal from the
31//!    scale-deflated second-moment, holding the activity law fixed,
32//!
33//! a fixed small number of times. The **factor count `r`** is chosen by an
34//! evidence ladder: each candidate `r` is scored by its penalized Gaussian
35//! log-evidence and the best is kept.
36//!
37//! # What it produces
38//!
39//! `StructuredResidualModel::row_metric` materializes the **per-row precision
40//! factor** `U_n ∈ ℝ^{p×p}` with `U_n U_nᵀ = Σ_n^{-1}`, packaged as a
41//! [`RowMetric`](gam_problem::RowMetric) with
42//! [`MetricProvenance::WhitenedStructured`](gam_problem::MetricProvenance::WhitenedStructured).
43//! Whitening a residual `r_n` through it (`U_nᵀ r_n`) yields a vector whose
44//! squared Euclidean norm is `r_nᵀ Σ_n^{-1} r_n` — the Mahalanobis residual under
45//! the estimated noise model, which is exactly the likelihood-correct data-fit.
46//! The factor is built from `Σ_n^{-1}` computed in **Woodbury form** (an
47//! `r × r` solve, never a `p × p` inverse), so the estimator scales with the
48//! factor rank, not the dense output dimension.
49//!
50//! This is the first real producer of `WhitenedStructured`, and therefore the
51//! first metric whose `whitens_likelihood()` is `true`: see
52//! [`RowMetric::whitens_likelihood`](gam_problem::RowMetric::whitens_likelihood).
53
54use std::sync::Arc;
55
56use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
57
58use faer::Side;
59use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh};
60use gam_problem::RowMetric;
61
62/// Number of (scale | factor) ↔ (factor | scale) alternation sweeps. Fixed and
63/// deterministic: the alternation is a smooth descent on the structured-Gaussian
64/// objective and converges geometrically, so a small fixed budget is both
65/// sufficient and reproducible (no clock/RNG-driven stopping).
66const ALTERNATION_SWEEPS: usize = 8;
67
68/// Number of bins the activity coordinate `z` is partitioned into for the smooth
69/// activity-scale `c(z)`. The per-bin factor activity is estimated then linearly
70/// interpolated across bin centers, giving a continuous piecewise-linear scale
71/// law. Chosen as a fixed structural constant (magic-by-default): enough bins to
72/// resolve a smooth monotone or unimodal scale trend without over-fitting the
73/// per-row noise.
74const ACTIVITY_SCALE_BINS: usize = 8;
75
76/// Relative floor on the idiosyncratic diagonal `D`, as a fraction of the mean
77/// residual variance. Keeps `Σ_n ≻ 0` and the Woodbury `r × r` capacitance
78/// invertible even when a channel is (near-)perfectly explained by the factor.
79const DIAGONAL_REL_FLOOR: f64 = 1e-6;
80
81/// Relative floor on the activity scale `c(z)`, as a fraction of its mean. Keeps
82/// `c(z) > 0` (a covariance scale) across the whole `z` range.
83const SCALE_REL_FLOOR: f64 = 1e-4;
84
85/// The fitted structured residual-covariance model: low-rank factor `Λ`,
86/// idiosyncratic diagonal `D`, and the smooth activity-scale `c(z)` evaluated at
87/// every row. Produces per-row precision factors and the
88/// [`MetricProvenance::WhitenedStructured`](gam_problem::MetricProvenance::WhitenedStructured)
89/// `RowMetric`.
90#[derive(Clone, Debug)]
91pub struct StructuredResidualModel {
92    /// Output dimensionality `p` (residual width).
93    p: usize,
94    /// Selected factor rank `r` (`0 ≤ r ≤ p`). `0` ⇒ pure-diagonal noise model.
95    factor_rank: usize,
96    /// Interference factor `Λ ∈ ℝ^{p×r}` (the shared off-diagonal subspace).
97    lambda: Array2<f64>,
98    /// Idiosyncratic diagonal `d ∈ ℝ^p` (`D = diag(d)`), floored `≻ 0`.
99    diagonal: Array1<f64>,
100    /// Per-row activity scale `c(z_n) > 0`, length `n`.
101    row_scale: Array1<f64>,
102    /// Penalized Gaussian log-evidence of the selected model (higher is better).
103    /// The value the evidence ladder maximized over the candidate ranks.
104    log_evidence: f64,
105}
106
107/// Estimator inputs: the residual matrix and the smooth activity coordinate.
108///
109/// `residuals` is `R ∈ ℝ^{n×p}`. `activity` is `z ∈ ℝ^n` — the coordinate the
110/// scale law `c(z)` is smooth in (e.g. an assignment-mass or activation-strength
111/// summary per row). When no genuine activity coordinate is available, passing a
112/// constant `z` recovers a homoscedastic factor model (`c(z) ≡ const`).
113pub struct ResidualFactorInput<'a> {
114    /// Residual matrix `R ∈ ℝ^{n×p}`.
115    pub residuals: ArrayView2<'a, f64>,
116    /// Activity coordinate `z ∈ ℝ^n` the scale law is smooth in.
117    pub activity: ArrayView1<'a, f64>,
118    /// Maximum factor rank the evidence ladder is allowed to consider. The
119    /// ladder scores `r = 0, 1, …, min(max_factor_rank, p−1)` and keeps the
120    /// penalized-evidence maximizer. `0` forces the pure-diagonal model.
121    pub max_factor_rank: usize,
122}
123
124/// A persistent, evidence-earning residual factor direction — a promotion
125/// candidate for the #2021 Λ nursery→promotion birth channel. Emitted by
126/// [`StructuredResidualModel::promotion_candidates`] when a column of this
127/// pass's `Λ` both (a) aligns with a column of the previous pass's `Λ` (it
128/// *persisted* across the outer alternation) and (b) explains residual energy
129/// above the idiosyncratic-noise floor (it *earns its complexity*). The driver
130/// accumulates persistence across passes (the nursery) and, once a direction
131/// survives long enough, promotes it to a new curved/linear atom seeded by
132/// [`Self::direction`].
133#[derive(Clone, Debug)]
134pub struct FactorPromotion {
135    /// Unit-norm factor direction in output space (`p`-vector): the L2-normalized
136    /// column of `Λ`. This is the decoder direction a promoted atom is born with.
137    pub direction: Array1<f64>,
138    /// Explained residual energy `‖Λ_:,j‖²` (pre-normalization squared column
139    /// norm) — the factor's contribution to `Σ = c·ΛΛᵀ + D`. Candidates are
140    /// returned in descending energy so the driver promotes the strongest first.
141    pub energy: f64,
142    /// `|cos|` alignment (∈ `[0, 1]`) between this direction and the best-matching
143    /// column of the previous pass's `Λ` — the persistence score gating promotion.
144    pub persistence_alignment: f64,
145    /// Index of the best-matching previous-pass `Λ` column (the nursery lineage
146    /// this candidate continues), so the driver can track a stable identity for a
147    /// direction across passes.
148    pub prev_column: usize,
149}
150
151impl StructuredResidualModel {
152    /// Fit the structured residual-covariance model by the deterministic
153    /// fixed-iteration alternation, selecting the factor rank by the evidence
154    /// ladder. Returns an error only on shape / non-finite-input violations; the
155    /// numerical path is total (every floor and solve is guarded).
156    pub fn fit(input: ResidualFactorInput<'_>) -> Result<Self, String> {
157        let r = input.residuals;
158        let z = input.activity;
159        let n = r.nrows();
160        let p = r.ncols();
161        if n == 0 || p == 0 {
162            return Err(format!(
163                "StructuredResidualModel::fit: residuals must be non-empty; got ({n}, {p})"
164            ));
165        }
166        if z.len() != n {
167            return Err(format!(
168                "StructuredResidualModel::fit: activity length {} != residual rows {n}",
169                z.len()
170            ));
171        }
172        if !r.iter().all(|v| v.is_finite()) {
173            return Err("StructuredResidualModel::fit: residuals must be finite".to_string());
174        }
175        if !z.iter().all(|v| v.is_finite()) {
176            return Err("StructuredResidualModel::fit: activity must be finite".to_string());
177        }
178
179        // Bin assignment for the activity-scale law: deterministic equal-width
180        // bins over the observed z-range. A degenerate (zero-width) range maps
181        // every row to bin 0, recovering a single homoscedastic scale.
182        let bins = ACTIVITY_SCALE_BINS.max(1);
183        let z_min = z.iter().copied().fold(f64::INFINITY, f64::min);
184        let z_max = z.iter().copied().fold(f64::NEG_INFINITY, f64::max);
185        let z_span = z_max - z_min;
186        let row_bin: Vec<usize> = (0..n)
187            .map(|i| {
188                if z_span <= 0.0 {
189                    0
190                } else {
191                    let frac = (z[i] - z_min) / z_span;
192                    let idx = (frac * bins as f64).floor() as isize;
193                    idx.clamp(0, bins as isize - 1) as usize
194                }
195            })
196            .collect();
197
198        let max_rank = input.max_factor_rank.min(p.saturating_sub(1));
199
200        // Evidence ladder over candidate factor ranks. Each candidate is fit by
201        // the full alternation and scored by its penalized Gaussian log-evidence;
202        // the maximizer is kept. Index order breaks any tie (lowest rank wins on
203        // an exact tie — Occam).
204        let mut best: Option<StructuredResidualModel> = None;
205        for rank in 0..=max_rank {
206            let model = Self::fit_fixed_rank(r, &row_bin, bins, rank)?;
207            let take = match &best {
208                None => true,
209                Some(b) => model.log_evidence > b.log_evidence,
210            };
211            if take {
212                best = Some(model);
213            }
214        }
215        best.ok_or_else(|| "StructuredResidualModel::fit: evidence ladder empty".to_string())
216    }
217
218    /// Fit the model at a fixed factor rank by the deterministic alternation.
219    fn fit_fixed_rank(
220        r: ArrayView2<'_, f64>,
221        row_bin: &[usize],
222        bins: usize,
223        rank: usize,
224    ) -> Result<Self, String> {
225        let n = r.nrows();
226        let p = r.ncols();
227
228        // Mean residual variance — the scale reference for the diagonal floor.
229        let mut total_var = 0.0_f64;
230        for i in 0..n {
231            for j in 0..p {
232                total_var += r[[i, j]] * r[[i, j]];
233            }
234        }
235        let mean_var = (total_var / (n as f64 * p as f64)).max(f64::MIN_POSITIVE);
236        let diag_floor = DIAGONAL_REL_FLOOR * mean_var;
237
238        // Initialize the per-row scale to 1 (homoscedastic start), the diagonal
239        // to the per-channel sample variance, and Λ to the leading eigenvectors
240        // of the (scale-1) second moment. The alternation refines all three.
241        let mut row_scale = Array1::<f64>::ones(n);
242        let mut bin_scale = Array1::<f64>::ones(bins);
243        // Raw (undeflated) per-channel second moment — the D estimator's data
244        // term. Constant across sweeps.
245        let raw_diag = column_variances(r);
246        let mut diagonal = raw_diag.mapv(|v| v.max(diag_floor));
247        let mut lambda = Array2::<f64>::zeros((p, rank));
248
249        for _sweep in 0..ALTERNATION_SWEEPS {
250            // (Λ, D | scale): scale-deflated second moment
251            //   S = (1/n) Σ_n (r_n r_nᵀ) / c(z_n).
252            // Under the model E[r_n r_nᵀ] = c_n ΛΛᵀ + D, so S ≈ ΛΛᵀ + D̄ with
253            // D̄ the scale-averaged diagonal; the leading eigenpairs of S − D
254            // give Λ, the residual diagonal gives D.
255            let s = scaled_second_moment(r, &row_scale);
256            let (evals, evecs) = symmetric_eig_ascending(&s)?;
257            // Leading `rank` eigenpairs (eigenvalues ascending ⇒ take the tail).
258            if rank > 0 {
259                for k in 0..rank {
260                    let col = p - 1 - k;
261                    // Factor energy above the idiosyncratic floor: the part of
262                    // the eigenvalue not explained by the mean diagonal.
263                    let mean_diag = diagonal.iter().copied().sum::<f64>() / p as f64;
264                    let energy = (evals[col] - mean_diag).max(0.0);
265                    let amp = energy.sqrt();
266                    for row in 0..p {
267                        lambda[[row, k]] = amp * evecs[[row, col]];
268                    }
269                }
270            }
271            // D update from the RAW (undeflated) moment, floored ≻ 0. The model
272            // is Σ_n = c_n·ΛΛᵀ + D with D NOT scale-multiplied, and c is mean-1
273            // normalized, so E[(1/n)Σ r_n r_nᵀ] = ΛΛᵀ + D exactly. The deflated
274            // moment `s` is the right object for the FACTOR block (its factor
275            // part is scale-free) but its diagonal carries D·mean(1/c) — a
276            // Jensen-inflated D (mean(1/c) > 1 for any non-constant law), which
277            // biased D upward by exactly mean(1/c̃) and let a spurious
278            // higher-rank candidate win the evidence ladder on a better D
279            // alone (the probe's rank-2 winner had a zero second column).
280            for j in 0..p {
281                let mut factor_var = 0.0_f64;
282                for k in 0..rank {
283                    factor_var += lambda[[j, k]] * lambda[[j, k]];
284                }
285                diagonal[j] = (raw_diag[j] - factor_var).max(diag_floor);
286            }
287
288            // (scale | Λ, D): per-row factor activity. With residual r_n, the
289            // factor-subspace energy is r_nᵀ P r_n where P projects onto
290            // range(Λ) in the D-whitened metric; the maximum-likelihood scalar
291            // multiplier on ΛΛᵀ that matches the row's factor-subspace energy is
292            //   c_n = (r̃_nᵀ B (BᵀB)^{-1} Bᵀ r̃_n) / tr(...)-normalizer.
293            // We use a stable closed-form proxy: the row's factor-coordinate
294            // energy ‖Λ⁺ r_n‖² normalized by the unit-scale expectation, then
295            // bin-smoothed across z. With rank 0 there is no factor ⇒ c ≡ 1.
296            if rank > 0 {
297                let mut bin_num = Array1::<f64>::zeros(bins);
298                let mut bin_den = Array1::<f64>::zeros(bins);
299                let coords = factor_coordinates(&lambda, &diagonal, r)?;
300                for i in 0..n {
301                    let mut energy = 0.0_f64;
302                    for k in 0..rank {
303                        energy += coords[[i, k]] * coords[[i, k]];
304                    }
305                    let b = row_bin[i];
306                    bin_num[b] += energy;
307                    bin_den[b] += rank as f64;
308                }
309                // Per-bin mean factor energy = activity scale. Empty bins inherit
310                // the global mean so the scale law stays defined everywhere.
311                let global = {
312                    let num: f64 = bin_num.iter().sum();
313                    let den: f64 = bin_den.iter().sum();
314                    if den > 0.0 { num / den } else { 1.0 }
315                };
316                for b in 0..bins {
317                    bin_scale[b] = if bin_den[b] > 0.0 {
318                        bin_num[b] / bin_den[b]
319                    } else {
320                        global
321                    };
322                }
323                // Smooth (3-point moving average over bins) for a continuous law,
324                // then floor ≻ 0.
325                let scale_floor = SCALE_REL_FLOOR * global.max(f64::MIN_POSITIVE);
326                let smoothed = moving_average_3(&bin_scale);
327                for b in 0..bins {
328                    bin_scale[b] = smoothed[b].max(scale_floor);
329                }
330                // Re-normalize so the mean scale is 1 (the factor amplitude lives
331                // in Λ; c(z) carries only the relative activity law). This keeps
332                // the (Λ, D) ↔ (scale) split identified.
333                //
334                // The mean MUST be taken over ROWS, not over bins. The identity
335                // that makes `raw_diag` an unbiased ΛΛᵀ + D estimator is
336                //   E[(1/n) Σ_n r_n r_nᵀ] = (1/n) Σ_i c(z_i) · ΛΛᵀ + D,
337                // which reduces to ΛΛᵀ + D iff the ROW mean of c is 1:
338                //   (1/n) Σ_i c(z_i) = Σ_b (n_b / n) · bin_scale[b] = 1,
339                // where n_b is the occupancy (row count) of bin b. Under uneven
340                // occupancy (the common case — z is data-driven) the bin-UNIFORM
341                // mean (1/bins) Σ_b bin_scale[b] ≠ this occupancy-weighted mean, so
342                // normalizing by it would leave raw_diag = ΛΛᵀ + D biased by
343                // exactly (occupancy mean / bin mean). Divide by the occupancy-
344                // weighted mean instead, so (1/n) Σ_i row_scale[i] is exactly 1.
345                // ORDERING: the positivity floor was applied above FIRST, so the
346                // floored per-bin values are the ones this normalization sees; the
347                // per-row assignment below therefore needs no second clamp (a
348                // re-clamp would use pre-normalization floor units and break the
349                // exact row-mean-1 invariant just established).
350                let mut bin_count = vec![0.0_f64; bins];
351                for &b in row_bin.iter() {
352                    bin_count[b] += 1.0;
353                }
354                let mean_scale =
355                    (0..bins).map(|b| bin_count[b] * bin_scale[b]).sum::<f64>() / n as f64;
356                if mean_scale > 0.0 {
357                    bin_scale.mapv_inplace(|v| v / mean_scale);
358                }
359                // Each bin_scale[b] is already ≥ scale_floor / mean_scale > 0.
360                for i in 0..n {
361                    row_scale[i] = bin_scale[row_bin[i]];
362                }
363            }
364        }
365
366        let log_evidence = penalized_log_evidence(r, &lambda, &diagonal, &row_scale, rank);
367        let mut model = Self {
368            p,
369            factor_rank: rank,
370            lambda,
371            diagonal,
372            row_scale,
373            log_evidence,
374        };
375        // Guard against any non-finite leak from a degenerate fit: fall back to a
376        // pure-diagonal model with the same evidence accounting.
377        if !model.is_finite() {
378            model.lambda = Array2::<f64>::zeros((p, rank));
379            model.row_scale = Array1::<f64>::ones(n);
380        }
381        Ok(model)
382    }
383
384    fn is_finite(&self) -> bool {
385        self.lambda.iter().all(|v| v.is_finite())
386            && self.diagonal.iter().all(|v| v.is_finite() && *v > 0.0)
387            && self.row_scale.iter().all(|v| v.is_finite() && *v > 0.0)
388            && self.log_evidence.is_finite()
389    }
390
391    /// Selected factor rank `r`.
392    pub fn factor_rank(&self) -> usize {
393        self.factor_rank
394    }
395
396    /// The fitted interference factor `Λ ∈ ℝ^{p×r}` (the shared off-isotropic
397    /// residual subspace). Consumed by the planted-subspace recovery test to
398    /// compare `range(Λ)` against the planted interference subspace.
399    pub fn factor(&self) -> ArrayView2<'_, f64> {
400        self.lambda.view()
401    }
402
403    /// The idiosyncratic diagonal `d ∈ ℝ^p` (`D = diag(d)`).
404    pub fn diagonal(&self) -> ArrayView1<'_, f64> {
405        self.diagonal.view()
406    }
407
408    /// The per-row activity scale `c(z_n) > 0`, length `n`. Recovers the smooth
409    /// activity-scale law evaluated at every observed `z_n`.
410    pub fn row_scale(&self) -> ArrayView1<'_, f64> {
411        self.row_scale.view()
412    }
413
414    /// The penalized Gaussian log-evidence the rank-selection ladder maximized.
415    pub fn log_evidence(&self) -> f64 {
416        self.log_evidence
417    }
418
419    /// #2021 Λ nursery→promotion: detect *persistent, evidence-earning* factor
420    /// directions relative to the previous outer-alternation pass's model.
421    ///
422    /// A column `j` of this model's `Λ` is a [`FactorPromotion`] candidate iff
423    /// both gates hold:
424    /// 1. **Earns its complexity** (evidence gate): its explained energy
425    ///    `‖Λ_:,j‖² ≥ energy_floor_mult · mean(diag(D))`. Every column is already
426    ///    inside the evidence-ladder-selected rank (so it cleared the BIC
427    ///    penalty globally); this per-direction floor additionally requires the
428    ///    factor to explain more than an average channel's idiosyncratic noise,
429    ///    so we never promote a direction that only barely survived rank
430    ///    selection.
431    /// 2. **Persists** (nursery gate): its `|cos|` alignment with the best-
432    ///    matching column of `prev`'s `Λ` is `≥ align_min` — the direction is the
433    ///    same subspace the previous pass already found, not a new
434    ///    pass-to-pass artifact.
435    ///
436    /// Returns candidates sorted by energy (descending). `prev = None` (the first
437    /// structured pass, damping toward `I`) yields no candidates — a direction
438    /// must survive at least one pass to enter the nursery. The driver holds the
439    /// cross-pass persistence count (promote after it clears the direction's
440    /// nursery dwell) and does the actual atom birth; this method is the pure,
441    /// per-pass detector.
442    ///
443    /// Errors on non-finite / out-of-range gates (`align_min ∈ [0,1]`,
444    /// `energy_floor_mult ≥ 0`) or a `prev` with a different output dim `p`.
445    pub fn promotion_candidates(
446        &self,
447        prev: Option<&StructuredResidualModel>,
448        align_min: f64,
449        energy_floor_mult: f64,
450    ) -> Result<Vec<FactorPromotion>, String> {
451        if !align_min.is_finite() || !(0.0..=1.0).contains(&align_min) {
452            return Err(format!(
453                "StructuredResidualModel::promotion_candidates: align_min must be finite in [0,1]; got {align_min}"
454            ));
455        }
456        if !energy_floor_mult.is_finite() || energy_floor_mult < 0.0 {
457            return Err(format!(
458                "StructuredResidualModel::promotion_candidates: energy_floor_mult must be finite and ≥ 0; got {energy_floor_mult}"
459            ));
460        }
461        let prev = match prev {
462            Some(pv) => pv,
463            None => return Ok(Vec::new()),
464        };
465        if prev.p != self.p {
466            return Err(format!(
467                "StructuredResidualModel::promotion_candidates: prev output dim {} != {}",
468                prev.p, self.p
469            ));
470        }
471        let r = self.factor_rank;
472        let prev_r = prev.factor_rank;
473        if r == 0 || prev_r == 0 {
474            return Ok(Vec::new());
475        }
476        // Idiosyncratic-noise floor: a promoted direction must explain more than
477        // an average channel's independent variance.
478        let mean_d = self.diagonal.iter().copied().sum::<f64>() / self.p as f64;
479        let energy_floor = energy_floor_mult * mean_d;
480
481        let mut out: Vec<FactorPromotion> = Vec::new();
482        for j in 0..r {
483            let col = self.lambda.column(j);
484            let energy: f64 = col.iter().map(|v| v * v).sum();
485            if energy <= 0.0 || energy < energy_floor {
486                continue;
487            }
488            let norm = energy.sqrt();
489            // Best |cos| against the previous pass's columns.
490            let mut best_align = 0.0_f64;
491            let mut best_k = 0usize;
492            for k in 0..prev_r {
493                let pcol = prev.lambda.column(k);
494                let pnorm: f64 = pcol.iter().map(|v| v * v).sum::<f64>().sqrt();
495                if pnorm <= 0.0 {
496                    continue;
497                }
498                let dot: f64 = col.iter().zip(pcol.iter()).map(|(a, b)| a * b).sum();
499                let cos_abs = (dot / (norm * pnorm)).abs();
500                if cos_abs > best_align {
501                    best_align = cos_abs;
502                    best_k = k;
503                }
504            }
505            if best_align >= align_min {
506                out.push(FactorPromotion {
507                    direction: col.mapv(|v| v / norm),
508                    energy,
509                    persistence_alignment: best_align,
510                    prev_column: best_k,
511                });
512            }
513        }
514        out.sort_by(|a, b| b.energy.total_cmp(&a.energy));
515        Ok(out)
516    }
517
518    /// Build the per-row precision factor stack `U_n ∈ ℝ^{p×p}` with
519    /// `U_n U_nᵀ = Σ_n^{-1}` and package it as a
520    /// [`MetricProvenance::WhitenedStructured`](gam_problem::MetricProvenance::WhitenedStructured)
521    /// `RowMetric`. This is the single
522    /// production site of `WhitenedStructured`.
523    ///
524    /// The precision is formed in **Woodbury form**:
525    /// ```text
526    ///   Σ_n^{-1} = D^{-1} − D^{-1} Λ ( c^{-1} I_r + Λᵀ D^{-1} Λ )^{-1} Λᵀ D^{-1},
527    /// ```
528    /// an `r × r` capacitance solve (never a `p × p` inverse). The factor `U_n`
529    /// is the lower-Cholesky of the assembled `Σ_n^{-1}` (`rank = p`), so
530    /// `whiten_residual_row` returns coordinates whose squared norm is the exact
531    /// Mahalanobis residual `r_nᵀ Σ_n^{-1} r_n`.
532    pub fn row_metric(&self, n_rows: usize) -> Result<RowMetric, String> {
533        if n_rows != self.row_scale.len() {
534            return Err(format!(
535                "StructuredResidualModel::row_metric: requested {n_rows} rows but model has {}",
536                self.row_scale.len()
537            ));
538        }
539        let p = self.p;
540        let r = self.factor_rank;
541        // Hoist every row-INDEPENDENT Woodbury part out of the per-row loop: the
542        // inverse diagonal D^{-1}, B = D^{-1}Λ, its transpose Bᵀ, and the Gram
543        // M0 = ΛᵀD^{-1}Λ. Only the c_n^{-1} I_r shift on the capacitance is
544        // per-row, so the per-row capacitance is M_n = M0 + c_n^{-1} I_r — a
545        // scalar-diagonal reweight of the SAME M0 (mirroring the Fix-B hoist in
546        // `penalized_log_evidence`). Building the n-row U_n stack now costs
547        // O(p·r² + n·(p·r + r³ + p³)) instead of rebuilding B and the Gram every
548        // row. The summation order per row is unchanged, so the assembled U_n is
549        // bit-for-bit identical to the per-row-rebuild it replaces.
550        let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / self.diagonal[i]).collect();
551        let mut b = Array2::<f64>::zeros((p, r));
552        let mut bt = Array2::<f64>::zeros((r, p));
553        let mut m0 = Array2::<f64>::zeros((r, r));
554        if r > 0 {
555            for i in 0..p {
556                for k in 0..r {
557                    b[[i, k]] = d_inv[i] * self.lambda[[i, k]];
558                }
559            }
560            for a in 0..r {
561                for bk in 0..r {
562                    let mut acc = 0.0_f64;
563                    for i in 0..p {
564                        acc += self.lambda[[i, a]] * b[[i, bk]];
565                    }
566                    m0[[a, bk]] = acc;
567                }
568            }
569            for k in 0..r {
570                for i in 0..p {
571                    bt[[k, i]] = b[[i, k]];
572                }
573            }
574        }
575        // Row-major flat factor matrix: u[n, i*p + k] = U_n[i, k]. Each row's
576        // Woodbury assemble + p×p Cholesky is independent of every other row's
577        // (no cross-row reduction), so the stack parallelizes over rows with
578        // BIT-IDENTICAL output — every row runs the exact serial arithmetic and
579        // writes only its own p² chunk. This was the dominant serial wall of the
580        // #974 metric install (n_rows × O(p³) on one core while the inner fit
581        // parallelizes cleanly). Same engagement discipline as
582        // `scaled_second_moment`: only above a row threshold (serial avoids
583        // rayon overhead on small stacks) and only when not already inside a
584        // rayon worker (nested calls keep the outer region's cores). Error
585        // selection stays deterministic: the indexed collect preserves row
586        // order, and the first `Some` scanned in that order is the same
587        // lowest-row error the serial loop returned.
588        let mut u = Array2::<f64>::zeros((n_rows, p * p));
589        let first_error = {
590            use rayon::prelude::*;
591            const PARALLEL_ROW_MIN: usize = 64;
592            let build_row = |row: usize, urow: &mut [f64]| -> Option<String> {
593                let precision = match self.row_precision(&d_inv, &b, &bt, &m0, row) {
594                    Ok(m) => m,
595                    Err(err) => return Some(err),
596                };
597                let factor = match lower_cholesky_psd(&precision) {
598                    Ok(f) => f,
599                    Err(err) => return Some(err),
600                };
601                for i in 0..p {
602                    for k in 0..p {
603                        urow[i * p + k] = factor[[i, k]];
604                    }
605                }
606                None
607            };
608            let u_flat = u.as_slice_mut().ok_or_else(|| {
609                "StructuredResidualModel::row_metric: factor stack must be standard-layout"
610                    .to_string()
611            })?;
612            if p > 0 && n_rows >= PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
613                u_flat
614                    .par_chunks_mut(p * p)
615                    .enumerate()
616                    .map(|(row, urow)| build_row(row, urow))
617                    .collect::<Vec<Option<String>>>()
618                    .into_iter()
619                    .flatten()
620                    .next()
621            } else if p > 0 {
622                u_flat
623                    .chunks_mut(p * p)
624                    .enumerate()
625                    .find_map(|(row, urow)| build_row(row, urow))
626            } else {
627                None
628            }
629        };
630        if let Some(err) = first_error {
631            return Err(err);
632        }
633        RowMetric::whitened_structured(Arc::new(u), p, p)
634    }
635
636    /// Convenience for the #2021 fit-path install seam: fit the structured
637    /// residual model on `input` and immediately materialize its per-row
638    /// `WhitenedStructured` [`RowMetric`] over all `input.residuals.nrows()`
639    /// rows. Equivalent to `Self::fit(input)?.row_metric(n)` — the single call
640    /// the outer alternation loop consumes when it installs the whitening metric
641    /// but does not also need the fitted factor (`factor()` / birth mining).
642    pub fn fit_row_metric(input: ResidualFactorInput<'_>) -> Result<RowMetric, String> {
643        let n = input.residuals.nrows();
644        Self::fit(input)?.row_metric(n)
645    }
646
647    /// The model's isotropic (iid-MLE) dispersion: the per-coordinate average of
648    /// its own fitted total residual variance,
649    /// ```text
650    ///   φ̂ = (1/p) · mean_row tr(Σ̂(row)) = mean(c)·‖Λ‖²_F / p + mean(d).
651    /// ```
652    /// This is the honest single-scalar summary of the SAME second moment the
653    /// structured model fits — the scale an iid Gaussian residual model would
654    /// estimate from these residuals. Used as the first-pass damping anchor in
655    /// [`Self::row_metric_damped`] (#2243 cap #2): anchoring at `φ̂·I` instead of
656    /// the unit `I_p` means near-noiseless data is whitened by its MEASURED
657    /// noise, so the downstream unit-dispersion REML criterion prices the
658    /// smoothing penalty against the real dispersion rather than an assumed
659    /// unit one. Floored at `f64::MIN_POSITIVE` so the blend stays SPD.
660    pub fn isotropic_dispersion(&self) -> f64 {
661        let p = self.p.max(1) as f64;
662        let n = self.row_scale.len().max(1) as f64;
663        let mean_c = self.row_scale.iter().copied().sum::<f64>() / n;
664        let lambda_energy: f64 = self.lambda.iter().map(|v| v * v).sum();
665        let mean_d = self.diagonal.iter().copied().sum::<f64>() / p;
666        (mean_c * lambda_energy / p + mean_d).max(f64::MIN_POSITIVE)
667    }
668
669    /// Damped per-row metric for the #2021 driver: blend covariances in the
670    /// **covariance domain** (before the Woodbury→Cholesky) between this model's
671    /// estimate and a previous one,
672    /// ```text
673    ///   Σ_t(row) = (1 − γ) · Σ_prev(row) + γ · Σ̂_t(row),
674    /// ```
675    /// where `Σ̂_t(row) = c_t(z)·ΛΛᵀ + D` is this model's per-row covariance
676    /// (built from the hoisted-M0 / occupancy-weighted `c(z)` path), and
677    /// `Σ_prev(row)` is `prev`'s per-row covariance when `Some`, else the
678    /// MEASURED iid anchor `φ̂·I_p` ([`Self::isotropic_dispersion`], #2243 cap
679    /// #2: a unit `I_p` anchor silently assumed unit noise, which on
680    /// near-noiseless data pinned the whitened likelihood — and therefore the
681    /// REML smoothing balance — at a noise scale ~1/φ̂ too coarse, i.e. the
682    /// clean-data over-penalization).
683    ///
684    /// Endpoints (exact):
685    /// * `γ = 1.0` ⇒ this model's [`Self::row_metric`] exactly (Woodbury path,
686    ///   byte-identical);
687    /// * `γ = 0.0` ⇒ `prev`'s [`Self::row_metric`] when `Some` (byte-identical),
688    ///   else the measured-scale identity `(φ̂·I)^{-1}` factors.
689    ///
690    /// `γ` must be finite and in `[0, 1]`; when `prev` is `Some` it must share
691    /// this model's `p` and row count.
692    pub fn row_metric_damped(
693        &self,
694        n_rows: usize,
695        gamma: f64,
696        prev: Option<&StructuredResidualModel>,
697    ) -> Result<RowMetric, String> {
698        if n_rows != self.row_scale.len() {
699            return Err(format!(
700                "StructuredResidualModel::row_metric_damped: requested {n_rows} rows but model has {}",
701                self.row_scale.len()
702            ));
703        }
704        if !gamma.is_finite() || !(0.0..=1.0).contains(&gamma) {
705            return Err(format!(
706                "StructuredResidualModel::row_metric_damped: gamma must be finite in [0,1]; got {gamma}"
707            ));
708        }
709        if let Some(pv) = prev {
710            if pv.p != self.p {
711                return Err(format!(
712                    "StructuredResidualModel::row_metric_damped: prev output dim {} != {}",
713                    pv.p, self.p
714                ));
715            }
716            if pv.row_scale.len() != n_rows {
717                return Err(format!(
718                    "StructuredResidualModel::row_metric_damped: prev has {} rows but requested {n_rows}",
719                    pv.row_scale.len()
720                ));
721            }
722        }
723        // Exact endpoints — reuse the undamped producers so the result is
724        // byte-identical (γ=1 ⇒ this model; γ=0 ⇒ prev, or Euclidean identity).
725        if gamma == 1.0 {
726            return self.row_metric(n_rows);
727        }
728        if gamma == 0.0 {
729            if let Some(pv) = prev {
730                return pv.row_metric(n_rows);
731            }
732            // prev = None falls through to the general path: the blend is then
733            // exactly the measured-scale identity `φ̂·I` (#2243 cap #2), not the
734            // unit Euclidean identity.
735        }
736
737        let p = self.p;
738        // #2243 cap #2 — the first-pass (prev = None) damping anchor is the
739        // model's own measured isotropic dispersion, hoisted out of the row loop.
740        let iid_anchor = self.isotropic_dispersion();
741        // Row-INDEPENDENT outer products ΛΛᵀ (this model and, if present, prev):
742        // only the per-row activity scale c(z) multiplies them, so hoist the Gram
743        // out of the per-row loop (mirroring the row_metric / penalized_log_evidence
744        // hoist).
745        let self_gram = outer_product(&self.lambda);
746        let prev_gram = prev.map(|pv| outer_product(&pv.lambda));
747
748        // Per-row blend + invert + Cholesky, parallelized over rows exactly as
749        // in [`Self::row_metric`]: rows are independent (each writes only its
750        // own p² chunk of `u`, no cross-row reduction), so the parallel stack is
751        // bit-identical to the serial one, with the same engagement discipline
752        // (row threshold, never nested inside a rayon worker) and the same
753        // deterministic lowest-row error selection.
754        let mut u = Array2::<f64>::zeros((n_rows, p * p));
755        let first_error = {
756            use rayon::prelude::*;
757            const PARALLEL_ROW_MIN: usize = 64;
758            let build_row = |row: usize, urow: &mut [f64]| -> Option<String> {
759                self.damped_row_factor(
760                    row,
761                    gamma,
762                    prev,
763                    iid_anchor,
764                    &self_gram,
765                    prev_gram.as_ref(),
766                    urow,
767                )
768                .err()
769            };
770            let u_flat = u
771                .as_slice_mut()
772                .ok_or_else(|| "StructuredResidualModel::row_metric_damped: factor stack must be standard-layout".to_string())?;
773            if p > 0 && n_rows >= PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
774                u_flat
775                    .par_chunks_mut(p * p)
776                    .enumerate()
777                    .map(|(row, urow)| build_row(row, urow))
778                    .collect::<Vec<Option<String>>>()
779                    .into_iter()
780                    .flatten()
781                    .next()
782            } else if p > 0 {
783                u_flat
784                    .chunks_mut(p * p)
785                    .enumerate()
786                    .find_map(|(row, urow)| build_row(row, urow))
787            } else {
788                None
789            }
790        };
791        if let Some(err) = first_error {
792            return Err(err);
793        }
794        RowMetric::whitened_structured(Arc::new(u), p, p)
795    }
796
797    /// One row of the damped-metric stack: assemble the convex-blend covariance
798    /// `Σ_t(row) = γ·(c·ΛΛᵀ + D) + (1−γ)·Σ_prev(row)`, symmetrize, invert, and
799    /// write the lower-Cholesky precision factor into `urow` (row-major `p×p`).
800    /// Factored out of [`Self::row_metric_damped`] so the serial and parallel
801    /// row drivers share one arithmetic body. `iid_anchor` is the measured
802    /// isotropic dispersion `φ̂` used as `Σ_prev = φ̂·I` when `prev` is `None`
803    /// (#2243 cap #2).
804    fn damped_row_factor(
805        &self,
806        row: usize,
807        gamma: f64,
808        prev: Option<&StructuredResidualModel>,
809        iid_anchor: f64,
810        self_gram: &Array2<f64>,
811        prev_gram: Option<&Array2<f64>>,
812        urow: &mut [f64],
813    ) -> Result<(), String> {
814        let p = self.p;
815        let c = self.row_scale[row].max(f64::MIN_POSITIVE);
816        // γ · Σ̂_t = γ·(c·ΛΛᵀ + D).
817        let mut sigma = Array2::<f64>::zeros((p, p));
818        for a in 0..p {
819            for b in 0..p {
820                sigma[[a, b]] = gamma * c * self_gram[[a, b]];
821            }
822            sigma[[a, a]] += gamma * self.diagonal[a];
823        }
824        // (1−γ) · Σ_prev  (prev's per-row Σ, or I_p when prev is None).
825        match (prev, prev_gram) {
826            (Some(pv), Some(pg)) => {
827                let cp = pv.row_scale[row].max(f64::MIN_POSITIVE);
828                for a in 0..p {
829                    for b in 0..p {
830                        sigma[[a, b]] += (1.0 - gamma) * cp * pg[[a, b]];
831                    }
832                    sigma[[a, a]] += (1.0 - gamma) * pv.diagonal[a];
833                }
834            }
835            (None, _) => {
836                // First-pass anchor: the MEASURED iid dispersion φ̂·I, not the
837                // unit identity (#2243 cap #2 — clean-data over-penalization).
838                for a in 0..p {
839                    sigma[[a, a]] += (1.0 - gamma) * iid_anchor;
840                }
841            }
842            (Some(_), None) => {
843                return Err(
844                    "previous residual model supplied without its precomputed gram".to_string()
845                );
846            }
847        }
848        // Symmetrize against round-off before inversion.
849        for a in 0..p {
850            for b in (a + 1)..p {
851                let avg = 0.5 * (sigma[[a, b]] + sigma[[b, a]]);
852                sigma[[a, b]] = avg;
853                sigma[[b, a]] = avg;
854            }
855        }
856        // Σ_t is a convex combination of SPD matrices (D ≻ 0 / I ≻ 0) ⇒ SPD.
857        // Precision = Σ_t^{-1} via a Cholesky solve against I_p, then the U_n
858        // factor is the lower-Cholesky of the precision (row_metric's U
859        // convention).
860        let precision = invert_spd(&sigma)?;
861        let factor = lower_cholesky_psd(&precision)?;
862        for i in 0..p {
863            for k in 0..p {
864                urow[i * p + k] = factor[[i, k]];
865            }
866        }
867        Ok(())
868    }
869
870    /// Per-row precision `Σ_n^{-1}` via the Woodbury identity (an `r × r` solve),
871    /// given the row-independent parts precomputed by [`Self::row_metric`]:
872    /// `d_inv = D^{-1}`, `b = D^{-1}Λ`, `bt = Bᵀ`, and the Gram `m0 = ΛᵀD^{-1}Λ`.
873    /// Only the per-row capacitance `M_n = m0 + c_n^{-1} I_r` and the back-solve
874    /// depend on the row.
875    fn row_precision(
876        &self,
877        d_inv: &[f64],
878        b: &Array2<f64>,
879        bt: &Array2<f64>,
880        m0: &Array2<f64>,
881        row: usize,
882    ) -> Result<Array2<f64>, String> {
883        let p = self.p;
884        let r = self.factor_rank;
885        // Start from D^{-1}.
886        let mut precision = Array2::<f64>::zeros((p, p));
887        for i in 0..p {
888            precision[[i, i]] = d_inv[i];
889        }
890        if r == 0 {
891            return Ok(precision);
892        }
893        let c = self.row_scale[row].max(f64::MIN_POSITIVE);
894        // Per-row capacitance M_n = M0 + c^{-1} I_r (copy the hoisted Gram, then
895        // add c^{-1} to the diagonal). M_n ≻ 0 since c^{-1} > 0 and M0 ⪰ 0.
896        let mut cap = m0.clone();
897        for a in 0..r {
898            cap[[a, a]] += 1.0 / c;
899        }
900        // Σ_n^{-1} = D^{-1} − B M_n^{-1} Bᵀ. Solve M_n X = Bᵀ for X = M_n^{-1} Bᵀ
901        // (r × p) via Cholesky.
902        let chol = cap
903            .cholesky(Side::Lower)
904            .map_err(|e| format!("StructuredResidualModel::row_precision capacitance: {e:?}"))?;
905        let x = chol.solve_mat(bt); // r × p
906        for i in 0..p {
907            for j in 0..p {
908                let mut acc = 0.0_f64;
909                for k in 0..r {
910                    acc += b[[i, k]] * x[[k, j]];
911                }
912                precision[[i, j]] -= acc;
913            }
914        }
915        // Symmetrize against round-off so the Cholesky downstream sees an exactly
916        // symmetric PSD matrix.
917        for i in 0..p {
918            for j in (i + 1)..p {
919                let avg = 0.5 * (precision[[i, j]] + precision[[j, i]]);
920                precision[[i, j]] = avg;
921                precision[[j, i]] = avg;
922            }
923        }
924        Ok(precision)
925    }
926}
927
928/// Outer product `Λ Λᵀ ∈ ℝ^{p×p}` of a factor matrix `Λ ∈ ℝ^{p×r}` — the
929/// row-independent factor covariance the per-row activity scale multiplies.
930/// Used by [`StructuredResidualModel::row_metric_damped`] to hoist the Gram out
931/// of its per-row covariance-blend loop.
932fn outer_product(lambda: &Array2<f64>) -> Array2<f64> {
933    let p = lambda.nrows();
934    let r = lambda.ncols();
935    let mut g = Array2::<f64>::zeros((p, p));
936    for a in 0..p {
937        for b in 0..p {
938            let mut acc = 0.0_f64;
939            for k in 0..r {
940                acc += lambda[[a, k]] * lambda[[b, k]];
941            }
942            g[[a, b]] = acc;
943        }
944    }
945    g
946}
947
948/// Inverse of a symmetric positive-definite matrix via a Cholesky solve against
949/// the identity, symmetrized against round-off. Used to form `Σ_t^{-1}` from a
950/// densely-blended covariance in [`StructuredResidualModel::row_metric_damped`]
951/// (the blended covariance is no longer low-rank-plus-diagonal, so Woodbury does
952/// not apply).
953fn invert_spd(a: &Array2<f64>) -> Result<Array2<f64>, String> {
954    let p = a.nrows();
955    let chol = a
956        .cholesky(Side::Lower)
957        .map_err(|e| format!("invert_spd: blended covariance not SPD: {e:?}"))?;
958    let mut inv = chol.solve_mat(&Array2::<f64>::eye(p));
959    for i in 0..p {
960        for j in (i + 1)..p {
961            let avg = 0.5 * (inv[[i, j]] + inv[[j, i]]);
962            inv[[i, j]] = avg;
963            inv[[j, i]] = avg;
964        }
965    }
966    Ok(inv)
967}
968
969/// Per-channel (column) sample second moment of the residual matrix.
970fn column_variances(r: ArrayView2<'_, f64>) -> Array1<f64> {
971    let n = r.nrows();
972    let p = r.ncols();
973    let mut v = Array1::<f64>::zeros(p);
974    for j in 0..p {
975        let mut acc = 0.0_f64;
976        for i in 0..n {
977            acc += r[[i, j]] * r[[i, j]];
978        }
979        v[j] = acc / n as f64;
980    }
981    v
982}
983
984/// Scale-deflated second moment `S = (1/n) Σ_n (r_n r_nᵀ) / c_n`.
985/// Per-row-chunk contribution to the scaled second moment — the inner
986/// `p×p` accumulation of one contiguous row block, summed in row order.
987fn scaled_second_moment_chunk(
988    r: ArrayView2<'_, f64>,
989    row_scale: &Array1<f64>,
990    lo: usize,
991    hi: usize,
992) -> Array2<f64> {
993    let p = r.ncols();
994    let mut s = Array2::<f64>::zeros((p, p));
995    for i in lo..hi {
996        let w = 1.0 / row_scale[i].max(f64::MIN_POSITIVE);
997        for a in 0..p {
998            let ra = r[[i, a]];
999            for b in 0..p {
1000                s[[a, b]] += w * ra * r[[i, b]];
1001            }
1002        }
1003    }
1004    s
1005}
1006
1007/// `S = (1/n) Σ_n (r_n r_nᵀ) / c(z_n)` — the O(N·p²) scale-deflated second moment
1008/// that dominates each alternation sweep of the residual-factor fit.
1009///
1010/// Reduced over the deterministic length-only pairwise tree
1011/// [`par_deterministic_block_fold`]: the `p×p` partial of each `BASE_CHUNK`-row
1012/// base block is combined by the same `left_split` association regardless of
1013/// thread count OR nesting, so the result is a pure function of the ordered rows
1014/// (#2228 reduction doctrine — parallel and nested-serial evaluation are
1015/// bit-identical, not merely run-to-run reproducible). The tree self-serializes
1016/// below `BASE_CHUNK` rows (a base block is folded directly with no `rayon::join`),
1017/// so small inputs and nested calls stay on a single thread without a separate
1018/// branch that could associate the round-off differently.
1019fn scaled_second_moment(r: ArrayView2<'_, f64>, row_scale: &Array1<f64>) -> Array2<f64> {
1020    use gam_linalg::pairwise_reduce::par_deterministic_block_fold;
1021    let n = r.nrows();
1022    let p = r.ncols();
1023
1024    let mut s = par_deterministic_block_fold(
1025        n,
1026        |range: core::ops::Range<usize>| {
1027            scaled_second_moment_chunk(r, row_scale, range.start, range.end)
1028        },
1029        |mut acc: Array2<f64>, part: Array2<f64>| {
1030            acc += &part;
1031            acc
1032        },
1033    )
1034    .unwrap_or_else(|| Array2::<f64>::zeros((p, p)));
1035
1036    s.mapv_inplace(|v| v / n as f64);
1037    // Symmetrize against accumulation round-off.
1038    for a in 0..p {
1039        for b in (a + 1)..p {
1040            let avg = 0.5 * (s[[a, b]] + s[[b, a]]);
1041            s[[a, b]] = avg;
1042            s[[b, a]] = avg;
1043        }
1044    }
1045    s
1046}
1047
1048/// Factor coordinates `Λ⁺_D r_n` per row: the generalized-least-squares
1049/// projection of each residual onto `range(Λ)` in the `D^{-1}` metric, returned
1050/// as an `n × r` matrix. Solves the `r × r` normal equations
1051/// `(Λᵀ D^{-1} Λ) γ = Λᵀ D^{-1} r_n` per row (shared factorization).
1052fn factor_coordinates(
1053    lambda: &Array2<f64>,
1054    diagonal: &Array1<f64>,
1055    r: ArrayView2<'_, f64>,
1056) -> Result<Array2<f64>, String> {
1057    let p = lambda.nrows();
1058    let rank = lambda.ncols();
1059    let n = r.nrows();
1060    // GLS weights 1/D_ii, with zero-variance channels DROPPED (weight 0): a
1061    // channel whose residual is identically zero carries no factor information,
1062    // and its 1/0 = ∞ weight poisons the whole normal matrix into NaN — the
1063    // fully-explained-target abort (Cholesky NonPositivePivot) that killed
1064    // stagewise runs on targets the dictionary explains exactly. Dropping the
1065    // channel is the pseudo-inverse limit; with every channel degenerate the
1066    // ridged normal matrix stays PD and the coordinates are the least-norm 0.
1067    let d_inv: Vec<f64> = (0..p)
1068        .map(|i| {
1069            let d = diagonal[i];
1070            if !(d > 0.0 && d.is_finite()) {
1071                return 0.0;
1072            }
1073            // A subnormal-floored variance (the zero-residual case floors the
1074            // scale reference at f64::MIN_POSITIVE) passes `d > 0` but its
1075            // reciprocal OVERFLOWS to ∞ — the same NaN poisoning through the
1076            // second door. A non-finite weight is the same degenerate-channel
1077            // verdict: drop it.
1078            let w = d.recip();
1079            if w.is_finite() { w } else { 0.0 }
1080        })
1081        .collect();
1082    // Normal matrix ΛᵀD^{-1}Λ (+ tiny ridge for invertibility).
1083    let mut normal = Array2::<f64>::zeros((rank, rank));
1084    for a in 0..rank {
1085        for b in 0..rank {
1086            let mut acc = 0.0_f64;
1087            for i in 0..p {
1088                acc += lambda[[i, a]] * d_inv[i] * lambda[[i, b]];
1089            }
1090            normal[[a, b]] = acc;
1091        }
1092    }
1093    let trace = (0..rank).map(|k| normal[[k, k]]).sum::<f64>().max(1.0);
1094    let ridge = 1e-10 * trace / rank.max(1) as f64;
1095    for k in 0..rank {
1096        normal[[k, k]] += ridge;
1097    }
1098    let chol = normal
1099        .cholesky(Side::Lower)
1100        .map_err(|e| format!("factor_coordinates normal solve: {e:?}"))?;
1101    let mut coords = Array2::<f64>::zeros((n, rank));
1102    let mut rhs = Array1::<f64>::zeros(rank);
1103    for i in 0..n {
1104        for a in 0..rank {
1105            let mut acc = 0.0_f64;
1106            for j in 0..p {
1107                acc += lambda[[j, a]] * d_inv[j] * r[[i, j]];
1108            }
1109            rhs[a] = acc;
1110        }
1111        let gamma = chol.solvevec(&rhs);
1112        for a in 0..rank {
1113            coords[[i, a]] = gamma[a];
1114        }
1115    }
1116    Ok(coords)
1117}
1118
1119/// 3-point moving average over a bin vector (edge-clamped), giving the smooth
1120/// activity-scale law a continuous, low-curvature shape.
1121fn moving_average_3(v: &Array1<f64>) -> Array1<f64> {
1122    let m = v.len();
1123    let mut out = Array1::<f64>::zeros(m);
1124    for i in 0..m {
1125        let lo = i.saturating_sub(1);
1126        let hi = (i + 1).min(m - 1);
1127        let mut acc = 0.0_f64;
1128        let mut cnt = 0.0_f64;
1129        for j in lo..=hi {
1130            acc += v[j];
1131            cnt += 1.0;
1132        }
1133        out[i] = acc / cnt;
1134    }
1135    out
1136}
1137
1138/// Ascending-eigenvalue symmetric eigendecomposition (faer convention).
1139fn symmetric_eig_ascending(m: &Array2<f64>) -> Result<(Array1<f64>, Array2<f64>), String> {
1140    m.eigh(Side::Lower)
1141        .map_err(|e| format!("symmetric_eig: {e:?}"))
1142}
1143
1144/// Lower-triangular Cholesky factor `L` of a (numerically) PSD matrix `A` with
1145/// `L Lᵀ = A`, with a relative spectral floor so a marginally-indefinite
1146/// precision (round-off) still factors. Used to turn `Σ_n^{-1}` into the
1147/// `RowMetric` factor `U_n` (here `U_n = L`).
1148fn lower_cholesky_psd(a: &Array2<f64>) -> Result<Array2<f64>, String> {
1149    if let Ok(chol) = a.cholesky(Side::Lower) {
1150        return Ok(chol.lower_triangular());
1151    }
1152    // Eigen-repair: clamp eigenvalues to a small positive floor, rebuild the
1153    // REPAIRED matrix Q·diag(λ_clamped)·Qᵀ itself, and Cholesky that (always
1154    // succeeds, PD). The returned factor must satisfy L·Lᵀ = A_repaired —
1155    // rebuilding the symmetric square root here and factoring THAT would hand
1156    // callers a factor with L·Lᵀ = A^{1/2}, silently taking every whitened
1157    // quadratic form against the square root of the intended precision.
1158    let (evals, evecs) = symmetric_eig_ascending(a)?;
1159    let max_ev = evals.iter().copied().fold(0.0_f64, f64::max).max(1.0);
1160    let floor = 1e-10 * max_ev;
1161    let p = a.nrows();
1162    let mut repaired = Array2::<f64>::zeros((p, p));
1163    for i in 0..p {
1164        for j in 0..p {
1165            let mut acc = 0.0_f64;
1166            for k in 0..p {
1167                let ev = evals[k].max(floor);
1168                acc += evecs[[i, k]] * ev * evecs[[j, k]];
1169            }
1170            repaired[[i, j]] = acc;
1171        }
1172    }
1173    repaired
1174        .cholesky(Side::Lower)
1175        .map(|c| c.lower_triangular())
1176        .map_err(|e| format!("lower_cholesky_psd eigen-repair: {e:?}"))
1177}
1178
1179/// Penalized Gaussian log-evidence of the structured model at the fitted
1180/// parameters — the evidence ladder's rank-selection score.
1181///
1182/// The per-row log-density of `r_n ~ N(0, Σ_n)` is
1183/// `−½ ( log|Σ_n| + r_nᵀ Σ_n^{-1} r_n + p log 2π )`. We sum it across rows and
1184/// subtract a parameter-count penalty `½ k_params · log n` (a BIC-style Occam
1185/// term over the `p·r` factor entries + `p` diagonal entries + the bin scales),
1186/// so adding a spurious factor that does not improve the fit is rejected. Both
1187/// `log|Σ_n|` and the quadratic use the Woodbury / matrix-determinant lemma so no
1188/// dense `p × p` inverse or determinant is formed.
1189fn penalized_log_evidence(
1190    r: ArrayView2<'_, f64>,
1191    lambda: &Array2<f64>,
1192    diagonal: &Array1<f64>,
1193    row_scale: &Array1<f64>,
1194    rank: usize,
1195) -> f64 {
1196    let n = r.nrows();
1197    let p = r.ncols();
1198    let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
1199    let log_det_d: f64 = diagonal.iter().map(|&d| d.ln()).sum();
1200    let two_pi_ln = (2.0 * std::f64::consts::PI).ln();
1201
1202    // Row-INDEPENDENT Gram M0 = ΛᵀD^{-1}Λ (r × r). This does not depend on the
1203    // row, so build it ONCE here rather than rebuilding it inside the per-row loop
1204    // (which was O(n·p·r²)). The per-row capacitance is only a scalar-diagonal
1205    // reweight of this SAME M0 — M_n = M0 + (1/c_n) I_r — so each row copies M0 and
1206    // adds 1/c_n to its diagonal (cheap, O(r)) before its own Cholesky. The
1207    // summation order over j (0..p) is preserved exactly and the diagonal add is
1208    // the identical `+= 1.0 / c` op, so the hoist is bit-for-bit identical to the
1209    // pre-hoist per-row rebuild (same log|Σ_n|, same quadratic, same evidence).
1210    let mut m0 = Array2::<f64>::zeros((rank, rank));
1211    if rank > 0 {
1212        for a in 0..rank {
1213            for b in 0..rank {
1214                let mut acc = 0.0_f64;
1215                for j in 0..p {
1216                    acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
1217                }
1218                m0[[a, b]] = acc;
1219            }
1220        }
1221    }
1222
1223    let mut log_lik = 0.0_f64;
1224    for i in 0..n {
1225        let c = row_scale[i].max(f64::MIN_POSITIVE);
1226        // Quadratic r_nᵀ Σ_n^{-1} r_n via Woodbury:
1227        //   r_nᵀ D^{-1} r_n − (Bᵀ r_n)ᵀ M^{-1} (Bᵀ r_n),
1228        // with B = D^{-1}Λ and M = c^{-1}I + ΛᵀD^{-1}Λ.
1229        let mut quad = 0.0_f64;
1230        for j in 0..p {
1231            quad += r[[i, j]] * d_inv[j] * r[[i, j]];
1232        }
1233        let mut log_det = log_det_d;
1234        if rank > 0 {
1235            // Per-row capacitance M_n = M0 + (1/c) I_r (copy the hoisted M0, then
1236            // add 1/c to the diagonal), and w = Bᵀ r_n = ΛᵀD^{-1} r_n.
1237            let mut m = m0.clone();
1238            for a in 0..rank {
1239                m[[a, a]] += 1.0 / c;
1240            }
1241            let mut w = Array1::<f64>::zeros(rank);
1242            for a in 0..rank {
1243                let mut wa = 0.0_f64;
1244                for j in 0..p {
1245                    wa += lambda[[j, a]] * d_inv[j] * r[[i, j]];
1246                }
1247                w[a] = wa;
1248            }
1249            // Cholesky M = R Rᵀ → log|M|, and solve M y = w.
1250            match m.cholesky(Side::Lower) {
1251                Ok(chol) => {
1252                    let y = chol.solvevec(&w);
1253                    let mut wy = 0.0_f64;
1254                    for a in 0..rank {
1255                        wy += w[a] * y[a];
1256                    }
1257                    quad -= wy;
1258                    // log|Σ_n| = log|D| + log|M| + r·log c   (matrix-determinant
1259                    // lemma; the c^{-1}I shift carries the +r·log c).
1260                    let diag = chol.diag();
1261                    let log_det_m: f64 = diag.iter().map(|&l| (l * l).ln()).sum();
1262                    log_det = log_det_d + log_det_m + rank as f64 * c.ln();
1263                }
1264                Err(_) => {
1265                    // Degenerate capacitance — fall back to the diagonal model's
1266                    // accounting for this row (no factor correction).
1267                    log_det = log_det_d;
1268                }
1269            }
1270        }
1271        log_lik += -0.5 * (log_det + quad + p as f64 * two_pi_ln);
1272    }
1273
1274    let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
1275    log_lik - 0.5 * k_params * (n.max(2) as f64).ln()
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280    use super::*;
1281    use ndarray::{Array1, Array2};
1282
1283    fn lcg_uniform(state: &mut u64) -> f64 {
1284        *state = state
1285            .wrapping_mul(6364136223846793005)
1286            .wrapping_add(1442695040888963407);
1287        ((*state >> 11) as f64) / ((1u64 << 53) as f64)
1288    }
1289
1290    fn lcg_normal(state: &mut u64) -> f64 {
1291        let u1 = lcg_uniform(state).max(1e-12);
1292        let u2 = lcg_uniform(state);
1293        (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
1294    }
1295
1296    /// Per-rank evidence breakdown on the planted single-factor activity-law
1297    /// DGP (the `fitted_scale_recovers_planted_activity_law` plant). Pins the
1298    /// rank-selection decision itself: the ladder must prefer rank 1, and this
1299    /// test names the margin so an over-selection regression is diagnosable
1300    /// from the failure message alone.
1301    #[test]
1302    fn evidence_ladder_prefers_planted_rank_one() {
1303        let n = 5000usize;
1304        let p = 4usize;
1305        let lambda0 = ndarray::array![[1.5], [1.2], [-0.4], [0.3]];
1306        let sigma_eps = 0.2_f64;
1307        let slope = 1.3_f64;
1308        let mut seed = 0xD1B54A32D192ED03_u64;
1309        let mut residuals = Array2::<f64>::zeros((n, p));
1310        let mut activity = Array1::<f64>::zeros(n);
1311        for row in 0..n {
1312            let z = (row as f64) / (n as f64 - 1.0);
1313            activity[row] = z;
1314            let amp = (slope * z).exp().sqrt();
1315            let f = lcg_normal(&mut seed);
1316            for i in 0..p {
1317                residuals[[row, i]] = amp * lambda0[[i, 0]] * f + sigma_eps * lcg_normal(&mut seed);
1318            }
1319        }
1320        // Reproduce fit()'s bin assignment, then score each rank directly.
1321        let bins = ACTIVITY_SCALE_BINS.max(1);
1322        let row_bin: Vec<usize> = (0..n)
1323            .map(|i| {
1324                let frac = activity[i];
1325                (frac * bins as f64).floor().clamp(0.0, bins as f64 - 1.0) as usize
1326            })
1327            .collect();
1328        let mut report = String::new();
1329        let mut ev = Vec::new();
1330        for rank in 0..=2usize {
1331            let m = StructuredResidualModel::fit_fixed_rank(residuals.view(), &row_bin, bins, rank)
1332                .expect("fixed-rank fit");
1333            let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
1334            let log_lik = m.log_evidence() + 0.5 * k_params * (n as f64).ln();
1335            let col_norms: Vec<f64> = (0..rank)
1336                .map(|k| {
1337                    m.factor()
1338                        .column(k)
1339                        .iter()
1340                        .map(|v| v * v)
1341                        .sum::<f64>()
1342                        .sqrt()
1343                })
1344                .collect();
1345            report.push_str(&format!(
1346                "rank {rank}: evidence={:.3} loglik={:.3} penalty={:.3} col_norms={:?} diag={:?}\n",
1347                m.log_evidence(),
1348                log_lik,
1349                0.5 * k_params * (n as f64).ln(),
1350                col_norms,
1351                m.diagonal()
1352                    .iter()
1353                    .map(|v| (v * 1e4).round() / 1e4)
1354                    .collect::<Vec<_>>()
1355            ));
1356            ev.push(m.log_evidence());
1357        }
1358        assert!(
1359            ev[1] > ev[0] && ev[1] > ev[2],
1360            "evidence ladder must prefer the planted rank 1; breakdown:\n{report}"
1361        );
1362    }
1363
1364    /// Orthonormalize the columns of `m` (modified Gram–Schmidt), dropping
1365    /// numerically-null columns. Test-side helper for subspace comparisons.
1366    fn orthonormal_columns(m: ArrayView2<'_, f64>) -> Vec<Array1<f64>> {
1367        let mut basis: Vec<Array1<f64>> = Vec::new();
1368        for k in 0..m.ncols() {
1369            let mut v = m.column(k).to_owned();
1370            for q in &basis {
1371                let c = v.dot(q);
1372                v = &v - &(q * c);
1373            }
1374            let norm = v.dot(&v).sqrt();
1375            if norm > 1e-10 {
1376                basis.push(v / norm);
1377            }
1378        }
1379        basis
1380    }
1381
1382    /// Squared norm of the projection of unit vector `v` onto span(basis) —
1383    /// `cos²` of the principal angle between `v` and the subspace.
1384    fn projection_energy(v: &Array1<f64>, basis: &[Array1<f64>]) -> f64 {
1385        basis.iter().map(|q| v.dot(q).powi(2)).sum()
1386    }
1387
1388    /// #974 verification arm (a): the fitted factor must recover the PLANTED
1389    /// interference subspace. Two orthogonal planted directions with distinct
1390    /// strengths; the principal angles between each planted direction and
1391    /// range(Λ̂) must be small, and the evidence ladder must select rank 2.
1392    #[test]
1393    fn factor_recovers_planted_interference_subspace() {
1394        let n = 6000usize;
1395        let p = 6usize;
1396        // Two orthogonal planted unit directions.
1397        let raw1: Array1<f64> = ndarray::array![1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1398        let raw2: Array1<f64> = ndarray::array![1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
1399        let v1 = &raw1 / raw1.dot(&raw1).sqrt();
1400        let v2 = &raw2 / raw2.dot(&raw2).sqrt();
1401        let (amp1, amp2) = (1.4_f64, 0.9_f64);
1402        let sigma_eps = 0.15_f64;
1403
1404        let mut seed = 0x9E3779B97F4A7C15_u64;
1405        let mut residuals = Array2::<f64>::zeros((n, p));
1406        let activity = Array1::<f64>::zeros(n); // constant ⇒ homoscedastic law
1407        for row in 0..n {
1408            let f1 = amp1 * lcg_normal(&mut seed);
1409            let f2 = amp2 * lcg_normal(&mut seed);
1410            for i in 0..p {
1411                residuals[[row, i]] = f1 * v1[i] + f2 * v2[i] + sigma_eps * lcg_normal(&mut seed);
1412            }
1413        }
1414
1415        let model = StructuredResidualModel::fit(ResidualFactorInput {
1416            residuals: residuals.view(),
1417            activity: activity.view(),
1418            max_factor_rank: 4,
1419        })
1420        .expect("fit");
1421
1422        assert_eq!(
1423            model.factor_rank(),
1424            2,
1425            "ladder must select the planted rank 2 (got {}, evidence {:.3})",
1426            model.factor_rank(),
1427            model.log_evidence()
1428        );
1429        let basis = orthonormal_columns(model.factor());
1430        assert_eq!(basis.len(), 2, "fitted factor must span 2 directions");
1431        let e1 = projection_energy(&v1, &basis);
1432        let e2 = projection_energy(&v2, &basis);
1433        // cos² of each principal angle ≥ 0.95 ⇒ angle ≤ ~13°.
1434        assert!(
1435            e1 > 0.95 && e2 > 0.95,
1436            "planted directions must lie in range(Λ̂): cos² = ({e1:.4}, {e2:.4})"
1437        );
1438    }
1439
1440    /// #974 verification arm (d): recovery of the planted activity-variance
1441    /// law. Single planted factor with per-row energy `exp(slope·z)`; the
1442    /// fitted `c(z_n)` must reproduce the law's shape — strongly correlated
1443    /// with the planted log-scale and with the right dynamic range.
1444    #[test]
1445    fn fitted_scale_recovers_planted_activity_law() {
1446        let n = 6000usize;
1447        let p = 4usize;
1448        let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
1449        let sigma_eps = 0.2_f64;
1450        let slope = 1.3_f64;
1451        let mut seed = 0xD1B54A32D192ED03_u64;
1452        let mut residuals = Array2::<f64>::zeros((n, p));
1453        let mut activity = Array1::<f64>::zeros(n);
1454        for row in 0..n {
1455            let z = (row as f64) / (n as f64 - 1.0);
1456            activity[row] = z;
1457            let amp = (slope * z).exp().sqrt();
1458            let f = lcg_normal(&mut seed);
1459            for i in 0..p {
1460                residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
1461            }
1462        }
1463
1464        let model = StructuredResidualModel::fit(ResidualFactorInput {
1465            residuals: residuals.view(),
1466            activity: activity.view(),
1467            max_factor_rank: 2,
1468        })
1469        .expect("fit");
1470        assert_eq!(model.factor_rank(), 1, "planted rank is 1");
1471
1472        // Pearson correlation between fitted log c(z_n) and the planted
1473        // log-law slope·z (mean-1 normalization cancels in the correlation).
1474        let fitted_log: Vec<f64> = model.row_scale().iter().map(|c| c.ln()).collect();
1475        let planted_log: Vec<f64> = activity.iter().map(|z| slope * z).collect();
1476        let mean_f = fitted_log.iter().sum::<f64>() / n as f64;
1477        let mean_p = planted_log.iter().sum::<f64>() / n as f64;
1478        let mut cov = 0.0_f64;
1479        let mut var_f = 0.0_f64;
1480        let mut var_p = 0.0_f64;
1481        for i in 0..n {
1482            let df = fitted_log[i] - mean_f;
1483            let dp = planted_log[i] - mean_p;
1484            cov += df * dp;
1485            var_f += df * df;
1486            var_p += dp * dp;
1487        }
1488        let corr = cov / (var_f.sqrt() * var_p.sqrt());
1489        assert!(
1490            corr > 0.9,
1491            "fitted activity law must track the planted exp({slope}·z): corr = {corr:.4}"
1492        );
1493
1494        // Dynamic range: planted c(top)/c(bottom) over the inner bin centers
1495        // is exp(slope·7/8) ≈ 3.1; the binned/smoothed estimate must land in
1496        // a generous bracket around it (smoothing shrinks the edges).
1497        let lo = model.row_scale()[n / 16]; // first-bin interior
1498        let hi = model.row_scale()[n - 1 - n / 16]; // last-bin interior
1499        let ratio = hi / lo;
1500        assert!(
1501            ratio > 1.8 && ratio < 5.5,
1502            "fitted dynamic range {ratio:.3} must bracket the planted ≈3.1"
1503        );
1504    }
1505
1506    /// Reproduce `fit`'s equal-width bin assignment for a test activity vector.
1507    fn assign_bins(activity: &Array1<f64>, bins: usize) -> Vec<usize> {
1508        let n = activity.len();
1509        let z_min = activity.iter().copied().fold(f64::INFINITY, f64::min);
1510        let z_max = activity.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1511        let span = z_max - z_min;
1512        (0..n)
1513            .map(|i| {
1514                if span <= 0.0 {
1515                    0
1516                } else {
1517                    let frac = (activity[i] - z_min) / span;
1518                    (frac * bins as f64).floor().clamp(0.0, bins as f64 - 1.0) as usize
1519                }
1520            })
1521            .collect()
1522    }
1523
1524    /// FIX A invariant: with deliberately UNEVEN bin occupancy the fitted
1525    /// per-row scale must have `(1/n) Σ_i row_scale[i] = 1` (occupancy-weighted
1526    /// mean-1), NOT the bin-uniform mean-1 the old code enforced. We assert the
1527    /// row-mean is 1 to tight tolerance, and that the bin-UNIFORM mean of the
1528    /// distinct per-bin scales is materially ≠ 1 — which is exactly the quantity
1529    /// the old normalization forced to 1, so this proves the two means differ
1530    /// under uneven occupancy and the test bites.
1531    #[test]
1532    fn occupancy_weighted_scale_has_row_mean_one() {
1533        let n = 4000usize;
1534        let p = 4usize;
1535        let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
1536        let sigma_eps = 0.2_f64;
1537        let slope = 2.0_f64;
1538        let mut seed = 0xB5297A4D_u64 ^ 0x68E31DA4_u64;
1539        let mut residuals = Array2::<f64>::zeros((n, p));
1540        let mut activity = Array1::<f64>::zeros(n);
1541        for row in 0..n {
1542            // Cubic warp concentrates rows in the low-z bins ⇒ uneven occupancy.
1543            let u = (row as f64) / (n as f64 - 1.0);
1544            let z = u * u * u;
1545            activity[row] = z;
1546            let amp = (slope * z).exp().sqrt();
1547            let f = lcg_normal(&mut seed);
1548            for i in 0..p {
1549                residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
1550            }
1551        }
1552
1553        let model = StructuredResidualModel::fit(ResidualFactorInput {
1554            residuals: residuals.view(),
1555            activity: activity.view(),
1556            max_factor_rank: 2,
1557        })
1558        .expect("fit");
1559        assert!(
1560            model.factor_rank() >= 1,
1561            "need a non-trivial scale law (rank ≥ 1) for this invariant to bite; got rank 0"
1562        );
1563
1564        // Occupancy-weighted (row) mean must be exactly 1.
1565        let row_mean = model.row_scale().iter().sum::<f64>() / n as f64;
1566        assert!(
1567            (row_mean - 1.0).abs() < 1e-9,
1568            "occupancy-weighted row mean of c(z) must be 1; got {row_mean:.12}"
1569        );
1570
1571        // Bins are genuinely unevenly occupied, and every occupied bin's rows
1572        // share one scale value (collect the distinct value per bin).
1573        let bins = ACTIVITY_SCALE_BINS.max(1);
1574        let row_bin = assign_bins(&activity, bins);
1575        let mut counts = vec![0usize; bins];
1576        let mut bin_val = vec![f64::NAN; bins];
1577        for i in 0..n {
1578            let b = row_bin[i];
1579            counts[b] += 1;
1580            bin_val[b] = model.row_scale()[i];
1581        }
1582        let occupied: Vec<usize> = (0..bins).filter(|&b| counts[b] > 0).collect();
1583        let max_c = *counts.iter().max().unwrap();
1584        let min_c = *counts.iter().filter(|&&c| c > 0).min().unwrap();
1585        assert!(
1586            max_c as f64 > 2.0 * min_c as f64,
1587            "fixture must have uneven occupancy; counts = {counts:?}"
1588        );
1589
1590        // The bin-UNIFORM mean of the per-bin scales is what the OLD code forced
1591        // to 1. Under uneven occupancy + a non-constant law it is materially ≠ 1,
1592        // so the old normalization would NOT satisfy the row-mean-1 identity.
1593        let uniform_mean =
1594            occupied.iter().map(|&b| bin_val[b]).sum::<f64>() / occupied.len() as f64;
1595        assert!(
1596            (uniform_mean - 1.0).abs() > 0.1,
1597            "bin-uniform mean must differ from 1 (proving occupancy weighting matters); \
1598             got uniform_mean = {uniform_mean:.6}, row_mean = {row_mean:.6}"
1599        );
1600    }
1601
1602    /// Naive, pre-hoist reference for `penalized_log_evidence`: rebuilds the
1603    /// row-independent Gram M0 = ΛᵀD⁻¹Λ INSIDE the per-row loop (the original
1604    /// formula). The production function hoists M0 out; the two must agree.
1605    fn naive_penalized_log_evidence(
1606        r: ArrayView2<'_, f64>,
1607        lambda: &Array2<f64>,
1608        diagonal: &Array1<f64>,
1609        row_scale: &Array1<f64>,
1610        rank: usize,
1611    ) -> f64 {
1612        let n = r.nrows();
1613        let p = r.ncols();
1614        let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
1615        let log_det_d: f64 = diagonal.iter().map(|&d| d.ln()).sum();
1616        let two_pi_ln = (2.0 * std::f64::consts::PI).ln();
1617        let mut log_lik = 0.0_f64;
1618        for i in 0..n {
1619            let c = row_scale[i].max(f64::MIN_POSITIVE);
1620            let mut quad = 0.0_f64;
1621            for j in 0..p {
1622                quad += r[[i, j]] * d_inv[j] * r[[i, j]];
1623            }
1624            let mut log_det = log_det_d;
1625            if rank > 0 {
1626                let mut m = Array2::<f64>::zeros((rank, rank));
1627                let mut w = Array1::<f64>::zeros(rank);
1628                for a in 0..rank {
1629                    let mut wa = 0.0_f64;
1630                    for j in 0..p {
1631                        wa += lambda[[j, a]] * d_inv[j] * r[[i, j]];
1632                    }
1633                    w[a] = wa;
1634                    for b in 0..rank {
1635                        let mut acc = 0.0_f64;
1636                        for j in 0..p {
1637                            acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
1638                        }
1639                        m[[a, b]] = acc;
1640                    }
1641                    m[[a, a]] += 1.0 / c;
1642                }
1643                match m.cholesky(Side::Lower) {
1644                    Ok(chol) => {
1645                        let y = chol.solvevec(&w);
1646                        let mut wy = 0.0_f64;
1647                        for a in 0..rank {
1648                            wy += w[a] * y[a];
1649                        }
1650                        quad -= wy;
1651                        let diag = chol.diag();
1652                        let log_det_m: f64 = diag.iter().map(|&l| (l * l).ln()).sum();
1653                        log_det = log_det_d + log_det_m + rank as f64 * c.ln();
1654                    }
1655                    Err(_) => {
1656                        log_det = log_det_d;
1657                    }
1658                }
1659            }
1660            log_lik += -0.5 * (log_det + quad + p as f64 * two_pi_ln);
1661        }
1662        let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
1663        log_lik - 0.5 * k_params * (n.max(2) as f64).ln()
1664    }
1665
1666    /// Naive, per-row-rebuild reference for `factor_coordinates`: rebuilds and
1667    /// re-factors the (row-independent) normal matrix ΛᵀD⁻¹Λ for EVERY row.
1668    /// Mathematically identical to the shared-factorization production path.
1669    fn naive_factor_coordinates(
1670        lambda: &Array2<f64>,
1671        diagonal: &Array1<f64>,
1672        r: ArrayView2<'_, f64>,
1673    ) -> Array2<f64> {
1674        let p = lambda.nrows();
1675        let rank = lambda.ncols();
1676        let n = r.nrows();
1677        let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
1678        let mut coords = Array2::<f64>::zeros((n, rank));
1679        for i in 0..n {
1680            let mut normal = Array2::<f64>::zeros((rank, rank));
1681            for a in 0..rank {
1682                for b in 0..rank {
1683                    let mut acc = 0.0_f64;
1684                    for j in 0..p {
1685                        acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
1686                    }
1687                    normal[[a, b]] = acc;
1688                }
1689            }
1690            let trace = (0..rank).map(|k| normal[[k, k]]).sum::<f64>().max(1.0);
1691            let ridge = 1e-10 * trace / rank.max(1) as f64;
1692            for k in 0..rank {
1693                normal[[k, k]] += ridge;
1694            }
1695            let chol = normal.cholesky(Side::Lower).expect("naive normal solve");
1696            let mut rhs = Array1::<f64>::zeros(rank);
1697            for a in 0..rank {
1698                let mut acc = 0.0_f64;
1699                for j in 0..p {
1700                    acc += lambda[[j, a]] * d_inv[j] * r[[i, j]];
1701                }
1702                rhs[a] = acc;
1703            }
1704            let gamma = chol.solvevec(&rhs);
1705            for a in 0..rank {
1706                coords[[i, a]] = gamma[a];
1707            }
1708        }
1709        coords
1710    }
1711
1712    /// FIX B equivalence: the hoisted `penalized_log_evidence` and the shared-
1713    /// factorization `factor_coordinates` must equal their naive per-row-rebuild
1714    /// references to ~1e-10 (in fact bit-for-bit — the hoist preserves op order).
1715    #[test]
1716    fn hoisted_gram_matches_naive_per_row_rebuild() {
1717        let n = 200usize;
1718        let p = 5usize;
1719        let rank = 2usize;
1720        let mut seed = 0x243F6A8885A308D3_u64;
1721        let mut lambda = Array2::<f64>::zeros((p, rank));
1722        for i in 0..p {
1723            for k in 0..rank {
1724                lambda[[i, k]] = lcg_normal(&mut seed);
1725            }
1726        }
1727        let mut diagonal = Array1::<f64>::zeros(p);
1728        for j in 0..p {
1729            diagonal[j] = 0.3 + lcg_uniform(&mut seed); // strictly positive
1730        }
1731        let mut row_scale = Array1::<f64>::zeros(n);
1732        for i in 0..n {
1733            row_scale[i] = 0.5 + 1.5 * lcg_uniform(&mut seed); // strictly positive
1734        }
1735        let mut residuals = Array2::<f64>::zeros((n, p));
1736        for i in 0..n {
1737            for j in 0..p {
1738                residuals[[i, j]] = lcg_normal(&mut seed);
1739            }
1740        }
1741
1742        let ev_hoisted =
1743            penalized_log_evidence(residuals.view(), &lambda, &diagonal, &row_scale, rank);
1744        let ev_naive =
1745            naive_penalized_log_evidence(residuals.view(), &lambda, &diagonal, &row_scale, rank);
1746        assert!(
1747            (ev_hoisted - ev_naive).abs() <= 1e-10 * (1.0 + ev_naive.abs()),
1748            "hoisted log-evidence must equal naive rebuild: {ev_hoisted} vs {ev_naive}"
1749        );
1750
1751        let coords_hoisted =
1752            factor_coordinates(&lambda, &diagonal, residuals.view()).expect("coords");
1753        let coords_naive = naive_factor_coordinates(&lambda, &diagonal, residuals.view());
1754        let mut max_abs = 0.0_f64;
1755        for i in 0..n {
1756            for a in 0..rank {
1757                max_abs = max_abs.max((coords_hoisted[[i, a]] - coords_naive[[i, a]]).abs());
1758            }
1759        }
1760        assert!(
1761            max_abs <= 1e-10,
1762            "hoisted factor coordinates must equal naive rebuild; max |Δ| = {max_abs:e}"
1763        );
1764    }
1765
1766    /// FIX A regression: on an uneven-bin synthetic with a KNOWN planted single
1767    /// factor, the low-rank reconstruction ΛΛᵀ + D built from the OCCUPANCY-
1768    /// weighted scale law reconstructs the empirical second moment
1769    /// (1/n) Σ_n r_n r_nᵀ strictly better (Frobenius) than the one built from
1770    /// the bin-UNIFORM scale law. Uses the module's own `scaled_second_moment` /
1771    /// eigen path so it exercises the real (Λ, D | scale) step.
1772    #[test]
1773    fn occupancy_scale_improves_second_moment_reconstruction() {
1774        let n = 4000usize;
1775        let p = 4usize;
1776        let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
1777        let sigma_eps = 0.2_f64;
1778        let slope = 2.0_f64;
1779        let bins = ACTIVITY_SCALE_BINS.max(1);
1780        let mut seed = 0xCA62C1D6_u64 ^ 0x9B05688C_u64;
1781        let mut residuals = Array2::<f64>::zeros((n, p));
1782        let mut activity = Array1::<f64>::zeros(n);
1783        let mut c_true = Array1::<f64>::zeros(n);
1784        for row in 0..n {
1785            let u = (row as f64) / (n as f64 - 1.0);
1786            let z = u * u * u; // cubic warp ⇒ uneven bin occupancy
1787            activity[row] = z;
1788            let c = (slope * z).exp();
1789            c_true[row] = c;
1790            let amp = c.sqrt();
1791            let f = lcg_normal(&mut seed);
1792            for i in 0..p {
1793                residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
1794            }
1795        }
1796
1797        // Empirical (undeflated) second moment T = (1/n) Σ_n r_n r_nᵀ — the
1798        // object the model's ΛΛᵀ + D must reconstruct.
1799        let mut t = Array2::<f64>::zeros((p, p));
1800        for i in 0..n {
1801            for a in 0..p {
1802                for b in 0..p {
1803                    t[[a, b]] += residuals[[i, a]] * residuals[[i, b]];
1804                }
1805            }
1806        }
1807        t.mapv_inplace(|v| v / n as f64);
1808
1809        let raw_diag = column_variances(residuals.view());
1810        let mean_var = raw_diag.iter().sum::<f64>() / p as f64;
1811        let diag_floor = DIAGONAL_REL_FLOOR * mean_var.max(f64::MIN_POSITIVE);
1812
1813        // Per-bin raw scale law: mean of the true c(z) within each bin.
1814        let row_bin = assign_bins(&activity, bins);
1815        let mut bin_sum = vec![0.0_f64; bins];
1816        let mut bin_cnt = vec![0.0_f64; bins];
1817        for i in 0..n {
1818            bin_sum[row_bin[i]] += c_true[i];
1819            bin_cnt[row_bin[i]] += 1.0;
1820        }
1821        let bin_raw: Vec<f64> = (0..bins)
1822            .map(|b| {
1823                if bin_cnt[b] > 0.0 {
1824                    bin_sum[b] / bin_cnt[b]
1825                } else {
1826                    1.0
1827                }
1828            })
1829            .collect();
1830
1831        // Occupancy-weighted mean-1 (Fix A) vs bin-uniform mean-1 (old).
1832        let mean_occ = (0..bins).map(|b| bin_cnt[b] * bin_raw[b]).sum::<f64>() / n as f64;
1833        let occupied: Vec<usize> = (0..bins).filter(|&b| bin_cnt[b] > 0.0).collect();
1834        let mean_uni = occupied.iter().map(|&b| bin_raw[b]).sum::<f64>() / occupied.len() as f64;
1835        let row_scale_occ: Array1<f64> = (0..n).map(|i| bin_raw[row_bin[i]] / mean_occ).collect();
1836        let row_scale_uni: Array1<f64> = (0..n).map(|i| bin_raw[row_bin[i]] / mean_uni).collect();
1837
1838        // One (Λ, D | scale) extraction from the deflated moment, mirroring the
1839        // production first sweep, returning the reconstruction ΛΛᵀ + D.
1840        let extract_recon = |row_scale: &Array1<f64>| -> Array2<f64> {
1841            let s = scaled_second_moment(residuals.view(), row_scale);
1842            let (evals, evecs) = symmetric_eig_ascending(&s).expect("eig");
1843            let mean_diag = raw_diag.iter().map(|&v| v.max(diag_floor)).sum::<f64>() / p as f64;
1844            let col = p - 1;
1845            let amp = (evals[col] - mean_diag).max(0.0).sqrt();
1846            let mut lam = Array1::<f64>::zeros(p);
1847            for j in 0..p {
1848                lam[j] = amp * evecs[[j, col]];
1849            }
1850            let mut recon = Array2::<f64>::zeros((p, p));
1851            for a in 0..p {
1852                for b in 0..p {
1853                    recon[[a, b]] = lam[a] * lam[b];
1854                }
1855            }
1856            for j in 0..p {
1857                let d = (raw_diag[j] - lam[j] * lam[j]).max(diag_floor);
1858                recon[[j, j]] += d;
1859            }
1860            recon
1861        };
1862
1863        let frob = |m: &Array2<f64>| -> f64 {
1864            let mut acc = 0.0_f64;
1865            for a in 0..p {
1866                for b in 0..p {
1867                    let d = m[[a, b]] - t[[a, b]];
1868                    acc += d * d;
1869                }
1870            }
1871            acc.sqrt()
1872        };
1873
1874        let dist_occ = frob(&extract_recon(&row_scale_occ));
1875        let dist_uni = frob(&extract_recon(&row_scale_uni));
1876        assert!(
1877            dist_occ < dist_uni,
1878            "occupancy-weighted reconstruction must beat bin-uniform: \
1879             ‖·‖_F occ = {dist_occ:.6} vs uni = {dist_uni:.6}"
1880        );
1881    }
1882
1883    /// Fit a small structured model on a planted single-factor DGP — shared
1884    /// fixture builder for the producer / damped-metric integration tests.
1885    fn fit_small_model(seed0: u64, lambda0: &Array1<f64>) -> (usize, StructuredResidualModel) {
1886        let n = 300usize;
1887        let p = lambda0.len();
1888        let sigma_eps = 0.25_f64;
1889        let slope = 1.4_f64;
1890        let mut seed = seed0;
1891        let mut residuals = Array2::<f64>::zeros((n, p));
1892        let mut activity = Array1::<f64>::zeros(n);
1893        for row in 0..n {
1894            let u = (row as f64) / (n as f64 - 1.0);
1895            let z = u * u;
1896            activity[row] = z;
1897            let amp = (slope * z).exp().sqrt();
1898            let f = lcg_normal(&mut seed);
1899            for i in 0..p {
1900                residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
1901            }
1902        }
1903        let model = StructuredResidualModel::fit(ResidualFactorInput {
1904            residuals: residuals.view(),
1905            activity: activity.view(),
1906            max_factor_rank: 2,
1907        })
1908        .expect("fit");
1909        (n, model)
1910    }
1911
1912    /// WAVE-2 producer integration (#2021): the WhitenedStructured RowMetric from
1913    /// `row_metric` must deliver the exact Mahalanobis `vᵀ Σ_n^{-1} v` for
1914    /// `Σ_n = c_n·ΛΛᵀ + D` over the fitted occupancy-normalized scale. A
1915    /// deterministic refit reproduces the same metric (the seam `fit_row_metric`
1916    /// relies on).
1917    #[test]
1918    fn row_metric_precision_matches_woodbury_over_fitted_scale() {
1919        let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
1920        let (n, model) = fit_small_model(0x14057B7EF767814F_u64, &lambda0);
1921        let p = 4usize;
1922        assert!(
1923            model.factor_rank() >= 1,
1924            "need a factor for a non-trivial Σ_n"
1925        );
1926
1927        let metric = model.row_metric(n).expect("row_metric");
1928        assert!(
1929            metric.whitens_likelihood(),
1930            "WhitenedStructured metric must whiten the likelihood"
1931        );
1932
1933        let rank = model.factor_rank();
1934        let lam = model.factor();
1935        let diag = model.diagonal();
1936        let v: Array1<f64> = ndarray::array![0.7, -1.3, 0.4, 0.9];
1937
1938        for &row in &[0usize, n / 3, n / 2, n - 1] {
1939            let c = model.row_scale()[row];
1940            let mut sigma = Array2::<f64>::zeros((p, p));
1941            for a in 0..p {
1942                for b in 0..p {
1943                    let mut fac = 0.0_f64;
1944                    for k in 0..rank {
1945                        fac += lam[[a, k]] * lam[[b, k]];
1946                    }
1947                    sigma[[a, b]] = c * fac;
1948                }
1949                sigma[[a, a]] += diag[a];
1950            }
1951            let chol = sigma.cholesky(Side::Lower).expect("Σ_n PD");
1952            let x = chol.solvevec(&v);
1953            let mahal_dense: f64 = v.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
1954            let mahal_metric = metric.quad_form(row, v.view());
1955            assert!(
1956                (mahal_dense - mahal_metric).abs() <= 1e-8 * (1.0 + mahal_dense.abs()),
1957                "row {row}: metric quad_form {mahal_metric} must equal dense vᵀΣ⁻¹v {mahal_dense}"
1958            );
1959        }
1960
1961        // Deterministic refit reproduces the identical metric (fit_row_metric seam).
1962        let (n2, model2) = fit_small_model(0x14057B7EF767814F_u64, &lambda0);
1963        assert_eq!(n2, n);
1964        let metric_again = model2.row_metric(n2).expect("row_metric again");
1965        for &row in &[0usize, n / 2, n - 1] {
1966            let q1 = metric.quad_form(row, v.view());
1967            let q2 = metric_again.quad_form(row, v.view());
1968            assert!(
1969                (q1 - q2).abs() <= 1e-12 * (1.0 + q1.abs()),
1970                "deterministic refit must match at row {row}: {q2} vs {q1}"
1971            );
1972        }
1973    }
1974
1975    /// WAVE-2 #2021 damped metric endpoint contracts: γ=1 ≡ row_metric (ignores
1976    /// prev), γ=0 ≡ prev.row_metric (or Euclidean identity), 0<γ<1 is SPD, and
1977    /// out-of-range / non-finite γ is rejected.
1978    #[test]
1979    fn row_metric_damped_endpoints() {
1980        let lambda_a = ndarray::array![1.5, 1.2, -0.4, 0.3];
1981        let lambda_b = ndarray::array![-0.6, 1.1, 0.9, -1.3];
1982        let (n, model) = fit_small_model(0x51ED270B_u64 ^ 0xF3A5C7D1_u64, &lambda_a);
1983        let (n_prev, prev) = fit_small_model(0x2545F491_u64 ^ 0x4F6CDD1D_u64, &lambda_b);
1984        assert_eq!(n, n_prev);
1985        let p = 4usize;
1986        let v: Array1<f64> = ndarray::array![0.7, -1.3, 0.4, 0.9];
1987
1988        // γ = 1 ⇒ byte-identical to this model's row_metric, regardless of prev.
1989        let base = model.row_metric(n).expect("row_metric");
1990        for prev_opt in [None, Some(&prev)] {
1991            let damped = model
1992                .row_metric_damped(n, 1.0, prev_opt)
1993                .expect("damped γ=1");
1994            for row in [0usize, n / 2, n - 1] {
1995                for i in 0..p {
1996                    for k in 0..p {
1997                        assert_eq!(
1998                            damped.factor_entry(row, i, k),
1999                            base.factor_entry(row, i, k),
2000                            "γ=1 must be byte-identical to row_metric at ({row},{i},{k})"
2001                        );
2002                    }
2003                }
2004            }
2005        }
2006
2007        // γ = 0, prev = None ⇒ the MEASURED-scale identity (#2243 cap #2):
2008        // Σ = φ̂·I with φ̂ the model's own isotropic dispersion, so
2009        // quad_form = ‖v‖² / φ̂ (a unit-I anchor would silently assume unit
2010        // noise and over-penalize clean data).
2011        let ident = model
2012            .row_metric_damped(n, 0.0, None)
2013            .expect("damped γ=0 None");
2014        let phi = model.isotropic_dispersion();
2015        assert!(phi.is_finite() && phi > 0.0, "φ̂ must be positive");
2016        let sumsq: f64 = v.iter().map(|x| x * x).sum();
2017        let expected = sumsq / phi;
2018        for row in [0usize, n / 2, n - 1] {
2019            let q = ident.quad_form(row, v.view());
2020            assert!(
2021                (q - expected).abs() <= 1e-9 * (1.0 + expected),
2022                "γ=0/None must be the measured-scale identity: quad_form {q} vs ‖v‖²/φ̂ {expected}"
2023            );
2024        }
2025
2026        // γ = 0, prev = Some ⇒ byte-identical to prev.row_metric.
2027        let prev_metric = prev.row_metric(n).expect("prev row_metric");
2028        let damped0 = model
2029            .row_metric_damped(n, 0.0, Some(&prev))
2030            .expect("damped γ=0 Some");
2031        for row in [0usize, n / 2, n - 1] {
2032            for i in 0..p {
2033                for k in 0..p {
2034                    assert_eq!(
2035                        damped0.factor_entry(row, i, k),
2036                        prev_metric.factor_entry(row, i, k),
2037                        "γ=0/Some must be byte-identical to prev.row_metric at ({row},{i},{k})"
2038                    );
2039                }
2040            }
2041        }
2042
2043        // 0 < γ < 1 ⇒ valid SPD metric.
2044        let mid = model
2045            .row_metric_damped(n, 0.5, Some(&prev))
2046            .expect("damped γ=0.5");
2047        for row in [0usize, n / 2, n - 1] {
2048            let q = mid.quad_form(row, v.view());
2049            assert!(
2050                q.is_finite() && q > 0.0,
2051                "γ=0.5 metric must be SPD; got {q}"
2052            );
2053        }
2054
2055        // Invalid γ rejected.
2056        assert!(model.row_metric_damped(n, 1.5, None).is_err());
2057        assert!(model.row_metric_damped(n, -0.1, None).is_err());
2058        assert!(model.row_metric_damped(n, f64::NAN, None).is_err());
2059    }
2060
2061    /// WAVE-2 #2021 Λ nursery→promotion: `promotion_candidates` must fire only
2062    /// for a factor that BOTH persists across passes (aligns with the previous
2063    /// model's Λ) AND clears the idiosyncratic-noise energy floor; a fresh
2064    /// orthogonal direction, an over-high energy floor, and `prev = None` all
2065    /// yield no candidates, and out-of-range gates are rejected.
2066    #[test]
2067    fn promotion_candidates_gates_on_persistence_and_energy() {
2068        let lambda_a = ndarray::array![1.5, 1.2, -0.4, 0.3];
2069        let lambda_b = ndarray::array![-0.6, 1.1, 0.9, -1.3];
2070        // Same planted direction across two passes ⇒ persistent.
2071        let (_, prev) = fit_small_model(0xA1B2C3D4_u64 ^ 0x0F0F0F0F_u64, &lambda_a);
2072        let (_, cur) = fit_small_model(0x5566778899AABBCC_u64, &lambda_a);
2073        // A different (well-separated) planted direction ⇒ NOT aligned with cur.
2074        let (_, other) = fit_small_model(0x1122334455667788_u64, &lambda_b);
2075
2076        assert!(prev.factor_rank() >= 1 && cur.factor_rank() >= 1 && other.factor_rank() >= 1);
2077
2078        // Persistent + energetic ⇒ at least one candidate, aligned with the
2079        // planted direction and above the noise floor.
2080        let cands = cur
2081            .promotion_candidates(Some(&prev), 0.9, 1.0)
2082            .expect("promotion_candidates");
2083        assert!(
2084            !cands.is_empty(),
2085            "a persistent, energetic factor must yield a promotion candidate"
2086        );
2087        let top = &cands[0];
2088        assert!(
2089            top.persistence_alignment >= 0.9,
2090            "top candidate must clear the alignment gate; got {}",
2091            top.persistence_alignment
2092        );
2093        // The promoted unit direction must align with the planted (unit) lambda_a.
2094        let la_norm = lambda_a.dot(&lambda_a).sqrt();
2095        let la_unit = lambda_a.mapv(|v| v / la_norm);
2096        let dir_cos = top.direction.dot(&la_unit).abs();
2097        assert!(
2098            dir_cos > 0.9,
2099            "promoted direction must recover the planted factor; |cos| = {dir_cos:.4}"
2100        );
2101        assert!(
2102            (top.direction.dot(&top.direction) - 1.0).abs() < 1e-10,
2103            "promoted direction must be unit-norm"
2104        );
2105        assert!(top.energy > 0.0);
2106
2107        // A fresh, well-separated direction does NOT persist ⇒ no candidate at 0.9.
2108        let cross = cur
2109            .promotion_candidates(Some(&other), 0.9, 1.0)
2110            .expect("promotion_candidates cross");
2111        assert!(
2112            cross.is_empty(),
2113            "a non-persistent (unaligned) factor must not be promoted; got {} candidate(s)",
2114            cross.len()
2115        );
2116
2117        // An over-high energy floor rejects even the persistent factor.
2118        let floored = cur
2119            .promotion_candidates(Some(&prev), 0.9, 1.0e6)
2120            .expect("promotion_candidates floored");
2121        assert!(
2122            floored.is_empty(),
2123            "energy floor must gate out factors below the noise-scaled threshold"
2124        );
2125
2126        // prev = None (first structured pass, damping toward I) ⇒ no candidates.
2127        assert!(cur.promotion_candidates(None, 0.9, 1.0).unwrap().is_empty());
2128
2129        // Invalid gates rejected.
2130        assert!(cur.promotion_candidates(Some(&prev), 1.5, 1.0).is_err());
2131        assert!(cur.promotion_candidates(Some(&prev), -0.1, 1.0).is_err());
2132        assert!(cur.promotion_candidates(Some(&prev), 0.9, -1.0).is_err());
2133        assert!(
2134            cur.promotion_candidates(Some(&prev), f64::NAN, 1.0)
2135                .is_err()
2136        );
2137    }
2138}