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;
15use crate::tolerances::{FIXED_POINT_FEAS_TOL, INT_ROUND_TOL};
16
17#[non_exhaustive]
19#[derive(Debug, PartialEq, Eq)]
20pub enum MipProblemError {
21 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
39fn 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#[derive(Debug, Clone)]
57pub struct MilpProblem {
58 pub lp: LpProblem,
60 pub integer_vars: Vec<usize>,
62}
63
64impl MilpProblem {
65 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 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 fn skip_node_presolve(&self) -> bool {
96 true
97 }
98}
99
100#[derive(Debug, Clone)]
105pub struct MiqpProblem {
106 pub qp: QpProblem,
108 pub integer_vars: Vec<usize>,
110}
111
112impl MiqpProblem {
113 pub fn new(qp: QpProblem, integer_vars: Vec<usize>) -> Result<Self, MipProblemError> {
121 let integer_vars = normalize_integer_vars(integer_vars, qp.num_vars)?;
122 Ok(Self { qp, integer_vars })
123 }
124
125 pub fn num_vars(&self) -> usize {
127 self.qp.num_vars
128 }
129
130 pub fn is_convex(&self) -> bool {
134 is_q_psd_by_cholesky(&self.qp.q)
135 }
136}
137
138impl Relaxation for MiqpProblem {
139 fn num_vars(&self) -> usize {
140 self.qp.num_vars
141 }
142 fn root_bounds(&self) -> &[(f64, f64)] {
143 &self.qp.bounds
144 }
145 fn integer_vars(&self) -> &[usize] {
146 &self.integer_vars
147 }
148 fn solve(&self, bounds: &[(f64, f64)], opts: &SolverOptions) -> SolverResult {
149 if let Some(r) = solve_fixed_point(&self.qp, bounds) {
153 return r;
154 }
155 let mut sub = self.qp.clone();
156 sub.bounds = bounds.to_vec();
157 crate::qp::solve_qp_with(&sub, opts)
158 }
159}
160
161fn solve_fixed_point(qp: &QpProblem, bounds: &[(f64, f64)]) -> Option<SolverResult> {
167 if !bounds.iter().all(|&(l, u)| u - l <= INT_ROUND_TOL) {
169 return None;
170 }
171 if bounds.iter().any(|&(l, u)| l - u > INT_ROUND_TOL) {
173 return Some(SolverResult::infeasible());
174 }
175 let x: Vec<f64> = bounds.iter().map(|&(l, u)| 0.5 * (l + u)).collect();
176
177 if qp.num_constraints > 0 {
178 let lhs = match qp.a.mat_vec_mul(&x) {
179 Ok(v) => v,
180 Err(_) => return Some(SolverResult::numerical_error()),
181 };
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 = match qp.q.mat_vec_mul(&x) {
196 Ok(v) => v,
197 Err(_) => return Some(SolverResult::numerical_error()),
198 };
199 let quad: f64 = 0.5 * x.iter().zip(&qx).map(|(xi, qxi)| xi * qxi).sum::<f64>();
200 let lin: f64 = qp.c.iter().zip(&x).map(|(ci, xi)| ci * xi).sum::<f64>();
201 Some(SolverResult {
202 status: SolveStatus::Optimal,
203 objective: quad + lin + qp.obj_offset,
204 solution: x,
205 ..Default::default()
206 })
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::problem::ConstraintType;
213 use crate::sparse::CscMatrix;
214
215 fn lp_2var() -> LpProblem {
216 let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
218 LpProblem::new_general(
219 vec![1.0, 1.0],
220 a,
221 vec![5.0],
222 vec![ConstraintType::Le],
223 vec![(0.0, 5.0), (0.0, 5.0)],
224 None,
225 )
226 .unwrap()
227 }
228
229 fn qp_diag(diag: &[f64]) -> QpProblem {
230 let n = diag.len();
231 let idx: Vec<usize> = (0..n).collect();
232 let q = CscMatrix::from_triplets(&idx, &idx, diag, n, n).unwrap();
233 let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
234 QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap()
235 }
236
237 #[test]
244 fn skip_node_presolve_unconditional() {
245 let a_le = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
246 let lp_le = LpProblem::new_general(
247 vec![1.0, 1.0],
248 a_le,
249 vec![5.0],
250 vec![ConstraintType::Le],
251 vec![(0.0, 5.0), (0.0, 5.0)],
252 None,
253 )
254 .unwrap();
255 let milp_le = MilpProblem::new(lp_le, vec![0]).unwrap();
256 assert!(milp_le.skip_node_presolve(), "Le: skip must be true");
257
258 let a_ge = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
259 let lp_ge = LpProblem::new_general(
260 vec![1.0, 1.0],
261 a_ge,
262 vec![1.0],
263 vec![ConstraintType::Ge],
264 vec![(0.0, 5.0), (0.0, 5.0)],
265 None,
266 )
267 .unwrap();
268 let milp_ge = MilpProblem::new(lp_ge, vec![0]).unwrap();
269 assert!(
270 milp_ge.skip_node_presolve(),
271 "Ge: skip must also be true (Bug 2 regression guard)"
272 );
273 }
274
275 #[test]
276 fn new_sorts_and_dedups_integer_vars() {
277 let m = MilpProblem::new(lp_2var(), vec![1, 0, 1]).unwrap();
278 assert_eq!(m.integer_vars, vec![0, 1]);
279 }
280
281 #[test]
282 fn new_rejects_out_of_range_index() {
283 let err = MilpProblem::new(lp_2var(), vec![0, 2]).unwrap_err();
284 assert_eq!(
285 err,
286 MipProblemError::InvalidIntegerVar {
287 index: 2,
288 num_vars: 2
289 }
290 );
291 }
292
293 #[test]
294 fn empty_integer_vars_allowed() {
295 let m = MilpProblem::new(lp_2var(), vec![]).unwrap();
296 assert!(m.integer_vars.is_empty());
297 }
298
299 #[test]
300 fn miqp_new_validates_indices() {
301 let err = MiqpProblem::new(qp_diag(&[2.0, 2.0]), vec![2]).unwrap_err();
302 assert_eq!(
303 err,
304 MipProblemError::InvalidIntegerVar {
305 index: 2,
306 num_vars: 2
307 }
308 );
309 }
310
311 #[test]
312 fn psd_diagonal_q_is_convex() {
313 let m = MiqpProblem::new(qp_diag(&[2.0, 4.0]), vec![0, 1]).unwrap();
314 assert!(m.is_convex(), "positive diagonal Q must be PSD");
315 }
316
317 #[test]
318 fn indefinite_q_is_not_convex() {
319 let m = MiqpProblem::new(qp_diag(&[2.0, -3.0]), vec![0, 1]).unwrap();
320 assert!(
321 !m.is_convex(),
322 "negative eigenvalue must be detected as non-convex"
323 );
324 }
325
326 #[test]
327 fn zero_q_is_convex() {
328 let m = MiqpProblem::new(qp_diag(&[0.0, 0.0]), vec![0]).unwrap();
330 assert!(m.is_convex());
331 }
332
333 #[test]
334 fn fixed_point_evaluates_objective_exactly() {
335 let qp = qp_diag(&[2.0]);
337 let r = solve_fixed_point(&qp, &[(2.0, 2.0)]).expect("all fixed → Some");
338 assert_eq!(r.status, SolveStatus::Optimal);
339 assert!((r.objective - 4.0).abs() < 1e-12, "obj={}", r.objective);
340 assert_eq!(r.solution, vec![2.0]);
341 }
342
343 #[test]
344 fn fixed_point_returns_none_when_a_var_is_free() {
345 let qp = qp_diag(&[2.0, 2.0]);
346 assert!(solve_fixed_point(&qp, &[(2.0, 2.0), (0.0, 5.0)]).is_none());
347 }
348
349 #[test]
350 fn fixed_point_infeasible_constraint() {
351 let q = CscMatrix::from_triplets(&[0], &[0], &[2.0], 1, 1).unwrap();
353 let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
354 let qp = QpProblem::new(
355 q,
356 vec![0.0],
357 a,
358 vec![5.0],
359 vec![(2.0, 2.0)],
360 vec![ConstraintType::Ge],
361 )
362 .unwrap();
363 let r = solve_fixed_point(&qp, &[(2.0, 2.0)]).expect("all fixed → Some");
364 assert_eq!(r.status, SolveStatus::Infeasible);
365 }
366
367 #[test]
368 fn fixed_point_empty_box_infeasible() {
369 let qp = qp_diag(&[2.0]);
370 let r = solve_fixed_point(&qp, &[(3.0, 2.0)]).expect("empty box → Some");
371 assert_eq!(r.status, SolveStatus::Infeasible);
372 }
373
374 #[test]
375 fn fixed_point_dim_mismatch_q_returns_numerical_error() {
376 let qp = qp_diag(&[2.0, 2.0]);
379 let r = solve_fixed_point(&qp, &[(1.0, 1.0)]);
380 assert!(
381 r.is_some(),
382 "dim mismatch must not return None (IPM fallback)"
383 );
384 assert_eq!(r.unwrap().status, SolveStatus::NumericalError);
385 }
386
387 #[test]
388 fn fixed_point_dim_mismatch_a_returns_numerical_error() {
389 let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[2.0, 2.0], 2, 2).unwrap();
391 let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
392 let qp =
393 QpProblem::new_all_le(q, vec![0.0, 0.0], a, vec![5.0], vec![(0.0, 5.0); 2]).unwrap();
394 let r = solve_fixed_point(&qp, &[(1.0, 1.0)]);
395 assert!(
396 r.is_some(),
397 "dim mismatch must not return None (IPM fallback)"
398 );
399 assert_eq!(r.unwrap().status, SolveStatus::NumericalError);
400 }
401
402 #[test]
403 fn psd_with_off_diagonal_detected() {
404 let q = CscMatrix::from_triplets(&[0, 0, 1, 1], &[0, 1, 0, 1], &[2.0, 1.0, 1.0, 2.0], 2, 2)
406 .unwrap();
407 let a = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
408 let qp = QpProblem::new_all_le(q, vec![0.0, 0.0], a, vec![], vec![(0.0, 5.0); 2]).unwrap();
409 assert!(MiqpProblem::new(qp, vec![0, 1]).unwrap().is_convex());
410
411 let q2 =
413 CscMatrix::from_triplets(&[0, 0, 1, 1], &[0, 1, 0, 1], &[1.0, 2.0, 2.0, 1.0], 2, 2)
414 .unwrap();
415 let a2 = CscMatrix::from_triplets(&[], &[], &[], 0, 2).unwrap();
416 let qp2 =
417 QpProblem::new_all_le(q2, vec![0.0, 0.0], a2, vec![], vec![(0.0, 5.0); 2]).unwrap();
418 assert!(!MiqpProblem::new(qp2, vec![0, 1]).unwrap().is_convex());
419 }
420
421 fn large_n_indefinite_miqp(n: usize) -> MiqpProblem {
426 let mut rows = Vec::new();
427 let mut cols = Vec::new();
428 let mut vals = Vec::new();
429 for i in 0..n {
430 rows.push(i);
431 cols.push(i);
432 vals.push(1.0_f64);
433 }
434 rows.push(0);
436 cols.push(1);
437 vals.push(2.0);
438 rows.push(1);
439 cols.push(0);
440 vals.push(2.0);
441 let q = CscMatrix::from_triplets(&rows, &cols, &vals, n, n).unwrap();
442 let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
443 let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
444 MiqpProblem::new(qp, vec![0]).unwrap()
445 }
446
447 fn large_n_psd_miqp(n: usize) -> MiqpProblem {
449 let idx: Vec<usize> = (0..n).collect();
450 let vals: Vec<f64> = vec![2.0; n];
451 let q = CscMatrix::from_triplets(&idx, &idx, &vals, n, n).unwrap();
452 let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
453 let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
454 MiqpProblem::new(qp, vec![0]).unwrap()
455 }
456
457 #[test]
463 fn large_n_off_diag_indefinite_is_not_convex() {
464 let m = large_n_indefinite_miqp(1001);
465 assert!(
466 !m.is_convex(),
467 "n=1001 indefinite MIQP (diag≥0, off-diag λ_min=-1) must be detected as \
468 nonconvex; `return true` for n>1000 produces false-Optimal (sentinel)"
469 );
470 }
471
472 #[test]
475 fn large_n_diagonal_psd_is_convex() {
476 let m = large_n_psd_miqp(1001);
478 assert!(
479 m.is_convex(),
480 "large-n diagonal PSD Q must not be over-rejected"
481 );
482 }
483
484 #[test]
486 fn large_n_zero_q_is_convex() {
487 let n = 1001usize;
488 let q = CscMatrix::from_triplets(&[], &[], &[], n, n).unwrap();
489 let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
490 let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
491 let m = MiqpProblem::new(qp, vec![0]).unwrap();
492 assert!(m.is_convex(), "large-n zero Q (LP) must be convex");
493 }
494
495 #[test]
497 fn large_n_psd_singular_is_convex() {
498 let n = 1001usize;
500 let mut rows: Vec<usize> = Vec::new();
501 let mut cols: Vec<usize> = Vec::new();
502 let mut vals: Vec<f64> = Vec::new();
503 for i in 0..n - 1 {
504 rows.push(i);
505 cols.push(i);
506 vals.push(1.0);
507 }
508 let q = CscMatrix::from_triplets(&rows, &cols, &vals, n, n).unwrap();
509 let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
510 let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
511 let m = MiqpProblem::new(qp, vec![0]).unwrap();
512 assert!(
513 m.is_convex(),
514 "large-n PSD-singular Q must be accepted as convex"
515 );
516 }
517
518 #[test]
522 fn is_convex_detects_indefinite_n1001() {
523 let n = 1001_usize;
524 let mut rows = vec![];
525 let mut cols = vec![];
526 let mut vals = vec![];
527 for i in 0..n {
528 rows.push(i);
529 cols.push(i);
530 vals.push(1.0_f64);
531 }
532 rows.push(0);
533 cols.push(1);
534 vals.push(2.0_f64);
535 let q = CscMatrix::from_triplets(&rows, &cols, &vals, n, n).unwrap();
536 let a = CscMatrix::from_triplets(&[], &[], &[], 0, n).unwrap();
537 let qp = QpProblem::new_all_le(q, vec![0.0; n], a, vec![], vec![(0.0, 5.0); n]).unwrap();
538 let m = MiqpProblem::new(qp, vec![0]).unwrap();
539 assert!(
540 !m.is_convex(),
541 "n=1001 indefinite Q must be detected as non-PSD"
542 );
543 }
544}