Skip to main content

gam_identifiability/
kernel.rs

1//! Numeric kernels for identifiability-theorem diagnostics.
2//!
3//! The kernels return scalar facts for iVAE auxiliary richness, decoder
4//! Jacobian sparsity, and manifold-SAE anchor coverage. Rust, Python, and CLI
5//! layers turn those facts into user-facing reports.
6
7use ndarray::{Array2, ArrayView2, Axis};
8
9/// Maximum sweeps for the cyclic-by-largest-pivot Jacobi eigensolver.
10///
11/// Jacobi converges quadratically once off-diagonals are small, and the
12/// matrices here are tiny (< 64×64 identifiability normal-equation blocks),
13/// so a converged solve needs only a handful of sweeps. 200 is a generous
14/// safety cap that the `JACOBI_OFFDIAG_TOL` break almost always reaches
15/// first; it only bounds pathological non-converging inputs.
16const JACOBI_MAX_SWEEPS: usize = 200;
17
18/// Off-diagonal magnitude below which the Jacobi sweep is considered
19/// converged. `1e-14` is two orders above f64 unit roundoff, tight enough
20/// that residual off-diagonal mass cannot perturb the rank/pseudo-inverse
21/// decisions these diagnostics make.
22const JACOBI_OFFDIAG_TOL: f64 = 1.0e-14;
23
24/// Maximum distinct values per aux column for it to count as "discrete".
25///
26/// An integer-valued column with at most this many levels is treated as a
27/// categorical/discrete covariate (the regime the iVAE auxiliary-richness
28/// theorem is stated for); above it the column is treated as continuous.
29const AUX_DISCRETE_MAX_LEVELS: usize = 64;
30
31/// Absolute gap below which two aux values count as the same distinct level.
32/// Integer-valued aux data dedups exactly; this only guards float dust from
33/// the `round()` check above.
34const AUX_LEVEL_DEDUP_TOL: f64 = 1.0e-12;
35
36/// Scalar facts about the auxiliary covariate / latent pair feeding an iVAE.
37#[derive(Debug, Clone)]
38pub struct AuxRichnessMetrics {
39    /// `true` iff every entry of the aux matrix is finite.
40    pub aux_observed: bool,
41    /// Number of non-finite entries in the aux matrix.
42    pub n_nonfinite_aux: usize,
43    /// Aux dimension (column count).
44    pub aux_dim: usize,
45    /// Latent dimension (column count of `latents`).
46    pub latent_dim: usize,
47    /// Row count `N`.
48    pub n_rows: usize,
49    /// Column indices (sorted, ascending) that are constant across rows.
50    pub constant_columns: Vec<usize>,
51    /// `true` iff aux is integer-valued and every column has <= 64 unique values.
52    pub aux_is_discrete: bool,
53    /// Joint distinct-row count of aux (only computed when `aux_is_discrete`).
54    pub n_distinct_levels: usize,
55    /// Empirical rank of the least-squares Jacobian `B = (Aᵀ A)^{-1} Aᵀ Z`.
56    /// `usize::MAX` sentinel if the rank could not be estimated (e.g. too few rows).
57    pub jacobian_rank: usize,
58    /// True iff we had enough rows + finite data to estimate the Jacobian rank.
59    pub jacobian_rank_estimated: bool,
60}
61
62/// Compute the iVAE auxiliary-richness numeric facts.
63///
64/// `aux` is `(N, aux_dim)`; `latents` is `(N, latent_dim)`. The empirical
65/// Jacobian is the linear-regression slope ``B`` of ``Z ~ A`` (centred). For
66/// a non-linear iVAE encoder this is a first-order surrogate; a deficient
67/// rank here forecloses identifiability regardless of nonlinear postproc.
68pub 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    // 1. Finiteness.
74    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    // 2. Constant columns. Skip non-finite columns entirely (they will be
83    //    flagged by `aux_observed=false`).
84    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            // sample std (population formula — exact zero iff constant).
89            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    // 3. Discreteness + distinct level count.
103    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            // Joint distinct rows.
125            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    // 4. Empirical Jacobian rank.
144    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        // Centre A and Z.
150        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        // Solve B = (Aᵀ A)^{+} Aᵀ Z via SVD on (Aᵀ A) — small (aux_dim x aux_dim).
165        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
186/// Moore-Penrose pseudo-inverse times rhs via SVD. Stable for the small
187/// `(aux_dim x aux_dim)` normal-equation matrices encountered here. Tolerance
188/// is `1e-12 * max_singular_value`.
189fn 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    // Symmetric eigen-decomposition via Jacobi (matrices are small, < 64x64
193    // in any realistic identifiability check — Jacobi is robust and avoids
194    // pulling in a heavier dependency for this code path).
195    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    // Build A^+ = V diag(1/λ_i if |λ_i|>tol else 0) Vᵀ.
199    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    // A^+ b  =  V D Vᵀ b  where D = diag(inv_diag).
207    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
218/// Jacobi rotation eigen-decomposition for small symmetric matrices.
219/// Returns `(eigenvalues, eigenvectors)` with `A = V diag(λ) Vᵀ`.
220fn 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        // Find largest off-diagonal.
227        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        // Update M.
255        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        // Update V.
272        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
283/// Numeric rank of `m` via its singular values (computed as
284/// `sqrt(eig(MᵀM))`). `tol` is absolute; entries with singular value
285/// `<= tol` are considered zero.
286fn 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/// Scalar facts about decoder Jacobian sparsity.
299#[derive(Debug, Clone)]
300pub struct JacobianSparsityMetrics {
301    /// `(N_samples, P, latent_dim)` shape elements.
302    pub n_samples: usize,
303    pub p_features: usize,
304    pub latent_dim: usize,
305    /// Fraction of entries with `|J| < zero_threshold * max|J|`, averaged
306    /// across samples.
307    pub mean_sparsity: f64,
308    /// Maximum absolute entry of the Jacobian stack.
309    pub max_abs: f64,
310    /// Per-sample numeric column rank (each entry in `[0, latent_dim]`).
311    pub ranks: Vec<usize>,
312}
313
314/// Compute mean sparsity and per-sample rank of a stack of Jacobians.
315///
316/// `jacobians` is `(N_samples, P, latent_dim)`, flattened to a `(N*P, latent_dim)`
317/// row-major view. `n_samples` is the leading axis size.
318pub 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    // Max abs.
328    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        // All zero Jacobian: maximally "sparse" but degenerate; caller flags this.
347        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    // Per-sample rank.
356    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        // Use `cutoff` (absolute) as the rank tolerance: an entry below it is
362        // considered zero, which matches the sparsity decision.
363        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/// Scalar facts about the per-atom anchor structure of an assignment matrix.
377#[derive(Debug, Clone)]
378pub struct AnchorConsistencyMetrics {
379    /// `N` (row count of the assignment matrix).
380    pub n_rows: usize,
381    /// `K` (column count = atom count).
382    pub n_atoms: usize,
383    /// Total number of anchor rows
384    /// (rows with `max|A|/sum|A| >= anchor_dominance`).
385    pub n_anchors: usize,
386    /// Per-atom anchor count `(length K)`: for each anchor row, the
387    /// dominant atom is tallied.
388    pub anchors_per_atom: Vec<usize>,
389}
390
391/// Compute anchor counts from an assignment matrix.
392///
393/// `assignments` is `(N, K)`. A row is an anchor when its maximum-magnitude
394/// entry contributes at least `anchor_dominance ∈ (1/2, 1]` of the row's L1
395/// mass. Zero-mass rows are *not* anchors.
396fn 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/// Typed pass/fail verdict for the anchor-consistency identifiability check.
430///
431/// A manifold-SAE with `K` atoms is identified up to permutation of atoms only
432/// when the assignment matrix contains enough *anchor* rows (rows where one
433/// atom carries at least `anchor_dominance` of the row's L1 mass). The
434/// thresholds here are derived from that separability argument, not tuned:
435///
436/// * `enough_anchors_total`: permutation identifiability needs at least one
437///   anchor per atom, hence `n_anchors >= K` is the weakest necessary count.
438/// * `anchors_cover_all_atoms`: an atom with zero anchors has no row that
439///   pins it individually, so it is only identified up to a linear mix with
440///   its neighbours.
441///
442/// `K == 1` passes vacuously: a single atom has no permutation ambiguity.
443///
444/// The verdict lives in the core so the CLI, Rust library, and Python wrapper
445/// all report identical diagnostics; presentation layers only format it.
446#[derive(Debug, Clone, PartialEq, Eq)]
447pub struct AnchorConsistencyPreconditions {
448    /// At least one anchor row exists per atom in aggregate.
449    pub enough_anchors_total: bool,
450    /// Every atom is the dominant atom of at least one anchor row.
451    pub anchors_cover_all_atoms: bool,
452}
453
454#[derive(Debug, Clone)]
455pub struct AnchorConsistencyReport {
456    /// The underlying anchor counts.
457    pub metrics: AnchorConsistencyMetrics,
458    /// The dominance threshold the counts were computed with.
459    pub anchor_dominance: f64,
460    /// Fraction of rows that are anchors (`n_anchors / max(n_rows, 1)`).
461    pub anchor_fraction: f64,
462    /// Preconditions derived from the number of fitted atoms.
463    pub preconditions: AnchorConsistencyPreconditions,
464    /// One human-readable statement per failed precondition.
465    pub violations: Vec<String>,
466    /// One concrete remediation per violation (`len == violations.len()`).
467    pub recommendations: Vec<String>,
468    /// Atoms with zero anchor rows (empty when coverage holds).
469    pub uncovered_atoms: Vec<usize>,
470}
471
472impl AnchorConsistencyReport {
473    /// `true` iff every precondition holds.
474    pub fn passes(&self) -> bool {
475        self.preconditions.enough_anchors_total && self.preconditions.anchors_cover_all_atoms
476    }
477}
478
479/// Default anchor-dominance threshold: the exact floating-point encoding of a
480/// strict majority.
481///
482/// Anchor separability requires the dominant atom to outweigh all remaining
483/// atoms combined (`share > 1/2`). Because `anchor_consistency_metrics` uses
484/// `>= threshold`, the next representable `f64` above one-half implements that
485/// theorem-derived strict inequality without an arbitrary robustness margin.
486/// Callers that want a stronger practical margin may request one explicitly.
487pub const ANCHOR_DOMINANCE_DEFAULT: f64 = f64::from_bits(0.5_f64.to_bits() + 1);
488
489/// Run the full anchor-consistency identifiability check and return the typed
490/// verdict. `assignments` is `(N, K)`; `anchor_dominance` defaults to
491/// [`ANCHOR_DOMINANCE_DEFAULT`] when `None`.
492pub 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
580/// Stack a list of per-atom decoder blocks (each shape `(basis_size_k, P)`)
581/// column-wise into a single Jacobian of shape `(P, sum_k basis_size_k)`.
582/// Used by the Python diagnostics dispatcher to feed
583/// [`jacobian_sparsity_metrics`] from a `ManifoldSAE.decoder_blocks` payload
584/// without doing the concatenation in Python.
585pub 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        // Block has shape (basis_size, P); transpose into columns of out.
605        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        // P=4, K=3, n_samples=1; mostly zero.
682        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}