Skip to main content

otspot_core/mip/
problem.rs

1//! MILP / MIQP problem definitions.
2//!
3//! A MILP/MIQP is a continuous LP/QP **relaxation** plus a set of variables
4//! constrained to integer values. We deliberately wrap the existing
5//! [`LpProblem`] / [`QpProblem`] rather than adding an integrality field to them:
6//! the continuous solvers stay untouched (zero regression surface), and each
7//! branch-and-bound node solves the relaxation by swapping `bounds` — the same
8//! mechanism the spatial QP B&B already uses for box subproblems.
9
10use super::Relaxation;
11use crate::linalg::ldl::is_q_psd_by_cholesky;
12use crate::options::SolverOptions;
13use crate::problem::{ConstraintType, LpProblem, SolveStatus, SolverResult};
14use crate::qp::QpProblem;
15use crate::tolerances::{FIXED_POINT_FEAS_TOL, INT_ROUND_TOL};
16
17/// Construction error for [`MilpProblem`] / [`MiqpProblem`].
18#[non_exhaustive]
19#[derive(Debug, PartialEq, Eq)]
20pub enum MipProblemError {
21    /// An integer-variable index is out of range for the relaxation.
22    InvalidIntegerVar { index: usize, num_vars: usize },
23}
24
25impl std::fmt::Display for MipProblemError {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            MipProblemError::InvalidIntegerVar { index, num_vars } => write!(
29                f,
30                "integer variable index {} out of range (num_vars = {})",
31                index, num_vars
32            ),
33        }
34    }
35}
36
37impl std::error::Error for MipProblemError {}
38
39/// Sort, de-duplicate and range-check the integer-variable index set.
40fn normalize_integer_vars(
41    mut integer_vars: Vec<usize>,
42    num_vars: usize,
43) -> Result<Vec<usize>, MipProblemError> {
44    integer_vars.sort_unstable();
45    integer_vars.dedup();
46    if let Some(&j) = integer_vars.last() {
47        if j >= num_vars {
48            return Err(MipProblemError::InvalidIntegerVar { index: j, num_vars });
49        }
50    }
51    Ok(integer_vars)
52}
53
54/// Mixed-Integer Linear Program: minimize `c^T x` over the [`LpProblem`]
55/// feasible region with `x[j]` integral for every `j` in `integer_vars`.
56#[derive(Debug, Clone)]
57pub struct MilpProblem {
58    /// Continuous LP relaxation (objective, constraints, bounds).
59    pub lp: LpProblem,
60    /// Sorted, de-duplicated indices of variables required to be integral.
61    pub integer_vars: Vec<usize>,
62}
63
64impl MilpProblem {
65    /// Build a MILP from an LP relaxation and the integer-variable indices.
66    ///
67    /// `integer_vars` is sorted and de-duplicated. An empty set is permitted and
68    /// means the problem is a plain LP (the solver falls back to the LP path).
69    pub fn new(lp: LpProblem, integer_vars: Vec<usize>) -> Result<Self, MipProblemError> {
70        let integer_vars = normalize_integer_vars(integer_vars, lp.num_vars)?;
71        Ok(Self { lp, integer_vars })
72    }
73
74    /// Number of decision variables.
75    pub fn num_vars(&self) -> usize {
76        self.lp.num_vars
77    }
78}
79
80impl Relaxation for MilpProblem {
81    fn num_vars(&self) -> usize {
82        self.lp.num_vars
83    }
84    fn root_bounds(&self) -> &[(f64, f64)] {
85        &self.lp.bounds
86    }
87    fn integer_vars(&self) -> &[usize] {
88        &self.integer_vars
89    }
90    fn solve(&self, bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult {
91        let mut sub = self.lp.clone();
92        sub.bounds = bounds.to_vec();
93        crate::lp::solve_lp_with(&sub, opts)
94    }
95}
96
97/// Mixed-Integer **convex** Quadratic Program: minimize `1/2 x^T Q x + c^T x`
98/// over the [`QpProblem`] feasible region with `x[j]` integral for every `j` in
99/// `integer_vars`. Only convex (`Q` PSD) instances are in scope — see
100/// [`MiqpProblem::is_convex`].
101#[derive(Debug, Clone)]
102pub struct MiqpProblem {
103    /// Continuous QP relaxation (objective, constraints, bounds).
104    pub qp: QpProblem,
105    /// Sorted, de-duplicated indices of variables required to be integral.
106    pub integer_vars: Vec<usize>,
107}
108
109impl MiqpProblem {
110    /// Build an MIQP from a QP relaxation and the integer-variable indices.
111    ///
112    /// Convexity is **not** enforced here; [`solve_miqp`] calls [`MiqpProblem::is_convex`]
113    /// and rejects non-PSD `Q`. `integer_vars` is sorted and de-duplicated; an empty
114    /// set means a plain QP (the solver falls back to the QP path).
115    ///
116    /// [`solve_miqp`]: crate::solve_miqp
117    pub fn new(qp: QpProblem, integer_vars: Vec<usize>) -> Result<Self, MipProblemError> {
118        let integer_vars = normalize_integer_vars(integer_vars, qp.num_vars)?;
119        Ok(Self { qp, integer_vars })
120    }
121
122    /// Number of decision variables.
123    pub fn num_vars(&self) -> usize {
124        self.qp.num_vars
125    }
126
127    /// Whether the objective is convex (`Q` positive semidefinite). The QP
128    /// relaxation is a valid lower bound only when this holds; a non-convex MIQP
129    /// is out of scope.
130    pub fn is_convex(&self) -> bool {
131        is_q_psd_by_cholesky(&self.qp.q)
132    }
133}
134
135impl Relaxation for MiqpProblem {
136    fn num_vars(&self) -> usize {
137        self.qp.num_vars
138    }
139    fn root_bounds(&self) -> &[(f64, f64)] {
140        &self.qp.bounds
141    }
142    fn integer_vars(&self) -> &[usize] {
143        &self.integer_vars
144    }
145    fn solve(&self, bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult {
146        // When integer branching fixes every variable to a point, the QP interior
147        // point method has no interior and fails (NumericalError). Evaluate the
148        // single candidate directly instead.
149        if let Some(r) = solve_fixed_point(&self.qp, bounds) {
150            return r;
151        }
152        let mut sub = self.qp.clone();
153        sub.bounds = bounds.to_vec();
154        crate::qp::solve_qp_with(&sub, opts)
155    }
156}
157
158/// Solve the relaxation when **every** variable is fixed to a point (zero-width
159/// box). The QP IPM cannot (no interior), so evaluate the single candidate `x`
160/// directly: check linear-constraint feasibility, then return its exact objective
161/// `1/2 x'Qx + c'x + offset`. Returns `None` when any variable is still free
162/// (the IPM handles those, including partially-fixed boxes).
163fn solve_fixed_point(qp: &QpProblem, bounds: &[(f64, f64)]) -> Option<SolverResult> {
164    // INT_ROUND_TOL: a fixed variable has width 0; this small tolerance guards float round-off.
165    if !bounds.iter().all(|&(l, u)| u - l <= INT_ROUND_TOL) {
166        return None;
167    }
168    // Empty box (lower meaningfully above upper) → infeasible subproblem.
169    if bounds.iter().any(|&(l, u)| l - u > INT_ROUND_TOL) {
170        return Some(SolverResult::infeasible());
171    }
172    let x: Vec<f64> = bounds.iter().map(|&(l, u)| 0.5 * (l + u)).collect();
173
174    if qp.num_constraints > 0 {
175        let lhs = match qp.a.mat_vec_mul(&x) {
176            Ok(v) => v,
177            Err(_) => return Some(SolverResult::numerical_error()),
178        };
179        for ((&lhs_k, &ct), &b_k) in lhs.iter().zip(&qp.constraint_types).zip(&qp.b) {
180            let feasible = match ct {
181                ConstraintType::Le => lhs_k <= b_k + FIXED_POINT_FEAS_TOL,
182                ConstraintType::Ge => lhs_k >= b_k - FIXED_POINT_FEAS_TOL,
183                ConstraintType::Eq => (lhs_k - b_k).abs() <= FIXED_POINT_FEAS_TOL,
184            };
185            if !feasible {
186                return Some(SolverResult::infeasible());
187            }
188        }
189    }
190
191    // Objective 1/2 x'Qx + c'x + offset (Q is full-symmetric CSC storage).
192    let qx = match qp.q.mat_vec_mul(&x) {
193        Ok(v) => v,
194        Err(_) => return Some(SolverResult::numerical_error()),
195    };
196    let quad: f64 = 0.5 * x.iter().zip(&qx).map(|(xi, qxi)| xi * qxi).sum::<f64>();
197    let lin: f64 = qp.c.iter().zip(&x).map(|(ci, xi)| ci * xi).sum::<f64>();
198    Some(SolverResult {
199        status: SolveStatus::Optimal,
200        objective: quad + lin + qp.obj_offset,
201        solution: x,
202        ..Default::default()
203    })
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::problem::ConstraintType;
210    use crate::sparse::CscMatrix;
211
212    fn lp_2var() -> LpProblem {
213        // trivial 2-var LP, bounds [0,5]^2, one <= constraint
214        let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
215        LpProblem::new_general(
216            vec![1.0, 1.0],
217            a,
218            vec![5.0],
219            vec![ConstraintType::Le],
220            vec![(0.0, 5.0), (0.0, 5.0)],
221            None,
222        )
223        .unwrap()
224    }
225
226    fn qp_diag(diag: &[f64]) -> QpProblem {
227        let n = diag.len();
228        let idx: Vec<usize> = (0..n).collect();
229        let q = CscMatrix::from_triplets(&idx, &idx, diag, n, n).unwrap();
230        let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
231        QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap()
232    }
233
234    #[test]
235    fn new_sorts_and_dedups_integer_vars() {
236        let m = MilpProblem::new(lp_2var(), vec![1, 0, 1]).unwrap();
237        assert_eq!(m.integer_vars, vec![0, 1]);
238    }
239
240    #[test]
241    fn new_rejects_out_of_range_index() {
242        let err = MilpProblem::new(lp_2var(), vec![0, 2]).unwrap_err();
243        assert_eq!(
244            err,
245            MipProblemError::InvalidIntegerVar {
246                index: 2,
247                num_vars: 2
248            }
249        );
250    }
251
252    #[test]
253    fn empty_integer_vars_allowed() {
254        let m = MilpProblem::new(lp_2var(), vec![]).unwrap();
255        assert!(m.integer_vars.is_empty());
256    }
257
258    #[test]
259    fn miqp_new_validates_indices() {
260        let err = MiqpProblem::new(qp_diag(&[2.0, 2.0]), vec![2]).unwrap_err();
261        assert_eq!(
262            err,
263            MipProblemError::InvalidIntegerVar {
264                index: 2,
265                num_vars: 2
266            }
267        );
268    }
269
270    #[test]
271    fn psd_diagonal_q_is_convex() {
272        let m = MiqpProblem::new(qp_diag(&[2.0, 4.0]), vec![0, 1]).unwrap();
273        assert!(m.is_convex(), "positive diagonal Q must be PSD");
274    }
275
276    #[test]
277    fn indefinite_q_is_not_convex() {
278        let m = MiqpProblem::new(qp_diag(&[2.0, -3.0]), vec![0, 1]).unwrap();
279        assert!(
280            !m.is_convex(),
281            "negative eigenvalue must be detected as non-convex"
282        );
283    }
284
285    #[test]
286    fn zero_q_is_convex() {
287        // Q = 0 (LP-like) is trivially PSD.
288        let m = MiqpProblem::new(qp_diag(&[0.0, 0.0]), vec![0]).unwrap();
289        assert!(m.is_convex());
290    }
291
292    #[test]
293    fn fixed_point_evaluates_objective_exactly() {
294        // min x^2 (Q=2) with x fixed to 2 → obj = 1/2·2·4 = 4, no IPM needed.
295        let qp = qp_diag(&[2.0]);
296        let r = solve_fixed_point(&qp, &[(2.0, 2.0)]).expect("all fixed → Some");
297        assert_eq!(r.status, SolveStatus::Optimal);
298        assert!((r.objective - 4.0).abs() < 1e-12, "obj={}", r.objective);
299        assert_eq!(r.solution, vec![2.0]);
300    }
301
302    #[test]
303    fn fixed_point_returns_none_when_a_var_is_free() {
304        let qp = qp_diag(&[2.0, 2.0]);
305        assert!(solve_fixed_point(&qp, &[(2.0, 2.0), (0.0, 5.0)]).is_none());
306    }
307
308    #[test]
309    fn fixed_point_infeasible_constraint() {
310        // x fixed to 2 but constraint x >= 5 → infeasible.
311        let q = CscMatrix::from_triplets(&[0], &[0], &[2.0], 1, 1).unwrap();
312        let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
313        let qp = QpProblem::new(
314            q,
315            vec![0.0],
316            a,
317            vec![5.0],
318            vec![(2.0, 2.0)],
319            vec![ConstraintType::Ge],
320        )
321        .unwrap();
322        let r = solve_fixed_point(&qp, &[(2.0, 2.0)]).expect("all fixed → Some");
323        assert_eq!(r.status, SolveStatus::Infeasible);
324    }
325
326    #[test]
327    fn fixed_point_empty_box_infeasible() {
328        let qp = qp_diag(&[2.0]);
329        let r = solve_fixed_point(&qp, &[(3.0, 2.0)]).expect("empty box → Some");
330        assert_eq!(r.status, SolveStatus::Infeasible);
331    }
332
333    #[test]
334    fn fixed_point_dim_mismatch_q_returns_numerical_error() {
335        // 2-var QP but only 1 bound: x.len()=1 != Q.ncols=2 → mat_vec_mul error.
336        // Must return Some(NumericalError), not None (which would silently fall to IPM).
337        let qp = qp_diag(&[2.0, 2.0]);
338        let r = solve_fixed_point(&qp, &[(1.0, 1.0)]);
339        assert!(
340            r.is_some(),
341            "dim mismatch must not return None (IPM fallback)"
342        );
343        assert_eq!(r.unwrap().status, SolveStatus::NumericalError);
344    }
345
346    #[test]
347    fn fixed_point_dim_mismatch_a_returns_numerical_error() {
348        // 2-var QP with a constraint: x.len()=1 but A.ncols=2 → A mat_vec_mul error.
349        let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[2.0, 2.0], 2, 2).unwrap();
350        let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
351        let qp =
352            QpProblem::new_all_le(q, vec![0.0, 0.0], a, vec![5.0], vec![(0.0, 5.0); 2]).unwrap();
353        let r = solve_fixed_point(&qp, &[(1.0, 1.0)]);
354        assert!(
355            r.is_some(),
356            "dim mismatch must not return None (IPM fallback)"
357        );
358        assert_eq!(r.unwrap().status, SolveStatus::NumericalError);
359    }
360
361    #[test]
362    fn psd_with_off_diagonal_detected() {
363        // Q = [[2,1],[1,2]] (full-symmetric storage) is PSD (eigenvalues 1, 3).
364        let q = CscMatrix::from_triplets(&[0, 0, 1, 1], &[0, 1, 0, 1], &[2.0, 1.0, 1.0, 2.0], 2, 2)
365            .unwrap();
366        let a = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
367        let qp = QpProblem::new_all_le(q, vec![0.0, 0.0], a, vec![], vec![(0.0, 5.0); 2]).unwrap();
368        assert!(MiqpProblem::new(qp, vec![0, 1]).unwrap().is_convex());
369
370        // Q = [[1,2],[2,1]] is indefinite (eigenvalues -1, 3).
371        let q2 =
372            CscMatrix::from_triplets(&[0, 0, 1, 1], &[0, 1, 0, 1], &[1.0, 2.0, 2.0, 1.0], 2, 2)
373                .unwrap();
374        let a2 = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
375        let qp2 =
376            QpProblem::new_all_le(q2, vec![0.0, 0.0], a2, vec![], vec![(0.0, 5.0); 2]).unwrap();
377        assert!(!MiqpProblem::new(qp2, vec![0, 1]).unwrap().is_convex());
378    }
379
380    // ── Large-n (n > former PSD_DENSE_LIMIT=1000) sentinels ──────────────────
381
382    /// Build an n×n MIQP with diagonal 1.0 and Q[0,1]=Q[1,0]=2.0.
383    /// The top-left 2×2 block has eigenvalues {-1, 3} → matrix is indefinite.
384    fn large_n_indefinite_miqp(n: usize) -> MiqpProblem {
385        let mut rows = Vec::new();
386        let mut cols = Vec::new();
387        let mut vals = Vec::new();
388        for i in 0..n {
389            rows.push(i);
390            cols.push(i);
391            vals.push(1.0_f64);
392        }
393        // off-diagonal: Q[0,1]=Q[1,0]=2 makes the 2×2 block eigenvalues {-1,3}
394        rows.push(0);
395        cols.push(1);
396        vals.push(2.0);
397        rows.push(1);
398        cols.push(0);
399        vals.push(2.0);
400        let q = CscMatrix::from_triplets(&rows, &cols, &vals, n, n).unwrap();
401        let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
402        let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
403        MiqpProblem::new(qp, vec![0]).unwrap()
404    }
405
406    /// Build an n×n MIQP with strictly positive diagonal Q (identity × 2) → PSD.
407    fn large_n_psd_miqp(n: usize) -> MiqpProblem {
408        let idx: Vec<usize> = (0..n).collect();
409        let vals: Vec<f64> = vec![2.0; n];
410        let q = CscMatrix::from_triplets(&idx, &idx, &vals, n, n).unwrap();
411        let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
412        let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
413        MiqpProblem::new(qp, vec![0]).unwrap()
414    }
415
416    /// **Sentinel**: n>1000, diagonal≥0, off-diagonal indefinite → nonconvex.
417    ///
418    /// No-op proof: if `is_convex` reverts to `return true` for n > 1000
419    /// (the old PSD_DENSE_LIMIT path), this assertion fails, exposing the
420    /// false-Optimal bug.
421    #[test]
422    fn large_n_off_diag_indefinite_is_not_convex() {
423        let m = large_n_indefinite_miqp(1001);
424        assert!(
425            !m.is_convex(),
426            "n=1001 indefinite MIQP (diag≥0, off-diag λ_min=-1) must be detected as \
427             nonconvex; `return true` for n>1000 produces false-Optimal (sentinel)"
428        );
429    }
430
431    /// **Regression guard**: large-n truly PSD Q must still pass the convexity gate.
432    /// Verifies no over-rejection (false negatives) after the fix.
433    #[test]
434    fn large_n_diagonal_psd_is_convex() {
435        // n=1001 diagonal-2 Q (strictly PD) must be convex.
436        let m = large_n_psd_miqp(1001);
437        assert!(
438            m.is_convex(),
439            "large-n diagonal PSD Q must not be over-rejected"
440        );
441    }
442
443    /// Regression guard: large-n Q=0 (LP case) is trivially convex.
444    #[test]
445    fn large_n_zero_q_is_convex() {
446        let n = 1001usize;
447        let q = CscMatrix::from_triplets(&[], &[], &[], n, n).unwrap();
448        let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
449        let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
450        let m = MiqpProblem::new(qp, vec![0]).unwrap();
451        assert!(m.is_convex(), "large-n zero Q (LP) must be convex");
452    }
453
454    /// Regression guard: large-n PSD-but-singular Q (rank-deficient PSD) is convex.
455    #[test]
456    fn large_n_psd_singular_is_convex() {
457        // Q: identity except last diagonal is 0 → rank n-1, λ_min=0 (PSD).
458        let n = 1001usize;
459        let mut rows: Vec<usize> = Vec::new();
460        let mut cols: Vec<usize> = Vec::new();
461        let mut vals: Vec<f64> = Vec::new();
462        for i in 0..n - 1 {
463            rows.push(i);
464            cols.push(i);
465            vals.push(1.0);
466        }
467        let q = CscMatrix::from_triplets(&rows, &cols, &vals, n, n).unwrap();
468        let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
469        let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
470        let m = MiqpProblem::new(qp, vec![0]).unwrap();
471        assert!(
472            m.is_convex(),
473            "large-n PSD-singular Q must be accepted as convex"
474        );
475    }
476
477    /// `is_convex` detects indefinite Q (n=1001) via Cholesky.
478    ///
479    /// **Sentinel**: replacing `is_q_psd_by_cholesky` with `true` causes this to FAIL.
480    #[test]
481    fn is_convex_detects_indefinite_n1001() {
482        let n = 1001_usize;
483        let mut rows = vec![];
484        let mut cols = vec![];
485        let mut vals = vec![];
486        for i in 0..n {
487            rows.push(i);
488            cols.push(i);
489            vals.push(1.0_f64);
490        }
491        rows.push(0);
492        cols.push(1);
493        vals.push(2.0_f64);
494        let q = CscMatrix::from_triplets(&rows, &cols, &vals, n, n).unwrap();
495        let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
496        let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
497        let m = MiqpProblem::new(qp, vec![0]).unwrap();
498        assert!(
499            !m.is_convex(),
500            "n=1001 indefinite Q must be detected as non-PSD"
501        );
502    }
503}