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}
96
97#[derive(Debug, Clone)]
102pub struct MiqpProblem {
103 pub qp: QpProblem,
105 pub integer_vars: Vec<usize>,
107}
108
109impl MiqpProblem {
110 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 pub fn num_vars(&self) -> usize {
124 self.qp.num_vars
125 }
126
127 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 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
158fn solve_fixed_point(qp: &QpProblem, bounds: &[(f64, f64)]) -> Option<SolverResult> {
164 if !bounds.iter().all(|&(l, u)| u - l <= INT_ROUND_TOL) {
166 return None;
167 }
168 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[test]
434 fn large_n_diagonal_psd_is_convex() {
435 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 #[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 #[test]
456 fn large_n_psd_singular_is_convex() {
457 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 #[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}