Skip to main content

pounce_presolve/
incidence.rs

1//! Bipartite incidence graph between equality rows and variables.
2//!
3//! PR 2 of the auxiliary-presolve port (issue #53). Driven by the
4//! inner TNLP's Jacobian sparsity plus the equality filter on
5//! `(g_l, g_u)`. ripopt anchor:
6//! `src/auxiliary_preprocessing.rs:2282-2318`.
7
8use pounce_common::types::{Index, Number};
9use pounce_nlp::tnlp::Linearity;
10
11/// View into the data we need to build an [`EqualityIncidence`].
12/// Decoupled from `TNLP` itself so this module stays unit-testable
13/// without spinning up a full inner problem.
14#[derive(Debug, Clone, Copy)]
15pub struct ProbeView<'a> {
16    /// Number of variables.
17    pub n_vars: usize,
18    /// Number of inner constraint rows.
19    pub m_rows: usize,
20    /// Inner Jacobian sparsity (one entry per structural nonzero).
21    pub jac_irow: &'a [Index],
22    pub jac_jcol: &'a [Index],
23    /// Optional Jacobian values at a probe point. When provided,
24    /// entries that evaluate to exactly `0.0` are dropped — this
25    /// removes structural zeros that linearity hasn't already
26    /// excluded.
27    pub jac_values: Option<&'a [Number]>,
28    pub g_l: &'a [Number],
29    pub g_u: &'a [Number],
30    /// Optional per-row linearity tags; unused by PR 2 but plumbed
31    /// for PR 5's coupling classifier.
32    pub linearity: Option<&'a [Linearity]>,
33    /// `true` when the inner TNLP uses Fortran (1-based) indexing.
34    pub one_based: bool,
35    /// Tolerance for `g_l[i] == g_u[i]`. Rows tighter than this are
36    /// treated as equalities.
37    pub eq_tol: Number,
38    /// PR 13: per-variable mask. `true` entries are excluded from
39    /// the incidence graph. Used to drop trivially-fixed variables
40    /// (`x_l[i] == x_u[i]`) before matching.
41    pub excluded_vars: Option<&'a [bool]>,
42    /// PR 13: per-row mask. `true` entries are excluded from the
43    /// incidence graph. Used to drop free rows and trivially-slack
44    /// inequalities before matching.
45    pub excluded_rows: Option<&'a [bool]>,
46}
47
48/// CSR-style bipartite adjacency: equality rows ↔ variables.
49///
50/// # Example
51///
52/// ```
53/// use pounce_presolve::incidence::{EqualityIncidence, ProbeView};
54///
55/// // 2 rows × 2 vars, one equality (row 0), one inequality (row 1).
56/// // Jacobian touches (0,0), (0,1), (1,0).
57/// let irow = [0, 0, 1];
58/// let jcol = [0, 1, 0];
59/// let g_l = [1.0, 0.0];
60/// let g_u = [1.0, 5.0]; // row 1 is g(x) ∈ [0, 5], not an equality.
61/// let p = ProbeView {
62///     n_vars: 2,
63///     m_rows: 2,
64///     jac_irow: &irow,
65///     jac_jcol: &jcol,
66///     jac_values: None,
67///     g_l: &g_l,
68///     g_u: &g_u,
69///     linearity: None,
70///     one_based: false,
71///     eq_tol: 1e-12,
72///     excluded_vars: None,
73///     excluded_rows: None,
74/// };
75/// let inc = EqualityIncidence::from_probe(&p);
76/// assert_eq!(inc.n_eq_rows(), 1);
77/// assert_eq!(inc.neighbors(0), &[0, 1]);
78/// ```
79#[derive(Debug, Clone, Default)]
80pub struct EqualityIncidence {
81    /// Number of variables (columns) in the original problem.
82    pub n_vars: usize,
83    /// Inner-row indices of the equality rows, in ascending order.
84    /// Length = `self.n_eq_rows()`.
85    pub eq_row_inner_idx: Vec<usize>,
86    /// CSR row pointers (length `n_eq_rows + 1`).
87    pub adj_ptr: Vec<usize>,
88    /// Sorted, deduped column indices per row.
89    pub vars: Vec<usize>,
90}
91
92impl EqualityIncidence {
93    /// Build an incidence graph from a probe.
94    pub fn from_probe(p: &ProbeView<'_>) -> Self {
95        // 1. Identify equality rows in inner-row index order.
96        //    Excludes any row marked in `excluded_rows` (PR 13).
97        let mut eq_row_inner_idx: Vec<usize> = Vec::new();
98        let mut inner_to_eq: Vec<Option<usize>> = vec![None; p.m_rows];
99        for (i, slot) in inner_to_eq.iter_mut().enumerate() {
100            if let Some(mask) = p.excluded_rows {
101                if mask[i] {
102                    continue;
103                }
104            }
105            if (p.g_u[i] - p.g_l[i]).abs() <= p.eq_tol {
106                *slot = Some(eq_row_inner_idx.len());
107                eq_row_inner_idx.push(i);
108            }
109        }
110        let n_eq = eq_row_inner_idx.len();
111
112        // 2. Bucket Jacobian entries by equality-row index.
113        let mut per_row: Vec<Vec<usize>> = vec![Vec::new(); n_eq];
114        let nnz = p.jac_irow.len();
115        for k in 0..nnz {
116            // Skip exact structural zeros when values are available.
117            if let Some(vals) = p.jac_values {
118                if vals[k] == 0.0 {
119                    continue;
120                }
121            }
122            let i = if p.one_based {
123                (p.jac_irow[k] as isize - 1) as usize
124            } else {
125                p.jac_irow[k] as usize
126            };
127            if i >= p.m_rows {
128                continue;
129            }
130            let Some(eq_k) = inner_to_eq[i] else { continue };
131            let j = if p.one_based {
132                (p.jac_jcol[k] as isize - 1) as usize
133            } else {
134                p.jac_jcol[k] as usize
135            };
136            if j >= p.n_vars {
137                continue;
138            }
139            // PR 13: drop entries touching trivially-fixed vars.
140            if let Some(mask) = p.excluded_vars {
141                if mask[j] {
142                    continue;
143                }
144            }
145            per_row[eq_k].push(j);
146        }
147
148        // 3. Sort + dedupe each row; pack into CSR.
149        let mut adj_ptr: Vec<usize> = Vec::with_capacity(n_eq + 1);
150        let mut vars: Vec<usize> = Vec::new();
151        adj_ptr.push(0);
152        for row in per_row.iter_mut() {
153            row.sort_unstable();
154            row.dedup();
155            vars.extend_from_slice(row);
156            adj_ptr.push(vars.len());
157        }
158
159        Self {
160            n_vars: p.n_vars,
161            eq_row_inner_idx,
162            adj_ptr,
163            vars,
164        }
165    }
166
167    /// Number of equality rows in the incidence graph.
168    pub fn n_eq_rows(&self) -> usize {
169        self.eq_row_inner_idx.len()
170    }
171
172    /// Sorted column neighbours of equality row `k` (0-based into
173    /// `eq_row_inner_idx`). Panics if `k >= self.n_eq_rows()`.
174    pub fn neighbors(&self, k: usize) -> &[usize] {
175        let lo = self.adj_ptr[k];
176        let hi = self.adj_ptr[k + 1];
177        &self.vars[lo..hi]
178    }
179}
180
181/// CSR-style bipartite adjacency: **inequality** rows ↔ variables.
182///
183/// Mirror of [`EqualityIncidence`] but for rows where
184/// `|g_u - g_l| > eq_tol` (any range or one-sided bound). Used by
185/// PR 5's coupling classifier: a candidate block is unsafe to
186/// eliminate if its variables also appear in any inequality row.
187#[derive(Debug, Clone, Default)]
188pub struct InequalityIncidence {
189    /// Number of variables (columns).
190    pub n_vars: usize,
191    /// Inner-row indices of the inequality rows, in ascending order.
192    pub ineq_row_inner_idx: Vec<usize>,
193    /// CSR row pointers.
194    pub adj_ptr: Vec<usize>,
195    /// Sorted, deduped column indices per row.
196    pub vars: Vec<usize>,
197    /// Reverse adjacency: for each variable, the list of inequality
198    /// rows that touch it (sorted ascending).
199    pub col_to_rows_ptr: Vec<usize>,
200    pub col_to_rows: Vec<usize>,
201}
202
203impl InequalityIncidence {
204    /// Build the inequality-incidence graph from the same probe
205    /// [`EqualityIncidence`] uses; we just invert the equality
206    /// filter.
207    pub fn from_probe(p: &ProbeView<'_>) -> Self {
208        // 1. Identify inequality rows. Excludes any row marked in
209        //    `excluded_rows` (PR 13: free rows + trivially-slack
210        //    inequalities).
211        let mut ineq_row_inner_idx: Vec<usize> = Vec::new();
212        let mut inner_to_ineq: Vec<Option<usize>> = vec![None; p.m_rows];
213        for (i, slot) in inner_to_ineq.iter_mut().enumerate() {
214            if let Some(mask) = p.excluded_rows {
215                if mask[i] {
216                    continue;
217                }
218            }
219            if (p.g_u[i] - p.g_l[i]).abs() > p.eq_tol {
220                *slot = Some(ineq_row_inner_idx.len());
221                ineq_row_inner_idx.push(i);
222            }
223        }
224        let n_ineq = ineq_row_inner_idx.len();
225
226        // 2. Bucket Jacobian entries by inequality-row index.
227        let mut per_row: Vec<Vec<usize>> = vec![Vec::new(); n_ineq];
228        let nnz = p.jac_irow.len();
229        for k in 0..nnz {
230            if let Some(vals) = p.jac_values {
231                if vals[k] == 0.0 {
232                    continue;
233                }
234            }
235            let i = if p.one_based {
236                (p.jac_irow[k] as isize - 1) as usize
237            } else {
238                p.jac_irow[k] as usize
239            };
240            if i >= p.m_rows {
241                continue;
242            }
243            let Some(ineq_k) = inner_to_ineq[i] else {
244                continue;
245            };
246            let j = if p.one_based {
247                (p.jac_jcol[k] as isize - 1) as usize
248            } else {
249                p.jac_jcol[k] as usize
250            };
251            if j >= p.n_vars {
252                continue;
253            }
254            // PR 13: drop entries touching trivially-fixed vars.
255            if let Some(mask) = p.excluded_vars {
256                if mask[j] {
257                    continue;
258                }
259            }
260            per_row[ineq_k].push(j);
261        }
262
263        // 3. CSR pack.
264        let mut adj_ptr: Vec<usize> = Vec::with_capacity(n_ineq + 1);
265        let mut vars: Vec<usize> = Vec::new();
266        adj_ptr.push(0);
267        for row in per_row.iter_mut() {
268            row.sort_unstable();
269            row.dedup();
270            vars.extend_from_slice(row);
271            adj_ptr.push(vars.len());
272        }
273
274        // 4. Reverse adjacency for cheap "does var j touch an
275        // inequality?" queries.
276        let mut col_to_rows_ptr = vec![0usize; p.n_vars + 1];
277        for &v in &vars {
278            col_to_rows_ptr[v + 1] += 1;
279        }
280        for i in 1..=p.n_vars {
281            col_to_rows_ptr[i] += col_to_rows_ptr[i - 1];
282        }
283        let mut col_to_rows = vec![0usize; col_to_rows_ptr[p.n_vars]];
284        let mut cursor = col_to_rows_ptr[..p.n_vars].to_vec();
285        for (ineq_k, row) in per_row.iter().enumerate() {
286            for &v in row {
287                col_to_rows[cursor[v]] = ineq_k;
288                cursor[v] += 1;
289            }
290        }
291
292        Self {
293            n_vars: p.n_vars,
294            ineq_row_inner_idx,
295            adj_ptr,
296            vars,
297            col_to_rows_ptr,
298            col_to_rows,
299        }
300    }
301
302    /// Number of inequality rows.
303    pub fn n_ineq_rows(&self) -> usize {
304        self.ineq_row_inner_idx.len()
305    }
306
307    /// Sorted variable neighbours of inequality row `k`.
308    pub fn neighbors(&self, k: usize) -> &[usize] {
309        let lo = self.adj_ptr[k];
310        let hi = self.adj_ptr[k + 1];
311        &self.vars[lo..hi]
312    }
313
314    /// Sorted inequality-row neighbours of variable `j` (0-based into
315    /// `0..n_vars`). Returns an empty slice if no inequality touches
316    /// `j`.
317    pub fn rows_for_var(&self, j: usize) -> &[usize] {
318        let lo = self.col_to_rows_ptr[j];
319        let hi = self.col_to_rows_ptr[j + 1];
320        &self.col_to_rows[lo..hi]
321    }
322
323    /// True iff variable `j` appears in any inequality row.
324    pub fn var_in_inequality(&self, j: usize) -> bool {
325        !self.rows_for_var(j).is_empty()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn probe<'a>(
334        n_vars: usize,
335        m_rows: usize,
336        irow: &'a [Index],
337        jcol: &'a [Index],
338        g_l: &'a [Number],
339        g_u: &'a [Number],
340    ) -> ProbeView<'a> {
341        ProbeView {
342            n_vars,
343            m_rows,
344            jac_irow: irow,
345            jac_jcol: jcol,
346            jac_values: None,
347            g_l,
348            g_u,
349            linearity: None,
350            one_based: false,
351            eq_tol: 1e-12,
352            excluded_vars: None,
353            excluded_rows: None,
354        }
355    }
356
357    #[test]
358    fn incidence_empty_problem_is_empty() {
359        let p = probe(0, 0, &[], &[], &[], &[]);
360        let inc = EqualityIncidence::from_probe(&p);
361        assert_eq!(inc.n_eq_rows(), 0);
362        assert_eq!(inc.vars.len(), 0);
363        assert_eq!(inc.adj_ptr, vec![0]);
364    }
365
366    #[test]
367    fn incidence_filters_inequalities() {
368        // Row 0 is g = 1 (equality). Row 1 is g ∈ [0, 5] (range).
369        let p = probe(2, 2, &[0, 0, 1], &[0, 1, 0], &[1.0, 0.0], &[1.0, 5.0]);
370        let inc = EqualityIncidence::from_probe(&p);
371        assert_eq!(inc.n_eq_rows(), 1);
372        assert_eq!(inc.eq_row_inner_idx, vec![0]);
373        assert_eq!(inc.neighbors(0), &[0, 1]);
374    }
375
376    #[test]
377    fn incidence_dedupes_and_sorts_columns() {
378        // Same equality row mentions column 1 twice and column 0
379        // after column 1 — output must be sorted [0, 1] without dupes.
380        let p = probe(2, 1, &[0, 0, 0, 0], &[1, 1, 0, 1], &[2.5], &[2.5]);
381        let inc = EqualityIncidence::from_probe(&p);
382        assert_eq!(inc.neighbors(0), &[0, 1]);
383    }
384
385    #[test]
386    fn incidence_respects_fortran_indexing() {
387        let mut p = probe(
388            2,
389            1,
390            &[1, 1], // Fortran rows 1..=1
391            &[1, 2], // Fortran cols 1..=2
392            &[0.0],
393            &[0.0],
394        );
395        p.one_based = true;
396        let inc = EqualityIncidence::from_probe(&p);
397        assert_eq!(inc.n_eq_rows(), 1);
398        assert_eq!(inc.neighbors(0), &[0, 1]);
399    }
400
401    #[test]
402    fn incidence_drops_structural_zeros_when_values_provided() {
403        // Row 0 touches columns 0 and 1, but the (0, 1) entry has
404        // value 0.0 at the probe point.
405        let vals = [3.5, 0.0];
406        let p = ProbeView {
407            n_vars: 2,
408            m_rows: 1,
409            jac_irow: &[0, 0],
410            jac_jcol: &[0, 1],
411            jac_values: Some(&vals),
412            g_l: &[1.0],
413            g_u: &[1.0],
414            linearity: None,
415            one_based: false,
416            eq_tol: 1e-12,
417            excluded_vars: None,
418            excluded_rows: None,
419        };
420        let inc = EqualityIncidence::from_probe(&p);
421        assert_eq!(inc.neighbors(0), &[0]);
422    }
423
424    #[test]
425    fn inequality_incidence_filters_equalities() {
426        // Row 0 is g = 1 (equality); row 1 is g ∈ [0, 5] (range).
427        // Only row 1 should appear in InequalityIncidence.
428        let p = probe(2, 2, &[0, 0, 1], &[0, 1, 0], &[1.0, 0.0], &[1.0, 5.0]);
429        let ineq = InequalityIncidence::from_probe(&p);
430        assert_eq!(ineq.n_ineq_rows(), 1);
431        assert_eq!(ineq.ineq_row_inner_idx, vec![1]);
432        assert_eq!(ineq.neighbors(0), &[0]);
433        assert!(ineq.var_in_inequality(0));
434        assert!(!ineq.var_in_inequality(1));
435    }
436
437    #[test]
438    fn inequality_incidence_range_row() {
439        // Single row, g ∈ [-2, 5] over both variables.
440        let p = probe(2, 1, &[0, 0], &[0, 1], &[-2.0], &[5.0]);
441        let ineq = InequalityIncidence::from_probe(&p);
442        assert_eq!(ineq.n_ineq_rows(), 1);
443        assert_eq!(ineq.neighbors(0), &[0, 1]);
444        // Reverse adjacency points each variable back to row 0.
445        assert_eq!(ineq.rows_for_var(0), &[0]);
446        assert_eq!(ineq.rows_for_var(1), &[0]);
447    }
448
449    #[test]
450    fn inequality_incidence_one_sided() {
451        // g(x) ≤ 5 (lower = -∞ in Ipopt land). Encoded with a very
452        // negative lower bound; abs gap >> eq_tol.
453        let p = probe(1, 1, &[0], &[0], &[-1e19], &[5.0]);
454        let ineq = InequalityIncidence::from_probe(&p);
455        assert_eq!(ineq.n_ineq_rows(), 1);
456        assert_eq!(ineq.neighbors(0), &[0]);
457    }
458}