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
14const 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;
31const 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#[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 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#[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#[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
207pub 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
230fn 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 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 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 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 && iteration >= 1
408 {
409 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 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
453fn 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 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
529fn 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
599fn 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
655fn 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
694struct CenteredRankOne {
697 atom: Array1<f64>,
699 codes: Array1<f64>,
701 fitted: Array2<f64>,
703 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(¢ered);
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 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 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
873pub 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 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 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 let (k, p, n) = (4usize, 8usize, 400usize);
1281 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(); 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 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 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 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 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 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 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 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 #[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 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 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 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}