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}