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}
31
32/// A pair `(block_a.column → block_b.column)` whose normalised
33/// inner product exceeds the alias-overlap reporting threshold.
34/// Reported once per audited pair, in block-order (`block_a` index
35/// strictly less than `block_b` index in the spec list, so the
36/// "earlier block carries the image" attribution is well-defined).
37#[derive(Debug, Clone)]
38pub struct AliasedPair {
39    pub block_a: String,
40    pub block_b: String,
41    pub direction_a: usize,
42    pub direction_b: usize,
43    /// `|aᵀb| / (‖a‖·‖b‖)`. Always in `[0, 1]`. Values at or near 1.0
44    /// indicate near-perfect collinearity; values in `(threshold, 1.0)`
45    /// indicate partial overlap that the column-pivoted QR will still
46    /// preserve (only fully redundant directions get pivoted out).
47    pub overlap: f64,
48    /// Bias shift applied to the null-distribution mean for this pair,
49    /// equal to `bias_shift_for_pair(z_a, z_b, s2_a, s2_b)`.
50    /// Non-zero when exactly one block carries a `RowScaledJacobian` callback
51    /// (or the two scalings differ) and the row-scaling vector is skewed.
52    /// Stored so that the halt-threshold check can apply the same
53    /// directional correction as the report-threshold check.
54    /// Zero for all pairs arising from the channel-aware audit path,
55    /// and for pairs from the flat path when both blocks have symmetric
56    /// (or absent) row scaling.
57    pub bias_shift: f64,
58}
59
60#[derive(Debug, Clone)]
61pub struct DroppedColumn {
62    pub block: String,
63    pub column: usize,
64    pub reason: String,
65}
66
67#[derive(Debug, Clone)]
68pub struct IdentifiabilityAudit {
69    pub blocks: Vec<BlockIdentity>,
70    pub aliased_pairs: Vec<AliasedPair>,
71    pub dropped_columns: Vec<DroppedColumn>,
72    /// `true` when at least one dropped column's attribution to an
73    /// earlier block is ambiguous (overlap distributed across multiple
74    /// earlier blocks above tolerance) or the drop would silently
75    /// change model semantics. Callers must refuse the fit in that
76    /// case rather than silently proceed with a different model.
77    pub fatal: bool,
78    pub summary: String,
79}
80
81/// Error produced when the MAP uniqueness condition
82/// `ker(J^T W J) ∩ ker(S) = {0}` is violated.
83///
84/// A null direction `n` of `J^T W J` with `n^T S n = 0` means the posterior
85/// is flat along `n`: no likelihood curvature AND no penalty curvature,
86/// so the MAP estimate is non-unique.  The error names the offending
87/// direction and the dominant block (the block whose columns have the
88/// largest component in `n`) so the caller can trace which smooth term
89/// contributed the unpenalised null direction.
90#[derive(Debug, Clone)]
91pub struct MapUniquenessError {
92    /// Human-readable description of the failure, including the dominant block.
93    pub message: String,
94    /// Name of the block whose columns dominate the null direction.
95    pub dominant_block: String,
96    /// Index of the null direction (0-based among directions below tolerance).
97    pub null_direction_index: usize,
98    /// `n^T S n` for the offending null direction (≈ 0.0).
99    pub penalty_quadratic_form: f64,
100}
101
102impl std::fmt::Display for MapUniquenessError {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        write!(f, "{}", self.message)
105    }
106}