1use crate::constraint::{Constraint, ConstraintSense};
18use crate::expression::Expression;
19use crate::quad_expr::{quad_to_csc, QuadExpr};
20use crate::variable::{VarKind, Variable};
21
22use crate::variable::VariableDefinition;
23
24use otspot_core::options::Tolerance;
25use otspot_core::problem::{ConstraintType, LpProblem, SolveStatus};
26use otspot_core::sparse::CscMatrix;
27use std::collections::BTreeMap;
28use std::fmt;
29use std::ops::Index;
30use std::sync::atomic::{AtomicU64, Ordering};
31
32static NEXT_MODEL_ID: AtomicU64 = AtomicU64::new(1);
33
34#[derive(Debug, Clone, Copy, PartialEq)]
39enum OptimizationSense {
40 Minimize,
41 Maximize,
42}
43
44pub struct Model {
50 model_id: u64,
51 name: Option<String>,
52 variables: Vec<VariableDefinition>,
53 constraints: Vec<Constraint>,
54 objective: Option<Expression>,
55 sense: OptimizationSense,
56 quadratic_objective: Option<CscMatrix>,
59 quad_dsl_error: Option<String>,
65 obj_expr_constant: f64,
70 invalid_inputs: BTreeMap<&'static str, String>,
71 timeout_secs: Option<f64>,
73 tolerance: Option<Tolerance>,
75 presolve: Option<bool>,
77 threads: Option<usize>,
79 obj_offset: f64,
81}
82
83impl Model {
84 pub fn new(name: &str) -> Self {
86 Model {
87 model_id: NEXT_MODEL_ID.fetch_add(1, Ordering::Relaxed),
88 name: Some(name.to_string()),
89 variables: Vec::new(),
90 constraints: Vec::new(),
91 objective: None,
92 sense: OptimizationSense::Minimize,
93 quadratic_objective: None,
94 quad_dsl_error: None,
95 obj_expr_constant: 0.0,
96 invalid_inputs: BTreeMap::new(),
97 timeout_secs: None,
98 tolerance: None,
99 presolve: None,
100 threads: None,
101 obj_offset: 0.0,
102 }
103 }
104
105 pub fn set_timeout(&mut self, secs: f64) {
107 if let Err(err) = self.try_set_timeout(secs) {
108 self.record_input_error("timeout", err);
109 }
110 }
111
112 pub fn try_set_timeout(&mut self, secs: f64) -> Result<&mut Self, ModelError> {
114 validate_timeout(secs)?;
115 self.timeout_secs = Some(secs);
116 self.invalid_inputs.remove("timeout");
117 Ok(self)
118 }
119
120 pub fn set_threads(&mut self, n: usize) -> &mut Self {
123 self.threads = Some(n.max(1));
124 self
125 }
126
127 pub fn set_tolerance(&mut self, tol: Tolerance) -> &mut Self {
129 self.tolerance = Some(tol);
130 self
131 }
132
133 pub fn set_presolve(&mut self, flag: bool) -> &mut Self {
135 self.presolve = Some(flag);
136 self
137 }
138
139 pub fn add_var(&mut self, name: &str, lb: f64, ub: f64) -> Variable {
144 match validate_bounds(lb, ub) {
145 Ok(()) => self.push_var(name, lb, ub, VarKind::Continuous),
146 Err(err) => {
147 self.record_input_error("variable_bounds", err);
148 self.push_var(name, 0.0, 0.0, VarKind::Continuous)
149 }
150 }
151 }
152
153 pub fn try_add_var(&mut self, name: &str, lb: f64, ub: f64) -> Result<Variable, ModelError> {
155 validate_bounds(lb, ub)?;
156 Ok(self.push_var(name, lb, ub, VarKind::Continuous))
157 }
158
159 pub fn add_int_var(&mut self, name: &str, lb: f64, ub: f64) -> Variable {
167 match validate_bounds(lb, ub) {
168 Ok(()) => self.push_var(name, lb, ub, VarKind::Integer),
169 Err(err) => {
170 self.record_input_error("variable_bounds", err);
171 self.push_var(name, 0.0, 0.0, VarKind::Integer)
172 }
173 }
174 }
175
176 pub fn try_add_int_var(
178 &mut self,
179 name: &str,
180 lb: f64,
181 ub: f64,
182 ) -> Result<Variable, ModelError> {
183 validate_bounds(lb, ub)?;
184 Ok(self.push_var(name, lb, ub, VarKind::Integer))
185 }
186
187 pub fn add_binary_var(&mut self, name: &str) -> Variable {
189 self.push_var(name, 0.0, 1.0, VarKind::Binary)
190 }
191
192 pub fn var_name(&self, var: Variable) -> &str {
199 &self.variables[var.index].name
200 }
201
202 pub fn try_var_name(&self, var: Variable) -> Result<&str, ModelError> {
208 if var.model_id != self.model_id {
209 return Err(ModelError::InvalidInput(
210 "variable belongs to a different model".to_string(),
211 ));
212 }
213 self.variables
214 .get(var.index)
215 .map(|v| v.name.as_str())
216 .ok_or_else(|| {
217 ModelError::InvalidInput(format!(
218 "variable index {} out of range (model has {} variables)",
219 var.index,
220 self.variables.len()
221 ))
222 })
223 }
224
225 fn push_var(&mut self, name: &str, lb: f64, ub: f64, kind: VarKind) -> Variable {
226 let index = self.variables.len();
227 self.variables.push(VariableDefinition {
228 name: name.to_string(),
229 lower_bound: lb,
230 upper_bound: ub,
231 kind,
232 });
233 Variable {
234 index,
235 model_id: self.model_id,
236 }
237 }
238
239 pub fn add_constraint(&mut self, c: Constraint) -> &mut Self {
241 if let Some(v) = c
242 .lhs
243 .coefficients
244 .keys()
245 .find(|v| v.model_id != self.model_id)
246 {
247 let msg = format!(
248 "constraint lhs: var {} belongs to model {}, not this model ({})",
249 v.index, v.model_id, self.model_id
250 );
251 self.record_input_error("constraint_cross_model", ModelError::InvalidInput(msg));
252 return self;
253 }
254 self.constraints.push(c);
255 self
256 }
257
258 pub fn minimize(&mut self, obj: impl Into<QuadExpr>) -> &mut Self {
265 self.apply_objective(obj.into(), OptimizationSense::Minimize)
266 }
267
268 pub fn maximize(&mut self, obj: impl Into<QuadExpr>) -> &mut Self {
273 self.apply_objective(obj.into(), OptimizationSense::Maximize)
274 }
275
276 fn validate_objective(&self) -> Result<(), ModelError> {
287 if let Some(msg) = &self.quad_dsl_error {
288 return Err(ModelError::InvalidInput(msg.clone()));
289 }
290 let Some(obj) = self.objective.as_ref() else {
291 return Ok(()); };
293 for (&var, &coef) in &obj.coefficients {
294 if var.model_id != self.model_id {
295 return Err(ModelError::InvalidInput(format!(
296 "objective: linear term (var {}) belongs to model {}, not this model ({})",
297 var.index, var.model_id, self.model_id
298 )));
299 }
300 if !coef.is_finite() {
301 return Err(ModelError::InvalidInput(format!(
302 "objective: non-finite linear coefficient for var {}: {coef}",
303 var.index
304 )));
305 }
306 }
307 if !self.obj_expr_constant.is_finite() {
308 return Err(ModelError::InvalidInput(format!(
309 "objective: non-finite constant term: {}",
310 self.obj_expr_constant
311 )));
312 }
313 if !self.obj_offset.is_finite() {
314 return Err(ModelError::InvalidInput(format!(
315 "objective: non-finite obj_offset: {}",
316 self.obj_offset
317 )));
318 }
319 if let Some(q) = &self.quadratic_objective {
320 if let Some(&v) = q.values().iter().find(|v| !v.is_finite()) {
321 return Err(ModelError::InvalidInput(format!(
322 "objective: non-finite Q coefficient: {v}"
323 )));
324 }
325 }
326 Ok(())
327 }
328
329 fn install_quad(&mut self, q: CscMatrix) {
331 self.quadratic_objective = Some(q);
332 self.quad_dsl_error = None;
333 }
334
335 fn fail_dsl_quad(&mut self, msg: String) {
338 self.quadratic_objective = None;
339 self.quad_dsl_error = Some(msg);
340 }
341
342 fn replace_with_linear_objective(&mut self) {
344 self.quadratic_objective = None;
345 }
346
347 fn find_foreign_var_error(&self, q: &QuadExpr) -> Option<String> {
351 if let Some(&v) = q
355 .linear
356 .coefficients
357 .keys()
358 .find(|v| v.model_id != self.model_id)
359 {
360 return Some(format!(
361 "linear term (var {}) belongs to model {}, not this model ({})",
362 v.index, v.model_id, self.model_id
363 ));
364 }
365 if let Some(&(va, vb)) = q
367 .quad
368 .keys()
369 .find(|(va, vb)| va.model_id != self.model_id || vb.model_id != self.model_id)
370 {
371 return Some(format!(
372 "quad term ({},{}) belongs to model(s) {}/{}, not this model ({})",
373 va.index, vb.index, va.model_id, vb.model_id, self.model_id
374 ));
375 }
376 None
377 }
378
379 fn apply_objective(&mut self, q: QuadExpr, sense: OptimizationSense) -> &mut Self {
381 self.quad_dsl_error = None;
386 if let Some(msg) = self.find_foreign_var_error(&q) {
390 self.fail_dsl_quad(msg);
391 } else if !q.quad.is_empty() {
392 match quad_to_csc(&q.quad, self.variables.len()) {
393 Ok(csc) => self.install_quad(csc),
394 Err(msg) => self.fail_dsl_quad(msg),
395 }
396 } else {
397 self.replace_with_linear_objective();
398 }
399 self.obj_expr_constant = q.linear.constant;
403 self.objective = Some(q.linear);
404 self.sense = sense;
405 self
406 }
407
408 pub fn set_obj_offset(&mut self, offset: f64) -> &mut Self {
411 self.obj_offset = offset;
412 self
413 }
414
415 pub fn solve(&mut self) -> Result<ModelResult, ModelError> {
421 if let Some(msg) = self.invalid_inputs.values().next() {
422 return Err(ModelError::InvalidInput(msg.clone()));
423 }
424
425 let obj_expr = self.objective.as_ref().ok_or(ModelError::NoObjective)?;
426
427 self.validate_objective()?;
432
433 let num_vars = self.variables.len();
434 let num_constraints = self.constraints.len();
435
436 let mid = self.model_id;
438 let mut c: Vec<f64> = (0..num_vars)
439 .map(|i| {
440 obj_expr.coefficient(Variable {
441 index: i,
442 model_id: mid,
443 })
444 })
445 .collect();
446
447 if self.sense == OptimizationSense::Maximize {
449 for ci in &mut c {
450 *ci = -*ci;
451 }
452 }
453
454 let mut trip_rows: Vec<usize> = Vec::new();
456 let mut trip_cols: Vec<usize> = Vec::new();
457 let mut trip_vals: Vec<f64> = Vec::new();
458 let mut b: Vec<f64> = Vec::new();
459 let mut constraint_types: Vec<ConstraintType> = Vec::new();
460
461 for (i, con) in self.constraints.iter().enumerate() {
462 for (&var, &coeff) in &con.lhs.coefficients {
463 trip_rows.push(i);
464 trip_cols.push(var.index);
465 trip_vals.push(coeff);
466 }
467 b.push(con.rhs);
469 constraint_types.push(match con.sense {
470 ConstraintSense::Le => ConstraintType::Le,
471 ConstraintSense::Ge => ConstraintType::Ge,
472 ConstraintSense::Eq => ConstraintType::Eq,
473 });
474 }
475
476 let a = if num_constraints == 0 {
478 CscMatrix::new(0, num_vars)
479 } else {
480 CscMatrix::from_triplets(
481 &trip_rows,
482 &trip_cols,
483 &trip_vals,
484 num_constraints,
485 num_vars,
486 )
487 .map_err(|e| ModelError::Internal(e.to_string()))?
488 };
489
490 let bounds: Vec<(f64, f64)> = self
492 .variables
493 .iter()
494 .map(|v| (v.lower_bound, v.upper_bound))
495 .collect();
496
497 let integer_vars: Vec<usize> = self
500 .variables
501 .iter()
502 .enumerate()
503 .filter(|(_, v)| v.kind != VarKind::Continuous)
504 .map(|(i, _)| i)
505 .collect();
506 if !integer_vars.is_empty() {
507 return self.solve_mip_internal(c, a, b, constraint_types, bounds, integer_vars);
508 }
509
510 if let Some(ref q_orig) = self.quadratic_objective.clone() {
512 return self.solve_qp_internal(c, bounds, q_orig.clone(), num_constraints);
513 }
514
515 let problem = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
517 .map_err(map_lp_build_err)?;
518
519 let mut lp_opts = otspot_core::options::SolverOptions::default();
520 if let Some(t) = self.timeout_secs {
521 lp_opts.timeout_secs = Some(t);
522 }
523 if let Some(tol) = self.tolerance {
524 lp_opts.tolerance = Some(tol);
525 }
526 if let Some(flag) = self.presolve {
527 lp_opts.presolve = flag;
528 }
529 if let Some(n) = self.threads {
530 lp_opts.threads = n;
531 }
532 let solver_result = otspot_core::lp::solve_lp_with(&problem, &lp_opts);
533
534 let lp_extras = |sr: &otspot_core::problem::SolverResult| {
537 let dual = if sr.dual_solution.is_empty() {
538 None
539 } else {
540 Some(sr.dual_solution.clone())
541 };
542 let rc = if sr.reduced_costs.is_empty() {
543 None
544 } else {
545 Some(sr.reduced_costs.clone())
546 };
547 let slack = if sr.slack.is_empty() {
548 None
549 } else {
550 Some(sr.slack.clone())
551 };
552 (dual, rc, slack)
553 };
554
555 let signed_obj = |raw: f64| -> f64 {
556 let oriented = if self.sense == OptimizationSense::Maximize {
557 -raw
558 } else {
559 raw
560 };
561 oriented + self.obj_offset + self.obj_expr_constant
562 };
563 let lp_model_id = self.model_id;
564 let build_ok = |sr: otspot_core::problem::SolverResult| {
565 let (dual, rc, slack) = lp_extras(&sr);
566 let status = sr.status.clone();
567 ModelResult {
568 model_id: lp_model_id,
569 status: status.clone(),
570 proof: SolutionProof::from_status(&status),
571 objective_value: signed_obj(sr.objective),
572 solution: sr.solution,
573 dual_solution: dual,
574 reduced_costs: rc,
575 slack,
576 bound_duals: sr.bound_duals,
577 stats: sr.stats,
578 }
579 };
580
581 match solver_result.status {
582 SolveStatus::Optimal => Ok(build_ok(solver_result)),
583 SolveStatus::MaxIterations => {
584 if solver_result.solution.is_empty() {
585 Err(ModelError::SolveError(SolveError::MaxIterations))
586 } else {
587 Ok(build_ok(solver_result))
588 }
589 }
590 SolveStatus::SuboptimalSolution => {
591 if solver_result.solution.is_empty() {
592 Err(ModelError::Timeout)
593 } else {
594 Ok(build_ok(solver_result))
595 }
596 }
597 SolveStatus::Timeout => Err(ModelError::Timeout),
598 SolveStatus::LocallyOptimal
599 | SolveStatus::NonconvexLocal
600 | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
601 "Unexpected nonconvex status on LP path".to_string(),
602 )),
603 s => Err(classify_status_error(s)
604 .unwrap_or_else(|| ModelError::Internal("unhandled LP status".to_string()))),
605 }
606 }
607
608 fn build_qp_problem(
612 &self,
613 c: Vec<f64>,
614 bounds: Vec<(f64, f64)>,
615 q_orig: CscMatrix,
616 ) -> Result<otspot_core::qp::QpProblem, ModelError> {
617 use otspot_core::qp::QpProblem;
618
619 let num_vars = self.variables.len();
620
621 let qp_q = if self.sense == OptimizationSense::Maximize {
623 q_orig.scale_values(-1.0)
624 } else {
625 q_orig
626 };
627
628 let mut qp_trip_rows: Vec<usize> = Vec::new();
631 let mut qp_trip_cols: Vec<usize> = Vec::new();
632 let mut qp_trip_vals: Vec<f64> = Vec::new();
633 let mut qp_b: Vec<f64> = Vec::new();
634 let mut qp_constraint_types: Vec<ConstraintType> = Vec::new();
635 let mut qp_row = 0usize;
636
637 for con in &self.constraints {
638 for (&var, &coeff) in &con.lhs.coefficients {
639 qp_trip_rows.push(qp_row);
640 qp_trip_cols.push(var.index);
641 qp_trip_vals.push(coeff);
642 }
643 qp_b.push(con.rhs);
644 qp_constraint_types.push(match con.sense {
645 ConstraintSense::Le => ConstraintType::Le,
646 ConstraintSense::Ge => ConstraintType::Ge,
647 ConstraintSense::Eq => ConstraintType::Eq,
648 });
649 qp_row += 1;
650 }
651
652 let m_qp = qp_row;
653 let qp_a = if m_qp == 0 {
654 CscMatrix::new(0, num_vars)
655 } else {
656 CscMatrix::from_triplets(&qp_trip_rows, &qp_trip_cols, &qp_trip_vals, m_qp, num_vars)
657 .map_err(|e| ModelError::Internal(e.to_string()))?
658 };
659
660 let mut qp_problem = QpProblem::new(qp_q, c, qp_a, qp_b, bounds, qp_constraint_types)
661 .map_err(map_qp_build_err)?;
662 qp_problem.obj_offset = 0.0;
664 Ok(qp_problem)
665 }
666
667 fn solve_qp_internal(
669 &self,
670 c: Vec<f64>,
671 bounds: Vec<(f64, f64)>,
672 q_orig: CscMatrix,
673 num_model_constraints: usize,
674 ) -> Result<ModelResult, ModelError> {
675 let qp_problem = self.build_qp_problem(c, bounds, q_orig)?;
676
677 let mut opts = otspot_core::options::SolverOptions::default();
678 if let Some(t) = self.timeout_secs {
679 opts.timeout_secs = Some(t);
680 }
681 if let Some(tol) = self.tolerance {
682 opts.tolerance = Some(tol);
683 }
684 if let Some(n) = self.threads {
685 opts.threads = n;
686 }
687 let qp_result = otspot_core::qp::solve_qp_with(&qp_problem, &opts);
688 let qp_stats = qp_result.stats.clone();
689
690 let fold_dual = |sol: &[f64]| -> Option<Vec<f64>> {
692 if sol.len() == num_model_constraints {
693 Some(sol.to_vec())
694 } else if !sol.is_empty() && num_model_constraints > 0 {
695 let take = num_model_constraints.min(sol.len());
696 Some(sol[..take].to_vec())
697 } else {
698 None
699 }
700 };
701
702 let signed_obj = |raw: f64| -> f64 {
703 let oriented = if self.sense == OptimizationSense::Maximize {
704 -raw
705 } else {
706 raw
707 };
708 oriented + self.obj_offset + self.obj_expr_constant
709 };
710 let qp_model_id = self.model_id;
711 let build_ok = |status: SolveStatus,
712 raw_obj: f64,
713 sol: Vec<f64>,
714 dual: Option<Vec<f64>>,
715 bd: Vec<f64>| {
716 let proof = SolutionProof::from_status(&status);
717 ModelResult {
718 model_id: qp_model_id,
719 status,
720 proof,
721 objective_value: signed_obj(raw_obj),
722 solution: sol,
723 dual_solution: dual,
724 reduced_costs: None,
725 slack: None,
726 bound_duals: bd,
727 stats: qp_stats.clone(),
728 }
729 };
730
731 match qp_result.status {
732 SolveStatus::Optimal => Ok(build_ok(
733 SolveStatus::Optimal,
734 qp_result.objective,
735 qp_result.solution,
736 fold_dual(&qp_result.dual_solution),
737 qp_result.bound_duals,
738 )),
739 SolveStatus::MaxIterations => {
740 if qp_result.solution.is_empty() {
741 Err(ModelError::SolveError(SolveError::MaxIterations))
742 } else {
743 Ok(build_ok(
744 SolveStatus::MaxIterations,
745 qp_result.objective,
746 qp_result.solution,
747 fold_dual(&qp_result.dual_solution),
748 qp_result.bound_duals,
749 ))
750 }
751 }
752 SolveStatus::SuboptimalSolution => {
753 if qp_result.solution.is_empty() {
755 Err(ModelError::Timeout)
756 } else {
757 Ok(build_ok(
758 SolveStatus::SuboptimalSolution,
759 qp_result.objective,
760 qp_result.solution,
761 fold_dual(&qp_result.dual_solution),
762 qp_result.bound_duals,
763 ))
764 }
765 }
766 SolveStatus::Timeout => Err(ModelError::Timeout),
767 status @ (SolveStatus::LocallyOptimal
770 | SolveStatus::NonconvexLocal
771 | SolveStatus::NonconvexGlobal) => Ok(build_ok(
772 status,
773 qp_result.objective,
774 qp_result.solution,
775 fold_dual(&qp_result.dual_solution),
776 qp_result.bound_duals,
777 )),
778 s => Err(classify_status_error(s)
779 .unwrap_or_else(|| ModelError::Internal("unhandled QP status".to_string()))),
780 }
781 }
782
783 fn solve_mip_internal(
789 &self,
790 c: Vec<f64>,
791 a: CscMatrix,
792 b: Vec<f64>,
793 constraint_types: Vec<ConstraintType>,
794 bounds: Vec<(f64, f64)>,
795 integer_vars: Vec<usize>,
796 ) -> Result<ModelResult, ModelError> {
797 let mut opts = otspot_core::options::SolverOptions::default();
798 if let Some(t) = self.timeout_secs {
799 opts.timeout_secs = Some(t);
800 }
801 if let Some(tol) = self.tolerance {
802 opts.tolerance = Some(tol);
803 }
804 if let Some(n) = self.threads {
805 opts.threads = n;
806 }
807 let cfg = otspot_core::options::MipConfig::default();
808
809 let result = if let Some(ref q_orig) = self.quadratic_objective.clone() {
810 let qp = self.build_qp_problem(c, bounds, q_orig.clone())?;
812 let miqp = otspot_core::mip::MiqpProblem::new(qp, integer_vars.clone())
813 .map_err(|e| ModelError::Internal(e.to_string()))?;
814 otspot_core::mip::solve_miqp(&miqp, &opts, &cfg)
815 } else {
816 if let Some(flag) = self.presolve {
818 opts.presolve = flag;
819 }
820 let lp = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
821 .map_err(map_lp_build_err)?;
822 let milp = otspot_core::mip::MilpProblem::new(lp, integer_vars.clone())
823 .map_err(|e| ModelError::Internal(e.to_string()))?;
824 otspot_core::mip::solve_milp(&milp, &opts, &cfg)
825 };
826
827 self.finish_mip(result, &integer_vars)
828 }
829
830 fn finish_mip(
833 &self,
834 result: otspot_core::problem::SolverResult,
835 integer_vars: &[usize],
836 ) -> Result<ModelResult, ModelError> {
837 let signed_obj = |raw: f64| -> f64 {
838 let oriented = if self.sense == OptimizationSense::Maximize {
839 -raw
840 } else {
841 raw
842 };
843 oriented + self.obj_offset + self.obj_expr_constant
844 };
845
846 let round_integers = |mut sol: Vec<f64>| -> Vec<f64> {
848 for &j in integer_vars {
849 if j < sol.len() {
850 sol[j] = sol[j].round();
851 }
852 }
853 sol
854 };
855
856 let mip_model_id = self.model_id;
857 let build_ok = |sr: otspot_core::problem::SolverResult| {
858 let status = sr.status.clone();
859 ModelResult {
860 model_id: mip_model_id,
861 status: status.clone(),
862 proof: SolutionProof::from_status(&status),
863 objective_value: signed_obj(sr.objective),
864 solution: round_integers(sr.solution),
865 dual_solution: None,
866 reduced_costs: None,
867 slack: None,
868 bound_duals: vec![],
869 stats: sr.stats,
870 }
871 };
872
873 match result.status {
874 SolveStatus::Optimal => Ok(build_ok(result)),
875 SolveStatus::Timeout => {
876 if result.solution.is_empty() {
878 Err(ModelError::Timeout)
879 } else {
880 Ok(build_ok(result))
881 }
882 }
883 SolveStatus::SuboptimalSolution | SolveStatus::MaxIterations => {
884 if result.solution.is_empty() {
885 Err(ModelError::SolveError(SolveError::MaxIterations))
886 } else {
887 Ok(build_ok(result))
888 }
889 }
890 SolveStatus::LocallyOptimal
891 | SolveStatus::NonconvexLocal
892 | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
893 "Unexpected nonconvex status on MIP path".to_string(),
894 )),
895 s => Err(classify_status_error(s)
896 .unwrap_or_else(|| ModelError::Internal("unhandled MIP status".to_string()))),
897 }
898 }
899
900 fn record_input_error(&mut self, key: &'static str, err: ModelError) {
901 let msg = match err {
902 ModelError::InvalidInput(msg) => msg,
903 other => other.to_string(),
904 };
905 self.invalid_inputs.insert(key, msg);
906 }
907}
908
909fn classify_status_error(status: SolveStatus) -> Option<ModelError> {
917 match status {
918 SolveStatus::Infeasible => Some(ModelError::SolveError(SolveError::Infeasible)),
919 SolveStatus::Unbounded => Some(ModelError::SolveError(SolveError::Unbounded)),
920 SolveStatus::NumericalError => Some(ModelError::SolveError(SolveError::NumericalError)),
921 SolveStatus::NonConvex(msg) => Some(ModelError::NonConvex(msg)),
922 SolveStatus::NotSupported(msg) => Some(ModelError::NotSupported(msg)),
923 SolveStatus::Optimal
926 | SolveStatus::LocallyOptimal
927 | SolveStatus::MaxIterations
928 | SolveStatus::SuboptimalSolution
929 | SolveStatus::Timeout
930 | SolveStatus::NonconvexLocal
931 | SolveStatus::NonconvexGlobal => None,
932 _ => None,
939 }
940}
941
942fn validate_timeout(secs: f64) -> Result<(), ModelError> {
943 if secs.is_finite() && secs >= 0.0 {
944 Ok(())
945 } else {
946 Err(ModelError::InvalidInput(format!(
947 "timeout must be finite and non-negative, got {secs}"
948 )))
949 }
950}
951
952fn map_lp_build_err(e: otspot_core::error::SolverError) -> ModelError {
957 use otspot_core::error::SolverError;
958 match e {
959 SolverError::NonFiniteCoefficient { .. } | SolverError::InvalidBounds { .. } => {
960 ModelError::InvalidInput(e.to_string())
961 }
962 _ => ModelError::Internal(e.to_string()),
963 }
964}
965
966fn map_qp_build_err(e: otspot_core::qp::QpProblemError) -> ModelError {
968 use otspot_core::qp::QpProblemError;
969 match e {
970 QpProblemError::NonFiniteCoefficient { .. }
971 | QpProblemError::InvalidBounds { .. }
972 | QpProblemError::TripletIndexOutOfBounds { .. } => ModelError::InvalidInput(e.to_string()),
973 QpProblemError::DimensionMismatch(_) => ModelError::Internal(e.to_string()),
974 _ => ModelError::Internal(e.to_string()),
976 }
977}
978
979fn validate_bounds(lb: f64, ub: f64) -> Result<(), ModelError> {
980 if lb.is_nan() || ub.is_nan() {
981 return Err(ModelError::InvalidInput(format!(
982 "variable bounds must not be NaN: lb={lb}, ub={ub}"
983 )));
984 }
985 if lb > ub {
986 return Err(ModelError::InvalidInput(format!(
987 "variable lower bound {lb} exceeds upper bound {ub}"
988 )));
989 }
990 Ok(())
991}
992
993#[non_exhaustive]
999#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1000pub enum SolutionProof {
1001 GlobalOptimal,
1003 LocalOptimal,
1005 FeasibleUnproven,
1007}
1008
1009impl SolutionProof {
1010 fn from_status(status: &SolveStatus) -> Self {
1011 match status {
1012 SolveStatus::Optimal | SolveStatus::NonconvexGlobal => SolutionProof::GlobalOptimal,
1013 SolveStatus::LocallyOptimal => SolutionProof::LocalOptimal,
1014 SolveStatus::MaxIterations
1015 | SolveStatus::SuboptimalSolution
1016 | SolveStatus::Timeout
1017 | SolveStatus::NonconvexLocal => SolutionProof::FeasibleUnproven,
1018 SolveStatus::Infeasible
1022 | SolveStatus::Unbounded
1023 | SolveStatus::NumericalError
1024 | SolveStatus::NonConvex(_)
1025 | SolveStatus::NotSupported(_) => {
1026 debug_assert!(
1027 false,
1028 "from_status called with error status {:?}: this arm is unreachable from build_ok",
1029 status
1030 );
1031 SolutionProof::FeasibleUnproven
1032 }
1033 _ => SolutionProof::FeasibleUnproven,
1035 }
1036 }
1037}
1038
1039#[derive(Debug, Clone)]
1041pub struct ModelResult {
1042 model_id: u64,
1043 pub status: SolveStatus,
1050 pub proof: SolutionProof,
1052 pub objective_value: f64,
1054 solution: Vec<f64>,
1056 pub dual_solution: Option<Vec<f64>>,
1058 pub reduced_costs: Option<Vec<f64>>,
1066 pub slack: Option<Vec<f64>>,
1068 pub bound_duals: Vec<f64>,
1072 pub stats: otspot_core::problem::SolveStats,
1074}
1075
1076#[cfg(test)]
1080impl Model {
1081 pub(crate) fn has_quad_dsl_error(&self) -> bool {
1084 self.quad_dsl_error.is_some()
1085 }
1086
1087 pub(crate) fn is_quad_via_dsl(&self) -> bool {
1089 self.quadratic_objective.is_some()
1090 }
1091
1092 pub(crate) fn has_quadratic_objective(&self) -> bool {
1094 self.quadratic_objective.is_some()
1095 }
1096}
1097
1098impl ModelResult {
1099 pub fn value(&self, var: Variable) -> f64 {
1105 self.solution[var.index]
1106 }
1107
1108 pub fn try_value(&self, var: Variable) -> Result<f64, ModelError> {
1114 if var.model_id != self.model_id {
1115 return Err(ModelError::InvalidInput(
1116 "variable belongs to a different model".to_string(),
1117 ));
1118 }
1119 self.solution.get(var.index).copied().ok_or_else(|| {
1120 ModelError::InvalidInput(format!(
1121 "variable index {} out of range (solution length {})",
1122 var.index,
1123 self.solution.len()
1124 ))
1125 })
1126 }
1127
1128 pub fn objective(&self) -> f64 {
1130 self.objective_value
1131 }
1132
1133 pub fn has_global_optimality_proof(&self) -> bool {
1135 self.proof == SolutionProof::GlobalOptimal
1136 }
1137}
1138
1139impl Index<Variable> for ModelResult {
1151 type Output = f64;
1152 fn index(&self, var: Variable) -> &f64 {
1153 &self.solution[var.index]
1154 }
1155}
1156
1157#[non_exhaustive]
1163#[derive(Debug, Clone, PartialEq)]
1164pub enum SolveError {
1165 Infeasible,
1167 Unbounded,
1169 MaxIterations,
1171 NumericalError,
1173}
1174
1175impl fmt::Display for SolveError {
1176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1177 match self {
1178 SolveError::Infeasible => write!(f, "Problem is infeasible"),
1179 SolveError::Unbounded => write!(f, "Problem is unbounded"),
1180 SolveError::MaxIterations => {
1181 write!(f, "Max iterations reached without optimal solution")
1182 }
1183 SolveError::NumericalError => write!(f, "Numerical breakdown during solve"),
1184 }
1185 }
1186}
1187
1188#[non_exhaustive]
1190#[derive(Debug)]
1191pub enum ModelError {
1192 NoObjective,
1194 InvalidInput(String),
1196 SolveError(SolveError),
1198 Timeout,
1200 NonConvex(String),
1203 NotSupported(String),
1205 Internal(String),
1207}
1208
1209impl fmt::Display for ModelError {
1210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1211 match self {
1212 ModelError::NoObjective => write!(
1213 f,
1214 "No objective function defined. Call model.minimize() or model.maximize() before solve()."
1215 ),
1216 ModelError::InvalidInput(msg) => write!(f, "Invalid model input: {}", msg),
1217 ModelError::SolveError(e) => write!(f, "Solve failed: {}", e),
1218 ModelError::Timeout => write!(f, "Solver timed out"),
1219 ModelError::NonConvex(msg) => write!(f, "Non-convex problem: {}", msg),
1220 ModelError::NotSupported(msg) => write!(f, "Not supported: {}", msg),
1221 ModelError::Internal(msg) => write!(f, "Internal error: {}", msg),
1222 }
1223 }
1224}
1225
1226impl std::error::Error for ModelError {}
1227
1228#[cfg(test)]
1233mod tests {
1234 use super::{classify_status_error, Model, ModelError, SolutionProof, SolveError};
1235 use crate::variable::Variable;
1236 use otspot_core::problem::SolveStatus;
1237
1238 const EPS: f64 = 2e-3;
1240
1241 fn assert_close(a: f64, b: f64, name: &str) {
1242 assert!(
1243 (a - b).abs() < EPS,
1244 "{}: expected {:.8}, got {:.8}",
1245 name,
1246 b,
1247 a
1248 );
1249 }
1250
1251 fn basic_model() -> (Model, Variable, Variable) {
1258 let mut model = Model::new("basic");
1259 let x = model.add_var("x", 0.0, f64::INFINITY);
1260 let y = model.add_var("y", 0.0, 10.0);
1261 model.add_constraint((2.0 * x + 3.0 * y).leq(12.0));
1263 model.add_constraint((x + y).geq(3.0));
1264 model.minimize(x + 2.0 * y);
1265 (model, x, y)
1266 }
1267
1268 #[test]
1269 fn test_basic_lp_3var_3con() {
1270 let mut model = Model::new("3var");
1276 let x = model.add_var("x", 0.0, f64::INFINITY);
1277 let y = model.add_var("y", 0.0, f64::INFINITY);
1278 let z = model.add_var("z", 0.0, f64::INFINITY);
1279
1280 model.add_constraint((x + y + z).geq(6.0));
1282 model.add_constraint((x + 2.0 * y).leq(10.0));
1283 model.add_constraint((y + z).geq(4.0));
1284 model.minimize(x + 2.0 * y + 3.0 * z);
1285
1286 let result = model.solve().unwrap();
1287 assert!(result[x] + result[y] + result[z] >= 6.0 - 1e-6);
1289 assert!(result[y] + result[z] >= 4.0 - 1e-6);
1290 assert!(result[x] >= -1e-9);
1291 assert!(result[y] >= -1e-9);
1292 assert!(result[z] >= -1e-9);
1293 assert!(result.objective_value > 0.0, "objective should be positive");
1294 }
1295
1296 #[test]
1297 fn test_unbounded() {
1298 let mut model = Model::new("unbounded");
1300 let x = model.add_var("x", 0.0, f64::INFINITY);
1301 model.minimize(-1.0 * x);
1302
1303 let err = model.solve().unwrap_err();
1304 assert!(
1305 matches!(err, ModelError::SolveError(SolveError::Unbounded)),
1306 "expected Unbounded, got {:?}",
1307 err
1308 );
1309 }
1310
1311 #[test]
1312 fn test_infeasible() {
1313 let mut model = Model::new("infeasible");
1315 let x = model.add_var("x", 0.0, f64::INFINITY);
1316 model.add_constraint(crate::constraint!(x >= 5.0));
1318 model.add_constraint(crate::constraint!(x <= 3.0));
1319 model.minimize(x);
1320
1321 let err = model.solve().unwrap_err();
1322 assert!(
1323 matches!(err, ModelError::SolveError(SolveError::Infeasible)),
1324 "expected Infeasible, got {:?}",
1325 err
1326 );
1327 }
1328
1329 #[test]
1330 fn test_equality_constraint() {
1331 let mut model = Model::new("eq");
1334 let x = model.add_var("x", 0.0, f64::INFINITY);
1335 let y = model.add_var("y", 0.0, f64::INFINITY);
1336 model.add_constraint((x + y).eq_constraint(5.0));
1338 model.minimize(x + y);
1339
1340 let result = model.solve().unwrap();
1341 assert!(
1342 (result.objective_value - 5.0).abs() < 1e-6,
1343 "obj={} expected 5.0",
1344 result.objective_value
1345 );
1346 }
1347
1348 #[test]
1349 fn test_variable_bounds() {
1350 let mut model = Model::new("bounds");
1353 let x = model.add_var("x", 0.0, 3.0);
1354 model.minimize(x);
1355
1356 let result = model.solve().unwrap();
1357 assert!(result[x].abs() < 1e-6, "x should be 0.0, got {}", result[x]);
1358
1359 let mut model2 = Model::new("bounds_max");
1363 let x2 = model2.add_var("x", 0.0, 3.0);
1364 model2.add_constraint(crate::constraint!(x2 <= 3.0));
1365 model2.maximize(x2);
1366
1367 let result2 = model2.solve().unwrap();
1368 assert!(
1369 (result2[x2] - 3.0).abs() < 1e-6,
1370 "x should be 3.0, got {}",
1371 result2[x2]
1372 );
1373 }
1374
1375 #[test]
1376 fn test_no_objective_error() {
1377 let mut model = Model::new("no_obj");
1378 let _x = model.add_var("x", 0.0, f64::INFINITY);
1379 let err = model.solve().unwrap_err();
1380 assert!(matches!(err, ModelError::NoObjective));
1381 }
1382
1383 #[test]
1384 fn solution_proof_mapping_preserves_unproven_statuses() {
1385 assert_eq!(
1386 SolutionProof::from_status(&SolveStatus::Optimal),
1387 SolutionProof::GlobalOptimal
1388 );
1389 assert_eq!(
1390 SolutionProof::from_status(&SolveStatus::NonconvexGlobal),
1391 SolutionProof::GlobalOptimal
1392 );
1393 assert_eq!(
1394 SolutionProof::from_status(&SolveStatus::LocallyOptimal),
1395 SolutionProof::LocalOptimal
1396 );
1397 assert_eq!(
1398 SolutionProof::from_status(&SolveStatus::NonconvexLocal),
1399 SolutionProof::FeasibleUnproven
1400 );
1401 assert_eq!(
1402 SolutionProof::from_status(&SolveStatus::Timeout),
1403 SolutionProof::FeasibleUnproven
1404 );
1405 assert_eq!(
1406 SolutionProof::from_status(&SolveStatus::SuboptimalSolution),
1407 SolutionProof::FeasibleUnproven
1408 );
1409 }
1410
1411 #[test]
1412 fn test_result_index_and_value_agree() {
1413 let (mut model, x, y) = basic_model();
1414 let result = model.solve().unwrap();
1415 assert!((result[x] - result.value(x)).abs() < 1e-12);
1416 assert!((result[y] - result.value(y)).abs() < 1e-12);
1417 }
1418
1419 #[test]
1420 fn test_maximize() {
1421 let mut model = Model::new("max_simple");
1423 let x = model.add_var("x", 0.0, f64::INFINITY);
1424 model.add_constraint(crate::constraint!(x <= 7.0));
1426 model.maximize(x);
1427
1428 let result = model.solve().unwrap();
1429 assert!(
1430 (result[x] - 7.0).abs() < 1e-6,
1431 "expected x=7, got {}",
1432 result[x]
1433 );
1434 assert!(
1435 (result.objective() - 7.0).abs() < 1e-6,
1436 "expected obj=7, got {}",
1437 result.objective()
1438 );
1439 }
1440
1441 #[test]
1442 fn test_model_qp_basic() {
1443 let mut model = Model::new("qp_basic");
1446 let x = model.add_var("x", 0.0, f64::INFINITY);
1447 let y = model.add_var("y", 0.0, f64::INFINITY);
1448 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1449
1450 let result = model.solve().unwrap();
1451 assert_close(result[x], 2.0, "T9: x");
1452 assert_close(result[y], 2.0, "T9: y");
1453 assert_close(result.objective_value, -8.0, "T9: obj");
1455 }
1456
1457 #[test]
1458 fn test_model_qp_equality() {
1459 let mut model = Model::new("qp_eq");
1462 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1463 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1464 model.add_constraint((x + y).eq_constraint(1.0));
1465 model.minimize(x * x + y * y);
1466
1467 let result = model.solve().unwrap();
1468 assert_close(result[x], 0.5, "T10: x");
1469 assert_close(result[y], 0.5, "T10: y");
1470 assert_close(result.objective_value, 0.5, "T10: obj");
1471 }
1472
1473 #[test]
1474 fn test_model_qp_ge_constraint() {
1475 let mut model = Model::new("qp_ge");
1478 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1479 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1480 model.add_constraint((x + y).geq(1.0));
1481 model.minimize(x * x + y * y);
1482
1483 let result = model.solve().unwrap();
1484 let qp_tol = 2e-3;
1485 assert!(
1486 (result[x] - 0.5).abs() < qp_tol,
1487 "T11: x expected 0.5, got {}",
1488 result[x]
1489 );
1490 assert!(
1491 (result[y] - 0.5).abs() < qp_tol,
1492 "T11: y expected 0.5, got {}",
1493 result[y]
1494 );
1495 assert!(
1496 (result.objective_value - 0.5).abs() < qp_tol,
1497 "T11: obj expected 0.5, got {}",
1498 result.objective_value
1499 );
1500 }
1501
1502 #[test]
1503 fn test_model_qp_maximize() {
1504 let mut model = Model::new("qp_maximize");
1507 let x = model.add_var("x", 0.0, f64::INFINITY);
1508 let y = model.add_var("y", 0.0, f64::INFINITY);
1509 model.add_constraint((x + y).geq(1.0));
1510 model.maximize((-1.0) * x * x + (-1.0) * y * y);
1511
1512 let result = model.solve().unwrap();
1513 assert_close(result[x], 0.5, "T12: x");
1514 assert_close(result[y], 0.5, "T12: y");
1515 assert_close(result.objective_value, -0.5, "T12: obj");
1516 }
1517
1518 #[test]
1519 fn test_model_qp_box_bounds() {
1520 let mut model = Model::new("qp_box");
1524 let x = model.add_var("x", 0.0, 1.0);
1525 let y = model.add_var("y", 0.0, 1.0);
1526 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1527
1528 let result = model.solve().unwrap();
1529 assert_close(result[x], 1.0, "T13: x");
1530 assert_close(result[y], 1.0, "T13: y");
1531 assert_close(result.objective_value, -6.0, "T13: obj");
1532 }
1533
1534 #[test]
1535 fn test_model_qp_timeout() {
1536 let mut model = Model::new("qp_timeout");
1538 let x = model.add_var("x", 0.0, f64::INFINITY);
1539 let y = model.add_var("y", 0.0, f64::INFINITY);
1540 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1541 model.set_timeout(0.000_001); let err = model.solve().unwrap_err();
1544 assert!(
1545 matches!(err, ModelError::Timeout),
1546 "expected Timeout, got {:?}",
1547 err
1548 );
1549 }
1550
1551 #[test]
1555 fn test_model_lp_equality() {
1556 let mut model = Model::new("lp_eq");
1559 let x = model.add_var("x", 0.0, f64::INFINITY);
1560 let y = model.add_var("y", 0.0, f64::INFINITY);
1561 model.add_constraint((x + y).eq_constraint(4.0));
1562 model.minimize(x + 2.0 * y);
1563
1564 let result = model.solve().unwrap();
1565 assert_close(result.objective_value, 4.0, "T8-1: obj");
1566 assert_close(result[x] + result[y], 4.0, "T8-1: x+y=4");
1568 }
1569
1570 #[test]
1574 fn test_model_eq_dual_solution() {
1575 let mut model = Model::new("qp_eq_dual");
1579 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1580 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1581 model.add_constraint((x + y).eq_constraint(1.0));
1582 model.minimize(x * x + y * y);
1583
1584 let result = model.solve().unwrap();
1585 assert_close(result.objective_value, 0.5, "T8-2: obj");
1586 assert_close(result[x], 0.5, "T8-2: x");
1587 assert_close(result[y], 0.5, "T8-2: y");
1588
1589 let dual = result
1591 .dual_solution
1592 .as_ref()
1593 .expect("T8-2: dual_solution is None");
1594 assert!(
1595 dual.len() == 1,
1596 "T8-2: dual length expected 1, got {}",
1597 dual.len()
1598 );
1599 assert!(
1600 (dual[0] - (-1.0)).abs() < EPS,
1601 "T8-2: dual[0] expected -1.0, got {}",
1602 dual[0]
1603 );
1604 }
1605
1606 #[test]
1613 #[allow(clippy::type_complexity)]
1614 fn test_model_qp_locally_optimal_proof() {
1615 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1617 ("diag(-2,2)", [-2.0, 2.0], (-1.0, 1.0), [0.0, 0.0]),
1619 ("diag(-3,5)", [-3.0, 5.0], (-2.0, 2.0), [-1.0, 0.0]),
1621 ];
1622
1623 for &(name, q_diag, (lb, ub), c) in cases {
1624 let mut model = Model::new(name);
1625 let x = model.add_var("x0", lb, ub);
1626 let y = model.add_var("x1", lb, ub);
1627 model.minimize(
1629 (q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y,
1630 );
1631
1632 let result = model.solve();
1633 match result {
1634 Ok(r) => {
1635 assert_eq!(
1636 r.status,
1637 otspot_core::problem::SolveStatus::LocallyOptimal,
1638 "[{name}] expected LocallyOptimal, got {:?}",
1639 r.status
1640 );
1641 assert_eq!(
1642 r.proof,
1643 SolutionProof::LocalOptimal,
1644 "[{name}] expected LocalOptimal proof, got {:?}",
1645 r.proof
1646 );
1647 assert!(
1648 !r.has_global_optimality_proof(),
1649 "[{name}] has_global_optimality_proof must be false for LocallyOptimal"
1650 );
1651 }
1652 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1653 }
1654 }
1655 }
1656
1657 #[test]
1665 #[allow(clippy::type_complexity)]
1666 fn test_model_qp_feasible_unproven_proof() {
1667 use otspot_core::options::Tolerance;
1668
1669 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1671 (
1674 "convex_2x2_tight_tol",
1675 [2.0, 2.0],
1676 (0.0, f64::INFINITY),
1677 [-4.0, -4.0],
1678 ),
1679 ("convex_box_tight_tol", [4.0, 4.0], (0.0, 3.0), [0.0, -2.0]),
1681 ];
1682
1683 for &(name, q_diag, (lb, ub), c) in cases {
1684 let mut model = Model::new(name);
1685 let x = model.add_var("x0", lb, ub);
1686 let y = model.add_var("x1", lb, ub);
1687 model.minimize(
1688 (q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y,
1689 );
1690 model.set_tolerance(Tolerance::Custom(1e-200));
1693
1694 let result = model.solve();
1695 match result {
1696 Ok(r) => {
1697 assert_eq!(
1698 r.proof,
1699 SolutionProof::FeasibleUnproven,
1700 "[{name}] expected FeasibleUnproven proof, got {:?} (status={:?})",
1701 r.proof,
1702 r.status
1703 );
1704 assert!(
1705 !r.has_global_optimality_proof(),
1706 "[{name}] has_global_optimality_proof must be false for FeasibleUnproven"
1707 );
1708 assert!(
1709 !r.solution.is_empty(),
1710 "[{name}] solution must be non-empty"
1711 );
1712 }
1713 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1714 }
1715 }
1716 }
1717
1718 #[test]
1723 fn classify_status_error_typed_variants() {
1724 let cases_nonconvex = ["indefinite Q: eigenvalue < 0", "non-PSD matrix in MIQP"];
1725 for msg in &cases_nonconvex {
1726 let status = SolveStatus::NonConvex(msg.to_string());
1727 let err = classify_status_error(status).expect("NonConvex must map to Some");
1728 assert!(
1729 matches!(err, ModelError::NonConvex(_)),
1730 "NonConvex status must yield ModelError::NonConvex, got {:?}",
1731 err
1732 );
1733 }
1734
1735 let cases_not_supported = ["QCQP not supported", "constraint type unsupported"];
1736 for msg in &cases_not_supported {
1737 let status = SolveStatus::NotSupported(msg.to_string());
1738 let err = classify_status_error(status).expect("NotSupported must map to Some");
1739 assert!(
1740 matches!(err, ModelError::NotSupported(_)),
1741 "NotSupported status must yield ModelError::NotSupported, got {:?}",
1742 err
1743 );
1744 }
1745 }
1746
1747 #[test]
1753 fn miqp_nonconvex_q_returns_nonconvex_error() {
1754 let cases: &[(&str, [f64; 2])] = &[
1755 ("diag(-1, 1)", [-1.0, 1.0]),
1756 ("diag(-2, 3)", [-2.0, 3.0]),
1757 ("diag(1, -1)", [1.0, -1.0]),
1758 ];
1759
1760 for &(name, q_diag) in cases {
1761 let mut model = Model::new(name);
1762 let x = model.add_binary_var("x");
1763 let y = model.add_binary_var("y");
1764 model.minimize((q_diag[0] / 2.0) * (x * x) + (q_diag[1] / 2.0) * (y * y));
1766
1767 let err = model
1768 .solve()
1769 .expect_err(&format!("[{name}] expected Err(NonConvex), got Ok"));
1770 assert!(
1771 matches!(err, ModelError::NonConvex(_)),
1772 "[{name}] expected ModelError::NonConvex, got {:?}",
1773 err
1774 );
1775 }
1776 }
1777
1778 #[test]
1785 fn add_var_lb_gt_ub_defers_error_to_solve() {
1786 let cases: &[(&str, f64, f64)] = &[
1787 ("lb=5 > ub=3", 5.0, 3.0),
1788 ("lb=1.0 > ub=0.999", 1.0, 0.999),
1789 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1790 ];
1791 for &(label, lb, ub) in cases {
1792 let mut model = Model::new(label);
1793 let x = model.add_var("x", lb, ub);
1794 model.minimize(x);
1795 let err = model
1796 .solve()
1797 .expect_err(&format!("[{label}] expected Err, got Ok"));
1798 assert!(
1799 matches!(err, ModelError::InvalidInput(_)),
1800 "[{label}] expected InvalidInput, got {err:?}"
1801 );
1802 }
1803 }
1804
1805 #[test]
1806 fn add_var_nan_bounds_defers_error_to_solve() {
1807 let cases: &[(&str, f64, f64)] = &[
1808 ("lb=NaN", f64::NAN, 1.0),
1809 ("ub=NaN", 0.0, f64::NAN),
1810 ("both=NaN", f64::NAN, f64::NAN),
1811 ];
1812 for &(label, lb, ub) in cases {
1813 let mut model = Model::new(label);
1814 let x = model.add_var("x", lb, ub);
1815 model.minimize(x);
1816 let err = model
1817 .solve()
1818 .expect_err(&format!("[{label}] expected Err, got Ok"));
1819 assert!(
1820 matches!(err, ModelError::InvalidInput(_)),
1821 "[{label}] expected InvalidInput, got {err:?}"
1822 );
1823 }
1824 }
1825
1826 #[test]
1827 fn add_int_var_lb_gt_ub_defers_error_to_solve() {
1828 let cases: &[(&str, f64, f64)] =
1829 &[("int lb=3 > ub=1", 3.0, 1.0), ("int lb=NaN", f64::NAN, 5.0)];
1830 for &(label, lb, ub) in cases {
1831 let mut model = Model::new(label);
1832 let x = model.add_int_var("x", lb, ub);
1833 model.minimize(x);
1834 let err = model
1835 .solve()
1836 .expect_err(&format!("[{label}] expected Err, got Ok"));
1837 assert!(
1838 matches!(err, ModelError::InvalidInput(_)),
1839 "[{label}] expected InvalidInput, got {err:?}"
1840 );
1841 }
1842 }
1843
1844 #[test]
1849 fn try_add_var_returns_err_for_invalid_bounds() {
1850 let cases: &[(&str, f64, f64)] = &[
1851 ("lb>ub", 2.0, 1.0),
1852 ("lb=NaN", f64::NAN, 1.0),
1853 ("ub=NaN", 0.0, f64::NAN),
1854 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1855 ];
1856 for &(label, lb, ub) in cases {
1857 let mut model = Model::new(label);
1858 let result = model.try_add_var("x", lb, ub);
1859 assert!(
1860 result.is_err(),
1861 "[{label}] try_add_var should return Err for invalid bounds, got Ok"
1862 );
1863 }
1864 }
1865
1866 #[test]
1867 fn try_add_var_returns_ok_for_valid_bounds() {
1868 let cases: &[(&str, f64, f64)] = &[
1869 ("lb=ub", 3.0, 3.0),
1870 ("lb=0 ub=inf", 0.0, f64::INFINITY),
1871 ("lb=-inf ub=inf", f64::NEG_INFINITY, f64::INFINITY),
1872 ("lb=-inf ub=0", f64::NEG_INFINITY, 0.0),
1873 ];
1874 for &(label, lb, ub) in cases {
1875 let mut model = Model::new(label);
1876 assert!(
1877 model.try_add_var("x", lb, ub).is_ok(),
1878 "[{label}] try_add_var should return Ok for valid bounds"
1879 );
1880 }
1881 }
1882
1883 #[test]
1884 fn try_add_int_var_returns_err_for_invalid_bounds() {
1885 let cases: &[(&str, f64, f64)] = &[("int lb>ub", 5.0, 2.0), ("int lb=NaN", f64::NAN, 3.0)];
1886 for &(label, lb, ub) in cases {
1887 let mut model = Model::new(label);
1888 assert!(
1889 model.try_add_int_var("n", lb, ub).is_err(),
1890 "[{label}] try_add_int_var should return Err"
1891 );
1892 }
1893 }
1894
1895 #[test]
1901 fn try_value_cross_model_returns_err() {
1902 let mut m1 = Model::new("m1");
1903 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
1904 m1.minimize(x1);
1905 let r1 = m1.solve().unwrap();
1906
1907 let mut m2 = Model::new("m2");
1909 let y = m2.add_var("y", 0.0, f64::INFINITY);
1910
1911 assert!(
1912 r1.try_value(y).is_err(),
1913 "try_value with variable from different model must return Err"
1914 );
1915 assert!(r1.try_value(x1).is_ok());
1917 }
1918
1919 #[test]
1920 fn try_value_valid_returns_ok() {
1921 let (mut model, x, y) = basic_model();
1922 let result = model.solve().unwrap();
1923 assert!(result.try_value(x).is_ok());
1924 assert!(result.try_value(y).is_ok());
1925 assert!((result.try_value(x).unwrap() - result.value(x)).abs() < 1e-12);
1926 }
1927
1928 #[test]
1933 fn try_value_out_of_range_same_model_returns_err() {
1934 let mut model = Model::new("grow");
1935 let x = model.add_var("x", 0.0, f64::INFINITY);
1936 model.minimize(x);
1937 let result = model.solve().unwrap();
1938
1939 let late = model.add_var("late", 0.0, f64::INFINITY);
1941 assert_eq!(late.model_id, result.model_id, "same model_id expected");
1942 assert!(
1943 late.index >= result.solution.len(),
1944 "test setup: late var must be out of range"
1945 );
1946 assert!(
1947 result.try_value(late).is_err(),
1948 "try_value must return Err for an out-of-range index even when model_id matches"
1949 );
1950 }
1951
1952 #[test]
1956 fn model_result_clone() {
1957 let (mut model, x, _y) = basic_model();
1958 let result = model.solve().unwrap();
1959 let cloned = result.clone();
1960 assert!((cloned.objective_value - result.objective_value).abs() < 1e-12);
1961 assert_eq!(cloned.solution.len(), result.solution.len());
1962 assert!((cloned[x] - result[x]).abs() < 1e-12);
1963 }
1964
1965 #[test]
1969 fn var_name_is_retained() {
1970 let cases = [("alpha", 0.0, 1.0), ("beta_var", 0.0, f64::INFINITY)];
1971 let mut model = Model::new("named");
1972 for &(name, lb, ub) in &cases {
1973 let v = model.add_var(name, lb, ub);
1974 assert_eq!(model.var_name(v), name, "var_name mismatch for '{name}'");
1975 }
1976 let iv = model.add_int_var("gamma_int", 0.0, 10.0);
1977 assert_eq!(model.var_name(iv), "gamma_int");
1978 }
1979
1980 #[test]
1987 fn try_var_name_ok_same_model() {
1988 let cases: &[(&str, f64, f64)] = &[
1989 ("x", 0.0, f64::INFINITY),
1990 ("y_var", -1.0, 1.0),
1991 ("z_int", 0.0, 10.0),
1992 ];
1993 let mut model = Model::new("try_var_name_ok");
1994 let mut vars = Vec::new();
1995 for &(name, lb, ub) in cases {
1996 vars.push((model.add_var(name, lb, ub), name));
1997 }
1998 for (var, expected_name) in &vars {
1999 assert_eq!(
2000 model.try_var_name(*var).unwrap(),
2001 *expected_name,
2002 "try_var_name mismatch"
2003 );
2004 assert_eq!(
2006 model.var_name(*var),
2007 model.try_var_name(*var).unwrap(),
2008 "var_name and try_var_name must return the same value for '{expected_name}'"
2009 );
2010 }
2011 }
2012
2013 #[test]
2017 fn try_var_name_err_cross_model() {
2018 let mut model_a = Model::new("model_a");
2019 let x = model_a.add_var("x_in_a", 0.0, 1.0);
2020
2021 let cases: &[&str] = &["model_b", "model_c", "model_d"];
2022 for &name in cases {
2023 let m = Model::new(name);
2024 let err = m.try_var_name(x).unwrap_err();
2025 assert!(
2026 matches!(err, ModelError::InvalidInput(_)),
2027 "[{name}] expected InvalidInput for cross-model var, got {err:?}"
2028 );
2029 }
2030 }
2031
2032 #[test]
2035 fn try_var_name_err_index_out_of_range() {
2036 let mut model = Model::new("range_check");
2037 let x = model.add_var("x", 0.0, 1.0); assert!(model.try_var_name(x).is_ok(), "index=0 (N-1) must be Ok");
2041
2042 let oob_n = Variable {
2044 index: 1,
2045 model_id: x.model_id,
2046 };
2047 let err_n = model.try_var_name(oob_n).unwrap_err();
2048 assert!(
2049 matches!(err_n, ModelError::InvalidInput(_)),
2050 "index=N=1: expected InvalidInput, got {err_n:?}"
2051 );
2052
2053 let oob_max = Variable {
2055 index: usize::MAX,
2056 model_id: x.model_id,
2057 };
2058 let err_max = model.try_var_name(oob_max).unwrap_err();
2059 assert!(
2060 matches!(err_max, ModelError::InvalidInput(_)),
2061 "index=usize::MAX: expected InvalidInput, got {err_max:?}"
2062 );
2063 }
2064
2065 #[test]
2066 fn var_name_regression_unchecked_still_works() {
2067 let mut model = Model::new("regression");
2069 let cases: &[(&str, f64, f64)] = &[("a", 0.0, 1.0), ("b", 0.0, 2.0), ("c_int", 0.0, 5.0)];
2070 let mut pairs = Vec::new();
2071 for &(name, lb, ub) in cases {
2072 pairs.push((model.add_var(name, lb, ub), name));
2073 }
2074 for (var, expected) in &pairs {
2075 assert_eq!(model.var_name(*var), *expected);
2076 }
2077 }
2078
2079 #[test]
2089 fn var_name_cross_model_in_range_silently_returns_wrong_data() {
2090 let mut model_a = Model::new("a");
2094 let x_a = model_a.add_var("x_in_a", 0.0, 1.0); let mut model_b = Model::new("b");
2097 let _y_b = model_b.add_var("y_in_b", 0.0, 1.0); let returned = model_b.var_name(x_a);
2102 assert_ne!(
2103 returned, "x_in_a",
2104 "unchecked: cross-model var_name returns unrelated data"
2105 );
2106 assert_eq!(
2107 returned, "y_in_b",
2108 "unchecked: returns model_b's var at the same index"
2109 );
2110 }
2111
2112 #[test]
2116 #[should_panic(expected = "index out of bounds")]
2117 fn var_name_cross_model_out_of_range_panics() {
2118 let mut model_a = Model::new("a_2var");
2119 let _x0 = model_a.add_var("x0", 0.0, 1.0); let x1 = model_a.add_var("x1", 0.0, 1.0); let mut model_b = Model::new("b_1var");
2124 let _y_b = model_b.add_var("y_in_b", 0.0, 1.0);
2125
2126 let _ = model_b.var_name(x1);
2127 }
2128
2129 #[test]
2134 fn set_timeout_invalid_defers_error() {
2135 let cases: &[(&str, f64)] = &[
2136 ("negative", -1.0),
2137 ("NaN", f64::NAN),
2138 ("neg_inf", f64::NEG_INFINITY),
2139 ];
2140 for &(label, secs) in cases {
2141 let mut model = Model::new(label);
2142 let x = model.add_var("x", 0.0, f64::INFINITY);
2143 model.minimize(x);
2144 model.set_timeout(secs);
2145 let err = model
2146 .solve()
2147 .expect_err(&format!("[{label}] expected Err for invalid timeout"));
2148 assert!(
2149 matches!(err, ModelError::InvalidInput(_)),
2150 "[{label}] expected InvalidInput, got {err:?}"
2151 );
2152 }
2153 }
2154
2155 #[test]
2156 fn try_set_timeout_returns_err_for_invalid() {
2157 let cases: &[(&str, f64)] = &[
2158 ("negative", -0.001),
2159 ("NaN", f64::NAN),
2160 ("inf", f64::INFINITY),
2161 ];
2162 for &(label, secs) in cases {
2163 let mut model = Model::new(label);
2164 assert!(
2165 model.try_set_timeout(secs).is_err(),
2166 "[{label}] try_set_timeout should return Err"
2167 );
2168 }
2169 }
2170
2171 #[test]
2172 fn try_set_timeout_ok_for_valid() {
2173 let valid = [0.0, 0.001, 1.0, 3600.0];
2174 for &secs in &valid {
2175 let mut model = Model::new("t");
2176 assert!(
2177 model.try_set_timeout(secs).is_ok(),
2178 "should be Ok for secs={secs}"
2179 );
2180 }
2181 }
2182}
2183
2184#[cfg(test)]
2193mod mip_model_tests {
2194 use super::{Model, ModelError, SolveError};
2195
2196 const EPS: f64 = 1e-4;
2197
2198 #[test]
2199 fn model_add_int_var_maximize_branches() {
2200 let mut m = Model::new("milp_int");
2202 let x = m.add_int_var("x", 0.0, 5.0);
2203 m.add_constraint((2.0 * x).leq(3.0));
2204 m.maximize(x);
2205 let r = m.solve().unwrap();
2206 assert!((r.objective() - 1.0).abs() < EPS, "obj={}", r.objective());
2207 assert!((r[x] - 1.0).abs() < EPS, "x={}", r[x]);
2208 }
2209
2210 #[test]
2211 fn model_binary_knapsack() {
2212 let mut m = Model::new("knapsack");
2213 let a = m.add_binary_var("a");
2214 let b = m.add_binary_var("b");
2215 let c = m.add_binary_var("c");
2216 let d = m.add_binary_var("d");
2217 m.add_constraint((5.0 * a + 7.0 * b + 4.0 * c + 3.0 * d).leq(14.0));
2218 m.maximize(8.0 * a + 11.0 * b + 6.0 * c + 4.0 * d);
2219 let r = m.solve().unwrap();
2220 assert!((r.objective() - 21.0).abs() < EPS, "obj={}", r.objective());
2221 assert_eq!(
2222 (r[a].round(), r[b].round(), r[c].round(), r[d].round()),
2223 (0.0, 1.0, 1.0, 1.0)
2224 );
2225 }
2226
2227 #[test]
2228 fn model_integer_infeasible_errors() {
2229 let mut m = Model::new("infeasible");
2230 let x = m.add_int_var("x", 0.0, 10.0);
2231 m.add_constraint((1.0 * x).geq(1.2));
2232 m.add_constraint((1.0 * x).leq(1.8));
2233 m.minimize(x);
2234 let err = m.solve().unwrap_err();
2235 assert!(
2236 matches!(err, ModelError::SolveError(SolveError::Infeasible)),
2237 "got {err:?}"
2238 );
2239 }
2240
2241 #[test]
2242 fn model_integer_unbounded_errors() {
2243 let mut m = Model::new("unbounded");
2244 let x = m.add_int_var("x", 0.0, f64::INFINITY);
2245 m.maximize(x);
2246 let err = m.solve().unwrap_err();
2247 assert!(
2248 matches!(err, ModelError::SolveError(SolveError::Unbounded)),
2249 "got {err:?}"
2250 );
2251 }
2252
2253 #[test]
2254 fn model_convex_miqp_branches_to_integer_optimum() {
2255 let mut m = Model::new("convex_miqp");
2258 let x = m.add_int_var("x", 0.0, 5.0);
2259 m.minimize(x * x + (-5.0) * x);
2260 let r = m.solve().unwrap();
2261 assert!(
2262 (r.objective() - (-6.0)).abs() < EPS,
2263 "obj={}",
2264 r.objective()
2265 );
2266 let xr = r[x].round();
2267 assert!(xr == 2.0 || xr == 3.0, "x must be 2 or 3, got {}", r[x]);
2268 assert!((r[x] - xr).abs() < EPS, "x must be integral: {}", r[x]);
2269 }
2270
2271 #[test]
2272 fn model_nonconvex_miqp_errors() {
2273 let cases: &[(&str, &[f64], &[f64])] = &[
2275 ("single neg", &[-2.0], &[1.0]),
2276 ("neg-pos-2var", &[-3.0, 2.0], &[0.0, 1.0]),
2277 ];
2278 for &(name, q_diag, c_vec) in cases {
2279 let n = q_diag.len();
2280 let mut m = Model::new(name);
2281 let vars: Vec<_> = (0..n)
2282 .map(|i| m.add_int_var(&format!("x{i}"), 0.0, 5.0))
2283 .collect();
2284 let obj = vars.iter().zip(q_diag).zip(c_vec).fold(
2286 crate::quad_expr::QuadExpr::from(0.0_f64),
2287 |acc, ((&v, &d), &c)| acc + (d / 2.0) * (v * v) + c * v,
2288 );
2289 m.minimize(obj);
2290 let err = m.solve().unwrap_err();
2291 assert!(
2292 matches!(err, ModelError::NonConvex(_)),
2293 "[{name}] expected ModelError::NonConvex, got {err:?}"
2294 );
2295 }
2296 }
2297
2298 #[test]
2299 fn model_mixed_integer_continuous() {
2300 let mut m = Model::new("mixed");
2303 let x = m.add_int_var("x", 0.0, 5.0);
2304 let y = m.add_var("y", 0.0, 5.0);
2305 m.add_constraint((x + y).leq(3.5));
2306 m.maximize(x + y);
2307 let r = m.solve().unwrap();
2308 assert!((r.objective() - 3.5).abs() < EPS, "obj={}", r.objective());
2309 assert!(
2310 (r[x].round() - r[x]).abs() < EPS,
2311 "x must be integral, x={}",
2312 r[x]
2313 );
2314 }
2315
2316 #[test]
2320 fn test_linear_objective_constant_included() {
2321 let mut model = Model::new("lin_const");
2323 let x = model.add_var("x", 1.0, f64::INFINITY);
2324 model.minimize(2.0 * x + 3.0);
2325 let result = model.solve().unwrap();
2326 assert!(
2327 (result[x] - 1.0).abs() < EPS,
2328 "x* should be 1, got {}",
2329 result[x]
2330 );
2331 assert!(
2332 (result.objective_value - 5.0).abs() < EPS,
2333 "obj* should be 5 (includes constant +3), got {}",
2334 result.objective_value
2335 );
2336 }
2337
2338 #[test]
2340 fn test_quad_objective_constant_included() {
2341 let mut model = Model::new("quad_const");
2344 let x = model.add_var("x", 0.0, f64::INFINITY);
2345 model.minimize(x * x + (-4.0) * x + 4.0);
2346 let result = model.solve().unwrap();
2347 assert!(
2348 (result[x] - 2.0).abs() < 1e-4,
2349 "quad_const: x* should be 2, got {}",
2350 result[x]
2351 );
2352 assert!(
2353 result.objective_value.abs() < 1e-4,
2354 "quad_const: obj* should be 0 (constant +4 included), got {}",
2355 result.objective_value
2356 );
2357 }
2358
2359 #[test]
2361 fn test_maximize_objective_constant_sign() {
2362 let mut model = Model::new("max_const");
2364 let x = model.add_var("x", 0.0, 10.0);
2365 model.maximize((-1.0) * x + 5.0);
2366 let result = model.solve().unwrap();
2367 assert!(
2368 (result[x]).abs() < EPS,
2369 "max_const: x* should be 0, got {}",
2370 result[x]
2371 );
2372 assert!(
2373 (result.objective_value - 5.0).abs() < EPS,
2374 "max_const: obj* should be 5 (constant not negated for max), got {}",
2375 result.objective_value
2376 );
2377 }
2378
2379 #[test]
2381 fn test_obj_offset_and_dsl_constant_no_double_count() {
2382 let mut model = Model::new("offset_plus_const");
2385 let x = model.add_var("x", 1.0, f64::INFINITY);
2386 model.minimize(1.0 * x + 3.0);
2387 model.set_obj_offset(10.0);
2388 let result = model.solve().unwrap();
2389 assert!(
2390 (result.objective_value - 14.0).abs() < EPS,
2391 "offset+const: obj* should be 14 (1+3+10), got {}",
2392 result.objective_value
2393 );
2394 }
2395
2396 #[test]
2398 fn test_reminimize_constant_not_double_counted() {
2399 let mut model = Model::new("reminimize");
2401 let x = model.add_var("x", 1.0, f64::INFINITY);
2402 model.minimize(1.0 * x + 3.0); model.minimize(1.0 * x + 7.0); let result = model.solve().unwrap();
2405 assert!(
2406 (result.objective_value - 8.0).abs() < EPS,
2407 "re-minimize: obj* should be 8 (1+7, not 1+3+7=11), got {}",
2408 result.objective_value
2409 );
2410 }
2411
2412 #[test]
2418 fn test_p2a_nan_dsl_quad_then_linear_is_optimal() {
2419 let mut model = Model::new("p2a_nan_then_linear");
2422 let x = model.add_var("x", 1.0, f64::INFINITY);
2423 model.minimize(f64::NAN * (x * x)); model.minimize(1.0 * x); let result = model.solve();
2426 assert!(
2427 result.is_ok(),
2428 "P2-a: should be Optimal after linear replaces NaN quad, got {result:?}"
2429 );
2430 let r = result.unwrap();
2431 assert!(
2432 (r[x] - 1.0).abs() < EPS,
2433 "P2-a: x* should be 1, got {}",
2434 r[x]
2435 );
2436 }
2437
2438 #[test]
2440 fn test_p2a_valid_quad_then_nan_errors() {
2441 let mut model = Model::new("p2a_valid_then_nan");
2442 let x = model.add_var("x", 0.0, f64::INFINITY);
2443 model.minimize(x * x + (-2.0) * x); model.minimize(f64::NAN * (x * x)); let result = model.solve();
2446 assert!(
2447 matches!(result, Err(ModelError::InvalidInput(_))),
2448 "P2-a: NaN quad must yield InvalidInput, got {result:?}"
2449 );
2450 }
2451
2452 #[test]
2454 fn test_p2a_nan_dsl_then_maximize_linear_is_optimal() {
2455 let mut model = Model::new("p2a_nan_then_max");
2456 let x = model.add_var("x", 0.0, 5.0);
2457 model.minimize(f64::NAN * (x * x)); model.maximize(1.0 * x); let result = model.solve();
2460 assert!(
2461 result.is_ok(),
2462 "P2-a: maximize should succeed after NaN DSL cleared, got {result:?}"
2463 );
2464 let r = result.unwrap();
2465 assert!(
2466 (r[x] - 5.0).abs() < EPS,
2467 "P2-a: maximize x → x*=5, got {}",
2468 r[x]
2469 );
2470 }
2471
2472 #[test]
2475 fn test_quad_state_invariants_transition_table() {
2476 fn mk() -> (Model, crate::variable::Variable) {
2478 let mut m = Model::new("t");
2479 let x = m.add_var("x", 0.0, f64::INFINITY);
2480 (m, x)
2481 }
2482
2483 {
2485 let (mut m, x) = mk();
2486 m.minimize(x * x);
2487 assert!(!m.has_quad_dsl_error(), "DSL ok: no error");
2488 assert!(m.is_quad_via_dsl(), "DSL ok: via_dsl=true");
2489 assert!(m.has_quadratic_objective(), "DSL ok: has_q=true");
2490 }
2491
2492 {
2494 let (mut m, x) = mk();
2495 m.minimize(f64::NAN * (x * x));
2496 assert!(m.has_quad_dsl_error(), "DSL fail: error recorded");
2497 assert!(!m.is_quad_via_dsl(), "DSL fail: via_dsl=false");
2498 assert!(!m.has_quadratic_objective(), "DSL fail: has_q=false");
2499 }
2500
2501 {
2503 let (mut m, x) = mk();
2504 m.minimize(x * x);
2505 m.minimize(f64::NAN * (x * x));
2506 assert!(m.has_quad_dsl_error(), "ok→fail: error recorded");
2507 assert!(!m.is_quad_via_dsl(), "ok→fail: via_dsl=false");
2508 assert!(!m.has_quadratic_objective(), "ok→fail: has_q=false");
2509 }
2510
2511 {
2513 let (mut m, x) = mk();
2514 m.minimize(x * x);
2515 m.minimize(1.0 * x);
2516 assert!(!m.has_quad_dsl_error(), "dsl→linear: no error");
2517 assert!(!m.is_quad_via_dsl(), "dsl→linear: via_dsl=false");
2518 assert!(!m.has_quadratic_objective(), "dsl→linear: DSL Q cleared");
2519 }
2520 }
2521
2522 #[test]
2524 fn test_quad_state_solve_outcome_table() {
2525 let cases: &[(&str, bool)] = &[
2526 ("nan_then_linear", true),
2529 ("nan_alone", false),
2531 ("ok_then_nan", false),
2533 ("nan_then_valid_quad", true),
2535 ("nan_nan_then_valid_quad", true),
2537 ];
2538
2539 for &(name, expect_ok) in cases {
2540 let mut m = Model::new(name);
2541 let x = m.add_var("x", 0.0, f64::INFINITY);
2542
2543 match name {
2544 "nan_then_linear" => {
2545 m.minimize(f64::NAN * (x * x) + x);
2546 m.minimize(1.0 * x);
2547 }
2548 "nan_alone" => {
2549 m.minimize(f64::NAN * (x * x) + x);
2550 }
2551 "ok_then_nan" => {
2552 m.minimize(x * x + (-4.0) * x);
2553 m.minimize(f64::NAN * (x * x) + x);
2554 }
2555 "nan_then_valid_quad" => {
2556 m.minimize(f64::NAN * (x * x));
2557 m.minimize(x * x + (-4.0) * x);
2558 }
2559 "nan_nan_then_valid_quad" => {
2560 m.minimize(f64::NAN * (x * x));
2561 m.minimize(f64::NAN * (x * x));
2562 m.minimize(x * x + (-4.0) * x);
2563 }
2564 _ => unreachable!(),
2565 }
2566
2567 let result = m.solve();
2568 if expect_ok {
2569 assert!(
2570 result.is_ok(),
2571 "case '{name}': expected Optimal, got {result:?}"
2572 );
2573 } else {
2574 assert!(
2575 matches!(result, Err(ModelError::InvalidInput(_))),
2576 "case '{name}': expected InvalidInput, got {result:?}"
2577 );
2578 }
2579 }
2580 }
2581
2582 #[test]
2588 fn test_p2f_mixed_quad_local_linear_foreign_rejected() {
2589 let mut m1 = Model::new("m1");
2590 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2591
2592 let mut m2 = Model::new("m2");
2593 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2594
2595 m1.minimize(x1 * x1 + y2);
2597 let result = m1.solve();
2598 assert!(
2599 matches!(result, Err(ModelError::InvalidInput(_))),
2600 "P2-f mixed: foreign linear term must give InvalidInput, got {result:?}"
2601 );
2602 }
2603
2604 #[test]
2606 fn test_p2f_pure_linear_foreign_rejected() {
2607 let mut m1 = Model::new("m1");
2608 let _x1 = m1.add_var("x", 0.0, f64::INFINITY);
2609
2610 let mut m2 = Model::new("m2");
2611 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2612
2613 m1.minimize(1.0 * y2);
2615 let result = m1.solve();
2616 assert!(
2617 matches!(result, Err(ModelError::InvalidInput(_))),
2618 "P2-f pure-linear: foreign variable must give InvalidInput (not silent drop), got {result:?}"
2619 );
2620 }
2621
2622 #[test]
2624 fn test_p2f_mixed_all_local_accepted() {
2625 let mut m = Model::new("m");
2626 let x = m.add_var("x", 0.0, f64::INFINITY);
2627 m.minimize(x * x + (-4.0) * x);
2629 let result = m.solve().unwrap();
2630 assert!(
2631 (result[x] - 2.0).abs() < EPS,
2632 "P2-f no false positive: x*=2, got {}",
2633 result[x]
2634 );
2635 }
2636
2637 #[test]
2639 fn test_p2f_cross_term_plus_linear_foreign() {
2640 let mut m1 = Model::new("m1");
2641 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2642
2643 let mut m2 = Model::new("m2");
2644 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2645
2646 m1.minimize(x1 * y2 + 2.0 * y2);
2649 let result = m1.solve();
2650 assert!(
2651 matches!(result, Err(ModelError::InvalidInput(_))),
2652 "P2-f cross+linear: must give InvalidInput, got {result:?}"
2653 );
2654 }
2655
2656 #[test]
2658 fn test_p2f_foreign_linear_maximize_rejected() {
2659 let mut m1 = Model::new("m1");
2660 let _x1 = m1.add_var("x", 0.0, 5.0);
2661
2662 let mut m2 = Model::new("m2");
2663 let y2 = m2.add_var("y", 0.0, 5.0);
2664
2665 m1.maximize(1.0 * y2);
2666 let result = m1.solve();
2667 assert!(
2668 matches!(result, Err(ModelError::InvalidInput(_))),
2669 "P2-f maximize foreign linear: must give InvalidInput, got {result:?}"
2670 );
2671 }
2672
2673 #[test]
2679 fn test_p2h_nan_linear_coef_rejected_at_solve() {
2680 let mut m = Model::new("p2h_nan_coef");
2681 let x = m.add_var("x", 0.0, f64::INFINITY);
2682 m.minimize(f64::NAN * x);
2683 let err = m.solve().unwrap_err();
2684 assert!(
2685 matches!(err, ModelError::InvalidInput(_)),
2686 "P2-h: NaN linear coefficient must give InvalidInput, got {err:?}"
2687 );
2688 }
2689
2690 #[test]
2692 fn test_p2h_inf_linear_coef_rejected_at_solve() {
2693 let mut m = Model::new("p2h_inf_coef");
2694 let x = m.add_var("x", 0.0, f64::INFINITY);
2695 m.minimize(f64::INFINITY * x);
2696 let err = m.solve().unwrap_err();
2697 assert!(
2698 matches!(err, ModelError::InvalidInput(_)),
2699 "P2-h: Inf linear coefficient must give InvalidInput, got {err:?}"
2700 );
2701 }
2702
2703 #[test]
2705 fn test_p2h_nan_constant_rejected_at_solve() {
2706 let mut m = Model::new("p2h_nan_const");
2707 let x = m.add_var("x", 0.0, f64::INFINITY);
2708 m.minimize(1.0 * x + f64::NAN);
2709 let err = m.solve().unwrap_err();
2710 assert!(
2711 matches!(err, ModelError::InvalidInput(_)),
2712 "P2-h: NaN constant term must give InvalidInput, got {err:?}"
2713 );
2714 }
2715
2716 #[test]
2718 fn test_p2h_inf_constant_rejected_at_solve() {
2719 let mut m = Model::new("p2h_inf_const");
2720 let x = m.add_var("x", 0.0, f64::INFINITY);
2721 m.minimize(1.0 * x + f64::INFINITY);
2722 let err = m.solve().unwrap_err();
2723 assert!(
2724 matches!(err, ModelError::InvalidInput(_)),
2725 "P2-h: Inf constant term must give InvalidInput, got {err:?}"
2726 );
2727 }
2728
2729 #[test]
2733 fn test_p2h_inf_q_coef_via_diagonal_overflow_rejected_at_solve() {
2734 let mut m = Model::new("p2h_inf_q_diagonal");
2735 let x = m.add_var("x", 0.0, f64::INFINITY);
2736 m.minimize(f64::MAX * (x * x));
2740 let err = m.solve().unwrap_err();
2741 assert!(
2742 matches!(err, ModelError::InvalidInput(_)),
2743 "P2-h: Inf Q diagonal (2*MAX overflow) must give InvalidInput, got {err:?}"
2744 );
2745 }
2746
2747 #[test]
2750 fn test_p2h_nan_q_coef_dsl_rejected_at_solve() {
2751 let mut m = Model::new("p2h_nan_q_dsl");
2752 let x = m.add_var("x", 0.0, f64::INFINITY);
2753 m.minimize(f64::NAN * (x * x));
2756 let err = m.solve().unwrap_err();
2757 assert!(
2758 matches!(err, ModelError::InvalidInput(_)),
2759 "P2-h: NaN DSL Q coefficient must give InvalidInput, got {err:?}"
2760 );
2761 }
2762
2763 #[test]
2769 fn test_p2i_nan_quad_replaced_by_valid_quad_is_optimal() {
2770 let mut model = Model::new("p2i_nan_then_valid");
2771 let x = model.add_var("x", 0.0, f64::INFINITY);
2772 model.minimize(f64::NAN * (x * x)); assert!(
2774 model.has_quad_dsl_error(),
2775 "P2-i setup: error must be recorded"
2776 );
2777
2778 model.minimize(x * x + (-4.0) * x); assert!(
2780 !model.has_quad_dsl_error(),
2781 "P2-i: valid minimize must clear quad_dsl_error"
2782 );
2783 assert!(
2784 model.has_quadratic_objective(),
2785 "P2-i: valid Q must be installed"
2786 );
2787
2788 let result = model.solve();
2790 assert!(
2791 result.is_ok(),
2792 "P2-i: valid quad after NaN must be Optimal, got {result:?}"
2793 );
2794 let r = result.unwrap();
2795 assert!((r[x] - 2.0).abs() < EPS, "P2-i: x*=2, got {}", r[x]);
2796 assert!(
2797 (r.objective_value - (-4.0)).abs() < EPS,
2798 "P2-i: obj*=-4, got {}",
2799 r.objective_value
2800 );
2801 }
2802
2803 #[test]
2805 fn test_p2i_nan_quad_alone_is_invalid() {
2806 let mut model = Model::new("p2i_nan_alone");
2807 let x = model.add_var("x", 0.0, f64::INFINITY);
2808 model.minimize(f64::NAN * (x * x));
2809 let result = model.solve();
2810 assert!(
2811 matches!(result, Err(ModelError::InvalidInput(_))),
2812 "P2-i: NaN quad alone must give InvalidInput, got {result:?}"
2813 );
2814 }
2815
2816 #[test]
2818 fn test_p2i_setter_ordering_table() {
2819 type Setup = fn(&mut Model, crate::variable::Variable);
2821 let cases: &[(&str, Setup, bool)] = &[
2822 (
2824 "nan→valid_quad",
2825 |m, x| {
2826 m.minimize(f64::NAN * (x * x));
2827 m.minimize(x * x + (-4.0) * x);
2828 },
2829 true,
2830 ),
2831 (
2833 "nan→nan→valid_quad",
2834 |m, x| {
2835 m.minimize(f64::NAN * (x * x));
2836 m.minimize(f64::NAN * (x * x));
2837 m.minimize(x * x + (-4.0) * x);
2838 },
2839 true,
2840 ),
2841 (
2843 "valid→nan",
2844 |m, x| {
2845 m.minimize(x * x + (-4.0) * x);
2846 m.minimize(f64::NAN * (x * x));
2847 },
2848 false,
2849 ),
2850 (
2852 "nan→linear",
2853 |m, x| {
2854 m.minimize(f64::NAN * (x * x));
2855 m.minimize(1.0 * x); },
2857 true,
2858 ),
2859 (
2861 "nan→valid→nan",
2862 |m, x| {
2863 m.minimize(f64::NAN * (x * x));
2864 m.minimize(x * x + (-4.0) * x);
2865 m.minimize(f64::NAN * (x * x));
2866 },
2867 false,
2868 ),
2869 ];
2870
2871 for &(label, setup, expect_optimal) in cases {
2872 let mut m = Model::new(label);
2873 let x = m.add_var("x", 0.0, f64::INFINITY);
2874 setup(&mut m, x);
2875 let result = m.solve();
2876 if expect_optimal {
2877 assert!(
2878 result.is_ok(),
2879 "P2-i [{label}]: expected Optimal, got {result:?}"
2880 );
2881 } else {
2882 assert!(
2883 matches!(result, Err(ModelError::InvalidInput(_))),
2884 "P2-i [{label}]: expected InvalidInput, got {result:?}"
2885 );
2886 }
2887 }
2888 }
2889
2890 #[test]
2893 fn test_sentinel_obj_offset_non_finite_rejected() {
2894 for &(label, bad_offset) in &[
2895 ("nan_offset", f64::NAN),
2896 ("inf_offset", f64::INFINITY),
2897 ("neg_inf_offset", f64::NEG_INFINITY),
2898 ] {
2899 let mut m = Model::new(label);
2900 let x = m.add_var("x", 0.0, f64::INFINITY);
2901 m.minimize(x);
2902 m.set_obj_offset(bad_offset);
2903 assert!(
2904 matches!(m.solve(), Err(ModelError::InvalidInput(_))),
2905 "[{label}]: non-finite obj_offset must be rejected at solve, got Ok"
2906 );
2907 }
2908 }
2909}