Skip to main content

pounce_presolve/
inequality_projection.rs

1//! Inequality projection for `InequalityCoupled` candidate blocks.
2//!
3//! PR 14 of the auxiliary-presolve port (issue #53). Today the
4//! orchestrator rejects any block whose variables also appear in an
5//! inequality row — fixing the block could violate the inequality.
6//! This module widens the accepted class: when both the block's
7//! equality system AND the coupled inequality rows are linear, we
8//! substitute the block solution into the inequalities and check
9//! whether the result is implied by the variable box on the
10//! surviving variables. If yes, the block is safe to eliminate
11//! without further IPM machinery.
12//!
13//! Algorithm: given the block's linear system
14//!
15//! ```text
16//!   J_block · b + J_other · y = const_block
17//! ```
18//!
19//! solve for `b = M y + p` (one dense LU factorisation, used as a
20//! right-hand-side solve once per surviving column plus once for
21//! the constant). Then for each coupled inequality
22//! `A_b · b + A_y · y ∈ [g_l, g_u]`:
23//!
24//! ```text
25//!   coef = A_b · M + A_y
26//!   rhs_l = g_l - A_b · p
27//!   rhs_u = g_u - A_b · p
28//! ```
29//!
30//! Compute the activity range `[lo, hi]` of `coef · y` over `y` in
31//! `[x_l, x_u]`. If `rhs_l ≤ lo` AND `hi ≤ rhs_u`, this inequality
32//! is implied. If every coupled inequality is implied, the block
33//! is safe.
34//!
35//! The master plan refers to this as "Fourier-Motzkin projection."
36//! Since BTF blocks are square (the equalities uniquely determine
37//! `b`), substitution and FM elimination produce equivalent
38//! projected constraints. General FM (handling underdetermined
39//! sub-blocks, range inequalities producing new constraints to add
40//! back to the IPM problem) is deferred; it requires extending
41//! `PresolveTnlp` to synthesize rows.
42
43use pounce_common::types::{Index, Number};
44
45use crate::block_solve::{lu_factor_partial_pivot, lu_solve};
46
47/// Outcome of projecting all coupled inequalities of one candidate
48/// block.
49#[derive(Debug, Clone)]
50pub struct ProjectionResult {
51    /// True iff every coupled inequality is implied by the
52    /// variable box after the block substitution.
53    pub all_implied: bool,
54    /// Per-inequality details, in the order of `coupled_ineq_rows`
55    /// passed in.
56    pub per_row: Vec<ProjectedRow>,
57}
58
59#[derive(Debug, Clone)]
60pub struct ProjectedRow {
61    /// Inner-row index this projection came from.
62    pub inner_row: usize,
63    /// Coefficient vector over surviving (non-block) variables;
64    /// length `n_vars`, zero at block columns.
65    pub coef: Vec<Number>,
66    /// Projected lower bound `g_l - A_b · p`.
67    pub rhs_l: Number,
68    /// Projected upper bound `g_u - A_b · p`.
69    pub rhs_u: Number,
70    /// Activity-range lower bound of `coef · y` over `[x_l, x_u]`.
71    pub activity_lo: Number,
72    /// Activity-range upper bound of `coef · y` over `[x_l, x_u]`.
73    pub activity_hi: Number,
74    /// True iff `rhs_l ≤ activity_lo ≤ activity_hi ≤ rhs_u`.
75    pub implied: bool,
76}
77
78/// Project the coupled inequalities by substituting the block
79/// solution `b = M y + p`. Returns `None` when `J_block` is
80/// singular (block solve would have failed anyway) or when the
81/// system can't be built coherently.
82#[allow(clippy::too_many_arguments)]
83pub fn project_inequalities(
84    block_rows: &[usize],
85    block_cols: &[usize],
86    coupled_ineq_rows: &[usize],
87    n_vars: usize,
88    x_l: &[Number],
89    x_u: &[Number],
90    g_l: &[Number],
91    g_u: &[Number],
92    jac_irow: &[Index],
93    jac_jcol: &[Index],
94    jac_values: &[Number],
95    g_at_probe: &[Number],
96    x_probe: &[Number],
97    one_based: bool,
98) -> Option<ProjectionResult> {
99    let k = block_rows.len();
100    if k == 0 || k != block_cols.len() {
101        return None;
102    }
103
104    // Bucket Jacobian entries by row → (col, value).
105    let nnz = jac_irow.len();
106    let mut by_row: std::collections::HashMap<usize, Vec<(usize, Number)>> =
107        std::collections::HashMap::new();
108    for kk in 0..nnz {
109        let i = if one_based {
110            (jac_irow[kk] as isize - 1) as usize
111        } else {
112            jac_irow[kk] as usize
113        };
114        let j = if one_based {
115            (jac_jcol[kk] as isize - 1) as usize
116        } else {
117            jac_jcol[kk] as usize
118        };
119        if j >= n_vars {
120            continue;
121        }
122        by_row.entry(i).or_default().push((j, jac_values[kk]));
123    }
124
125    // Index lookup: which position in `block_cols` does an inner
126    // column belong to? -1 (None) if it's a surviving variable.
127    let mut col_to_block_pos: Vec<Option<usize>> = vec![None; n_vars];
128    for (pos, &c) in block_cols.iter().enumerate() {
129        col_to_block_pos[c] = Some(pos);
130    }
131
132    // Build `J_block` (k×k, row-major) and `const_block` (k,
133    // = g_l[r] - linear constant of row r).
134    // Linear constant: for a linear row, g(x) = J · x + c, so c =
135    // g(x_probe) - J · x_probe.
136    let mut j_block = vec![0.0; k * k];
137    let mut const_block = vec![0.0; k];
138    for (i_block, &r) in block_rows.iter().enumerate() {
139        let entries = match by_row.get(&r) {
140            Some(e) => e,
141            None => return None, // empty equality row — block ill-posed
142        };
143        let mut sum_jx = 0.0;
144        for &(c, v) in entries {
145            sum_jx += v * x_probe[c];
146            if let Some(j_pos) = col_to_block_pos[c] {
147                j_block[i_block * k + j_pos] = v;
148            }
149        }
150        let c_r = g_at_probe[r] - sum_jx;
151        const_block[i_block] = g_l[r] - c_r;
152    }
153
154    // LU-factor J_block.
155    let mut j_block_lu = j_block.clone();
156    let piv = lu_factor_partial_pivot(&mut j_block_lu, k).ok()?;
157
158    // Solve J_block · p = const_block.
159    let mut p = const_block.clone();
160    lu_solve(&j_block_lu, &piv, &mut p, k);
161
162    // Solve J_block · M_col = J_other_col for each surviving col,
163    // then negate to get the column of M. Store M as
164    // `m_by_y[(surviving_col_index_in_block_var_space)][(block_var_idx)]`.
165    // Cheaper: per surviving var v that appears in any block row,
166    // build its J_other column, LU-solve, store. Vars that don't
167    // appear in any block row have a zero column in M.
168    let mut m_columns: std::collections::HashMap<usize, Vec<Number>> =
169        std::collections::HashMap::new();
170    for (i_block, &r) in block_rows.iter().enumerate() {
171        let entries = by_row.get(&r).expect("checked above");
172        for &(c, v) in entries {
173            if col_to_block_pos[c].is_some() {
174                continue;
175            }
176            // Accumulate J_other column for surviving var c.
177            let col = m_columns.entry(c).or_insert_with(|| vec![0.0; k]);
178            col[i_block] += v;
179            let _ = v;
180            let _ = i_block;
181        }
182    }
183    let mut m: std::collections::HashMap<usize, Vec<Number>> =
184        std::collections::HashMap::with_capacity(m_columns.len());
185    for (c, mut col) in m_columns {
186        lu_solve(&j_block_lu, &piv, &mut col, k);
187        // Negate.
188        for v in col.iter_mut() {
189            *v = -*v;
190        }
191        m.insert(c, col);
192    }
193
194    // Now project each coupled inequality.
195    let mut per_row: Vec<ProjectedRow> = Vec::with_capacity(coupled_ineq_rows.len());
196    let mut all_implied = true;
197    for &r in coupled_ineq_rows {
198        let entries = by_row.get(&r).cloned().unwrap_or_default();
199        // Compute the linear constant for this row.
200        let mut sum_jx = 0.0;
201        for &(c, v) in &entries {
202            sum_jx += v * x_probe[c];
203        }
204        let const_r = g_at_probe[r] - sum_jx;
205        let gl = g_l[r] - const_r;
206        let gu = g_u[r] - const_r;
207        // a_b · p: contribution from block columns hitting `p`.
208        // Build A_b (in block-column order) and A_y (per-surviving-col).
209        let mut a_b = vec![0.0; k];
210        let mut a_y: std::collections::HashMap<usize, Number> = std::collections::HashMap::new();
211        for &(c, v) in &entries {
212            if let Some(pos) = col_to_block_pos[c] {
213                a_b[pos] = v;
214            } else {
215                *a_y.entry(c).or_insert(0.0) += v;
216            }
217        }
218        let mut a_b_dot_p: Number = 0.0;
219        for (i_block, &val) in a_b.iter().enumerate() {
220            a_b_dot_p += val * p[i_block];
221        }
222        let rhs_l = gl - a_b_dot_p;
223        let rhs_u = gu - a_b_dot_p;
224        // coef[c] = a_y[c] + Σ_i a_b[i] * M[c][i]   (for surviving c)
225        let mut coef = vec![0.0; n_vars];
226        for (&c, &ay_val) in &a_y {
227            coef[c] += ay_val;
228        }
229        for (&c, m_col) in &m {
230            let mut s = 0.0;
231            for i_block in 0..k {
232                s += a_b[i_block] * m_col[i_block];
233            }
234            coef[c] += s;
235        }
236        // Activity range of `coef · y` over `y ∈ [x_l, x_u]`.
237        // Treat block columns as their fixed values (b = M y + p
238        // means the activity over block cols is zero in `coef`
239        // because we substituted them out — coef is zero there).
240        let mut lo: Number = 0.0;
241        let mut hi: Number = 0.0;
242        let mut bounded = true;
243        for c in 0..n_vars {
244            let v = coef[c];
245            if v == 0.0 {
246                continue;
247            }
248            // For block columns, x_l/x_u may already be clamped or
249            // wide; but `coef[block_col]` should be zero by
250            // construction.
251            let xl = x_l[c];
252            let xu = x_u[c];
253            if !xl.is_finite() || !xu.is_finite() {
254                bounded = false;
255                break;
256            }
257            if v > 0.0 {
258                lo += v * xl;
259                hi += v * xu;
260            } else {
261                lo += v * xu;
262                hi += v * xl;
263            }
264        }
265        let (activity_lo, activity_hi) = if bounded {
266            (lo, hi)
267        } else {
268            (Number::NEG_INFINITY, Number::INFINITY)
269        };
270        let implied = rhs_l <= activity_lo && activity_hi <= rhs_u;
271        if !implied {
272            all_implied = false;
273        }
274        per_row.push(ProjectedRow {
275            inner_row: r,
276            coef,
277            rhs_l,
278            rhs_u,
279            activity_lo,
280            activity_hi,
281            implied,
282        });
283    }
284
285    Some(ProjectionResult {
286        all_implied,
287        per_row,
288    })
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn projection_implied_singleton() {
297        // Block: row 0, var 0. Equality: 1·b = 3, so b = 3.
298        // Coupled ineq: row 1: 1·b + 0·y in [-10, 10]. After
299        // substituting b=3: 3 in [-10, 10] → implied.
300        // n_vars = 2 (b is x[0], y is x[1]).
301        let result = project_inequalities(
302            &[0], // block_rows
303            &[0], // block_cols
304            &[1], // coupled_ineq_rows
305            2,
306            &[-1e19, 0.0],
307            &[1e19, 1.0],
308            &[3.0, -10.0],
309            &[3.0, 10.0],
310            &[0, 1],
311            &[0, 0],
312            &[1.0, 1.0],
313            &[0.0, 0.0],
314            &[0.0, 0.0],
315            false,
316        )
317        .expect("non-singular");
318        assert!(result.all_implied);
319        let row = &result.per_row[0];
320        // No `y` coefficient → coef[1] = 0; activity range [0,0].
321        assert_eq!(row.coef[0], 0.0); // block col
322        assert!(row.coef[1].abs() < 1e-12);
323        assert!((row.rhs_l - (-13.0)).abs() < 1e-12); // -10 - 3
324        assert!((row.rhs_u - 7.0).abs() < 1e-12); // 10 - 3
325        assert!(row.implied);
326    }
327
328    #[test]
329    fn projection_not_implied_tight() {
330        // Block: row 0, var 0. Equality: 1·b = 5, so b = 5.
331        // Coupled ineq: row 1: 1·b in [-1, 1]. After substituting:
332        // 5 in [-1, 1] → NOT implied. activity_lo = activity_hi = 0
333        // (no y contribution), rhs_l = -6, rhs_u = -4. Check
334        // [-6, -4] is required to contain [0, 0]? rhs_u = -4 < 0 →
335        // not implied.
336        let result = project_inequalities(
337            &[0],
338            &[0],
339            &[1],
340            2,
341            &[-1e19, 0.0],
342            &[1e19, 1.0],
343            &[5.0, -1.0],
344            &[5.0, 1.0],
345            &[0, 1],
346            &[0, 0],
347            &[1.0, 1.0],
348            &[0.0, 0.0],
349            &[0.0, 0.0],
350            false,
351        )
352        .expect("non-singular");
353        assert!(!result.all_implied);
354        assert!(!result.per_row[0].implied);
355    }
356
357    #[test]
358    fn projection_2x2_block_implied() {
359        // 2-var block. Equalities:
360        //   r0: b0 + b1 = 3
361        //   r1: b0 - b1 = 1
362        // → b0 = 2, b1 = 1.
363        // Coupled ineq r2: b0 + b1 + 0·y in [0, 100]. After
364        // substitution: 3 ∈ [0, 100] → implied.
365        let result = project_inequalities(
366            &[0, 1],
367            &[0, 1],
368            &[2],
369            3,
370            &[-1e19, -1e19, 0.0],
371            &[1e19, 1e19, 1.0],
372            &[3.0, 1.0, 0.0],
373            &[3.0, 1.0, 100.0],
374            &[0, 0, 1, 1, 2, 2],
375            &[0, 1, 0, 1, 0, 1],
376            &[1.0, 1.0, 1.0, -1.0, 1.0, 1.0],
377            &[0.0, 0.0, 0.0],
378            &[0.0, 0.0, 0.0],
379            false,
380        )
381        .expect("non-singular");
382        assert!(result.all_implied);
383    }
384
385    #[test]
386    fn projection_unbounded_var_in_y_blocks_admit() {
387        // Block: row 0, var 0. r0: 1·b = 3 → b = 3.
388        // Coupled ineq: row 1: 1·b + 1·y ≤ 100, with y unbounded
389        // above. Activity hi = +∞ → not implied.
390        let result = project_inequalities(
391            &[0],
392            &[0],
393            &[1],
394            2,
395            &[-1e19, 0.0],
396            &[1e19, 1e19], // y unbounded
397            &[3.0, -1e19],
398            &[3.0, 100.0],
399            &[0, 1, 1],
400            &[0, 0, 1],
401            &[1.0, 1.0, 1.0],
402            &[0.0, 0.0],
403            &[0.0, 0.0],
404            false,
405        )
406        .expect("non-singular");
407        assert!(!result.all_implied);
408    }
409
410    #[test]
411    fn projection_singular_block() {
412        // 2-var block with rank-1 equality system → LU fails →
413        // returns None.
414        let r = project_inequalities(
415            &[0, 1],
416            &[0, 1],
417            &[],
418            2,
419            &[-1e19, -1e19],
420            &[1e19, 1e19],
421            &[0.0, 0.0],
422            &[0.0, 0.0],
423            &[0, 0, 1, 1],
424            &[0, 1, 0, 1],
425            &[1.0, 2.0, 2.0, 4.0],
426            &[0.0, 0.0],
427            &[0.0, 0.0],
428            false,
429        );
430        assert!(r.is_none());
431    }
432}