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`](gam_problem::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`](gam_problem::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 {
826 Some(pv) => {
827 let cp = pv.row_scale[row].max(f64::MIN_POSITIVE);
828 let pg = prev_gram.as_ref().unwrap();
829 for a in 0..p {
830 for b in 0..p {
831 sigma[[a, b]] += (1.0 - gamma) * cp * pg[[a, b]];
832 }
833 sigma[[a, a]] += (1.0 - gamma) * pv.diagonal[a];
834 }
835 }
836 None => {
837 // First-pass anchor: the MEASURED iid dispersion φ̂·I, not the
838 // unit identity (#2243 cap #2 — clean-data over-penalization).
839 for a in 0..p {
840 sigma[[a, a]] += (1.0 - gamma) * iid_anchor;
841 }
842 }
843 }
844 // Symmetrize against round-off before inversion.
845 for a in 0..p {
846 for b in (a + 1)..p {
847 let avg = 0.5 * (sigma[[a, b]] + sigma[[b, a]]);
848 sigma[[a, b]] = avg;
849 sigma[[b, a]] = avg;
850 }
851 }
852 // Σ_t is a convex combination of SPD matrices (D ≻ 0 / I ≻ 0) ⇒ SPD.
853 // Precision = Σ_t^{-1} via a Cholesky solve against I_p, then the U_n
854 // factor is the lower-Cholesky of the precision (row_metric's U
855 // convention).
856 let precision = invert_spd(&sigma)?;
857 let factor = lower_cholesky_psd(&precision)?;
858 for i in 0..p {
859 for k in 0..p {
860 urow[i * p + k] = factor[[i, k]];
861 }
862 }
863 Ok(())
864 }
865
866 /// Per-row precision `Σ_n^{-1}` via the Woodbury identity (an `r × r` solve),
867 /// given the row-independent parts precomputed by [`Self::row_metric`]:
868 /// `d_inv = D^{-1}`, `b = D^{-1}Λ`, `bt = Bᵀ`, and the Gram `m0 = ΛᵀD^{-1}Λ`.
869 /// Only the per-row capacitance `M_n = m0 + c_n^{-1} I_r` and the back-solve
870 /// depend on the row.
871 fn row_precision(
872 &self,
873 d_inv: &[f64],
874 b: &Array2<f64>,
875 bt: &Array2<f64>,
876 m0: &Array2<f64>,
877 row: usize,
878 ) -> Result<Array2<f64>, String> {
879 let p = self.p;
880 let r = self.factor_rank;
881 // Start from D^{-1}.
882 let mut precision = Array2::<f64>::zeros((p, p));
883 for i in 0..p {
884 precision[[i, i]] = d_inv[i];
885 }
886 if r == 0 {
887 return Ok(precision);
888 }
889 let c = self.row_scale[row].max(f64::MIN_POSITIVE);
890 // Per-row capacitance M_n = M0 + c^{-1} I_r (copy the hoisted Gram, then
891 // add c^{-1} to the diagonal). M_n ≻ 0 since c^{-1} > 0 and M0 ⪰ 0.
892 let mut cap = m0.clone();
893 for a in 0..r {
894 cap[[a, a]] += 1.0 / c;
895 }
896 // Σ_n^{-1} = D^{-1} − B M_n^{-1} Bᵀ. Solve M_n X = Bᵀ for X = M_n^{-1} Bᵀ
897 // (r × p) via Cholesky.
898 let chol = cap
899 .cholesky(Side::Lower)
900 .map_err(|e| format!("StructuredResidualModel::row_precision capacitance: {e:?}"))?;
901 let x = chol.solve_mat(bt); // r × p
902 for i in 0..p {
903 for j in 0..p {
904 let mut acc = 0.0_f64;
905 for k in 0..r {
906 acc += b[[i, k]] * x[[k, j]];
907 }
908 precision[[i, j]] -= acc;
909 }
910 }
911 // Symmetrize against round-off so the Cholesky downstream sees an exactly
912 // symmetric PSD matrix.
913 for i in 0..p {
914 for j in (i + 1)..p {
915 let avg = 0.5 * (precision[[i, j]] + precision[[j, i]]);
916 precision[[i, j]] = avg;
917 precision[[j, i]] = avg;
918 }
919 }
920 Ok(precision)
921 }
922}
923
924/// Outer product `Λ Λᵀ ∈ ℝ^{p×p}` of a factor matrix `Λ ∈ ℝ^{p×r}` — the
925/// row-independent factor covariance the per-row activity scale multiplies.
926/// Used by [`StructuredResidualModel::row_metric_damped`] to hoist the Gram out
927/// of its per-row covariance-blend loop.
928fn outer_product(lambda: &Array2<f64>) -> Array2<f64> {
929 let p = lambda.nrows();
930 let r = lambda.ncols();
931 let mut g = Array2::<f64>::zeros((p, p));
932 for a in 0..p {
933 for b in 0..p {
934 let mut acc = 0.0_f64;
935 for k in 0..r {
936 acc += lambda[[a, k]] * lambda[[b, k]];
937 }
938 g[[a, b]] = acc;
939 }
940 }
941 g
942}
943
944/// Inverse of a symmetric positive-definite matrix via a Cholesky solve against
945/// the identity, symmetrized against round-off. Used to form `Σ_t^{-1}` from a
946/// densely-blended covariance in [`StructuredResidualModel::row_metric_damped`]
947/// (the blended covariance is no longer low-rank-plus-diagonal, so Woodbury does
948/// not apply).
949fn invert_spd(a: &Array2<f64>) -> Result<Array2<f64>, String> {
950 let p = a.nrows();
951 let chol = a
952 .cholesky(Side::Lower)
953 .map_err(|e| format!("invert_spd: blended covariance not SPD: {e:?}"))?;
954 let mut inv = chol.solve_mat(&Array2::<f64>::eye(p));
955 for i in 0..p {
956 for j in (i + 1)..p {
957 let avg = 0.5 * (inv[[i, j]] + inv[[j, i]]);
958 inv[[i, j]] = avg;
959 inv[[j, i]] = avg;
960 }
961 }
962 Ok(inv)
963}
964
965/// Per-channel (column) sample second moment of the residual matrix.
966fn column_variances(r: ArrayView2<'_, f64>) -> Array1<f64> {
967 let n = r.nrows();
968 let p = r.ncols();
969 let mut v = Array1::<f64>::zeros(p);
970 for j in 0..p {
971 let mut acc = 0.0_f64;
972 for i in 0..n {
973 acc += r[[i, j]] * r[[i, j]];
974 }
975 v[j] = acc / n as f64;
976 }
977 v
978}
979
980/// Scale-deflated second moment `S = (1/n) Σ_n (r_n r_nᵀ) / c_n`.
981/// Per-row-chunk contribution to the scaled second moment — the inner
982/// `p×p` accumulation of one contiguous row block, summed in row order.
983fn scaled_second_moment_chunk(
984 r: ArrayView2<'_, f64>,
985 row_scale: &Array1<f64>,
986 lo: usize,
987 hi: usize,
988) -> Array2<f64> {
989 let p = r.ncols();
990 let mut s = Array2::<f64>::zeros((p, p));
991 for i in lo..hi {
992 let w = 1.0 / row_scale[i].max(f64::MIN_POSITIVE);
993 for a in 0..p {
994 let ra = r[[i, a]];
995 for b in 0..p {
996 s[[a, b]] += w * ra * r[[i, b]];
997 }
998 }
999 }
1000 s
1001}
1002
1003/// `S = (1/n) Σ_n (r_n r_nᵀ) / c(z_n)` — the O(N·p²) scale-deflated second moment
1004/// that dominates each alternation sweep of the residual-factor fit.
1005///
1006/// Parallelized over FIXED contiguous row chunks whose `p×p` partials are summed
1007/// in CHUNK ORDER, so the result is bit-reproducible and independent of the thread
1008/// count (the estimator's determinism contract holds) — it differs from a single
1009/// running row-sum only in the harmless grouping of accumulation round-off, which
1010/// the trailing symmetrization already absorbs. Engaged only above a row threshold
1011/// (the serial path stays exact on small inputs and avoids rayon overhead) and only
1012/// when NOT already inside a rayon worker (the same nesting discipline the
1013/// Arrow-Schur solve uses — a nested call runs the serial reduction so an outer
1014/// parallel region keeps its cores).
1015fn scaled_second_moment(r: ArrayView2<'_, f64>, row_scale: &Array1<f64>) -> Array2<f64> {
1016 use rayon::prelude::*;
1017 const PARALLEL_ROW_MIN: usize = 8192;
1018 const CHUNK_ROWS: usize = 2048;
1019 let n = r.nrows();
1020 let p = r.ncols();
1021
1022 let mut s = if n >= PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
1023 let n_chunks = n.div_ceil(CHUNK_ROWS);
1024 let partials: Vec<Array2<f64>> = (0..n_chunks)
1025 .into_par_iter()
1026 .map(|c| {
1027 let lo = c * CHUNK_ROWS;
1028 let hi = ((c + 1) * CHUNK_ROWS).min(n);
1029 scaled_second_moment_chunk(r, row_scale, lo, hi)
1030 })
1031 .collect();
1032 let mut acc = Array2::<f64>::zeros((p, p));
1033 for part in &partials {
1034 acc += part;
1035 }
1036 acc
1037 } else {
1038 scaled_second_moment_chunk(r, row_scale, 0, n)
1039 };
1040
1041 s.mapv_inplace(|v| v / n as f64);
1042 // Symmetrize against accumulation round-off.
1043 for a in 0..p {
1044 for b in (a + 1)..p {
1045 let avg = 0.5 * (s[[a, b]] + s[[b, a]]);
1046 s[[a, b]] = avg;
1047 s[[b, a]] = avg;
1048 }
1049 }
1050 s
1051}
1052
1053/// Factor coordinates `Λ⁺_D r_n` per row: the generalized-least-squares
1054/// projection of each residual onto `range(Λ)` in the `D^{-1}` metric, returned
1055/// as an `n × r` matrix. Solves the `r × r` normal equations
1056/// `(Λᵀ D^{-1} Λ) γ = Λᵀ D^{-1} r_n` per row (shared factorization).
1057fn factor_coordinates(
1058 lambda: &Array2<f64>,
1059 diagonal: &Array1<f64>,
1060 r: ArrayView2<'_, f64>,
1061) -> Result<Array2<f64>, String> {
1062 let p = lambda.nrows();
1063 let rank = lambda.ncols();
1064 let n = r.nrows();
1065 // GLS weights 1/D_ii, with zero-variance channels DROPPED (weight 0): a
1066 // channel whose residual is identically zero carries no factor information,
1067 // and its 1/0 = ∞ weight poisons the whole normal matrix into NaN — the
1068 // fully-explained-target abort (Cholesky NonPositivePivot) that killed
1069 // stagewise runs on targets the dictionary explains exactly. Dropping the
1070 // channel is the pseudo-inverse limit; with every channel degenerate the
1071 // ridged normal matrix stays PD and the coordinates are the least-norm 0.
1072 let d_inv: Vec<f64> = (0..p)
1073 .map(|i| {
1074 let d = diagonal[i];
1075 if !(d > 0.0 && d.is_finite()) {
1076 return 0.0;
1077 }
1078 // A subnormal-floored variance (the zero-residual case floors the
1079 // scale reference at f64::MIN_POSITIVE) passes `d > 0` but its
1080 // reciprocal OVERFLOWS to ∞ — the same NaN poisoning through the
1081 // second door. A non-finite weight is the same degenerate-channel
1082 // verdict: drop it.
1083 let w = d.recip();
1084 if w.is_finite() { w } else { 0.0 }
1085 })
1086 .collect();
1087 // Normal matrix ΛᵀD^{-1}Λ (+ tiny ridge for invertibility).
1088 let mut normal = Array2::<f64>::zeros((rank, rank));
1089 for a in 0..rank {
1090 for b in 0..rank {
1091 let mut acc = 0.0_f64;
1092 for i in 0..p {
1093 acc += lambda[[i, a]] * d_inv[i] * lambda[[i, b]];
1094 }
1095 normal[[a, b]] = acc;
1096 }
1097 }
1098 let trace = (0..rank).map(|k| normal[[k, k]]).sum::<f64>().max(1.0);
1099 let ridge = 1e-10 * trace / rank.max(1) as f64;
1100 for k in 0..rank {
1101 normal[[k, k]] += ridge;
1102 }
1103 let chol = normal
1104 .cholesky(Side::Lower)
1105 .map_err(|e| format!("factor_coordinates normal solve: {e:?}"))?;
1106 let mut coords = Array2::<f64>::zeros((n, rank));
1107 let mut rhs = Array1::<f64>::zeros(rank);
1108 for i in 0..n {
1109 for a in 0..rank {
1110 let mut acc = 0.0_f64;
1111 for j in 0..p {
1112 acc += lambda[[j, a]] * d_inv[j] * r[[i, j]];
1113 }
1114 rhs[a] = acc;
1115 }
1116 let gamma = chol.solvevec(&rhs);
1117 for a in 0..rank {
1118 coords[[i, a]] = gamma[a];
1119 }
1120 }
1121 Ok(coords)
1122}
1123
1124/// 3-point moving average over a bin vector (edge-clamped), giving the smooth
1125/// activity-scale law a continuous, low-curvature shape.
1126fn moving_average_3(v: &Array1<f64>) -> Array1<f64> {
1127 let m = v.len();
1128 let mut out = Array1::<f64>::zeros(m);
1129 for i in 0..m {
1130 let lo = i.saturating_sub(1);
1131 let hi = (i + 1).min(m - 1);
1132 let mut acc = 0.0_f64;
1133 let mut cnt = 0.0_f64;
1134 for j in lo..=hi {
1135 acc += v[j];
1136 cnt += 1.0;
1137 }
1138 out[i] = acc / cnt;
1139 }
1140 out
1141}
1142
1143/// Ascending-eigenvalue symmetric eigendecomposition (faer convention).
1144fn symmetric_eig_ascending(m: &Array2<f64>) -> Result<(Array1<f64>, Array2<f64>), String> {
1145 m.eigh(Side::Lower)
1146 .map_err(|e| format!("symmetric_eig: {e:?}"))
1147}
1148
1149/// Lower-triangular Cholesky factor `L` of a (numerically) PSD matrix `A` with
1150/// `L Lᵀ = A`, with a relative spectral floor so a marginally-indefinite
1151/// precision (round-off) still factors. Used to turn `Σ_n^{-1}` into the
1152/// `RowMetric` factor `U_n` (here `U_n = L`).
1153fn lower_cholesky_psd(a: &Array2<f64>) -> Result<Array2<f64>, String> {
1154 if let Ok(chol) = a.cholesky(Side::Lower) {
1155 return Ok(chol.lower_triangular());
1156 }
1157 // Eigen-repair: clamp eigenvalues to a small positive floor, rebuild the
1158 // REPAIRED matrix Q·diag(λ_clamped)·Qᵀ itself, and Cholesky that (always
1159 // succeeds, PD). The returned factor must satisfy L·Lᵀ = A_repaired —
1160 // rebuilding the symmetric square root here and factoring THAT would hand
1161 // callers a factor with L·Lᵀ = A^{1/2}, silently taking every whitened
1162 // quadratic form against the square root of the intended precision.
1163 let (evals, evecs) = symmetric_eig_ascending(a)?;
1164 let max_ev = evals.iter().copied().fold(0.0_f64, f64::max).max(1.0);
1165 let floor = 1e-10 * max_ev;
1166 let p = a.nrows();
1167 let mut repaired = Array2::<f64>::zeros((p, p));
1168 for i in 0..p {
1169 for j in 0..p {
1170 let mut acc = 0.0_f64;
1171 for k in 0..p {
1172 let ev = evals[k].max(floor);
1173 acc += evecs[[i, k]] * ev * evecs[[j, k]];
1174 }
1175 repaired[[i, j]] = acc;
1176 }
1177 }
1178 repaired
1179 .cholesky(Side::Lower)
1180 .map(|c| c.lower_triangular())
1181 .map_err(|e| format!("lower_cholesky_psd eigen-repair: {e:?}"))
1182}
1183
1184/// Penalized Gaussian log-evidence of the structured model at the fitted
1185/// parameters — the evidence ladder's rank-selection score.
1186///
1187/// The per-row log-density of `r_n ~ N(0, Σ_n)` is
1188/// `−½ ( log|Σ_n| + r_nᵀ Σ_n^{-1} r_n + p log 2π )`. We sum it across rows and
1189/// subtract a parameter-count penalty `½ k_params · log n` (a BIC-style Occam
1190/// term over the `p·r` factor entries + `p` diagonal entries + the bin scales),
1191/// so adding a spurious factor that does not improve the fit is rejected. Both
1192/// `log|Σ_n|` and the quadratic use the Woodbury / matrix-determinant lemma so no
1193/// dense `p × p` inverse or determinant is formed.
1194fn penalized_log_evidence(
1195 r: ArrayView2<'_, f64>,
1196 lambda: &Array2<f64>,
1197 diagonal: &Array1<f64>,
1198 row_scale: &Array1<f64>,
1199 rank: usize,
1200) -> f64 {
1201 let n = r.nrows();
1202 let p = r.ncols();
1203 let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
1204 let log_det_d: f64 = diagonal.iter().map(|&d| d.ln()).sum();
1205 let two_pi_ln = (2.0 * std::f64::consts::PI).ln();
1206
1207 // Row-INDEPENDENT Gram M0 = ΛᵀD^{-1}Λ (r × r). This does not depend on the
1208 // row, so build it ONCE here rather than rebuilding it inside the per-row loop
1209 // (which was O(n·p·r²)). The per-row capacitance is only a scalar-diagonal
1210 // reweight of this SAME M0 — M_n = M0 + (1/c_n) I_r — so each row copies M0 and
1211 // adds 1/c_n to its diagonal (cheap, O(r)) before its own Cholesky. The
1212 // summation order over j (0..p) is preserved exactly and the diagonal add is
1213 // the identical `+= 1.0 / c` op, so the hoist is bit-for-bit identical to the
1214 // pre-hoist per-row rebuild (same log|Σ_n|, same quadratic, same evidence).
1215 let mut m0 = Array2::<f64>::zeros((rank, rank));
1216 if rank > 0 {
1217 for a in 0..rank {
1218 for b in 0..rank {
1219 let mut acc = 0.0_f64;
1220 for j in 0..p {
1221 acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
1222 }
1223 m0[[a, b]] = acc;
1224 }
1225 }
1226 }
1227
1228 let mut log_lik = 0.0_f64;
1229 for i in 0..n {
1230 let c = row_scale[i].max(f64::MIN_POSITIVE);
1231 // Quadratic r_nᵀ Σ_n^{-1} r_n via Woodbury:
1232 // r_nᵀ D^{-1} r_n − (Bᵀ r_n)ᵀ M^{-1} (Bᵀ r_n),
1233 // with B = D^{-1}Λ and M = c^{-1}I + ΛᵀD^{-1}Λ.
1234 let mut quad = 0.0_f64;
1235 for j in 0..p {
1236 quad += r[[i, j]] * d_inv[j] * r[[i, j]];
1237 }
1238 let mut log_det = log_det_d;
1239 if rank > 0 {
1240 // Per-row capacitance M_n = M0 + (1/c) I_r (copy the hoisted M0, then
1241 // add 1/c to the diagonal), and w = Bᵀ r_n = ΛᵀD^{-1} r_n.
1242 let mut m = m0.clone();
1243 for a in 0..rank {
1244 m[[a, a]] += 1.0 / c;
1245 }
1246 let mut w = Array1::<f64>::zeros(rank);
1247 for a in 0..rank {
1248 let mut wa = 0.0_f64;
1249 for j in 0..p {
1250 wa += lambda[[j, a]] * d_inv[j] * r[[i, j]];
1251 }
1252 w[a] = wa;
1253 }
1254 // Cholesky M = R Rᵀ → log|M|, and solve M y = w.
1255 match m.cholesky(Side::Lower) {
1256 Ok(chol) => {
1257 let y = chol.solvevec(&w);
1258 let mut wy = 0.0_f64;
1259 for a in 0..rank {
1260 wy += w[a] * y[a];
1261 }
1262 quad -= wy;
1263 // log|Σ_n| = log|D| + log|M| + r·log c (matrix-determinant
1264 // lemma; the c^{-1}I shift carries the +r·log c).
1265 let diag = chol.diag();
1266 let log_det_m: f64 = diag.iter().map(|&l| (l * l).ln()).sum();
1267 log_det = log_det_d + log_det_m + rank as f64 * c.ln();
1268 }
1269 Err(_) => {
1270 // Degenerate capacitance — fall back to the diagonal model's
1271 // accounting for this row (no factor correction).
1272 log_det = log_det_d;
1273 }
1274 }
1275 }
1276 log_lik += -0.5 * (log_det + quad + p as f64 * two_pi_ln);
1277 }
1278
1279 let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
1280 log_lik - 0.5 * k_params * (n.max(2) as f64).ln()
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use super::*;
1286 use ndarray::{Array1, Array2};
1287
1288 fn lcg_uniform(state: &mut u64) -> f64 {
1289 *state = state
1290 .wrapping_mul(6364136223846793005)
1291 .wrapping_add(1442695040888963407);
1292 ((*state >> 11) as f64) / ((1u64 << 53) as f64)
1293 }
1294
1295 fn lcg_normal(state: &mut u64) -> f64 {
1296 let u1 = lcg_uniform(state).max(1e-12);
1297 let u2 = lcg_uniform(state);
1298 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
1299 }
1300
1301 /// Per-rank evidence breakdown on the planted single-factor activity-law
1302 /// DGP (the `fitted_scale_recovers_planted_activity_law` plant). Pins the
1303 /// rank-selection decision itself: the ladder must prefer rank 1, and this
1304 /// test names the margin so an over-selection regression is diagnosable
1305 /// from the failure message alone.
1306 #[test]
1307 fn evidence_ladder_prefers_planted_rank_one() {
1308 let n = 5000usize;
1309 let p = 4usize;
1310 let lambda0 = ndarray::array![[1.5], [1.2], [-0.4], [0.3]];
1311 let sigma_eps = 0.2_f64;
1312 let slope = 1.3_f64;
1313 let mut seed = 0xD1B54A32D192ED03_u64;
1314 let mut residuals = Array2::<f64>::zeros((n, p));
1315 let mut activity = Array1::<f64>::zeros(n);
1316 for row in 0..n {
1317 let z = (row as f64) / (n as f64 - 1.0);
1318 activity[row] = z;
1319 let amp = (slope * z).exp().sqrt();
1320 let f = lcg_normal(&mut seed);
1321 for i in 0..p {
1322 residuals[[row, i]] = amp * lambda0[[i, 0]] * f + sigma_eps * lcg_normal(&mut seed);
1323 }
1324 }
1325 // Reproduce fit()'s bin assignment, then score each rank directly.
1326 let bins = ACTIVITY_SCALE_BINS.max(1);
1327 let row_bin: Vec<usize> = (0..n)
1328 .map(|i| {
1329 let frac = activity[i];
1330 (frac * bins as f64).floor().clamp(0.0, bins as f64 - 1.0) as usize
1331 })
1332 .collect();
1333 let mut report = String::new();
1334 let mut ev = Vec::new();
1335 for rank in 0..=2usize {
1336 let m = StructuredResidualModel::fit_fixed_rank(residuals.view(), &row_bin, bins, rank)
1337 .expect("fixed-rank fit");
1338 let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
1339 let log_lik = m.log_evidence() + 0.5 * k_params * (n as f64).ln();
1340 let col_norms: Vec<f64> = (0..rank)
1341 .map(|k| {
1342 m.factor()
1343 .column(k)
1344 .iter()
1345 .map(|v| v * v)
1346 .sum::<f64>()
1347 .sqrt()
1348 })
1349 .collect();
1350 report.push_str(&format!(
1351 "rank {rank}: evidence={:.3} loglik={:.3} penalty={:.3} col_norms={:?} diag={:?}\n",
1352 m.log_evidence(),
1353 log_lik,
1354 0.5 * k_params * (n as f64).ln(),
1355 col_norms,
1356 m.diagonal()
1357 .iter()
1358 .map(|v| (v * 1e4).round() / 1e4)
1359 .collect::<Vec<_>>()
1360 ));
1361 ev.push(m.log_evidence());
1362 }
1363 assert!(
1364 ev[1] > ev[0] && ev[1] > ev[2],
1365 "evidence ladder must prefer the planted rank 1; breakdown:\n{report}"
1366 );
1367 }
1368
1369 /// Orthonormalize the columns of `m` (modified Gram–Schmidt), dropping
1370 /// numerically-null columns. Test-side helper for subspace comparisons.
1371 fn orthonormal_columns(m: ArrayView2<'_, f64>) -> Vec<Array1<f64>> {
1372 let mut basis: Vec<Array1<f64>> = Vec::new();
1373 for k in 0..m.ncols() {
1374 let mut v = m.column(k).to_owned();
1375 for q in &basis {
1376 let c = v.dot(q);
1377 v = &v - &(q * c);
1378 }
1379 let norm = v.dot(&v).sqrt();
1380 if norm > 1e-10 {
1381 basis.push(v / norm);
1382 }
1383 }
1384 basis
1385 }
1386
1387 /// Squared norm of the projection of unit vector `v` onto span(basis) —
1388 /// `cos²` of the principal angle between `v` and the subspace.
1389 fn projection_energy(v: &Array1<f64>, basis: &[Array1<f64>]) -> f64 {
1390 basis.iter().map(|q| v.dot(q).powi(2)).sum()
1391 }
1392
1393 /// #974 verification arm (a): the fitted factor must recover the PLANTED
1394 /// interference subspace. Two orthogonal planted directions with distinct
1395 /// strengths; the principal angles between each planted direction and
1396 /// range(Λ̂) must be small, and the evidence ladder must select rank 2.
1397 #[test]
1398 fn factor_recovers_planted_interference_subspace() {
1399 let n = 6000usize;
1400 let p = 6usize;
1401 // Two orthogonal planted unit directions.
1402 let raw1: Array1<f64> = ndarray::array![1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1403 let raw2: Array1<f64> = ndarray::array![1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
1404 let v1 = &raw1 / raw1.dot(&raw1).sqrt();
1405 let v2 = &raw2 / raw2.dot(&raw2).sqrt();
1406 let (amp1, amp2) = (1.4_f64, 0.9_f64);
1407 let sigma_eps = 0.15_f64;
1408
1409 let mut seed = 0x9E3779B97F4A7C15_u64;
1410 let mut residuals = Array2::<f64>::zeros((n, p));
1411 let activity = Array1::<f64>::zeros(n); // constant ⇒ homoscedastic law
1412 for row in 0..n {
1413 let f1 = amp1 * lcg_normal(&mut seed);
1414 let f2 = amp2 * lcg_normal(&mut seed);
1415 for i in 0..p {
1416 residuals[[row, i]] = f1 * v1[i] + f2 * v2[i] + sigma_eps * lcg_normal(&mut seed);
1417 }
1418 }
1419
1420 let model = StructuredResidualModel::fit(ResidualFactorInput {
1421 residuals: residuals.view(),
1422 activity: activity.view(),
1423 max_factor_rank: 4,
1424 })
1425 .expect("fit");
1426
1427 assert_eq!(
1428 model.factor_rank(),
1429 2,
1430 "ladder must select the planted rank 2 (got {}, evidence {:.3})",
1431 model.factor_rank(),
1432 model.log_evidence()
1433 );
1434 let basis = orthonormal_columns(model.factor());
1435 assert_eq!(basis.len(), 2, "fitted factor must span 2 directions");
1436 let e1 = projection_energy(&v1, &basis);
1437 let e2 = projection_energy(&v2, &basis);
1438 // cos² of each principal angle ≥ 0.95 ⇒ angle ≤ ~13°.
1439 assert!(
1440 e1 > 0.95 && e2 > 0.95,
1441 "planted directions must lie in range(Λ̂): cos² = ({e1:.4}, {e2:.4})"
1442 );
1443 }
1444
1445 /// #974 verification arm (d): recovery of the planted activity-variance
1446 /// law. Single planted factor with per-row energy `exp(slope·z)`; the
1447 /// fitted `c(z_n)` must reproduce the law's shape — strongly correlated
1448 /// with the planted log-scale and with the right dynamic range.
1449 #[test]
1450 fn fitted_scale_recovers_planted_activity_law() {
1451 let n = 6000usize;
1452 let p = 4usize;
1453 let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
1454 let sigma_eps = 0.2_f64;
1455 let slope = 1.3_f64;
1456 let mut seed = 0xD1B54A32D192ED03_u64;
1457 let mut residuals = Array2::<f64>::zeros((n, p));
1458 let mut activity = Array1::<f64>::zeros(n);
1459 for row in 0..n {
1460 let z = (row as f64) / (n as f64 - 1.0);
1461 activity[row] = z;
1462 let amp = (slope * z).exp().sqrt();
1463 let f = lcg_normal(&mut seed);
1464 for i in 0..p {
1465 residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
1466 }
1467 }
1468
1469 let model = StructuredResidualModel::fit(ResidualFactorInput {
1470 residuals: residuals.view(),
1471 activity: activity.view(),
1472 max_factor_rank: 2,
1473 })
1474 .expect("fit");
1475 assert_eq!(model.factor_rank(), 1, "planted rank is 1");
1476
1477 // Pearson correlation between fitted log c(z_n) and the planted
1478 // log-law slope·z (mean-1 normalization cancels in the correlation).
1479 let fitted_log: Vec<f64> = model.row_scale().iter().map(|c| c.ln()).collect();
1480 let planted_log: Vec<f64> = activity.iter().map(|z| slope * z).collect();
1481 let mean_f = fitted_log.iter().sum::<f64>() / n as f64;
1482 let mean_p = planted_log.iter().sum::<f64>() / n as f64;
1483 let mut cov = 0.0_f64;
1484 let mut var_f = 0.0_f64;
1485 let mut var_p = 0.0_f64;
1486 for i in 0..n {
1487 let df = fitted_log[i] - mean_f;
1488 let dp = planted_log[i] - mean_p;
1489 cov += df * dp;
1490 var_f += df * df;
1491 var_p += dp * dp;
1492 }
1493 let corr = cov / (var_f.sqrt() * var_p.sqrt());
1494 assert!(
1495 corr > 0.9,
1496 "fitted activity law must track the planted exp({slope}·z): corr = {corr:.4}"
1497 );
1498
1499 // Dynamic range: planted c(top)/c(bottom) over the inner bin centers
1500 // is exp(slope·7/8) ≈ 3.1; the binned/smoothed estimate must land in
1501 // a generous bracket around it (smoothing shrinks the edges).
1502 let lo = model.row_scale()[n / 16]; // first-bin interior
1503 let hi = model.row_scale()[n - 1 - n / 16]; // last-bin interior
1504 let ratio = hi / lo;
1505 assert!(
1506 ratio > 1.8 && ratio < 5.5,
1507 "fitted dynamic range {ratio:.3} must bracket the planted ≈3.1"
1508 );
1509 }
1510
1511 /// Reproduce `fit`'s equal-width bin assignment for a test activity vector.
1512 fn assign_bins(activity: &Array1<f64>, bins: usize) -> Vec<usize> {
1513 let n = activity.len();
1514 let z_min = activity.iter().copied().fold(f64::INFINITY, f64::min);
1515 let z_max = activity.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1516 let span = z_max - z_min;
1517 (0..n)
1518 .map(|i| {
1519 if span <= 0.0 {
1520 0
1521 } else {
1522 let frac = (activity[i] - z_min) / span;
1523 (frac * bins as f64).floor().clamp(0.0, bins as f64 - 1.0) as usize
1524 }
1525 })
1526 .collect()
1527 }
1528
1529 /// FIX A invariant: with deliberately UNEVEN bin occupancy the fitted
1530 /// per-row scale must have `(1/n) Σ_i row_scale[i] = 1` (occupancy-weighted
1531 /// mean-1), NOT the bin-uniform mean-1 the old code enforced. We assert the
1532 /// row-mean is 1 to tight tolerance, and that the bin-UNIFORM mean of the
1533 /// distinct per-bin scales is materially ≠ 1 — which is exactly the quantity
1534 /// the old normalization forced to 1, so this proves the two means differ
1535 /// under uneven occupancy and the test bites.
1536 #[test]
1537 fn occupancy_weighted_scale_has_row_mean_one() {
1538 let n = 4000usize;
1539 let p = 4usize;
1540 let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
1541 let sigma_eps = 0.2_f64;
1542 let slope = 2.0_f64;
1543 let mut seed = 0xB5297A4D_u64 ^ 0x68E31DA4_u64;
1544 let mut residuals = Array2::<f64>::zeros((n, p));
1545 let mut activity = Array1::<f64>::zeros(n);
1546 for row in 0..n {
1547 // Cubic warp concentrates rows in the low-z bins ⇒ uneven occupancy.
1548 let u = (row as f64) / (n as f64 - 1.0);
1549 let z = u * u * u;
1550 activity[row] = z;
1551 let amp = (slope * z).exp().sqrt();
1552 let f = lcg_normal(&mut seed);
1553 for i in 0..p {
1554 residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
1555 }
1556 }
1557
1558 let model = StructuredResidualModel::fit(ResidualFactorInput {
1559 residuals: residuals.view(),
1560 activity: activity.view(),
1561 max_factor_rank: 2,
1562 })
1563 .expect("fit");
1564 assert!(
1565 model.factor_rank() >= 1,
1566 "need a non-trivial scale law (rank ≥ 1) for this invariant to bite; got rank 0"
1567 );
1568
1569 // Occupancy-weighted (row) mean must be exactly 1.
1570 let row_mean = model.row_scale().iter().sum::<f64>() / n as f64;
1571 assert!(
1572 (row_mean - 1.0).abs() < 1e-9,
1573 "occupancy-weighted row mean of c(z) must be 1; got {row_mean:.12}"
1574 );
1575
1576 // Bins are genuinely unevenly occupied, and every occupied bin's rows
1577 // share one scale value (collect the distinct value per bin).
1578 let bins = ACTIVITY_SCALE_BINS.max(1);
1579 let row_bin = assign_bins(&activity, bins);
1580 let mut counts = vec![0usize; bins];
1581 let mut bin_val = vec![f64::NAN; bins];
1582 for i in 0..n {
1583 let b = row_bin[i];
1584 counts[b] += 1;
1585 bin_val[b] = model.row_scale()[i];
1586 }
1587 let occupied: Vec<usize> = (0..bins).filter(|&b| counts[b] > 0).collect();
1588 let max_c = *counts.iter().max().unwrap();
1589 let min_c = *counts.iter().filter(|&&c| c > 0).min().unwrap();
1590 assert!(
1591 max_c as f64 > 2.0 * min_c as f64,
1592 "fixture must have uneven occupancy; counts = {counts:?}"
1593 );
1594
1595 // The bin-UNIFORM mean of the per-bin scales is what the OLD code forced
1596 // to 1. Under uneven occupancy + a non-constant law it is materially ≠ 1,
1597 // so the old normalization would NOT satisfy the row-mean-1 identity.
1598 let uniform_mean =
1599 occupied.iter().map(|&b| bin_val[b]).sum::<f64>() / occupied.len() as f64;
1600 assert!(
1601 (uniform_mean - 1.0).abs() > 0.1,
1602 "bin-uniform mean must differ from 1 (proving occupancy weighting matters); \
1603 got uniform_mean = {uniform_mean:.6}, row_mean = {row_mean:.6}"
1604 );
1605 }
1606
1607 /// Naive, pre-hoist reference for `penalized_log_evidence`: rebuilds the
1608 /// row-independent Gram M0 = ΛᵀD⁻¹Λ INSIDE the per-row loop (the original
1609 /// formula). The production function hoists M0 out; the two must agree.
1610 fn naive_penalized_log_evidence(
1611 r: ArrayView2<'_, f64>,
1612 lambda: &Array2<f64>,
1613 diagonal: &Array1<f64>,
1614 row_scale: &Array1<f64>,
1615 rank: usize,
1616 ) -> f64 {
1617 let n = r.nrows();
1618 let p = r.ncols();
1619 let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
1620 let log_det_d: f64 = diagonal.iter().map(|&d| d.ln()).sum();
1621 let two_pi_ln = (2.0 * std::f64::consts::PI).ln();
1622 let mut log_lik = 0.0_f64;
1623 for i in 0..n {
1624 let c = row_scale[i].max(f64::MIN_POSITIVE);
1625 let mut quad = 0.0_f64;
1626 for j in 0..p {
1627 quad += r[[i, j]] * d_inv[j] * r[[i, j]];
1628 }
1629 let mut log_det = log_det_d;
1630 if rank > 0 {
1631 let mut m = Array2::<f64>::zeros((rank, rank));
1632 let mut w = Array1::<f64>::zeros(rank);
1633 for a in 0..rank {
1634 let mut wa = 0.0_f64;
1635 for j in 0..p {
1636 wa += lambda[[j, a]] * d_inv[j] * r[[i, j]];
1637 }
1638 w[a] = wa;
1639 for b in 0..rank {
1640 let mut acc = 0.0_f64;
1641 for j in 0..p {
1642 acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
1643 }
1644 m[[a, b]] = acc;
1645 }
1646 m[[a, a]] += 1.0 / c;
1647 }
1648 match m.cholesky(Side::Lower) {
1649 Ok(chol) => {
1650 let y = chol.solvevec(&w);
1651 let mut wy = 0.0_f64;
1652 for a in 0..rank {
1653 wy += w[a] * y[a];
1654 }
1655 quad -= wy;
1656 let diag = chol.diag();
1657 let log_det_m: f64 = diag.iter().map(|&l| (l * l).ln()).sum();
1658 log_det = log_det_d + log_det_m + rank as f64 * c.ln();
1659 }
1660 Err(_) => {
1661 log_det = log_det_d;
1662 }
1663 }
1664 }
1665 log_lik += -0.5 * (log_det + quad + p as f64 * two_pi_ln);
1666 }
1667 let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
1668 log_lik - 0.5 * k_params * (n.max(2) as f64).ln()
1669 }
1670
1671 /// Naive, per-row-rebuild reference for `factor_coordinates`: rebuilds and
1672 /// re-factors the (row-independent) normal matrix ΛᵀD⁻¹Λ for EVERY row.
1673 /// Mathematically identical to the shared-factorization production path.
1674 fn naive_factor_coordinates(
1675 lambda: &Array2<f64>,
1676 diagonal: &Array1<f64>,
1677 r: ArrayView2<'_, f64>,
1678 ) -> Array2<f64> {
1679 let p = lambda.nrows();
1680 let rank = lambda.ncols();
1681 let n = r.nrows();
1682 let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
1683 let mut coords = Array2::<f64>::zeros((n, rank));
1684 for i in 0..n {
1685 let mut normal = Array2::<f64>::zeros((rank, rank));
1686 for a in 0..rank {
1687 for b in 0..rank {
1688 let mut acc = 0.0_f64;
1689 for j in 0..p {
1690 acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
1691 }
1692 normal[[a, b]] = acc;
1693 }
1694 }
1695 let trace = (0..rank).map(|k| normal[[k, k]]).sum::<f64>().max(1.0);
1696 let ridge = 1e-10 * trace / rank.max(1) as f64;
1697 for k in 0..rank {
1698 normal[[k, k]] += ridge;
1699 }
1700 let chol = normal.cholesky(Side::Lower).expect("naive normal solve");
1701 let mut rhs = Array1::<f64>::zeros(rank);
1702 for a in 0..rank {
1703 let mut acc = 0.0_f64;
1704 for j in 0..p {
1705 acc += lambda[[j, a]] * d_inv[j] * r[[i, j]];
1706 }
1707 rhs[a] = acc;
1708 }
1709 let gamma = chol.solvevec(&rhs);
1710 for a in 0..rank {
1711 coords[[i, a]] = gamma[a];
1712 }
1713 }
1714 coords
1715 }
1716
1717 /// FIX B equivalence: the hoisted `penalized_log_evidence` and the shared-
1718 /// factorization `factor_coordinates` must equal their naive per-row-rebuild
1719 /// references to ~1e-10 (in fact bit-for-bit — the hoist preserves op order).
1720 #[test]
1721 fn hoisted_gram_matches_naive_per_row_rebuild() {
1722 let n = 200usize;
1723 let p = 5usize;
1724 let rank = 2usize;
1725 let mut seed = 0x243F6A8885A308D3_u64;
1726 let mut lambda = Array2::<f64>::zeros((p, rank));
1727 for i in 0..p {
1728 for k in 0..rank {
1729 lambda[[i, k]] = lcg_normal(&mut seed);
1730 }
1731 }
1732 let mut diagonal = Array1::<f64>::zeros(p);
1733 for j in 0..p {
1734 diagonal[j] = 0.3 + lcg_uniform(&mut seed); // strictly positive
1735 }
1736 let mut row_scale = Array1::<f64>::zeros(n);
1737 for i in 0..n {
1738 row_scale[i] = 0.5 + 1.5 * lcg_uniform(&mut seed); // strictly positive
1739 }
1740 let mut residuals = Array2::<f64>::zeros((n, p));
1741 for i in 0..n {
1742 for j in 0..p {
1743 residuals[[i, j]] = lcg_normal(&mut seed);
1744 }
1745 }
1746
1747 let ev_hoisted =
1748 penalized_log_evidence(residuals.view(), &lambda, &diagonal, &row_scale, rank);
1749 let ev_naive =
1750 naive_penalized_log_evidence(residuals.view(), &lambda, &diagonal, &row_scale, rank);
1751 assert!(
1752 (ev_hoisted - ev_naive).abs() <= 1e-10 * (1.0 + ev_naive.abs()),
1753 "hoisted log-evidence must equal naive rebuild: {ev_hoisted} vs {ev_naive}"
1754 );
1755
1756 let coords_hoisted =
1757 factor_coordinates(&lambda, &diagonal, residuals.view()).expect("coords");
1758 let coords_naive = naive_factor_coordinates(&lambda, &diagonal, residuals.view());
1759 let mut max_abs = 0.0_f64;
1760 for i in 0..n {
1761 for a in 0..rank {
1762 max_abs = max_abs.max((coords_hoisted[[i, a]] - coords_naive[[i, a]]).abs());
1763 }
1764 }
1765 assert!(
1766 max_abs <= 1e-10,
1767 "hoisted factor coordinates must equal naive rebuild; max |Δ| = {max_abs:e}"
1768 );
1769 }
1770
1771 /// FIX A regression: on an uneven-bin synthetic with a KNOWN planted single
1772 /// factor, the low-rank reconstruction ΛΛᵀ + D built from the OCCUPANCY-
1773 /// weighted scale law reconstructs the empirical second moment
1774 /// (1/n) Σ_n r_n r_nᵀ strictly better (Frobenius) than the one built from
1775 /// the bin-UNIFORM scale law. Uses the module's own `scaled_second_moment` /
1776 /// eigen path so it exercises the real (Λ, D | scale) step.
1777 #[test]
1778 fn occupancy_scale_improves_second_moment_reconstruction() {
1779 let n = 4000usize;
1780 let p = 4usize;
1781 let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
1782 let sigma_eps = 0.2_f64;
1783 let slope = 2.0_f64;
1784 let bins = ACTIVITY_SCALE_BINS.max(1);
1785 let mut seed = 0xCA62C1D6_u64 ^ 0x9B05688C_u64;
1786 let mut residuals = Array2::<f64>::zeros((n, p));
1787 let mut activity = Array1::<f64>::zeros(n);
1788 let mut c_true = Array1::<f64>::zeros(n);
1789 for row in 0..n {
1790 let u = (row as f64) / (n as f64 - 1.0);
1791 let z = u * u * u; // cubic warp ⇒ uneven bin occupancy
1792 activity[row] = z;
1793 let c = (slope * z).exp();
1794 c_true[row] = c;
1795 let amp = c.sqrt();
1796 let f = lcg_normal(&mut seed);
1797 for i in 0..p {
1798 residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
1799 }
1800 }
1801
1802 // Empirical (undeflated) second moment T = (1/n) Σ_n r_n r_nᵀ — the
1803 // object the model's ΛΛᵀ + D must reconstruct.
1804 let mut t = Array2::<f64>::zeros((p, p));
1805 for i in 0..n {
1806 for a in 0..p {
1807 for b in 0..p {
1808 t[[a, b]] += residuals[[i, a]] * residuals[[i, b]];
1809 }
1810 }
1811 }
1812 t.mapv_inplace(|v| v / n as f64);
1813
1814 let raw_diag = column_variances(residuals.view());
1815 let mean_var = raw_diag.iter().sum::<f64>() / p as f64;
1816 let diag_floor = DIAGONAL_REL_FLOOR * mean_var.max(f64::MIN_POSITIVE);
1817
1818 // Per-bin raw scale law: mean of the true c(z) within each bin.
1819 let row_bin = assign_bins(&activity, bins);
1820 let mut bin_sum = vec![0.0_f64; bins];
1821 let mut bin_cnt = vec![0.0_f64; bins];
1822 for i in 0..n {
1823 bin_sum[row_bin[i]] += c_true[i];
1824 bin_cnt[row_bin[i]] += 1.0;
1825 }
1826 let bin_raw: Vec<f64> = (0..bins)
1827 .map(|b| {
1828 if bin_cnt[b] > 0.0 {
1829 bin_sum[b] / bin_cnt[b]
1830 } else {
1831 1.0
1832 }
1833 })
1834 .collect();
1835
1836 // Occupancy-weighted mean-1 (Fix A) vs bin-uniform mean-1 (old).
1837 let mean_occ = (0..bins).map(|b| bin_cnt[b] * bin_raw[b]).sum::<f64>() / n as f64;
1838 let occupied: Vec<usize> = (0..bins).filter(|&b| bin_cnt[b] > 0.0).collect();
1839 let mean_uni = occupied.iter().map(|&b| bin_raw[b]).sum::<f64>() / occupied.len() as f64;
1840 let row_scale_occ: Array1<f64> = (0..n).map(|i| bin_raw[row_bin[i]] / mean_occ).collect();
1841 let row_scale_uni: Array1<f64> = (0..n).map(|i| bin_raw[row_bin[i]] / mean_uni).collect();
1842
1843 // One (Λ, D | scale) extraction from the deflated moment, mirroring the
1844 // production first sweep, returning the reconstruction ΛΛᵀ + D.
1845 let extract_recon = |row_scale: &Array1<f64>| -> Array2<f64> {
1846 let s = scaled_second_moment(residuals.view(), row_scale);
1847 let (evals, evecs) = symmetric_eig_ascending(&s).expect("eig");
1848 let mean_diag = raw_diag.iter().map(|&v| v.max(diag_floor)).sum::<f64>() / p as f64;
1849 let col = p - 1;
1850 let amp = (evals[col] - mean_diag).max(0.0).sqrt();
1851 let mut lam = Array1::<f64>::zeros(p);
1852 for j in 0..p {
1853 lam[j] = amp * evecs[[j, col]];
1854 }
1855 let mut recon = Array2::<f64>::zeros((p, p));
1856 for a in 0..p {
1857 for b in 0..p {
1858 recon[[a, b]] = lam[a] * lam[b];
1859 }
1860 }
1861 for j in 0..p {
1862 let d = (raw_diag[j] - lam[j] * lam[j]).max(diag_floor);
1863 recon[[j, j]] += d;
1864 }
1865 recon
1866 };
1867
1868 let frob = |m: &Array2<f64>| -> f64 {
1869 let mut acc = 0.0_f64;
1870 for a in 0..p {
1871 for b in 0..p {
1872 let d = m[[a, b]] - t[[a, b]];
1873 acc += d * d;
1874 }
1875 }
1876 acc.sqrt()
1877 };
1878
1879 let dist_occ = frob(&extract_recon(&row_scale_occ));
1880 let dist_uni = frob(&extract_recon(&row_scale_uni));
1881 assert!(
1882 dist_occ < dist_uni,
1883 "occupancy-weighted reconstruction must beat bin-uniform: \
1884 ‖·‖_F occ = {dist_occ:.6} vs uni = {dist_uni:.6}"
1885 );
1886 }
1887
1888 /// Fit a small structured model on a planted single-factor DGP — shared
1889 /// fixture builder for the producer / damped-metric integration tests.
1890 fn fit_small_model(seed0: u64, lambda0: &Array1<f64>) -> (usize, StructuredResidualModel) {
1891 let n = 300usize;
1892 let p = lambda0.len();
1893 let sigma_eps = 0.25_f64;
1894 let slope = 1.4_f64;
1895 let mut seed = seed0;
1896 let mut residuals = Array2::<f64>::zeros((n, p));
1897 let mut activity = Array1::<f64>::zeros(n);
1898 for row in 0..n {
1899 let u = (row as f64) / (n as f64 - 1.0);
1900 let z = u * u;
1901 activity[row] = z;
1902 let amp = (slope * z).exp().sqrt();
1903 let f = lcg_normal(&mut seed);
1904 for i in 0..p {
1905 residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
1906 }
1907 }
1908 let model = StructuredResidualModel::fit(ResidualFactorInput {
1909 residuals: residuals.view(),
1910 activity: activity.view(),
1911 max_factor_rank: 2,
1912 })
1913 .expect("fit");
1914 (n, model)
1915 }
1916
1917 /// WAVE-2 producer integration (#2021): the WhitenedStructured RowMetric from
1918 /// `row_metric` must deliver the exact Mahalanobis `vᵀ Σ_n^{-1} v` for
1919 /// `Σ_n = c_n·ΛΛᵀ + D` over the fitted occupancy-normalized scale. A
1920 /// deterministic refit reproduces the same metric (the seam `fit_row_metric`
1921 /// relies on).
1922 #[test]
1923 fn row_metric_precision_matches_woodbury_over_fitted_scale() {
1924 let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
1925 let (n, model) = fit_small_model(0x14057B7EF767814F_u64, &lambda0);
1926 let p = 4usize;
1927 assert!(
1928 model.factor_rank() >= 1,
1929 "need a factor for a non-trivial Σ_n"
1930 );
1931
1932 let metric = model.row_metric(n).expect("row_metric");
1933 assert!(
1934 metric.whitens_likelihood(),
1935 "WhitenedStructured metric must whiten the likelihood"
1936 );
1937
1938 let rank = model.factor_rank();
1939 let lam = model.factor();
1940 let diag = model.diagonal();
1941 let v: Array1<f64> = ndarray::array![0.7, -1.3, 0.4, 0.9];
1942
1943 for &row in &[0usize, n / 3, n / 2, n - 1] {
1944 let c = model.row_scale()[row];
1945 let mut sigma = Array2::<f64>::zeros((p, p));
1946 for a in 0..p {
1947 for b in 0..p {
1948 let mut fac = 0.0_f64;
1949 for k in 0..rank {
1950 fac += lam[[a, k]] * lam[[b, k]];
1951 }
1952 sigma[[a, b]] = c * fac;
1953 }
1954 sigma[[a, a]] += diag[a];
1955 }
1956 let chol = sigma.cholesky(Side::Lower).expect("Σ_n PD");
1957 let x = chol.solvevec(&v);
1958 let mahal_dense: f64 = v.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
1959 let mahal_metric = metric.quad_form(row, v.view());
1960 assert!(
1961 (mahal_dense - mahal_metric).abs() <= 1e-8 * (1.0 + mahal_dense.abs()),
1962 "row {row}: metric quad_form {mahal_metric} must equal dense vᵀΣ⁻¹v {mahal_dense}"
1963 );
1964 }
1965
1966 // Deterministic refit reproduces the identical metric (fit_row_metric seam).
1967 let (n2, model2) = fit_small_model(0x14057B7EF767814F_u64, &lambda0);
1968 assert_eq!(n2, n);
1969 let metric_again = model2.row_metric(n2).expect("row_metric again");
1970 for &row in &[0usize, n / 2, n - 1] {
1971 let q1 = metric.quad_form(row, v.view());
1972 let q2 = metric_again.quad_form(row, v.view());
1973 assert!(
1974 (q1 - q2).abs() <= 1e-12 * (1.0 + q1.abs()),
1975 "deterministic refit must match at row {row}: {q2} vs {q1}"
1976 );
1977 }
1978 }
1979
1980 /// WAVE-2 #2021 damped metric endpoint contracts: γ=1 ≡ row_metric (ignores
1981 /// prev), γ=0 ≡ prev.row_metric (or Euclidean identity), 0<γ<1 is SPD, and
1982 /// out-of-range / non-finite γ is rejected.
1983 #[test]
1984 fn row_metric_damped_endpoints() {
1985 let lambda_a = ndarray::array![1.5, 1.2, -0.4, 0.3];
1986 let lambda_b = ndarray::array![-0.6, 1.1, 0.9, -1.3];
1987 let (n, model) = fit_small_model(0x51ED270B_u64 ^ 0xF3A5C7D1_u64, &lambda_a);
1988 let (n_prev, prev) = fit_small_model(0x2545F491_u64 ^ 0x4F6CDD1D_u64, &lambda_b);
1989 assert_eq!(n, n_prev);
1990 let p = 4usize;
1991 let v: Array1<f64> = ndarray::array![0.7, -1.3, 0.4, 0.9];
1992
1993 // γ = 1 ⇒ byte-identical to this model's row_metric, regardless of prev.
1994 let base = model.row_metric(n).expect("row_metric");
1995 for prev_opt in [None, Some(&prev)] {
1996 let damped = model
1997 .row_metric_damped(n, 1.0, prev_opt)
1998 .expect("damped γ=1");
1999 for row in [0usize, n / 2, n - 1] {
2000 for i in 0..p {
2001 for k in 0..p {
2002 assert_eq!(
2003 damped.factor_entry(row, i, k),
2004 base.factor_entry(row, i, k),
2005 "γ=1 must be byte-identical to row_metric at ({row},{i},{k})"
2006 );
2007 }
2008 }
2009 }
2010 }
2011
2012 // γ = 0, prev = None ⇒ the MEASURED-scale identity (#2243 cap #2):
2013 // Σ = φ̂·I with φ̂ the model's own isotropic dispersion, so
2014 // quad_form = ‖v‖² / φ̂ (a unit-I anchor would silently assume unit
2015 // noise and over-penalize clean data).
2016 let ident = model
2017 .row_metric_damped(n, 0.0, None)
2018 .expect("damped γ=0 None");
2019 let phi = model.isotropic_dispersion();
2020 assert!(phi.is_finite() && phi > 0.0, "φ̂ must be positive");
2021 let sumsq: f64 = v.iter().map(|x| x * x).sum();
2022 let expected = sumsq / phi;
2023 for row in [0usize, n / 2, n - 1] {
2024 let q = ident.quad_form(row, v.view());
2025 assert!(
2026 (q - expected).abs() <= 1e-9 * (1.0 + expected),
2027 "γ=0/None must be the measured-scale identity: quad_form {q} vs ‖v‖²/φ̂ {expected}"
2028 );
2029 }
2030
2031 // γ = 0, prev = Some ⇒ byte-identical to prev.row_metric.
2032 let prev_metric = prev.row_metric(n).expect("prev row_metric");
2033 let damped0 = model
2034 .row_metric_damped(n, 0.0, Some(&prev))
2035 .expect("damped γ=0 Some");
2036 for row in [0usize, n / 2, n - 1] {
2037 for i in 0..p {
2038 for k in 0..p {
2039 assert_eq!(
2040 damped0.factor_entry(row, i, k),
2041 prev_metric.factor_entry(row, i, k),
2042 "γ=0/Some must be byte-identical to prev.row_metric at ({row},{i},{k})"
2043 );
2044 }
2045 }
2046 }
2047
2048 // 0 < γ < 1 ⇒ valid SPD metric.
2049 let mid = model
2050 .row_metric_damped(n, 0.5, Some(&prev))
2051 .expect("damped γ=0.5");
2052 for row in [0usize, n / 2, n - 1] {
2053 let q = mid.quad_form(row, v.view());
2054 assert!(
2055 q.is_finite() && q > 0.0,
2056 "γ=0.5 metric must be SPD; got {q}"
2057 );
2058 }
2059
2060 // Invalid γ rejected.
2061 assert!(model.row_metric_damped(n, 1.5, None).is_err());
2062 assert!(model.row_metric_damped(n, -0.1, None).is_err());
2063 assert!(model.row_metric_damped(n, f64::NAN, None).is_err());
2064 }
2065
2066 /// WAVE-2 #2021 Λ nursery→promotion: `promotion_candidates` must fire only
2067 /// for a factor that BOTH persists across passes (aligns with the previous
2068 /// model's Λ) AND clears the idiosyncratic-noise energy floor; a fresh
2069 /// orthogonal direction, an over-high energy floor, and `prev = None` all
2070 /// yield no candidates, and out-of-range gates are rejected.
2071 #[test]
2072 fn promotion_candidates_gates_on_persistence_and_energy() {
2073 let lambda_a = ndarray::array![1.5, 1.2, -0.4, 0.3];
2074 let lambda_b = ndarray::array![-0.6, 1.1, 0.9, -1.3];
2075 // Same planted direction across two passes ⇒ persistent.
2076 let (_, prev) = fit_small_model(0xA1B2C3D4_u64 ^ 0x0F0F0F0F_u64, &lambda_a);
2077 let (_, cur) = fit_small_model(0x5566778899AABBCC_u64, &lambda_a);
2078 // A different (well-separated) planted direction ⇒ NOT aligned with cur.
2079 let (_, other) = fit_small_model(0x1122334455667788_u64, &lambda_b);
2080
2081 assert!(prev.factor_rank() >= 1 && cur.factor_rank() >= 1 && other.factor_rank() >= 1);
2082
2083 // Persistent + energetic ⇒ at least one candidate, aligned with the
2084 // planted direction and above the noise floor.
2085 let cands = cur
2086 .promotion_candidates(Some(&prev), 0.9, 1.0)
2087 .expect("promotion_candidates");
2088 assert!(
2089 !cands.is_empty(),
2090 "a persistent, energetic factor must yield a promotion candidate"
2091 );
2092 let top = &cands[0];
2093 assert!(
2094 top.persistence_alignment >= 0.9,
2095 "top candidate must clear the alignment gate; got {}",
2096 top.persistence_alignment
2097 );
2098 // The promoted unit direction must align with the planted (unit) lambda_a.
2099 let la_norm = lambda_a.dot(&lambda_a).sqrt();
2100 let la_unit = lambda_a.mapv(|v| v / la_norm);
2101 let dir_cos = top.direction.dot(&la_unit).abs();
2102 assert!(
2103 dir_cos > 0.9,
2104 "promoted direction must recover the planted factor; |cos| = {dir_cos:.4}"
2105 );
2106 assert!(
2107 (top.direction.dot(&top.direction) - 1.0).abs() < 1e-10,
2108 "promoted direction must be unit-norm"
2109 );
2110 assert!(top.energy > 0.0);
2111
2112 // A fresh, well-separated direction does NOT persist ⇒ no candidate at 0.9.
2113 let cross = cur
2114 .promotion_candidates(Some(&other), 0.9, 1.0)
2115 .expect("promotion_candidates cross");
2116 assert!(
2117 cross.is_empty(),
2118 "a non-persistent (unaligned) factor must not be promoted; got {} candidate(s)",
2119 cross.len()
2120 );
2121
2122 // An over-high energy floor rejects even the persistent factor.
2123 let floored = cur
2124 .promotion_candidates(Some(&prev), 0.9, 1.0e6)
2125 .expect("promotion_candidates floored");
2126 assert!(
2127 floored.is_empty(),
2128 "energy floor must gate out factors below the noise-scaled threshold"
2129 );
2130
2131 // prev = None (first structured pass, damping toward I) ⇒ no candidates.
2132 assert!(cur.promotion_candidates(None, 0.9, 1.0).unwrap().is_empty());
2133
2134 // Invalid gates rejected.
2135 assert!(cur.promotion_candidates(Some(&prev), 1.5, 1.0).is_err());
2136 assert!(cur.promotion_candidates(Some(&prev), -0.1, 1.0).is_err());
2137 assert!(cur.promotion_candidates(Some(&prev), 0.9, -1.0).is_err());
2138 assert!(
2139 cur.promotion_candidates(Some(&prev), f64::NAN, 1.0)
2140 .is_err()
2141 );
2142 }
2143}