1use 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#[non_exhaustive]
18#[derive(Debug, PartialEq, Eq)]
19pub enum MipProblemError {
20 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
38fn 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#[derive(Debug, Clone)]
56pub struct MilpProblem {
57 pub lp: LpProblem,
59 pub integer_vars: Vec<usize>,
61}
62
63impl MilpProblem {
64 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 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#[derive(Debug, Clone)]
101pub struct MiqpProblem {
102 pub qp: QpProblem,
104 pub integer_vars: Vec<usize>,
106}
107
108impl MiqpProblem {
109 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 pub fn num_vars(&self) -> usize {
123 self.qp.num_vars
124 }
125
126 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 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
157const FIXED_BOX_TOL: f64 = 1e-9;
161const FIXED_POINT_FEAS_TOL: f64 = 1e-6;
164
165fn 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 let old_path_result = q.nrows > 1000; 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 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 #[test]
404 fn large_n_diagonal_psd_is_convex() {
405 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 #[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 #[test]
423 fn large_n_psd_singular_is_convex() {
424 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}