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 fn test_model_qp_locally_optimal_proof() {
1620 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1622 ("diag(-2,2)", [-2.0, 2.0], (-1.0, 1.0), [0.0, 0.0]),
1624 ("diag(-3,5)", [-3.0, 5.0], (-2.0, 2.0), [-1.0, 0.0]),
1626 ];
1627
1628 for &(name, q_diag, (lb, ub), c) in cases {
1629 let mut model = Model::new(name);
1630 let x = model.add_var("x0", lb, ub);
1631 let y = model.add_var("x1", lb, ub);
1632 model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1634
1635 let result = model.solve();
1636 match result {
1637 Ok(r) => {
1638 assert_eq!(
1639 r.status,
1640 otspot_core::problem::SolveStatus::LocallyOptimal,
1641 "[{name}] expected LocallyOptimal, got {:?}",
1642 r.status
1643 );
1644 assert_eq!(
1645 r.proof,
1646 SolutionProof::LocalOptimal,
1647 "[{name}] expected LocalOptimal proof, got {:?}",
1648 r.proof
1649 );
1650 assert!(
1651 !r.has_global_optimality_proof(),
1652 "[{name}] has_global_optimality_proof must be false for LocallyOptimal"
1653 );
1654 }
1655 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1656 }
1657 }
1658 }
1659
1660 #[test]
1668 fn test_model_qp_feasible_unproven_proof() {
1669 use otspot_core::options::Tolerance;
1670
1671 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1673 ("convex_2x2_tight_tol", [2.0, 2.0], (0.0, f64::INFINITY), [-4.0, -4.0]),
1676 ("convex_box_tight_tol", [4.0, 4.0], (0.0, 3.0), [0.0, -2.0]),
1678 ];
1679
1680 for &(name, q_diag, (lb, ub), c) in cases {
1681 let mut model = Model::new(name);
1682 let x = model.add_var("x0", lb, ub);
1683 let y = model.add_var("x1", lb, ub);
1684 model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1685 model.set_tolerance(Tolerance::Custom(1e-200));
1688
1689 let result = model.solve();
1690 match result {
1691 Ok(r) => {
1692 assert_eq!(
1693 r.proof,
1694 SolutionProof::FeasibleUnproven,
1695 "[{name}] expected FeasibleUnproven proof, got {:?} (status={:?})",
1696 r.proof, 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!(!r.solution.is_empty(), "[{name}] solution must be non-empty");
1703 }
1704 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1705 }
1706 }
1707 }
1708
1709 #[test]
1714 fn classify_status_error_typed_variants() {
1715 let cases_nonconvex = [
1716 "indefinite Q: eigenvalue < 0",
1717 "non-PSD matrix in MIQP",
1718 ];
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 = [
1730 "QCQP not supported",
1731 "constraint type unsupported",
1732 ];
1733 for msg in &cases_not_supported {
1734 let status = SolveStatus::NotSupported(msg.to_string());
1735 let err = classify_status_error(status).expect("NotSupported must map to Some");
1736 assert!(
1737 matches!(err, ModelError::NotSupported(_)),
1738 "NotSupported status must yield ModelError::NotSupported, got {:?}",
1739 err
1740 );
1741 }
1742 }
1743
1744 #[test]
1750 fn miqp_nonconvex_q_returns_nonconvex_error() {
1751 let cases: &[(&str, [f64; 2])] = &[
1752 ("diag(-1, 1)", [-1.0, 1.0]),
1753 ("diag(-2, 3)", [-2.0, 3.0]),
1754 ("diag(1, -1)", [1.0, -1.0]),
1755 ];
1756
1757 for &(name, q_diag) in cases {
1758 let mut model = Model::new(name);
1759 let x = model.add_binary_var("x");
1760 let y = model.add_binary_var("y");
1761 model.minimize((q_diag[0] / 2.0) * (x * x) + (q_diag[1] / 2.0) * (y * y));
1763
1764 let err = model
1765 .solve()
1766 .expect_err(&format!("[{name}] expected Err(NonConvex), got Ok"));
1767 assert!(
1768 matches!(err, ModelError::NonConvex(_)),
1769 "[{name}] expected ModelError::NonConvex, got {:?}",
1770 err
1771 );
1772 }
1773 }
1774
1775 #[test]
1782 fn add_var_lb_gt_ub_defers_error_to_solve() {
1783 let cases: &[(&str, f64, f64)] = &[
1784 ("lb=5 > ub=3", 5.0, 3.0),
1785 ("lb=1.0 > ub=0.999", 1.0, 0.999),
1786 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1787 ];
1788 for &(label, lb, ub) in cases {
1789 let mut model = Model::new(label);
1790 let x = model.add_var("x", lb, ub);
1791 model.minimize(x);
1792 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1793 assert!(
1794 matches!(err, ModelError::InvalidInput(_)),
1795 "[{label}] expected InvalidInput, got {err:?}"
1796 );
1797 }
1798 }
1799
1800 #[test]
1801 fn add_var_nan_bounds_defers_error_to_solve() {
1802 let cases: &[(&str, f64, f64)] = &[
1803 ("lb=NaN", f64::NAN, 1.0),
1804 ("ub=NaN", 0.0, f64::NAN),
1805 ("both=NaN", f64::NAN, f64::NAN),
1806 ];
1807 for &(label, lb, ub) in cases {
1808 let mut model = Model::new(label);
1809 let x = model.add_var("x", lb, ub);
1810 model.minimize(x);
1811 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1812 assert!(
1813 matches!(err, ModelError::InvalidInput(_)),
1814 "[{label}] expected InvalidInput, got {err:?}"
1815 );
1816 }
1817 }
1818
1819 #[test]
1820 fn add_int_var_lb_gt_ub_defers_error_to_solve() {
1821 let cases: &[(&str, f64, f64)] = &[
1822 ("int lb=3 > ub=1", 3.0, 1.0),
1823 ("int lb=NaN", f64::NAN, 5.0),
1824 ];
1825 for &(label, lb, ub) in cases {
1826 let mut model = Model::new(label);
1827 let x = model.add_int_var("x", lb, ub);
1828 model.minimize(x);
1829 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1830 assert!(
1831 matches!(err, ModelError::InvalidInput(_)),
1832 "[{label}] expected InvalidInput, got {err:?}"
1833 );
1834 }
1835 }
1836
1837 #[test]
1842 fn try_add_var_returns_err_for_invalid_bounds() {
1843 let cases: &[(&str, f64, f64)] = &[
1844 ("lb>ub", 2.0, 1.0),
1845 ("lb=NaN", f64::NAN, 1.0),
1846 ("ub=NaN", 0.0, f64::NAN),
1847 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1848 ];
1849 for &(label, lb, ub) in cases {
1850 let mut model = Model::new(label);
1851 let result = model.try_add_var("x", lb, ub);
1852 assert!(
1853 result.is_err(),
1854 "[{label}] try_add_var should return Err for invalid bounds, got Ok"
1855 );
1856 }
1857 }
1858
1859 #[test]
1860 fn try_add_var_returns_ok_for_valid_bounds() {
1861 let cases: &[(&str, f64, f64)] = &[
1862 ("lb=ub", 3.0, 3.0),
1863 ("lb=0 ub=inf", 0.0, f64::INFINITY),
1864 ("lb=-inf ub=inf", f64::NEG_INFINITY, f64::INFINITY),
1865 ("lb=-inf ub=0", f64::NEG_INFINITY, 0.0),
1866 ];
1867 for &(label, lb, ub) in cases {
1868 let mut model = Model::new(label);
1869 assert!(
1870 model.try_add_var("x", lb, ub).is_ok(),
1871 "[{label}] try_add_var should return Ok for valid bounds"
1872 );
1873 }
1874 }
1875
1876 #[test]
1877 fn try_add_int_var_returns_err_for_invalid_bounds() {
1878 let cases: &[(&str, f64, f64)] = &[
1879 ("int lb>ub", 5.0, 2.0),
1880 ("int lb=NaN", f64::NAN, 3.0),
1881 ];
1882 for &(label, lb, ub) in cases {
1883 let mut model = Model::new(label);
1884 assert!(
1885 model.try_add_int_var("n", lb, ub).is_err(),
1886 "[{label}] try_add_int_var should return Err"
1887 );
1888 }
1889 }
1890
1891 #[test]
1897 fn try_value_cross_model_returns_err() {
1898 let mut m1 = Model::new("m1");
1899 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
1900 m1.minimize(x1);
1901 let r1 = m1.solve().unwrap();
1902
1903 let mut m2 = Model::new("m2");
1905 let y = m2.add_var("y", 0.0, f64::INFINITY);
1906
1907 assert!(
1908 r1.try_value(y).is_err(),
1909 "try_value with variable from different model must return Err"
1910 );
1911 assert!(r1.try_value(x1).is_ok());
1913 }
1914
1915 #[test]
1916 fn try_value_valid_returns_ok() {
1917 let (mut model, x, y) = basic_model();
1918 let result = model.solve().unwrap();
1919 assert!(result.try_value(x).is_ok());
1920 assert!(result.try_value(y).is_ok());
1921 assert!((result.try_value(x).unwrap() - result.value(x)).abs() < 1e-12);
1922 }
1923
1924 #[test]
1929 fn try_value_out_of_range_same_model_returns_err() {
1930 let mut model = Model::new("grow");
1931 let x = model.add_var("x", 0.0, f64::INFINITY);
1932 model.minimize(x);
1933 let result = model.solve().unwrap();
1934
1935 let late = model.add_var("late", 0.0, f64::INFINITY);
1937 assert_eq!(late.model_id, result.model_id, "same model_id expected");
1938 assert!(
1939 late.index >= result.solution.len(),
1940 "test setup: late var must be out of range"
1941 );
1942 assert!(
1943 result.try_value(late).is_err(),
1944 "try_value must return Err for an out-of-range index even when model_id matches"
1945 );
1946 }
1947
1948 #[test]
1952 fn model_result_clone() {
1953 let (mut model, x, _y) = basic_model();
1954 let result = model.solve().unwrap();
1955 let cloned = result.clone();
1956 assert!((cloned.objective_value - result.objective_value).abs() < 1e-12);
1957 assert_eq!(cloned.solution.len(), result.solution.len());
1958 assert!((cloned[x] - result[x]).abs() < 1e-12);
1959 }
1960
1961 #[test]
1965 fn var_name_is_retained() {
1966 let cases = [("alpha", 0.0, 1.0), ("beta_var", 0.0, f64::INFINITY)];
1967 let mut model = Model::new("named");
1968 for &(name, lb, ub) in &cases {
1969 let v = model.add_var(name, lb, ub);
1970 assert_eq!(model.var_name(v), name, "var_name mismatch for '{name}'");
1971 }
1972 let iv = model.add_int_var("gamma_int", 0.0, 10.0);
1973 assert_eq!(model.var_name(iv), "gamma_int");
1974 }
1975
1976 #[test]
1981 fn set_timeout_invalid_defers_error() {
1982 let cases: &[(&str, f64)] = &[
1983 ("negative", -1.0),
1984 ("NaN", f64::NAN),
1985 ("neg_inf", f64::NEG_INFINITY),
1986 ];
1987 for &(label, secs) in cases {
1988 let mut model = Model::new(label);
1989 let x = model.add_var("x", 0.0, f64::INFINITY);
1990 model.minimize(x);
1991 model.set_timeout(secs);
1992 let err = model.solve().expect_err(&format!("[{label}] expected Err for invalid timeout"));
1993 assert!(
1994 matches!(err, ModelError::InvalidInput(_)),
1995 "[{label}] expected InvalidInput, got {err:?}"
1996 );
1997 }
1998 }
1999
2000 #[test]
2001 fn try_set_timeout_returns_err_for_invalid() {
2002 let cases: &[(&str, f64)] = &[("negative", -0.001), ("NaN", f64::NAN), ("inf", f64::INFINITY)];
2003 for &(label, secs) in cases {
2004 let mut model = Model::new(label);
2005 assert!(
2006 model.try_set_timeout(secs).is_err(),
2007 "[{label}] try_set_timeout should return Err"
2008 );
2009 }
2010 }
2011
2012 #[test]
2013 fn try_set_timeout_ok_for_valid() {
2014 let valid = [0.0, 0.001, 1.0, 3600.0];
2015 for &secs in &valid {
2016 let mut model = Model::new("t");
2017 assert!(model.try_set_timeout(secs).is_ok(), "should be Ok for secs={secs}");
2018 }
2019 }
2020}
2021
2022#[cfg(test)]
2031mod mip_model_tests {
2032 use super::{Model, ModelError, SolveError};
2033
2034 const EPS: f64 = 1e-4;
2035
2036 #[test]
2037 fn model_add_int_var_maximize_branches() {
2038 let mut m = Model::new("milp_int");
2040 let x = m.add_int_var("x", 0.0, 5.0);
2041 m.add_constraint((2.0 * x).leq(3.0));
2042 m.maximize(x);
2043 let r = m.solve().unwrap();
2044 assert!((r.objective() - 1.0).abs() < EPS, "obj={}", r.objective());
2045 assert!((r[x] - 1.0).abs() < EPS, "x={}", r[x]);
2046 }
2047
2048 #[test]
2049 fn model_binary_knapsack() {
2050 let mut m = Model::new("knapsack");
2051 let a = m.add_binary_var("a");
2052 let b = m.add_binary_var("b");
2053 let c = m.add_binary_var("c");
2054 let d = m.add_binary_var("d");
2055 m.add_constraint((5.0 * a + 7.0 * b + 4.0 * c + 3.0 * d).leq(14.0));
2056 m.maximize(8.0 * a + 11.0 * b + 6.0 * c + 4.0 * d);
2057 let r = m.solve().unwrap();
2058 assert!((r.objective() - 21.0).abs() < EPS, "obj={}", r.objective());
2059 assert_eq!(
2060 (r[a].round(), r[b].round(), r[c].round(), r[d].round()),
2061 (0.0, 1.0, 1.0, 1.0)
2062 );
2063 }
2064
2065 #[test]
2066 fn model_integer_infeasible_errors() {
2067 let mut m = Model::new("infeasible");
2068 let x = m.add_int_var("x", 0.0, 10.0);
2069 m.add_constraint((1.0 * x).geq(1.2));
2070 m.add_constraint((1.0 * x).leq(1.8));
2071 m.minimize(x);
2072 let err = m.solve().unwrap_err();
2073 assert!(matches!(err, ModelError::SolveError(SolveError::Infeasible)), "got {err:?}");
2074 }
2075
2076 #[test]
2077 fn model_integer_unbounded_errors() {
2078 let mut m = Model::new("unbounded");
2079 let x = m.add_int_var("x", 0.0, f64::INFINITY);
2080 m.maximize(x);
2081 let err = m.solve().unwrap_err();
2082 assert!(matches!(err, ModelError::SolveError(SolveError::Unbounded)), "got {err:?}");
2083 }
2084
2085 #[test]
2086 fn model_convex_miqp_branches_to_integer_optimum() {
2087 let mut m = Model::new("convex_miqp");
2090 let x = m.add_int_var("x", 0.0, 5.0);
2091 m.minimize(x * x + (-5.0) * x);
2092 let r = m.solve().unwrap();
2093 assert!((r.objective() - (-6.0)).abs() < EPS, "obj={}", r.objective());
2094 let xr = r[x].round();
2095 assert!(xr == 2.0 || xr == 3.0, "x must be 2 or 3, got {}", r[x]);
2096 assert!((r[x] - xr).abs() < EPS, "x must be integral: {}", r[x]);
2097 }
2098
2099 #[test]
2100 fn model_nonconvex_miqp_errors() {
2101 let cases: &[(&str, &[f64], &[f64])] = &[
2103 ("single neg", &[-2.0], &[1.0]),
2104 ("neg-pos-2var", &[-3.0, 2.0], &[0.0, 1.0]),
2105 ];
2106 for &(name, q_diag, c_vec) in cases {
2107 let n = q_diag.len();
2108 let mut m = Model::new(name);
2109 let vars: Vec<_> = (0..n).map(|i| m.add_int_var(&format!("x{i}"), 0.0, 5.0)).collect();
2110 let obj = vars.iter().zip(q_diag).zip(c_vec).fold(
2112 crate::quad_expr::QuadExpr::from(0.0_f64),
2113 |acc, ((&v, &d), &c)| acc + (d / 2.0) * (v * v) + c * v,
2114 );
2115 m.minimize(obj);
2116 let err = m.solve().unwrap_err();
2117 assert!(
2118 matches!(err, ModelError::NonConvex(_)),
2119 "[{name}] expected ModelError::NonConvex, got {err:?}"
2120 );
2121 }
2122 }
2123
2124 #[test]
2125 fn model_mixed_integer_continuous() {
2126 let mut m = Model::new("mixed");
2129 let x = m.add_int_var("x", 0.0, 5.0);
2130 let y = m.add_var("y", 0.0, 5.0);
2131 m.add_constraint((x + y).leq(3.5));
2132 m.maximize(x + y);
2133 let r = m.solve().unwrap();
2134 assert!((r.objective() - 3.5).abs() < EPS, "obj={}", r.objective());
2135 assert!((r[x].round() - r[x]).abs() < EPS, "x must be integral, x={}", r[x]);
2136 }
2137
2138 #[test]
2142 fn test_linear_objective_constant_included() {
2143 let mut model = Model::new("lin_const");
2145 let x = model.add_var("x", 1.0, f64::INFINITY);
2146 model.minimize(2.0 * x + 3.0);
2147 let result = model.solve().unwrap();
2148 assert!((result[x] - 1.0).abs() < EPS, "x* should be 1, got {}", result[x]);
2149 assert!(
2150 (result.objective_value - 5.0).abs() < EPS,
2151 "obj* should be 5 (includes constant +3), got {}",
2152 result.objective_value
2153 );
2154 }
2155
2156 #[test]
2158 fn test_quad_objective_constant_included() {
2159 let mut model = Model::new("quad_const");
2162 let x = model.add_var("x", 0.0, f64::INFINITY);
2163 model.minimize(x * x + (-4.0) * x + 4.0);
2164 let result = model.solve().unwrap();
2165 assert!(
2166 (result[x] - 2.0).abs() < 1e-4,
2167 "quad_const: x* should be 2, got {}",
2168 result[x]
2169 );
2170 assert!(
2171 result.objective_value.abs() < 1e-4,
2172 "quad_const: obj* should be 0 (constant +4 included), got {}",
2173 result.objective_value
2174 );
2175 }
2176
2177 #[test]
2179 fn test_maximize_objective_constant_sign() {
2180 let mut model = Model::new("max_const");
2182 let x = model.add_var("x", 0.0, 10.0);
2183 model.maximize((-1.0) * x + 5.0);
2184 let result = model.solve().unwrap();
2185 assert!(
2186 (result[x]).abs() < EPS,
2187 "max_const: x* should be 0, got {}",
2188 result[x]
2189 );
2190 assert!(
2191 (result.objective_value - 5.0).abs() < EPS,
2192 "max_const: obj* should be 5 (constant not negated for max), got {}",
2193 result.objective_value
2194 );
2195 }
2196
2197 #[test]
2199 fn test_obj_offset_and_dsl_constant_no_double_count() {
2200 let mut model = Model::new("offset_plus_const");
2203 let x = model.add_var("x", 1.0, f64::INFINITY);
2204 model.minimize(1.0 * x + 3.0);
2205 model.set_obj_offset(10.0);
2206 let result = model.solve().unwrap();
2207 assert!(
2208 (result.objective_value - 14.0).abs() < EPS,
2209 "offset+const: obj* should be 14 (1+3+10), got {}",
2210 result.objective_value
2211 );
2212 }
2213
2214 #[test]
2216 fn test_reminimize_constant_not_double_counted() {
2217 let mut model = Model::new("reminimize");
2219 let x = model.add_var("x", 1.0, f64::INFINITY);
2220 model.minimize(1.0 * x + 3.0); model.minimize(1.0 * x + 7.0); let result = model.solve().unwrap();
2223 assert!(
2224 (result.objective_value - 8.0).abs() < EPS,
2225 "re-minimize: obj* should be 8 (1+7, not 1+3+7=11), got {}",
2226 result.objective_value
2227 );
2228 }
2229
2230 #[test]
2236 fn test_p2a_nan_dsl_quad_then_linear_is_optimal() {
2237 let mut model = Model::new("p2a_nan_then_linear");
2240 let x = model.add_var("x", 1.0, f64::INFINITY);
2241 model.minimize(f64::NAN * (x * x)); model.minimize(1.0 * x); let result = model.solve();
2244 assert!(result.is_ok(), "P2-a: should be Optimal after linear replaces NaN quad, got {result:?}");
2245 let r = result.unwrap();
2246 assert!((r[x] - 1.0).abs() < EPS, "P2-a: x* should be 1, got {}", r[x]);
2247 }
2248
2249 #[test]
2251 fn test_p2a_valid_quad_then_nan_errors() {
2252 let mut model = Model::new("p2a_valid_then_nan");
2253 let x = model.add_var("x", 0.0, f64::INFINITY);
2254 model.minimize(x * x + (-2.0) * x); model.minimize(f64::NAN * (x * x)); let result = model.solve();
2257 assert!(
2258 matches!(result, Err(ModelError::InvalidInput(_))),
2259 "P2-a: NaN quad must yield InvalidInput, got {result:?}"
2260 );
2261 }
2262
2263 #[test]
2265 fn test_p2a_nan_dsl_then_maximize_linear_is_optimal() {
2266 let mut model = Model::new("p2a_nan_then_max");
2267 let x = model.add_var("x", 0.0, 5.0);
2268 model.minimize(f64::NAN * (x * x)); model.maximize(1.0 * x); let result = model.solve();
2271 assert!(result.is_ok(), "P2-a: maximize should succeed after NaN DSL cleared, got {result:?}");
2272 let r = result.unwrap();
2273 assert!((r[x] - 5.0).abs() < EPS, "P2-a: maximize x → x*=5, got {}", r[x]);
2274 }
2275
2276 #[test]
2279 fn test_quad_state_invariants_transition_table() {
2280 fn mk() -> (Model, crate::variable::Variable) {
2282 let mut m = Model::new("t");
2283 let x = m.add_var("x", 0.0, f64::INFINITY);
2284 (m, x)
2285 }
2286
2287 {
2289 let (mut m, x) = mk();
2290 m.minimize(x * x);
2291 assert!(!m.has_quad_dsl_error(), "DSL ok: no error");
2292 assert!(m.is_quad_via_dsl(), "DSL ok: via_dsl=true");
2293 assert!(m.has_quadratic_objective(),"DSL ok: has_q=true");
2294 }
2295
2296 {
2298 let (mut m, x) = mk();
2299 m.minimize(f64::NAN * (x * x));
2300 assert!(m.has_quad_dsl_error(), "DSL fail: error recorded");
2301 assert!(!m.is_quad_via_dsl(), "DSL fail: via_dsl=false");
2302 assert!(!m.has_quadratic_objective(),"DSL fail: has_q=false");
2303 }
2304
2305 {
2307 let (mut m, x) = mk();
2308 m.minimize(x * x);
2309 m.minimize(f64::NAN * (x * x));
2310 assert!(m.has_quad_dsl_error(), "ok→fail: error recorded");
2311 assert!(!m.is_quad_via_dsl(), "ok→fail: via_dsl=false");
2312 assert!(!m.has_quadratic_objective(),"ok→fail: has_q=false");
2313 }
2314
2315 {
2317 let (mut m, x) = mk();
2318 m.minimize(x * x);
2319 m.minimize(1.0 * x);
2320 assert!(!m.has_quad_dsl_error(), "dsl→linear: no error");
2321 assert!(!m.is_quad_via_dsl(), "dsl→linear: via_dsl=false");
2322 assert!(!m.has_quadratic_objective(),"dsl→linear: DSL Q cleared");
2323 }
2324 }
2325
2326 #[test]
2328 fn test_quad_state_solve_outcome_table() {
2329 let cases: &[(&str, bool)] = &[
2330 ("nan_then_linear", true),
2333 ("nan_alone", false),
2335 ("ok_then_nan", false),
2337 ("nan_then_valid_quad", true),
2339 ("nan_nan_then_valid_quad", true),
2341 ];
2342
2343 for &(name, expect_ok) in cases {
2344 let mut m = Model::new(name);
2345 let x = m.add_var("x", 0.0, f64::INFINITY);
2346
2347 match name {
2348 "nan_then_linear" => {
2349 m.minimize(f64::NAN * (x * x) + x);
2350 m.minimize(1.0 * x);
2351 }
2352 "nan_alone" => {
2353 m.minimize(f64::NAN * (x * x) + x);
2354 }
2355 "ok_then_nan" => {
2356 m.minimize(x * x + (-4.0) * x);
2357 m.minimize(f64::NAN * (x * x) + x);
2358 }
2359 "nan_then_valid_quad" => {
2360 m.minimize(f64::NAN * (x * x));
2361 m.minimize(x * x + (-4.0) * x);
2362 }
2363 "nan_nan_then_valid_quad" => {
2364 m.minimize(f64::NAN * (x * x));
2365 m.minimize(f64::NAN * (x * x));
2366 m.minimize(x * x + (-4.0) * x);
2367 }
2368 _ => unreachable!(),
2369 }
2370
2371 let result = m.solve();
2372 if expect_ok {
2373 assert!(
2374 result.is_ok(),
2375 "case '{name}': expected Optimal, got {result:?}"
2376 );
2377 } else {
2378 assert!(
2379 matches!(result, Err(ModelError::InvalidInput(_))),
2380 "case '{name}': expected InvalidInput, got {result:?}"
2381 );
2382 }
2383 }
2384 }
2385
2386 #[test]
2392 fn test_p2f_mixed_quad_local_linear_foreign_rejected() {
2393 let mut m1 = Model::new("m1");
2394 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2395
2396 let mut m2 = Model::new("m2");
2397 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2398
2399 m1.minimize(x1 * x1 + y2);
2401 let result = m1.solve();
2402 assert!(
2403 matches!(result, Err(ModelError::InvalidInput(_))),
2404 "P2-f mixed: foreign linear term must give InvalidInput, got {result:?}"
2405 );
2406 }
2407
2408 #[test]
2410 fn test_p2f_pure_linear_foreign_rejected() {
2411 let mut m1 = Model::new("m1");
2412 let _x1 = m1.add_var("x", 0.0, f64::INFINITY);
2413
2414 let mut m2 = Model::new("m2");
2415 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2416
2417 m1.minimize(1.0 * y2);
2419 let result = m1.solve();
2420 assert!(
2421 matches!(result, Err(ModelError::InvalidInput(_))),
2422 "P2-f pure-linear: foreign variable must give InvalidInput (not silent drop), got {result:?}"
2423 );
2424 }
2425
2426 #[test]
2428 fn test_p2f_mixed_all_local_accepted() {
2429 let mut m = Model::new("m");
2430 let x = m.add_var("x", 0.0, f64::INFINITY);
2431 m.minimize(x * x + (-4.0) * x);
2433 let result = m.solve().unwrap();
2434 assert!(
2435 (result[x] - 2.0).abs() < EPS,
2436 "P2-f no false positive: x*=2, got {}",
2437 result[x]
2438 );
2439 }
2440
2441 #[test]
2443 fn test_p2f_cross_term_plus_linear_foreign() {
2444 let mut m1 = Model::new("m1");
2445 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2446
2447 let mut m2 = Model::new("m2");
2448 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2449
2450 m1.minimize(x1 * y2 + 2.0 * y2);
2453 let result = m1.solve();
2454 assert!(
2455 matches!(result, Err(ModelError::InvalidInput(_))),
2456 "P2-f cross+linear: must give InvalidInput, got {result:?}"
2457 );
2458 }
2459
2460 #[test]
2462 fn test_p2f_foreign_linear_maximize_rejected() {
2463 let mut m1 = Model::new("m1");
2464 let _x1 = m1.add_var("x", 0.0, 5.0);
2465
2466 let mut m2 = Model::new("m2");
2467 let y2 = m2.add_var("y", 0.0, 5.0);
2468
2469 m1.maximize(1.0 * y2);
2470 let result = m1.solve();
2471 assert!(
2472 matches!(result, Err(ModelError::InvalidInput(_))),
2473 "P2-f maximize foreign linear: must give InvalidInput, got {result:?}"
2474 );
2475 }
2476
2477 #[test]
2483 fn test_p2h_nan_linear_coef_rejected_at_solve() {
2484 let mut m = Model::new("p2h_nan_coef");
2485 let x = m.add_var("x", 0.0, f64::INFINITY);
2486 m.minimize(f64::NAN * x);
2487 let err = m.solve().unwrap_err();
2488 assert!(
2489 matches!(err, ModelError::InvalidInput(_)),
2490 "P2-h: NaN linear coefficient must give InvalidInput, got {err:?}"
2491 );
2492 }
2493
2494 #[test]
2496 fn test_p2h_inf_linear_coef_rejected_at_solve() {
2497 let mut m = Model::new("p2h_inf_coef");
2498 let x = m.add_var("x", 0.0, f64::INFINITY);
2499 m.minimize(f64::INFINITY * x);
2500 let err = m.solve().unwrap_err();
2501 assert!(
2502 matches!(err, ModelError::InvalidInput(_)),
2503 "P2-h: Inf linear coefficient must give InvalidInput, got {err:?}"
2504 );
2505 }
2506
2507 #[test]
2509 fn test_p2h_nan_constant_rejected_at_solve() {
2510 let mut m = Model::new("p2h_nan_const");
2511 let x = m.add_var("x", 0.0, f64::INFINITY);
2512 m.minimize(1.0 * x + f64::NAN);
2513 let err = m.solve().unwrap_err();
2514 assert!(
2515 matches!(err, ModelError::InvalidInput(_)),
2516 "P2-h: NaN constant term must give InvalidInput, got {err:?}"
2517 );
2518 }
2519
2520 #[test]
2522 fn test_p2h_inf_constant_rejected_at_solve() {
2523 let mut m = Model::new("p2h_inf_const");
2524 let x = m.add_var("x", 0.0, f64::INFINITY);
2525 m.minimize(1.0 * x + f64::INFINITY);
2526 let err = m.solve().unwrap_err();
2527 assert!(
2528 matches!(err, ModelError::InvalidInput(_)),
2529 "P2-h: Inf constant term must give InvalidInput, got {err:?}"
2530 );
2531 }
2532
2533 #[test]
2537 fn test_p2h_inf_q_coef_via_diagonal_overflow_rejected_at_solve() {
2538 let mut m = Model::new("p2h_inf_q_diagonal");
2539 let x = m.add_var("x", 0.0, f64::INFINITY);
2540 m.minimize(f64::MAX * (x * x));
2544 let err = m.solve().unwrap_err();
2545 assert!(
2546 matches!(err, ModelError::InvalidInput(_)),
2547 "P2-h: Inf Q diagonal (2*MAX overflow) must give InvalidInput, got {err:?}"
2548 );
2549 }
2550
2551 #[test]
2554 fn test_p2h_nan_q_coef_dsl_rejected_at_solve() {
2555 let mut m = Model::new("p2h_nan_q_dsl");
2556 let x = m.add_var("x", 0.0, f64::INFINITY);
2557 m.minimize(f64::NAN * (x * x));
2560 let err = m.solve().unwrap_err();
2561 assert!(
2562 matches!(err, ModelError::InvalidInput(_)),
2563 "P2-h: NaN DSL Q coefficient must give InvalidInput, got {err:?}"
2564 );
2565 }
2566
2567 #[test]
2573 fn test_p2i_nan_quad_replaced_by_valid_quad_is_optimal() {
2574 let mut model = Model::new("p2i_nan_then_valid");
2575 let x = model.add_var("x", 0.0, f64::INFINITY);
2576 model.minimize(f64::NAN * (x * x)); assert!(model.has_quad_dsl_error(), "P2-i setup: error must be recorded");
2578
2579 model.minimize(x * x + (-4.0) * x); assert!(!model.has_quad_dsl_error(), "P2-i: valid minimize must clear quad_dsl_error");
2581 assert!(model.has_quadratic_objective(), "P2-i: valid Q must be installed");
2582
2583 let result = model.solve();
2585 assert!(result.is_ok(), "P2-i: valid quad after NaN must be Optimal, got {result:?}");
2586 let r = result.unwrap();
2587 assert!((r[x] - 2.0).abs() < EPS, "P2-i: x*=2, got {}", r[x]);
2588 assert!((r.objective_value - (-4.0)).abs() < EPS, "P2-i: obj*=-4, got {}", r.objective_value);
2589 }
2590
2591 #[test]
2593 fn test_p2i_nan_quad_alone_is_invalid() {
2594 let mut model = Model::new("p2i_nan_alone");
2595 let x = model.add_var("x", 0.0, f64::INFINITY);
2596 model.minimize(f64::NAN * (x * x));
2597 let result = model.solve();
2598 assert!(
2599 matches!(result, Err(ModelError::InvalidInput(_))),
2600 "P2-i: NaN quad alone must give InvalidInput, got {result:?}"
2601 );
2602 }
2603
2604 #[test]
2606 fn test_p2i_setter_ordering_table() {
2607 type Setup = fn(&mut Model, crate::variable::Variable);
2609 let cases: &[(&str, Setup, bool)] = &[
2610 ("nan→valid_quad", |m, x| {
2612 m.minimize(f64::NAN * (x * x));
2613 m.minimize(x * x + (-4.0) * x);
2614 }, true),
2615 ("nan→nan→valid_quad", |m, x| {
2617 m.minimize(f64::NAN * (x * x));
2618 m.minimize(f64::NAN * (x * x));
2619 m.minimize(x * x + (-4.0) * x);
2620 }, true),
2621 ("valid→nan", |m, x| {
2623 m.minimize(x * x + (-4.0) * x);
2624 m.minimize(f64::NAN * (x * x));
2625 }, false),
2626 ("nan→linear", |m, x| {
2628 m.minimize(f64::NAN * (x * x));
2629 m.minimize(1.0 * x); }, true),
2631 ("nan→valid→nan", |m, x| {
2633 m.minimize(f64::NAN * (x * x));
2634 m.minimize(x * x + (-4.0) * x);
2635 m.minimize(f64::NAN * (x * x));
2636 }, false),
2637 ];
2638
2639 for &(label, setup, expect_optimal) in cases {
2640 let mut m = Model::new(label);
2641 let x = m.add_var("x", 0.0, f64::INFINITY);
2642 setup(&mut m, x);
2643 let result = m.solve();
2644 if expect_optimal {
2645 assert!(result.is_ok(), "P2-i [{label}]: expected Optimal, got {result:?}");
2646 } else {
2647 assert!(
2648 matches!(result, Err(ModelError::InvalidInput(_))),
2649 "P2-i [{label}]: expected InvalidInput, got {result:?}"
2650 );
2651 }
2652 }
2653 }
2654
2655}