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
747fn initialize_atoms(x: ArrayView2<'_, f64>, n_atoms: usize) -> Array2<f64> {
748    let mut atoms = Array2::<f64>::zeros((n_atoms, x.ncols()));
749    let first = max_norm_row(x);
750    atoms.row_mut(0).assign(&x.row(first));
751    normalize_row(atoms.slice_mut(s![0, ..]));
752    let mut min_dist2 = Array1::<f64>::from_elem(x.nrows(), f64::INFINITY);
753
754    for atom_idx in 1..n_atoms {
755        let prev = atoms.row(atom_idx - 1);
756        for row in 0..x.nrows() {
757            let dist2 = squared_distance(x.row(row), prev);
758            if dist2 < min_dist2[row] {
759                min_dist2[row] = dist2;
760            }
761        }
762        let chosen = if atom_idx < x.nrows() {
763            max_index(min_dist2.view())
764        } else {
765            atom_idx % x.nrows()
766        };
767        atoms.row_mut(atom_idx).assign(&x.row(chosen));
768        normalize_row(atoms.slice_mut(s![atom_idx, ..]));
769    }
770    atoms
771}
772
773fn fit_one_atom_penalized_ls(
774    x: ArrayView2<'_, f64>,
775    atoms: &mut Array2<f64>,
776    assignments: &mut Array2<f64>,
777    fitted: &mut Array2<f64>,
778    lambdas: &mut Array1<f64>,
779    reml_scores: &mut Array1<f64>,
780    atom_idx: usize,
781    atom_ridge: f64,
782) -> Result<bool, String> {
783    let code = assignments.column(atom_idx).to_owned();
784    let code_norm2 = code.dot(&code);
785    if code_norm2 <= MIN_NORM2 {
786        // #1500: this atom's cluster is EMPTY (no rows routed to it by the
787        // assignment step). Zeroing it here made the atom permanently DEAD — a
788        // zero atom has zero similarity to every row, so `top_k_assignments`
789        // never routes anything back to it, the dictionary collapses to < K live
790        // atoms, and it under-explains variance even when the data is exactly K
791        // rank-1 atoms a K-atom dictionary could reconstruct perfectly. Instead
792        // RE-SEED the atom into the worst-currently-reconstructed direction (the
793        // standard k-means empty-cluster cure): point it at the largest-residual
794        // row's UNEXPLAINED component so the next assignment sweep can route that
795        // row's cluster to it and revive it. Returns `true` so the outer loop
796        // suppresses convergence this iteration (the revived atom has no code
797        // yet, so EV is momentarily flat — converging now would strand it).
798        let mut worst_row = 0usize;
799        let mut worst_res2 = -1.0_f64;
800        for row in 0..x.nrows() {
801            let mut res2 = 0.0_f64;
802            for col in 0..x.ncols() {
803                let d = x[[row, col]] - fitted[[row, col]];
804                res2 += d * d;
805            }
806            if res2 > worst_res2 {
807                worst_res2 = res2;
808                worst_row = row;
809            }
810        }
811        if worst_res2 <= MIN_NORM2 {
812            // Every row is already fully reconstructed by the other atoms: there
813            // is no unexplained direction to seed, so this atom is genuinely
814            // redundant capacity. Leave it inactive (this is not the bug).
815            atoms.row_mut(atom_idx).fill(0.0);
816            lambdas[atom_idx] = INACTIVE_LAMBDA;
817            reml_scores[atom_idx] = 0.0;
818            return Ok(false);
819        }
820        for col in 0..x.ncols() {
821            atoms[[atom_idx, col]] = x[[worst_row, col]] - fitted[[worst_row, col]];
822        }
823        normalize_row(atoms.slice_mut(s![atom_idx, ..]));
824        lambdas[atom_idx] = atom_ridge;
825        reml_scores[atom_idx] =
826            penalized_reconstruction_loss(x, fitted.view(), atom_ridge, atoms.view());
827        return Ok(true);
828    }
829
830    let old_atom = atoms.row(atom_idx).to_owned();
831    let mut residual = x.to_owned() - fitted.view();
832    residual += &code
833        .view()
834        .insert_axis(Axis(1))
835        .dot(&old_atom.view().insert_axis(Axis(0)));
836
837    let denominator = code_norm2 + atom_ridge;
838    for col in 0..x.ncols() {
839        atoms[[atom_idx, col]] = code.dot(&residual.column(col)) / denominator;
840    }
841    lambdas[atom_idx] = atom_ridge;
842    normalize_atom_and_assignments(atoms, assignments, atom_idx);
843    let updated_code = assignments.column(atom_idx).to_owned();
844    fitted.assign(&x);
845    *fitted -= &residual;
846    *fitted += &updated_code
847        .view()
848        .insert_axis(Axis(1))
849        .dot(&atoms.row(atom_idx).insert_axis(Axis(0)));
850    reml_scores[atom_idx] =
851        penalized_reconstruction_loss(x, fitted.view(), atom_ridge, atoms.view());
852    Ok(false)
853}
854
855fn top_k_assignments(
856    x: ArrayView2<'_, f64>,
857    atoms: ArrayView2<'_, f64>,
858    top_k: usize,
859    code_ridge: f64,
860) -> Result<Array2<f64>, String> {
861    let cross = x.dot(&atoms.t());
862    let mut assignments = Array2::<f64>::zeros((x.nrows(), atoms.nrows()));
863    for row in 0..x.nrows() {
864        let active = top_indices_by_abs(cross.row(row), top_k);
865        let coeffs = solve_active_coefficients(atoms, cross.row(row), &active, code_ridge)?;
866        for pos in 0..active.len() {
867            assignments[[row, active[pos]]] = coeffs[pos];
868        }
869    }
870    Ok(assignments)
871}
872
873/// Encode held-out rows `x` (`M x P`) against a frozen dictionary `atoms`
874/// (`K x P`) using the same top-`top_k` ridge least-squares routing the fit
875/// uses against its final atoms. Returns the `(M, K)` sparse code matrix.
876///
877/// This is the out-of-sample `transform`/encode step for a fitted linear
878/// dictionary; the math (top-k selection + active-set ridge solve) lives in
879/// the Rust core so the Python facade stays a thin wrapper.
880pub fn linear_dictionary_transform(
881    x: ArrayView2<'_, f64>,
882    atoms: ArrayView2<'_, f64>,
883    top_k: usize,
884    code_ridge: f64,
885) -> Result<Array2<f64>, String> {
886    let k = atoms.nrows();
887    if k == 0 {
888        return Err("linear_dictionary_transform: dictionary has no atoms".to_string());
889    }
890    if x.ncols() != atoms.ncols() {
891        return Err(format!(
892            "linear_dictionary_transform: X has P={} columns but atoms have P={}",
893            x.ncols(),
894            atoms.ncols()
895        ));
896    }
897    let effective_k = top_k.min(k).max(1);
898    top_k_assignments(x, atoms, effective_k, code_ridge)
899}
900
901fn softmax_assignments(
902    x: ArrayView2<'_, f64>,
903    atoms: ArrayView2<'_, f64>,
904    top_k: usize,
905    temperature: f64,
906    code_ridge: f64,
907) -> Result<Array2<f64>, String> {
908    let cross = x.dot(&atoms.t());
909    let atom_norm2 = atoms.map_axis(Axis(1), |row| row.dot(&row).max(MIN_NORM2));
910    let mut assignments = Array2::<f64>::zeros((x.nrows(), atoms.nrows()));
911    for row in 0..x.nrows() {
912        let active = top_indices_by_abs(cross.row(row), top_k);
913        let mut max_score = f64::NEG_INFINITY;
914        for &atom_idx in &active {
915            let score = cross[[row, atom_idx]].abs() / (atom_norm2[atom_idx].sqrt() * temperature);
916            if score > max_score {
917                max_score = score;
918            }
919        }
920        let mut denom = 0.0;
921        for &atom_idx in &active {
922            let score = cross[[row, atom_idx]].abs() / (atom_norm2[atom_idx].sqrt() * temperature);
923            let mass = (score - max_score).exp();
924            assignments[[row, atom_idx]] = mass;
925            denom += mass;
926        }
927        if denom <= 0.0 || !denom.is_finite() {
928            return Err("linear_dictionary_fit softmax assignment underflowed".to_string());
929        }
930        for &atom_idx in &active {
931            let projection = cross[[row, atom_idx]] / (atom_norm2[atom_idx] + code_ridge);
932            assignments[[row, atom_idx]] = assignments[[row, atom_idx]] * projection / denom;
933        }
934    }
935    Ok(assignments)
936}
937
938fn solve_active_coefficients(
939    atoms: ArrayView2<'_, f64>,
940    cross_row: ArrayView1<'_, f64>,
941    active: &[usize],
942    code_ridge: f64,
943) -> Result<Array1<f64>, String> {
944    let m = active.len();
945    let mut system = Array2::<f64>::zeros((m, m));
946    let mut rhs = Array2::<f64>::zeros((m, 1));
947    for i in 0..m {
948        rhs[[i, 0]] = cross_row[active[i]];
949        for j in 0..m {
950            system[[i, j]] = atoms.row(active[i]).dot(&atoms.row(active[j]));
951        }
952        system[[i, i]] += code_ridge;
953    }
954    let factor = system
955        .cholesky(Side::Lower)
956        .map_err(|err| format!("linear_dictionary_fit sparse-code solve failed: {err}"))?;
957    let mut solution = rhs;
958    factor.solve_mat_in_place(&mut solution);
959    Ok(solution.column(0).to_owned())
960}
961
962fn top_indices_by_abs(row: ArrayView1<'_, f64>, top_k: usize) -> Vec<usize> {
963    let mut selected: Vec<(usize, f64)> = Vec::with_capacity(top_k);
964    for idx in 0..row.len() {
965        let score = row[idx].abs();
966        if selected.len() < top_k {
967            selected.push((idx, score));
968            continue;
969        }
970        let mut worst_pos = 0usize;
971        for pos in 1..selected.len() {
972            if selected[pos].1 < selected[worst_pos].1
973                || (selected[pos].1 == selected[worst_pos].1
974                    && selected[pos].0 > selected[worst_pos].0)
975            {
976                worst_pos = pos;
977            }
978        }
979        let worst = selected[worst_pos];
980        if score > worst.1 || (score == worst.1 && idx < worst.0) {
981            selected[worst_pos] = (idx, score);
982        }
983    }
984    selected.sort_by(|a, b| {
985        b.1.partial_cmp(&a.1)
986            .unwrap_or(std::cmp::Ordering::Equal)
987            .then_with(|| a.0.cmp(&b.0))
988    });
989    selected.into_iter().map(|(idx, _)| idx).collect()
990}
991
992fn normalize_atom_and_assignments(
993    atoms: &mut Array2<f64>,
994    assignments: &mut Array2<f64>,
995    atom_idx: usize,
996) {
997    let norm = atoms.row(atom_idx).dot(&atoms.row(atom_idx)).sqrt();
998    if norm > MIN_NORM2.sqrt() {
999        atoms.row_mut(atom_idx).mapv_inplace(|value| value / norm);
1000        assignments
1001            .column_mut(atom_idx)
1002            .mapv_inplace(|value| value * norm);
1003    }
1004    orient_atom_and_code(atoms, assignments, atom_idx);
1005}
1006
1007fn orient_atom_and_code(atoms: &mut Array2<f64>, assignments: &mut Array2<f64>, atom_idx: usize) {
1008    let sign = first_nonzero_sign(atoms.row(atom_idx));
1009    if sign < 0.0 {
1010        atoms.row_mut(atom_idx).mapv_inplace(|value| -value);
1011        assignments
1012            .column_mut(atom_idx)
1013            .mapv_inplace(|value| -value);
1014    }
1015}
1016
1017fn orient_vector(vector: &mut Array1<f64>) {
1018    if first_nonzero_sign(vector.view()) < 0.0 {
1019        vector.mapv_inplace(|value| -value);
1020    }
1021}
1022
1023fn first_nonzero_sign(row: ndarray::ArrayView1<'_, f64>) -> f64 {
1024    for &value in row {
1025        if value.abs() > 1.0e-12 {
1026            return value.signum();
1027        }
1028    }
1029    1.0
1030}
1031
1032fn normalize_row(mut row: ndarray::ArrayViewMut1<'_, f64>) {
1033    let norm = row.dot(&row).sqrt();
1034    if norm > MIN_NORM2.sqrt() {
1035        row.mapv_inplace(|value| value / norm);
1036    }
1037}
1038
1039fn max_norm_row(x: ArrayView2<'_, f64>) -> usize {
1040    let mut best = 0usize;
1041    let mut best_norm = f64::NEG_INFINITY;
1042    for row in 0..x.nrows() {
1043        let norm = x.row(row).dot(&x.row(row));
1044        if norm > best_norm {
1045            best = row;
1046            best_norm = norm;
1047        }
1048    }
1049    best
1050}
1051
1052fn max_index(values: ndarray::ArrayView1<'_, f64>) -> usize {
1053    let mut best = 0usize;
1054    let mut best_value = f64::NEG_INFINITY;
1055    for idx in 0..values.len() {
1056        if values[idx] > best_value {
1057            best = idx;
1058            best_value = values[idx];
1059        }
1060    }
1061    best
1062}
1063
1064fn squared_distance(a: ndarray::ArrayView1<'_, f64>, b: ndarray::ArrayView1<'_, f64>) -> f64 {
1065    a.iter()
1066        .zip(b.iter())
1067        .map(|(av, bv)| {
1068            let diff = av - bv;
1069            diff * diff
1070        })
1071        .sum()
1072}
1073
1074fn explained_variance(x: ArrayView2<'_, f64>, fitted: ArrayView2<'_, f64>) -> f64 {
1075    let mut rss = 0.0;
1076    for row in 0..x.nrows() {
1077        for col in 0..x.ncols() {
1078            let residual = x[[row, col]] - fitted[[row, col]];
1079            rss += residual * residual;
1080        }
1081    }
1082    let means = x.mean_axis(Axis(0)).expect("non-empty input has means");
1083    let mut tss = 0.0;
1084    for row in 0..x.nrows() {
1085        for col in 0..x.ncols() {
1086            let centered = x[[row, col]] - means[col];
1087            tss += centered * centered;
1088        }
1089    }
1090    if tss <= MIN_NORM2 {
1091        if rss <= MIN_NORM2 { 1.0 } else { 0.0 }
1092    } else {
1093        1.0 - rss / tss
1094    }
1095}
1096
1097fn penalized_reconstruction_loss(
1098    x: ArrayView2<'_, f64>,
1099    fitted: ArrayView2<'_, f64>,
1100    ridge: f64,
1101    atoms: ArrayView2<'_, f64>,
1102) -> f64 {
1103    let mut loss = 0.0;
1104    for row in 0..x.nrows() {
1105        for col in 0..x.ncols() {
1106            let residual = x[[row, col]] - fitted[[row, col]];
1107            loss += residual * residual;
1108        }
1109    }
1110    loss + ridge * atoms.iter().map(|value| value * value).sum::<f64>()
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116    use approx::assert_abs_diff_eq;
1117    use ndarray::{Array2, array};
1118
1119    #[test]
1120    fn planted_sparse_linear_dictionary_reaches_high_explained_variance() {
1121        let truth = array![
1122            [1.0, 0.0, 0.0, 0.0],
1123            [0.0, 1.0, 0.0, 0.0],
1124            [0.0, 0.0, 1.0, 0.0],
1125            [0.0, 0.0, 0.0, 1.0],
1126        ];
1127        let mut assignments = Array2::<f64>::zeros((160, 4));
1128        for row in 0..160 {
1129            let atom = row % 4;
1130            assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1131            assignments[[row, (atom + 1) % 4]] = 0.2;
1132        }
1133        let x = assignments.dot(&truth);
1134        let config = LinearDictionaryConfig {
1135            n_atoms: 4,
1136            max_iter: 40,
1137            top_k: 2,
1138            assignment: LinearDictionaryAssignment::TopK,
1139            temperature: DEFAULT_TEMPERATURE,
1140            code_ridge: DEFAULT_CODE_RIDGE,
1141            tolerance: 1.0e-9,
1142            center_rank_one: false,
1143        };
1144
1145        let fit = fit_linear_dictionary(x.view(), &config).expect("linear dictionary fit");
1146
1147        assert!(
1148            fit.explained_variance > 0.95,
1149            "expected EV > 0.95, got {}",
1150            fit.explained_variance
1151        );
1152    }
1153
1154    #[test]
1155    fn coupled_topk_dictionary_reaches_fixed_point_under_small_budget_2372() {
1156        // A DIFFERENT coupled fixture from the planted 4-atom ring: three
1157        // orthonormal but NON-axis-aligned directions in R^6, each row loading a
1158        // cyclic pair (dominant on atom k, secondary on (k+1)%3). The data is
1159        // exactly representable, so the alternating-LS fixed point is EV = 1, but
1160        // the two shared atoms per row give the map a dominant slow mode whose
1161        // ratio (~0.99) needs THOUSANDS of plain sweeps to close a 1e-9 fixed-point
1162        // residual. This pins, from a different geometry than
1163        // `planted_sparse_linear_dictionary_reaches_high_explained_variance`, that
1164        // the safeguarded geometric acceleration drives the certified fixed point
1165        // inside a small iteration budget instead of grinding to `max_iter` and
1166        // raising `NonConvergence` (#2372).
1167        let truth = array![
1168            [
1169                std::f64::consts::FRAC_1_SQRT_2,
1170                std::f64::consts::FRAC_1_SQRT_2,
1171                0.0,
1172                0.0,
1173                0.0,
1174                0.0
1175            ],
1176            [
1177                std::f64::consts::FRAC_1_SQRT_2,
1178                -std::f64::consts::FRAC_1_SQRT_2,
1179                0.0,
1180                0.0,
1181                0.0,
1182                0.0
1183            ],
1184            [
1185                0.0,
1186                0.0,
1187                std::f64::consts::FRAC_1_SQRT_2,
1188                std::f64::consts::FRAC_1_SQRT_2,
1189                0.0,
1190                0.0
1191            ],
1192        ];
1193        let mut codes = Array2::<f64>::zeros((120, 3));
1194        for row in 0..120 {
1195            let atom = row % 3;
1196            codes[[row, atom]] = 0.6 + 0.02 * ((row / 3) as f64);
1197            codes[[row, (atom + 1) % 3]] = 0.3;
1198        }
1199        let x = codes.dot(&truth);
1200        let config = LinearDictionaryConfig {
1201            n_atoms: 3,
1202            max_iter: 80,
1203            top_k: 2,
1204            assignment: LinearDictionaryAssignment::TopK,
1205            temperature: DEFAULT_TEMPERATURE,
1206            code_ridge: DEFAULT_CODE_RIDGE,
1207            tolerance: 1.0e-9,
1208            center_rank_one: false,
1209        };
1210
1211        let fit = fit_linear_dictionary(x.view(), &config)
1212            .expect("acceleration must reach the fixed point within the budget");
1213        assert!(
1214            fit.explained_variance > 0.999,
1215            "coupled data must reconstruct well at the converged fixed point, got EV {}",
1216            fit.explained_variance
1217        );
1218        assert!(
1219            fit.convergence.ev_residual <= fit.convergence.tolerance,
1220            "ev_residual {} must close the {} contract",
1221            fit.convergence.ev_residual,
1222            fit.convergence.tolerance
1223        );
1224        assert!(
1225            fit.convergence.routing_residual <= fit.convergence.tolerance,
1226            "routing_residual {} must close the {} contract",
1227            fit.convergence.routing_residual,
1228            fit.convergence.tolerance
1229        );
1230        assert_eq!(fit.convergence.accepted_births, 0);
1231        // The returned assignments must be exactly the canonical reroute against the
1232        // final atoms (the acceleration must leave the model self-consistent).
1233        let canonical = reroute_against_atoms(x.view(), fit.atoms.view(), fit.top_k, &config)
1234            .expect("canonical reroute");
1235        for (returned, rerouted) in fit.assignments.iter().zip(canonical.iter()) {
1236            assert_abs_diff_eq!(*returned, *rerouted, epsilon = 1.0e-12);
1237        }
1238    }
1239
1240    #[test]
1241    fn single_atom_matches_penalized_pca_oracle() {
1242        let mut x = Array2::<f64>::zeros((80, 3));
1243        for row in 0..80 {
1244            let t = (row as f64 - 39.5) / 20.0;
1245            x[[row, 0]] = 2.0 * t;
1246            x[[row, 1]] = -t;
1247            x[[row, 2]] = 0.05 * (row as f64).sin();
1248        }
1249        let config = LinearDictionaryConfig {
1250            n_atoms: 1,
1251            max_iter: 5,
1252            top_k: 1,
1253            assignment: LinearDictionaryAssignment::TopK,
1254            temperature: DEFAULT_TEMPERATURE,
1255            code_ridge: DEFAULT_CODE_RIDGE,
1256            tolerance: DEFAULT_TOLERANCE,
1257            center_rank_one: false,
1258        };
1259
1260        let fit = fit_linear_dictionary(x.view(), &config).expect("rank-one fit");
1261        let covariance = x.t().dot(&x);
1262        let (evals, _) = covariance.eigh(Side::Lower).expect("PCA eigensolve");
1263        let shrink = 1.0 / (1.0 + DEFAULT_CODE_RIDGE);
1264        let oracle_ev = 1.0
1265            - ((1.0 - shrink) * (1.0 - shrink) * evals[evals.len() - 1]
1266                + evals.slice(s![..evals.len() - 1]).sum())
1267                / evals.sum();
1268
1269        assert!(fit.explained_variance > 0.99);
1270        assert_abs_diff_eq!(fit.explained_variance, oracle_ev, epsilon = 2.0e-4);
1271    }
1272
1273    #[test]
1274    fn orthonormal_rank_one_atoms_all_revived_no_dead_collapse_1500() {
1275        // #1500: rows lie on K mutually ORTHONORMAL rank-1 directions, so a
1276        // K-atom top_k=1 dictionary that recovers them reconstructs every row
1277        // exactly (EV → 1). The dead-atom bug emptied a cluster, zeroed that atom
1278        // permanently, and returned < K live atoms with badly under-explained
1279        // variance. With empty-cluster re-seeding every atom stays live.
1280        let (k, p, n) = (4usize, 8usize, 400usize);
1281        // Deterministic orthonormal directions: eigenvectors of a fixed symmetric
1282        // matrix are orthonormal, so no RNG is needed for a stable regression.
1283        let mut a = Array2::<f64>::zeros((p, p));
1284        for i in 0..p {
1285            for j in 0..p {
1286                a[[i, j]] = ((i * 7 + j * 3 + 1) % 11) as f64 - 5.0;
1287            }
1288        }
1289        let sym = &a + &a.t();
1290        let (_evals, evecs) = sym.eigh(Side::Lower).expect("orthonormal directions");
1291        let dirs = evecs.slice(s![.., ..k]).t().to_owned(); // k×p, orthonormal rows
1292        let mut x = Array2::<f64>::zeros((n, p));
1293        for row in 0..n {
1294            let atom = row % k;
1295            let scale = if row % 2 == 0 { 2.0 } else { -1.5 } + 0.01 * (row / k) as f64;
1296            for col in 0..p {
1297                let noise = 1.0e-3 * (((row * p + col) % 13) as f64 - 6.0);
1298                x[[row, col]] = scale * dirs[[atom, col]] + noise;
1299            }
1300        }
1301        let config = LinearDictionaryConfig {
1302            n_atoms: k,
1303            max_iter: 40,
1304            top_k: 1,
1305            assignment: LinearDictionaryAssignment::TopK,
1306            temperature: DEFAULT_TEMPERATURE,
1307            code_ridge: DEFAULT_CODE_RIDGE,
1308            tolerance: 1.0e-9,
1309            center_rank_one: false,
1310        };
1311        let fit = fit_linear_dictionary(x.view(), &config).expect("orthonormal dictionary fit");
1312        let live = fit
1313            .atoms
1314            .axis_iter(Axis(0))
1315            .filter(|atom| atom.iter().any(|value| value.abs() > 1.0e-12))
1316            .count();
1317        assert_eq!(
1318            live, k,
1319            "all {k} atoms must stay live (no dead-atom collapse); got {live} live"
1320        );
1321        assert!(
1322            fit.explained_variance > 0.99,
1323            "K orthonormal rank-1 atoms must be reconstructed at EV > 0.99; got {}",
1324            fit.explained_variance
1325        );
1326    }
1327
1328    #[test]
1329    fn returned_state_is_the_certified_canonical_routing() {
1330        // Planted sparse problem where the coordinate-descent routing and a fresh
1331        // global reroute against updated atoms generally differ. The model must be
1332        // the exact rerouted state that passed both fixed-point residual tests.
1333        let truth = array![
1334            [1.0, 0.0, 0.0, 0.0],
1335            [0.0, 1.0, 0.0, 0.0],
1336            [0.0, 0.0, 1.0, 0.0],
1337            [0.0, 0.0, 0.0, 1.0],
1338        ];
1339        let mut assignments = Array2::<f64>::zeros((160, 4));
1340        for row in 0..160 {
1341            let atom = row % 4;
1342            assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1343            assignments[[row, (atom + 1) % 4]] = 0.2;
1344        }
1345        let x = assignments.dot(&truth);
1346        let config = LinearDictionaryConfig {
1347            n_atoms: 4,
1348            max_iter: 40,
1349            top_k: 2,
1350            assignment: LinearDictionaryAssignment::TopK,
1351            temperature: DEFAULT_TEMPERATURE,
1352            code_ridge: DEFAULT_CODE_RIDGE,
1353            tolerance: 1.0e-9,
1354            center_rank_one: false,
1355        };
1356
1357        let fit = fit_linear_dictionary(x.view(), &config).expect("linear dictionary fit");
1358        assert!(fit.convergence.ev_residual <= fit.convergence.tolerance);
1359        assert!(fit.convergence.routing_residual <= fit.convergence.tolerance);
1360        assert_eq!(fit.convergence.accepted_births, 0);
1361
1362        // Returned fitted must be exactly assignments.dot(atoms) for the adopted
1363        // routing, and the reported EV must match that fitted.
1364        let canonical = reroute_against_atoms(x.view(), fit.atoms.view(), fit.top_k, &config)
1365            .expect("canonical reroute");
1366        for (returned, rerouted) in fit.assignments.iter().zip(canonical.iter()) {
1367            assert_abs_diff_eq!(*returned, *rerouted, epsilon = 1.0e-12);
1368        }
1369        let recomputed_fitted = fit.assignments.dot(&fit.atoms);
1370        for (a, b) in fit.fitted.iter().zip(recomputed_fitted.iter()) {
1371            assert_abs_diff_eq!(*a, *b, epsilon = 1.0e-10);
1372        }
1373        assert_abs_diff_eq!(
1374            fit.explained_variance,
1375            explained_variance(x.view(), fit.fitted.view()),
1376            epsilon = 1.0e-10
1377        );
1378    }
1379
1380    #[test]
1381    fn nonconverged_multi_atom_fit_is_an_error_not_a_model() {
1382        // SPEC 20: an iterate that has not closed the fixed-point certificate is
1383        // numerical evidence, never a model.
1384        //
1385        // The fixture is the coupled cyclic-pair geometry of
1386        // `coupled_topk_dictionary_reaches_fixed_point_under_small_budget_2372`:
1387        // every row loads two shared atoms, so the sweep+reroute map has a dominant
1388        // slow mode (ratio ~0.99) that needs thousands of plain sweeps to close the
1389        // residual contract. The budget is two sweeps — the smallest budget the
1390        // two-sweep sequence rule can evaluate at all, and one short of the first
1391        // iteration at which the safeguarded geometric acceleration has a collinear
1392        // step pair to extrapolate along (it needs both `prev_delta` and
1393        // `this_delta`, which first coexist at iteration 2). The fit is therefore
1394        // still genuinely MOVING when the budget ends, which is what makes the
1395        // refusal residual-driven rather than an artifact of the budget: the
1396        // returned evidence must itself violate the fixed-point contract. The
1397        // independent sequence-rule law — that one agreeing pair is not a plateau
1398        // even when the residuals are zero — is pinned by
1399        // `single_sweep_cannot_certify_an_initialization_already_at_the_fixed_point`.
1400        let truth = array![
1401            [
1402                std::f64::consts::FRAC_1_SQRT_2,
1403                std::f64::consts::FRAC_1_SQRT_2,
1404                0.0,
1405                0.0,
1406                0.0,
1407                0.0
1408            ],
1409            [
1410                std::f64::consts::FRAC_1_SQRT_2,
1411                -std::f64::consts::FRAC_1_SQRT_2,
1412                0.0,
1413                0.0,
1414                0.0,
1415                0.0
1416            ],
1417            [
1418                0.0,
1419                0.0,
1420                std::f64::consts::FRAC_1_SQRT_2,
1421                std::f64::consts::FRAC_1_SQRT_2,
1422                0.0,
1423                0.0
1424            ],
1425        ];
1426        let mut codes = Array2::<f64>::zeros((120, 3));
1427        for row in 0..120 {
1428            let atom = row % 3;
1429            codes[[row, atom]] = 0.6 + 0.02 * ((row / 3) as f64);
1430            codes[[row, (atom + 1) % 3]] = 0.3;
1431        }
1432        let x = codes.dot(&truth);
1433        let config = LinearDictionaryConfig {
1434            n_atoms: 3,
1435            max_iter: 2,
1436            top_k: 2,
1437            assignment: LinearDictionaryAssignment::TopK,
1438            temperature: DEFAULT_TEMPERATURE,
1439            code_ridge: DEFAULT_CODE_RIDGE,
1440            tolerance: DEFAULT_TOLERANCE,
1441            center_rank_one: false,
1442        };
1443        let err = fit_linear_dictionary(x.view(), &config)
1444            .expect_err("a still-moving iterate cannot certify an EV plateau");
1445        match err {
1446            LinearDictionaryError::NonConvergence {
1447                iterations,
1448                explained_variance,
1449                ev_residual,
1450                routing_residual,
1451                accepted_births,
1452                tolerance,
1453            } => {
1454                assert_eq!(iterations, 2);
1455                assert!(explained_variance.is_finite());
1456                assert!(ev_residual.is_finite());
1457                assert!(routing_residual.is_finite());
1458                // The premise: this fixture is genuinely non-converged at its
1459                // budget end, so the refusal is attributable to the numerical
1460                // evidence the error carries and not to the budget alone.
1461                assert!(
1462                    ev_residual > tolerance || routing_residual > tolerance || accepted_births > 0,
1463                    "fixture must still be moving: ev_residual {ev_residual:.3e}, \
1464                     routing_residual {routing_residual:.3e}, births {accepted_births} \
1465                     against tolerance {tolerance:.3e}"
1466                );
1467                assert_eq!(tolerance, DEFAULT_TOLERANCE);
1468            }
1469            other => panic!("expected typed non-convergence evidence, got: {other}"),
1470        }
1471    }
1472
1473    #[test]
1474    fn single_sweep_cannot_certify_an_initialization_already_at_the_fixed_point() {
1475        // The complement of the test above: a plateau is a SEQUENCE property, so a
1476        // one-sweep budget must be refused EVEN WHEN that single sweep's residual
1477        // pair already agrees to machine precision. An initialization that happens
1478        // to sit at the fixed point must not self-certify without the solver ever
1479        // demonstrating stability.
1480        //
1481        // The fixture makes that situation exact rather than incidental. Rows are
1482        // the axis directions e_{i mod 3} scaled by 1 + 0.01·i, so `initialize_atoms`
1483        // seeds atom 0 from the max-norm row (row 23, direction e2) and atom 1 from
1484        // the row farthest from it (row 22, direction e1). Under top-1 routing each
1485        // e1/e2 row carries its own norm into its own atom and the e0 rows project
1486        // to zero, so the per-atom penalized-LS sweep reproduces {e2, e1} exactly:
1487        // the seeded dictionary IS a fixed point of the sweep+reroute map, and both
1488        // residuals are zero on the only sweep the budget allows.
1489        let mut x = Array2::<f64>::zeros((24, 3));
1490        for row in 0..24 {
1491            x[[row, row % 3]] = 1.0 + 0.01 * row as f64;
1492        }
1493        let mut config = LinearDictionaryConfig {
1494            n_atoms: 2,
1495            max_iter: 1,
1496            top_k: 1,
1497            assignment: LinearDictionaryAssignment::TopK,
1498            temperature: DEFAULT_TEMPERATURE,
1499            code_ridge: DEFAULT_CODE_RIDGE,
1500            tolerance: DEFAULT_TOLERANCE,
1501            center_rank_one: false,
1502        };
1503        let err = fit_linear_dictionary(x.view(), &config)
1504            .expect_err("a single sweep is one data point, not a plateau");
1505        match err {
1506            LinearDictionaryError::NonConvergence {
1507                iterations,
1508                explained_variance,
1509                ev_residual,
1510                routing_residual,
1511                accepted_births,
1512                tolerance,
1513            } => {
1514                assert_eq!(iterations, 1);
1515                assert!(explained_variance.is_finite());
1516                // The point of this fixture: the residual contract is ALREADY met on
1517                // the one sweep, and the fit is refused anyway. If these ever start
1518                // exceeding the tolerance the fixture has stopped witnessing the
1519                // sequence rule and this test would silently become a duplicate of
1520                // `nonconverged_multi_atom_fit_is_an_error_not_a_model`.
1521                assert!(
1522                    ev_residual <= tolerance,
1523                    "seeded fixed point must agree on the first sweep, got ev_residual \
1524                     {ev_residual:.3e} against tolerance {tolerance:.3e}"
1525                );
1526                assert!(
1527                    routing_residual <= tolerance,
1528                    "seeded fixed point must survive its own reroute, got routing_residual \
1529                     {routing_residual:.3e} against tolerance {tolerance:.3e}"
1530                );
1531                assert_eq!(accepted_births, 0);
1532                assert_eq!(tolerance, DEFAULT_TOLERANCE);
1533            }
1534            other => panic!("expected typed non-convergence evidence, got: {other}"),
1535        }
1536
1537        // The same problem with a budget that can complete the second sweep does
1538        // certify — so the refusal above is the sequence rule and nothing else.
1539        config.max_iter = 2;
1540        let fit = fit_linear_dictionary(x.view(), &config)
1541            .expect("two agreeing sweeps certify the plateau");
1542        assert_eq!(fit.iterations, 2);
1543        assert!(fit.convergence.ev_residual <= fit.convergence.tolerance);
1544        assert!(fit.convergence.routing_residual <= fit.convergence.tolerance);
1545        assert_eq!(fit.convergence.accepted_births, 0);
1546    }
1547
1548    #[test]
1549    fn negative_convergence_tolerance_is_rejected() {
1550        let x = array![[1.0, 0.0], [0.0, 1.0]];
1551        let mut config = LinearDictionaryConfig::new(2);
1552        config.tolerance = -f64::EPSILON;
1553        let error = fit_linear_dictionary(x.view(), &config)
1554            .expect_err("a negative residual tolerance has no convergence meaning");
1555        assert!(matches!(error, LinearDictionaryError::InvalidInput { .. }));
1556    }
1557
1558    #[test]
1559    fn sparse_assignment_scales_to_thousand_atom_dictionary() {
1560        let active_atoms = array![
1561            [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1562            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1563            [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1564            [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
1565            [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
1566            [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1567            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
1568            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1569        ];
1570        let mut x = Array2::<f64>::zeros((256, 8));
1571        for row in 0..x.nrows() {
1572            let atom = row % active_atoms.nrows();
1573            let scale = 0.7 + 0.003 * row as f64;
1574            x.row_mut(row).assign(&(&active_atoms.row(atom) * scale));
1575        }
1576        let config = LinearDictionaryConfig {
1577            n_atoms: 1024,
1578            max_iter: 8,
1579            top_k: 1,
1580            assignment: LinearDictionaryAssignment::TopK,
1581            temperature: DEFAULT_TEMPERATURE,
1582            code_ridge: DEFAULT_CODE_RIDGE,
1583            tolerance: 1.0e-9,
1584            center_rank_one: false,
1585        };
1586
1587        let fit = fit_linear_dictionary(x.view(), &config).expect("large-K linear dictionary fit");
1588        let max_active = fit
1589            .assignments
1590            .axis_iter(Axis(0))
1591            .map(|row| row.iter().filter(|value| value.abs() > 1.0e-10).count())
1592            .max()
1593            .unwrap();
1594
1595        assert_eq!(max_active, 1);
1596        assert!(
1597            fit.explained_variance > 0.95,
1598            "expected EV > 0.95 at K=1024, got {}",
1599            fit.explained_variance
1600        );
1601    }
1602
1603    /// #2372 instrument: per-sweep EV/routing trace on the planted fixture the
1604    /// two plateau tests use — discriminates a routing LIMIT CYCLE (support
1605    /// flipping between equivalent top-k routings, residual oscillating at a
1606    /// fixed amplitude) from slow drift (residual decaying but not reaching
1607    /// the 1e-9 tolerance inside the 40-sweep budget).
1608    #[test]
1609    fn zz_measure_2372_dictionary_plateau_trace() {
1610        let (x, config) = planted_fixture_for_trace();
1611        let top_k = config.top_k.min(config.n_atoms).max(1);
1612        let mut atoms = initialize_atoms(x.view(), config.n_atoms);
1613        let mut assignments =
1614            reroute_against_atoms(x.view(), atoms.view(), top_k, &config).expect("route");
1615        let mut fitted = assignments.dot(&atoms);
1616        let mut lambdas = Array1::<f64>::from_elem(config.n_atoms, INACTIVE_LAMBDA);
1617        let mut reml_scores = Array1::<f64>::zeros(config.n_atoms);
1618        let initial_ev = explained_variance(x.view(), fitted.view());
1619        let mut previous_ev = initial_ev;
1620        let mut prev_support: Option<Vec<Vec<bool>>> = None;
1621        let mut observed_sweeps = 0usize;
1622        for sweep in 0..12 {
1623            for atom_idx in 0..config.n_atoms {
1624                fit_one_atom_penalized_ls(
1625                    x.view(),
1626                    &mut atoms,
1627                    &mut assignments,
1628                    &mut fitted,
1629                    &mut lambdas,
1630                    &mut reml_scores,
1631                    atom_idx,
1632                    config.code_ridge,
1633                )
1634                .expect("atom update");
1635            }
1636            let sweep_ev = explained_variance(x.view(), fitted.view());
1637            let rerouted =
1638                reroute_against_atoms(x.view(), atoms.view(), top_k, &config).expect("route");
1639            let rerouted_fitted = rerouted.dot(&atoms);
1640            let rerouted_ev = explained_variance(x.view(), rerouted_fitted.view());
1641            let support: Vec<Vec<bool>> = (0..rerouted.nrows())
1642                .map(|i| rerouted.row(i).iter().map(|v| *v != 0.0).collect())
1643                .collect();
1644            let support_changed = prev_support.as_ref().map_or(-1_i64, |p| {
1645                p.iter()
1646                    .zip(&support)
1647                    .map(|(a, b)| a.iter().zip(b).filter(|(x, y)| x != y).count())
1648                    .sum::<usize>() as i64
1649            });
1650            eprintln!(
1651                "[zz2372:dict] sweep={sweep} sweep_ev={sweep_ev:.15} rerouted_ev={rerouted_ev:.15} ev_res={:.3e} routing_res={:.3e} support_flips={support_changed}",
1652                (rerouted_ev - previous_ev).abs(),
1653                (rerouted_ev - sweep_ev).abs(),
1654            );
1655            assert!(
1656                sweep_ev.is_finite() && rerouted_ev.is_finite(),
1657                "[zz2372:dict] sweep={sweep} produced a non-finite explained \
1658                 variance: sweep_ev={sweep_ev} rerouted_ev={rerouted_ev}"
1659            );
1660            // `explained_variance` returns 1 - RSS/TSS with RSS a sum of
1661            // squares, so EV <= 1 is an identity of the function, not a
1662            // property of the fit. The 1e-12 slack covers float summation
1663            // order on the two sums only.
1664            assert!(
1665                sweep_ev <= 1.0 + 1e-12 && rerouted_ev <= 1.0 + 1e-12,
1666                "[zz2372:dict] sweep={sweep} explained variance exceeded 1: \
1667                 sweep_ev={sweep_ev} rerouted_ev={rerouted_ev}"
1668            );
1669            observed_sweeps += 1;
1670            previous_ev = rerouted_ev;
1671            prev_support = Some(support);
1672            assignments = rerouted;
1673            fitted = rerouted_fitted;
1674        }
1675        // Deliberately NOT a per-sweep monotone-objective gate, even though
1676        // this is nominally coordinate descent. Two reasons, both structural:
1677        //   * `fit_one_atom_penalized_ls` descends a RIDGE-penalized loss whose
1678        //     lambda it re-estimates by REML on every call, so the objective it
1679        //     descends is not fixed across the sweep and the unpenalized EV
1680        //     traced here is not its Lyapunov function;
1681        //   * `reroute_against_atoms` is a greedy top-k selection, not the
1682        //     exact minimizer of that loss over assignments, so the reroute
1683        //     step can lower EV.
1684        // Whether EV actually oscillates is precisely the limit-cycle question
1685        // this instrument was cut to answer; asserting monotonicity would
1686        // encode the answer as the premise.
1687        //
1688        // What the trace can honestly claim is NET progress on a planted
1689        // 4-atom fixture: twelve full sweeps of atom refits must not leave the
1690        // fit worse than the initialization routing they started from. A red
1691        // here is divergence, not slow convergence -- and it would refute the
1692        // "slow drift" reading directly.
1693        assert_eq!(
1694            observed_sweeps, 12,
1695            "the trace must record all twelve sweeps; a short loop would make \
1696             the per-sweep gates vacuous"
1697        );
1698        assert!(
1699            previous_ev >= initial_ev - 1e-12,
1700            "[zz2372:dict] twelve coordinate-descent sweeps left the fit WORSE \
1701             than initialization: initial_ev={initial_ev:.15} \
1702             final_ev={previous_ev:.15}"
1703        );
1704    }
1705
1706    /// The same 6x12 planted two-atom overcomplete fixture
1707    /// `planted_sparse_linear_dictionary_reaches_high_explained_variance` uses,
1708    /// factored so the trace and the contract test stay on identical data.
1709    fn planted_fixture_for_trace() -> (ndarray::Array2<f64>, LinearDictionaryConfig) {
1710        let truth = array![
1711            [1.0, 0.0, 0.0, 0.0],
1712            [0.0, 1.0, 0.0, 0.0],
1713            [0.0, 0.0, 1.0, 0.0],
1714            [0.0, 0.0, 0.0, 1.0],
1715        ];
1716        let mut assignments = Array2::<f64>::zeros((160, 4));
1717        for row in 0..160 {
1718            let atom = row % 4;
1719            assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1720            assignments[[row, (atom + 1) % 4]] = 0.2;
1721        }
1722        let x = assignments.dot(&truth);
1723        let config = LinearDictionaryConfig {
1724            n_atoms: 4,
1725            max_iter: 40,
1726            top_k: 2,
1727            assignment: LinearDictionaryAssignment::TopK,
1728            temperature: DEFAULT_TEMPERATURE,
1729            code_ridge: DEFAULT_CODE_RIDGE,
1730            tolerance: 1.0e-9,
1731            center_rank_one: false,
1732        };
1733
1734        (x, config)
1735    }
1736}