Skip to main content

pounce_algorithm/
debug_rank.rs

1//! Numerical rank diagnosis of the active-constraint Jacobian.
2//!
3//! When the interior-point solver applies dual regularization δ_c (or
4//! reports wrong KKT inertia), the usual root cause is a (near)
5//! rank-deficient constraint Jacobian — linearly dependent or redundant
6//! equality constraints, i.e. an LICQ failure. The debugger already
7//! *detects* this as a scalar (`δ_c > 0`) and names *structurally*
8//! dependent rows via a Dulmage–Mendelsohn pass on the sparsity pattern
9//! (`pounce-presolve`). This module closes the remaining gap: a
10//! *numerical* rank-revealing SVD of the equality Jacobian at the current
11//! iterate, which localizes the dependency to the specific equations that
12//! participate in it — including dependencies that are numerical only
13//! (cancelling values over a full-rank pattern), which the structural
14//! pass cannot see.
15//!
16//! The math. For the equality Jacobian `A` (m_c × n) with SVD `A = U Σ Vᵀ`,
17//! the left singular vectors `u_k` whose singular value `σ_k ≈ 0` span the
18//! left null space — the row combinations `u_kᵀ A ≈ 0` that vanish. Row
19//! `i`'s participation in that null space,
20//! `w_i = Σ_{k : σ_k ≤ τ} u[i,k]² ∈ [0, 1]`, localizes the dependency to
21//! specific equations (`w_i = 1` ⇒ row `i` lies entirely in the null
22//! space). The REPL resolves the implicated rows to model names via the
23//! same `.row` path as `print equation`.
24
25use crate::debug::ResidKind;
26use faer::Mat;
27use pounce_common::types::Number;
28
29/// Rows whose null-space participation is below this are treated as not
30/// implicated (numerical dust). A genuine dependency among `k` rows
31/// spreads weight ~`1/k` across them, well above this floor.
32const CULPRIT_WEIGHT_TOL: Number = 1e-3;
33
34/// Identity of one active row in a [`RankReport`], so the REPL can resolve
35/// it to a model name (the equality name pool for [`ResidKind::Eq`], the
36/// inequality pool for [`ResidKind::Ineq`]).
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct RankRow {
39    pub kind: ResidKind,
40    pub index: usize,
41}
42
43/// One row implicated in the Jacobian's near-null space, with its
44/// participation weight `Σ_{k∈null} u[i,k]² ∈ [0, 1]`.
45#[derive(Clone, Copy, Debug)]
46pub struct RankCulprit {
47    /// Index into [`RankReport::rows`].
48    pub row: usize,
49    /// Null-space participation weight in `[0, 1]`.
50    pub weight: Number,
51}
52
53/// Numerical rank diagnosis of the active-constraint Jacobian at one
54/// iterate. See the module docs for the underlying SVD analysis.
55#[derive(Clone, Debug)]
56pub struct RankReport {
57    /// The active rows analyzed, in block order (currently the equality
58    /// block `J_c`). `rows[i]` is the identity of dense row `i`.
59    pub rows: Vec<RankRow>,
60    /// Number of variables (columns of the analyzed block).
61    pub n_cols: usize,
62    /// Singular values, nonincreasing.
63    pub singular_values: Vec<Number>,
64    /// Numerical-rank threshold `τ = σ_max · max(m, n) · ε`.
65    pub tol: Number,
66    /// Numerical rank (count of `σ > τ`).
67    pub rank: usize,
68    /// Condition number `σ_max / σ_min` (`∞` if `σ_min == 0`).
69    pub cond: Number,
70    /// Rows participating in the near-null space, sorted by weight
71    /// descending. Empty when the block has full row rank.
72    pub culprits: Vec<RankCulprit>,
73}
74
75impl RankReport {
76    /// Number of active rows analyzed (`m`).
77    pub fn n_rows(&self) -> usize {
78        self.rows.len()
79    }
80
81    /// Row-rank deficiency `m − rank` — the number of dependent rows.
82    pub fn deficiency(&self) -> usize {
83        self.rows.len().saturating_sub(self.rank)
84    }
85
86    /// Whether the analyzed block is (numerically) row-rank-deficient.
87    pub fn is_rank_deficient(&self) -> bool {
88        self.rank < self.rows.len()
89    }
90
91    /// Smallest singular value (0 if there are none).
92    pub fn sigma_min(&self) -> Number {
93        self.singular_values.last().copied().unwrap_or(0.0)
94    }
95
96    /// Largest singular value (0 if there are none).
97    pub fn sigma_max(&self) -> Number {
98        self.singular_values.first().copied().unwrap_or(0.0)
99    }
100}
101
102/// Run the rank-revealing SVD on a dense **row-major** `m × n` matrix and
103/// build a [`RankReport`]. `rows` carries the identity of each of the `m`
104/// rows (for downstream naming) and must have length `m`. Returns `None`
105/// if the block is empty or the SVD fails to converge.
106pub(crate) fn svd_rank(
107    m: usize,
108    n: usize,
109    dense_row_major: &[Number],
110    rows: Vec<RankRow>,
111) -> Option<RankReport> {
112    if m == 0 || n == 0 {
113        return None;
114    }
115    debug_assert_eq!(dense_row_major.len(), m * n, "dense buffer is not m*n");
116    debug_assert_eq!(rows.len(), m, "row-identity count must equal m");
117
118    let a = Mat::from_fn(m, n, |i, j| dense_row_major[i * n + j]);
119    let svd = a.svd().ok()?;
120
121    let s: Vec<Number> = svd.S().column_vector().iter().copied().collect();
122    let smax = s.first().copied().unwrap_or(0.0);
123    // The standard LAPACK/NumPy numerical-rank threshold.
124    let tol = smax * (m.max(n) as Number) * Number::EPSILON;
125    let rank = s.iter().filter(|&&sv| sv > tol).count();
126    let smin = s.last().copied().unwrap_or(0.0);
127    let cond = if smin > 0.0 {
128        smax / smin
129    } else {
130        Number::INFINITY
131    };
132
133    // Per-row participation in the left null space: the columns of U whose
134    // singular value is ≤ τ. For a "tall" block (m > n) the columns in
135    // `[n, m)` carry no singular value and span guaranteed null space — the
136    // `rank..m` range below covers both cases (U is m × m).
137    let u = svd.U();
138    let mut weights = vec![0.0_f64; m];
139    for k in rank..m {
140        let col = u.col(k);
141        for (i, &val) in col.iter().enumerate() {
142            weights[i] += val * val;
143        }
144    }
145
146    let mut culprits: Vec<RankCulprit> = weights
147        .iter()
148        .enumerate()
149        .filter(|(_, &w)| w > CULPRIT_WEIGHT_TOL)
150        .map(|(row, &w)| RankCulprit { row, weight: w })
151        .collect();
152    culprits.sort_by(|a, b| {
153        b.weight
154            .partial_cmp(&a.weight)
155            .unwrap_or(std::cmp::Ordering::Equal)
156    });
157
158    Some(RankReport {
159        rows,
160        n_cols: n,
161        singular_values: s,
162        tol,
163        rank,
164        cond,
165        culprits,
166    })
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn eq_rows(m: usize) -> Vec<RankRow> {
174        (0..m)
175            .map(|index| RankRow {
176                kind: ResidKind::Eq,
177                index,
178            })
179            .collect()
180    }
181
182    #[test]
183    fn full_rank_block_has_no_culprits() {
184        // 2×3 with independent rows.
185        let dense = vec![
186            1.0, 0.0, 0.0, //
187            0.0, 1.0, 0.0,
188        ];
189        let r = svd_rank(2, 3, &dense, eq_rows(2)).expect("svd");
190        assert_eq!(r.rank, 2);
191        assert!(!r.is_rank_deficient());
192        assert_eq!(r.deficiency(), 0);
193        assert!(r.culprits.is_empty());
194        assert!(r.cond.is_finite());
195    }
196
197    #[test]
198    fn duplicate_rows_are_flagged_as_culprits() {
199        // Rows 0 and 2 are identical ⇒ rank 2, one dependency shared
200        // between rows 0 and 2 (each ~0.5 participation), row 1 clean.
201        let dense = vec![
202            1.0, 2.0, 3.0, //
203            0.0, 1.0, 0.0, //
204            1.0, 2.0, 3.0,
205        ];
206        let r = svd_rank(3, 3, &dense, eq_rows(3)).expect("svd");
207        assert_eq!(r.rank, 2, "one redundant row");
208        assert!(r.is_rank_deficient());
209        assert_eq!(r.deficiency(), 1);
210        let flagged: Vec<usize> = r.culprits.iter().map(|c| c.row).collect();
211        assert!(
212            flagged.contains(&0),
213            "row 0 should be implicated: {flagged:?}"
214        );
215        assert!(
216            flagged.contains(&2),
217            "row 2 should be implicated: {flagged:?}"
218        );
219        assert!(!flagged.contains(&1), "row 1 is independent: {flagged:?}");
220        // The two duplicates split the null direction roughly evenly.
221        for c in &r.culprits {
222            assert!((c.weight - 0.5).abs() < 0.1, "weight {} ~ 0.5", c.weight);
223        }
224    }
225
226    #[test]
227    fn value_dependence_is_caught_numerically() {
228        // Row 2 = row 0 + row 1 exactly: the sparsity pattern is full
229        // (every entry nonzero), so a structural Dulmage–Mendelsohn pass
230        // sees full rank — but the *values* make the rows dependent. This
231        // is precisely the gap the numerical SVD closes.
232        let dense = vec![
233            1.0, 0.0, 1.0, //
234            0.0, 1.0, 1.0, //
235            1.0, 1.0, 2.0,
236        ];
237        let r = svd_rank(3, 3, &dense, eq_rows(3)).expect("svd");
238        assert_eq!(r.rank, 2, "value-dependent third row");
239        assert!(r.is_rank_deficient());
240        assert_eq!(r.deficiency(), 1);
241        assert!(
242            r.cond > 1e14 || r.cond.is_infinite(),
243            "ill-conditioned: cond={:.2e}",
244            r.cond
245        );
246        assert!(!r.culprits.is_empty(), "the dependency must be localized");
247    }
248
249    #[test]
250    fn rank_threshold_keeps_small_but_resolvable_singular_values() {
251        // A perturbation (1e-9) well above τ ≈ σ_max·3·ε must NOT be
252        // mistaken for a dependency — the block is genuinely full rank.
253        let dense = vec![
254            1.0,
255            0.0,
256            1.0, //
257            0.0,
258            1.0,
259            1.0, //
260            1.0,
261            1.0,
262            2.0 + 1e-9,
263        ];
264        let r = svd_rank(3, 3, &dense, eq_rows(3)).expect("svd");
265        assert_eq!(r.rank, 3, "1e-9 perturbation is above the rank tol");
266        assert!(!r.is_rank_deficient());
267        assert!(r.culprits.is_empty());
268    }
269
270    #[test]
271    fn empty_block_returns_none() {
272        assert!(svd_rank(0, 3, &[], vec![]).is_none());
273        assert!(svd_rank(3, 0, &[], eq_rows(3)).is_none());
274    }
275}