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.mean_axis(Axis(0)).unwrap();
153 let z_mean = z_c.mean_axis(Axis(0)).unwrap();
154 for mut row in a_c.rows_mut() {
155 row -= &a_mean;
156 }
157 for mut row in z_c.rows_mut() {
158 row -= &z_mean;
159 }
160 let ata = a_c.t().dot(&a_c);
162 let atz = a_c.t().dot(&z_c);
163 let b_hat = pinv_solve(ata.view(), atz.view());
164 jacobian_rank = matrix_rank(b_hat.view(), 1.0e-8);
165 jacobian_rank_estimated = true;
166 }
167
168 AuxRichnessMetrics {
169 aux_observed,
170 n_nonfinite_aux,
171 aux_dim,
172 latent_dim,
173 n_rows: n,
174 constant_columns,
175 aux_is_discrete,
176 n_distinct_levels,
177 jacobian_rank,
178 jacobian_rank_estimated,
179 }
180}
181
182fn pinv_solve(a: ArrayView2<f64>, b: ArrayView2<f64>) -> Array2<f64> {
186 let (m, n) = a.dim();
187 assert_eq!(m, n, "pinv_solve expects a square normal-equation matrix");
188 let (eigvals, eigvecs) = jacobi_symmetric_eigen(a);
192 let max_abs = eigvals.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
193 let tol = 1.0e-12 * max_abs.max(1.0);
194 let k = eigvals.len();
196 let mut inv_diag = vec![0.0_f64; k];
197 for i in 0..k {
198 if eigvals[i].abs() > tol {
199 inv_diag[i] = 1.0 / eigvals[i];
200 }
201 }
202 let vtb = eigvecs.t().dot(&b);
204 let mut dvtb = vtb.clone();
205 for i in 0..k {
206 let scale = inv_diag[i];
207 for j in 0..dvtb.ncols() {
208 dvtb[[i, j]] *= scale;
209 }
210 }
211 eigvecs.dot(&dvtb)
212}
213
214fn jacobi_symmetric_eigen(a: ArrayView2<f64>) -> (Vec<f64>, Array2<f64>) {
217 let n = a.nrows();
218 assert_eq!(n, a.ncols());
219 let mut m = a.to_owned();
220 let mut v = Array2::<f64>::eye(n);
221 for _ in 0..JACOBI_MAX_SWEEPS {
222 let mut p = 0usize;
224 let mut q = 1usize;
225 let mut max_off = 0.0_f64;
226 for i in 0..n {
227 for j in (i + 1)..n {
228 let av = m[[i, j]].abs();
229 if av > max_off {
230 max_off = av;
231 p = i;
232 q = j;
233 }
234 }
235 }
236 if max_off < JACOBI_OFFDIAG_TOL {
237 break;
238 }
239 let app = m[[p, p]];
240 let aqq = m[[q, q]];
241 let apq = m[[p, q]];
242 let theta = 0.5 * (aqq - app) / apq;
243 let t = if theta >= 0.0 {
244 1.0 / (theta + (1.0 + theta * theta).sqrt())
245 } else {
246 1.0 / (theta - (1.0 + theta * theta).sqrt())
247 };
248 let c = 1.0 / (1.0 + t * t).sqrt();
249 let s = t * c;
250 let new_pp = app - t * apq;
252 let new_qq = aqq + t * apq;
253 m[[p, p]] = new_pp;
254 m[[q, q]] = new_qq;
255 m[[p, q]] = 0.0;
256 m[[q, p]] = 0.0;
257 for i in 0..n {
258 if i != p && i != q {
259 let aip = m[[i, p]];
260 let aiq = m[[i, q]];
261 m[[i, p]] = c * aip - s * aiq;
262 m[[p, i]] = m[[i, p]];
263 m[[i, q]] = s * aip + c * aiq;
264 m[[q, i]] = m[[i, q]];
265 }
266 }
267 for i in 0..n {
269 let vip = v[[i, p]];
270 let viq = v[[i, q]];
271 v[[i, p]] = c * vip - s * viq;
272 v[[i, q]] = s * vip + c * viq;
273 }
274 }
275 let eigvals: Vec<f64> = (0..n).map(|i| m[[i, i]]).collect();
276 (eigvals, v)
277}
278
279fn matrix_rank(m: ArrayView2<f64>, tol: f64) -> usize {
283 let gram = m.t().dot(&m);
284 let (eigvals, _) = jacobi_symmetric_eigen(gram.view());
285 let mut rank = 0usize;
286 for &lam in eigvals.iter() {
287 if lam.max(0.0).sqrt() > tol {
288 rank += 1;
289 }
290 }
291 rank
292}
293
294#[derive(Debug, Clone)]
296pub struct JacobianSparsityMetrics {
297 pub n_samples: usize,
299 pub p_features: usize,
300 pub latent_dim: usize,
301 pub mean_sparsity: f64,
304 pub max_abs: f64,
306 pub ranks: Vec<usize>,
308}
309
310pub fn jacobian_sparsity_metrics(
315 jacobians_flat: ArrayView2<f64>,
316 n_samples: usize,
317 zero_threshold: f64,
318) -> JacobianSparsityMetrics {
319 let (np_rows, latent_dim) = jacobians_flat.dim();
320 assert!(np_rows % n_samples == 0, "rows not divisible by n_samples");
321 let p_features = np_rows / n_samples;
322
323 let mut max_abs = 0.0_f64;
325 for &v in jacobians_flat.iter() {
326 let a = v.abs();
327 if a > max_abs {
328 max_abs = a;
329 }
330 }
331 let cutoff = zero_threshold * max_abs;
332
333 let mut total_near_zero: usize = 0;
334 let total_entries = np_rows * latent_dim;
335 if max_abs > 0.0 {
336 for &v in jacobians_flat.iter() {
337 if v.abs() < cutoff {
338 total_near_zero += 1;
339 }
340 }
341 } else {
342 total_near_zero = total_entries;
344 }
345 let mean_sparsity = if total_entries > 0 {
346 total_near_zero as f64 / total_entries as f64
347 } else {
348 0.0
349 };
350
351 let mut ranks = Vec::with_capacity(n_samples);
353 for s in 0..n_samples {
354 let start = s * p_features;
355 let end = start + p_features;
356 let view = jacobians_flat.slice(ndarray::s![start..end, ..]);
357 ranks.push(matrix_rank(view, cutoff.max(1.0e-300)));
360 }
361
362 JacobianSparsityMetrics {
363 n_samples,
364 p_features,
365 latent_dim,
366 mean_sparsity,
367 max_abs,
368 ranks,
369 }
370}
371
372#[derive(Debug, Clone)]
374pub struct AnchorConsistencyMetrics {
375 pub n_rows: usize,
377 pub n_atoms: usize,
379 pub n_anchors: usize,
382 pub anchors_per_atom: Vec<usize>,
385}
386
387fn anchor_consistency_metrics(
393 assignments: ArrayView2<f64>,
394 anchor_dominance: f64,
395) -> AnchorConsistencyMetrics {
396 let (n, k) = assignments.dim();
397 let mut anchors_per_atom = vec![0_usize; k];
398 let mut n_anchors = 0_usize;
399 for i in 0..n {
400 let row = assignments.row(i);
401 let mut mass = 0.0_f64;
402 let mut max_val = 0.0_f64;
403 let mut max_j = 0_usize;
404 for j in 0..k {
405 let a = row[j].abs();
406 mass += a;
407 if a > max_val {
408 max_val = a;
409 max_j = j;
410 }
411 }
412 if mass > 0.0 && max_val / mass >= anchor_dominance {
413 n_anchors += 1;
414 anchors_per_atom[max_j] += 1;
415 }
416 }
417 AnchorConsistencyMetrics {
418 n_rows: n,
419 n_atoms: k,
420 n_anchors,
421 anchors_per_atom,
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct AnchorConsistencyPreconditions {
444 pub enough_anchors_total: bool,
446 pub anchors_cover_all_atoms: bool,
448}
449
450#[derive(Debug, Clone)]
451pub struct AnchorConsistencyReport {
452 pub metrics: AnchorConsistencyMetrics,
454 pub anchor_dominance: f64,
456 pub anchor_fraction: f64,
458 pub preconditions: AnchorConsistencyPreconditions,
460 pub violations: Vec<String>,
462 pub recommendations: Vec<String>,
464 pub uncovered_atoms: Vec<usize>,
466}
467
468impl AnchorConsistencyReport {
469 pub fn passes(&self) -> bool {
471 self.preconditions.enough_anchors_total && self.preconditions.anchors_cover_all_atoms
472 }
473}
474
475pub const ANCHOR_DOMINANCE_DEFAULT: f64 = f64::from_bits(0.5_f64.to_bits() + 1);
484
485pub fn anchor_consistency_report(
489 assignments: ArrayView2<f64>,
490 anchor_dominance: Option<f64>,
491) -> Result<AnchorConsistencyReport, String> {
492 if let Some(((row, atom), value)) = assignments
493 .indexed_iter()
494 .find(|(_, value)| !value.is_finite())
495 {
496 return Err(format!(
497 "assignments must be finite; entry ({row}, {atom}) is {value}"
498 ));
499 }
500 let anchor_dominance = anchor_dominance.unwrap_or(ANCHOR_DOMINANCE_DEFAULT);
501 if !(anchor_dominance > 0.5 && anchor_dominance <= 1.0) {
502 return Err(format!(
503 "anchor_dominance must be in (0.5, 1]; got {anchor_dominance}"
504 ));
505 }
506 let (_, k) = assignments.dim();
507 if k < 1 {
508 return Err("assignments must have at least one atom column".to_string());
509 }
510 let metrics = anchor_consistency_metrics(assignments, anchor_dominance);
511 let anchor_fraction = metrics.n_anchors as f64 / metrics.n_rows.max(1) as f64;
512
513 let mut violations = Vec::new();
514 let mut recommendations = Vec::new();
515 let mut uncovered_atoms = Vec::new();
516
517 let preconditions = if k == 1 {
518 AnchorConsistencyPreconditions {
519 enough_anchors_total: true,
520 anchors_cover_all_atoms: true,
521 }
522 } else {
523 let enough_anchors = metrics.n_anchors >= k;
524 if !enough_anchors {
525 violations.push(format!(
526 "Only {} anchor row(s) (dominance >= {:.2}) found in a K={}-atom \
527 model; need at least {}. The recovered atoms are identified only \
528 up to a linear transformation in atom space.",
529 metrics.n_anchors, anchor_dominance, k, k
530 ));
531 recommendations.push(format!(
532 "Reduce K to <= {}, sharpen the assignment prior (e.g. lower \
533 temperature / stronger IBP concentration), or collect more \
534 anchor-like rows where a single atom dominates.",
535 metrics.n_anchors.max(1)
536 ));
537 }
538 uncovered_atoms = metrics
539 .anchors_per_atom
540 .iter()
541 .enumerate()
542 .filter_map(|(j, &count)| (count == 0).then_some(j))
543 .collect();
544 let cover_ok = uncovered_atoms.is_empty();
545 if !cover_ok {
546 violations.push(format!(
547 "Atom(s) {:?} have zero anchor rows; they are not individually \
548 identifiable and may be redundant or merged with neighbours.",
549 uncovered_atoms
550 ));
551 recommendations.push(format!(
552 "Prune the {} uncovered atom(s) (refit with K={}) or strengthen \
553 the per-atom sparsity prior so that each atom acquires a \
554 dominant region.",
555 uncovered_atoms.len(),
556 (k - uncovered_atoms.len()).max(1)
557 ));
558 }
559 AnchorConsistencyPreconditions {
560 enough_anchors_total: enough_anchors,
561 anchors_cover_all_atoms: cover_ok,
562 }
563 };
564
565 Ok(AnchorConsistencyReport {
566 metrics,
567 anchor_dominance,
568 anchor_fraction,
569 preconditions,
570 violations,
571 recommendations,
572 uncovered_atoms,
573 })
574}
575
576pub fn concat_decoder_blocks(blocks: &[ArrayView2<f64>]) -> Result<Array2<f64>, String> {
582 if blocks.is_empty() {
583 return Err("concat_decoder_blocks: empty block list".into());
584 }
585 let p = blocks[0].ncols();
586 for (i, b) in blocks.iter().enumerate() {
587 if b.ncols() != p {
588 return Err(format!(
589 "concat_decoder_blocks: block {} has {} cols, expected {}",
590 i,
591 b.ncols(),
592 p
593 ));
594 }
595 }
596 let total_k: usize = blocks.iter().map(|b| b.nrows()).sum();
597 let mut out = Array2::<f64>::zeros((p, total_k));
598 let mut col = 0_usize;
599 for b in blocks {
600 for k in 0..b.nrows() {
602 for row in 0..p {
603 out[[row, col]] = b[[k, row]];
604 }
605 col += 1;
606 }
607 }
608 Ok(out)
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use ndarray::array;
615
616 #[test]
617 fn aux_richness_passes_on_rich_2d_aux() {
618 let aux = array![
619 [0.0, 0.0],
620 [0.0, 1.0],
621 [1.0, 0.0],
622 [1.0, 1.0],
623 [2.0, 0.0],
624 [2.0, 1.0],
625 [0.0, 2.0],
626 [1.0, 2.0],
627 [2.0, 2.0],
628 ];
629 let lat = array![
630 [0.10, 0.05],
631 [0.02, 1.01],
632 [1.05, 0.04],
633 [1.01, 1.02],
634 [2.03, 0.07],
635 [2.04, 1.01],
636 [0.05, 2.02],
637 [1.02, 2.01],
638 [2.01, 2.05],
639 ];
640 let m = aux_richness_metrics(aux.view(), lat.view());
641 assert!(m.aux_observed);
642 assert_eq!(m.aux_dim, 2);
643 assert_eq!(m.latent_dim, 2);
644 assert!(m.constant_columns.is_empty());
645 assert!(m.aux_is_discrete);
646 assert!(m.n_distinct_levels >= 3);
647 assert!(m.jacobian_rank_estimated);
648 assert_eq!(m.jacobian_rank, 2);
649 }
650
651 #[test]
652 fn aux_richness_flags_constant_aux() {
653 let aux = Array2::<f64>::zeros((20, 1));
654 let mut lat = Array2::<f64>::zeros((20, 2));
655 for i in 0..20 {
656 lat[[i, 0]] = i as f64;
657 lat[[i, 1]] = (i as f64).cos();
658 }
659 let m = aux_richness_metrics(aux.view(), lat.view());
660 assert_eq!(m.aux_dim, 1);
661 assert_eq!(m.latent_dim, 2);
662 assert_eq!(m.constant_columns, vec![0_usize]);
663 }
664
665 #[test]
666 fn aux_richness_flags_nonfinite_aux() {
667 let mut aux = Array2::<f64>::zeros((10, 1));
668 aux[[3, 0]] = f64::NAN;
669 let lat = Array2::<f64>::zeros((10, 1));
670 let m = aux_richness_metrics(aux.view(), lat.view());
671 assert!(!m.aux_observed);
672 assert_eq!(m.n_nonfinite_aux, 1);
673 }
674
675 #[test]
676 fn jacobian_sparsity_passes_on_diagonal() {
677 let j = array![
679 [1.0_f64, 0.0, 0.0],
680 [0.0, 1.0, 0.0],
681 [0.0, 0.0, 1.0],
682 [0.0, 0.0, 0.0]
683 ];
684 let m = jacobian_sparsity_metrics(j.view(), 1, 1.0e-3);
685 assert_eq!(m.p_features, 4);
686 assert_eq!(m.latent_dim, 3);
687 assert!(m.mean_sparsity > 0.5);
688 assert_eq!(m.ranks, vec![3_usize]);
689 }
690
691 #[test]
692 fn jacobian_sparsity_dense_has_low_sparsity() {
693 let mut j = Array2::<f64>::zeros((4, 3));
694 for i in 0..4 {
695 for k in 0..3 {
696 j[[i, k]] = 1.0 + 0.1 * (i + k) as f64;
697 }
698 }
699 let m = jacobian_sparsity_metrics(j.view(), 1, 1.0e-3);
700 assert!(m.mean_sparsity < 0.1);
701 }
702
703 #[test]
704 fn anchor_consistency_three_clusters() {
705 let mut a = Array2::<f64>::from_elem((9, 3), 0.01);
706 for i in 0..3 {
707 a[[i, 0]] = 1.0;
708 }
709 for i in 3..6 {
710 a[[i, 1]] = 1.0;
711 }
712 for i in 6..9 {
713 a[[i, 2]] = 1.0;
714 }
715 let m = anchor_consistency_metrics(a.view(), 0.95);
716 assert_eq!(m.n_atoms, 3);
717 assert_eq!(m.n_anchors, 9);
718 assert_eq!(m.anchors_per_atom, vec![3, 3, 3]);
719 }
720
721 #[test]
722 fn anchor_consistency_uniform_has_zero_anchors() {
723 let a = Array2::<f64>::from_elem((10, 4), 0.25);
724 let m = anchor_consistency_metrics(a.view(), 0.95);
725 assert_eq!(m.n_anchors, 0);
726 assert_eq!(m.anchors_per_atom, vec![0, 0, 0, 0]);
727 }
728
729 #[test]
730 fn anchor_consistency_report_owns_the_pass_fail_verdict() {
731 let a = array![[1.0_f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
732 let report = anchor_consistency_report(a.view(), None).unwrap();
733 assert_eq!(report.anchor_dominance, ANCHOR_DOMINANCE_DEFAULT);
734 assert!(report.passes());
735 assert_eq!(
736 report.preconditions,
737 AnchorConsistencyPreconditions {
738 enough_anchors_total: true,
739 anchors_cover_all_atoms: true,
740 }
741 );
742 assert!(report.uncovered_atoms.is_empty());
743 }
744
745 #[test]
746 fn anchor_consistency_report_derives_thresholds_from_atom_count() {
747 let a = Array2::<f64>::from_elem((7, 4), 0.25);
748 let report = anchor_consistency_report(a.view(), Some(0.95)).unwrap();
749 assert!(!report.passes());
750 assert_eq!(
751 report.preconditions,
752 AnchorConsistencyPreconditions {
753 enough_anchors_total: false,
754 anchors_cover_all_atoms: false,
755 }
756 );
757 assert_eq!(report.uncovered_atoms, vec![0, 1, 2, 3]);
758 assert_eq!(report.violations.len(), 2);
759 assert_eq!(report.recommendations.len(), report.violations.len());
760 assert!(report.violations[0].contains("need at least 4"));
761 }
762
763 #[test]
764 fn anchor_consistency_report_rejects_invalid_dominance() {
765 let a = Array2::<f64>::ones((2, 2));
766 let error = anchor_consistency_report(a.view(), Some(0.0)).unwrap_err();
767 assert!(error.contains("anchor_dominance must be in (0.5, 1]"));
768 }
769
770 #[test]
771 fn default_anchor_rule_is_theorem_derived_strict_majority() {
772 let tied = array![[0.5_f64, 0.5], [0.5, 0.5]];
773 let tied_report = anchor_consistency_report(tied.view(), None).unwrap();
774 assert_eq!(tied_report.metrics.n_anchors, 0);
775
776 let majority = array![[0.5_f64.next_up(), 0.5], [0.5, 0.5_f64.next_up()]];
777 let majority_report = anchor_consistency_report(majority.view(), None).unwrap();
778 assert_eq!(majority_report.metrics.n_anchors, 2);
779 assert!(majority_report.passes());
780 }
781
782 #[test]
783 fn anchor_consistency_report_rejects_non_finite_assignments() {
784 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
785 let assignments = array![[1.0_f64, 0.0], [0.0, value]];
786 let error = anchor_consistency_report(assignments.view(), None).unwrap_err();
787 assert!(error.contains("assignments must be finite"));
788 assert!(error.contains("(1, 1)"));
789 }
790 }
791
792 #[test]
793 fn anchor_consistency_report_distinguishes_count_from_atom_coverage() {
794 let a = array![
795 [1.0_f64, 0.0, 0.0],
796 [1.0, 0.0, 0.0],
797 [1.0, 0.0, 0.0],
798 [0.0, 1.0, 0.0],
799 ];
800 let report = anchor_consistency_report(a.view(), None).unwrap();
801 assert!(report.preconditions.enough_anchors_total);
802 assert!(!report.preconditions.anchors_cover_all_atoms);
803 assert_eq!(report.uncovered_atoms, vec![2]);
804 assert!(!report.passes());
805 assert_eq!(report.violations.len(), 1);
806 }
807}