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}