Skip to main content

gam_terms/
dictionary.rs

1use faer::Side;
2use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh};
3use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis, s};
4use std::fmt;
5
6const DEFAULT_MAX_ITER: usize = 30;
7const DEFAULT_TOP_K: usize = 1;
8const DEFAULT_TEMPERATURE: f64 = 0.25;
9const DEFAULT_CODE_RIDGE: f64 = 1.0e-8;
10const DEFAULT_TOLERANCE: f64 = 1.0e-7;
11const INACTIVE_LAMBDA: f64 = 1.0e30;
12const MIN_NORM2: f64 = 1.0e-24;
13
14// --- Safeguarded geometric acceleration of the atom trajectory (#2372). ---------
15// The alternating atom-sweep ↔ reroute map converges LINEARLY: near the solution
16// the atom matrix moves along one dominant slow mode `Aₖ ≈ A* + c·rᵏ·V` with ratio
17// `r` set by the coupling of the atoms that share rows. On the planted 2-sparse
18// ring fixture `r ≈ 0.9985`, so reaching a `1e-9` fixed-point residual takes ~2500
19// plain sweeps — orders of magnitude past any reasonable `max_iter`, which is why
20// the fixed-point contract never closed. When two consecutive atom steps are
21// collinear (`cos ≥ GEOM_COS_MIN`) and contracting (`r ∈ (GEOM_R_MIN, 1)`) the
22// sequence is in that single-mode geometric tail, and the sum of the remaining
23// steps is `Δₖ·r/(1−r)`. Jumping there collapses the tail in one step. The jump is
24// a SAFEGUARDED proposal: it is adopted only when its rerouted EV strictly beats
25// the plain step's, so it can never degrade the fit (monotonicity preserved) — the
26// only risk of a bad `r` estimate is a rejected proposal, never a worse model.
27const GEOM_R_MIN: f64 = 0.1;
28const GEOM_R_EPS: f64 = 1.0e-12;
29const GEOM_COS_MIN: f64 = 0.9;
30const GEOM_FACTOR_CAP: f64 = 1.0e6;
31// Line-search ladder over the extrapolation step length: the spiral's straight-line
32// tangent is closest to the fixed point at an interior factor, so probe a geometric
33// ladder from `BASE` up to the geometric-sum bound `r/(1−r)` and keep the best.
34const GEOM_LADDER_BASE: f64 = 1.3;
35const GEOM_LADDER_STEP: f64 = 1.3;
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum LinearDictionaryAssignment {
39    TopK,
40    Softmax,
41}
42
43impl LinearDictionaryAssignment {
44    pub fn parse(value: &str) -> Result<Self, String> {
45        match value.trim().to_ascii_lowercase().as_str() {
46            "top_k" | "topk" | "hard" => Ok(Self::TopK),
47            "softmax" | "soft" => Ok(Self::Softmax),
48            other => Err(format!(
49                "linear dictionary assignment must be 'top_k' or 'softmax'; got {other:?}"
50            )),
51        }
52    }
53
54    pub const fn as_str(self) -> &'static str {
55        match self {
56            Self::TopK => "top_k",
57            Self::Softmax => "softmax",
58        }
59    }
60}
61
62/// Typed failure from [`fit_linear_dictionary`].
63///
64/// In particular, [`LinearDictionaryError::NonConvergence`] preserves the
65/// numerical certificate that prevented the final iterate from becoming a
66/// [`LinearDictionaryFit`].
67#[derive(Clone, Debug, PartialEq)]
68pub enum LinearDictionaryError {
69    InvalidInput {
70        reason: String,
71    },
72    NumericalFailure {
73        reason: String,
74    },
75    NonConvergence {
76        iterations: usize,
77        explained_variance: f64,
78        ev_residual: f64,
79        routing_residual: f64,
80        accepted_births: usize,
81        tolerance: f64,
82    },
83}
84
85impl LinearDictionaryError {
86    fn invalid_input(reason: impl Into<String>) -> Self {
87        Self::InvalidInput {
88            reason: reason.into(),
89        }
90    }
91}
92
93impl From<String> for LinearDictionaryError {
94    fn from(reason: String) -> Self {
95        Self::NumericalFailure { reason }
96    }
97}
98
99impl fmt::Display for LinearDictionaryError {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            Self::InvalidInput { reason } | Self::NumericalFailure { reason } => {
103                f.write_str(reason)
104            }
105            Self::NonConvergence {
106                iterations,
107                explained_variance,
108                ev_residual,
109                routing_residual,
110                accepted_births,
111                tolerance,
112            } => write!(
113                f,
114                "linear_dictionary_fit did not converge: {iterations} coordinate-descent sweeps \
115                 ended at EV {explained_variance:.6} with canonical EV residual \
116                 {ev_residual:.3e}, reroute residual {routing_residual:.3e}, and \
117                 {accepted_births} accepted dead-atom births (tolerance {tolerance:.3e}); a \
118                 non-converged iterate is not a model"
119            ),
120        }
121    }
122}
123
124impl std::error::Error for LinearDictionaryError {}
125
126#[derive(Clone, Debug)]
127pub struct LinearDictionaryConfig {
128    pub n_atoms: usize,
129    pub max_iter: usize,
130    pub top_k: usize,
131    pub assignment: LinearDictionaryAssignment,
132    pub temperature: f64,
133    pub code_ridge: f64,
134    pub tolerance: f64,
135    /// K=1 lane only. When `false` (default) the rank-one lane takes the leading
136    /// eigenvector of the UNCENTERED second-moment matrix `XᵀX` (byte-identical to
137    /// historical behavior), which is only a true centered-PCA ceiling when `x` is
138    /// already mean-centered. When `true` the lane subtracts the column mean, takes
139    /// the leading eigenvector of the CENTERED second-moment matrix, fits the
140    /// rank-1 code on the centered data, and adds the mean back — so the reported
141    /// EV (measured against the crate's centered denominator) is a genuine
142    /// centered-PCA ceiling even on uncentered input. Because the reconstruction is
143    /// then affine (mean + rank-1), the returned `fitted` INCLUDES the mean and is
144    /// NOT equal to `assignments.dot(atoms)` in this mode.
145    pub center_rank_one: bool,
146}
147
148impl LinearDictionaryConfig {
149    pub fn new(n_atoms: usize) -> Self {
150        Self {
151            n_atoms,
152            ..Self::default()
153        }
154    }
155}
156
157impl Default for LinearDictionaryConfig {
158    fn default() -> Self {
159        Self {
160            n_atoms: 1,
161            max_iter: DEFAULT_MAX_ITER,
162            top_k: DEFAULT_TOP_K,
163            assignment: LinearDictionaryAssignment::TopK,
164            temperature: DEFAULT_TEMPERATURE,
165            code_ridge: DEFAULT_CODE_RIDGE,
166            tolerance: DEFAULT_TOLERANCE,
167            center_rank_one: false,
168        }
169    }
170}
171
172/// A converged linear-dictionary model. This struct exists ONLY for a certified
173/// fit (SPEC 20): the coordinate-descent solver returns it exclusively when the
174/// EV-plateau convergence test fired, and non-convergence is an error carrying
175/// its evidence (sweeps run, last EV, last improvement, tolerance) — never a
176/// degraded/best-effort fit. The closed-form K=1 lanes are converged by
177/// construction (a single exact eigensolve).
178#[derive(Clone, Debug)]
179pub struct LinearDictionaryFit {
180    pub atoms: Array2<f64>,
181    pub assignments: Array2<f64>,
182    pub fitted: Array2<f64>,
183    pub lambdas: Array1<f64>,
184    pub reml_scores: Array1<f64>,
185    pub explained_variance: f64,
186    pub iterations: usize,
187    pub convergence: LinearDictionaryConvergence,
188    pub assignment: LinearDictionaryAssignment,
189    pub top_k: usize,
190}
191
192/// Fixed-point evidence attached to every converged [`LinearDictionaryFit`].
193///
194/// The two residuals certify the exact canonical routing stored in the model:
195/// `ev_residual` compares consecutive full atom-sweep + reroute states, while
196/// `routing_residual` compares the final atom-sweep state with a fresh global
197/// routing against those same final atoms. A fit is constructed only when both
198/// are below `tolerance` and no proposed dead-atom birth entered the routing.
199#[derive(Clone, Copy, Debug, PartialEq)]
200pub struct LinearDictionaryConvergence {
201    pub ev_residual: f64,
202    pub routing_residual: f64,
203    pub accepted_births: usize,
204    pub tolerance: f64,
205}
206
207/// Fit a linear (flat) dictionary by block coordinate descent: each sweep
208/// re-routes rows to atoms (the assignment step) and then refines every atom and
209/// its assignment column by a penalized least-squares update against the residual.
210///
211/// CONTRACT: this is a heuristic coordinate-descent dictionary learner, not a
212/// globally-optimal linear SAE. Every sweep closes with a fresh global routing
213/// against the updated atoms. Convergence is certified on that exact canonical
214/// state: both its change from the previous canonical state and its discrepancy
215/// from the just-completed atom sweep must be below tolerance, and no proposed
216/// dead-atom birth may enter the routing. The returned assignments, fitted values,
217/// EV, and diagnostics are therefore the state that passed the certificate; no
218/// post-certificate reroute or best-effort adoption occurs.
219pub fn fit_linear_dictionary(
220    x: ArrayView2<'_, f64>,
221    config: &LinearDictionaryConfig,
222) -> Result<LinearDictionaryFit, LinearDictionaryError> {
223    validate_inputs(x, config)?;
224    if config.n_atoms == 1 {
225        return fit_rank_one_pca_lane(x, config);
226    }
227    fit_multi_atom_dictionary(x, config)
228}
229
230/// One plain alternating step: a full per-atom penalized-LS sweep followed by a
231/// fresh global reroute, committing the rerouted assignment and fitted values into
232/// `assignments`/`fitted`. Reseeded atoms (#1500) whose reroute column stays empty
233/// are the rejected redundant proposals — they are zeroed here so a held-out
234/// transform cannot expose a direction absent from the certified model; a reseed
235/// the reroute actually uses counts as an `accepted_births`. Returns the sweep EV
236/// (pre-reroute), the rerouted EV, and the accepted-birth count so the caller can
237/// form the fixed-point residuals and drive the geometric acceleration.
238fn plain_atom_step(
239    x: ArrayView2<'_, f64>,
240    atoms: &mut Array2<f64>,
241    assignments: &mut Array2<f64>,
242    fitted: &mut Array2<f64>,
243    lambdas: &mut Array1<f64>,
244    reml_scores: &mut Array1<f64>,
245    top_k: usize,
246    config: &LinearDictionaryConfig,
247) -> Result<(f64, f64, usize), LinearDictionaryError> {
248    let n_atoms = atoms.nrows();
249    let mut reseeded = vec![false; n_atoms];
250    for atom_idx in 0..n_atoms {
251        reseeded[atom_idx] = fit_one_atom_penalized_ls(
252            x,
253            atoms,
254            assignments,
255            fitted,
256            lambdas,
257            reml_scores,
258            atom_idx,
259            config.code_ridge,
260        )?;
261    }
262
263    let sweep_ev = explained_variance(x, fitted.view());
264    let rerouted = reroute_against_atoms(x, atoms.view(), top_k, config)?;
265    let rerouted_fitted = rerouted.dot(&*atoms);
266    let rerouted_ev = explained_variance(x, rerouted_fitted.view());
267
268    let mut accepted_births = 0usize;
269    for atom_idx in 0..n_atoms {
270        if !reseeded[atom_idx] {
271            continue;
272        }
273        let accepted = rerouted
274            .column(atom_idx)
275            .iter()
276            .any(|coefficient| *coefficient != 0.0);
277        if accepted {
278            accepted_births += 1;
279        } else {
280            atoms.row_mut(atom_idx).fill(0.0);
281            lambdas[atom_idx] = INACTIVE_LAMBDA;
282            reml_scores[atom_idx] = 0.0;
283        }
284    }
285
286    *assignments = rerouted;
287    *fitted = rerouted_fitted;
288    Ok((sweep_ev, rerouted_ev, accepted_births))
289}
290
291fn fit_multi_atom_dictionary(
292    x: ArrayView2<'_, f64>,
293    config: &LinearDictionaryConfig,
294) -> Result<LinearDictionaryFit, LinearDictionaryError> {
295    let top_k = config.top_k.min(config.n_atoms).max(1);
296    let mut atoms = initialize_atoms(x, config.n_atoms);
297    let mut assignments = reroute_against_atoms(x, atoms.view(), top_k, config)?;
298    let mut fitted = assignments.dot(&atoms);
299    let mut lambdas = Array1::<f64>::from_elem(config.n_atoms, INACTIVE_LAMBDA);
300    let mut reml_scores = Array1::<f64>::zeros(config.n_atoms);
301    let mut previous_ev = explained_variance(x, fitted.view());
302    let mut completed_iterations = 0usize;
303    let mut last_ev = previous_ev;
304    let mut ev_residual = f64::INFINITY;
305    let mut routing_residual = f64::INFINITY;
306    let mut accepted_births = 0usize;
307    // History of the atom trajectory for the safeguarded geometric acceleration:
308    // `prev_atoms` is the previous iteration's canonical dictionary and `prev_delta`
309    // the previous atom step, so a collinear pair of steps exposes the dominant
310    // slow mode to extrapolate along.
311    let mut prev_atoms: Option<Array2<f64>> = None;
312    let mut prev_delta: Option<Array2<f64>> = None;
313
314    for iteration in 0..config.max_iter {
315        let (mut sweep_ev, mut rerouted_ev, mut births) = plain_atom_step(
316            x,
317            &mut atoms,
318            &mut assignments,
319            &mut fitted,
320            &mut lambdas,
321            &mut reml_scores,
322            top_k,
323            config,
324        )?;
325
326        // Safeguarded geometric acceleration. `atoms` is now the canonical
327        // dictionary this sweep converged to; its step from the previous one is
328        // `this_delta`. When that step is collinear with the last (and the support
329        // is settled — no births) the trajectory is in its dominant-mode geometric
330        // tail, and a line-searched extrapolation along it lands near the fixed
331        // point. A jump is a big non-plain step that breaks the collinear pair the
332        // next jump needs, so after adopting one we REBUILD the geometric history
333        // with two more plain steps INLINE — keeping the acceleration firing every
334        // outer iteration rather than stalling two sweeps between jumps. The jump is
335        // adopted only when it strictly beats the plain EV, so it never degrades the
336        // fit; a broken sequence (fresh births, a support flip, a non-contracting
337        // ratio) is simply left to plain alternation.
338        let this_delta = prev_atoms.as_ref().map(|previous| &atoms - previous);
339        let mut jumped = false;
340        if births == 0 {
341            if let (Some(delta), Some(previous_delta)) = (this_delta.as_ref(), prev_delta.as_ref())
342            {
343                if let Some((cand_atoms, cand_route, cand_fitted)) = try_geometric_extrapolation(
344                    atoms.view(),
345                    delta.view(),
346                    previous_delta.view(),
347                    x,
348                    top_k,
349                    config,
350                    rerouted_ev,
351                ) {
352                    atoms = cand_atoms;
353                    assignments = cand_route;
354                    fitted = cand_fitted;
355                    jumped = true;
356                    // Rebuild the geometric history from the jumped point with two
357                    // inline plain steps so the next outer iteration can jump again.
358                    let (_, _, rebuild_births_1) = plain_atom_step(
359                        x,
360                        &mut atoms,
361                        &mut assignments,
362                        &mut fitted,
363                        &mut lambdas,
364                        &mut reml_scores,
365                        top_k,
366                        config,
367                    )?;
368                    let rebuilt_prev = atoms.clone();
369                    let (rebuild_sweep_ev, rebuild_ev, rebuild_births_2) = plain_atom_step(
370                        x,
371                        &mut atoms,
372                        &mut assignments,
373                        &mut fitted,
374                        &mut lambdas,
375                        &mut reml_scores,
376                        top_k,
377                        config,
378                    )?;
379                    prev_delta = Some(&atoms - &rebuilt_prev);
380                    prev_atoms = Some(atoms.clone());
381                    sweep_ev = rebuild_sweep_ev;
382                    rerouted_ev = rebuild_ev;
383                    births = rebuild_births_1.max(rebuild_births_2);
384                }
385            }
386        }
387        if !jumped {
388            prev_atoms = Some(atoms.clone());
389            prev_delta = this_delta;
390        }
391        accepted_births = births;
392
393        completed_iterations = iteration + 1;
394        ev_residual = (rerouted_ev - previous_ev).abs();
395        routing_residual = (rerouted_ev - sweep_ev).abs();
396        last_ev = rerouted_ev;
397
398        if accepted_births == 0
399            && ev_residual <= config.tolerance
400            && routing_residual <= config.tolerance
401            // A plateau is a SEQUENCE property: `ev_residual` compares this
402            // sweep's canonical EV against the PREVIOUS one, and on sweep 0
403            // "previous" is the initialization — a single agreeing pair is one
404            // data point, not a plateau (an initialization that happens to sit
405            // at the fixed point would self-certify without the solver ever
406            // demonstrating stability). Two completed sweeps minimum.
407            && iteration >= 1
408        {
409            // The per-atom update recorded scores at intermediate Gauss-Seidel
410            // states. Recompute every active score from the exact canonical state
411            // that passed the fixed-point certificate.
412            let final_score =
413                penalized_reconstruction_loss(x, fitted.view(), config.code_ridge, atoms.view());
414            for atom_idx in 0..config.n_atoms {
415                if atoms.row(atom_idx).dot(&atoms.row(atom_idx)) > MIN_NORM2 {
416                    reml_scores[atom_idx] = final_score;
417                }
418            }
419            return Ok(LinearDictionaryFit {
420                atoms,
421                assignments,
422                fitted,
423                lambdas,
424                reml_scores,
425                explained_variance: last_ev,
426                iterations: completed_iterations,
427                convergence: LinearDictionaryConvergence {
428                    ev_residual,
429                    routing_residual,
430                    accepted_births,
431                    tolerance: config.tolerance,
432                },
433                assignment: config.assignment,
434                top_k,
435            });
436        }
437        previous_ev = rerouted_ev;
438    }
439
440    // SPEC 20: the final canonical work state failed at least one fixed-point
441    // condition. Preserve its numerical evidence in a typed error; never mint a
442    // degraded/best-effort model from it.
443    Err(LinearDictionaryError::NonConvergence {
444        iterations: completed_iterations,
445        explained_variance: last_ev,
446        ev_residual,
447        routing_residual,
448        accepted_births,
449        tolerance: config.tolerance,
450    })
451}
452
453/// Safeguarded extrapolation of the atom trajectory (#2372). Given the current
454/// dictionary `atoms = Aₖ`, this sweep's step `delta = Aₖ − Aₖ₋₁`, and the previous
455/// step `prev_delta = Aₖ₋₁ − Aₖ₋₂`, decide whether the trajectory is in its slow
456/// single-mode tail and, if so, propose a dictionary further along that mode.
457///
458/// The atom trajectory does not decay along a fixed straight line — it SPIRALS: the
459/// dominant mode is a nearly-real eigenvalue `λ = ρ·e^{iθ}` with `ρ ≈ 0.9985` and a
460/// tiny per-step angle `θ`, so consecutive steps are collinear to six digits
461/// (`cos ≈ 1`) yet accumulate a large arc over the `r/(1−r) ≈ 1600` steps that
462/// remain. Extrapolating the full geometric sum in a straight line therefore
463/// OVERSHOOTS the limit badly (it leaves the sphere-product the atoms live on). So
464/// instead of one fixed jump we LINE-SEARCH the step length: walk a geometric ladder
465/// of factors up to the geometric-sum bound `r/(1−r)`, reroute each candidate, and
466/// keep the one with the highest EV that strictly beats the plain step. The tangent
467/// from `Aₖ` heads toward the limit and only later curves away, so a well-defined
468/// interior factor gets closest — the ladder finds it, and the strict-improvement
469/// guard means a bad estimate costs at most a rejected proposal, never a worse fit.
470fn try_geometric_extrapolation(
471    atoms: ArrayView2<'_, f64>,
472    delta: ArrayView2<'_, f64>,
473    prev_delta: ArrayView2<'_, f64>,
474    x: ArrayView2<'_, f64>,
475    top_k: usize,
476    config: &LinearDictionaryConfig,
477    current_ev: f64,
478) -> Option<(Array2<f64>, Array2<f64>, Array2<f64>)> {
479    let cross: f64 = delta
480        .iter()
481        .zip(prev_delta.iter())
482        .map(|(a, b)| a * b)
483        .sum();
484    let prev_norm2: f64 = prev_delta.iter().map(|v| v * v).sum();
485    let delta_norm2: f64 = delta.iter().map(|v| v * v).sum();
486    if !(prev_norm2 > 0.0 && delta_norm2 > 0.0) {
487        return None;
488    }
489    let ratio = cross / prev_norm2;
490    if !(ratio > GEOM_R_MIN && ratio < 1.0 - GEOM_R_EPS) {
491        return None;
492    }
493    // Collinearity of the two steps — the single-dominant-mode assumption. A
494    // support flip or a two-mode transient makes the steps non-parallel; skip it.
495    let cosine = cross / (delta_norm2.sqrt() * prev_norm2.sqrt());
496    if !(cosine > GEOM_COS_MIN) {
497        return None;
498    }
499    let max_factor = (ratio / (1.0 - ratio)).min(GEOM_FACTOR_CAP);
500    if !(max_factor.is_finite() && max_factor > 1.0) {
501        return None;
502    }
503    let delta_owned = delta.to_owned();
504    let atoms_owned = atoms.to_owned();
505    let mut best: Option<(Array2<f64>, Array2<f64>, Array2<f64>)> = None;
506    let mut best_ev = current_ev;
507    let mut factor = GEOM_LADDER_BASE;
508    loop {
509        let mut candidate = &atoms_owned + &(factor * &delta_owned);
510        for atom_idx in 0..candidate.nrows() {
511            normalize_row(candidate.slice_mut(s![atom_idx, ..]));
512        }
513        if let Ok(route) = reroute_against_atoms(x, candidate.view(), top_k, config) {
514            let fitted = route.dot(&candidate);
515            let ev = explained_variance(x, fitted.view());
516            if ev > best_ev {
517                best_ev = ev;
518                best = Some((candidate, route, fitted));
519            }
520        }
521        if factor >= max_factor {
522            break;
523        }
524        factor = (factor * GEOM_LADDER_STEP).min(max_factor);
525    }
526    best
527}
528
529/// Fresh global routing of every row against `atoms` using the configured
530/// assignment rule. This is the single source of truth shared by the
531/// coordinate-descent assignment step and the post-loop final reroute, so both
532/// route identically and the reroute is a true global re-assignment against the
533/// final atoms.
534fn reroute_against_atoms(
535    x: ArrayView2<'_, f64>,
536    atoms: ArrayView2<'_, f64>,
537    top_k: usize,
538    config: &LinearDictionaryConfig,
539) -> Result<Array2<f64>, String> {
540    match config.assignment {
541        LinearDictionaryAssignment::TopK => top_k_assignments(x, atoms, top_k, config.code_ridge),
542        LinearDictionaryAssignment::Softmax => {
543            softmax_assignments(x, atoms, top_k, config.temperature, config.code_ridge)
544        }
545    }
546}
547
548fn validate_inputs(
549    x: ArrayView2<'_, f64>,
550    config: &LinearDictionaryConfig,
551) -> Result<(), LinearDictionaryError> {
552    if x.nrows() == 0 || x.ncols() == 0 {
553        return Err(LinearDictionaryError::invalid_input(
554            "linear_dictionary_fit requires a non-empty 2-D matrix",
555        ));
556    }
557    if !x.iter().all(|value| value.is_finite()) {
558        return Err(LinearDictionaryError::invalid_input(
559            "linear_dictionary_fit input must be finite",
560        ));
561    }
562    if config.n_atoms == 0 {
563        return Err(LinearDictionaryError::invalid_input(
564            "linear_dictionary_fit requires K >= 1",
565        ));
566    }
567    if config.max_iter == 0 {
568        return Err(LinearDictionaryError::invalid_input(
569            "linear_dictionary_fit requires max_iter >= 1",
570        ));
571    }
572    if config.top_k == 0 || config.top_k > config.n_atoms {
573        return Err(LinearDictionaryError::invalid_input(format!(
574            "linear_dictionary_fit top_k must be in [1, K={}]; got {}",
575            config.n_atoms, config.top_k
576        )));
577    }
578    if !(config.temperature.is_finite() && config.temperature > 0.0) {
579        return Err(LinearDictionaryError::invalid_input(format!(
580            "linear_dictionary_fit temperature must be finite and positive; got {}",
581            config.temperature
582        )));
583    }
584    if !(config.code_ridge.is_finite() && config.code_ridge > 0.0) {
585        return Err(LinearDictionaryError::invalid_input(format!(
586            "linear_dictionary_fit code_ridge must be finite and positive; got {}",
587            config.code_ridge
588        )));
589    }
590    if !(config.tolerance.is_finite() && config.tolerance >= 0.0) {
591        return Err(LinearDictionaryError::invalid_input(format!(
592            "linear_dictionary_fit tolerance must be finite and non-negative; got {}",
593            config.tolerance
594        )));
595    }
596    Ok(())
597}
598
599/// K=1 closed-form lane.
600///
601/// Default (`config.center_rank_one == false`): the leading eigenvector of the
602/// UNCENTERED second-moment matrix `XᵀX`. This is only a true centered-PCA ceiling
603/// when `x` is already mean-centered upstream; the `explained_variance` denominator
604/// IS centered, so on uncentered input the leading `XᵀX` eigenvector can absorb the
605/// mean direction and this lane is a second-moment rank-1 fit rather than the
606/// centered principal component. This branch is byte-identical to historical
607/// behavior.
608///
609/// Centered (`config.center_rank_one == true`): delegates to
610/// [`fit_rank_one_centered_lane`], which subtracts the column mean, takes the
611/// leading eigenvector of the CENTERED second-moment matrix, and adds the mean
612/// back, so the reported EV is a genuine centered-PCA ceiling even on uncentered
613/// input. See that function and [`rank_one_centered_pca_ceiling`] for details.
614fn fit_rank_one_pca_lane(
615    x: ArrayView2<'_, f64>,
616    config: &LinearDictionaryConfig,
617) -> Result<LinearDictionaryFit, LinearDictionaryError> {
618    if config.center_rank_one {
619        return fit_rank_one_centered_lane(x, config);
620    }
621    let covariance = x.t().dot(&x);
622    let (evals, evecs) = covariance
623        .eigh(Side::Lower)
624        .map_err(|err| format!("linear_dictionary_fit PCA eigensolve failed: {err}"))?;
625    let last = evals.len() - 1;
626    let mut atom = evecs.column(last).to_owned();
627    orient_vector(&mut atom);
628    let mut assignments = Array2::<f64>::zeros((x.nrows(), 1));
629    for row in 0..x.nrows() {
630        assignments[[row, 0]] = x.row(row).dot(&atom) / (1.0 + config.code_ridge);
631    }
632    let mut atoms = atom.insert_axis(Axis(0)).to_owned();
633    normalize_atom_and_assignments(&mut atoms, &mut assignments, 0);
634    let fitted = assignments.dot(&atoms);
635    let score = penalized_reconstruction_loss(x, fitted.view(), config.code_ridge, atoms.view());
636    Ok(LinearDictionaryFit {
637        atoms,
638        assignments,
639        fitted: fitted.clone(),
640        lambdas: Array1::from_elem(1, config.code_ridge),
641        reml_scores: Array1::from_elem(1, score),
642        explained_variance: explained_variance(x, fitted.view()),
643        iterations: 1.min(config.max_iter),
644        convergence: LinearDictionaryConvergence {
645            ev_residual: 0.0,
646            routing_residual: 0.0,
647            accepted_births: 0,
648            tolerance: config.tolerance,
649        },
650        assignment: config.assignment,
651        top_k: 1,
652    })
653}
654
655/// Centered K=1 lane (`config.center_rank_one == true`): a genuine centered-PCA
656/// ceiling. Builds a full [`LinearDictionaryFit`] from the shared centered
657/// components — `atoms` is the unit-norm centered principal direction,
658/// `assignments` are the centered rank-1 codes, and `fitted` is the AFFINE
659/// reconstruction `mean + code·atom`, so `explained_variance` (centered
660/// denominator) is a true ceiling. Because the reconstruction is affine, `fitted`
661/// INCLUDES the mean and is NOT `assignments.dot(atoms)` in this mode.
662fn fit_rank_one_centered_lane(
663    x: ArrayView2<'_, f64>,
664    config: &LinearDictionaryConfig,
665) -> Result<LinearDictionaryFit, LinearDictionaryError> {
666    let CenteredRankOne {
667        atom,
668        codes,
669        fitted,
670        explained_variance: ev,
671    } = centered_rank_one_components(x, config.code_ridge)?;
672    let atoms = atom.insert_axis(Axis(0)).to_owned();
673    let assignments = codes.insert_axis(Axis(1)).to_owned();
674    let score = penalized_reconstruction_loss(x, fitted.view(), config.code_ridge, atoms.view());
675    Ok(LinearDictionaryFit {
676        atoms,
677        assignments,
678        fitted,
679        lambdas: Array1::from_elem(1, config.code_ridge),
680        reml_scores: Array1::from_elem(1, score),
681        explained_variance: ev,
682        iterations: 1.min(config.max_iter),
683        convergence: LinearDictionaryConvergence {
684            ev_residual: 0.0,
685            routing_residual: 0.0,
686            accepted_births: 0,
687            tolerance: config.tolerance,
688        },
689        assignment: config.assignment,
690        top_k: 1,
691    })
692}
693
694/// Shared components of the centered rank-1 fit, so the public ceiling helper and
695/// the centered K=1 lane compute exactly the same principal direction / codes.
696struct CenteredRankOne {
697    /// Unit-norm centered principal direction (length `p`).
698    atom: Array1<f64>,
699    /// Centered rank-1 codes with the ridge shrink applied (length `n`).
700    codes: Array1<f64>,
701    /// Affine reconstruction `mean + code·atom` (shape `n × p`).
702    fitted: Array2<f64>,
703    /// EV of `fitted` against the crate's centered denominator.
704    explained_variance: f64,
705}
706
707fn centered_rank_one_components(
708    x: ArrayView2<'_, f64>,
709    code_ridge: f64,
710) -> Result<CenteredRankOne, String> {
711    if x.nrows() == 0 || x.ncols() == 0 {
712        return Err("rank_one_centered_pca_ceiling requires a non-empty 2-D matrix".to_string());
713    }
714    if !(code_ridge.is_finite() && code_ridge > 0.0) {
715        return Err(format!(
716            "rank_one_centered_pca_ceiling code_ridge must be finite and positive; got {code_ridge}"
717        ));
718    }
719    let means = x.mean_axis(Axis(0)).expect("non-empty input has means");
720    let centered = &x.to_owned() - &means;
721    let covariance = centered.t().dot(&centered);
722    let (evals, evecs) = covariance
723        .eigh(Side::Lower)
724        .map_err(|err| format!("rank_one_centered_pca_ceiling eigensolve failed: {err}"))?;
725    let last = evals.len() - 1;
726    let mut atom = evecs.column(last).to_owned();
727    orient_vector(&mut atom);
728    let shrink = 1.0 / (1.0 + code_ridge);
729    let mut codes = Array1::<f64>::zeros(x.nrows());
730    let mut fitted = Array2::<f64>::zeros(x.dim());
731    for row in 0..x.nrows() {
732        let code = centered.row(row).dot(&atom) * shrink;
733        codes[row] = code;
734        for col in 0..x.ncols() {
735            fitted[[row, col]] = means[col] + code * atom[col];
736        }
737    }
738    let ev = explained_variance(x, fitted.view());
739    Ok(CenteredRankOne {
740        atom,
741        codes,
742        fitted,
743        explained_variance: ev,
744    })
745}
746
747/// Centered rank-1 PCA ceiling for the K=1 lane, exposed for callers that want the
748/// ceiling reconstruction/EV directly. Subtracts the column means, takes the
749/// leading eigenvector of the CENTERED second-moment matrix, fits the rank-1 code
750/// on the centered data with the same ridge shrink the uncentered lane uses, then
751/// adds the mean back so the reconstruction lives in the original space. Returns
752/// `(fitted, explained_variance)`; the EV is measured against the same centered
753/// denominator as the rest of the crate, so it is directly comparable to (and an
754/// upper bound on) the uncentered lane's EV. Prefer setting
755/// `LinearDictionaryConfig::center_rank_one = true` to route the K=1 lane through
756/// this computation as part of a full [`LinearDictionaryFit`].
757pub fn rank_one_centered_pca_ceiling(
758    x: ArrayView2<'_, f64>,
759    code_ridge: f64,
760) -> Result<(Array2<f64>, f64), String> {
761    let components = centered_rank_one_components(x, code_ridge)?;
762    Ok((components.fitted, components.explained_variance))
763}
764
765fn initialize_atoms(x: ArrayView2<'_, f64>, n_atoms: usize) -> Array2<f64> {
766    let mut atoms = Array2::<f64>::zeros((n_atoms, x.ncols()));
767    let first = max_norm_row(x);
768    atoms.row_mut(0).assign(&x.row(first));
769    normalize_row(atoms.slice_mut(s![0, ..]));
770    let mut min_dist2 = Array1::<f64>::from_elem(x.nrows(), f64::INFINITY);
771
772    for atom_idx in 1..n_atoms {
773        let prev = atoms.row(atom_idx - 1);
774        for row in 0..x.nrows() {
775            let dist2 = squared_distance(x.row(row), prev);
776            if dist2 < min_dist2[row] {
777                min_dist2[row] = dist2;
778            }
779        }
780        let chosen = if atom_idx < x.nrows() {
781            max_index(min_dist2.view())
782        } else {
783            atom_idx % x.nrows()
784        };
785        atoms.row_mut(atom_idx).assign(&x.row(chosen));
786        normalize_row(atoms.slice_mut(s![atom_idx, ..]));
787    }
788    atoms
789}
790
791fn fit_one_atom_penalized_ls(
792    x: ArrayView2<'_, f64>,
793    atoms: &mut Array2<f64>,
794    assignments: &mut Array2<f64>,
795    fitted: &mut Array2<f64>,
796    lambdas: &mut Array1<f64>,
797    reml_scores: &mut Array1<f64>,
798    atom_idx: usize,
799    atom_ridge: f64,
800) -> Result<bool, String> {
801    let code = assignments.column(atom_idx).to_owned();
802    let code_norm2 = code.dot(&code);
803    if code_norm2 <= MIN_NORM2 {
804        // #1500: this atom's cluster is EMPTY (no rows routed to it by the
805        // assignment step). Zeroing it here made the atom permanently DEAD — a
806        // zero atom has zero similarity to every row, so `top_k_assignments`
807        // never routes anything back to it, the dictionary collapses to < K live
808        // atoms, and it under-explains variance even when the data is exactly K
809        // rank-1 atoms a K-atom dictionary could reconstruct perfectly. Instead
810        // RE-SEED the atom into the worst-currently-reconstructed direction (the
811        // standard k-means empty-cluster cure): point it at the largest-residual
812        // row's UNEXPLAINED component so the next assignment sweep can route that
813        // row's cluster to it and revive it. Returns `true` so the outer loop
814        // suppresses convergence this iteration (the revived atom has no code
815        // yet, so EV is momentarily flat — converging now would strand it).
816        let mut worst_row = 0usize;
817        let mut worst_res2 = -1.0_f64;
818        for row in 0..x.nrows() {
819            let mut res2 = 0.0_f64;
820            for col in 0..x.ncols() {
821                let d = x[[row, col]] - fitted[[row, col]];
822                res2 += d * d;
823            }
824            if res2 > worst_res2 {
825                worst_res2 = res2;
826                worst_row = row;
827            }
828        }
829        if worst_res2 <= MIN_NORM2 {
830            // Every row is already fully reconstructed by the other atoms: there
831            // is no unexplained direction to seed, so this atom is genuinely
832            // redundant capacity. Leave it inactive (this is not the bug).
833            atoms.row_mut(atom_idx).fill(0.0);
834            lambdas[atom_idx] = INACTIVE_LAMBDA;
835            reml_scores[atom_idx] = 0.0;
836            return Ok(false);
837        }
838        for col in 0..x.ncols() {
839            atoms[[atom_idx, col]] = x[[worst_row, col]] - fitted[[worst_row, col]];
840        }
841        normalize_row(atoms.slice_mut(s![atom_idx, ..]));
842        lambdas[atom_idx] = atom_ridge;
843        reml_scores[atom_idx] =
844            penalized_reconstruction_loss(x, fitted.view(), atom_ridge, atoms.view());
845        return Ok(true);
846    }
847
848    let old_atom = atoms.row(atom_idx).to_owned();
849    let mut residual = x.to_owned() - fitted.view();
850    residual += &code
851        .view()
852        .insert_axis(Axis(1))
853        .dot(&old_atom.view().insert_axis(Axis(0)));
854
855    let denominator = code_norm2 + atom_ridge;
856    for col in 0..x.ncols() {
857        atoms[[atom_idx, col]] = code.dot(&residual.column(col)) / denominator;
858    }
859    lambdas[atom_idx] = atom_ridge;
860    normalize_atom_and_assignments(atoms, assignments, atom_idx);
861    let updated_code = assignments.column(atom_idx).to_owned();
862    fitted.assign(&x);
863    *fitted -= &residual;
864    *fitted += &updated_code
865        .view()
866        .insert_axis(Axis(1))
867        .dot(&atoms.row(atom_idx).insert_axis(Axis(0)));
868    reml_scores[atom_idx] =
869        penalized_reconstruction_loss(x, fitted.view(), atom_ridge, atoms.view());
870    Ok(false)
871}
872
873fn top_k_assignments(
874    x: ArrayView2<'_, f64>,
875    atoms: ArrayView2<'_, f64>,
876    top_k: usize,
877    code_ridge: f64,
878) -> Result<Array2<f64>, String> {
879    let cross = x.dot(&atoms.t());
880    let mut assignments = Array2::<f64>::zeros((x.nrows(), atoms.nrows()));
881    for row in 0..x.nrows() {
882        let active = top_indices_by_abs(cross.row(row), top_k);
883        let coeffs = solve_active_coefficients(atoms, cross.row(row), &active, code_ridge)?;
884        for pos in 0..active.len() {
885            assignments[[row, active[pos]]] = coeffs[pos];
886        }
887    }
888    Ok(assignments)
889}
890
891/// Encode held-out rows `x` (`M x P`) against a frozen dictionary `atoms`
892/// (`K x P`) using the same top-`top_k` ridge least-squares routing the fit
893/// uses against its final atoms. Returns the `(M, K)` sparse code matrix.
894///
895/// This is the out-of-sample `transform`/encode step for a fitted linear
896/// dictionary; the math (top-k selection + active-set ridge solve) lives in
897/// the Rust core so the Python facade stays a thin wrapper.
898pub fn linear_dictionary_transform(
899    x: ArrayView2<'_, f64>,
900    atoms: ArrayView2<'_, f64>,
901    top_k: usize,
902    code_ridge: f64,
903) -> Result<Array2<f64>, String> {
904    let k = atoms.nrows();
905    if k == 0 {
906        return Err("linear_dictionary_transform: dictionary has no atoms".to_string());
907    }
908    if x.ncols() != atoms.ncols() {
909        return Err(format!(
910            "linear_dictionary_transform: X has P={} columns but atoms have P={}",
911            x.ncols(),
912            atoms.ncols()
913        ));
914    }
915    let effective_k = top_k.min(k).max(1);
916    top_k_assignments(x, atoms, effective_k, code_ridge)
917}
918
919fn softmax_assignments(
920    x: ArrayView2<'_, f64>,
921    atoms: ArrayView2<'_, f64>,
922    top_k: usize,
923    temperature: f64,
924    code_ridge: f64,
925) -> Result<Array2<f64>, String> {
926    let cross = x.dot(&atoms.t());
927    let atom_norm2 = atoms.map_axis(Axis(1), |row| row.dot(&row).max(MIN_NORM2));
928    let mut assignments = Array2::<f64>::zeros((x.nrows(), atoms.nrows()));
929    for row in 0..x.nrows() {
930        let active = top_indices_by_abs(cross.row(row), top_k);
931        let mut max_score = f64::NEG_INFINITY;
932        for &atom_idx in &active {
933            let score = cross[[row, atom_idx]].abs() / (atom_norm2[atom_idx].sqrt() * temperature);
934            if score > max_score {
935                max_score = score;
936            }
937        }
938        let mut denom = 0.0;
939        for &atom_idx in &active {
940            let score = cross[[row, atom_idx]].abs() / (atom_norm2[atom_idx].sqrt() * temperature);
941            let mass = (score - max_score).exp();
942            assignments[[row, atom_idx]] = mass;
943            denom += mass;
944        }
945        if denom <= 0.0 || !denom.is_finite() {
946            return Err("linear_dictionary_fit softmax assignment underflowed".to_string());
947        }
948        for &atom_idx in &active {
949            let projection = cross[[row, atom_idx]] / (atom_norm2[atom_idx] + code_ridge);
950            assignments[[row, atom_idx]] = assignments[[row, atom_idx]] * projection / denom;
951        }
952    }
953    Ok(assignments)
954}
955
956fn solve_active_coefficients(
957    atoms: ArrayView2<'_, f64>,
958    cross_row: ArrayView1<'_, f64>,
959    active: &[usize],
960    code_ridge: f64,
961) -> Result<Array1<f64>, String> {
962    let m = active.len();
963    let mut system = Array2::<f64>::zeros((m, m));
964    let mut rhs = Array2::<f64>::zeros((m, 1));
965    for i in 0..m {
966        rhs[[i, 0]] = cross_row[active[i]];
967        for j in 0..m {
968            system[[i, j]] = atoms.row(active[i]).dot(&atoms.row(active[j]));
969        }
970        system[[i, i]] += code_ridge;
971    }
972    let factor = system
973        .cholesky(Side::Lower)
974        .map_err(|err| format!("linear_dictionary_fit sparse-code solve failed: {err}"))?;
975    let mut solution = rhs;
976    factor.solve_mat_in_place(&mut solution);
977    Ok(solution.column(0).to_owned())
978}
979
980fn top_indices_by_abs(row: ArrayView1<'_, f64>, top_k: usize) -> Vec<usize> {
981    let mut selected: Vec<(usize, f64)> = Vec::with_capacity(top_k);
982    for idx in 0..row.len() {
983        let score = row[idx].abs();
984        if selected.len() < top_k {
985            selected.push((idx, score));
986            continue;
987        }
988        let mut worst_pos = 0usize;
989        for pos in 1..selected.len() {
990            if selected[pos].1 < selected[worst_pos].1
991                || (selected[pos].1 == selected[worst_pos].1
992                    && selected[pos].0 > selected[worst_pos].0)
993            {
994                worst_pos = pos;
995            }
996        }
997        let worst = selected[worst_pos];
998        if score > worst.1 || (score == worst.1 && idx < worst.0) {
999            selected[worst_pos] = (idx, score);
1000        }
1001    }
1002    selected.sort_by(|a, b| {
1003        b.1.partial_cmp(&a.1)
1004            .unwrap_or(std::cmp::Ordering::Equal)
1005            .then_with(|| a.0.cmp(&b.0))
1006    });
1007    selected.into_iter().map(|(idx, _)| idx).collect()
1008}
1009
1010fn normalize_atom_and_assignments(
1011    atoms: &mut Array2<f64>,
1012    assignments: &mut Array2<f64>,
1013    atom_idx: usize,
1014) {
1015    let norm = atoms.row(atom_idx).dot(&atoms.row(atom_idx)).sqrt();
1016    if norm > MIN_NORM2.sqrt() {
1017        atoms.row_mut(atom_idx).mapv_inplace(|value| value / norm);
1018        assignments
1019            .column_mut(atom_idx)
1020            .mapv_inplace(|value| value * norm);
1021    }
1022    orient_atom_and_code(atoms, assignments, atom_idx);
1023}
1024
1025fn orient_atom_and_code(atoms: &mut Array2<f64>, assignments: &mut Array2<f64>, atom_idx: usize) {
1026    let sign = first_nonzero_sign(atoms.row(atom_idx));
1027    if sign < 0.0 {
1028        atoms.row_mut(atom_idx).mapv_inplace(|value| -value);
1029        assignments
1030            .column_mut(atom_idx)
1031            .mapv_inplace(|value| -value);
1032    }
1033}
1034
1035fn orient_vector(vector: &mut Array1<f64>) {
1036    if first_nonzero_sign(vector.view()) < 0.0 {
1037        vector.mapv_inplace(|value| -value);
1038    }
1039}
1040
1041fn first_nonzero_sign(row: ndarray::ArrayView1<'_, f64>) -> f64 {
1042    for &value in row {
1043        if value.abs() > 1.0e-12 {
1044            return value.signum();
1045        }
1046    }
1047    1.0
1048}
1049
1050fn normalize_row(mut row: ndarray::ArrayViewMut1<'_, f64>) {
1051    let norm = row.dot(&row).sqrt();
1052    if norm > MIN_NORM2.sqrt() {
1053        row.mapv_inplace(|value| value / norm);
1054    }
1055}
1056
1057fn max_norm_row(x: ArrayView2<'_, f64>) -> usize {
1058    let mut best = 0usize;
1059    let mut best_norm = f64::NEG_INFINITY;
1060    for row in 0..x.nrows() {
1061        let norm = x.row(row).dot(&x.row(row));
1062        if norm > best_norm {
1063            best = row;
1064            best_norm = norm;
1065        }
1066    }
1067    best
1068}
1069
1070fn max_index(values: ndarray::ArrayView1<'_, f64>) -> usize {
1071    let mut best = 0usize;
1072    let mut best_value = f64::NEG_INFINITY;
1073    for idx in 0..values.len() {
1074        if values[idx] > best_value {
1075            best = idx;
1076            best_value = values[idx];
1077        }
1078    }
1079    best
1080}
1081
1082fn squared_distance(a: ndarray::ArrayView1<'_, f64>, b: ndarray::ArrayView1<'_, f64>) -> f64 {
1083    a.iter()
1084        .zip(b.iter())
1085        .map(|(av, bv)| {
1086            let diff = av - bv;
1087            diff * diff
1088        })
1089        .sum()
1090}
1091
1092fn explained_variance(x: ArrayView2<'_, f64>, fitted: ArrayView2<'_, f64>) -> f64 {
1093    let mut rss = 0.0;
1094    for row in 0..x.nrows() {
1095        for col in 0..x.ncols() {
1096            let residual = x[[row, col]] - fitted[[row, col]];
1097            rss += residual * residual;
1098        }
1099    }
1100    let means = x.mean_axis(Axis(0)).expect("non-empty input has means");
1101    let mut tss = 0.0;
1102    for row in 0..x.nrows() {
1103        for col in 0..x.ncols() {
1104            let centered = x[[row, col]] - means[col];
1105            tss += centered * centered;
1106        }
1107    }
1108    if tss <= MIN_NORM2 {
1109        if rss <= MIN_NORM2 { 1.0 } else { 0.0 }
1110    } else {
1111        1.0 - rss / tss
1112    }
1113}
1114
1115fn penalized_reconstruction_loss(
1116    x: ArrayView2<'_, f64>,
1117    fitted: ArrayView2<'_, f64>,
1118    ridge: f64,
1119    atoms: ArrayView2<'_, f64>,
1120) -> f64 {
1121    let mut loss = 0.0;
1122    for row in 0..x.nrows() {
1123        for col in 0..x.ncols() {
1124            let residual = x[[row, col]] - fitted[[row, col]];
1125            loss += residual * residual;
1126        }
1127    }
1128    loss + ridge * atoms.iter().map(|value| value * value).sum::<f64>()
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    use super::*;
1134    use approx::assert_abs_diff_eq;
1135    use ndarray::{Array2, array};
1136
1137    #[test]
1138    fn planted_sparse_linear_dictionary_reaches_high_explained_variance() {
1139        let truth = array![
1140            [1.0, 0.0, 0.0, 0.0],
1141            [0.0, 1.0, 0.0, 0.0],
1142            [0.0, 0.0, 1.0, 0.0],
1143            [0.0, 0.0, 0.0, 1.0],
1144        ];
1145        let mut assignments = Array2::<f64>::zeros((160, 4));
1146        for row in 0..160 {
1147            let atom = row % 4;
1148            assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1149            assignments[[row, (atom + 1) % 4]] = 0.2;
1150        }
1151        let x = assignments.dot(&truth);
1152        let config = LinearDictionaryConfig {
1153            n_atoms: 4,
1154            max_iter: 40,
1155            top_k: 2,
1156            assignment: LinearDictionaryAssignment::TopK,
1157            temperature: DEFAULT_TEMPERATURE,
1158            code_ridge: DEFAULT_CODE_RIDGE,
1159            tolerance: 1.0e-9,
1160            center_rank_one: false,
1161        };
1162
1163        let fit = fit_linear_dictionary(x.view(), &config).expect("linear dictionary fit");
1164
1165        assert!(
1166            fit.explained_variance > 0.95,
1167            "expected EV > 0.95, got {}",
1168            fit.explained_variance
1169        );
1170    }
1171
1172    #[test]
1173    fn coupled_topk_dictionary_reaches_fixed_point_under_small_budget_2372() {
1174        // A DIFFERENT coupled fixture from the planted 4-atom ring: three
1175        // orthonormal but NON-axis-aligned directions in R^6, each row loading a
1176        // cyclic pair (dominant on atom k, secondary on (k+1)%3). The data is
1177        // exactly representable, so the alternating-LS fixed point is EV = 1, but
1178        // the two shared atoms per row give the map a dominant slow mode whose
1179        // ratio (~0.99) needs THOUSANDS of plain sweeps to close a 1e-9 fixed-point
1180        // residual. This pins, from a different geometry than
1181        // `planted_sparse_linear_dictionary_reaches_high_explained_variance`, that
1182        // the safeguarded geometric acceleration drives the certified fixed point
1183        // inside a small iteration budget instead of grinding to `max_iter` and
1184        // raising `NonConvergence` (#2372).
1185        let truth = array![
1186            [
1187                std::f64::consts::FRAC_1_SQRT_2,
1188                std::f64::consts::FRAC_1_SQRT_2,
1189                0.0,
1190                0.0,
1191                0.0,
1192                0.0
1193            ],
1194            [
1195                std::f64::consts::FRAC_1_SQRT_2,
1196                -std::f64::consts::FRAC_1_SQRT_2,
1197                0.0,
1198                0.0,
1199                0.0,
1200                0.0
1201            ],
1202            [
1203                0.0,
1204                0.0,
1205                std::f64::consts::FRAC_1_SQRT_2,
1206                std::f64::consts::FRAC_1_SQRT_2,
1207                0.0,
1208                0.0
1209            ],
1210        ];
1211        let mut codes = Array2::<f64>::zeros((120, 3));
1212        for row in 0..120 {
1213            let atom = row % 3;
1214            codes[[row, atom]] = 0.6 + 0.02 * ((row / 3) as f64);
1215            codes[[row, (atom + 1) % 3]] = 0.3;
1216        }
1217        let x = codes.dot(&truth);
1218        let config = LinearDictionaryConfig {
1219            n_atoms: 3,
1220            max_iter: 80,
1221            top_k: 2,
1222            assignment: LinearDictionaryAssignment::TopK,
1223            temperature: DEFAULT_TEMPERATURE,
1224            code_ridge: DEFAULT_CODE_RIDGE,
1225            tolerance: 1.0e-9,
1226            center_rank_one: false,
1227        };
1228
1229        let fit = fit_linear_dictionary(x.view(), &config)
1230            .expect("acceleration must reach the fixed point within the budget");
1231        assert!(
1232            fit.explained_variance > 0.999,
1233            "coupled data must reconstruct well at the converged fixed point, got EV {}",
1234            fit.explained_variance
1235        );
1236        assert!(
1237            fit.convergence.ev_residual <= fit.convergence.tolerance,
1238            "ev_residual {} must close the {} contract",
1239            fit.convergence.ev_residual,
1240            fit.convergence.tolerance
1241        );
1242        assert!(
1243            fit.convergence.routing_residual <= fit.convergence.tolerance,
1244            "routing_residual {} must close the {} contract",
1245            fit.convergence.routing_residual,
1246            fit.convergence.tolerance
1247        );
1248        assert_eq!(fit.convergence.accepted_births, 0);
1249        // The returned assignments must be exactly the canonical reroute against the
1250        // final atoms (the acceleration must leave the model self-consistent).
1251        let canonical = reroute_against_atoms(x.view(), fit.atoms.view(), fit.top_k, &config)
1252            .expect("canonical reroute");
1253        for (returned, rerouted) in fit.assignments.iter().zip(canonical.iter()) {
1254            assert_abs_diff_eq!(*returned, *rerouted, epsilon = 1.0e-12);
1255        }
1256    }
1257
1258    #[test]
1259    fn single_atom_matches_penalized_pca_oracle() {
1260        let mut x = Array2::<f64>::zeros((80, 3));
1261        for row in 0..80 {
1262            let t = (row as f64 - 39.5) / 20.0;
1263            x[[row, 0]] = 2.0 * t;
1264            x[[row, 1]] = -t;
1265            x[[row, 2]] = 0.05 * (row as f64).sin();
1266        }
1267        let config = LinearDictionaryConfig {
1268            n_atoms: 1,
1269            max_iter: 5,
1270            top_k: 1,
1271            assignment: LinearDictionaryAssignment::TopK,
1272            temperature: DEFAULT_TEMPERATURE,
1273            code_ridge: DEFAULT_CODE_RIDGE,
1274            tolerance: DEFAULT_TOLERANCE,
1275            center_rank_one: false,
1276        };
1277
1278        let fit = fit_linear_dictionary(x.view(), &config).expect("rank-one fit");
1279        let covariance = x.t().dot(&x);
1280        let (evals, _) = covariance.eigh(Side::Lower).expect("PCA eigensolve");
1281        let shrink = 1.0 / (1.0 + DEFAULT_CODE_RIDGE);
1282        let oracle_ev = 1.0
1283            - ((1.0 - shrink) * (1.0 - shrink) * evals[evals.len() - 1]
1284                + evals.slice(s![..evals.len() - 1]).sum())
1285                / evals.sum();
1286
1287        assert!(fit.explained_variance > 0.99);
1288        assert_abs_diff_eq!(fit.explained_variance, oracle_ev, epsilon = 2.0e-4);
1289    }
1290
1291    #[test]
1292    fn orthonormal_rank_one_atoms_all_revived_no_dead_collapse_1500() {
1293        // #1500: rows lie on K mutually ORTHONORMAL rank-1 directions, so a
1294        // K-atom top_k=1 dictionary that recovers them reconstructs every row
1295        // exactly (EV → 1). The dead-atom bug emptied a cluster, zeroed that atom
1296        // permanently, and returned < K live atoms with badly under-explained
1297        // variance. With empty-cluster re-seeding every atom stays live.
1298        let (k, p, n) = (4usize, 8usize, 400usize);
1299        // Deterministic orthonormal directions: eigenvectors of a fixed symmetric
1300        // matrix are orthonormal, so no RNG is needed for a stable regression.
1301        let mut a = Array2::<f64>::zeros((p, p));
1302        for i in 0..p {
1303            for j in 0..p {
1304                a[[i, j]] = ((i * 7 + j * 3 + 1) % 11) as f64 - 5.0;
1305            }
1306        }
1307        let sym = &a + &a.t();
1308        let (_evals, evecs) = sym.eigh(Side::Lower).expect("orthonormal directions");
1309        let dirs = evecs.slice(s![.., ..k]).t().to_owned(); // k×p, orthonormal rows
1310        let mut x = Array2::<f64>::zeros((n, p));
1311        for row in 0..n {
1312            let atom = row % k;
1313            let scale = if row % 2 == 0 { 2.0 } else { -1.5 } + 0.01 * (row / k) as f64;
1314            for col in 0..p {
1315                let noise = 1.0e-3 * (((row * p + col) % 13) as f64 - 6.0);
1316                x[[row, col]] = scale * dirs[[atom, col]] + noise;
1317            }
1318        }
1319        let config = LinearDictionaryConfig {
1320            n_atoms: k,
1321            max_iter: 40,
1322            top_k: 1,
1323            assignment: LinearDictionaryAssignment::TopK,
1324            temperature: DEFAULT_TEMPERATURE,
1325            code_ridge: DEFAULT_CODE_RIDGE,
1326            tolerance: 1.0e-9,
1327            center_rank_one: false,
1328        };
1329        let fit = fit_linear_dictionary(x.view(), &config).expect("orthonormal dictionary fit");
1330        let live = fit
1331            .atoms
1332            .axis_iter(Axis(0))
1333            .filter(|atom| atom.iter().any(|value| value.abs() > 1.0e-12))
1334            .count();
1335        assert_eq!(
1336            live, k,
1337            "all {k} atoms must stay live (no dead-atom collapse); got {live} live"
1338        );
1339        assert!(
1340            fit.explained_variance > 0.99,
1341            "K orthonormal rank-1 atoms must be reconstructed at EV > 0.99; got {}",
1342            fit.explained_variance
1343        );
1344    }
1345
1346    #[test]
1347    fn returned_state_is_the_certified_canonical_routing() {
1348        // Planted sparse problem where the coordinate-descent routing and a fresh
1349        // global reroute against updated atoms generally differ. The model must be
1350        // the exact rerouted state that passed both fixed-point residual tests.
1351        let truth = array![
1352            [1.0, 0.0, 0.0, 0.0],
1353            [0.0, 1.0, 0.0, 0.0],
1354            [0.0, 0.0, 1.0, 0.0],
1355            [0.0, 0.0, 0.0, 1.0],
1356        ];
1357        let mut assignments = Array2::<f64>::zeros((160, 4));
1358        for row in 0..160 {
1359            let atom = row % 4;
1360            assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1361            assignments[[row, (atom + 1) % 4]] = 0.2;
1362        }
1363        let x = assignments.dot(&truth);
1364        let config = LinearDictionaryConfig {
1365            n_atoms: 4,
1366            max_iter: 40,
1367            top_k: 2,
1368            assignment: LinearDictionaryAssignment::TopK,
1369            temperature: DEFAULT_TEMPERATURE,
1370            code_ridge: DEFAULT_CODE_RIDGE,
1371            tolerance: 1.0e-9,
1372            center_rank_one: false,
1373        };
1374
1375        let fit = fit_linear_dictionary(x.view(), &config).expect("linear dictionary fit");
1376        assert!(fit.convergence.ev_residual <= fit.convergence.tolerance);
1377        assert!(fit.convergence.routing_residual <= fit.convergence.tolerance);
1378        assert_eq!(fit.convergence.accepted_births, 0);
1379
1380        // Returned fitted must be exactly assignments.dot(atoms) for the adopted
1381        // routing, and the reported EV must match that fitted.
1382        let canonical = reroute_against_atoms(x.view(), fit.atoms.view(), fit.top_k, &config)
1383            .expect("canonical reroute");
1384        for (returned, rerouted) in fit.assignments.iter().zip(canonical.iter()) {
1385            assert_abs_diff_eq!(*returned, *rerouted, epsilon = 1.0e-12);
1386        }
1387        let recomputed_fitted = fit.assignments.dot(&fit.atoms);
1388        for (a, b) in fit.fitted.iter().zip(recomputed_fitted.iter()) {
1389            assert_abs_diff_eq!(*a, *b, epsilon = 1.0e-10);
1390        }
1391        assert_abs_diff_eq!(
1392            fit.explained_variance,
1393            explained_variance(x.view(), fit.fitted.view()),
1394            epsilon = 1.0e-10
1395        );
1396    }
1397
1398    #[test]
1399    fn centered_rank_one_ceiling_agrees_when_data_already_centered() {
1400        // Build correlated data, then explicitly mean-center it. On centered input
1401        // the uncentered XᵀX lane and the centered helper see the same second-moment
1402        // matrix, so their EVs must agree.
1403        let mut x = Array2::<f64>::zeros((90, 3));
1404        for row in 0..90 {
1405            let t = (row as f64 - 44.5) / 25.0;
1406            x[[row, 0]] = 1.5 * t;
1407            x[[row, 1]] = -0.8 * t + 0.02 * (row as f64).cos();
1408            x[[row, 2]] = 0.6 * t;
1409        }
1410        let means = x.mean_axis(Axis(0)).unwrap();
1411        let centered = &x - &means;
1412
1413        let config = LinearDictionaryConfig::new(1);
1414        let uncentered = fit_linear_dictionary(centered.view(), &config).expect("rank-one fit");
1415        let (_fitted, centered_ev) =
1416            rank_one_centered_pca_ceiling(centered.view(), DEFAULT_CODE_RIDGE)
1417                .expect("centered ceiling");
1418
1419        assert_abs_diff_eq!(uncentered.explained_variance, centered_ev, epsilon = 1.0e-9);
1420    }
1421
1422    #[test]
1423    fn centered_rank_one_ceiling_beats_uncentered_with_strong_mean() {
1424        // Strong column mean (offset) plus a low-variance signal direction: the
1425        // uncentered XᵀX lane wastes its single rank on the mean direction and
1426        // under-explains the CENTERED variance, while the centered helper recovers
1427        // the true principal component and is a genuine, higher centered-PCA ceiling.
1428        let mut x = Array2::<f64>::zeros((120, 2));
1429        for row in 0..120 {
1430            let t = (row as f64 - 59.5) / 60.0; // small spread around the offset
1431            x[[row, 0]] = 50.0 + 0.3 * t;
1432            x[[row, 1]] = 50.0 - 0.3 * t;
1433        }
1434        let config = LinearDictionaryConfig::new(1);
1435        let uncentered = fit_linear_dictionary(x.view(), &config).expect("rank-one fit");
1436        let (fitted, centered_ev) =
1437            rank_one_centered_pca_ceiling(x.view(), DEFAULT_CODE_RIDGE).expect("centered ceiling");
1438
1439        assert!(
1440            centered_ev > uncentered.explained_variance + 1.0e-6,
1441            "centered ceiling ({centered_ev}) should beat uncentered lane ({}) on strong-mean data",
1442            uncentered.explained_variance
1443        );
1444        // The centered helper's reported EV is consistent with its returned fitted.
1445        assert_abs_diff_eq!(
1446            centered_ev,
1447            explained_variance(x.view(), fitted.view()),
1448            epsilon = 1.0e-10
1449        );
1450    }
1451
1452    #[test]
1453    fn center_rank_one_config_flag_routes_k1_lane_to_centered_ceiling() {
1454        // Strong-mean, low-variance-signal data: the default (uncentered) K=1 lane
1455        // wastes its single rank on the mean, so setting `center_rank_one = true`
1456        // must route the lane through the centered computation and report the
1457        // genuine (higher) centered-PCA ceiling — matching the standalone helper.
1458        let mut x = Array2::<f64>::zeros((100, 3));
1459        for row in 0..100 {
1460            let t = (row as f64 - 49.5) / 50.0;
1461            x[[row, 0]] = 30.0 + 0.2 * t;
1462            x[[row, 1]] = 30.0 - 0.2 * t;
1463            x[[row, 2]] = 30.0 + 0.05 * t;
1464        }
1465
1466        let default_config = LinearDictionaryConfig::new(1);
1467        assert!(
1468            !default_config.center_rank_one,
1469            "flag must default to false"
1470        );
1471        let uncentered = fit_linear_dictionary(x.view(), &default_config).expect("uncentered lane");
1472
1473        let mut centered_config = LinearDictionaryConfig::new(1);
1474        centered_config.center_rank_one = true;
1475        let centered = fit_linear_dictionary(x.view(), &centered_config).expect("centered lane");
1476
1477        // The flag actually routes to the centered lane: its EV equals the helper's
1478        // centered ceiling and strictly beats the default uncentered lane.
1479        let (_fitted, helper_ev) =
1480            rank_one_centered_pca_ceiling(x.view(), DEFAULT_CODE_RIDGE).expect("helper ceiling");
1481        assert_abs_diff_eq!(centered.explained_variance, helper_ev, epsilon = 1.0e-10);
1482        assert!(
1483            centered.explained_variance > uncentered.explained_variance + 1.0e-6,
1484            "center_rank_one=true ({}) must beat default ({}) on strong-mean data",
1485            centered.explained_variance,
1486            uncentered.explained_variance
1487        );
1488        // Centered lane reports the affine reconstruction directly, so its EV is
1489        // consistent with the returned `fitted` (which INCLUDES the mean and is not
1490        // assignments.dot(atoms) in this mode).
1491        assert_abs_diff_eq!(
1492            centered.explained_variance,
1493            explained_variance(x.view(), centered.fitted.view()),
1494            epsilon = 1.0e-10
1495        );
1496    }
1497
1498    #[test]
1499    fn nonconverged_multi_atom_fit_is_an_error_not_a_model() {
1500        // SPEC 20: an iterate that has not closed the fixed-point certificate is
1501        // numerical evidence, never a model.
1502        //
1503        // The fixture is the coupled cyclic-pair geometry of
1504        // `coupled_topk_dictionary_reaches_fixed_point_under_small_budget_2372`:
1505        // every row loads two shared atoms, so the sweep+reroute map has a dominant
1506        // slow mode (ratio ~0.99) that needs thousands of plain sweeps to close the
1507        // residual contract. The budget is two sweeps — the smallest budget the
1508        // two-sweep sequence rule can evaluate at all, and one short of the first
1509        // iteration at which the safeguarded geometric acceleration has a collinear
1510        // step pair to extrapolate along (it needs both `prev_delta` and
1511        // `this_delta`, which first coexist at iteration 2). The fit is therefore
1512        // still genuinely MOVING when the budget ends, which is what makes the
1513        // refusal residual-driven rather than an artifact of the budget: the
1514        // returned evidence must itself violate the fixed-point contract. The
1515        // independent sequence-rule law — that one agreeing pair is not a plateau
1516        // even when the residuals are zero — is pinned by
1517        // `single_sweep_cannot_certify_an_initialization_already_at_the_fixed_point`.
1518        let truth = array![
1519            [
1520                std::f64::consts::FRAC_1_SQRT_2,
1521                std::f64::consts::FRAC_1_SQRT_2,
1522                0.0,
1523                0.0,
1524                0.0,
1525                0.0
1526            ],
1527            [
1528                std::f64::consts::FRAC_1_SQRT_2,
1529                -std::f64::consts::FRAC_1_SQRT_2,
1530                0.0,
1531                0.0,
1532                0.0,
1533                0.0
1534            ],
1535            [
1536                0.0,
1537                0.0,
1538                std::f64::consts::FRAC_1_SQRT_2,
1539                std::f64::consts::FRAC_1_SQRT_2,
1540                0.0,
1541                0.0
1542            ],
1543        ];
1544        let mut codes = Array2::<f64>::zeros((120, 3));
1545        for row in 0..120 {
1546            let atom = row % 3;
1547            codes[[row, atom]] = 0.6 + 0.02 * ((row / 3) as f64);
1548            codes[[row, (atom + 1) % 3]] = 0.3;
1549        }
1550        let x = codes.dot(&truth);
1551        let config = LinearDictionaryConfig {
1552            n_atoms: 3,
1553            max_iter: 2,
1554            top_k: 2,
1555            assignment: LinearDictionaryAssignment::TopK,
1556            temperature: DEFAULT_TEMPERATURE,
1557            code_ridge: DEFAULT_CODE_RIDGE,
1558            tolerance: DEFAULT_TOLERANCE,
1559            center_rank_one: false,
1560        };
1561        let err = fit_linear_dictionary(x.view(), &config)
1562            .expect_err("a still-moving iterate cannot certify an EV plateau");
1563        match err {
1564            LinearDictionaryError::NonConvergence {
1565                iterations,
1566                explained_variance,
1567                ev_residual,
1568                routing_residual,
1569                accepted_births,
1570                tolerance,
1571            } => {
1572                assert_eq!(iterations, 2);
1573                assert!(explained_variance.is_finite());
1574                assert!(ev_residual.is_finite());
1575                assert!(routing_residual.is_finite());
1576                // The premise: this fixture is genuinely non-converged at its
1577                // budget end, so the refusal is attributable to the numerical
1578                // evidence the error carries and not to the budget alone.
1579                assert!(
1580                    ev_residual > tolerance || routing_residual > tolerance || accepted_births > 0,
1581                    "fixture must still be moving: ev_residual {ev_residual:.3e}, \
1582                     routing_residual {routing_residual:.3e}, births {accepted_births} \
1583                     against tolerance {tolerance:.3e}"
1584                );
1585                assert_eq!(tolerance, DEFAULT_TOLERANCE);
1586            }
1587            other => panic!("expected typed non-convergence evidence, got: {other}"),
1588        }
1589    }
1590
1591    #[test]
1592    fn single_sweep_cannot_certify_an_initialization_already_at_the_fixed_point() {
1593        // The complement of the test above: a plateau is a SEQUENCE property, so a
1594        // one-sweep budget must be refused EVEN WHEN that single sweep's residual
1595        // pair already agrees to machine precision. An initialization that happens
1596        // to sit at the fixed point must not self-certify without the solver ever
1597        // demonstrating stability.
1598        //
1599        // The fixture makes that situation exact rather than incidental. Rows are
1600        // the axis directions e_{i mod 3} scaled by 1 + 0.01·i, so `initialize_atoms`
1601        // seeds atom 0 from the max-norm row (row 23, direction e2) and atom 1 from
1602        // the row farthest from it (row 22, direction e1). Under top-1 routing each
1603        // e1/e2 row carries its own norm into its own atom and the e0 rows project
1604        // to zero, so the per-atom penalized-LS sweep reproduces {e2, e1} exactly:
1605        // the seeded dictionary IS a fixed point of the sweep+reroute map, and both
1606        // residuals are zero on the only sweep the budget allows.
1607        let mut x = Array2::<f64>::zeros((24, 3));
1608        for row in 0..24 {
1609            x[[row, row % 3]] = 1.0 + 0.01 * row as f64;
1610        }
1611        let mut config = LinearDictionaryConfig {
1612            n_atoms: 2,
1613            max_iter: 1,
1614            top_k: 1,
1615            assignment: LinearDictionaryAssignment::TopK,
1616            temperature: DEFAULT_TEMPERATURE,
1617            code_ridge: DEFAULT_CODE_RIDGE,
1618            tolerance: DEFAULT_TOLERANCE,
1619            center_rank_one: false,
1620        };
1621        let err = fit_linear_dictionary(x.view(), &config)
1622            .expect_err("a single sweep is one data point, not a plateau");
1623        match err {
1624            LinearDictionaryError::NonConvergence {
1625                iterations,
1626                explained_variance,
1627                ev_residual,
1628                routing_residual,
1629                accepted_births,
1630                tolerance,
1631            } => {
1632                assert_eq!(iterations, 1);
1633                assert!(explained_variance.is_finite());
1634                // The point of this fixture: the residual contract is ALREADY met on
1635                // the one sweep, and the fit is refused anyway. If these ever start
1636                // exceeding the tolerance the fixture has stopped witnessing the
1637                // sequence rule and this test would silently become a duplicate of
1638                // `nonconverged_multi_atom_fit_is_an_error_not_a_model`.
1639                assert!(
1640                    ev_residual <= tolerance,
1641                    "seeded fixed point must agree on the first sweep, got ev_residual \
1642                     {ev_residual:.3e} against tolerance {tolerance:.3e}"
1643                );
1644                assert!(
1645                    routing_residual <= tolerance,
1646                    "seeded fixed point must survive its own reroute, got routing_residual \
1647                     {routing_residual:.3e} against tolerance {tolerance:.3e}"
1648                );
1649                assert_eq!(accepted_births, 0);
1650                assert_eq!(tolerance, DEFAULT_TOLERANCE);
1651            }
1652            other => panic!("expected typed non-convergence evidence, got: {other}"),
1653        }
1654
1655        // The same problem with a budget that can complete the second sweep does
1656        // certify — so the refusal above is the sequence rule and nothing else.
1657        config.max_iter = 2;
1658        let fit = fit_linear_dictionary(x.view(), &config)
1659            .expect("two agreeing sweeps certify the plateau");
1660        assert_eq!(fit.iterations, 2);
1661        assert!(fit.convergence.ev_residual <= fit.convergence.tolerance);
1662        assert!(fit.convergence.routing_residual <= fit.convergence.tolerance);
1663        assert_eq!(fit.convergence.accepted_births, 0);
1664    }
1665
1666    #[test]
1667    fn negative_convergence_tolerance_is_rejected() {
1668        let x = array![[1.0, 0.0], [0.0, 1.0]];
1669        let mut config = LinearDictionaryConfig::new(2);
1670        config.tolerance = -f64::EPSILON;
1671        let error = fit_linear_dictionary(x.view(), &config)
1672            .expect_err("a negative residual tolerance has no convergence meaning");
1673        assert!(matches!(error, LinearDictionaryError::InvalidInput { .. }));
1674    }
1675
1676    #[test]
1677    fn sparse_assignment_scales_to_thousand_atom_dictionary() {
1678        let active_atoms = array![
1679            [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1680            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1681            [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1682            [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
1683            [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
1684            [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1685            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
1686            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1687        ];
1688        let mut x = Array2::<f64>::zeros((256, 8));
1689        for row in 0..x.nrows() {
1690            let atom = row % active_atoms.nrows();
1691            let scale = 0.7 + 0.003 * row as f64;
1692            x.row_mut(row).assign(&(&active_atoms.row(atom) * scale));
1693        }
1694        let config = LinearDictionaryConfig {
1695            n_atoms: 1024,
1696            max_iter: 8,
1697            top_k: 1,
1698            assignment: LinearDictionaryAssignment::TopK,
1699            temperature: DEFAULT_TEMPERATURE,
1700            code_ridge: DEFAULT_CODE_RIDGE,
1701            tolerance: 1.0e-9,
1702            center_rank_one: false,
1703        };
1704
1705        let fit = fit_linear_dictionary(x.view(), &config).expect("large-K linear dictionary fit");
1706        let max_active = fit
1707            .assignments
1708            .axis_iter(Axis(0))
1709            .map(|row| row.iter().filter(|value| value.abs() > 1.0e-10).count())
1710            .max()
1711            .unwrap();
1712
1713        assert_eq!(max_active, 1);
1714        assert!(
1715            fit.explained_variance > 0.95,
1716            "expected EV > 0.95 at K=1024, got {}",
1717            fit.explained_variance
1718        );
1719    }
1720
1721    /// #2372 instrument: per-sweep EV/routing trace on the planted fixture the
1722    /// two plateau tests use — discriminates a routing LIMIT CYCLE (support
1723    /// flipping between equivalent top-k routings, residual oscillating at a
1724    /// fixed amplitude) from slow drift (residual decaying but not reaching
1725    /// the 1e-9 tolerance inside the 40-sweep budget).
1726    #[test]
1727    fn zz_measure_2372_dictionary_plateau_trace() {
1728        let (x, config) = planted_fixture_for_trace();
1729        let top_k = config.top_k.min(config.n_atoms).max(1);
1730        let mut atoms = initialize_atoms(x.view(), config.n_atoms);
1731        let mut assignments =
1732            reroute_against_atoms(x.view(), atoms.view(), top_k, &config).expect("route");
1733        let mut fitted = assignments.dot(&atoms);
1734        let mut lambdas = Array1::<f64>::from_elem(config.n_atoms, INACTIVE_LAMBDA);
1735        let mut reml_scores = Array1::<f64>::zeros(config.n_atoms);
1736        let initial_ev = explained_variance(x.view(), fitted.view());
1737        let mut previous_ev = initial_ev;
1738        let mut prev_support: Option<Vec<Vec<bool>>> = None;
1739        let mut observed_sweeps = 0usize;
1740        for sweep in 0..12 {
1741            for atom_idx in 0..config.n_atoms {
1742                fit_one_atom_penalized_ls(
1743                    x.view(),
1744                    &mut atoms,
1745                    &mut assignments,
1746                    &mut fitted,
1747                    &mut lambdas,
1748                    &mut reml_scores,
1749                    atom_idx,
1750                    config.code_ridge,
1751                )
1752                .expect("atom update");
1753            }
1754            let sweep_ev = explained_variance(x.view(), fitted.view());
1755            let rerouted =
1756                reroute_against_atoms(x.view(), atoms.view(), top_k, &config).expect("route");
1757            let rerouted_fitted = rerouted.dot(&atoms);
1758            let rerouted_ev = explained_variance(x.view(), rerouted_fitted.view());
1759            let support: Vec<Vec<bool>> = (0..rerouted.nrows())
1760                .map(|i| rerouted.row(i).iter().map(|v| *v != 0.0).collect())
1761                .collect();
1762            let support_changed = prev_support.as_ref().map_or(-1_i64, |p| {
1763                p.iter()
1764                    .zip(&support)
1765                    .map(|(a, b)| a.iter().zip(b).filter(|(x, y)| x != y).count())
1766                    .sum::<usize>() as i64
1767            });
1768            eprintln!(
1769                "[zz2372:dict] sweep={sweep} sweep_ev={sweep_ev:.15} rerouted_ev={rerouted_ev:.15} ev_res={:.3e} routing_res={:.3e} support_flips={support_changed}",
1770                (rerouted_ev - previous_ev).abs(),
1771                (rerouted_ev - sweep_ev).abs(),
1772            );
1773            assert!(
1774                sweep_ev.is_finite() && rerouted_ev.is_finite(),
1775                "[zz2372:dict] sweep={sweep} produced a non-finite explained \
1776                 variance: sweep_ev={sweep_ev} rerouted_ev={rerouted_ev}"
1777            );
1778            // `explained_variance` returns 1 - RSS/TSS with RSS a sum of
1779            // squares, so EV <= 1 is an identity of the function, not a
1780            // property of the fit. The 1e-12 slack covers float summation
1781            // order on the two sums only.
1782            assert!(
1783                sweep_ev <= 1.0 + 1e-12 && rerouted_ev <= 1.0 + 1e-12,
1784                "[zz2372:dict] sweep={sweep} explained variance exceeded 1: \
1785                 sweep_ev={sweep_ev} rerouted_ev={rerouted_ev}"
1786            );
1787            observed_sweeps += 1;
1788            previous_ev = rerouted_ev;
1789            prev_support = Some(support);
1790            assignments = rerouted;
1791            fitted = rerouted_fitted;
1792        }
1793        // Deliberately NOT a per-sweep monotone-objective gate, even though
1794        // this is nominally coordinate descent. Two reasons, both structural:
1795        //   * `fit_one_atom_penalized_ls` descends a RIDGE-penalized loss whose
1796        //     lambda it re-estimates by REML on every call, so the objective it
1797        //     descends is not fixed across the sweep and the unpenalized EV
1798        //     traced here is not its Lyapunov function;
1799        //   * `reroute_against_atoms` is a greedy top-k selection, not the
1800        //     exact minimizer of that loss over assignments, so the reroute
1801        //     step can lower EV.
1802        // Whether EV actually oscillates is precisely the limit-cycle question
1803        // this instrument was cut to answer; asserting monotonicity would
1804        // encode the answer as the premise.
1805        //
1806        // What the trace can honestly claim is NET progress on a planted
1807        // 4-atom fixture: twelve full sweeps of atom refits must not leave the
1808        // fit worse than the initialization routing they started from. A red
1809        // here is divergence, not slow convergence -- and it would refute the
1810        // "slow drift" reading directly.
1811        assert_eq!(
1812            observed_sweeps, 12,
1813            "the trace must record all twelve sweeps; a short loop would make \
1814             the per-sweep gates vacuous"
1815        );
1816        assert!(
1817            previous_ev >= initial_ev - 1e-12,
1818            "[zz2372:dict] twelve coordinate-descent sweeps left the fit WORSE \
1819             than initialization: initial_ev={initial_ev:.15} \
1820             final_ev={previous_ev:.15}"
1821        );
1822    }
1823
1824    /// The same 6x12 planted two-atom overcomplete fixture
1825    /// `planted_sparse_linear_dictionary_reaches_high_explained_variance` uses,
1826    /// factored so the trace and the contract test stay on identical data.
1827    fn planted_fixture_for_trace() -> (ndarray::Array2<f64>, LinearDictionaryConfig) {
1828        let truth = array![
1829            [1.0, 0.0, 0.0, 0.0],
1830            [0.0, 1.0, 0.0, 0.0],
1831            [0.0, 0.0, 1.0, 0.0],
1832            [0.0, 0.0, 0.0, 1.0],
1833        ];
1834        let mut assignments = Array2::<f64>::zeros((160, 4));
1835        for row in 0..160 {
1836            let atom = row % 4;
1837            assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1838            assignments[[row, (atom + 1) % 4]] = 0.2;
1839        }
1840        let x = assignments.dot(&truth);
1841        let config = LinearDictionaryConfig {
1842            n_atoms: 4,
1843            max_iter: 40,
1844            top_k: 2,
1845            assignment: LinearDictionaryAssignment::TopK,
1846            temperature: DEFAULT_TEMPERATURE,
1847            code_ridge: DEFAULT_CODE_RIDGE,
1848            tolerance: 1.0e-9,
1849            center_rank_one: false,
1850        };
1851
1852        (x, config)
1853    }
1854}