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 let Some(q) = &self.quadratic_objective {
314 if let Some(&v) = q.values().iter().find(|v| !v.is_finite()) {
315 return Err(ModelError::InvalidInput(format!(
316 "objective: non-finite Q coefficient: {v}"
317 )));
318 }
319 }
320 Ok(())
321 }
322
323 fn install_quad(&mut self, q: CscMatrix) {
325 self.quadratic_objective = Some(q);
326 self.quad_dsl_error = None;
327 }
328
329 fn fail_dsl_quad(&mut self, msg: String) {
332 self.quadratic_objective = None;
333 self.quad_dsl_error = Some(msg);
334 }
335
336 fn replace_with_linear_objective(&mut self) {
338 self.quadratic_objective = None;
339 }
340
341 fn find_foreign_var_error(&self, q: &QuadExpr) -> Option<String> {
345 if let Some(&v) = q
349 .linear
350 .coefficients
351 .keys()
352 .find(|v| v.model_id != self.model_id)
353 {
354 return Some(format!(
355 "linear term (var {}) belongs to model {}, not this model ({})",
356 v.index, v.model_id, self.model_id
357 ));
358 }
359 if let Some(&(va, vb)) = q
361 .quad
362 .keys()
363 .find(|(va, vb)| va.model_id != self.model_id || vb.model_id != self.model_id)
364 {
365 return Some(format!(
366 "quad term ({},{}) belongs to model(s) {}/{}, not this model ({})",
367 va.index, vb.index, va.model_id, vb.model_id, self.model_id
368 ));
369 }
370 None
371 }
372
373 fn apply_objective(&mut self, q: QuadExpr, sense: OptimizationSense) -> &mut Self {
375 self.quad_dsl_error = None;
380 if let Some(msg) = self.find_foreign_var_error(&q) {
384 self.fail_dsl_quad(msg);
385 } else if !q.quad.is_empty() {
386 match quad_to_csc(&q.quad, self.variables.len()) {
387 Ok(csc) => self.install_quad(csc),
388 Err(msg) => self.fail_dsl_quad(msg),
389 }
390 } else {
391 self.replace_with_linear_objective();
392 }
393 self.obj_expr_constant = q.linear.constant;
397 self.objective = Some(q.linear);
398 self.sense = sense;
399 self
400 }
401
402 pub fn set_obj_offset(&mut self, offset: f64) -> &mut Self {
405 self.obj_offset = offset;
406 self
407 }
408
409 pub fn solve(&mut self) -> Result<ModelResult, ModelError> {
415 if let Some(msg) = self.invalid_inputs.values().next() {
416 return Err(ModelError::InvalidInput(msg.clone()));
417 }
418
419 let obj_expr = self.objective.as_ref().ok_or(ModelError::NoObjective)?;
420
421 self.validate_objective()?;
426
427 let num_vars = self.variables.len();
428 let num_constraints = self.constraints.len();
429
430 let mid = self.model_id;
432 let mut c: Vec<f64> = (0..num_vars)
433 .map(|i| {
434 obj_expr.coefficient(Variable {
435 index: i,
436 model_id: mid,
437 })
438 })
439 .collect();
440
441 if self.sense == OptimizationSense::Maximize {
443 for ci in &mut c {
444 *ci = -*ci;
445 }
446 }
447
448 let mut trip_rows: Vec<usize> = Vec::new();
450 let mut trip_cols: Vec<usize> = Vec::new();
451 let mut trip_vals: Vec<f64> = Vec::new();
452 let mut b: Vec<f64> = Vec::new();
453 let mut constraint_types: Vec<ConstraintType> = Vec::new();
454
455 for (i, con) in self.constraints.iter().enumerate() {
456 for (&var, &coeff) in &con.lhs.coefficients {
457 trip_rows.push(i);
458 trip_cols.push(var.index);
459 trip_vals.push(coeff);
460 }
461 b.push(con.rhs);
463 constraint_types.push(match con.sense {
464 ConstraintSense::Le => ConstraintType::Le,
465 ConstraintSense::Ge => ConstraintType::Ge,
466 ConstraintSense::Eq => ConstraintType::Eq,
467 });
468 }
469
470 let a = if num_constraints == 0 {
472 CscMatrix::new(0, num_vars)
473 } else {
474 CscMatrix::from_triplets(
475 &trip_rows,
476 &trip_cols,
477 &trip_vals,
478 num_constraints,
479 num_vars,
480 )
481 .map_err(|e| ModelError::Internal(e.to_string()))?
482 };
483
484 let bounds: Vec<(f64, f64)> = self
486 .variables
487 .iter()
488 .map(|v| (v.lower_bound, v.upper_bound))
489 .collect();
490
491 let integer_vars: Vec<usize> = self
494 .variables
495 .iter()
496 .enumerate()
497 .filter(|(_, v)| v.kind != VarKind::Continuous)
498 .map(|(i, _)| i)
499 .collect();
500 if !integer_vars.is_empty() {
501 return self.solve_mip_internal(c, a, b, constraint_types, bounds, integer_vars);
502 }
503
504 if let Some(ref q_orig) = self.quadratic_objective.clone() {
506 return self.solve_qp_internal(c, bounds, q_orig.clone(), num_constraints);
507 }
508
509 let problem = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
511 .map_err(map_lp_build_err)?;
512
513 let mut lp_opts = otspot_core::options::SolverOptions::default();
514 if let Some(t) = self.timeout_secs {
515 lp_opts.timeout_secs = Some(t);
516 }
517 if let Some(tol) = self.tolerance {
518 lp_opts.tolerance = Some(tol);
519 }
520 if let Some(flag) = self.presolve {
521 lp_opts.presolve = flag;
522 }
523 if let Some(n) = self.threads {
524 lp_opts.threads = n;
525 }
526 let solver_result = otspot_core::lp::solve_lp_with(&problem, &lp_opts);
527
528 let lp_extras = |sr: &otspot_core::problem::SolverResult| {
531 let dual = if sr.dual_solution.is_empty() {
532 None
533 } else {
534 Some(sr.dual_solution.clone())
535 };
536 let rc = if sr.reduced_costs.is_empty() {
537 None
538 } else {
539 Some(sr.reduced_costs.clone())
540 };
541 let slack = if sr.slack.is_empty() {
542 None
543 } else {
544 Some(sr.slack.clone())
545 };
546 (dual, rc, slack)
547 };
548
549 let signed_obj = |raw: f64| -> f64 {
550 let oriented = if self.sense == OptimizationSense::Maximize {
551 -raw
552 } else {
553 raw
554 };
555 oriented + self.obj_offset + self.obj_expr_constant
556 };
557 let lp_model_id = self.model_id;
558 let build_ok = |sr: otspot_core::problem::SolverResult| {
559 let (dual, rc, slack) = lp_extras(&sr);
560 let status = sr.status.clone();
561 ModelResult {
562 model_id: lp_model_id,
563 status: status.clone(),
564 proof: SolutionProof::from_status(&status),
565 objective_value: signed_obj(sr.objective),
566 solution: sr.solution,
567 dual_solution: dual,
568 reduced_costs: rc,
569 slack,
570 bound_duals: sr.bound_duals,
571 stats: sr.stats,
572 }
573 };
574
575 match solver_result.status {
576 SolveStatus::Optimal => Ok(build_ok(solver_result)),
577 SolveStatus::MaxIterations => {
578 if solver_result.solution.is_empty() {
579 Err(ModelError::SolveError(SolveError::MaxIterations))
580 } else {
581 Ok(build_ok(solver_result))
582 }
583 }
584 SolveStatus::SuboptimalSolution => {
585 if solver_result.solution.is_empty() {
586 Err(ModelError::Timeout)
587 } else {
588 Ok(build_ok(solver_result))
589 }
590 }
591 SolveStatus::Timeout => Err(ModelError::Timeout),
592 SolveStatus::LocallyOptimal
593 | SolveStatus::NonconvexLocal
594 | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
595 "Unexpected nonconvex status on LP path".to_string(),
596 )),
597 s => Err(classify_status_error(s)
598 .unwrap_or_else(|| ModelError::Internal("unhandled LP status".to_string()))),
599 }
600 }
601
602 fn build_qp_problem(
606 &self,
607 c: Vec<f64>,
608 bounds: Vec<(f64, f64)>,
609 q_orig: CscMatrix,
610 ) -> Result<otspot_core::qp::QpProblem, ModelError> {
611 use otspot_core::qp::QpProblem;
612
613 let num_vars = self.variables.len();
614
615 let qp_q = if self.sense == OptimizationSense::Maximize {
617 q_orig.scale_values(-1.0)
618 } else {
619 q_orig
620 };
621
622 let mut qp_trip_rows: Vec<usize> = Vec::new();
625 let mut qp_trip_cols: Vec<usize> = Vec::new();
626 let mut qp_trip_vals: Vec<f64> = Vec::new();
627 let mut qp_b: Vec<f64> = Vec::new();
628 let mut qp_constraint_types: Vec<ConstraintType> = Vec::new();
629 let mut qp_row = 0usize;
630
631 for con in &self.constraints {
632 for (&var, &coeff) in &con.lhs.coefficients {
633 qp_trip_rows.push(qp_row);
634 qp_trip_cols.push(var.index);
635 qp_trip_vals.push(coeff);
636 }
637 qp_b.push(con.rhs);
638 qp_constraint_types.push(match con.sense {
639 ConstraintSense::Le => ConstraintType::Le,
640 ConstraintSense::Ge => ConstraintType::Ge,
641 ConstraintSense::Eq => ConstraintType::Eq,
642 });
643 qp_row += 1;
644 }
645
646 let m_qp = qp_row;
647 let qp_a = if m_qp == 0 {
648 CscMatrix::new(0, num_vars)
649 } else {
650 CscMatrix::from_triplets(&qp_trip_rows, &qp_trip_cols, &qp_trip_vals, m_qp, num_vars)
651 .map_err(|e| ModelError::Internal(e.to_string()))?
652 };
653
654 let mut qp_problem = QpProblem::new(qp_q, c, qp_a, qp_b, bounds, qp_constraint_types)
655 .map_err(map_qp_build_err)?;
656 qp_problem.obj_offset = 0.0;
658 Ok(qp_problem)
659 }
660
661 fn solve_qp_internal(
663 &self,
664 c: Vec<f64>,
665 bounds: Vec<(f64, f64)>,
666 q_orig: CscMatrix,
667 num_model_constraints: usize,
668 ) -> Result<ModelResult, ModelError> {
669 let qp_problem = self.build_qp_problem(c, bounds, q_orig)?;
670
671 let mut opts = otspot_core::options::SolverOptions::default();
672 if let Some(t) = self.timeout_secs {
673 opts.timeout_secs = Some(t);
674 }
675 if let Some(tol) = self.tolerance {
676 opts.tolerance = Some(tol);
677 }
678 if let Some(n) = self.threads {
679 opts.threads = n;
680 }
681 let qp_result = otspot_core::qp::solve_qp_with(&qp_problem, &opts);
682 let qp_stats = qp_result.stats.clone();
683
684 let fold_dual = |sol: &[f64]| -> Option<Vec<f64>> {
686 if sol.len() == num_model_constraints {
687 Some(sol.to_vec())
688 } else if !sol.is_empty() && num_model_constraints > 0 {
689 let take = num_model_constraints.min(sol.len());
690 Some(sol[..take].to_vec())
691 } else {
692 None
693 }
694 };
695
696 let signed_obj = |raw: f64| -> f64 {
697 let oriented = if self.sense == OptimizationSense::Maximize {
698 -raw
699 } else {
700 raw
701 };
702 oriented + self.obj_offset + self.obj_expr_constant
703 };
704 let qp_model_id = self.model_id;
705 let build_ok = |status: SolveStatus,
706 raw_obj: f64,
707 sol: Vec<f64>,
708 dual: Option<Vec<f64>>,
709 bd: Vec<f64>| {
710 let proof = SolutionProof::from_status(&status);
711 ModelResult {
712 model_id: qp_model_id,
713 status,
714 proof,
715 objective_value: signed_obj(raw_obj),
716 solution: sol,
717 dual_solution: dual,
718 reduced_costs: None,
719 slack: None,
720 bound_duals: bd,
721 stats: qp_stats.clone(),
722 }
723 };
724
725 match qp_result.status {
726 SolveStatus::Optimal => Ok(build_ok(
727 SolveStatus::Optimal,
728 qp_result.objective,
729 qp_result.solution,
730 fold_dual(&qp_result.dual_solution),
731 qp_result.bound_duals,
732 )),
733 SolveStatus::MaxIterations => {
734 if qp_result.solution.is_empty() {
735 Err(ModelError::SolveError(SolveError::MaxIterations))
736 } else {
737 Ok(build_ok(
738 SolveStatus::MaxIterations,
739 qp_result.objective,
740 qp_result.solution,
741 fold_dual(&qp_result.dual_solution),
742 qp_result.bound_duals,
743 ))
744 }
745 }
746 SolveStatus::SuboptimalSolution => {
747 if qp_result.solution.is_empty() {
749 Err(ModelError::Timeout)
750 } else {
751 Ok(build_ok(
752 SolveStatus::SuboptimalSolution,
753 qp_result.objective,
754 qp_result.solution,
755 fold_dual(&qp_result.dual_solution),
756 qp_result.bound_duals,
757 ))
758 }
759 }
760 SolveStatus::Timeout => Err(ModelError::Timeout),
761 status @ (SolveStatus::LocallyOptimal
764 | SolveStatus::NonconvexLocal
765 | SolveStatus::NonconvexGlobal) => Ok(build_ok(
766 status,
767 qp_result.objective,
768 qp_result.solution,
769 fold_dual(&qp_result.dual_solution),
770 qp_result.bound_duals,
771 )),
772 s => Err(classify_status_error(s)
773 .unwrap_or_else(|| ModelError::Internal("unhandled QP status".to_string()))),
774 }
775 }
776
777 fn solve_mip_internal(
783 &self,
784 c: Vec<f64>,
785 a: CscMatrix,
786 b: Vec<f64>,
787 constraint_types: Vec<ConstraintType>,
788 bounds: Vec<(f64, f64)>,
789 integer_vars: Vec<usize>,
790 ) -> Result<ModelResult, ModelError> {
791 let mut opts = otspot_core::options::SolverOptions::default();
792 if let Some(t) = self.timeout_secs {
793 opts.timeout_secs = Some(t);
794 }
795 if let Some(tol) = self.tolerance {
796 opts.tolerance = Some(tol);
797 }
798 if let Some(n) = self.threads {
799 opts.threads = n;
800 }
801 let cfg = otspot_core::options::MipConfig::default();
802
803 let result = if let Some(ref q_orig) = self.quadratic_objective.clone() {
804 let qp = self.build_qp_problem(c, bounds, q_orig.clone())?;
806 let miqp = otspot_core::mip::MiqpProblem::new(qp, integer_vars.clone())
807 .map_err(|e| ModelError::Internal(e.to_string()))?;
808 otspot_core::mip::solve_miqp(&miqp, &opts, &cfg)
809 } else {
810 if let Some(flag) = self.presolve {
812 opts.presolve = flag;
813 }
814 let lp = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
815 .map_err(map_lp_build_err)?;
816 let milp = otspot_core::mip::MilpProblem::new(lp, integer_vars.clone())
817 .map_err(|e| ModelError::Internal(e.to_string()))?;
818 otspot_core::mip::solve_milp(&milp, &opts, &cfg)
819 };
820
821 self.finish_mip(result, &integer_vars)
822 }
823
824 fn finish_mip(
827 &self,
828 result: otspot_core::problem::SolverResult,
829 integer_vars: &[usize],
830 ) -> Result<ModelResult, ModelError> {
831 let signed_obj = |raw: f64| -> f64 {
832 let oriented = if self.sense == OptimizationSense::Maximize {
833 -raw
834 } else {
835 raw
836 };
837 oriented + self.obj_offset + self.obj_expr_constant
838 };
839
840 let round_integers = |mut sol: Vec<f64>| -> Vec<f64> {
842 for &j in integer_vars {
843 if j < sol.len() {
844 sol[j] = sol[j].round();
845 }
846 }
847 sol
848 };
849
850 let mip_model_id = self.model_id;
851 let build_ok = |sr: otspot_core::problem::SolverResult| {
852 let status = sr.status.clone();
853 ModelResult {
854 model_id: mip_model_id,
855 status: status.clone(),
856 proof: SolutionProof::from_status(&status),
857 objective_value: signed_obj(sr.objective),
858 solution: round_integers(sr.solution),
859 dual_solution: None,
860 reduced_costs: None,
861 slack: None,
862 bound_duals: vec![],
863 stats: sr.stats,
864 }
865 };
866
867 match result.status {
868 SolveStatus::Optimal => Ok(build_ok(result)),
869 SolveStatus::Timeout => {
870 if result.solution.is_empty() {
872 Err(ModelError::Timeout)
873 } else {
874 Ok(build_ok(result))
875 }
876 }
877 SolveStatus::SuboptimalSolution | SolveStatus::MaxIterations => {
878 if result.solution.is_empty() {
879 Err(ModelError::SolveError(SolveError::MaxIterations))
880 } else {
881 Ok(build_ok(result))
882 }
883 }
884 SolveStatus::LocallyOptimal
885 | SolveStatus::NonconvexLocal
886 | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
887 "Unexpected nonconvex status on MIP path".to_string(),
888 )),
889 s => Err(classify_status_error(s)
890 .unwrap_or_else(|| ModelError::Internal("unhandled MIP status".to_string()))),
891 }
892 }
893
894 fn record_input_error(&mut self, key: &'static str, err: ModelError) {
895 let msg = match err {
896 ModelError::InvalidInput(msg) => msg,
897 other => other.to_string(),
898 };
899 self.invalid_inputs.insert(key, msg);
900 }
901}
902
903fn classify_status_error(status: SolveStatus) -> Option<ModelError> {
911 match status {
912 SolveStatus::Infeasible => Some(ModelError::SolveError(SolveError::Infeasible)),
913 SolveStatus::Unbounded => Some(ModelError::SolveError(SolveError::Unbounded)),
914 SolveStatus::NumericalError => Some(ModelError::SolveError(SolveError::NumericalError)),
915 SolveStatus::NonConvex(msg) => Some(ModelError::NonConvex(msg)),
916 SolveStatus::NotSupported(msg) => Some(ModelError::NotSupported(msg)),
917 SolveStatus::Optimal
920 | SolveStatus::LocallyOptimal
921 | SolveStatus::MaxIterations
922 | SolveStatus::SuboptimalSolution
923 | SolveStatus::Timeout
924 | SolveStatus::NonconvexLocal
925 | SolveStatus::NonconvexGlobal => None,
926 _ => None,
933 }
934}
935
936fn validate_timeout(secs: f64) -> Result<(), ModelError> {
937 if secs.is_finite() && secs >= 0.0 {
938 Ok(())
939 } else {
940 Err(ModelError::InvalidInput(format!(
941 "timeout must be finite and non-negative, got {secs}"
942 )))
943 }
944}
945
946fn map_lp_build_err(e: otspot_core::error::SolverError) -> ModelError {
951 use otspot_core::error::SolverError;
952 match e {
953 SolverError::NonFiniteCoefficient { .. } | SolverError::InvalidBounds { .. } => {
954 ModelError::InvalidInput(e.to_string())
955 }
956 _ => ModelError::Internal(e.to_string()),
957 }
958}
959
960fn map_qp_build_err(e: otspot_core::qp::QpProblemError) -> ModelError {
962 use otspot_core::qp::QpProblemError;
963 match e {
964 QpProblemError::NonFiniteCoefficient { .. }
965 | QpProblemError::InvalidBounds { .. }
966 | QpProblemError::TripletIndexOutOfBounds { .. } => ModelError::InvalidInput(e.to_string()),
967 QpProblemError::DimensionMismatch(_) => ModelError::Internal(e.to_string()),
968 _ => ModelError::Internal(e.to_string()),
970 }
971}
972
973fn validate_bounds(lb: f64, ub: f64) -> Result<(), ModelError> {
974 if lb.is_nan() || ub.is_nan() {
975 return Err(ModelError::InvalidInput(format!(
976 "variable bounds must not be NaN: lb={lb}, ub={ub}"
977 )));
978 }
979 if lb > ub {
980 return Err(ModelError::InvalidInput(format!(
981 "variable lower bound {lb} exceeds upper bound {ub}"
982 )));
983 }
984 Ok(())
985}
986
987#[non_exhaustive]
993#[derive(Debug, Clone, Copy, PartialEq, Eq)]
994pub enum SolutionProof {
995 GlobalOptimal,
997 LocalOptimal,
999 FeasibleUnproven,
1001}
1002
1003impl SolutionProof {
1004 fn from_status(status: &SolveStatus) -> Self {
1005 match status {
1006 SolveStatus::Optimal | SolveStatus::NonconvexGlobal => SolutionProof::GlobalOptimal,
1007 SolveStatus::LocallyOptimal => SolutionProof::LocalOptimal,
1008 SolveStatus::MaxIterations
1009 | SolveStatus::SuboptimalSolution
1010 | SolveStatus::Timeout
1011 | SolveStatus::NonconvexLocal => SolutionProof::FeasibleUnproven,
1012 SolveStatus::Infeasible
1016 | SolveStatus::Unbounded
1017 | SolveStatus::NumericalError
1018 | SolveStatus::NonConvex(_)
1019 | SolveStatus::NotSupported(_) => {
1020 debug_assert!(
1021 false,
1022 "from_status called with error status {:?}: this arm is unreachable from build_ok",
1023 status
1024 );
1025 SolutionProof::FeasibleUnproven
1026 }
1027 _ => SolutionProof::FeasibleUnproven,
1029 }
1030 }
1031}
1032
1033#[derive(Debug, Clone)]
1035pub struct ModelResult {
1036 model_id: u64,
1037 pub status: SolveStatus,
1044 pub proof: SolutionProof,
1046 pub objective_value: f64,
1048 solution: Vec<f64>,
1050 pub dual_solution: Option<Vec<f64>>,
1052 pub reduced_costs: Option<Vec<f64>>,
1060 pub slack: Option<Vec<f64>>,
1062 pub bound_duals: Vec<f64>,
1066 pub stats: otspot_core::problem::SolveStats,
1068}
1069
1070#[cfg(test)]
1074impl Model {
1075 pub(crate) fn has_quad_dsl_error(&self) -> bool {
1078 self.quad_dsl_error.is_some()
1079 }
1080
1081 pub(crate) fn is_quad_via_dsl(&self) -> bool {
1083 self.quadratic_objective.is_some()
1084 }
1085
1086 pub(crate) fn has_quadratic_objective(&self) -> bool {
1088 self.quadratic_objective.is_some()
1089 }
1090}
1091
1092impl ModelResult {
1093 pub fn value(&self, var: Variable) -> f64 {
1099 self.solution[var.index]
1100 }
1101
1102 pub fn try_value(&self, var: Variable) -> Result<f64, ModelError> {
1108 if var.model_id != self.model_id {
1109 return Err(ModelError::InvalidInput(
1110 "variable belongs to a different model".to_string(),
1111 ));
1112 }
1113 self.solution.get(var.index).copied().ok_or_else(|| {
1114 ModelError::InvalidInput(format!(
1115 "variable index {} out of range (solution length {})",
1116 var.index,
1117 self.solution.len()
1118 ))
1119 })
1120 }
1121
1122 pub fn objective(&self) -> f64 {
1124 self.objective_value
1125 }
1126
1127 pub fn has_global_optimality_proof(&self) -> bool {
1129 self.proof == SolutionProof::GlobalOptimal
1130 }
1131}
1132
1133impl Index<Variable> for ModelResult {
1145 type Output = f64;
1146 fn index(&self, var: Variable) -> &f64 {
1147 &self.solution[var.index]
1148 }
1149}
1150
1151#[non_exhaustive]
1157#[derive(Debug, Clone, PartialEq)]
1158pub enum SolveError {
1159 Infeasible,
1161 Unbounded,
1163 MaxIterations,
1165 NumericalError,
1167}
1168
1169impl fmt::Display for SolveError {
1170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1171 match self {
1172 SolveError::Infeasible => write!(f, "Problem is infeasible"),
1173 SolveError::Unbounded => write!(f, "Problem is unbounded"),
1174 SolveError::MaxIterations => {
1175 write!(f, "Max iterations reached without optimal solution")
1176 }
1177 SolveError::NumericalError => write!(f, "Numerical breakdown during solve"),
1178 }
1179 }
1180}
1181
1182#[non_exhaustive]
1184#[derive(Debug)]
1185pub enum ModelError {
1186 NoObjective,
1188 InvalidInput(String),
1190 SolveError(SolveError),
1192 Timeout,
1194 NonConvex(String),
1197 NotSupported(String),
1199 Internal(String),
1201}
1202
1203impl fmt::Display for ModelError {
1204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1205 match self {
1206 ModelError::NoObjective => write!(
1207 f,
1208 "No objective function defined. Call model.minimize() or model.maximize() before solve()."
1209 ),
1210 ModelError::InvalidInput(msg) => write!(f, "Invalid model input: {}", msg),
1211 ModelError::SolveError(e) => write!(f, "Solve failed: {}", e),
1212 ModelError::Timeout => write!(f, "Solver timed out"),
1213 ModelError::NonConvex(msg) => write!(f, "Non-convex problem: {}", msg),
1214 ModelError::NotSupported(msg) => write!(f, "Not supported: {}", msg),
1215 ModelError::Internal(msg) => write!(f, "Internal error: {}", msg),
1216 }
1217 }
1218}
1219
1220impl std::error::Error for ModelError {}
1221
1222#[cfg(test)]
1227mod tests {
1228 use super::{classify_status_error, Model, ModelError, SolutionProof, SolveError};
1229 use crate::variable::Variable;
1230 use otspot_core::problem::SolveStatus;
1231
1232 const EPS: f64 = 2e-3;
1234
1235 fn assert_close(a: f64, b: f64, name: &str) {
1236 assert!(
1237 (a - b).abs() < EPS,
1238 "{}: expected {:.8}, got {:.8}",
1239 name,
1240 b,
1241 a
1242 );
1243 }
1244
1245 fn basic_model() -> (Model, Variable, Variable) {
1252 let mut model = Model::new("basic");
1253 let x = model.add_var("x", 0.0, f64::INFINITY);
1254 let y = model.add_var("y", 0.0, 10.0);
1255 model.add_constraint((2.0 * x + 3.0 * y).leq(12.0));
1257 model.add_constraint((x + y).geq(3.0));
1258 model.minimize(x + 2.0 * y);
1259 (model, x, y)
1260 }
1261
1262 #[test]
1263 fn test_basic_lp_3var_3con() {
1264 let mut model = Model::new("3var");
1270 let x = model.add_var("x", 0.0, f64::INFINITY);
1271 let y = model.add_var("y", 0.0, f64::INFINITY);
1272 let z = model.add_var("z", 0.0, f64::INFINITY);
1273
1274 model.add_constraint((x + y + z).geq(6.0));
1276 model.add_constraint((x + 2.0 * y).leq(10.0));
1277 model.add_constraint((y + z).geq(4.0));
1278 model.minimize(x + 2.0 * y + 3.0 * z);
1279
1280 let result = model.solve().unwrap();
1281 assert!(result[x] + result[y] + result[z] >= 6.0 - 1e-6);
1283 assert!(result[y] + result[z] >= 4.0 - 1e-6);
1284 assert!(result[x] >= -1e-9);
1285 assert!(result[y] >= -1e-9);
1286 assert!(result[z] >= -1e-9);
1287 assert!(result.objective_value > 0.0, "objective should be positive");
1288 }
1289
1290 #[test]
1291 fn test_unbounded() {
1292 let mut model = Model::new("unbounded");
1294 let x = model.add_var("x", 0.0, f64::INFINITY);
1295 model.minimize(-1.0 * x);
1296
1297 let err = model.solve().unwrap_err();
1298 assert!(
1299 matches!(err, ModelError::SolveError(SolveError::Unbounded)),
1300 "expected Unbounded, got {:?}",
1301 err
1302 );
1303 }
1304
1305 #[test]
1306 fn test_infeasible() {
1307 let mut model = Model::new("infeasible");
1309 let x = model.add_var("x", 0.0, f64::INFINITY);
1310 model.add_constraint(crate::constraint!(x >= 5.0));
1312 model.add_constraint(crate::constraint!(x <= 3.0));
1313 model.minimize(x);
1314
1315 let err = model.solve().unwrap_err();
1316 assert!(
1317 matches!(err, ModelError::SolveError(SolveError::Infeasible)),
1318 "expected Infeasible, got {:?}",
1319 err
1320 );
1321 }
1322
1323 #[test]
1324 fn test_equality_constraint() {
1325 let mut model = Model::new("eq");
1328 let x = model.add_var("x", 0.0, f64::INFINITY);
1329 let y = model.add_var("y", 0.0, f64::INFINITY);
1330 model.add_constraint((x + y).eq_constraint(5.0));
1332 model.minimize(x + y);
1333
1334 let result = model.solve().unwrap();
1335 assert!(
1336 (result.objective_value - 5.0).abs() < 1e-6,
1337 "obj={} expected 5.0",
1338 result.objective_value
1339 );
1340 }
1341
1342 #[test]
1343 fn test_variable_bounds() {
1344 let mut model = Model::new("bounds");
1347 let x = model.add_var("x", 0.0, 3.0);
1348 model.minimize(x);
1349
1350 let result = model.solve().unwrap();
1351 assert!(result[x].abs() < 1e-6, "x should be 0.0, got {}", result[x]);
1352
1353 let mut model2 = Model::new("bounds_max");
1357 let x2 = model2.add_var("x", 0.0, 3.0);
1358 model2.add_constraint(crate::constraint!(x2 <= 3.0));
1359 model2.maximize(x2);
1360
1361 let result2 = model2.solve().unwrap();
1362 assert!(
1363 (result2[x2] - 3.0).abs() < 1e-6,
1364 "x should be 3.0, got {}",
1365 result2[x2]
1366 );
1367 }
1368
1369 #[test]
1370 fn test_no_objective_error() {
1371 let mut model = Model::new("no_obj");
1372 let _x = model.add_var("x", 0.0, f64::INFINITY);
1373 let err = model.solve().unwrap_err();
1374 assert!(matches!(err, ModelError::NoObjective));
1375 }
1376
1377 #[test]
1378 fn solution_proof_mapping_preserves_unproven_statuses() {
1379 assert_eq!(
1380 SolutionProof::from_status(&SolveStatus::Optimal),
1381 SolutionProof::GlobalOptimal
1382 );
1383 assert_eq!(
1384 SolutionProof::from_status(&SolveStatus::NonconvexGlobal),
1385 SolutionProof::GlobalOptimal
1386 );
1387 assert_eq!(
1388 SolutionProof::from_status(&SolveStatus::LocallyOptimal),
1389 SolutionProof::LocalOptimal
1390 );
1391 assert_eq!(
1392 SolutionProof::from_status(&SolveStatus::NonconvexLocal),
1393 SolutionProof::FeasibleUnproven
1394 );
1395 assert_eq!(
1396 SolutionProof::from_status(&SolveStatus::Timeout),
1397 SolutionProof::FeasibleUnproven
1398 );
1399 assert_eq!(
1400 SolutionProof::from_status(&SolveStatus::SuboptimalSolution),
1401 SolutionProof::FeasibleUnproven
1402 );
1403 }
1404
1405 #[test]
1406 fn test_result_index_and_value_agree() {
1407 let (mut model, x, y) = basic_model();
1408 let result = model.solve().unwrap();
1409 assert!((result[x] - result.value(x)).abs() < 1e-12);
1410 assert!((result[y] - result.value(y)).abs() < 1e-12);
1411 }
1412
1413 #[test]
1414 fn test_maximize() {
1415 let mut model = Model::new("max_simple");
1417 let x = model.add_var("x", 0.0, f64::INFINITY);
1418 model.add_constraint(crate::constraint!(x <= 7.0));
1420 model.maximize(x);
1421
1422 let result = model.solve().unwrap();
1423 assert!(
1424 (result[x] - 7.0).abs() < 1e-6,
1425 "expected x=7, got {}",
1426 result[x]
1427 );
1428 assert!(
1429 (result.objective() - 7.0).abs() < 1e-6,
1430 "expected obj=7, got {}",
1431 result.objective()
1432 );
1433 }
1434
1435 #[test]
1436 fn test_model_qp_basic() {
1437 let mut model = Model::new("qp_basic");
1440 let x = model.add_var("x", 0.0, f64::INFINITY);
1441 let y = model.add_var("y", 0.0, f64::INFINITY);
1442 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1443
1444 let result = model.solve().unwrap();
1445 assert_close(result[x], 2.0, "T9: x");
1446 assert_close(result[y], 2.0, "T9: y");
1447 assert_close(result.objective_value, -8.0, "T9: obj");
1449 }
1450
1451 #[test]
1452 fn test_model_qp_equality() {
1453 let mut model = Model::new("qp_eq");
1456 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1457 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1458 model.add_constraint((x + y).eq_constraint(1.0));
1459 model.minimize(x * x + y * y);
1460
1461 let result = model.solve().unwrap();
1462 assert_close(result[x], 0.5, "T10: x");
1463 assert_close(result[y], 0.5, "T10: y");
1464 assert_close(result.objective_value, 0.5, "T10: obj");
1465 }
1466
1467 #[test]
1468 fn test_model_qp_ge_constraint() {
1469 let mut model = Model::new("qp_ge");
1472 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1473 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1474 model.add_constraint((x + y).geq(1.0));
1475 model.minimize(x * x + y * y);
1476
1477 let result = model.solve().unwrap();
1478 let qp_tol = 2e-3;
1479 assert!(
1480 (result[x] - 0.5).abs() < qp_tol,
1481 "T11: x expected 0.5, got {}",
1482 result[x]
1483 );
1484 assert!(
1485 (result[y] - 0.5).abs() < qp_tol,
1486 "T11: y expected 0.5, got {}",
1487 result[y]
1488 );
1489 assert!(
1490 (result.objective_value - 0.5).abs() < qp_tol,
1491 "T11: obj expected 0.5, got {}",
1492 result.objective_value
1493 );
1494 }
1495
1496 #[test]
1497 fn test_model_qp_maximize() {
1498 let mut model = Model::new("qp_maximize");
1501 let x = model.add_var("x", 0.0, f64::INFINITY);
1502 let y = model.add_var("y", 0.0, f64::INFINITY);
1503 model.add_constraint((x + y).geq(1.0));
1504 model.maximize((-1.0) * x * x + (-1.0) * y * y);
1505
1506 let result = model.solve().unwrap();
1507 assert_close(result[x], 0.5, "T12: x");
1508 assert_close(result[y], 0.5, "T12: y");
1509 assert_close(result.objective_value, -0.5, "T12: obj");
1510 }
1511
1512 #[test]
1513 fn test_model_qp_box_bounds() {
1514 let mut model = Model::new("qp_box");
1518 let x = model.add_var("x", 0.0, 1.0);
1519 let y = model.add_var("y", 0.0, 1.0);
1520 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1521
1522 let result = model.solve().unwrap();
1523 assert_close(result[x], 1.0, "T13: x");
1524 assert_close(result[y], 1.0, "T13: y");
1525 assert_close(result.objective_value, -6.0, "T13: obj");
1526 }
1527
1528 #[test]
1529 fn test_model_qp_timeout() {
1530 let mut model = Model::new("qp_timeout");
1532 let x = model.add_var("x", 0.0, f64::INFINITY);
1533 let y = model.add_var("y", 0.0, f64::INFINITY);
1534 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1535 model.set_timeout(0.000_001); let err = model.solve().unwrap_err();
1538 assert!(
1539 matches!(err, ModelError::Timeout),
1540 "expected Timeout, got {:?}",
1541 err
1542 );
1543 }
1544
1545 #[test]
1549 fn test_model_lp_equality() {
1550 let mut model = Model::new("lp_eq");
1553 let x = model.add_var("x", 0.0, f64::INFINITY);
1554 let y = model.add_var("y", 0.0, f64::INFINITY);
1555 model.add_constraint((x + y).eq_constraint(4.0));
1556 model.minimize(x + 2.0 * y);
1557
1558 let result = model.solve().unwrap();
1559 assert_close(result.objective_value, 4.0, "T8-1: obj");
1560 assert_close(result[x] + result[y], 4.0, "T8-1: x+y=4");
1562 }
1563
1564 #[test]
1568 fn test_model_eq_dual_solution() {
1569 let mut model = Model::new("qp_eq_dual");
1573 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1574 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1575 model.add_constraint((x + y).eq_constraint(1.0));
1576 model.minimize(x * x + y * y);
1577
1578 let result = model.solve().unwrap();
1579 assert_close(result.objective_value, 0.5, "T8-2: obj");
1580 assert_close(result[x], 0.5, "T8-2: x");
1581 assert_close(result[y], 0.5, "T8-2: y");
1582
1583 let dual = result
1585 .dual_solution
1586 .as_ref()
1587 .expect("T8-2: dual_solution is None");
1588 assert!(
1589 dual.len() == 1,
1590 "T8-2: dual length expected 1, got {}",
1591 dual.len()
1592 );
1593 assert!(
1594 (dual[0] - (-1.0)).abs() < EPS,
1595 "T8-2: dual[0] expected -1.0, got {}",
1596 dual[0]
1597 );
1598 }
1599
1600 #[test]
1607 #[allow(clippy::type_complexity)]
1608 fn test_model_qp_locally_optimal_proof() {
1609 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1611 ("diag(-2,2)", [-2.0, 2.0], (-1.0, 1.0), [0.0, 0.0]),
1613 ("diag(-3,5)", [-3.0, 5.0], (-2.0, 2.0), [-1.0, 0.0]),
1615 ];
1616
1617 for &(name, q_diag, (lb, ub), c) in cases {
1618 let mut model = Model::new(name);
1619 let x = model.add_var("x0", lb, ub);
1620 let y = model.add_var("x1", lb, ub);
1621 model.minimize(
1623 (q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y,
1624 );
1625
1626 let result = model.solve();
1627 match result {
1628 Ok(r) => {
1629 assert_eq!(
1630 r.status,
1631 otspot_core::problem::SolveStatus::LocallyOptimal,
1632 "[{name}] expected LocallyOptimal, got {:?}",
1633 r.status
1634 );
1635 assert_eq!(
1636 r.proof,
1637 SolutionProof::LocalOptimal,
1638 "[{name}] expected LocalOptimal proof, got {:?}",
1639 r.proof
1640 );
1641 assert!(
1642 !r.has_global_optimality_proof(),
1643 "[{name}] has_global_optimality_proof must be false for LocallyOptimal"
1644 );
1645 }
1646 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1647 }
1648 }
1649 }
1650
1651 #[test]
1659 #[allow(clippy::type_complexity)]
1660 fn test_model_qp_feasible_unproven_proof() {
1661 use otspot_core::options::Tolerance;
1662
1663 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1665 (
1668 "convex_2x2_tight_tol",
1669 [2.0, 2.0],
1670 (0.0, f64::INFINITY),
1671 [-4.0, -4.0],
1672 ),
1673 ("convex_box_tight_tol", [4.0, 4.0], (0.0, 3.0), [0.0, -2.0]),
1675 ];
1676
1677 for &(name, q_diag, (lb, ub), c) in cases {
1678 let mut model = Model::new(name);
1679 let x = model.add_var("x0", lb, ub);
1680 let y = model.add_var("x1", lb, ub);
1681 model.minimize(
1682 (q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y,
1683 );
1684 model.set_tolerance(Tolerance::Custom(1e-200));
1687
1688 let result = model.solve();
1689 match result {
1690 Ok(r) => {
1691 assert_eq!(
1692 r.proof,
1693 SolutionProof::FeasibleUnproven,
1694 "[{name}] expected FeasibleUnproven proof, got {:?} (status={:?})",
1695 r.proof,
1696 r.status
1697 );
1698 assert!(
1699 !r.has_global_optimality_proof(),
1700 "[{name}] has_global_optimality_proof must be false for FeasibleUnproven"
1701 );
1702 assert!(
1703 !r.solution.is_empty(),
1704 "[{name}] solution must be non-empty"
1705 );
1706 }
1707 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1708 }
1709 }
1710 }
1711
1712 #[test]
1717 fn classify_status_error_typed_variants() {
1718 let cases_nonconvex = ["indefinite Q: eigenvalue < 0", "non-PSD matrix in MIQP"];
1719 for msg in &cases_nonconvex {
1720 let status = SolveStatus::NonConvex(msg.to_string());
1721 let err = classify_status_error(status).expect("NonConvex must map to Some");
1722 assert!(
1723 matches!(err, ModelError::NonConvex(_)),
1724 "NonConvex status must yield ModelError::NonConvex, got {:?}",
1725 err
1726 );
1727 }
1728
1729 let cases_not_supported = ["QCQP not supported", "constraint type unsupported"];
1730 for msg in &cases_not_supported {
1731 let status = SolveStatus::NotSupported(msg.to_string());
1732 let err = classify_status_error(status).expect("NotSupported must map to Some");
1733 assert!(
1734 matches!(err, ModelError::NotSupported(_)),
1735 "NotSupported status must yield ModelError::NotSupported, got {:?}",
1736 err
1737 );
1738 }
1739 }
1740
1741 #[test]
1747 fn miqp_nonconvex_q_returns_nonconvex_error() {
1748 let cases: &[(&str, [f64; 2])] = &[
1749 ("diag(-1, 1)", [-1.0, 1.0]),
1750 ("diag(-2, 3)", [-2.0, 3.0]),
1751 ("diag(1, -1)", [1.0, -1.0]),
1752 ];
1753
1754 for &(name, q_diag) in cases {
1755 let mut model = Model::new(name);
1756 let x = model.add_binary_var("x");
1757 let y = model.add_binary_var("y");
1758 model.minimize((q_diag[0] / 2.0) * (x * x) + (q_diag[1] / 2.0) * (y * y));
1760
1761 let err = model
1762 .solve()
1763 .expect_err(&format!("[{name}] expected Err(NonConvex), got Ok"));
1764 assert!(
1765 matches!(err, ModelError::NonConvex(_)),
1766 "[{name}] expected ModelError::NonConvex, got {:?}",
1767 err
1768 );
1769 }
1770 }
1771
1772 #[test]
1779 fn add_var_lb_gt_ub_defers_error_to_solve() {
1780 let cases: &[(&str, f64, f64)] = &[
1781 ("lb=5 > ub=3", 5.0, 3.0),
1782 ("lb=1.0 > ub=0.999", 1.0, 0.999),
1783 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1784 ];
1785 for &(label, lb, ub) in cases {
1786 let mut model = Model::new(label);
1787 let x = model.add_var("x", lb, ub);
1788 model.minimize(x);
1789 let err = model
1790 .solve()
1791 .expect_err(&format!("[{label}] expected Err, got Ok"));
1792 assert!(
1793 matches!(err, ModelError::InvalidInput(_)),
1794 "[{label}] expected InvalidInput, got {err:?}"
1795 );
1796 }
1797 }
1798
1799 #[test]
1800 fn add_var_nan_bounds_defers_error_to_solve() {
1801 let cases: &[(&str, f64, f64)] = &[
1802 ("lb=NaN", f64::NAN, 1.0),
1803 ("ub=NaN", 0.0, f64::NAN),
1804 ("both=NaN", f64::NAN, f64::NAN),
1805 ];
1806 for &(label, lb, ub) in cases {
1807 let mut model = Model::new(label);
1808 let x = model.add_var("x", lb, ub);
1809 model.minimize(x);
1810 let err = model
1811 .solve()
1812 .expect_err(&format!("[{label}] expected Err, got Ok"));
1813 assert!(
1814 matches!(err, ModelError::InvalidInput(_)),
1815 "[{label}] expected InvalidInput, got {err:?}"
1816 );
1817 }
1818 }
1819
1820 #[test]
1821 fn add_int_var_lb_gt_ub_defers_error_to_solve() {
1822 let cases: &[(&str, f64, f64)] =
1823 &[("int lb=3 > ub=1", 3.0, 1.0), ("int lb=NaN", f64::NAN, 5.0)];
1824 for &(label, lb, ub) in cases {
1825 let mut model = Model::new(label);
1826 let x = model.add_int_var("x", lb, ub);
1827 model.minimize(x);
1828 let err = model
1829 .solve()
1830 .expect_err(&format!("[{label}] expected Err, got Ok"));
1831 assert!(
1832 matches!(err, ModelError::InvalidInput(_)),
1833 "[{label}] expected InvalidInput, got {err:?}"
1834 );
1835 }
1836 }
1837
1838 #[test]
1843 fn try_add_var_returns_err_for_invalid_bounds() {
1844 let cases: &[(&str, f64, f64)] = &[
1845 ("lb>ub", 2.0, 1.0),
1846 ("lb=NaN", f64::NAN, 1.0),
1847 ("ub=NaN", 0.0, f64::NAN),
1848 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1849 ];
1850 for &(label, lb, ub) in cases {
1851 let mut model = Model::new(label);
1852 let result = model.try_add_var("x", lb, ub);
1853 assert!(
1854 result.is_err(),
1855 "[{label}] try_add_var should return Err for invalid bounds, got Ok"
1856 );
1857 }
1858 }
1859
1860 #[test]
1861 fn try_add_var_returns_ok_for_valid_bounds() {
1862 let cases: &[(&str, f64, f64)] = &[
1863 ("lb=ub", 3.0, 3.0),
1864 ("lb=0 ub=inf", 0.0, f64::INFINITY),
1865 ("lb=-inf ub=inf", f64::NEG_INFINITY, f64::INFINITY),
1866 ("lb=-inf ub=0", f64::NEG_INFINITY, 0.0),
1867 ];
1868 for &(label, lb, ub) in cases {
1869 let mut model = Model::new(label);
1870 assert!(
1871 model.try_add_var("x", lb, ub).is_ok(),
1872 "[{label}] try_add_var should return Ok for valid bounds"
1873 );
1874 }
1875 }
1876
1877 #[test]
1878 fn try_add_int_var_returns_err_for_invalid_bounds() {
1879 let cases: &[(&str, f64, f64)] = &[("int lb>ub", 5.0, 2.0), ("int lb=NaN", f64::NAN, 3.0)];
1880 for &(label, lb, ub) in cases {
1881 let mut model = Model::new(label);
1882 assert!(
1883 model.try_add_int_var("n", lb, ub).is_err(),
1884 "[{label}] try_add_int_var should return Err"
1885 );
1886 }
1887 }
1888
1889 #[test]
1895 fn try_value_cross_model_returns_err() {
1896 let mut m1 = Model::new("m1");
1897 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
1898 m1.minimize(x1);
1899 let r1 = m1.solve().unwrap();
1900
1901 let mut m2 = Model::new("m2");
1903 let y = m2.add_var("y", 0.0, f64::INFINITY);
1904
1905 assert!(
1906 r1.try_value(y).is_err(),
1907 "try_value with variable from different model must return Err"
1908 );
1909 assert!(r1.try_value(x1).is_ok());
1911 }
1912
1913 #[test]
1914 fn try_value_valid_returns_ok() {
1915 let (mut model, x, y) = basic_model();
1916 let result = model.solve().unwrap();
1917 assert!(result.try_value(x).is_ok());
1918 assert!(result.try_value(y).is_ok());
1919 assert!((result.try_value(x).unwrap() - result.value(x)).abs() < 1e-12);
1920 }
1921
1922 #[test]
1927 fn try_value_out_of_range_same_model_returns_err() {
1928 let mut model = Model::new("grow");
1929 let x = model.add_var("x", 0.0, f64::INFINITY);
1930 model.minimize(x);
1931 let result = model.solve().unwrap();
1932
1933 let late = model.add_var("late", 0.0, f64::INFINITY);
1935 assert_eq!(late.model_id, result.model_id, "same model_id expected");
1936 assert!(
1937 late.index >= result.solution.len(),
1938 "test setup: late var must be out of range"
1939 );
1940 assert!(
1941 result.try_value(late).is_err(),
1942 "try_value must return Err for an out-of-range index even when model_id matches"
1943 );
1944 }
1945
1946 #[test]
1950 fn model_result_clone() {
1951 let (mut model, x, _y) = basic_model();
1952 let result = model.solve().unwrap();
1953 let cloned = result.clone();
1954 assert!((cloned.objective_value - result.objective_value).abs() < 1e-12);
1955 assert_eq!(cloned.solution.len(), result.solution.len());
1956 assert!((cloned[x] - result[x]).abs() < 1e-12);
1957 }
1958
1959 #[test]
1963 fn var_name_is_retained() {
1964 let cases = [("alpha", 0.0, 1.0), ("beta_var", 0.0, f64::INFINITY)];
1965 let mut model = Model::new("named");
1966 for &(name, lb, ub) in &cases {
1967 let v = model.add_var(name, lb, ub);
1968 assert_eq!(model.var_name(v), name, "var_name mismatch for '{name}'");
1969 }
1970 let iv = model.add_int_var("gamma_int", 0.0, 10.0);
1971 assert_eq!(model.var_name(iv), "gamma_int");
1972 }
1973
1974 #[test]
1981 fn try_var_name_ok_same_model() {
1982 let cases: &[(&str, f64, f64)] = &[
1983 ("x", 0.0, f64::INFINITY),
1984 ("y_var", -1.0, 1.0),
1985 ("z_int", 0.0, 10.0),
1986 ];
1987 let mut model = Model::new("try_var_name_ok");
1988 let mut vars = Vec::new();
1989 for &(name, lb, ub) in cases {
1990 vars.push((model.add_var(name, lb, ub), name));
1991 }
1992 for (var, expected_name) in &vars {
1993 assert_eq!(
1994 model.try_var_name(*var).unwrap(),
1995 *expected_name,
1996 "try_var_name mismatch"
1997 );
1998 assert_eq!(
2000 model.var_name(*var),
2001 model.try_var_name(*var).unwrap(),
2002 "var_name and try_var_name must return the same value for '{expected_name}'"
2003 );
2004 }
2005 }
2006
2007 #[test]
2011 fn try_var_name_err_cross_model() {
2012 let mut model_a = Model::new("model_a");
2013 let x = model_a.add_var("x_in_a", 0.0, 1.0);
2014
2015 let cases: &[&str] = &["model_b", "model_c", "model_d"];
2016 for &name in cases {
2017 let m = Model::new(name);
2018 let err = m.try_var_name(x).unwrap_err();
2019 assert!(
2020 matches!(err, ModelError::InvalidInput(_)),
2021 "[{name}] expected InvalidInput for cross-model var, got {err:?}"
2022 );
2023 }
2024 }
2025
2026 #[test]
2029 fn try_var_name_err_index_out_of_range() {
2030 let mut model = Model::new("range_check");
2031 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");
2035
2036 let oob_n = Variable {
2038 index: 1,
2039 model_id: x.model_id,
2040 };
2041 let err_n = model.try_var_name(oob_n).unwrap_err();
2042 assert!(
2043 matches!(err_n, ModelError::InvalidInput(_)),
2044 "index=N=1: expected InvalidInput, got {err_n:?}"
2045 );
2046
2047 let oob_max = Variable {
2049 index: usize::MAX,
2050 model_id: x.model_id,
2051 };
2052 let err_max = model.try_var_name(oob_max).unwrap_err();
2053 assert!(
2054 matches!(err_max, ModelError::InvalidInput(_)),
2055 "index=usize::MAX: expected InvalidInput, got {err_max:?}"
2056 );
2057 }
2058
2059 #[test]
2060 fn var_name_regression_unchecked_still_works() {
2061 let mut model = Model::new("regression");
2063 let cases: &[(&str, f64, f64)] = &[("a", 0.0, 1.0), ("b", 0.0, 2.0), ("c_int", 0.0, 5.0)];
2064 let mut pairs = Vec::new();
2065 for &(name, lb, ub) in cases {
2066 pairs.push((model.add_var(name, lb, ub), name));
2067 }
2068 for (var, expected) in &pairs {
2069 assert_eq!(model.var_name(*var), *expected);
2070 }
2071 }
2072
2073 #[test]
2083 fn var_name_cross_model_in_range_silently_returns_wrong_data() {
2084 let mut model_a = Model::new("a");
2088 let x_a = model_a.add_var("x_in_a", 0.0, 1.0); let mut model_b = Model::new("b");
2091 let _y_b = model_b.add_var("y_in_b", 0.0, 1.0); let returned = model_b.var_name(x_a);
2096 assert_ne!(
2097 returned, "x_in_a",
2098 "unchecked: cross-model var_name returns unrelated data"
2099 );
2100 assert_eq!(
2101 returned, "y_in_b",
2102 "unchecked: returns model_b's var at the same index"
2103 );
2104 }
2105
2106 #[test]
2110 #[should_panic(expected = "index out of bounds")]
2111 fn var_name_cross_model_out_of_range_panics() {
2112 let mut model_a = Model::new("a_2var");
2113 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");
2118 let _y_b = model_b.add_var("y_in_b", 0.0, 1.0);
2119
2120 let _ = model_b.var_name(x1);
2121 }
2122
2123 #[test]
2128 fn set_timeout_invalid_defers_error() {
2129 let cases: &[(&str, f64)] = &[
2130 ("negative", -1.0),
2131 ("NaN", f64::NAN),
2132 ("neg_inf", f64::NEG_INFINITY),
2133 ];
2134 for &(label, secs) in cases {
2135 let mut model = Model::new(label);
2136 let x = model.add_var("x", 0.0, f64::INFINITY);
2137 model.minimize(x);
2138 model.set_timeout(secs);
2139 let err = model
2140 .solve()
2141 .expect_err(&format!("[{label}] expected Err for invalid timeout"));
2142 assert!(
2143 matches!(err, ModelError::InvalidInput(_)),
2144 "[{label}] expected InvalidInput, got {err:?}"
2145 );
2146 }
2147 }
2148
2149 #[test]
2150 fn try_set_timeout_returns_err_for_invalid() {
2151 let cases: &[(&str, f64)] = &[
2152 ("negative", -0.001),
2153 ("NaN", f64::NAN),
2154 ("inf", f64::INFINITY),
2155 ];
2156 for &(label, secs) in cases {
2157 let mut model = Model::new(label);
2158 assert!(
2159 model.try_set_timeout(secs).is_err(),
2160 "[{label}] try_set_timeout should return Err"
2161 );
2162 }
2163 }
2164
2165 #[test]
2166 fn try_set_timeout_ok_for_valid() {
2167 let valid = [0.0, 0.001, 1.0, 3600.0];
2168 for &secs in &valid {
2169 let mut model = Model::new("t");
2170 assert!(
2171 model.try_set_timeout(secs).is_ok(),
2172 "should be Ok for secs={secs}"
2173 );
2174 }
2175 }
2176}
2177
2178#[cfg(test)]
2187mod mip_model_tests {
2188 use super::{Model, ModelError, SolveError};
2189
2190 const EPS: f64 = 1e-4;
2191
2192 #[test]
2193 fn model_add_int_var_maximize_branches() {
2194 let mut m = Model::new("milp_int");
2196 let x = m.add_int_var("x", 0.0, 5.0);
2197 m.add_constraint((2.0 * x).leq(3.0));
2198 m.maximize(x);
2199 let r = m.solve().unwrap();
2200 assert!((r.objective() - 1.0).abs() < EPS, "obj={}", r.objective());
2201 assert!((r[x] - 1.0).abs() < EPS, "x={}", r[x]);
2202 }
2203
2204 #[test]
2205 fn model_binary_knapsack() {
2206 let mut m = Model::new("knapsack");
2207 let a = m.add_binary_var("a");
2208 let b = m.add_binary_var("b");
2209 let c = m.add_binary_var("c");
2210 let d = m.add_binary_var("d");
2211 m.add_constraint((5.0 * a + 7.0 * b + 4.0 * c + 3.0 * d).leq(14.0));
2212 m.maximize(8.0 * a + 11.0 * b + 6.0 * c + 4.0 * d);
2213 let r = m.solve().unwrap();
2214 assert!((r.objective() - 21.0).abs() < EPS, "obj={}", r.objective());
2215 assert_eq!(
2216 (r[a].round(), r[b].round(), r[c].round(), r[d].round()),
2217 (0.0, 1.0, 1.0, 1.0)
2218 );
2219 }
2220
2221 #[test]
2222 fn model_integer_infeasible_errors() {
2223 let mut m = Model::new("infeasible");
2224 let x = m.add_int_var("x", 0.0, 10.0);
2225 m.add_constraint((1.0 * x).geq(1.2));
2226 m.add_constraint((1.0 * x).leq(1.8));
2227 m.minimize(x);
2228 let err = m.solve().unwrap_err();
2229 assert!(
2230 matches!(err, ModelError::SolveError(SolveError::Infeasible)),
2231 "got {err:?}"
2232 );
2233 }
2234
2235 #[test]
2236 fn model_integer_unbounded_errors() {
2237 let mut m = Model::new("unbounded");
2238 let x = m.add_int_var("x", 0.0, f64::INFINITY);
2239 m.maximize(x);
2240 let err = m.solve().unwrap_err();
2241 assert!(
2242 matches!(err, ModelError::SolveError(SolveError::Unbounded)),
2243 "got {err:?}"
2244 );
2245 }
2246
2247 #[test]
2248 fn model_convex_miqp_branches_to_integer_optimum() {
2249 let mut m = Model::new("convex_miqp");
2252 let x = m.add_int_var("x", 0.0, 5.0);
2253 m.minimize(x * x + (-5.0) * x);
2254 let r = m.solve().unwrap();
2255 assert!(
2256 (r.objective() - (-6.0)).abs() < EPS,
2257 "obj={}",
2258 r.objective()
2259 );
2260 let xr = r[x].round();
2261 assert!(xr == 2.0 || xr == 3.0, "x must be 2 or 3, got {}", r[x]);
2262 assert!((r[x] - xr).abs() < EPS, "x must be integral: {}", r[x]);
2263 }
2264
2265 #[test]
2266 fn model_nonconvex_miqp_errors() {
2267 let cases: &[(&str, &[f64], &[f64])] = &[
2269 ("single neg", &[-2.0], &[1.0]),
2270 ("neg-pos-2var", &[-3.0, 2.0], &[0.0, 1.0]),
2271 ];
2272 for &(name, q_diag, c_vec) in cases {
2273 let n = q_diag.len();
2274 let mut m = Model::new(name);
2275 let vars: Vec<_> = (0..n)
2276 .map(|i| m.add_int_var(&format!("x{i}"), 0.0, 5.0))
2277 .collect();
2278 let obj = vars.iter().zip(q_diag).zip(c_vec).fold(
2280 crate::quad_expr::QuadExpr::from(0.0_f64),
2281 |acc, ((&v, &d), &c)| acc + (d / 2.0) * (v * v) + c * v,
2282 );
2283 m.minimize(obj);
2284 let err = m.solve().unwrap_err();
2285 assert!(
2286 matches!(err, ModelError::NonConvex(_)),
2287 "[{name}] expected ModelError::NonConvex, got {err:?}"
2288 );
2289 }
2290 }
2291
2292 #[test]
2293 fn model_mixed_integer_continuous() {
2294 let mut m = Model::new("mixed");
2297 let x = m.add_int_var("x", 0.0, 5.0);
2298 let y = m.add_var("y", 0.0, 5.0);
2299 m.add_constraint((x + y).leq(3.5));
2300 m.maximize(x + y);
2301 let r = m.solve().unwrap();
2302 assert!((r.objective() - 3.5).abs() < EPS, "obj={}", r.objective());
2303 assert!(
2304 (r[x].round() - r[x]).abs() < EPS,
2305 "x must be integral, x={}",
2306 r[x]
2307 );
2308 }
2309
2310 #[test]
2314 fn test_linear_objective_constant_included() {
2315 let mut model = Model::new("lin_const");
2317 let x = model.add_var("x", 1.0, f64::INFINITY);
2318 model.minimize(2.0 * x + 3.0);
2319 let result = model.solve().unwrap();
2320 assert!(
2321 (result[x] - 1.0).abs() < EPS,
2322 "x* should be 1, got {}",
2323 result[x]
2324 );
2325 assert!(
2326 (result.objective_value - 5.0).abs() < EPS,
2327 "obj* should be 5 (includes constant +3), got {}",
2328 result.objective_value
2329 );
2330 }
2331
2332 #[test]
2334 fn test_quad_objective_constant_included() {
2335 let mut model = Model::new("quad_const");
2338 let x = model.add_var("x", 0.0, f64::INFINITY);
2339 model.minimize(x * x + (-4.0) * x + 4.0);
2340 let result = model.solve().unwrap();
2341 assert!(
2342 (result[x] - 2.0).abs() < 1e-4,
2343 "quad_const: x* should be 2, got {}",
2344 result[x]
2345 );
2346 assert!(
2347 result.objective_value.abs() < 1e-4,
2348 "quad_const: obj* should be 0 (constant +4 included), got {}",
2349 result.objective_value
2350 );
2351 }
2352
2353 #[test]
2355 fn test_maximize_objective_constant_sign() {
2356 let mut model = Model::new("max_const");
2358 let x = model.add_var("x", 0.0, 10.0);
2359 model.maximize((-1.0) * x + 5.0);
2360 let result = model.solve().unwrap();
2361 assert!(
2362 (result[x]).abs() < EPS,
2363 "max_const: x* should be 0, got {}",
2364 result[x]
2365 );
2366 assert!(
2367 (result.objective_value - 5.0).abs() < EPS,
2368 "max_const: obj* should be 5 (constant not negated for max), got {}",
2369 result.objective_value
2370 );
2371 }
2372
2373 #[test]
2375 fn test_obj_offset_and_dsl_constant_no_double_count() {
2376 let mut model = Model::new("offset_plus_const");
2379 let x = model.add_var("x", 1.0, f64::INFINITY);
2380 model.minimize(1.0 * x + 3.0);
2381 model.set_obj_offset(10.0);
2382 let result = model.solve().unwrap();
2383 assert!(
2384 (result.objective_value - 14.0).abs() < EPS,
2385 "offset+const: obj* should be 14 (1+3+10), got {}",
2386 result.objective_value
2387 );
2388 }
2389
2390 #[test]
2392 fn test_reminimize_constant_not_double_counted() {
2393 let mut model = Model::new("reminimize");
2395 let x = model.add_var("x", 1.0, f64::INFINITY);
2396 model.minimize(1.0 * x + 3.0); model.minimize(1.0 * x + 7.0); let result = model.solve().unwrap();
2399 assert!(
2400 (result.objective_value - 8.0).abs() < EPS,
2401 "re-minimize: obj* should be 8 (1+7, not 1+3+7=11), got {}",
2402 result.objective_value
2403 );
2404 }
2405
2406 #[test]
2412 fn test_p2a_nan_dsl_quad_then_linear_is_optimal() {
2413 let mut model = Model::new("p2a_nan_then_linear");
2416 let x = model.add_var("x", 1.0, f64::INFINITY);
2417 model.minimize(f64::NAN * (x * x)); model.minimize(1.0 * x); let result = model.solve();
2420 assert!(
2421 result.is_ok(),
2422 "P2-a: should be Optimal after linear replaces NaN quad, got {result:?}"
2423 );
2424 let r = result.unwrap();
2425 assert!(
2426 (r[x] - 1.0).abs() < EPS,
2427 "P2-a: x* should be 1, got {}",
2428 r[x]
2429 );
2430 }
2431
2432 #[test]
2434 fn test_p2a_valid_quad_then_nan_errors() {
2435 let mut model = Model::new("p2a_valid_then_nan");
2436 let x = model.add_var("x", 0.0, f64::INFINITY);
2437 model.minimize(x * x + (-2.0) * x); model.minimize(f64::NAN * (x * x)); let result = model.solve();
2440 assert!(
2441 matches!(result, Err(ModelError::InvalidInput(_))),
2442 "P2-a: NaN quad must yield InvalidInput, got {result:?}"
2443 );
2444 }
2445
2446 #[test]
2448 fn test_p2a_nan_dsl_then_maximize_linear_is_optimal() {
2449 let mut model = Model::new("p2a_nan_then_max");
2450 let x = model.add_var("x", 0.0, 5.0);
2451 model.minimize(f64::NAN * (x * x)); model.maximize(1.0 * x); let result = model.solve();
2454 assert!(
2455 result.is_ok(),
2456 "P2-a: maximize should succeed after NaN DSL cleared, got {result:?}"
2457 );
2458 let r = result.unwrap();
2459 assert!(
2460 (r[x] - 5.0).abs() < EPS,
2461 "P2-a: maximize x → x*=5, got {}",
2462 r[x]
2463 );
2464 }
2465
2466 #[test]
2469 fn test_quad_state_invariants_transition_table() {
2470 fn mk() -> (Model, crate::variable::Variable) {
2472 let mut m = Model::new("t");
2473 let x = m.add_var("x", 0.0, f64::INFINITY);
2474 (m, x)
2475 }
2476
2477 {
2479 let (mut m, x) = mk();
2480 m.minimize(x * x);
2481 assert!(!m.has_quad_dsl_error(), "DSL ok: no error");
2482 assert!(m.is_quad_via_dsl(), "DSL ok: via_dsl=true");
2483 assert!(m.has_quadratic_objective(), "DSL ok: has_q=true");
2484 }
2485
2486 {
2488 let (mut m, x) = mk();
2489 m.minimize(f64::NAN * (x * x));
2490 assert!(m.has_quad_dsl_error(), "DSL fail: error recorded");
2491 assert!(!m.is_quad_via_dsl(), "DSL fail: via_dsl=false");
2492 assert!(!m.has_quadratic_objective(), "DSL fail: has_q=false");
2493 }
2494
2495 {
2497 let (mut m, x) = mk();
2498 m.minimize(x * x);
2499 m.minimize(f64::NAN * (x * x));
2500 assert!(m.has_quad_dsl_error(), "ok→fail: error recorded");
2501 assert!(!m.is_quad_via_dsl(), "ok→fail: via_dsl=false");
2502 assert!(!m.has_quadratic_objective(), "ok→fail: has_q=false");
2503 }
2504
2505 {
2507 let (mut m, x) = mk();
2508 m.minimize(x * x);
2509 m.minimize(1.0 * x);
2510 assert!(!m.has_quad_dsl_error(), "dsl→linear: no error");
2511 assert!(!m.is_quad_via_dsl(), "dsl→linear: via_dsl=false");
2512 assert!(!m.has_quadratic_objective(), "dsl→linear: DSL Q cleared");
2513 }
2514 }
2515
2516 #[test]
2518 fn test_quad_state_solve_outcome_table() {
2519 let cases: &[(&str, bool)] = &[
2520 ("nan_then_linear", true),
2523 ("nan_alone", false),
2525 ("ok_then_nan", false),
2527 ("nan_then_valid_quad", true),
2529 ("nan_nan_then_valid_quad", true),
2531 ];
2532
2533 for &(name, expect_ok) in cases {
2534 let mut m = Model::new(name);
2535 let x = m.add_var("x", 0.0, f64::INFINITY);
2536
2537 match name {
2538 "nan_then_linear" => {
2539 m.minimize(f64::NAN * (x * x) + x);
2540 m.minimize(1.0 * x);
2541 }
2542 "nan_alone" => {
2543 m.minimize(f64::NAN * (x * x) + x);
2544 }
2545 "ok_then_nan" => {
2546 m.minimize(x * x + (-4.0) * x);
2547 m.minimize(f64::NAN * (x * x) + x);
2548 }
2549 "nan_then_valid_quad" => {
2550 m.minimize(f64::NAN * (x * x));
2551 m.minimize(x * x + (-4.0) * x);
2552 }
2553 "nan_nan_then_valid_quad" => {
2554 m.minimize(f64::NAN * (x * x));
2555 m.minimize(f64::NAN * (x * x));
2556 m.minimize(x * x + (-4.0) * x);
2557 }
2558 _ => unreachable!(),
2559 }
2560
2561 let result = m.solve();
2562 if expect_ok {
2563 assert!(
2564 result.is_ok(),
2565 "case '{name}': expected Optimal, got {result:?}"
2566 );
2567 } else {
2568 assert!(
2569 matches!(result, Err(ModelError::InvalidInput(_))),
2570 "case '{name}': expected InvalidInput, got {result:?}"
2571 );
2572 }
2573 }
2574 }
2575
2576 #[test]
2582 fn test_p2f_mixed_quad_local_linear_foreign_rejected() {
2583 let mut m1 = Model::new("m1");
2584 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2585
2586 let mut m2 = Model::new("m2");
2587 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2588
2589 m1.minimize(x1 * x1 + y2);
2591 let result = m1.solve();
2592 assert!(
2593 matches!(result, Err(ModelError::InvalidInput(_))),
2594 "P2-f mixed: foreign linear term must give InvalidInput, got {result:?}"
2595 );
2596 }
2597
2598 #[test]
2600 fn test_p2f_pure_linear_foreign_rejected() {
2601 let mut m1 = Model::new("m1");
2602 let _x1 = m1.add_var("x", 0.0, f64::INFINITY);
2603
2604 let mut m2 = Model::new("m2");
2605 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2606
2607 m1.minimize(1.0 * y2);
2609 let result = m1.solve();
2610 assert!(
2611 matches!(result, Err(ModelError::InvalidInput(_))),
2612 "P2-f pure-linear: foreign variable must give InvalidInput (not silent drop), got {result:?}"
2613 );
2614 }
2615
2616 #[test]
2618 fn test_p2f_mixed_all_local_accepted() {
2619 let mut m = Model::new("m");
2620 let x = m.add_var("x", 0.0, f64::INFINITY);
2621 m.minimize(x * x + (-4.0) * x);
2623 let result = m.solve().unwrap();
2624 assert!(
2625 (result[x] - 2.0).abs() < EPS,
2626 "P2-f no false positive: x*=2, got {}",
2627 result[x]
2628 );
2629 }
2630
2631 #[test]
2633 fn test_p2f_cross_term_plus_linear_foreign() {
2634 let mut m1 = Model::new("m1");
2635 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2636
2637 let mut m2 = Model::new("m2");
2638 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2639
2640 m1.minimize(x1 * y2 + 2.0 * y2);
2643 let result = m1.solve();
2644 assert!(
2645 matches!(result, Err(ModelError::InvalidInput(_))),
2646 "P2-f cross+linear: must give InvalidInput, got {result:?}"
2647 );
2648 }
2649
2650 #[test]
2652 fn test_p2f_foreign_linear_maximize_rejected() {
2653 let mut m1 = Model::new("m1");
2654 let _x1 = m1.add_var("x", 0.0, 5.0);
2655
2656 let mut m2 = Model::new("m2");
2657 let y2 = m2.add_var("y", 0.0, 5.0);
2658
2659 m1.maximize(1.0 * y2);
2660 let result = m1.solve();
2661 assert!(
2662 matches!(result, Err(ModelError::InvalidInput(_))),
2663 "P2-f maximize foreign linear: must give InvalidInput, got {result:?}"
2664 );
2665 }
2666
2667 #[test]
2673 fn test_p2h_nan_linear_coef_rejected_at_solve() {
2674 let mut m = Model::new("p2h_nan_coef");
2675 let x = m.add_var("x", 0.0, f64::INFINITY);
2676 m.minimize(f64::NAN * x);
2677 let err = m.solve().unwrap_err();
2678 assert!(
2679 matches!(err, ModelError::InvalidInput(_)),
2680 "P2-h: NaN linear coefficient must give InvalidInput, got {err:?}"
2681 );
2682 }
2683
2684 #[test]
2686 fn test_p2h_inf_linear_coef_rejected_at_solve() {
2687 let mut m = Model::new("p2h_inf_coef");
2688 let x = m.add_var("x", 0.0, f64::INFINITY);
2689 m.minimize(f64::INFINITY * x);
2690 let err = m.solve().unwrap_err();
2691 assert!(
2692 matches!(err, ModelError::InvalidInput(_)),
2693 "P2-h: Inf linear coefficient must give InvalidInput, got {err:?}"
2694 );
2695 }
2696
2697 #[test]
2699 fn test_p2h_nan_constant_rejected_at_solve() {
2700 let mut m = Model::new("p2h_nan_const");
2701 let x = m.add_var("x", 0.0, f64::INFINITY);
2702 m.minimize(1.0 * x + f64::NAN);
2703 let err = m.solve().unwrap_err();
2704 assert!(
2705 matches!(err, ModelError::InvalidInput(_)),
2706 "P2-h: NaN constant term must give InvalidInput, got {err:?}"
2707 );
2708 }
2709
2710 #[test]
2712 fn test_p2h_inf_constant_rejected_at_solve() {
2713 let mut m = Model::new("p2h_inf_const");
2714 let x = m.add_var("x", 0.0, f64::INFINITY);
2715 m.minimize(1.0 * x + f64::INFINITY);
2716 let err = m.solve().unwrap_err();
2717 assert!(
2718 matches!(err, ModelError::InvalidInput(_)),
2719 "P2-h: Inf constant term must give InvalidInput, got {err:?}"
2720 );
2721 }
2722
2723 #[test]
2727 fn test_p2h_inf_q_coef_via_diagonal_overflow_rejected_at_solve() {
2728 let mut m = Model::new("p2h_inf_q_diagonal");
2729 let x = m.add_var("x", 0.0, f64::INFINITY);
2730 m.minimize(f64::MAX * (x * x));
2734 let err = m.solve().unwrap_err();
2735 assert!(
2736 matches!(err, ModelError::InvalidInput(_)),
2737 "P2-h: Inf Q diagonal (2*MAX overflow) must give InvalidInput, got {err:?}"
2738 );
2739 }
2740
2741 #[test]
2744 fn test_p2h_nan_q_coef_dsl_rejected_at_solve() {
2745 let mut m = Model::new("p2h_nan_q_dsl");
2746 let x = m.add_var("x", 0.0, f64::INFINITY);
2747 m.minimize(f64::NAN * (x * x));
2750 let err = m.solve().unwrap_err();
2751 assert!(
2752 matches!(err, ModelError::InvalidInput(_)),
2753 "P2-h: NaN DSL Q coefficient must give InvalidInput, got {err:?}"
2754 );
2755 }
2756
2757 #[test]
2763 fn test_p2i_nan_quad_replaced_by_valid_quad_is_optimal() {
2764 let mut model = Model::new("p2i_nan_then_valid");
2765 let x = model.add_var("x", 0.0, f64::INFINITY);
2766 model.minimize(f64::NAN * (x * x)); assert!(
2768 model.has_quad_dsl_error(),
2769 "P2-i setup: error must be recorded"
2770 );
2771
2772 model.minimize(x * x + (-4.0) * x); assert!(
2774 !model.has_quad_dsl_error(),
2775 "P2-i: valid minimize must clear quad_dsl_error"
2776 );
2777 assert!(
2778 model.has_quadratic_objective(),
2779 "P2-i: valid Q must be installed"
2780 );
2781
2782 let result = model.solve();
2784 assert!(
2785 result.is_ok(),
2786 "P2-i: valid quad after NaN must be Optimal, got {result:?}"
2787 );
2788 let r = result.unwrap();
2789 assert!((r[x] - 2.0).abs() < EPS, "P2-i: x*=2, got {}", r[x]);
2790 assert!(
2791 (r.objective_value - (-4.0)).abs() < EPS,
2792 "P2-i: obj*=-4, got {}",
2793 r.objective_value
2794 );
2795 }
2796
2797 #[test]
2799 fn test_p2i_nan_quad_alone_is_invalid() {
2800 let mut model = Model::new("p2i_nan_alone");
2801 let x = model.add_var("x", 0.0, f64::INFINITY);
2802 model.minimize(f64::NAN * (x * x));
2803 let result = model.solve();
2804 assert!(
2805 matches!(result, Err(ModelError::InvalidInput(_))),
2806 "P2-i: NaN quad alone must give InvalidInput, got {result:?}"
2807 );
2808 }
2809
2810 #[test]
2812 fn test_p2i_setter_ordering_table() {
2813 type Setup = fn(&mut Model, crate::variable::Variable);
2815 let cases: &[(&str, Setup, bool)] = &[
2816 (
2818 "nan→valid_quad",
2819 |m, x| {
2820 m.minimize(f64::NAN * (x * x));
2821 m.minimize(x * x + (-4.0) * x);
2822 },
2823 true,
2824 ),
2825 (
2827 "nan→nan→valid_quad",
2828 |m, x| {
2829 m.minimize(f64::NAN * (x * x));
2830 m.minimize(f64::NAN * (x * x));
2831 m.minimize(x * x + (-4.0) * x);
2832 },
2833 true,
2834 ),
2835 (
2837 "valid→nan",
2838 |m, x| {
2839 m.minimize(x * x + (-4.0) * x);
2840 m.minimize(f64::NAN * (x * x));
2841 },
2842 false,
2843 ),
2844 (
2846 "nan→linear",
2847 |m, x| {
2848 m.minimize(f64::NAN * (x * x));
2849 m.minimize(1.0 * x); },
2851 true,
2852 ),
2853 (
2855 "nan→valid→nan",
2856 |m, x| {
2857 m.minimize(f64::NAN * (x * x));
2858 m.minimize(x * x + (-4.0) * x);
2859 m.minimize(f64::NAN * (x * x));
2860 },
2861 false,
2862 ),
2863 ];
2864
2865 for &(label, setup, expect_optimal) in cases {
2866 let mut m = Model::new(label);
2867 let x = m.add_var("x", 0.0, f64::INFINITY);
2868 setup(&mut m, x);
2869 let result = m.solve();
2870 if expect_optimal {
2871 assert!(
2872 result.is_ok(),
2873 "P2-i [{label}]: expected Optimal, got {result:?}"
2874 );
2875 } else {
2876 assert!(
2877 matches!(result, Err(ModelError::InvalidInput(_))),
2878 "P2-i [{label}]: expected InvalidInput, got {result:?}"
2879 );
2880 }
2881 }
2882 }
2883}