gam_problem/identifiability_audit.rs
1//! Pure-data identifiability-audit result types.
2//!
3//! These structs are the family-facing results of the pre-fit cross-block
4//! identifiability audit and the MAP-uniqueness check. They carry only plain
5//! data (`Vec`/`String`/`f64`/`bool`/`usize`) with no `faer`/`ndarray`/solver
6//! dependency, so they live in `gam-problem` (below the monolith) where the
7//! `CustomFamilyError` cone and other low-level consumers can name them. The
8//! compute code that BUILDS these audits stays in the monolith
9//! (`crate::identifiability::audit`) and constructs them through these public
10//! fields.
11
12/// Per-block accounting record. `original_dim` is the spec's column
13/// count at audit entry (post `joint_null_rotation` absorption — the
14/// audit is contractually run on the rotated specs). `effective_dim`
15/// is what remains after the audit drops aliased columns. Equal values
16/// mean the block carried no redundant directions w.r.t. earlier
17/// blocks.
18#[derive(Debug, Clone)]
19pub struct BlockIdentity {
20 pub block_name: String,
21 pub original_dim: usize,
22 pub effective_dim: usize,
23 /// Numerical rank of the block's column space at the n training
24 /// rows, computed by penalty-aware column-pivoted RRQR on `[J; S]`
25 /// (so penalty-covered design-null directions count as identified).
26 /// Equal to `original_dim` for any well-posed block; smaller values
27 /// flag a within-block rank deficiency that escaped within-smooth
28 /// nullspace absorption.
29 pub design_range_rank: usize,
30 /// Comma-joined descending singular values (√eig of the ranked Gram) of the
31 /// matrix whose rank produced `design_range_rank`, populated ONLY when the
32 /// block is within-block rank-deficient (`design_range_rank < original_dim`);
33 /// empty otherwise. Surfaced in the intra-block-deficiency refusal so a
34 /// `range_rank ≪ dim` verdict names the real geometry (a one-dominant-value
35 /// spectrum = numerical rank collapse, e.g. an extreme per-row channel
36 /// weight; a genuinely low-rank design has several near-zero values).
37 pub singular_spectrum: String,
38}
39
40/// A pair `(block_a.column → block_b.column)` whose normalised
41/// inner product exceeds the alias-overlap reporting threshold.
42/// Reported once per audited pair, in block-order (`block_a` index
43/// strictly less than `block_b` index in the spec list, so the
44/// "earlier block carries the image" attribution is well-defined).
45#[derive(Debug, Clone)]
46pub struct AliasedPair {
47 pub block_a: String,
48 pub block_b: String,
49 pub direction_a: usize,
50 pub direction_b: usize,
51 /// `|aᵀb| / (‖a‖·‖b‖)`. Always in `[0, 1]`. Values at or near 1.0
52 /// indicate near-perfect collinearity; values in `(threshold, 1.0)`
53 /// indicate partial overlap that the column-pivoted QR will still
54 /// preserve (only fully redundant directions get pivoted out).
55 pub overlap: f64,
56 /// Bias shift applied to the null-distribution mean for this pair,
57 /// equal to `bias_shift_for_pair(z_a, z_b, s2_a, s2_b)`.
58 /// Non-zero when exactly one block carries a `RowScaledJacobian` callback
59 /// (or the two scalings differ) and the row-scaling vector is skewed.
60 /// Stored so that the halt-threshold check can apply the same
61 /// directional correction as the report-threshold check.
62 /// Zero for all pairs arising from the channel-aware audit path,
63 /// and for pairs from the flat path when both blocks have symmetric
64 /// (or absent) row scaling.
65 pub bias_shift: f64,
66}
67
68#[derive(Debug, Clone)]
69pub struct DroppedColumn {
70 pub block: String,
71 pub column: usize,
72 pub reason: String,
73}
74
75#[derive(Debug, Clone)]
76pub struct IdentifiabilityAudit {
77 pub blocks: Vec<BlockIdentity>,
78 pub aliased_pairs: Vec<AliasedPair>,
79 pub dropped_columns: Vec<DroppedColumn>,
80 /// `true` when at least one dropped column's attribution to an
81 /// earlier block is ambiguous (overlap distributed across multiple
82 /// earlier blocks above tolerance) or the drop would silently
83 /// change model semantics. Callers must refuse the fit in that
84 /// case rather than silently proceed with a different model.
85 pub fatal: bool,
86 pub summary: String,
87}
88
89/// Error produced when the MAP uniqueness condition
90/// `ker(J^T W J) ∩ ker(S) = {0}` is violated.
91///
92/// A null direction `n` of `J^T W J` with `n^T S n = 0` means the posterior
93/// is flat along `n`: no likelihood curvature AND no penalty curvature,
94/// so the MAP estimate is non-unique. The error names the offending
95/// direction and the dominant block (the block whose columns have the
96/// largest component in `n`) so the caller can trace which smooth term
97/// contributed the unpenalised null direction.
98#[derive(Debug, Clone)]
99pub struct MapUniquenessError {
100 /// Human-readable description of the failure, including the dominant block.
101 pub message: String,
102 /// Name of the block whose columns dominate the null direction.
103 pub dominant_block: String,
104 /// Index of the null direction (0-based among directions below tolerance).
105 pub null_direction_index: usize,
106 /// `n^T S n` for the offending null direction (≈ 0.0).
107 pub penalty_quadratic_form: f64,
108}
109
110impl std::fmt::Display for MapUniquenessError {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 write!(f, "{}", self.message)
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 // ── MapUniquenessError ────────────────────────────────────────────────────
121
122 #[test]
123 fn map_uniqueness_error_display_uses_message_field() {
124 let err = MapUniquenessError {
125 message: "null direction in block_x".to_string(),
126 dominant_block: "block_x".to_string(),
127 null_direction_index: 2,
128 penalty_quadratic_form: 1e-18,
129 };
130 assert_eq!(err.to_string(), "null direction in block_x");
131 }
132
133 #[test]
134 fn map_uniqueness_error_fields_accessible() {
135 let err = MapUniquenessError {
136 message: "msg".to_string(),
137 dominant_block: "blk".to_string(),
138 null_direction_index: 5,
139 penalty_quadratic_form: 0.0,
140 };
141 assert_eq!(err.dominant_block, "blk");
142 assert_eq!(err.null_direction_index, 5);
143 assert_eq!(err.penalty_quadratic_form, 0.0);
144 }
145
146 // ── IdentifiabilityAudit ──────────────────────────────────────────────────
147
148 #[test]
149 fn identifiability_audit_fatal_field_readable() {
150 let audit = IdentifiabilityAudit {
151 blocks: vec![],
152 aliased_pairs: vec![],
153 dropped_columns: vec![],
154 fatal: true,
155 summary: "summary text".to_string(),
156 };
157 assert!(audit.fatal);
158 assert_eq!(audit.summary, "summary text");
159 }
160
161 #[test]
162 fn block_identity_fields_accessible() {
163 let bi = BlockIdentity {
164 block_name: "smooth_1".to_string(),
165 original_dim: 5,
166 effective_dim: 4,
167 design_range_rank: 4,
168 singular_spectrum: String::new(),
169 };
170 assert_eq!(bi.block_name, "smooth_1");
171 assert_eq!(bi.original_dim, 5);
172 assert_eq!(bi.effective_dim, 4);
173 }
174
175 #[test]
176 fn aliased_pair_overlap_in_range() {
177 let pair = AliasedPair {
178 block_a: "a".to_string(),
179 block_b: "b".to_string(),
180 direction_a: 0,
181 direction_b: 1,
182 overlap: 0.95,
183 bias_shift: 0.0,
184 };
185 assert!(pair.overlap >= 0.0 && pair.overlap <= 1.0);
186 assert_eq!(pair.block_a, "a");
187 }
188}