1use ndarray::{Array2, ArrayView2, Axis};
8
9const JACOBI_MAX_SWEEPS: usize = 200;
17
18const JACOBI_OFFDIAG_TOL: f64 = 1.0e-14;
23
24const AUX_DISCRETE_MAX_LEVELS: usize = 64;
30
31const AUX_LEVEL_DEDUP_TOL: f64 = 1.0e-12;
35
36#[derive(Debug, Clone)]
38pub struct AuxRichnessMetrics {
39 pub aux_observed: bool,
41 pub n_nonfinite_aux: usize,
43 pub aux_dim: usize,
45 pub latent_dim: usize,
47 pub n_rows: usize,
49 pub constant_columns: Vec<usize>,
51 pub aux_is_discrete: bool,
53 pub n_distinct_levels: usize,
55 pub jacobian_rank: usize,
58 pub jacobian_rank_estimated: bool,
60}
61
62pub fn aux_richness_metrics(aux: ArrayView2<f64>, latents: ArrayView2<f64>) -> AuxRichnessMetrics {
69 let (n, aux_dim) = aux.dim();
70 let (n_z, latent_dim) = latents.dim();
71 assert_eq!(n, n_z, "aux and latents must share row count");
72
73 let mut n_nonfinite_aux: usize = 0;
75 for &v in aux.iter() {
76 if !v.is_finite() {
77 n_nonfinite_aux += 1;
78 }
79 }
80 let aux_observed = n_nonfinite_aux == 0;
81
82 let mut constant_columns: Vec<usize> = Vec::new();
85 if aux_observed && n >= 1 {
86 for j in 0..aux_dim {
87 let col = aux.column(j);
88 let mean: f64 = col.sum() / n as f64;
90 let mut var = 0.0_f64;
91 for &v in col.iter() {
92 let d = v - mean;
93 var += d * d;
94 }
95 var /= n as f64;
96 if var <= 1.0e-24 {
97 constant_columns.push(j);
98 }
99 }
100 }
101
102 let (aux_is_discrete, n_distinct_levels) = if aux_observed && n >= 1 {
104 let mut discrete = true;
105 for &v in aux.iter() {
106 if (v - v.round()).abs() > 0.0 {
107 discrete = false;
108 break;
109 }
110 }
111 if discrete {
112 for j in 0..aux_dim {
113 let col = aux.column(j);
114 let mut sorted: Vec<f64> = col.iter().copied().collect();
115 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
116 sorted.dedup_by(|a, b| (*a - *b).abs() < AUX_LEVEL_DEDUP_TOL);
117 if sorted.len() > AUX_DISCRETE_MAX_LEVELS {
118 discrete = false;
119 break;
120 }
121 }
122 }
123 if discrete {
124 let mut keys: Vec<Vec<i64>> = Vec::with_capacity(n);
126 for i in 0..n {
127 let mut row = Vec::with_capacity(aux_dim);
128 for j in 0..aux_dim {
129 row.push(aux[[i, j]].round() as i64);
130 }
131 keys.push(row);
132 }
133 keys.sort();
134 keys.dedup();
135 (true, keys.len())
136 } else {
137 (false, 0)
138 }
139 } else {
140 (false, 0)
141 };
142
143 let need_rows = aux_dim.max(latent_dim) + 1;
145 let mut jacobian_rank_estimated = false;
146 let mut jacobian_rank: usize = usize::MAX;
147 let z_finite = latents.iter().all(|v| v.is_finite());
148 if aux_observed && z_finite && n >= need_rows && aux_dim >= 1 && latent_dim >= 1 {
149 let mut a_c = aux.to_owned();
151 let mut z_c = latents.to_owned();
152 let a_mean = a_c
153 .mean_axis(Axis(0))
154 .expect("the n >= need_rows >= 1 guard above rules out an empty axis");
155 let z_mean = z_c
156 .mean_axis(Axis(0))
157 .expect("the n >= need_rows >= 1 guard above rules out an empty axis");
158 for mut row in a_c.rows_mut() {
159 row -= &a_mean;
160 }
161 for mut row in z_c.rows_mut() {
162 row -= &z_mean;
163 }
164 let ata = a_c.t().dot(&a_c);
166 let atz = a_c.t().dot(&z_c);
167 let b_hat = pinv_solve(ata.view(), atz.view());
168 jacobian_rank = matrix_rank(b_hat.view(), 1.0e-8);
169 jacobian_rank_estimated = true;
170 }
171
172 AuxRichnessMetrics {
173 aux_observed,
174 n_nonfinite_aux,
175 aux_dim,
176 latent_dim,
177 n_rows: n,
178 constant_columns,
179 aux_is_discrete,
180 n_distinct_levels,
181 jacobian_rank,
182 jacobian_rank_estimated,
183 }
184}
185
186fn pinv_solve(a: ArrayView2<f64>, b: ArrayView2<f64>) -> Array2<f64> {
190 let (m, n) = a.dim();
191 assert_eq!(m, n, "pinv_solve expects a square normal-equation matrix");
192 let (eigvals, eigvecs) = jacobi_symmetric_eigen(a);
196 let max_abs = eigvals.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
197 let tol = 1.0e-12 * max_abs.max(1.0);
198 let k = eigvals.len();
200 let mut inv_diag = vec![0.0_f64; k];
201 for i in 0..k {
202 if eigvals[i].abs() > tol {
203 inv_diag[i] = 1.0 / eigvals[i];
204 }
205 }
206 let vtb = eigvecs.t().dot(&b);
208 let mut dvtb = vtb.clone();
209 for i in 0..k {
210 let scale = inv_diag[i];
211 for j in 0..dvtb.ncols() {
212 dvtb[[i, j]] *= scale;
213 }
214 }
215 eigvecs.dot(&dvtb)
216}
217
218fn jacobi_symmetric_eigen(a: ArrayView2<f64>) -> (Vec<f64>, Array2<f64>) {
221 let n = a.nrows();
222 assert_eq!(n, a.ncols());
223 let mut m = a.to_owned();
224 let mut v = Array2::<f64>::eye(n);
225 for _ in 0..JACOBI_MAX_SWEEPS {
226 let mut p = 0usize;
228 let mut q = 1usize;
229 let mut max_off = 0.0_f64;
230 for i in 0..n {
231 for j in (i + 1)..n {
232 let av = m[[i, j]].abs();
233 if av > max_off {
234 max_off = av;
235 p = i;
236 q = j;
237 }
238 }
239 }
240 if max_off < JACOBI_OFFDIAG_TOL {
241 break;
242 }
243 let app = m[[p, p]];
244 let aqq = m[[q, q]];
245 let apq = m[[p, q]];
246 let theta = 0.5 * (aqq - app) / apq;
247 let t = if theta >= 0.0 {
248 1.0 / (theta + (1.0 + theta * theta).sqrt())
249 } else {
250 1.0 / (theta - (1.0 + theta * theta).sqrt())
251 };
252 let c = 1.0 / (1.0 + t * t).sqrt();
253 let s = t * c;
254 let new_pp = app - t * apq;
256 let new_qq = aqq + t * apq;
257 m[[p, p]] = new_pp;
258 m[[q, q]] = new_qq;
259 m[[p, q]] = 0.0;
260 m[[q, p]] = 0.0;
261 for i in 0..n {
262 if i != p && i != q {
263 let aip = m[[i, p]];
264 let aiq = m[[i, q]];
265 m[[i, p]] = c * aip - s * aiq;
266 m[[p, i]] = m[[i, p]];
267 m[[i, q]] = s * aip + c * aiq;
268 m[[q, i]] = m[[i, q]];
269 }
270 }
271 for i in 0..n {
273 let vip = v[[i, p]];
274 let viq = v[[i, q]];
275 v[[i, p]] = c * vip - s * viq;
276 v[[i, q]] = s * vip + c * viq;
277 }
278 }
279 let eigvals: Vec<f64> = (0..n).map(|i| m[[i, i]]).collect();
280 (eigvals, v)
281}
282
283fn matrix_rank(m: ArrayView2<f64>, tol: f64) -> usize {
287 let gram = m.t().dot(&m);
288 let (eigvals, _) = jacobi_symmetric_eigen(gram.view());
289 let mut rank = 0usize;
290 for &lam in eigvals.iter() {
291 if lam.max(0.0).sqrt() > tol {
292 rank += 1;
293 }
294 }
295 rank
296}
297
298#[derive(Debug, Clone)]
300pub struct JacobianSparsityMetrics {
301 pub n_samples: usize,
303 pub p_features: usize,
304 pub latent_dim: usize,
305 pub mean_sparsity: f64,
308 pub max_abs: f64,
310 pub ranks: Vec<usize>,
312}
313
314pub fn jacobian_sparsity_metrics(
319 jacobians_flat: ArrayView2<f64>,
320 n_samples: usize,
321 zero_threshold: f64,
322) -> JacobianSparsityMetrics {
323 let (np_rows, latent_dim) = jacobians_flat.dim();
324 assert!(np_rows % n_samples == 0, "rows not divisible by n_samples");
325 let p_features = np_rows / n_samples;
326
327 let mut max_abs = 0.0_f64;
329 for &v in jacobians_flat.iter() {
330 let a = v.abs();
331 if a > max_abs {
332 max_abs = a;
333 }
334 }
335 let cutoff = zero_threshold * max_abs;
336
337 let mut total_near_zero: usize = 0;
338 let total_entries = np_rows * latent_dim;
339 if max_abs > 0.0 {
340 for &v in jacobians_flat.iter() {
341 if v.abs() < cutoff {
342 total_near_zero += 1;
343 }
344 }
345 } else {
346 total_near_zero = total_entries;
348 }
349 let mean_sparsity = if total_entries > 0 {
350 total_near_zero as f64 / total_entries as f64
351 } else {
352 0.0
353 };
354
355 let mut ranks = Vec::with_capacity(n_samples);
357 for s in 0..n_samples {
358 let start = s * p_features;
359 let end = start + p_features;
360 let view = jacobians_flat.slice(ndarray::s![start..end, ..]);
361 ranks.push(matrix_rank(view, cutoff.max(1.0e-300)));
364 }
365
366 JacobianSparsityMetrics {
367 n_samples,
368 p_features,
369 latent_dim,
370 mean_sparsity,
371 max_abs,
372 ranks,
373 }
374}
375
376#[derive(Debug, Clone)]
378pub struct AnchorConsistencyMetrics {
379 pub n_rows: usize,
381 pub n_atoms: usize,
383 pub n_anchors: usize,
386 pub anchors_per_atom: Vec<usize>,
389}
390
391fn anchor_consistency_metrics(
397 assignments: ArrayView2<f64>,
398 anchor_dominance: f64,
399) -> AnchorConsistencyMetrics {
400 let (n, k) = assignments.dim();
401 let mut anchors_per_atom = vec![0_usize; k];
402 let mut n_anchors = 0_usize;
403 for i in 0..n {
404 let row = assignments.row(i);
405 let mut mass = 0.0_f64;
406 let mut max_val = 0.0_f64;
407 let mut max_j = 0_usize;
408 for j in 0..k {
409 let a = row[j].abs();
410 mass += a;
411 if a > max_val {
412 max_val = a;
413 max_j = j;
414 }
415 }
416 if mass > 0.0 && max_val / mass >= anchor_dominance {
417 n_anchors += 1;
418 anchors_per_atom[max_j] += 1;
419 }
420 }
421 AnchorConsistencyMetrics {
422 n_rows: n,
423 n_atoms: k,
424 n_anchors,
425 anchors_per_atom,
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq)]
447pub struct AnchorConsistencyPreconditions {
448 pub enough_anchors_total: bool,
450 pub anchors_cover_all_atoms: bool,
452}
453
454#[derive(Debug, Clone)]
455pub struct AnchorConsistencyReport {
456 pub metrics: AnchorConsistencyMetrics,
458 pub anchor_dominance: f64,
460 pub anchor_fraction: f64,
462 pub preconditions: AnchorConsistencyPreconditions,
464 pub violations: Vec<String>,
466 pub recommendations: Vec<String>,
468 pub uncovered_atoms: Vec<usize>,
470}
471
472impl AnchorConsistencyReport {
473 pub fn passes(&self) -> bool {
475 self.preconditions.enough_anchors_total && self.preconditions.anchors_cover_all_atoms
476 }
477}
478
479pub const ANCHOR_DOMINANCE_DEFAULT: f64 = f64::from_bits(0.5_f64.to_bits() + 1);
488
489pub fn anchor_consistency_report(
493 assignments: ArrayView2<f64>,
494 anchor_dominance: Option<f64>,
495) -> Result<AnchorConsistencyReport, String> {
496 if let Some(((row, atom), value)) = assignments
497 .indexed_iter()
498 .find(|(_, value)| !value.is_finite())
499 {
500 return Err(format!(
501 "assignments must be finite; entry ({row}, {atom}) is {value}"
502 ));
503 }
504 let anchor_dominance = anchor_dominance.unwrap_or(ANCHOR_DOMINANCE_DEFAULT);
505 if !(anchor_dominance > 0.5 && anchor_dominance <= 1.0) {
506 return Err(format!(
507 "anchor_dominance must be in (0.5, 1]; got {anchor_dominance}"
508 ));
509 }
510 let (_, k) = assignments.dim();
511 if k < 1 {
512 return Err("assignments must have at least one atom column".to_string());
513 }
514 let metrics = anchor_consistency_metrics(assignments, anchor_dominance);
515 let anchor_fraction = metrics.n_anchors as f64 / metrics.n_rows.max(1) as f64;
516
517 let mut violations = Vec::new();
518 let mut recommendations = Vec::new();
519 let mut uncovered_atoms = Vec::new();
520
521 let preconditions = if k == 1 {
522 AnchorConsistencyPreconditions {
523 enough_anchors_total: true,
524 anchors_cover_all_atoms: true,
525 }
526 } else {
527 let enough_anchors = metrics.n_anchors >= k;
528 if !enough_anchors {
529 violations.push(format!(
530 "Only {} anchor row(s) (dominance >= {:.2}) found in a K={}-atom \
531 model; need at least {}. The recovered atoms are identified only \
532 up to a linear transformation in atom space.",
533 metrics.n_anchors, anchor_dominance, k, k
534 ));
535 recommendations.push(format!(
536 "Reduce K to <= {}, sharpen the assignment prior (e.g. lower \
537 temperature / stronger IBP concentration), or collect more \
538 anchor-like rows where a single atom dominates.",
539 metrics.n_anchors.max(1)
540 ));
541 }
542 uncovered_atoms = metrics
543 .anchors_per_atom
544 .iter()
545 .enumerate()
546 .filter_map(|(j, &count)| (count == 0).then_some(j))
547 .collect();
548 let cover_ok = uncovered_atoms.is_empty();
549 if !cover_ok {
550 violations.push(format!(
551 "Atom(s) {:?} have zero anchor rows; they are not individually \
552 identifiable and may be redundant or merged with neighbours.",
553 uncovered_atoms
554 ));
555 recommendations.push(format!(
556 "Prune the {} uncovered atom(s) (refit with K={}) or strengthen \
557 the per-atom sparsity prior so that each atom acquires a \
558 dominant region.",
559 uncovered_atoms.len(),
560 (k - uncovered_atoms.len()).max(1)
561 ));
562 }
563 AnchorConsistencyPreconditions {
564 enough_anchors_total: enough_anchors,
565 anchors_cover_all_atoms: cover_ok,
566 }
567 };
568
569 Ok(AnchorConsistencyReport {
570 metrics,
571 anchor_dominance,
572 anchor_fraction,
573 preconditions,
574 violations,
575 recommendations,
576 uncovered_atoms,
577 })
578}
579
580pub fn concat_decoder_blocks(blocks: &[ArrayView2<f64>]) -> Result<Array2<f64>, String> {
586 if blocks.is_empty() {
587 return Err("concat_decoder_blocks: empty block list".into());
588 }
589 let p = blocks[0].ncols();
590 for (i, b) in blocks.iter().enumerate() {
591 if b.ncols() != p {
592 return Err(format!(
593 "concat_decoder_blocks: block {} has {} cols, expected {}",
594 i,
595 b.ncols(),
596 p
597 ));
598 }
599 }
600 let total_k: usize = blocks.iter().map(|b| b.nrows()).sum();
601 let mut out = Array2::<f64>::zeros((p, total_k));
602 let mut col = 0_usize;
603 for b in blocks {
604 for k in 0..b.nrows() {
606 for row in 0..p {
607 out[[row, col]] = b[[k, row]];
608 }
609 col += 1;
610 }
611 }
612 Ok(out)
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use ndarray::array;
619
620 #[test]
621 fn aux_richness_passes_on_rich_2d_aux() {
622 let aux = array![
623 [0.0, 0.0],
624 [0.0, 1.0],
625 [1.0, 0.0],
626 [1.0, 1.0],
627 [2.0, 0.0],
628 [2.0, 1.0],
629 [0.0, 2.0],
630 [1.0, 2.0],
631 [2.0, 2.0],
632 ];
633 let lat = array![
634 [0.10, 0.05],
635 [0.02, 1.01],
636 [1.05, 0.04],
637 [1.01, 1.02],
638 [2.03, 0.07],
639 [2.04, 1.01],
640 [0.05, 2.02],
641 [1.02, 2.01],
642 [2.01, 2.05],
643 ];
644 let m = aux_richness_metrics(aux.view(), lat.view());
645 assert!(m.aux_observed);
646 assert_eq!(m.aux_dim, 2);
647 assert_eq!(m.latent_dim, 2);
648 assert!(m.constant_columns.is_empty());
649 assert!(m.aux_is_discrete);
650 assert!(m.n_distinct_levels >= 3);
651 assert!(m.jacobian_rank_estimated);
652 assert_eq!(m.jacobian_rank, 2);
653 }
654
655 #[test]
656 fn aux_richness_flags_constant_aux() {
657 let aux = Array2::<f64>::zeros((20, 1));
658 let mut lat = Array2::<f64>::zeros((20, 2));
659 for i in 0..20 {
660 lat[[i, 0]] = i as f64;
661 lat[[i, 1]] = (i as f64).cos();
662 }
663 let m = aux_richness_metrics(aux.view(), lat.view());
664 assert_eq!(m.aux_dim, 1);
665 assert_eq!(m.latent_dim, 2);
666 assert_eq!(m.constant_columns, vec![0_usize]);
667 }
668
669 #[test]
670 fn aux_richness_flags_nonfinite_aux() {
671 let mut aux = Array2::<f64>::zeros((10, 1));
672 aux[[3, 0]] = f64::NAN;
673 let lat = Array2::<f64>::zeros((10, 1));
674 let m = aux_richness_metrics(aux.view(), lat.view());
675 assert!(!m.aux_observed);
676 assert_eq!(m.n_nonfinite_aux, 1);
677 }
678
679 #[test]
680 fn jacobian_sparsity_passes_on_diagonal() {
681 let j = array![
683 [1.0_f64, 0.0, 0.0],
684 [0.0, 1.0, 0.0],
685 [0.0, 0.0, 1.0],
686 [0.0, 0.0, 0.0]
687 ];
688 let m = jacobian_sparsity_metrics(j.view(), 1, 1.0e-3);
689 assert_eq!(m.p_features, 4);
690 assert_eq!(m.latent_dim, 3);
691 assert!(m.mean_sparsity > 0.5);
692 assert_eq!(m.ranks, vec![3_usize]);
693 }
694
695 #[test]
696 fn jacobian_sparsity_dense_has_low_sparsity() {
697 let mut j = Array2::<f64>::zeros((4, 3));
698 for i in 0..4 {
699 for k in 0..3 {
700 j[[i, k]] = 1.0 + 0.1 * (i + k) as f64;
701 }
702 }
703 let m = jacobian_sparsity_metrics(j.view(), 1, 1.0e-3);
704 assert!(m.mean_sparsity < 0.1);
705 }
706
707 #[test]
708 fn anchor_consistency_three_clusters() {
709 let mut a = Array2::<f64>::from_elem((9, 3), 0.01);
710 for i in 0..3 {
711 a[[i, 0]] = 1.0;
712 }
713 for i in 3..6 {
714 a[[i, 1]] = 1.0;
715 }
716 for i in 6..9 {
717 a[[i, 2]] = 1.0;
718 }
719 let m = anchor_consistency_metrics(a.view(), 0.95);
720 assert_eq!(m.n_atoms, 3);
721 assert_eq!(m.n_anchors, 9);
722 assert_eq!(m.anchors_per_atom, vec![3, 3, 3]);
723 }
724
725 #[test]
726 fn anchor_consistency_uniform_has_zero_anchors() {
727 let a = Array2::<f64>::from_elem((10, 4), 0.25);
728 let m = anchor_consistency_metrics(a.view(), 0.95);
729 assert_eq!(m.n_anchors, 0);
730 assert_eq!(m.anchors_per_atom, vec![0, 0, 0, 0]);
731 }
732
733 #[test]
734 fn anchor_consistency_report_owns_the_pass_fail_verdict() {
735 let a = array![[1.0_f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
736 let report = anchor_consistency_report(a.view(), None).unwrap();
737 assert_eq!(report.anchor_dominance, ANCHOR_DOMINANCE_DEFAULT);
738 assert!(report.passes());
739 assert_eq!(
740 report.preconditions,
741 AnchorConsistencyPreconditions {
742 enough_anchors_total: true,
743 anchors_cover_all_atoms: true,
744 }
745 );
746 assert!(report.uncovered_atoms.is_empty());
747 }
748
749 #[test]
750 fn anchor_consistency_report_derives_thresholds_from_atom_count() {
751 let a = Array2::<f64>::from_elem((7, 4), 0.25);
752 let report = anchor_consistency_report(a.view(), Some(0.95)).unwrap();
753 assert!(!report.passes());
754 assert_eq!(
755 report.preconditions,
756 AnchorConsistencyPreconditions {
757 enough_anchors_total: false,
758 anchors_cover_all_atoms: false,
759 }
760 );
761 assert_eq!(report.uncovered_atoms, vec![0, 1, 2, 3]);
762 assert_eq!(report.violations.len(), 2);
763 assert_eq!(report.recommendations.len(), report.violations.len());
764 assert!(report.violations[0].contains("need at least 4"));
765 }
766
767 #[test]
768 fn anchor_consistency_report_rejects_invalid_dominance() {
769 let a = Array2::<f64>::ones((2, 2));
770 let error = anchor_consistency_report(a.view(), Some(0.0)).unwrap_err();
771 assert!(error.contains("anchor_dominance must be in (0.5, 1]"));
772 }
773
774 #[test]
775 fn default_anchor_rule_is_theorem_derived_strict_majority() {
776 let tied = array![[0.5_f64, 0.5], [0.5, 0.5]];
777 let tied_report = anchor_consistency_report(tied.view(), None).unwrap();
778 assert_eq!(tied_report.metrics.n_anchors, 0);
779
780 let majority = array![[0.5_f64.next_up(), 0.5], [0.5, 0.5_f64.next_up()]];
781 let majority_report = anchor_consistency_report(majority.view(), None).unwrap();
782 assert_eq!(majority_report.metrics.n_anchors, 2);
783 assert!(majority_report.passes());
784 }
785
786 #[test]
787 fn anchor_consistency_report_rejects_non_finite_assignments() {
788 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
789 let assignments = array![[1.0_f64, 0.0], [0.0, value]];
790 let error = anchor_consistency_report(assignments.view(), None).unwrap_err();
791 assert!(error.contains("assignments must be finite"));
792 assert!(error.contains("(1, 1)"));
793 }
794 }
795
796 #[test]
797 fn anchor_consistency_report_distinguishes_count_from_atom_coverage() {
798 let a = array![
799 [1.0_f64, 0.0, 0.0],
800 [1.0, 0.0, 0.0],
801 [1.0, 0.0, 0.0],
802 [0.0, 1.0, 0.0],
803 ];
804 let report = anchor_consistency_report(a.view(), None).unwrap();
805 assert!(report.preconditions.enough_anchors_total);
806 assert!(!report.preconditions.anchors_cover_all_atoms);
807 assert_eq!(report.uncovered_atoms, vec![2]);
808 assert!(!report.passes());
809 assert_eq!(report.violations.len(), 1);
810 }
811}