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