Skip to main content

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/// The joint-rank decision an audit took, kept with the margin that makes it
76/// transportable (#2337 §8 Thm 8.3).
77///
78/// An identifiability audit ranks the design at ONE operating point, but the
79/// fit then moves that point. A bare rank cannot say whether it still holds
80/// after the move; a rank plus its certified gap can, because the gap converts
81/// into a radius (`gam_linalg::decision::rank_transport_radius`) inside which
82/// no operator perturbation can change the decision. Recorded here so a later
83/// audit can price its own excursion against the earlier one's margin instead
84/// of comparing two bare integers.
85/// Held as the raw ingredients of the decision rather than a decision object,
86/// so this stays plain data per the module contract AND so there is one source
87/// of truth: a consumer re-derives the verdict with
88/// `gam_linalg::decision::certified_rank(&spectrum, tol, gap)` instead of
89/// trusting a stored copy that could drift from the spectrum beside it.
90#[derive(Debug, Clone)]
91pub struct JointRankCertificate {
92    /// The equilibrated penalty-augmented joint spectrum, descending. Two
93    /// spectra of the same operator family give a Weyl LOWER bound on the
94    /// operator excursion between their operating points
95    /// (`gam_linalg::decision::spectral_excursion_lower_bound`) — enough to
96    /// prove an earlier certificate VOID, never enough to prove it carries.
97    pub spectrum: Vec<f64>,
98    /// Tolerance the decision was posed at.
99    pub tol: f64,
100    /// Multiplicative half-gap the decision was posed with.
101    pub gap: f64,
102}
103
104#[derive(Debug, Clone)]
105pub struct IdentifiabilityAudit {
106    pub blocks: Vec<BlockIdentity>,
107    pub aliased_pairs: Vec<AliasedPair>,
108    pub dropped_columns: Vec<DroppedColumn>,
109    /// `true` when at least one dropped column's attribution to an
110    /// earlier block is ambiguous (overlap distributed across multiple
111    /// earlier blocks above tolerance) or the drop would silently
112    /// change model semantics. Callers must refuse the fit in that
113    /// case rather than silently proceed with a different model.
114    pub fatal: bool,
115    pub summary: String,
116    /// The transportable joint-rank certificate, when this audit path took a
117    /// two-sided decision it could certify. `None` on the paths that return
118    /// early (empty design, structural refusal) — an honest "no certificate
119    /// here" rather than a default that would read as a wide margin.
120    pub joint_rank_certificate: Option<JointRankCertificate>,
121}
122
123/// Error produced when the MAP uniqueness condition
124/// `ker(J^T W J) ∩ ker(S) = {0}` is violated.
125///
126/// A null direction `n` of `J^T W J` with `n^T S n = 0` means the posterior
127/// is flat along `n`: no likelihood curvature AND no penalty curvature,
128/// so the MAP estimate is non-unique.  The error names the offending
129/// direction and the dominant block (the block whose columns have the
130/// largest component in `n`) so the caller can trace which smooth term
131/// contributed the unpenalised null direction.
132#[derive(Debug, Clone)]
133pub struct MapUniquenessError {
134    /// Human-readable description of the failure, including the dominant block.
135    pub message: String,
136    /// Name of the block whose columns dominate the null direction.
137    pub dominant_block: String,
138    /// Index of the null direction (0-based among directions below tolerance).
139    pub null_direction_index: usize,
140    /// `n^T S n` for the offending null direction (≈ 0.0).
141    pub penalty_quadratic_form: f64,
142}
143
144impl std::fmt::Display for MapUniquenessError {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "{}", self.message)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    // ── MapUniquenessError ────────────────────────────────────────────────────
155
156    #[test]
157    fn map_uniqueness_error_display_uses_message_field() {
158        let err = MapUniquenessError {
159            message: "null direction in block_x".to_string(),
160            dominant_block: "block_x".to_string(),
161            null_direction_index: 2,
162            penalty_quadratic_form: 1e-18,
163        };
164        assert_eq!(err.to_string(), "null direction in block_x");
165    }
166
167    #[test]
168    fn map_uniqueness_error_fields_accessible() {
169        let err = MapUniquenessError {
170            message: "msg".to_string(),
171            dominant_block: "blk".to_string(),
172            null_direction_index: 5,
173            penalty_quadratic_form: 0.0,
174        };
175        assert_eq!(err.dominant_block, "blk");
176        assert_eq!(err.null_direction_index, 5);
177        assert_eq!(err.penalty_quadratic_form, 0.0);
178    }
179
180    // ── IdentifiabilityAudit ──────────────────────────────────────────────────
181
182    #[test]
183    fn identifiability_audit_fatal_field_readable() {
184        let audit = IdentifiabilityAudit {
185            blocks: vec![],
186            aliased_pairs: vec![],
187            dropped_columns: vec![],
188            fatal: true,
189            summary: "summary text".to_string(),
190            joint_rank_certificate: None,
191        };
192        assert!(audit.fatal);
193        assert_eq!(audit.summary, "summary text");
194    }
195
196    #[test]
197    fn block_identity_fields_accessible() {
198        let bi = BlockIdentity {
199            block_name: "smooth_1".to_string(),
200            original_dim: 5,
201            effective_dim: 4,
202            design_range_rank: 4,
203            singular_spectrum: String::new(),
204        };
205        assert_eq!(bi.block_name, "smooth_1");
206        assert_eq!(bi.original_dim, 5);
207        assert_eq!(bi.effective_dim, 4);
208    }
209
210    #[test]
211    fn aliased_pair_overlap_in_range() {
212        let pair = AliasedPair {
213            block_a: "a".to_string(),
214            block_b: "b".to_string(),
215            direction_a: 0,
216            direction_b: 1,
217            overlap: 0.95,
218            bias_shift: 0.0,
219        };
220        assert!(pair.overlap >= 0.0 && pair.overlap <= 1.0);
221        assert_eq!(pair.block_a, "a");
222    }
223}