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 use_ruiz_scaling: Option<bool>,
75 tolerance: Option<Tolerance>,
77 presolve: Option<bool>,
79 threads: Option<usize>,
81 obj_offset: f64,
83}
84
85impl Model {
86 pub fn new(name: &str) -> Self {
88 Model {
89 model_id: NEXT_MODEL_ID.fetch_add(1, Ordering::Relaxed),
90 name: Some(name.to_string()),
91 variables: Vec::new(),
92 constraints: Vec::new(),
93 objective: None,
94 sense: OptimizationSense::Minimize,
95 quadratic_objective: None,
96 quad_dsl_error: None,
97 obj_expr_constant: 0.0,
98 invalid_inputs: BTreeMap::new(),
99 timeout_secs: None,
100 use_ruiz_scaling: None,
101 tolerance: None,
102 presolve: None,
103 threads: None,
104 obj_offset: 0.0,
105 }
106 }
107
108 pub fn set_timeout(&mut self, secs: f64) {
110 if let Err(err) = self.try_set_timeout(secs) {
111 self.record_input_error("timeout", err);
112 }
113 }
114
115 pub fn try_set_timeout(&mut self, secs: f64) -> Result<&mut Self, ModelError> {
117 validate_timeout(secs)?;
118 self.timeout_secs = Some(secs);
119 self.invalid_inputs.remove("timeout");
120 Ok(self)
121 }
122
123 pub fn set_threads(&mut self, n: usize) -> &mut Self {
126 self.threads = Some(n.max(1));
127 self
128 }
129
130 pub fn set_use_ruiz_scaling(&mut self, flag: bool) {
132 self.use_ruiz_scaling = Some(flag);
133 }
134
135 pub fn set_tolerance(&mut self, tol: Tolerance) -> &mut Self {
137 self.tolerance = Some(tol);
138 self
139 }
140
141 pub fn set_presolve(&mut self, flag: bool) -> &mut Self {
143 self.presolve = Some(flag);
144 self
145 }
146
147 pub fn add_var(&mut self, name: &str, lb: f64, ub: f64) -> Variable {
152 match validate_bounds(lb, ub) {
153 Ok(()) => self.push_var(name, lb, ub, VarKind::Continuous),
154 Err(err) => {
155 self.record_input_error("variable_bounds", err);
156 self.push_var(name, 0.0, 0.0, VarKind::Continuous)
157 }
158 }
159 }
160
161 pub fn try_add_var(&mut self, name: &str, lb: f64, ub: f64) -> Result<Variable, ModelError> {
163 validate_bounds(lb, ub)?;
164 Ok(self.push_var(name, lb, ub, VarKind::Continuous))
165 }
166
167 pub fn add_int_var(&mut self, name: &str, lb: f64, ub: f64) -> Variable {
175 match validate_bounds(lb, ub) {
176 Ok(()) => self.push_var(name, lb, ub, VarKind::Integer),
177 Err(err) => {
178 self.record_input_error("variable_bounds", err);
179 self.push_var(name, 0.0, 0.0, VarKind::Integer)
180 }
181 }
182 }
183
184 pub fn try_add_int_var(&mut self, name: &str, lb: f64, ub: f64) -> Result<Variable, ModelError> {
186 validate_bounds(lb, ub)?;
187 Ok(self.push_var(name, lb, ub, VarKind::Integer))
188 }
189
190 pub fn add_binary_var(&mut self, name: &str) -> Variable {
192 self.push_var(name, 0.0, 1.0, VarKind::Binary)
193 }
194
195 pub fn var_name(&self, var: Variable) -> &str {
197 &self.variables[var.index].name
198 }
199
200 fn push_var(&mut self, name: &str, lb: f64, ub: f64, kind: VarKind) -> Variable {
201 let index = self.variables.len();
202 self.variables.push(VariableDefinition {
203 name: name.to_string(),
204 lower_bound: lb,
205 upper_bound: ub,
206 kind,
207 });
208 Variable { index, model_id: self.model_id }
209 }
210
211 pub fn add_constraint(&mut self, c: Constraint) -> &mut Self {
213 self.constraints.push(c);
214 self
215 }
216
217 pub fn minimize(&mut self, obj: impl Into<QuadExpr>) -> &mut Self {
224 self.apply_objective(obj.into(), OptimizationSense::Minimize)
225 }
226
227 pub fn maximize(&mut self, obj: impl Into<QuadExpr>) -> &mut Self {
232 self.apply_objective(obj.into(), OptimizationSense::Maximize)
233 }
234
235 fn validate_objective(&self) -> Result<(), ModelError> {
256 if let Some(msg) = &self.quad_dsl_error {
257 return Err(ModelError::InvalidInput(msg.clone()));
258 }
259 let Some(obj) = self.objective.as_ref() else {
260 return Ok(()); };
262 for (&var, &coef) in &obj.coefficients {
263 if var.model_id != self.model_id {
264 return Err(ModelError::InvalidInput(format!(
265 "objective: linear term (var {}) belongs to model {}, not this model ({})",
266 var.index, var.model_id, self.model_id
267 )));
268 }
269 if !coef.is_finite() {
270 return Err(ModelError::InvalidInput(format!(
271 "objective: non-finite linear coefficient for var {}: {coef}",
272 var.index
273 )));
274 }
275 }
276 if !self.obj_expr_constant.is_finite() {
277 return Err(ModelError::InvalidInput(format!(
278 "objective: non-finite constant term: {}",
279 self.obj_expr_constant
280 )));
281 }
282 if let Some(q) = &self.quadratic_objective {
283 if let Some(&v) = q.values().iter().find(|v| !v.is_finite()) {
284 return Err(ModelError::InvalidInput(format!(
285 "objective: non-finite Q coefficient: {v}"
286 )));
287 }
288 }
289 Ok(())
290 }
291
292 fn install_quad(&mut self, q: CscMatrix) {
294 self.quadratic_objective = Some(q);
295 self.quad_dsl_error = None;
296 }
297
298 fn fail_dsl_quad(&mut self, msg: String) {
301 self.quadratic_objective = None;
302 self.quad_dsl_error = Some(msg);
303 }
304
305 fn replace_with_linear_objective(&mut self) {
307 self.quadratic_objective = None;
308 }
309
310 fn find_foreign_var_error(&self, q: &QuadExpr) -> Option<String> {
314 if let Some(&v) = q.linear.coefficients.keys().find(|v| v.model_id != self.model_id) {
318 return Some(format!(
319 "linear term (var {}) belongs to model {}, not this model ({})",
320 v.index, v.model_id, self.model_id
321 ));
322 }
323 if let Some(&(va, vb)) = q.quad.keys().find(|(va, vb)| {
325 va.model_id != self.model_id || vb.model_id != self.model_id
326 }) {
327 return Some(format!(
328 "quad term ({},{}) belongs to model(s) {}/{}, not this model ({})",
329 va.index, vb.index, va.model_id, vb.model_id, self.model_id
330 ));
331 }
332 None
333 }
334
335 fn apply_objective(&mut self, q: QuadExpr, sense: OptimizationSense) -> &mut Self {
337 self.quad_dsl_error = None;
342 if let Some(msg) = self.find_foreign_var_error(&q) {
346 self.fail_dsl_quad(msg);
347 } else if !q.quad.is_empty() {
348 match quad_to_csc(&q.quad, self.variables.len()) {
349 Ok(csc) => self.install_quad(csc),
350 Err(msg) => self.fail_dsl_quad(msg),
351 }
352 } else {
353 self.replace_with_linear_objective();
354 }
355 self.obj_expr_constant = q.linear.constant;
359 self.objective = Some(q.linear);
360 self.sense = sense;
361 self
362 }
363
364 pub fn set_obj_offset(&mut self, offset: f64) -> &mut Self {
370 self.obj_offset = offset;
371 self
372 }
373
374 pub fn solve(&mut self) -> Result<ModelResult, ModelError> {
380 if let Some(msg) = self.invalid_inputs.values().next() {
381 return Err(ModelError::InvalidInput(msg.clone()));
382 }
383
384 let obj_expr = self.objective.as_ref().ok_or(ModelError::NoObjective)?;
385
386 self.validate_objective()?;
391
392 let num_vars = self.variables.len();
393 let num_constraints = self.constraints.len();
394
395 let mid = self.model_id;
397 let mut c: Vec<f64> = (0..num_vars)
398 .map(|i| obj_expr.coefficient(Variable { index: i, model_id: mid }))
399 .collect();
400
401 if self.sense == OptimizationSense::Maximize {
403 for ci in &mut c {
404 *ci = -*ci;
405 }
406 }
407
408 let mut trip_rows: Vec<usize> = Vec::new();
410 let mut trip_cols: Vec<usize> = Vec::new();
411 let mut trip_vals: Vec<f64> = Vec::new();
412 let mut b: Vec<f64> = Vec::new();
413 let mut constraint_types: Vec<ConstraintType> = Vec::new();
414
415 for (i, con) in self.constraints.iter().enumerate() {
416 for (&var, &coeff) in &con.lhs.coefficients {
417 trip_rows.push(i);
418 trip_cols.push(var.index);
419 trip_vals.push(coeff);
420 }
421 b.push(con.rhs);
423 constraint_types.push(match con.sense {
424 ConstraintSense::Le => ConstraintType::Le,
425 ConstraintSense::Ge => ConstraintType::Ge,
426 ConstraintSense::Eq => ConstraintType::Eq,
427 });
428 }
429
430 let a = if num_constraints == 0 {
432 CscMatrix::new(0, num_vars)
433 } else {
434 CscMatrix::from_triplets(
435 &trip_rows,
436 &trip_cols,
437 &trip_vals,
438 num_constraints,
439 num_vars,
440 )
441 .map_err(|e| ModelError::Internal(e.to_string()))?
442 };
443
444 let bounds: Vec<(f64, f64)> = self
446 .variables
447 .iter()
448 .map(|v| (v.lower_bound, v.upper_bound))
449 .collect();
450
451 let integer_vars: Vec<usize> = self
454 .variables
455 .iter()
456 .enumerate()
457 .filter(|(_, v)| v.kind != VarKind::Continuous)
458 .map(|(i, _)| i)
459 .collect();
460 if !integer_vars.is_empty() {
461 return self.solve_mip_internal(c, a, b, constraint_types, bounds, integer_vars);
462 }
463
464 if let Some(ref q_orig) = self.quadratic_objective.clone() {
466 return self.solve_qp_internal(c, a, b, bounds, q_orig.clone(), num_constraints);
467 }
468
469 let problem = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
471 .map_err(map_lp_build_err)?;
472
473 let mut lp_opts = otspot_core::options::SolverOptions::default();
474 if let Some(t) = self.timeout_secs {
475 lp_opts.timeout_secs = Some(t);
476 }
477 if let Some(tol) = self.tolerance {
478 lp_opts.tolerance = Some(tol);
479 }
480 if let Some(flag) = self.presolve {
481 lp_opts.presolve = flag;
482 }
483 if let Some(n) = self.threads {
484 lp_opts.threads = n;
485 }
486 let solver_result = otspot_core::lp::solve_lp_with(&problem, &lp_opts);
487
488 let lp_extras = |sr: &otspot_core::problem::SolverResult| {
491 let dual = if sr.dual_solution.is_empty() {
492 None
493 } else {
494 Some(sr.dual_solution.clone())
495 };
496 let rc = if sr.reduced_costs.is_empty() {
497 None
498 } else {
499 Some(sr.reduced_costs.clone())
500 };
501 let slack = if sr.slack.is_empty() {
502 None
503 } else {
504 Some(sr.slack.clone())
505 };
506 (dual, rc, slack)
507 };
508
509 let signed_obj = |raw: f64| -> f64 {
510 let oriented = if self.sense == OptimizationSense::Maximize {
511 -raw
512 } else {
513 raw
514 };
515 oriented + self.obj_offset + self.obj_expr_constant
516 };
517 let lp_model_id = self.model_id;
518 let build_ok = |sr: otspot_core::problem::SolverResult| {
519 let (dual, rc, slack) = lp_extras(&sr);
520 let status = sr.status.clone();
521 ModelResult {
522 model_id: lp_model_id,
523 status: status.clone(),
524 proof: SolutionProof::from_status(&status),
525 objective_value: signed_obj(sr.objective),
526 solution: sr.solution,
527 dual_solution: dual,
528 reduced_costs: rc,
529 slack,
530 bound_duals: sr.bound_duals,
531 stats: sr.stats,
532 }
533 };
534
535 match solver_result.status {
536 SolveStatus::Optimal => Ok(build_ok(solver_result)),
537 SolveStatus::MaxIterations => {
538 if solver_result.solution.is_empty() {
539 Err(ModelError::SolveError(SolveError::MaxIterations))
540 } else {
541 Ok(build_ok(solver_result))
542 }
543 }
544 SolveStatus::SuboptimalSolution => {
545 if solver_result.solution.is_empty() {
546 Err(ModelError::Timeout)
547 } else {
548 Ok(build_ok(solver_result))
549 }
550 }
551 SolveStatus::Timeout => Err(ModelError::Timeout),
552 SolveStatus::LocallyOptimal
553 | SolveStatus::NonconvexLocal
554 | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
555 "Unexpected nonconvex status on LP path".to_string(),
556 )),
557 s => Err(classify_status_error(s)
558 .unwrap_or_else(|| ModelError::Internal("unhandled LP status".to_string()))),
559 }
560 }
561
562 fn build_qp_problem(
566 &self,
567 c: Vec<f64>,
568 bounds: Vec<(f64, f64)>,
569 q_orig: CscMatrix,
570 ) -> Result<otspot_core::qp::QpProblem, ModelError> {
571 use otspot_core::qp::QpProblem;
572
573 let num_vars = self.variables.len();
574
575 let qp_q = if self.sense == OptimizationSense::Maximize {
577 q_orig.scale_values(-1.0)
578 } else {
579 q_orig
580 };
581
582 let mut qp_trip_rows: Vec<usize> = Vec::new();
585 let mut qp_trip_cols: Vec<usize> = Vec::new();
586 let mut qp_trip_vals: Vec<f64> = Vec::new();
587 let mut qp_b: Vec<f64> = Vec::new();
588 let mut qp_constraint_types: Vec<ConstraintType> = Vec::new();
589 let mut qp_row = 0usize;
590
591 for con in &self.constraints {
592 for (&var, &coeff) in &con.lhs.coefficients {
593 qp_trip_rows.push(qp_row);
594 qp_trip_cols.push(var.index);
595 qp_trip_vals.push(coeff);
596 }
597 qp_b.push(con.rhs);
598 qp_constraint_types.push(match con.sense {
599 ConstraintSense::Le => ConstraintType::Le,
600 ConstraintSense::Ge => ConstraintType::Ge,
601 ConstraintSense::Eq => ConstraintType::Eq,
602 });
603 qp_row += 1;
604 }
605
606 let m_qp = qp_row;
607 let qp_a = if m_qp == 0 {
608 CscMatrix::new(0, num_vars)
609 } else {
610 CscMatrix::from_triplets(&qp_trip_rows, &qp_trip_cols, &qp_trip_vals, m_qp, num_vars)
611 .map_err(|e| ModelError::Internal(e.to_string()))?
612 };
613
614 let mut qp_problem = QpProblem::new(qp_q, c, qp_a, qp_b, bounds, qp_constraint_types)
615 .map_err(map_qp_build_err)?;
616 qp_problem.obj_offset = 0.0;
618 Ok(qp_problem)
619 }
620
621 fn solve_qp_internal(
623 &self,
624 c: Vec<f64>,
625 _lp_a: CscMatrix,
626 _lp_b: Vec<f64>,
627 bounds: Vec<(f64, f64)>,
628 q_orig: CscMatrix,
629 num_model_constraints: usize,
630 ) -> Result<ModelResult, ModelError> {
631 let qp_problem = self.build_qp_problem(c, bounds, q_orig)?;
632
633 let mut opts = otspot_core::options::SolverOptions::default();
634 if let Some(t) = self.timeout_secs {
635 opts.timeout_secs = Some(t);
636 }
637 if let Some(flag) = self.use_ruiz_scaling {
638 opts.use_ruiz_scaling = flag;
639 }
640 if let Some(tol) = self.tolerance {
641 opts.tolerance = Some(tol);
642 }
643 if let Some(n) = self.threads {
644 opts.threads = n;
645 }
646 let qp_result = otspot_core::qp::solve_qp_with(&qp_problem, &opts);
647 let qp_stats = qp_result.stats.clone();
648
649 let fold_dual = |sol: &[f64]| -> Option<Vec<f64>> {
651 if sol.len() == num_model_constraints {
652 Some(sol.to_vec())
653 } else if !sol.is_empty() && num_model_constraints > 0 {
654 let take = num_model_constraints.min(sol.len());
655 Some(sol[..take].to_vec())
656 } else {
657 None
658 }
659 };
660
661 let signed_obj = |raw: f64| -> f64 {
662 let oriented = if self.sense == OptimizationSense::Maximize {
663 -raw
664 } else {
665 raw
666 };
667 oriented + self.obj_offset + self.obj_expr_constant
668 };
669 let qp_model_id = self.model_id;
670 let build_ok = |status: SolveStatus,
671 raw_obj: f64,
672 sol: Vec<f64>,
673 dual: Option<Vec<f64>>,
674 bd: Vec<f64>| {
675 let proof = SolutionProof::from_status(&status);
676 ModelResult {
677 model_id: qp_model_id,
678 status,
679 proof,
680 objective_value: signed_obj(raw_obj),
681 solution: sol,
682 dual_solution: dual,
683 reduced_costs: None,
684 slack: None,
685 bound_duals: bd,
686 stats: qp_stats.clone(),
687 }
688 };
689
690 match qp_result.status {
691 SolveStatus::Optimal => Ok(build_ok(
692 SolveStatus::Optimal,
693 qp_result.objective,
694 qp_result.solution,
695 fold_dual(&qp_result.dual_solution),
696 qp_result.bound_duals,
697 )),
698 SolveStatus::MaxIterations => {
699 if qp_result.solution.is_empty() {
700 Err(ModelError::SolveError(SolveError::MaxIterations))
701 } else {
702 Ok(build_ok(
703 SolveStatus::MaxIterations,
704 qp_result.objective,
705 qp_result.solution,
706 fold_dual(&qp_result.dual_solution),
707 qp_result.bound_duals,
708 ))
709 }
710 }
711 SolveStatus::SuboptimalSolution => {
712 if qp_result.solution.is_empty() {
714 Err(ModelError::Timeout)
715 } else {
716 Ok(build_ok(
717 SolveStatus::SuboptimalSolution,
718 qp_result.objective,
719 qp_result.solution,
720 fold_dual(&qp_result.dual_solution),
721 qp_result.bound_duals,
722 ))
723 }
724 }
725 SolveStatus::Timeout => Err(ModelError::Timeout),
726 status @ (SolveStatus::LocallyOptimal
729 | SolveStatus::NonconvexLocal
730 | SolveStatus::NonconvexGlobal) => Ok(build_ok(
731 status,
732 qp_result.objective,
733 qp_result.solution,
734 fold_dual(&qp_result.dual_solution),
735 qp_result.bound_duals,
736 )),
737 s => Err(classify_status_error(s)
738 .unwrap_or_else(|| ModelError::Internal("unhandled QP status".to_string()))),
739 }
740 }
741
742 fn solve_mip_internal(
748 &self,
749 c: Vec<f64>,
750 a: CscMatrix,
751 b: Vec<f64>,
752 constraint_types: Vec<ConstraintType>,
753 bounds: Vec<(f64, f64)>,
754 integer_vars: Vec<usize>,
755 ) -> Result<ModelResult, ModelError> {
756 let mut opts = otspot_core::options::SolverOptions::default();
757 if let Some(t) = self.timeout_secs {
758 opts.timeout_secs = Some(t);
759 }
760 if let Some(tol) = self.tolerance {
761 opts.tolerance = Some(tol);
762 }
763 if let Some(n) = self.threads {
764 opts.threads = n;
765 }
766 let cfg = otspot_core::options::MipConfig::default();
767
768 let result = if let Some(ref q_orig) = self.quadratic_objective.clone() {
769 if let Some(flag) = self.use_ruiz_scaling {
771 opts.use_ruiz_scaling = flag;
772 }
773 let qp = self.build_qp_problem(c, bounds, q_orig.clone())?;
774 let miqp = otspot_core::mip::MiqpProblem::new(qp, integer_vars.clone())
775 .map_err(|e| ModelError::Internal(e.to_string()))?;
776 otspot_core::mip::solve_miqp(&miqp, &opts, &cfg)
777 } else {
778 if let Some(flag) = self.presolve {
780 opts.presolve = flag;
781 }
782 let lp = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
783 .map_err(map_lp_build_err)?;
784 let milp = otspot_core::mip::MilpProblem::new(lp, integer_vars.clone())
785 .map_err(|e| ModelError::Internal(e.to_string()))?;
786 otspot_core::mip::solve_milp(&milp, &opts, &cfg)
787 };
788
789 self.finish_mip(result, &integer_vars)
790 }
791
792 fn finish_mip(
795 &self,
796 result: otspot_core::problem::SolverResult,
797 integer_vars: &[usize],
798 ) -> Result<ModelResult, ModelError> {
799 let signed_obj = |raw: f64| -> f64 {
800 let oriented = if self.sense == OptimizationSense::Maximize {
801 -raw
802 } else {
803 raw
804 };
805 oriented + self.obj_offset + self.obj_expr_constant
806 };
807
808 let round_integers = |mut sol: Vec<f64>| -> Vec<f64> {
810 for &j in integer_vars {
811 if j < sol.len() {
812 sol[j] = sol[j].round();
813 }
814 }
815 sol
816 };
817
818 let mip_model_id = self.model_id;
819 let build_ok = |sr: otspot_core::problem::SolverResult| {
820 let status = sr.status.clone();
821 ModelResult {
822 model_id: mip_model_id,
823 status: status.clone(),
824 proof: SolutionProof::from_status(&status),
825 objective_value: signed_obj(sr.objective),
826 solution: round_integers(sr.solution),
827 dual_solution: None,
828 reduced_costs: None,
829 slack: None,
830 bound_duals: vec![],
831 stats: sr.stats,
832 }
833 };
834
835 match result.status {
836 SolveStatus::Optimal => Ok(build_ok(result)),
837 SolveStatus::Timeout => {
838 if result.solution.is_empty() {
840 Err(ModelError::Timeout)
841 } else {
842 Ok(build_ok(result))
843 }
844 }
845 SolveStatus::SuboptimalSolution | SolveStatus::MaxIterations => {
846 if result.solution.is_empty() {
847 Err(ModelError::SolveError(SolveError::MaxIterations))
848 } else {
849 Ok(build_ok(result))
850 }
851 }
852 SolveStatus::LocallyOptimal
853 | SolveStatus::NonconvexLocal
854 | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
855 "Unexpected nonconvex status on MIP path".to_string(),
856 )),
857 s => Err(classify_status_error(s)
858 .unwrap_or_else(|| ModelError::Internal("unhandled MIP status".to_string()))),
859 }
860 }
861
862 fn record_input_error(&mut self, key: &'static str, err: ModelError) {
863 let msg = match err {
864 ModelError::InvalidInput(msg) => msg,
865 other => other.to_string(),
866 };
867 self.invalid_inputs.insert(key, msg);
868 }
869}
870
871fn classify_status_error(status: SolveStatus) -> Option<ModelError> {
879 match status {
880 SolveStatus::Infeasible => Some(ModelError::SolveError(SolveError::Infeasible)),
881 SolveStatus::Unbounded => Some(ModelError::SolveError(SolveError::Unbounded)),
882 SolveStatus::NumericalError => Some(ModelError::SolveError(SolveError::NumericalError)),
883 SolveStatus::NonConvex(msg) => Some(ModelError::NonConvex(msg)),
884 SolveStatus::NotSupported(msg) => Some(ModelError::NotSupported(msg)),
885 SolveStatus::Optimal
888 | SolveStatus::LocallyOptimal
889 | SolveStatus::MaxIterations
890 | SolveStatus::SuboptimalSolution
891 | SolveStatus::Timeout
892 | SolveStatus::NonconvexLocal
893 | SolveStatus::NonconvexGlobal => None,
894 _ => None,
901 }
902}
903
904fn validate_timeout(secs: f64) -> Result<(), ModelError> {
905 if secs.is_finite() && secs >= 0.0 {
906 Ok(())
907 } else {
908 Err(ModelError::InvalidInput(format!(
909 "timeout must be finite and non-negative, got {secs}"
910 )))
911 }
912}
913
914fn map_lp_build_err(e: otspot_core::error::SolverError) -> ModelError {
919 use otspot_core::error::SolverError;
920 match e {
921 SolverError::NonFiniteCoefficient { .. } | SolverError::InvalidBounds { .. } => {
922 ModelError::InvalidInput(e.to_string())
923 }
924 _ => ModelError::Internal(e.to_string()),
925 }
926}
927
928fn map_qp_build_err(e: otspot_core::qp::QpProblemError) -> ModelError {
930 use otspot_core::qp::QpProblemError;
931 match e {
932 QpProblemError::NonFiniteCoefficient { .. }
933 | QpProblemError::InvalidBounds { .. }
934 | QpProblemError::TripletIndexOutOfBounds { .. } => {
935 ModelError::InvalidInput(e.to_string())
936 }
937 QpProblemError::DimensionMismatch(_) => ModelError::Internal(e.to_string()),
938 _ => ModelError::Internal(e.to_string()),
940 }
941}
942
943fn validate_bounds(lb: f64, ub: f64) -> Result<(), ModelError> {
944 if lb.is_nan() || ub.is_nan() {
945 return Err(ModelError::InvalidInput(format!(
946 "variable bounds must not be NaN: lb={lb}, ub={ub}"
947 )));
948 }
949 if lb > ub {
950 return Err(ModelError::InvalidInput(format!(
951 "variable lower bound {lb} exceeds upper bound {ub}"
952 )));
953 }
954 Ok(())
955}
956
957#[non_exhaustive]
963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub enum SolutionProof {
965 GlobalOptimal,
967 LocalOptimal,
969 FeasibleUnproven,
971}
972
973impl SolutionProof {
974 fn from_status(status: &SolveStatus) -> Self {
975 match status {
976 SolveStatus::Optimal | SolveStatus::NonconvexGlobal => SolutionProof::GlobalOptimal,
977 SolveStatus::LocallyOptimal => SolutionProof::LocalOptimal,
978 SolveStatus::MaxIterations
979 | SolveStatus::SuboptimalSolution
980 | SolveStatus::Timeout
981 | SolveStatus::NonconvexLocal => SolutionProof::FeasibleUnproven,
982 SolveStatus::Infeasible
986 | SolveStatus::Unbounded
987 | SolveStatus::NumericalError
988 | SolveStatus::NonConvex(_)
989 | SolveStatus::NotSupported(_) => {
990 debug_assert!(
991 false,
992 "from_status called with error status {:?}: this arm is unreachable from build_ok",
993 status
994 );
995 SolutionProof::FeasibleUnproven
996 }
997 _ => SolutionProof::FeasibleUnproven,
999 }
1000 }
1001}
1002
1003#[derive(Debug, Clone)]
1005pub struct ModelResult {
1006 model_id: u64,
1007 pub status: SolveStatus,
1014 pub proof: SolutionProof,
1016 pub objective_value: f64,
1018 solution: Vec<f64>,
1020 pub dual_solution: Option<Vec<f64>>,
1022 pub reduced_costs: Option<Vec<f64>>,
1030 pub slack: Option<Vec<f64>>,
1032 pub bound_duals: Vec<f64>,
1036 pub stats: otspot_core::problem::SolveStats,
1038}
1039
1040#[cfg(test)]
1044impl Model {
1045 pub(crate) fn has_quad_dsl_error(&self) -> bool {
1048 self.quad_dsl_error.is_some()
1049 }
1050
1051 pub(crate) fn is_quad_via_dsl(&self) -> bool {
1053 self.quadratic_objective.is_some()
1054 }
1055
1056 pub(crate) fn has_quadratic_objective(&self) -> bool {
1058 self.quadratic_objective.is_some()
1059 }
1060}
1061
1062impl ModelResult {
1063 pub fn value(&self, var: Variable) -> f64 {
1069 self.solution[var.index]
1070 }
1071
1072 pub fn try_value(&self, var: Variable) -> Result<f64, ModelError> {
1078 if var.model_id != self.model_id {
1079 return Err(ModelError::InvalidInput(
1080 "variable belongs to a different model".to_string(),
1081 ));
1082 }
1083 self.solution.get(var.index).copied().ok_or_else(|| {
1084 ModelError::InvalidInput(format!(
1085 "variable index {} out of range (solution length {})",
1086 var.index,
1087 self.solution.len()
1088 ))
1089 })
1090 }
1091
1092 pub fn objective(&self) -> f64 {
1094 self.objective_value
1095 }
1096
1097 pub fn has_global_optimality_proof(&self) -> bool {
1099 self.proof == SolutionProof::GlobalOptimal
1100 }
1101}
1102
1103impl Index<Variable> for ModelResult {
1115 type Output = f64;
1116 fn index(&self, var: Variable) -> &f64 {
1117 &self.solution[var.index]
1118 }
1119}
1120
1121#[non_exhaustive]
1127#[derive(Debug, Clone, PartialEq)]
1128pub enum SolveError {
1129 Infeasible,
1131 Unbounded,
1133 MaxIterations,
1135 NumericalError,
1137}
1138
1139impl fmt::Display for SolveError {
1140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1141 match self {
1142 SolveError::Infeasible => write!(f, "Problem is infeasible"),
1143 SolveError::Unbounded => write!(f, "Problem is unbounded"),
1144 SolveError::MaxIterations => {
1145 write!(f, "Max iterations reached without optimal solution")
1146 }
1147 SolveError::NumericalError => write!(f, "Numerical breakdown during solve"),
1148 }
1149 }
1150}
1151
1152#[non_exhaustive]
1154#[derive(Debug)]
1155pub enum ModelError {
1156 NoObjective,
1158 InvalidInput(String),
1160 SolveError(SolveError),
1162 Timeout,
1164 NonConvex(String),
1167 NotSupported(String),
1169 Internal(String),
1171}
1172
1173impl fmt::Display for ModelError {
1174 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1175 match self {
1176 ModelError::NoObjective => write!(
1177 f,
1178 "No objective function defined. Call model.minimize() or model.maximize() before solve()."
1179 ),
1180 ModelError::InvalidInput(msg) => write!(f, "Invalid model input: {}", msg),
1181 ModelError::SolveError(e) => write!(f, "Solve failed: {}", e),
1182 ModelError::Timeout => write!(f, "Solver timed out"),
1183 ModelError::NonConvex(msg) => write!(f, "Non-convex problem: {}", msg),
1184 ModelError::NotSupported(msg) => write!(f, "Not supported: {}", msg),
1185 ModelError::Internal(msg) => write!(f, "Internal error: {}", msg),
1186 }
1187 }
1188}
1189
1190impl std::error::Error for ModelError {}
1191
1192#[cfg(test)]
1197mod tests {
1198 use super::{classify_status_error, Model, ModelError, SolutionProof, SolveError};
1199 use crate::variable::Variable;
1200 use otspot_core::problem::SolveStatus;
1201
1202 const EPS: f64 = 2e-3;
1204
1205 fn assert_close(a: f64, b: f64, name: &str) {
1206 assert!(
1207 (a - b).abs() < EPS,
1208 "{}: expected {:.8}, got {:.8}",
1209 name,
1210 b,
1211 a
1212 );
1213 }
1214
1215 fn basic_model() -> (Model, Variable, Variable) {
1222 let mut model = Model::new("basic");
1223 let x = model.add_var("x", 0.0, f64::INFINITY);
1224 let y = model.add_var("y", 0.0, 10.0);
1225 model.add_constraint((2.0 * x + 3.0 * y).leq(12.0));
1227 model.add_constraint((x + y).geq(3.0));
1228 model.minimize(x + 2.0 * y);
1229 (model, x, y)
1230 }
1231
1232 #[test]
1236 fn test_basic_lp_3var_3con() {
1237 let mut model = Model::new("3var");
1243 let x = model.add_var("x", 0.0, f64::INFINITY);
1244 let y = model.add_var("y", 0.0, f64::INFINITY);
1245 let z = model.add_var("z", 0.0, f64::INFINITY);
1246
1247 model.add_constraint((x + y + z).geq(6.0));
1249 model.add_constraint((x + 2.0 * y).leq(10.0));
1250 model.add_constraint((y + z).geq(4.0));
1251 model.minimize(x + 2.0 * y + 3.0 * z);
1252
1253 let result = model.solve().unwrap();
1254 assert!(result[x] + result[y] + result[z] >= 6.0 - 1e-6);
1256 assert!(result[y] + result[z] >= 4.0 - 1e-6);
1257 assert!(result[x] >= -1e-9);
1258 assert!(result[y] >= -1e-9);
1259 assert!(result[z] >= -1e-9);
1260 assert!(result.objective_value > 0.0, "objective should be positive");
1261 }
1262
1263 #[test]
1267 fn test_unbounded() {
1268 let mut model = Model::new("unbounded");
1270 let x = model.add_var("x", 0.0, f64::INFINITY);
1271 model.minimize(-1.0 * x);
1272
1273 let err = model.solve().unwrap_err();
1274 assert!(
1275 matches!(err, ModelError::SolveError(SolveError::Unbounded)),
1276 "expected Unbounded, got {:?}",
1277 err
1278 );
1279 }
1280
1281 #[test]
1285 fn test_infeasible() {
1286 let mut model = Model::new("infeasible");
1288 let x = model.add_var("x", 0.0, f64::INFINITY);
1289 model.add_constraint(crate::constraint!(x >= 5.0));
1291 model.add_constraint(crate::constraint!(x <= 3.0));
1292 model.minimize(x);
1293
1294 let err = model.solve().unwrap_err();
1295 assert!(
1296 matches!(err, ModelError::SolveError(SolveError::Infeasible)),
1297 "expected Infeasible, got {:?}",
1298 err
1299 );
1300 }
1301
1302 #[test]
1306 fn test_equality_constraint() {
1307 let mut model = Model::new("eq");
1310 let x = model.add_var("x", 0.0, f64::INFINITY);
1311 let y = model.add_var("y", 0.0, f64::INFINITY);
1312 model.add_constraint((x + y).eq_constraint(5.0));
1314 model.minimize(x + y);
1315
1316 let result = model.solve().unwrap();
1317 assert!(
1318 (result.objective_value - 5.0).abs() < 1e-6,
1319 "obj={} expected 5.0",
1320 result.objective_value
1321 );
1322 }
1323
1324 #[test]
1328 fn test_variable_bounds() {
1329 let mut model = Model::new("bounds");
1332 let x = model.add_var("x", 0.0, 3.0);
1333 model.minimize(x);
1334
1335 let result = model.solve().unwrap();
1336 assert!(result[x].abs() < 1e-6, "x should be 0.0, got {}", result[x]);
1337
1338 let mut model2 = Model::new("bounds_max");
1342 let x2 = model2.add_var("x", 0.0, 3.0);
1343 model2.add_constraint(crate::constraint!(x2 <= 3.0));
1344 model2.maximize(x2);
1345
1346 let result2 = model2.solve().unwrap();
1347 assert!(
1348 (result2[x2] - 3.0).abs() < 1e-6,
1349 "x should be 3.0, got {}",
1350 result2[x2]
1351 );
1352 }
1353
1354 #[test]
1358 fn test_no_objective_error() {
1359 let mut model = Model::new("no_obj");
1360 let _x = model.add_var("x", 0.0, f64::INFINITY);
1361 let err = model.solve().unwrap_err();
1362 assert!(matches!(err, ModelError::NoObjective));
1363 }
1364
1365 #[test]
1366 fn solution_proof_mapping_preserves_unproven_statuses() {
1367 assert_eq!(
1368 SolutionProof::from_status(&SolveStatus::Optimal),
1369 SolutionProof::GlobalOptimal
1370 );
1371 assert_eq!(
1372 SolutionProof::from_status(&SolveStatus::NonconvexGlobal),
1373 SolutionProof::GlobalOptimal
1374 );
1375 assert_eq!(
1376 SolutionProof::from_status(&SolveStatus::LocallyOptimal),
1377 SolutionProof::LocalOptimal
1378 );
1379 assert_eq!(
1380 SolutionProof::from_status(&SolveStatus::NonconvexLocal),
1381 SolutionProof::FeasibleUnproven
1382 );
1383 assert_eq!(
1384 SolutionProof::from_status(&SolveStatus::Timeout),
1385 SolutionProof::FeasibleUnproven
1386 );
1387 assert_eq!(
1388 SolutionProof::from_status(&SolveStatus::SuboptimalSolution),
1389 SolutionProof::FeasibleUnproven
1390 );
1391 }
1392
1393 #[test]
1397 fn test_result_index_and_value_agree() {
1398 let (mut model, x, y) = basic_model();
1399 let result = model.solve().unwrap();
1400 assert!((result[x] - result.value(x)).abs() < 1e-12);
1401 assert!((result[y] - result.value(y)).abs() < 1e-12);
1402 }
1403
1404 #[test]
1408 fn test_maximize() {
1409 let mut model = Model::new("max_simple");
1411 let x = model.add_var("x", 0.0, f64::INFINITY);
1412 model.add_constraint(crate::constraint!(x <= 7.0));
1414 model.maximize(x);
1415
1416 let result = model.solve().unwrap();
1417 assert!(
1418 (result[x] - 7.0).abs() < 1e-6,
1419 "expected x=7, got {}",
1420 result[x]
1421 );
1422 assert!(
1423 (result.objective() - 7.0).abs() < 1e-6,
1424 "expected obj=7, got {}",
1425 result.objective()
1426 );
1427 }
1428
1429 #[test]
1433 fn test_model_qp_basic() {
1434 let mut model = Model::new("qp_basic");
1437 let x = model.add_var("x", 0.0, f64::INFINITY);
1438 let y = model.add_var("y", 0.0, f64::INFINITY);
1439 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1440
1441 let result = model.solve().unwrap();
1442 assert_close(result[x], 2.0, "T9: x");
1443 assert_close(result[y], 2.0, "T9: y");
1444 assert_close(result.objective_value, -8.0, "T9: obj");
1446 }
1447
1448 #[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]
1471 fn test_model_qp_ge_constraint() {
1472 let mut model = Model::new("qp_ge");
1475 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1476 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1477 model.add_constraint((x + y).geq(1.0));
1478 model.minimize(x * x + y * y);
1479
1480 let result = model.solve().unwrap();
1481 let qp_tol = 2e-3;
1482 assert!(
1483 (result[x] - 0.5).abs() < qp_tol,
1484 "T11: x expected 0.5, got {}",
1485 result[x]
1486 );
1487 assert!(
1488 (result[y] - 0.5).abs() < qp_tol,
1489 "T11: y expected 0.5, got {}",
1490 result[y]
1491 );
1492 assert!(
1493 (result.objective_value - 0.5).abs() < qp_tol,
1494 "T11: obj expected 0.5, got {}",
1495 result.objective_value
1496 );
1497 }
1498
1499 #[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]
1522 fn test_model_qp_box_bounds() {
1523 let mut model = Model::new("qp_box");
1527 let x = model.add_var("x", 0.0, 1.0);
1528 let y = model.add_var("y", 0.0, 1.0);
1529 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1530
1531 let result = model.solve().unwrap();
1532 assert_close(result[x], 1.0, "T13: x");
1533 assert_close(result[y], 1.0, "T13: y");
1534 assert_close(result.objective_value, -6.0, "T13: obj");
1535 }
1536
1537 #[test]
1541 fn test_model_qp_timeout() {
1542 let mut model = Model::new("qp_timeout");
1544 let x = model.add_var("x", 0.0, f64::INFINITY);
1545 let y = model.add_var("y", 0.0, f64::INFINITY);
1546 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1547 model.set_timeout(0.000_001); let err = model.solve().unwrap_err();
1550 assert!(
1551 matches!(err, ModelError::Timeout),
1552 "expected Timeout, got {:?}",
1553 err
1554 );
1555 }
1556
1557 #[test]
1561 fn test_model_lp_equality() {
1562 let mut model = Model::new("lp_eq");
1565 let x = model.add_var("x", 0.0, f64::INFINITY);
1566 let y = model.add_var("y", 0.0, f64::INFINITY);
1567 model.add_constraint((x + y).eq_constraint(4.0));
1568 model.minimize(x + 2.0 * y);
1569
1570 let result = model.solve().unwrap();
1571 assert_close(result.objective_value, 4.0, "T8-1: obj");
1572 assert_close(result[x] + result[y], 4.0, "T8-1: x+y=4");
1574 }
1575
1576 #[test]
1580 fn test_model_eq_dual_solution() {
1581 let mut model = Model::new("qp_eq_dual");
1585 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1586 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1587 model.add_constraint((x + y).eq_constraint(1.0));
1588 model.minimize(x * x + y * y);
1589
1590 let result = model.solve().unwrap();
1591 assert_close(result.objective_value, 0.5, "T8-2: obj");
1592 assert_close(result[x], 0.5, "T8-2: x");
1593 assert_close(result[y], 0.5, "T8-2: y");
1594
1595 let dual = result
1597 .dual_solution
1598 .as_ref()
1599 .expect("T8-2: dual_solution is None");
1600 assert!(
1601 dual.len() == 1,
1602 "T8-2: dual length expected 1, got {}",
1603 dual.len()
1604 );
1605 assert!(
1606 (dual[0] - (-1.0)).abs() < EPS,
1607 "T8-2: dual[0] expected -1.0, got {}",
1608 dual[0]
1609 );
1610 }
1611
1612 #[test]
1619 #[allow(clippy::type_complexity)]
1620 fn test_model_qp_locally_optimal_proof() {
1621 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1623 ("diag(-2,2)", [-2.0, 2.0], (-1.0, 1.0), [0.0, 0.0]),
1625 ("diag(-3,5)", [-3.0, 5.0], (-2.0, 2.0), [-1.0, 0.0]),
1627 ];
1628
1629 for &(name, q_diag, (lb, ub), c) in cases {
1630 let mut model = Model::new(name);
1631 let x = model.add_var("x0", lb, ub);
1632 let y = model.add_var("x1", lb, ub);
1633 model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1635
1636 let result = model.solve();
1637 match result {
1638 Ok(r) => {
1639 assert_eq!(
1640 r.status,
1641 otspot_core::problem::SolveStatus::LocallyOptimal,
1642 "[{name}] expected LocallyOptimal, got {:?}",
1643 r.status
1644 );
1645 assert_eq!(
1646 r.proof,
1647 SolutionProof::LocalOptimal,
1648 "[{name}] expected LocalOptimal proof, got {:?}",
1649 r.proof
1650 );
1651 assert!(
1652 !r.has_global_optimality_proof(),
1653 "[{name}] has_global_optimality_proof must be false for LocallyOptimal"
1654 );
1655 }
1656 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1657 }
1658 }
1659 }
1660
1661 #[test]
1669 #[allow(clippy::type_complexity)]
1670 fn test_model_qp_feasible_unproven_proof() {
1671 use otspot_core::options::Tolerance;
1672
1673 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1675 ("convex_2x2_tight_tol", [2.0, 2.0], (0.0, f64::INFINITY), [-4.0, -4.0]),
1678 ("convex_box_tight_tol", [4.0, 4.0], (0.0, 3.0), [0.0, -2.0]),
1680 ];
1681
1682 for &(name, q_diag, (lb, ub), c) in cases {
1683 let mut model = Model::new(name);
1684 let x = model.add_var("x0", lb, ub);
1685 let y = model.add_var("x1", lb, ub);
1686 model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1687 model.set_tolerance(Tolerance::Custom(1e-200));
1690
1691 let result = model.solve();
1692 match result {
1693 Ok(r) => {
1694 assert_eq!(
1695 r.proof,
1696 SolutionProof::FeasibleUnproven,
1697 "[{name}] expected FeasibleUnproven proof, got {:?} (status={:?})",
1698 r.proof, r.status
1699 );
1700 assert!(
1701 !r.has_global_optimality_proof(),
1702 "[{name}] has_global_optimality_proof must be false for FeasibleUnproven"
1703 );
1704 assert!(!r.solution.is_empty(), "[{name}] solution must be non-empty");
1705 }
1706 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1707 }
1708 }
1709 }
1710
1711 #[test]
1716 fn classify_status_error_typed_variants() {
1717 let cases_nonconvex = [
1718 "indefinite Q: eigenvalue < 0",
1719 "non-PSD matrix in MIQP",
1720 ];
1721 for msg in &cases_nonconvex {
1722 let status = SolveStatus::NonConvex(msg.to_string());
1723 let err = classify_status_error(status).expect("NonConvex must map to Some");
1724 assert!(
1725 matches!(err, ModelError::NonConvex(_)),
1726 "NonConvex status must yield ModelError::NonConvex, got {:?}",
1727 err
1728 );
1729 }
1730
1731 let cases_not_supported = [
1732 "QCQP not supported",
1733 "constraint type unsupported",
1734 ];
1735 for msg in &cases_not_supported {
1736 let status = SolveStatus::NotSupported(msg.to_string());
1737 let err = classify_status_error(status).expect("NotSupported must map to Some");
1738 assert!(
1739 matches!(err, ModelError::NotSupported(_)),
1740 "NotSupported status must yield ModelError::NotSupported, got {:?}",
1741 err
1742 );
1743 }
1744 }
1745
1746 #[test]
1752 fn miqp_nonconvex_q_returns_nonconvex_error() {
1753 let cases: &[(&str, [f64; 2])] = &[
1754 ("diag(-1, 1)", [-1.0, 1.0]),
1755 ("diag(-2, 3)", [-2.0, 3.0]),
1756 ("diag(1, -1)", [1.0, -1.0]),
1757 ];
1758
1759 for &(name, q_diag) in cases {
1760 let mut model = Model::new(name);
1761 let x = model.add_binary_var("x");
1762 let y = model.add_binary_var("y");
1763 model.minimize((q_diag[0] / 2.0) * (x * x) + (q_diag[1] / 2.0) * (y * y));
1765
1766 let err = model
1767 .solve()
1768 .expect_err(&format!("[{name}] expected Err(NonConvex), got Ok"));
1769 assert!(
1770 matches!(err, ModelError::NonConvex(_)),
1771 "[{name}] expected ModelError::NonConvex, got {:?}",
1772 err
1773 );
1774 }
1775 }
1776
1777 #[test]
1784 fn add_var_lb_gt_ub_defers_error_to_solve() {
1785 let cases: &[(&str, f64, f64)] = &[
1786 ("lb=5 > ub=3", 5.0, 3.0),
1787 ("lb=1.0 > ub=0.999", 1.0, 0.999),
1788 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1789 ];
1790 for &(label, lb, ub) in cases {
1791 let mut model = Model::new(label);
1792 let x = model.add_var("x", lb, ub);
1793 model.minimize(x);
1794 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1795 assert!(
1796 matches!(err, ModelError::InvalidInput(_)),
1797 "[{label}] expected InvalidInput, got {err:?}"
1798 );
1799 }
1800 }
1801
1802 #[test]
1803 fn add_var_nan_bounds_defers_error_to_solve() {
1804 let cases: &[(&str, f64, f64)] = &[
1805 ("lb=NaN", f64::NAN, 1.0),
1806 ("ub=NaN", 0.0, f64::NAN),
1807 ("both=NaN", f64::NAN, f64::NAN),
1808 ];
1809 for &(label, lb, ub) in cases {
1810 let mut model = Model::new(label);
1811 let x = model.add_var("x", lb, ub);
1812 model.minimize(x);
1813 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1814 assert!(
1815 matches!(err, ModelError::InvalidInput(_)),
1816 "[{label}] expected InvalidInput, got {err:?}"
1817 );
1818 }
1819 }
1820
1821 #[test]
1822 fn add_int_var_lb_gt_ub_defers_error_to_solve() {
1823 let cases: &[(&str, f64, f64)] = &[
1824 ("int lb=3 > ub=1", 3.0, 1.0),
1825 ("int lb=NaN", f64::NAN, 5.0),
1826 ];
1827 for &(label, lb, ub) in cases {
1828 let mut model = Model::new(label);
1829 let x = model.add_int_var("x", lb, ub);
1830 model.minimize(x);
1831 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1832 assert!(
1833 matches!(err, ModelError::InvalidInput(_)),
1834 "[{label}] expected InvalidInput, got {err:?}"
1835 );
1836 }
1837 }
1838
1839 #[test]
1844 fn try_add_var_returns_err_for_invalid_bounds() {
1845 let cases: &[(&str, f64, f64)] = &[
1846 ("lb>ub", 2.0, 1.0),
1847 ("lb=NaN", f64::NAN, 1.0),
1848 ("ub=NaN", 0.0, f64::NAN),
1849 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1850 ];
1851 for &(label, lb, ub) in cases {
1852 let mut model = Model::new(label);
1853 let result = model.try_add_var("x", lb, ub);
1854 assert!(
1855 result.is_err(),
1856 "[{label}] try_add_var should return Err for invalid bounds, got Ok"
1857 );
1858 }
1859 }
1860
1861 #[test]
1862 fn try_add_var_returns_ok_for_valid_bounds() {
1863 let cases: &[(&str, f64, f64)] = &[
1864 ("lb=ub", 3.0, 3.0),
1865 ("lb=0 ub=inf", 0.0, f64::INFINITY),
1866 ("lb=-inf ub=inf", f64::NEG_INFINITY, f64::INFINITY),
1867 ("lb=-inf ub=0", f64::NEG_INFINITY, 0.0),
1868 ];
1869 for &(label, lb, ub) in cases {
1870 let mut model = Model::new(label);
1871 assert!(
1872 model.try_add_var("x", lb, ub).is_ok(),
1873 "[{label}] try_add_var should return Ok for valid bounds"
1874 );
1875 }
1876 }
1877
1878 #[test]
1879 fn try_add_int_var_returns_err_for_invalid_bounds() {
1880 let cases: &[(&str, f64, f64)] = &[
1881 ("int lb>ub", 5.0, 2.0),
1882 ("int lb=NaN", f64::NAN, 3.0),
1883 ];
1884 for &(label, lb, ub) in cases {
1885 let mut model = Model::new(label);
1886 assert!(
1887 model.try_add_int_var("n", lb, ub).is_err(),
1888 "[{label}] try_add_int_var should return Err"
1889 );
1890 }
1891 }
1892
1893 #[test]
1899 fn try_value_cross_model_returns_err() {
1900 let mut m1 = Model::new("m1");
1901 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
1902 m1.minimize(x1);
1903 let r1 = m1.solve().unwrap();
1904
1905 let mut m2 = Model::new("m2");
1907 let y = m2.add_var("y", 0.0, f64::INFINITY);
1908
1909 assert!(
1910 r1.try_value(y).is_err(),
1911 "try_value with variable from different model must return Err"
1912 );
1913 assert!(r1.try_value(x1).is_ok());
1915 }
1916
1917 #[test]
1918 fn try_value_valid_returns_ok() {
1919 let (mut model, x, y) = basic_model();
1920 let result = model.solve().unwrap();
1921 assert!(result.try_value(x).is_ok());
1922 assert!(result.try_value(y).is_ok());
1923 assert!((result.try_value(x).unwrap() - result.value(x)).abs() < 1e-12);
1924 }
1925
1926 #[test]
1931 fn try_value_out_of_range_same_model_returns_err() {
1932 let mut model = Model::new("grow");
1933 let x = model.add_var("x", 0.0, f64::INFINITY);
1934 model.minimize(x);
1935 let result = model.solve().unwrap();
1936
1937 let late = model.add_var("late", 0.0, f64::INFINITY);
1939 assert_eq!(late.model_id, result.model_id, "same model_id expected");
1940 assert!(
1941 late.index >= result.solution.len(),
1942 "test setup: late var must be out of range"
1943 );
1944 assert!(
1945 result.try_value(late).is_err(),
1946 "try_value must return Err for an out-of-range index even when model_id matches"
1947 );
1948 }
1949
1950 #[test]
1954 fn model_result_clone() {
1955 let (mut model, x, _y) = basic_model();
1956 let result = model.solve().unwrap();
1957 let cloned = result.clone();
1958 assert!((cloned.objective_value - result.objective_value).abs() < 1e-12);
1959 assert_eq!(cloned.solution.len(), result.solution.len());
1960 assert!((cloned[x] - result[x]).abs() < 1e-12);
1961 }
1962
1963 #[test]
1967 fn var_name_is_retained() {
1968 let cases = [("alpha", 0.0, 1.0), ("beta_var", 0.0, f64::INFINITY)];
1969 let mut model = Model::new("named");
1970 for &(name, lb, ub) in &cases {
1971 let v = model.add_var(name, lb, ub);
1972 assert_eq!(model.var_name(v), name, "var_name mismatch for '{name}'");
1973 }
1974 let iv = model.add_int_var("gamma_int", 0.0, 10.0);
1975 assert_eq!(model.var_name(iv), "gamma_int");
1976 }
1977
1978 #[test]
1983 fn set_timeout_invalid_defers_error() {
1984 let cases: &[(&str, f64)] = &[
1985 ("negative", -1.0),
1986 ("NaN", f64::NAN),
1987 ("neg_inf", f64::NEG_INFINITY),
1988 ];
1989 for &(label, secs) in cases {
1990 let mut model = Model::new(label);
1991 let x = model.add_var("x", 0.0, f64::INFINITY);
1992 model.minimize(x);
1993 model.set_timeout(secs);
1994 let err = model.solve().expect_err(&format!("[{label}] expected Err for invalid timeout"));
1995 assert!(
1996 matches!(err, ModelError::InvalidInput(_)),
1997 "[{label}] expected InvalidInput, got {err:?}"
1998 );
1999 }
2000 }
2001
2002 #[test]
2003 fn try_set_timeout_returns_err_for_invalid() {
2004 let cases: &[(&str, f64)] = &[("negative", -0.001), ("NaN", f64::NAN), ("inf", f64::INFINITY)];
2005 for &(label, secs) in cases {
2006 let mut model = Model::new(label);
2007 assert!(
2008 model.try_set_timeout(secs).is_err(),
2009 "[{label}] try_set_timeout should return Err"
2010 );
2011 }
2012 }
2013
2014 #[test]
2015 fn try_set_timeout_ok_for_valid() {
2016 let valid = [0.0, 0.001, 1.0, 3600.0];
2017 for &secs in &valid {
2018 let mut model = Model::new("t");
2019 assert!(model.try_set_timeout(secs).is_ok(), "should be Ok for secs={secs}");
2020 }
2021 }
2022}
2023
2024#[cfg(test)]
2033mod mip_model_tests {
2034 use super::{Model, ModelError, SolveError};
2035
2036 const EPS: f64 = 1e-4;
2037
2038 #[test]
2039 fn model_add_int_var_maximize_branches() {
2040 let mut m = Model::new("milp_int");
2042 let x = m.add_int_var("x", 0.0, 5.0);
2043 m.add_constraint((2.0 * x).leq(3.0));
2044 m.maximize(x);
2045 let r = m.solve().unwrap();
2046 assert!((r.objective() - 1.0).abs() < EPS, "obj={}", r.objective());
2047 assert!((r[x] - 1.0).abs() < EPS, "x={}", r[x]);
2048 }
2049
2050 #[test]
2051 fn model_binary_knapsack() {
2052 let mut m = Model::new("knapsack");
2053 let a = m.add_binary_var("a");
2054 let b = m.add_binary_var("b");
2055 let c = m.add_binary_var("c");
2056 let d = m.add_binary_var("d");
2057 m.add_constraint((5.0 * a + 7.0 * b + 4.0 * c + 3.0 * d).leq(14.0));
2058 m.maximize(8.0 * a + 11.0 * b + 6.0 * c + 4.0 * d);
2059 let r = m.solve().unwrap();
2060 assert!((r.objective() - 21.0).abs() < EPS, "obj={}", r.objective());
2061 assert_eq!(
2062 (r[a].round(), r[b].round(), r[c].round(), r[d].round()),
2063 (0.0, 1.0, 1.0, 1.0)
2064 );
2065 }
2066
2067 #[test]
2068 fn model_integer_infeasible_errors() {
2069 let mut m = Model::new("infeasible");
2070 let x = m.add_int_var("x", 0.0, 10.0);
2071 m.add_constraint((1.0 * x).geq(1.2));
2072 m.add_constraint((1.0 * x).leq(1.8));
2073 m.minimize(x);
2074 let err = m.solve().unwrap_err();
2075 assert!(matches!(err, ModelError::SolveError(SolveError::Infeasible)), "got {err:?}");
2076 }
2077
2078 #[test]
2079 fn model_integer_unbounded_errors() {
2080 let mut m = Model::new("unbounded");
2081 let x = m.add_int_var("x", 0.0, f64::INFINITY);
2082 m.maximize(x);
2083 let err = m.solve().unwrap_err();
2084 assert!(matches!(err, ModelError::SolveError(SolveError::Unbounded)), "got {err:?}");
2085 }
2086
2087 #[test]
2088 fn model_convex_miqp_branches_to_integer_optimum() {
2089 let mut m = Model::new("convex_miqp");
2092 let x = m.add_int_var("x", 0.0, 5.0);
2093 m.minimize(x * x + (-5.0) * x);
2094 let r = m.solve().unwrap();
2095 assert!((r.objective() - (-6.0)).abs() < EPS, "obj={}", r.objective());
2096 let xr = r[x].round();
2097 assert!(xr == 2.0 || xr == 3.0, "x must be 2 or 3, got {}", r[x]);
2098 assert!((r[x] - xr).abs() < EPS, "x must be integral: {}", r[x]);
2099 }
2100
2101 #[test]
2102 fn model_nonconvex_miqp_errors() {
2103 let cases: &[(&str, &[f64], &[f64])] = &[
2105 ("single neg", &[-2.0], &[1.0]),
2106 ("neg-pos-2var", &[-3.0, 2.0], &[0.0, 1.0]),
2107 ];
2108 for &(name, q_diag, c_vec) in cases {
2109 let n = q_diag.len();
2110 let mut m = Model::new(name);
2111 let vars: Vec<_> = (0..n).map(|i| m.add_int_var(&format!("x{i}"), 0.0, 5.0)).collect();
2112 let obj = vars.iter().zip(q_diag).zip(c_vec).fold(
2114 crate::quad_expr::QuadExpr::from(0.0_f64),
2115 |acc, ((&v, &d), &c)| acc + (d / 2.0) * (v * v) + c * v,
2116 );
2117 m.minimize(obj);
2118 let err = m.solve().unwrap_err();
2119 assert!(
2120 matches!(err, ModelError::NonConvex(_)),
2121 "[{name}] expected ModelError::NonConvex, got {err:?}"
2122 );
2123 }
2124 }
2125
2126 #[test]
2127 fn model_mixed_integer_continuous() {
2128 let mut m = Model::new("mixed");
2131 let x = m.add_int_var("x", 0.0, 5.0);
2132 let y = m.add_var("y", 0.0, 5.0);
2133 m.add_constraint((x + y).leq(3.5));
2134 m.maximize(x + y);
2135 let r = m.solve().unwrap();
2136 assert!((r.objective() - 3.5).abs() < EPS, "obj={}", r.objective());
2137 assert!((r[x].round() - r[x]).abs() < EPS, "x must be integral, x={}", r[x]);
2138 }
2139
2140 #[test]
2144 fn test_linear_objective_constant_included() {
2145 let mut model = Model::new("lin_const");
2147 let x = model.add_var("x", 1.0, f64::INFINITY);
2148 model.minimize(2.0 * x + 3.0);
2149 let result = model.solve().unwrap();
2150 assert!((result[x] - 1.0).abs() < EPS, "x* should be 1, got {}", result[x]);
2151 assert!(
2152 (result.objective_value - 5.0).abs() < EPS,
2153 "obj* should be 5 (includes constant +3), got {}",
2154 result.objective_value
2155 );
2156 }
2157
2158 #[test]
2160 fn test_quad_objective_constant_included() {
2161 let mut model = Model::new("quad_const");
2164 let x = model.add_var("x", 0.0, f64::INFINITY);
2165 model.minimize(x * x + (-4.0) * x + 4.0);
2166 let result = model.solve().unwrap();
2167 assert!(
2168 (result[x] - 2.0).abs() < 1e-4,
2169 "quad_const: x* should be 2, got {}",
2170 result[x]
2171 );
2172 assert!(
2173 result.objective_value.abs() < 1e-4,
2174 "quad_const: obj* should be 0 (constant +4 included), got {}",
2175 result.objective_value
2176 );
2177 }
2178
2179 #[test]
2181 fn test_maximize_objective_constant_sign() {
2182 let mut model = Model::new("max_const");
2184 let x = model.add_var("x", 0.0, 10.0);
2185 model.maximize((-1.0) * x + 5.0);
2186 let result = model.solve().unwrap();
2187 assert!(
2188 (result[x]).abs() < EPS,
2189 "max_const: x* should be 0, got {}",
2190 result[x]
2191 );
2192 assert!(
2193 (result.objective_value - 5.0).abs() < EPS,
2194 "max_const: obj* should be 5 (constant not negated for max), got {}",
2195 result.objective_value
2196 );
2197 }
2198
2199 #[test]
2201 fn test_obj_offset_and_dsl_constant_no_double_count() {
2202 let mut model = Model::new("offset_plus_const");
2205 let x = model.add_var("x", 1.0, f64::INFINITY);
2206 model.minimize(1.0 * x + 3.0);
2207 model.set_obj_offset(10.0);
2208 let result = model.solve().unwrap();
2209 assert!(
2210 (result.objective_value - 14.0).abs() < EPS,
2211 "offset+const: obj* should be 14 (1+3+10), got {}",
2212 result.objective_value
2213 );
2214 }
2215
2216 #[test]
2218 fn test_reminimize_constant_not_double_counted() {
2219 let mut model = Model::new("reminimize");
2221 let x = model.add_var("x", 1.0, f64::INFINITY);
2222 model.minimize(1.0 * x + 3.0); model.minimize(1.0 * x + 7.0); let result = model.solve().unwrap();
2225 assert!(
2226 (result.objective_value - 8.0).abs() < EPS,
2227 "re-minimize: obj* should be 8 (1+7, not 1+3+7=11), got {}",
2228 result.objective_value
2229 );
2230 }
2231
2232 #[test]
2238 fn test_p2a_nan_dsl_quad_then_linear_is_optimal() {
2239 let mut model = Model::new("p2a_nan_then_linear");
2242 let x = model.add_var("x", 1.0, f64::INFINITY);
2243 model.minimize(f64::NAN * (x * x)); model.minimize(1.0 * x); let result = model.solve();
2246 assert!(result.is_ok(), "P2-a: should be Optimal after linear replaces NaN quad, got {result:?}");
2247 let r = result.unwrap();
2248 assert!((r[x] - 1.0).abs() < EPS, "P2-a: x* should be 1, got {}", r[x]);
2249 }
2250
2251 #[test]
2253 fn test_p2a_valid_quad_then_nan_errors() {
2254 let mut model = Model::new("p2a_valid_then_nan");
2255 let x = model.add_var("x", 0.0, f64::INFINITY);
2256 model.minimize(x * x + (-2.0) * x); model.minimize(f64::NAN * (x * x)); let result = model.solve();
2259 assert!(
2260 matches!(result, Err(ModelError::InvalidInput(_))),
2261 "P2-a: NaN quad must yield InvalidInput, got {result:?}"
2262 );
2263 }
2264
2265 #[test]
2267 fn test_p2a_nan_dsl_then_maximize_linear_is_optimal() {
2268 let mut model = Model::new("p2a_nan_then_max");
2269 let x = model.add_var("x", 0.0, 5.0);
2270 model.minimize(f64::NAN * (x * x)); model.maximize(1.0 * x); let result = model.solve();
2273 assert!(result.is_ok(), "P2-a: maximize should succeed after NaN DSL cleared, got {result:?}");
2274 let r = result.unwrap();
2275 assert!((r[x] - 5.0).abs() < EPS, "P2-a: maximize x → x*=5, got {}", r[x]);
2276 }
2277
2278 #[test]
2281 fn test_quad_state_invariants_transition_table() {
2282 fn mk() -> (Model, crate::variable::Variable) {
2284 let mut m = Model::new("t");
2285 let x = m.add_var("x", 0.0, f64::INFINITY);
2286 (m, x)
2287 }
2288
2289 {
2291 let (mut m, x) = mk();
2292 m.minimize(x * x);
2293 assert!(!m.has_quad_dsl_error(), "DSL ok: no error");
2294 assert!(m.is_quad_via_dsl(), "DSL ok: via_dsl=true");
2295 assert!(m.has_quadratic_objective(),"DSL ok: has_q=true");
2296 }
2297
2298 {
2300 let (mut m, x) = mk();
2301 m.minimize(f64::NAN * (x * x));
2302 assert!(m.has_quad_dsl_error(), "DSL fail: error recorded");
2303 assert!(!m.is_quad_via_dsl(), "DSL fail: via_dsl=false");
2304 assert!(!m.has_quadratic_objective(),"DSL fail: has_q=false");
2305 }
2306
2307 {
2309 let (mut m, x) = mk();
2310 m.minimize(x * x);
2311 m.minimize(f64::NAN * (x * x));
2312 assert!(m.has_quad_dsl_error(), "ok→fail: error recorded");
2313 assert!(!m.is_quad_via_dsl(), "ok→fail: via_dsl=false");
2314 assert!(!m.has_quadratic_objective(),"ok→fail: has_q=false");
2315 }
2316
2317 {
2319 let (mut m, x) = mk();
2320 m.minimize(x * x);
2321 m.minimize(1.0 * x);
2322 assert!(!m.has_quad_dsl_error(), "dsl→linear: no error");
2323 assert!(!m.is_quad_via_dsl(), "dsl→linear: via_dsl=false");
2324 assert!(!m.has_quadratic_objective(),"dsl→linear: DSL Q cleared");
2325 }
2326 }
2327
2328 #[test]
2330 fn test_quad_state_solve_outcome_table() {
2331 let cases: &[(&str, bool)] = &[
2332 ("nan_then_linear", true),
2335 ("nan_alone", false),
2337 ("ok_then_nan", false),
2339 ("nan_then_valid_quad", true),
2341 ("nan_nan_then_valid_quad", true),
2343 ];
2344
2345 for &(name, expect_ok) in cases {
2346 let mut m = Model::new(name);
2347 let x = m.add_var("x", 0.0, f64::INFINITY);
2348
2349 match name {
2350 "nan_then_linear" => {
2351 m.minimize(f64::NAN * (x * x) + x);
2352 m.minimize(1.0 * x);
2353 }
2354 "nan_alone" => {
2355 m.minimize(f64::NAN * (x * x) + x);
2356 }
2357 "ok_then_nan" => {
2358 m.minimize(x * x + (-4.0) * x);
2359 m.minimize(f64::NAN * (x * x) + x);
2360 }
2361 "nan_then_valid_quad" => {
2362 m.minimize(f64::NAN * (x * x));
2363 m.minimize(x * x + (-4.0) * x);
2364 }
2365 "nan_nan_then_valid_quad" => {
2366 m.minimize(f64::NAN * (x * x));
2367 m.minimize(f64::NAN * (x * x));
2368 m.minimize(x * x + (-4.0) * x);
2369 }
2370 _ => unreachable!(),
2371 }
2372
2373 let result = m.solve();
2374 if expect_ok {
2375 assert!(
2376 result.is_ok(),
2377 "case '{name}': expected Optimal, got {result:?}"
2378 );
2379 } else {
2380 assert!(
2381 matches!(result, Err(ModelError::InvalidInput(_))),
2382 "case '{name}': expected InvalidInput, got {result:?}"
2383 );
2384 }
2385 }
2386 }
2387
2388 #[test]
2394 fn test_p2f_mixed_quad_local_linear_foreign_rejected() {
2395 let mut m1 = Model::new("m1");
2396 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2397
2398 let mut m2 = Model::new("m2");
2399 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2400
2401 m1.minimize(x1 * x1 + y2);
2403 let result = m1.solve();
2404 assert!(
2405 matches!(result, Err(ModelError::InvalidInput(_))),
2406 "P2-f mixed: foreign linear term must give InvalidInput, got {result:?}"
2407 );
2408 }
2409
2410 #[test]
2412 fn test_p2f_pure_linear_foreign_rejected() {
2413 let mut m1 = Model::new("m1");
2414 let _x1 = m1.add_var("x", 0.0, f64::INFINITY);
2415
2416 let mut m2 = Model::new("m2");
2417 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2418
2419 m1.minimize(1.0 * y2);
2421 let result = m1.solve();
2422 assert!(
2423 matches!(result, Err(ModelError::InvalidInput(_))),
2424 "P2-f pure-linear: foreign variable must give InvalidInput (not silent drop), got {result:?}"
2425 );
2426 }
2427
2428 #[test]
2430 fn test_p2f_mixed_all_local_accepted() {
2431 let mut m = Model::new("m");
2432 let x = m.add_var("x", 0.0, f64::INFINITY);
2433 m.minimize(x * x + (-4.0) * x);
2435 let result = m.solve().unwrap();
2436 assert!(
2437 (result[x] - 2.0).abs() < EPS,
2438 "P2-f no false positive: x*=2, got {}",
2439 result[x]
2440 );
2441 }
2442
2443 #[test]
2445 fn test_p2f_cross_term_plus_linear_foreign() {
2446 let mut m1 = Model::new("m1");
2447 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2448
2449 let mut m2 = Model::new("m2");
2450 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2451
2452 m1.minimize(x1 * y2 + 2.0 * y2);
2455 let result = m1.solve();
2456 assert!(
2457 matches!(result, Err(ModelError::InvalidInput(_))),
2458 "P2-f cross+linear: must give InvalidInput, got {result:?}"
2459 );
2460 }
2461
2462 #[test]
2464 fn test_p2f_foreign_linear_maximize_rejected() {
2465 let mut m1 = Model::new("m1");
2466 let _x1 = m1.add_var("x", 0.0, 5.0);
2467
2468 let mut m2 = Model::new("m2");
2469 let y2 = m2.add_var("y", 0.0, 5.0);
2470
2471 m1.maximize(1.0 * y2);
2472 let result = m1.solve();
2473 assert!(
2474 matches!(result, Err(ModelError::InvalidInput(_))),
2475 "P2-f maximize foreign linear: must give InvalidInput, got {result:?}"
2476 );
2477 }
2478
2479 #[test]
2485 fn test_p2h_nan_linear_coef_rejected_at_solve() {
2486 let mut m = Model::new("p2h_nan_coef");
2487 let x = m.add_var("x", 0.0, f64::INFINITY);
2488 m.minimize(f64::NAN * x);
2489 let err = m.solve().unwrap_err();
2490 assert!(
2491 matches!(err, ModelError::InvalidInput(_)),
2492 "P2-h: NaN linear coefficient must give InvalidInput, got {err:?}"
2493 );
2494 }
2495
2496 #[test]
2498 fn test_p2h_inf_linear_coef_rejected_at_solve() {
2499 let mut m = Model::new("p2h_inf_coef");
2500 let x = m.add_var("x", 0.0, f64::INFINITY);
2501 m.minimize(f64::INFINITY * x);
2502 let err = m.solve().unwrap_err();
2503 assert!(
2504 matches!(err, ModelError::InvalidInput(_)),
2505 "P2-h: Inf linear coefficient must give InvalidInput, got {err:?}"
2506 );
2507 }
2508
2509 #[test]
2511 fn test_p2h_nan_constant_rejected_at_solve() {
2512 let mut m = Model::new("p2h_nan_const");
2513 let x = m.add_var("x", 0.0, f64::INFINITY);
2514 m.minimize(1.0 * x + f64::NAN);
2515 let err = m.solve().unwrap_err();
2516 assert!(
2517 matches!(err, ModelError::InvalidInput(_)),
2518 "P2-h: NaN constant term must give InvalidInput, got {err:?}"
2519 );
2520 }
2521
2522 #[test]
2524 fn test_p2h_inf_constant_rejected_at_solve() {
2525 let mut m = Model::new("p2h_inf_const");
2526 let x = m.add_var("x", 0.0, f64::INFINITY);
2527 m.minimize(1.0 * x + f64::INFINITY);
2528 let err = m.solve().unwrap_err();
2529 assert!(
2530 matches!(err, ModelError::InvalidInput(_)),
2531 "P2-h: Inf constant term must give InvalidInput, got {err:?}"
2532 );
2533 }
2534
2535 #[test]
2539 fn test_p2h_inf_q_coef_via_diagonal_overflow_rejected_at_solve() {
2540 let mut m = Model::new("p2h_inf_q_diagonal");
2541 let x = m.add_var("x", 0.0, f64::INFINITY);
2542 m.minimize(f64::MAX * (x * x));
2546 let err = m.solve().unwrap_err();
2547 assert!(
2548 matches!(err, ModelError::InvalidInput(_)),
2549 "P2-h: Inf Q diagonal (2*MAX overflow) must give InvalidInput, got {err:?}"
2550 );
2551 }
2552
2553 #[test]
2556 fn test_p2h_nan_q_coef_dsl_rejected_at_solve() {
2557 let mut m = Model::new("p2h_nan_q_dsl");
2558 let x = m.add_var("x", 0.0, f64::INFINITY);
2559 m.minimize(f64::NAN * (x * x));
2562 let err = m.solve().unwrap_err();
2563 assert!(
2564 matches!(err, ModelError::InvalidInput(_)),
2565 "P2-h: NaN DSL Q coefficient must give InvalidInput, got {err:?}"
2566 );
2567 }
2568
2569 #[test]
2575 fn test_p2i_nan_quad_replaced_by_valid_quad_is_optimal() {
2576 let mut model = Model::new("p2i_nan_then_valid");
2577 let x = model.add_var("x", 0.0, f64::INFINITY);
2578 model.minimize(f64::NAN * (x * x)); assert!(model.has_quad_dsl_error(), "P2-i setup: error must be recorded");
2580
2581 model.minimize(x * x + (-4.0) * x); assert!(!model.has_quad_dsl_error(), "P2-i: valid minimize must clear quad_dsl_error");
2583 assert!(model.has_quadratic_objective(), "P2-i: valid Q must be installed");
2584
2585 let result = model.solve();
2587 assert!(result.is_ok(), "P2-i: valid quad after NaN must be Optimal, got {result:?}");
2588 let r = result.unwrap();
2589 assert!((r[x] - 2.0).abs() < EPS, "P2-i: x*=2, got {}", r[x]);
2590 assert!((r.objective_value - (-4.0)).abs() < EPS, "P2-i: obj*=-4, got {}", r.objective_value);
2591 }
2592
2593 #[test]
2595 fn test_p2i_nan_quad_alone_is_invalid() {
2596 let mut model = Model::new("p2i_nan_alone");
2597 let x = model.add_var("x", 0.0, f64::INFINITY);
2598 model.minimize(f64::NAN * (x * x));
2599 let result = model.solve();
2600 assert!(
2601 matches!(result, Err(ModelError::InvalidInput(_))),
2602 "P2-i: NaN quad alone must give InvalidInput, got {result:?}"
2603 );
2604 }
2605
2606 #[test]
2608 fn test_p2i_setter_ordering_table() {
2609 type Setup = fn(&mut Model, crate::variable::Variable);
2611 let cases: &[(&str, Setup, bool)] = &[
2612 ("nan→valid_quad", |m, x| {
2614 m.minimize(f64::NAN * (x * x));
2615 m.minimize(x * x + (-4.0) * x);
2616 }, true),
2617 ("nan→nan→valid_quad", |m, x| {
2619 m.minimize(f64::NAN * (x * x));
2620 m.minimize(f64::NAN * (x * x));
2621 m.minimize(x * x + (-4.0) * x);
2622 }, true),
2623 ("valid→nan", |m, x| {
2625 m.minimize(x * x + (-4.0) * x);
2626 m.minimize(f64::NAN * (x * x));
2627 }, false),
2628 ("nan→linear", |m, x| {
2630 m.minimize(f64::NAN * (x * x));
2631 m.minimize(1.0 * x); }, true),
2633 ("nan→valid→nan", |m, x| {
2635 m.minimize(f64::NAN * (x * x));
2636 m.minimize(x * x + (-4.0) * x);
2637 m.minimize(f64::NAN * (x * x));
2638 }, false),
2639 ];
2640
2641 for &(label, setup, expect_optimal) in cases {
2642 let mut m = Model::new(label);
2643 let x = m.add_var("x", 0.0, f64::INFINITY);
2644 setup(&mut m, x);
2645 let result = m.solve();
2646 if expect_optimal {
2647 assert!(result.is_ok(), "P2-i [{label}]: expected Optimal, got {result:?}");
2648 } else {
2649 assert!(
2650 matches!(result, Err(ModelError::InvalidInput(_))),
2651 "P2-i [{label}]: expected InvalidInput, got {result:?}"
2652 );
2653 }
2654 }
2655 }
2656
2657}