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 {
202 &self.variables[var.index].name
203 }
204
205 pub fn try_var_name(&self, var: Variable) -> Result<&str, ModelError> {
211 if var.model_id != self.model_id {
212 return Err(ModelError::InvalidInput(
213 "variable belongs to a different model".to_string(),
214 ));
215 }
216 self.variables
217 .get(var.index)
218 .map(|v| v.name.as_str())
219 .ok_or_else(|| {
220 ModelError::InvalidInput(format!(
221 "variable index {} out of range (model has {} variables)",
222 var.index,
223 self.variables.len()
224 ))
225 })
226 }
227
228 fn push_var(&mut self, name: &str, lb: f64, ub: f64, kind: VarKind) -> Variable {
229 let index = self.variables.len();
230 self.variables.push(VariableDefinition {
231 name: name.to_string(),
232 lower_bound: lb,
233 upper_bound: ub,
234 kind,
235 });
236 Variable { index, model_id: self.model_id }
237 }
238
239 pub fn add_constraint(&mut self, c: Constraint) -> &mut Self {
241 self.constraints.push(c);
242 self
243 }
244
245 pub fn minimize(&mut self, obj: impl Into<QuadExpr>) -> &mut Self {
252 self.apply_objective(obj.into(), OptimizationSense::Minimize)
253 }
254
255 pub fn maximize(&mut self, obj: impl Into<QuadExpr>) -> &mut Self {
260 self.apply_objective(obj.into(), OptimizationSense::Maximize)
261 }
262
263 fn validate_objective(&self) -> Result<(), ModelError> {
284 if let Some(msg) = &self.quad_dsl_error {
285 return Err(ModelError::InvalidInput(msg.clone()));
286 }
287 let Some(obj) = self.objective.as_ref() else {
288 return Ok(()); };
290 for (&var, &coef) in &obj.coefficients {
291 if var.model_id != self.model_id {
292 return Err(ModelError::InvalidInput(format!(
293 "objective: linear term (var {}) belongs to model {}, not this model ({})",
294 var.index, var.model_id, self.model_id
295 )));
296 }
297 if !coef.is_finite() {
298 return Err(ModelError::InvalidInput(format!(
299 "objective: non-finite linear coefficient for var {}: {coef}",
300 var.index
301 )));
302 }
303 }
304 if !self.obj_expr_constant.is_finite() {
305 return Err(ModelError::InvalidInput(format!(
306 "objective: non-finite constant term: {}",
307 self.obj_expr_constant
308 )));
309 }
310 if let Some(q) = &self.quadratic_objective {
311 if let Some(&v) = q.values().iter().find(|v| !v.is_finite()) {
312 return Err(ModelError::InvalidInput(format!(
313 "objective: non-finite Q coefficient: {v}"
314 )));
315 }
316 }
317 Ok(())
318 }
319
320 fn install_quad(&mut self, q: CscMatrix) {
322 self.quadratic_objective = Some(q);
323 self.quad_dsl_error = None;
324 }
325
326 fn fail_dsl_quad(&mut self, msg: String) {
329 self.quadratic_objective = None;
330 self.quad_dsl_error = Some(msg);
331 }
332
333 fn replace_with_linear_objective(&mut self) {
335 self.quadratic_objective = None;
336 }
337
338 fn find_foreign_var_error(&self, q: &QuadExpr) -> Option<String> {
342 if let Some(&v) = q.linear.coefficients.keys().find(|v| v.model_id != self.model_id) {
346 return Some(format!(
347 "linear term (var {}) belongs to model {}, not this model ({})",
348 v.index, v.model_id, self.model_id
349 ));
350 }
351 if let Some(&(va, vb)) = q.quad.keys().find(|(va, vb)| {
353 va.model_id != self.model_id || vb.model_id != self.model_id
354 }) {
355 return Some(format!(
356 "quad term ({},{}) belongs to model(s) {}/{}, not this model ({})",
357 va.index, vb.index, va.model_id, vb.model_id, self.model_id
358 ));
359 }
360 None
361 }
362
363 fn apply_objective(&mut self, q: QuadExpr, sense: OptimizationSense) -> &mut Self {
365 self.quad_dsl_error = None;
370 if let Some(msg) = self.find_foreign_var_error(&q) {
374 self.fail_dsl_quad(msg);
375 } else if !q.quad.is_empty() {
376 match quad_to_csc(&q.quad, self.variables.len()) {
377 Ok(csc) => self.install_quad(csc),
378 Err(msg) => self.fail_dsl_quad(msg),
379 }
380 } else {
381 self.replace_with_linear_objective();
382 }
383 self.obj_expr_constant = q.linear.constant;
387 self.objective = Some(q.linear);
388 self.sense = sense;
389 self
390 }
391
392 pub fn set_obj_offset(&mut self, offset: f64) -> &mut Self {
398 self.obj_offset = offset;
399 self
400 }
401
402 pub fn solve(&mut self) -> Result<ModelResult, ModelError> {
408 if let Some(msg) = self.invalid_inputs.values().next() {
409 return Err(ModelError::InvalidInput(msg.clone()));
410 }
411
412 let obj_expr = self.objective.as_ref().ok_or(ModelError::NoObjective)?;
413
414 self.validate_objective()?;
419
420 let num_vars = self.variables.len();
421 let num_constraints = self.constraints.len();
422
423 let mid = self.model_id;
425 let mut c: Vec<f64> = (0..num_vars)
426 .map(|i| obj_expr.coefficient(Variable { index: i, model_id: mid }))
427 .collect();
428
429 if self.sense == OptimizationSense::Maximize {
431 for ci in &mut c {
432 *ci = -*ci;
433 }
434 }
435
436 let mut trip_rows: Vec<usize> = Vec::new();
438 let mut trip_cols: Vec<usize> = Vec::new();
439 let mut trip_vals: Vec<f64> = Vec::new();
440 let mut b: Vec<f64> = Vec::new();
441 let mut constraint_types: Vec<ConstraintType> = Vec::new();
442
443 for (i, con) in self.constraints.iter().enumerate() {
444 for (&var, &coeff) in &con.lhs.coefficients {
445 trip_rows.push(i);
446 trip_cols.push(var.index);
447 trip_vals.push(coeff);
448 }
449 b.push(con.rhs);
451 constraint_types.push(match con.sense {
452 ConstraintSense::Le => ConstraintType::Le,
453 ConstraintSense::Ge => ConstraintType::Ge,
454 ConstraintSense::Eq => ConstraintType::Eq,
455 });
456 }
457
458 let a = if num_constraints == 0 {
460 CscMatrix::new(0, num_vars)
461 } else {
462 CscMatrix::from_triplets(
463 &trip_rows,
464 &trip_cols,
465 &trip_vals,
466 num_constraints,
467 num_vars,
468 )
469 .map_err(|e| ModelError::Internal(e.to_string()))?
470 };
471
472 let bounds: Vec<(f64, f64)> = self
474 .variables
475 .iter()
476 .map(|v| (v.lower_bound, v.upper_bound))
477 .collect();
478
479 let integer_vars: Vec<usize> = self
482 .variables
483 .iter()
484 .enumerate()
485 .filter(|(_, v)| v.kind != VarKind::Continuous)
486 .map(|(i, _)| i)
487 .collect();
488 if !integer_vars.is_empty() {
489 return self.solve_mip_internal(c, a, b, constraint_types, bounds, integer_vars);
490 }
491
492 if let Some(ref q_orig) = self.quadratic_objective.clone() {
494 return self.solve_qp_internal(c, a, b, bounds, q_orig.clone(), num_constraints);
495 }
496
497 let problem = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
499 .map_err(map_lp_build_err)?;
500
501 let mut lp_opts = otspot_core::options::SolverOptions::default();
502 if let Some(t) = self.timeout_secs {
503 lp_opts.timeout_secs = Some(t);
504 }
505 if let Some(tol) = self.tolerance {
506 lp_opts.tolerance = Some(tol);
507 }
508 if let Some(flag) = self.presolve {
509 lp_opts.presolve = flag;
510 }
511 if let Some(n) = self.threads {
512 lp_opts.threads = n;
513 }
514 let solver_result = otspot_core::lp::solve_lp_with(&problem, &lp_opts);
515
516 let lp_extras = |sr: &otspot_core::problem::SolverResult| {
519 let dual = if sr.dual_solution.is_empty() {
520 None
521 } else {
522 Some(sr.dual_solution.clone())
523 };
524 let rc = if sr.reduced_costs.is_empty() {
525 None
526 } else {
527 Some(sr.reduced_costs.clone())
528 };
529 let slack = if sr.slack.is_empty() {
530 None
531 } else {
532 Some(sr.slack.clone())
533 };
534 (dual, rc, slack)
535 };
536
537 let signed_obj = |raw: f64| -> f64 {
538 let oriented = if self.sense == OptimizationSense::Maximize {
539 -raw
540 } else {
541 raw
542 };
543 oriented + self.obj_offset + self.obj_expr_constant
544 };
545 let lp_model_id = self.model_id;
546 let build_ok = |sr: otspot_core::problem::SolverResult| {
547 let (dual, rc, slack) = lp_extras(&sr);
548 let status = sr.status.clone();
549 ModelResult {
550 model_id: lp_model_id,
551 status: status.clone(),
552 proof: SolutionProof::from_status(&status),
553 objective_value: signed_obj(sr.objective),
554 solution: sr.solution,
555 dual_solution: dual,
556 reduced_costs: rc,
557 slack,
558 bound_duals: sr.bound_duals,
559 stats: sr.stats,
560 }
561 };
562
563 match solver_result.status {
564 SolveStatus::Optimal => Ok(build_ok(solver_result)),
565 SolveStatus::MaxIterations => {
566 if solver_result.solution.is_empty() {
567 Err(ModelError::SolveError(SolveError::MaxIterations))
568 } else {
569 Ok(build_ok(solver_result))
570 }
571 }
572 SolveStatus::SuboptimalSolution => {
573 if solver_result.solution.is_empty() {
574 Err(ModelError::Timeout)
575 } else {
576 Ok(build_ok(solver_result))
577 }
578 }
579 SolveStatus::Timeout => Err(ModelError::Timeout),
580 SolveStatus::LocallyOptimal
581 | SolveStatus::NonconvexLocal
582 | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
583 "Unexpected nonconvex status on LP path".to_string(),
584 )),
585 s => Err(classify_status_error(s)
586 .unwrap_or_else(|| ModelError::Internal("unhandled LP status".to_string()))),
587 }
588 }
589
590 fn build_qp_problem(
594 &self,
595 c: Vec<f64>,
596 bounds: Vec<(f64, f64)>,
597 q_orig: CscMatrix,
598 ) -> Result<otspot_core::qp::QpProblem, ModelError> {
599 use otspot_core::qp::QpProblem;
600
601 let num_vars = self.variables.len();
602
603 let qp_q = if self.sense == OptimizationSense::Maximize {
605 q_orig.scale_values(-1.0)
606 } else {
607 q_orig
608 };
609
610 let mut qp_trip_rows: Vec<usize> = Vec::new();
613 let mut qp_trip_cols: Vec<usize> = Vec::new();
614 let mut qp_trip_vals: Vec<f64> = Vec::new();
615 let mut qp_b: Vec<f64> = Vec::new();
616 let mut qp_constraint_types: Vec<ConstraintType> = Vec::new();
617 let mut qp_row = 0usize;
618
619 for con in &self.constraints {
620 for (&var, &coeff) in &con.lhs.coefficients {
621 qp_trip_rows.push(qp_row);
622 qp_trip_cols.push(var.index);
623 qp_trip_vals.push(coeff);
624 }
625 qp_b.push(con.rhs);
626 qp_constraint_types.push(match con.sense {
627 ConstraintSense::Le => ConstraintType::Le,
628 ConstraintSense::Ge => ConstraintType::Ge,
629 ConstraintSense::Eq => ConstraintType::Eq,
630 });
631 qp_row += 1;
632 }
633
634 let m_qp = qp_row;
635 let qp_a = if m_qp == 0 {
636 CscMatrix::new(0, num_vars)
637 } else {
638 CscMatrix::from_triplets(&qp_trip_rows, &qp_trip_cols, &qp_trip_vals, m_qp, num_vars)
639 .map_err(|e| ModelError::Internal(e.to_string()))?
640 };
641
642 let mut qp_problem = QpProblem::new(qp_q, c, qp_a, qp_b, bounds, qp_constraint_types)
643 .map_err(map_qp_build_err)?;
644 qp_problem.obj_offset = 0.0;
646 Ok(qp_problem)
647 }
648
649 fn solve_qp_internal(
651 &self,
652 c: Vec<f64>,
653 _lp_a: CscMatrix,
654 _lp_b: Vec<f64>,
655 bounds: Vec<(f64, f64)>,
656 q_orig: CscMatrix,
657 num_model_constraints: usize,
658 ) -> Result<ModelResult, ModelError> {
659 let qp_problem = self.build_qp_problem(c, bounds, q_orig)?;
660
661 let mut opts = otspot_core::options::SolverOptions::default();
662 if let Some(t) = self.timeout_secs {
663 opts.timeout_secs = Some(t);
664 }
665 if let Some(flag) = self.use_ruiz_scaling {
666 opts.use_ruiz_scaling = flag;
667 }
668 if let Some(tol) = self.tolerance {
669 opts.tolerance = Some(tol);
670 }
671 if let Some(n) = self.threads {
672 opts.threads = n;
673 }
674 let qp_result = otspot_core::qp::solve_qp_with(&qp_problem, &opts);
675 let qp_stats = qp_result.stats.clone();
676
677 let fold_dual = |sol: &[f64]| -> Option<Vec<f64>> {
679 if sol.len() == num_model_constraints {
680 Some(sol.to_vec())
681 } else if !sol.is_empty() && num_model_constraints > 0 {
682 let take = num_model_constraints.min(sol.len());
683 Some(sol[..take].to_vec())
684 } else {
685 None
686 }
687 };
688
689 let signed_obj = |raw: f64| -> f64 {
690 let oriented = if self.sense == OptimizationSense::Maximize {
691 -raw
692 } else {
693 raw
694 };
695 oriented + self.obj_offset + self.obj_expr_constant
696 };
697 let qp_model_id = self.model_id;
698 let build_ok = |status: SolveStatus,
699 raw_obj: f64,
700 sol: Vec<f64>,
701 dual: Option<Vec<f64>>,
702 bd: Vec<f64>| {
703 let proof = SolutionProof::from_status(&status);
704 ModelResult {
705 model_id: qp_model_id,
706 status,
707 proof,
708 objective_value: signed_obj(raw_obj),
709 solution: sol,
710 dual_solution: dual,
711 reduced_costs: None,
712 slack: None,
713 bound_duals: bd,
714 stats: qp_stats.clone(),
715 }
716 };
717
718 match qp_result.status {
719 SolveStatus::Optimal => Ok(build_ok(
720 SolveStatus::Optimal,
721 qp_result.objective,
722 qp_result.solution,
723 fold_dual(&qp_result.dual_solution),
724 qp_result.bound_duals,
725 )),
726 SolveStatus::MaxIterations => {
727 if qp_result.solution.is_empty() {
728 Err(ModelError::SolveError(SolveError::MaxIterations))
729 } else {
730 Ok(build_ok(
731 SolveStatus::MaxIterations,
732 qp_result.objective,
733 qp_result.solution,
734 fold_dual(&qp_result.dual_solution),
735 qp_result.bound_duals,
736 ))
737 }
738 }
739 SolveStatus::SuboptimalSolution => {
740 if qp_result.solution.is_empty() {
742 Err(ModelError::Timeout)
743 } else {
744 Ok(build_ok(
745 SolveStatus::SuboptimalSolution,
746 qp_result.objective,
747 qp_result.solution,
748 fold_dual(&qp_result.dual_solution),
749 qp_result.bound_duals,
750 ))
751 }
752 }
753 SolveStatus::Timeout => Err(ModelError::Timeout),
754 status @ (SolveStatus::LocallyOptimal
757 | SolveStatus::NonconvexLocal
758 | SolveStatus::NonconvexGlobal) => Ok(build_ok(
759 status,
760 qp_result.objective,
761 qp_result.solution,
762 fold_dual(&qp_result.dual_solution),
763 qp_result.bound_duals,
764 )),
765 s => Err(classify_status_error(s)
766 .unwrap_or_else(|| ModelError::Internal("unhandled QP status".to_string()))),
767 }
768 }
769
770 fn solve_mip_internal(
776 &self,
777 c: Vec<f64>,
778 a: CscMatrix,
779 b: Vec<f64>,
780 constraint_types: Vec<ConstraintType>,
781 bounds: Vec<(f64, f64)>,
782 integer_vars: Vec<usize>,
783 ) -> Result<ModelResult, ModelError> {
784 let mut opts = otspot_core::options::SolverOptions::default();
785 if let Some(t) = self.timeout_secs {
786 opts.timeout_secs = Some(t);
787 }
788 if let Some(tol) = self.tolerance {
789 opts.tolerance = Some(tol);
790 }
791 if let Some(n) = self.threads {
792 opts.threads = n;
793 }
794 let cfg = otspot_core::options::MipConfig::default();
795
796 let result = if let Some(ref q_orig) = self.quadratic_objective.clone() {
797 if let Some(flag) = self.use_ruiz_scaling {
799 opts.use_ruiz_scaling = flag;
800 }
801 let qp = self.build_qp_problem(c, bounds, q_orig.clone())?;
802 let miqp = otspot_core::mip::MiqpProblem::new(qp, integer_vars.clone())
803 .map_err(|e| ModelError::Internal(e.to_string()))?;
804 otspot_core::mip::solve_miqp(&miqp, &opts, &cfg)
805 } else {
806 if let Some(flag) = self.presolve {
808 opts.presolve = flag;
809 }
810 let lp = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
811 .map_err(map_lp_build_err)?;
812 let milp = otspot_core::mip::MilpProblem::new(lp, integer_vars.clone())
813 .map_err(|e| ModelError::Internal(e.to_string()))?;
814 otspot_core::mip::solve_milp(&milp, &opts, &cfg)
815 };
816
817 self.finish_mip(result, &integer_vars)
818 }
819
820 fn finish_mip(
823 &self,
824 result: otspot_core::problem::SolverResult,
825 integer_vars: &[usize],
826 ) -> Result<ModelResult, ModelError> {
827 let signed_obj = |raw: f64| -> f64 {
828 let oriented = if self.sense == OptimizationSense::Maximize {
829 -raw
830 } else {
831 raw
832 };
833 oriented + self.obj_offset + self.obj_expr_constant
834 };
835
836 let round_integers = |mut sol: Vec<f64>| -> Vec<f64> {
838 for &j in integer_vars {
839 if j < sol.len() {
840 sol[j] = sol[j].round();
841 }
842 }
843 sol
844 };
845
846 let mip_model_id = self.model_id;
847 let build_ok = |sr: otspot_core::problem::SolverResult| {
848 let status = sr.status.clone();
849 ModelResult {
850 model_id: mip_model_id,
851 status: status.clone(),
852 proof: SolutionProof::from_status(&status),
853 objective_value: signed_obj(sr.objective),
854 solution: round_integers(sr.solution),
855 dual_solution: None,
856 reduced_costs: None,
857 slack: None,
858 bound_duals: vec![],
859 stats: sr.stats,
860 }
861 };
862
863 match result.status {
864 SolveStatus::Optimal => Ok(build_ok(result)),
865 SolveStatus::Timeout => {
866 if result.solution.is_empty() {
868 Err(ModelError::Timeout)
869 } else {
870 Ok(build_ok(result))
871 }
872 }
873 SolveStatus::SuboptimalSolution | SolveStatus::MaxIterations => {
874 if result.solution.is_empty() {
875 Err(ModelError::SolveError(SolveError::MaxIterations))
876 } else {
877 Ok(build_ok(result))
878 }
879 }
880 SolveStatus::LocallyOptimal
881 | SolveStatus::NonconvexLocal
882 | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
883 "Unexpected nonconvex status on MIP path".to_string(),
884 )),
885 s => Err(classify_status_error(s)
886 .unwrap_or_else(|| ModelError::Internal("unhandled MIP status".to_string()))),
887 }
888 }
889
890 fn record_input_error(&mut self, key: &'static str, err: ModelError) {
891 let msg = match err {
892 ModelError::InvalidInput(msg) => msg,
893 other => other.to_string(),
894 };
895 self.invalid_inputs.insert(key, msg);
896 }
897}
898
899fn classify_status_error(status: SolveStatus) -> Option<ModelError> {
907 match status {
908 SolveStatus::Infeasible => Some(ModelError::SolveError(SolveError::Infeasible)),
909 SolveStatus::Unbounded => Some(ModelError::SolveError(SolveError::Unbounded)),
910 SolveStatus::NumericalError => Some(ModelError::SolveError(SolveError::NumericalError)),
911 SolveStatus::NonConvex(msg) => Some(ModelError::NonConvex(msg)),
912 SolveStatus::NotSupported(msg) => Some(ModelError::NotSupported(msg)),
913 SolveStatus::Optimal
916 | SolveStatus::LocallyOptimal
917 | SolveStatus::MaxIterations
918 | SolveStatus::SuboptimalSolution
919 | SolveStatus::Timeout
920 | SolveStatus::NonconvexLocal
921 | SolveStatus::NonconvexGlobal => None,
922 _ => None,
929 }
930}
931
932fn validate_timeout(secs: f64) -> Result<(), ModelError> {
933 if secs.is_finite() && secs >= 0.0 {
934 Ok(())
935 } else {
936 Err(ModelError::InvalidInput(format!(
937 "timeout must be finite and non-negative, got {secs}"
938 )))
939 }
940}
941
942fn map_lp_build_err(e: otspot_core::error::SolverError) -> ModelError {
947 use otspot_core::error::SolverError;
948 match e {
949 SolverError::NonFiniteCoefficient { .. } | SolverError::InvalidBounds { .. } => {
950 ModelError::InvalidInput(e.to_string())
951 }
952 _ => ModelError::Internal(e.to_string()),
953 }
954}
955
956fn map_qp_build_err(e: otspot_core::qp::QpProblemError) -> ModelError {
958 use otspot_core::qp::QpProblemError;
959 match e {
960 QpProblemError::NonFiniteCoefficient { .. }
961 | QpProblemError::InvalidBounds { .. }
962 | QpProblemError::TripletIndexOutOfBounds { .. } => {
963 ModelError::InvalidInput(e.to_string())
964 }
965 QpProblemError::DimensionMismatch(_) => ModelError::Internal(e.to_string()),
966 _ => ModelError::Internal(e.to_string()),
968 }
969}
970
971fn validate_bounds(lb: f64, ub: f64) -> Result<(), ModelError> {
972 if lb.is_nan() || ub.is_nan() {
973 return Err(ModelError::InvalidInput(format!(
974 "variable bounds must not be NaN: lb={lb}, ub={ub}"
975 )));
976 }
977 if lb > ub {
978 return Err(ModelError::InvalidInput(format!(
979 "variable lower bound {lb} exceeds upper bound {ub}"
980 )));
981 }
982 Ok(())
983}
984
985#[non_exhaustive]
991#[derive(Debug, Clone, Copy, PartialEq, Eq)]
992pub enum SolutionProof {
993 GlobalOptimal,
995 LocalOptimal,
997 FeasibleUnproven,
999}
1000
1001impl SolutionProof {
1002 fn from_status(status: &SolveStatus) -> Self {
1003 match status {
1004 SolveStatus::Optimal | SolveStatus::NonconvexGlobal => SolutionProof::GlobalOptimal,
1005 SolveStatus::LocallyOptimal => SolutionProof::LocalOptimal,
1006 SolveStatus::MaxIterations
1007 | SolveStatus::SuboptimalSolution
1008 | SolveStatus::Timeout
1009 | SolveStatus::NonconvexLocal => SolutionProof::FeasibleUnproven,
1010 SolveStatus::Infeasible
1014 | SolveStatus::Unbounded
1015 | SolveStatus::NumericalError
1016 | SolveStatus::NonConvex(_)
1017 | SolveStatus::NotSupported(_) => {
1018 debug_assert!(
1019 false,
1020 "from_status called with error status {:?}: this arm is unreachable from build_ok",
1021 status
1022 );
1023 SolutionProof::FeasibleUnproven
1024 }
1025 _ => SolutionProof::FeasibleUnproven,
1027 }
1028 }
1029}
1030
1031#[derive(Debug, Clone)]
1033pub struct ModelResult {
1034 model_id: u64,
1035 pub status: SolveStatus,
1042 pub proof: SolutionProof,
1044 pub objective_value: f64,
1046 solution: Vec<f64>,
1048 pub dual_solution: Option<Vec<f64>>,
1050 pub reduced_costs: Option<Vec<f64>>,
1058 pub slack: Option<Vec<f64>>,
1060 pub bound_duals: Vec<f64>,
1064 pub stats: otspot_core::problem::SolveStats,
1066}
1067
1068#[cfg(test)]
1072impl Model {
1073 pub(crate) fn has_quad_dsl_error(&self) -> bool {
1076 self.quad_dsl_error.is_some()
1077 }
1078
1079 pub(crate) fn is_quad_via_dsl(&self) -> bool {
1081 self.quadratic_objective.is_some()
1082 }
1083
1084 pub(crate) fn has_quadratic_objective(&self) -> bool {
1086 self.quadratic_objective.is_some()
1087 }
1088}
1089
1090impl ModelResult {
1091 pub fn value(&self, var: Variable) -> f64 {
1097 self.solution[var.index]
1098 }
1099
1100 pub fn try_value(&self, var: Variable) -> Result<f64, ModelError> {
1106 if var.model_id != self.model_id {
1107 return Err(ModelError::InvalidInput(
1108 "variable belongs to a different model".to_string(),
1109 ));
1110 }
1111 self.solution.get(var.index).copied().ok_or_else(|| {
1112 ModelError::InvalidInput(format!(
1113 "variable index {} out of range (solution length {})",
1114 var.index,
1115 self.solution.len()
1116 ))
1117 })
1118 }
1119
1120 pub fn objective(&self) -> f64 {
1122 self.objective_value
1123 }
1124
1125 pub fn has_global_optimality_proof(&self) -> bool {
1127 self.proof == SolutionProof::GlobalOptimal
1128 }
1129}
1130
1131impl Index<Variable> for ModelResult {
1143 type Output = f64;
1144 fn index(&self, var: Variable) -> &f64 {
1145 &self.solution[var.index]
1146 }
1147}
1148
1149#[non_exhaustive]
1155#[derive(Debug, Clone, PartialEq)]
1156pub enum SolveError {
1157 Infeasible,
1159 Unbounded,
1161 MaxIterations,
1163 NumericalError,
1165}
1166
1167impl fmt::Display for SolveError {
1168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1169 match self {
1170 SolveError::Infeasible => write!(f, "Problem is infeasible"),
1171 SolveError::Unbounded => write!(f, "Problem is unbounded"),
1172 SolveError::MaxIterations => {
1173 write!(f, "Max iterations reached without optimal solution")
1174 }
1175 SolveError::NumericalError => write!(f, "Numerical breakdown during solve"),
1176 }
1177 }
1178}
1179
1180#[non_exhaustive]
1182#[derive(Debug)]
1183pub enum ModelError {
1184 NoObjective,
1186 InvalidInput(String),
1188 SolveError(SolveError),
1190 Timeout,
1192 NonConvex(String),
1195 NotSupported(String),
1197 Internal(String),
1199}
1200
1201impl fmt::Display for ModelError {
1202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1203 match self {
1204 ModelError::NoObjective => write!(
1205 f,
1206 "No objective function defined. Call model.minimize() or model.maximize() before solve()."
1207 ),
1208 ModelError::InvalidInput(msg) => write!(f, "Invalid model input: {}", msg),
1209 ModelError::SolveError(e) => write!(f, "Solve failed: {}", e),
1210 ModelError::Timeout => write!(f, "Solver timed out"),
1211 ModelError::NonConvex(msg) => write!(f, "Non-convex problem: {}", msg),
1212 ModelError::NotSupported(msg) => write!(f, "Not supported: {}", msg),
1213 ModelError::Internal(msg) => write!(f, "Internal error: {}", msg),
1214 }
1215 }
1216}
1217
1218impl std::error::Error for ModelError {}
1219
1220#[cfg(test)]
1225mod tests {
1226 use super::{classify_status_error, Model, ModelError, SolutionProof, SolveError};
1227 use crate::variable::Variable;
1228 use otspot_core::problem::SolveStatus;
1229
1230 const EPS: f64 = 2e-3;
1232
1233 fn assert_close(a: f64, b: f64, name: &str) {
1234 assert!(
1235 (a - b).abs() < EPS,
1236 "{}: expected {:.8}, got {:.8}",
1237 name,
1238 b,
1239 a
1240 );
1241 }
1242
1243 fn basic_model() -> (Model, Variable, Variable) {
1250 let mut model = Model::new("basic");
1251 let x = model.add_var("x", 0.0, f64::INFINITY);
1252 let y = model.add_var("y", 0.0, 10.0);
1253 model.add_constraint((2.0 * x + 3.0 * y).leq(12.0));
1255 model.add_constraint((x + y).geq(3.0));
1256 model.minimize(x + 2.0 * y);
1257 (model, x, y)
1258 }
1259
1260 #[test]
1264 fn test_basic_lp_3var_3con() {
1265 let mut model = Model::new("3var");
1271 let x = model.add_var("x", 0.0, f64::INFINITY);
1272 let y = model.add_var("y", 0.0, f64::INFINITY);
1273 let z = model.add_var("z", 0.0, f64::INFINITY);
1274
1275 model.add_constraint((x + y + z).geq(6.0));
1277 model.add_constraint((x + 2.0 * y).leq(10.0));
1278 model.add_constraint((y + z).geq(4.0));
1279 model.minimize(x + 2.0 * y + 3.0 * z);
1280
1281 let result = model.solve().unwrap();
1282 assert!(result[x] + result[y] + result[z] >= 6.0 - 1e-6);
1284 assert!(result[y] + result[z] >= 4.0 - 1e-6);
1285 assert!(result[x] >= -1e-9);
1286 assert!(result[y] >= -1e-9);
1287 assert!(result[z] >= -1e-9);
1288 assert!(result.objective_value > 0.0, "objective should be positive");
1289 }
1290
1291 #[test]
1295 fn test_unbounded() {
1296 let mut model = Model::new("unbounded");
1298 let x = model.add_var("x", 0.0, f64::INFINITY);
1299 model.minimize(-1.0 * x);
1300
1301 let err = model.solve().unwrap_err();
1302 assert!(
1303 matches!(err, ModelError::SolveError(SolveError::Unbounded)),
1304 "expected Unbounded, got {:?}",
1305 err
1306 );
1307 }
1308
1309 #[test]
1313 fn test_infeasible() {
1314 let mut model = Model::new("infeasible");
1316 let x = model.add_var("x", 0.0, f64::INFINITY);
1317 model.add_constraint(crate::constraint!(x >= 5.0));
1319 model.add_constraint(crate::constraint!(x <= 3.0));
1320 model.minimize(x);
1321
1322 let err = model.solve().unwrap_err();
1323 assert!(
1324 matches!(err, ModelError::SolveError(SolveError::Infeasible)),
1325 "expected Infeasible, got {:?}",
1326 err
1327 );
1328 }
1329
1330 #[test]
1334 fn test_equality_constraint() {
1335 let mut model = Model::new("eq");
1338 let x = model.add_var("x", 0.0, f64::INFINITY);
1339 let y = model.add_var("y", 0.0, f64::INFINITY);
1340 model.add_constraint((x + y).eq_constraint(5.0));
1342 model.minimize(x + y);
1343
1344 let result = model.solve().unwrap();
1345 assert!(
1346 (result.objective_value - 5.0).abs() < 1e-6,
1347 "obj={} expected 5.0",
1348 result.objective_value
1349 );
1350 }
1351
1352 #[test]
1356 fn test_variable_bounds() {
1357 let mut model = Model::new("bounds");
1360 let x = model.add_var("x", 0.0, 3.0);
1361 model.minimize(x);
1362
1363 let result = model.solve().unwrap();
1364 assert!(result[x].abs() < 1e-6, "x should be 0.0, got {}", result[x]);
1365
1366 let mut model2 = Model::new("bounds_max");
1370 let x2 = model2.add_var("x", 0.0, 3.0);
1371 model2.add_constraint(crate::constraint!(x2 <= 3.0));
1372 model2.maximize(x2);
1373
1374 let result2 = model2.solve().unwrap();
1375 assert!(
1376 (result2[x2] - 3.0).abs() < 1e-6,
1377 "x should be 3.0, got {}",
1378 result2[x2]
1379 );
1380 }
1381
1382 #[test]
1386 fn test_no_objective_error() {
1387 let mut model = Model::new("no_obj");
1388 let _x = model.add_var("x", 0.0, f64::INFINITY);
1389 let err = model.solve().unwrap_err();
1390 assert!(matches!(err, ModelError::NoObjective));
1391 }
1392
1393 #[test]
1394 fn solution_proof_mapping_preserves_unproven_statuses() {
1395 assert_eq!(
1396 SolutionProof::from_status(&SolveStatus::Optimal),
1397 SolutionProof::GlobalOptimal
1398 );
1399 assert_eq!(
1400 SolutionProof::from_status(&SolveStatus::NonconvexGlobal),
1401 SolutionProof::GlobalOptimal
1402 );
1403 assert_eq!(
1404 SolutionProof::from_status(&SolveStatus::LocallyOptimal),
1405 SolutionProof::LocalOptimal
1406 );
1407 assert_eq!(
1408 SolutionProof::from_status(&SolveStatus::NonconvexLocal),
1409 SolutionProof::FeasibleUnproven
1410 );
1411 assert_eq!(
1412 SolutionProof::from_status(&SolveStatus::Timeout),
1413 SolutionProof::FeasibleUnproven
1414 );
1415 assert_eq!(
1416 SolutionProof::from_status(&SolveStatus::SuboptimalSolution),
1417 SolutionProof::FeasibleUnproven
1418 );
1419 }
1420
1421 #[test]
1425 fn test_result_index_and_value_agree() {
1426 let (mut model, x, y) = basic_model();
1427 let result = model.solve().unwrap();
1428 assert!((result[x] - result.value(x)).abs() < 1e-12);
1429 assert!((result[y] - result.value(y)).abs() < 1e-12);
1430 }
1431
1432 #[test]
1436 fn test_maximize() {
1437 let mut model = Model::new("max_simple");
1439 let x = model.add_var("x", 0.0, f64::INFINITY);
1440 model.add_constraint(crate::constraint!(x <= 7.0));
1442 model.maximize(x);
1443
1444 let result = model.solve().unwrap();
1445 assert!(
1446 (result[x] - 7.0).abs() < 1e-6,
1447 "expected x=7, got {}",
1448 result[x]
1449 );
1450 assert!(
1451 (result.objective() - 7.0).abs() < 1e-6,
1452 "expected obj=7, got {}",
1453 result.objective()
1454 );
1455 }
1456
1457 #[test]
1461 fn test_model_qp_basic() {
1462 let mut model = Model::new("qp_basic");
1465 let x = model.add_var("x", 0.0, f64::INFINITY);
1466 let y = model.add_var("y", 0.0, f64::INFINITY);
1467 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1468
1469 let result = model.solve().unwrap();
1470 assert_close(result[x], 2.0, "T9: x");
1471 assert_close(result[y], 2.0, "T9: y");
1472 assert_close(result.objective_value, -8.0, "T9: obj");
1474 }
1475
1476 #[test]
1480 fn test_model_qp_equality() {
1481 let mut model = Model::new("qp_eq");
1484 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1485 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1486 model.add_constraint((x + y).eq_constraint(1.0));
1487 model.minimize(x * x + y * y);
1488
1489 let result = model.solve().unwrap();
1490 assert_close(result[x], 0.5, "T10: x");
1491 assert_close(result[y], 0.5, "T10: y");
1492 assert_close(result.objective_value, 0.5, "T10: obj");
1493 }
1494
1495 #[test]
1499 fn test_model_qp_ge_constraint() {
1500 let mut model = Model::new("qp_ge");
1503 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1504 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1505 model.add_constraint((x + y).geq(1.0));
1506 model.minimize(x * x + y * y);
1507
1508 let result = model.solve().unwrap();
1509 let qp_tol = 2e-3;
1510 assert!(
1511 (result[x] - 0.5).abs() < qp_tol,
1512 "T11: x expected 0.5, got {}",
1513 result[x]
1514 );
1515 assert!(
1516 (result[y] - 0.5).abs() < qp_tol,
1517 "T11: y expected 0.5, got {}",
1518 result[y]
1519 );
1520 assert!(
1521 (result.objective_value - 0.5).abs() < qp_tol,
1522 "T11: obj expected 0.5, got {}",
1523 result.objective_value
1524 );
1525 }
1526
1527 #[test]
1531 fn test_model_qp_maximize() {
1532 let mut model = Model::new("qp_maximize");
1535 let x = model.add_var("x", 0.0, f64::INFINITY);
1536 let y = model.add_var("y", 0.0, f64::INFINITY);
1537 model.add_constraint((x + y).geq(1.0));
1538 model.maximize((-1.0) * x * x + (-1.0) * y * y);
1539
1540 let result = model.solve().unwrap();
1541 assert_close(result[x], 0.5, "T12: x");
1542 assert_close(result[y], 0.5, "T12: y");
1543 assert_close(result.objective_value, -0.5, "T12: obj");
1544 }
1545
1546 #[test]
1550 fn test_model_qp_box_bounds() {
1551 let mut model = Model::new("qp_box");
1555 let x = model.add_var("x", 0.0, 1.0);
1556 let y = model.add_var("y", 0.0, 1.0);
1557 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1558
1559 let result = model.solve().unwrap();
1560 assert_close(result[x], 1.0, "T13: x");
1561 assert_close(result[y], 1.0, "T13: y");
1562 assert_close(result.objective_value, -6.0, "T13: obj");
1563 }
1564
1565 #[test]
1569 fn test_model_qp_timeout() {
1570 let mut model = Model::new("qp_timeout");
1572 let x = model.add_var("x", 0.0, f64::INFINITY);
1573 let y = model.add_var("y", 0.0, f64::INFINITY);
1574 model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1575 model.set_timeout(0.000_001); let err = model.solve().unwrap_err();
1578 assert!(
1579 matches!(err, ModelError::Timeout),
1580 "expected Timeout, got {:?}",
1581 err
1582 );
1583 }
1584
1585 #[test]
1589 fn test_model_lp_equality() {
1590 let mut model = Model::new("lp_eq");
1593 let x = model.add_var("x", 0.0, f64::INFINITY);
1594 let y = model.add_var("y", 0.0, f64::INFINITY);
1595 model.add_constraint((x + y).eq_constraint(4.0));
1596 model.minimize(x + 2.0 * y);
1597
1598 let result = model.solve().unwrap();
1599 assert_close(result.objective_value, 4.0, "T8-1: obj");
1600 assert_close(result[x] + result[y], 4.0, "T8-1: x+y=4");
1602 }
1603
1604 #[test]
1608 fn test_model_eq_dual_solution() {
1609 let mut model = Model::new("qp_eq_dual");
1613 let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1614 let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1615 model.add_constraint((x + y).eq_constraint(1.0));
1616 model.minimize(x * x + y * y);
1617
1618 let result = model.solve().unwrap();
1619 assert_close(result.objective_value, 0.5, "T8-2: obj");
1620 assert_close(result[x], 0.5, "T8-2: x");
1621 assert_close(result[y], 0.5, "T8-2: y");
1622
1623 let dual = result
1625 .dual_solution
1626 .as_ref()
1627 .expect("T8-2: dual_solution is None");
1628 assert!(
1629 dual.len() == 1,
1630 "T8-2: dual length expected 1, got {}",
1631 dual.len()
1632 );
1633 assert!(
1634 (dual[0] - (-1.0)).abs() < EPS,
1635 "T8-2: dual[0] expected -1.0, got {}",
1636 dual[0]
1637 );
1638 }
1639
1640 #[test]
1647 #[allow(clippy::type_complexity)]
1648 fn test_model_qp_locally_optimal_proof() {
1649 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1651 ("diag(-2,2)", [-2.0, 2.0], (-1.0, 1.0), [0.0, 0.0]),
1653 ("diag(-3,5)", [-3.0, 5.0], (-2.0, 2.0), [-1.0, 0.0]),
1655 ];
1656
1657 for &(name, q_diag, (lb, ub), c) in cases {
1658 let mut model = Model::new(name);
1659 let x = model.add_var("x0", lb, ub);
1660 let y = model.add_var("x1", lb, ub);
1661 model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1663
1664 let result = model.solve();
1665 match result {
1666 Ok(r) => {
1667 assert_eq!(
1668 r.status,
1669 otspot_core::problem::SolveStatus::LocallyOptimal,
1670 "[{name}] expected LocallyOptimal, got {:?}",
1671 r.status
1672 );
1673 assert_eq!(
1674 r.proof,
1675 SolutionProof::LocalOptimal,
1676 "[{name}] expected LocalOptimal proof, got {:?}",
1677 r.proof
1678 );
1679 assert!(
1680 !r.has_global_optimality_proof(),
1681 "[{name}] has_global_optimality_proof must be false for LocallyOptimal"
1682 );
1683 }
1684 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1685 }
1686 }
1687 }
1688
1689 #[test]
1697 #[allow(clippy::type_complexity)]
1698 fn test_model_qp_feasible_unproven_proof() {
1699 use otspot_core::options::Tolerance;
1700
1701 let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1703 ("convex_2x2_tight_tol", [2.0, 2.0], (0.0, f64::INFINITY), [-4.0, -4.0]),
1706 ("convex_box_tight_tol", [4.0, 4.0], (0.0, 3.0), [0.0, -2.0]),
1708 ];
1709
1710 for &(name, q_diag, (lb, ub), c) in cases {
1711 let mut model = Model::new(name);
1712 let x = model.add_var("x0", lb, ub);
1713 let y = model.add_var("x1", lb, ub);
1714 model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1715 model.set_tolerance(Tolerance::Custom(1e-200));
1718
1719 let result = model.solve();
1720 match result {
1721 Ok(r) => {
1722 assert_eq!(
1723 r.proof,
1724 SolutionProof::FeasibleUnproven,
1725 "[{name}] expected FeasibleUnproven proof, got {:?} (status={:?})",
1726 r.proof, r.status
1727 );
1728 assert!(
1729 !r.has_global_optimality_proof(),
1730 "[{name}] has_global_optimality_proof must be false for FeasibleUnproven"
1731 );
1732 assert!(!r.solution.is_empty(), "[{name}] solution must be non-empty");
1733 }
1734 Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1735 }
1736 }
1737 }
1738
1739 #[test]
1744 fn classify_status_error_typed_variants() {
1745 let cases_nonconvex = [
1746 "indefinite Q: eigenvalue < 0",
1747 "non-PSD matrix in MIQP",
1748 ];
1749 for msg in &cases_nonconvex {
1750 let status = SolveStatus::NonConvex(msg.to_string());
1751 let err = classify_status_error(status).expect("NonConvex must map to Some");
1752 assert!(
1753 matches!(err, ModelError::NonConvex(_)),
1754 "NonConvex status must yield ModelError::NonConvex, got {:?}",
1755 err
1756 );
1757 }
1758
1759 let cases_not_supported = [
1760 "QCQP not supported",
1761 "constraint type unsupported",
1762 ];
1763 for msg in &cases_not_supported {
1764 let status = SolveStatus::NotSupported(msg.to_string());
1765 let err = classify_status_error(status).expect("NotSupported must map to Some");
1766 assert!(
1767 matches!(err, ModelError::NotSupported(_)),
1768 "NotSupported status must yield ModelError::NotSupported, got {:?}",
1769 err
1770 );
1771 }
1772 }
1773
1774 #[test]
1780 fn miqp_nonconvex_q_returns_nonconvex_error() {
1781 let cases: &[(&str, [f64; 2])] = &[
1782 ("diag(-1, 1)", [-1.0, 1.0]),
1783 ("diag(-2, 3)", [-2.0, 3.0]),
1784 ("diag(1, -1)", [1.0, -1.0]),
1785 ];
1786
1787 for &(name, q_diag) in cases {
1788 let mut model = Model::new(name);
1789 let x = model.add_binary_var("x");
1790 let y = model.add_binary_var("y");
1791 model.minimize((q_diag[0] / 2.0) * (x * x) + (q_diag[1] / 2.0) * (y * y));
1793
1794 let err = model
1795 .solve()
1796 .expect_err(&format!("[{name}] expected Err(NonConvex), got Ok"));
1797 assert!(
1798 matches!(err, ModelError::NonConvex(_)),
1799 "[{name}] expected ModelError::NonConvex, got {:?}",
1800 err
1801 );
1802 }
1803 }
1804
1805 #[test]
1812 fn add_var_lb_gt_ub_defers_error_to_solve() {
1813 let cases: &[(&str, f64, f64)] = &[
1814 ("lb=5 > ub=3", 5.0, 3.0),
1815 ("lb=1.0 > ub=0.999", 1.0, 0.999),
1816 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1817 ];
1818 for &(label, lb, ub) in cases {
1819 let mut model = Model::new(label);
1820 let x = model.add_var("x", lb, ub);
1821 model.minimize(x);
1822 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1823 assert!(
1824 matches!(err, ModelError::InvalidInput(_)),
1825 "[{label}] expected InvalidInput, got {err:?}"
1826 );
1827 }
1828 }
1829
1830 #[test]
1831 fn add_var_nan_bounds_defers_error_to_solve() {
1832 let cases: &[(&str, f64, f64)] = &[
1833 ("lb=NaN", f64::NAN, 1.0),
1834 ("ub=NaN", 0.0, f64::NAN),
1835 ("both=NaN", f64::NAN, f64::NAN),
1836 ];
1837 for &(label, lb, ub) in cases {
1838 let mut model = Model::new(label);
1839 let x = model.add_var("x", lb, ub);
1840 model.minimize(x);
1841 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1842 assert!(
1843 matches!(err, ModelError::InvalidInput(_)),
1844 "[{label}] expected InvalidInput, got {err:?}"
1845 );
1846 }
1847 }
1848
1849 #[test]
1850 fn add_int_var_lb_gt_ub_defers_error_to_solve() {
1851 let cases: &[(&str, f64, f64)] = &[
1852 ("int lb=3 > ub=1", 3.0, 1.0),
1853 ("int lb=NaN", f64::NAN, 5.0),
1854 ];
1855 for &(label, lb, ub) in cases {
1856 let mut model = Model::new(label);
1857 let x = model.add_int_var("x", lb, ub);
1858 model.minimize(x);
1859 let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1860 assert!(
1861 matches!(err, ModelError::InvalidInput(_)),
1862 "[{label}] expected InvalidInput, got {err:?}"
1863 );
1864 }
1865 }
1866
1867 #[test]
1872 fn try_add_var_returns_err_for_invalid_bounds() {
1873 let cases: &[(&str, f64, f64)] = &[
1874 ("lb>ub", 2.0, 1.0),
1875 ("lb=NaN", f64::NAN, 1.0),
1876 ("ub=NaN", 0.0, f64::NAN),
1877 ("lb=inf > ub=0", f64::INFINITY, 0.0),
1878 ];
1879 for &(label, lb, ub) in cases {
1880 let mut model = Model::new(label);
1881 let result = model.try_add_var("x", lb, ub);
1882 assert!(
1883 result.is_err(),
1884 "[{label}] try_add_var should return Err for invalid bounds, got Ok"
1885 );
1886 }
1887 }
1888
1889 #[test]
1890 fn try_add_var_returns_ok_for_valid_bounds() {
1891 let cases: &[(&str, f64, f64)] = &[
1892 ("lb=ub", 3.0, 3.0),
1893 ("lb=0 ub=inf", 0.0, f64::INFINITY),
1894 ("lb=-inf ub=inf", f64::NEG_INFINITY, f64::INFINITY),
1895 ("lb=-inf ub=0", f64::NEG_INFINITY, 0.0),
1896 ];
1897 for &(label, lb, ub) in cases {
1898 let mut model = Model::new(label);
1899 assert!(
1900 model.try_add_var("x", lb, ub).is_ok(),
1901 "[{label}] try_add_var should return Ok for valid bounds"
1902 );
1903 }
1904 }
1905
1906 #[test]
1907 fn try_add_int_var_returns_err_for_invalid_bounds() {
1908 let cases: &[(&str, f64, f64)] = &[
1909 ("int lb>ub", 5.0, 2.0),
1910 ("int lb=NaN", f64::NAN, 3.0),
1911 ];
1912 for &(label, lb, ub) in cases {
1913 let mut model = Model::new(label);
1914 assert!(
1915 model.try_add_int_var("n", lb, ub).is_err(),
1916 "[{label}] try_add_int_var should return Err"
1917 );
1918 }
1919 }
1920
1921 #[test]
1927 fn try_value_cross_model_returns_err() {
1928 let mut m1 = Model::new("m1");
1929 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
1930 m1.minimize(x1);
1931 let r1 = m1.solve().unwrap();
1932
1933 let mut m2 = Model::new("m2");
1935 let y = m2.add_var("y", 0.0, f64::INFINITY);
1936
1937 assert!(
1938 r1.try_value(y).is_err(),
1939 "try_value with variable from different model must return Err"
1940 );
1941 assert!(r1.try_value(x1).is_ok());
1943 }
1944
1945 #[test]
1946 fn try_value_valid_returns_ok() {
1947 let (mut model, x, y) = basic_model();
1948 let result = model.solve().unwrap();
1949 assert!(result.try_value(x).is_ok());
1950 assert!(result.try_value(y).is_ok());
1951 assert!((result.try_value(x).unwrap() - result.value(x)).abs() < 1e-12);
1952 }
1953
1954 #[test]
1959 fn try_value_out_of_range_same_model_returns_err() {
1960 let mut model = Model::new("grow");
1961 let x = model.add_var("x", 0.0, f64::INFINITY);
1962 model.minimize(x);
1963 let result = model.solve().unwrap();
1964
1965 let late = model.add_var("late", 0.0, f64::INFINITY);
1967 assert_eq!(late.model_id, result.model_id, "same model_id expected");
1968 assert!(
1969 late.index >= result.solution.len(),
1970 "test setup: late var must be out of range"
1971 );
1972 assert!(
1973 result.try_value(late).is_err(),
1974 "try_value must return Err for an out-of-range index even when model_id matches"
1975 );
1976 }
1977
1978 #[test]
1982 fn model_result_clone() {
1983 let (mut model, x, _y) = basic_model();
1984 let result = model.solve().unwrap();
1985 let cloned = result.clone();
1986 assert!((cloned.objective_value - result.objective_value).abs() < 1e-12);
1987 assert_eq!(cloned.solution.len(), result.solution.len());
1988 assert!((cloned[x] - result[x]).abs() < 1e-12);
1989 }
1990
1991 #[test]
1995 fn var_name_is_retained() {
1996 let cases = [("alpha", 0.0, 1.0), ("beta_var", 0.0, f64::INFINITY)];
1997 let mut model = Model::new("named");
1998 for &(name, lb, ub) in &cases {
1999 let v = model.add_var(name, lb, ub);
2000 assert_eq!(model.var_name(v), name, "var_name mismatch for '{name}'");
2001 }
2002 let iv = model.add_int_var("gamma_int", 0.0, 10.0);
2003 assert_eq!(model.var_name(iv), "gamma_int");
2004 }
2005
2006 #[test]
2013 fn try_var_name_ok_same_model() {
2014 let cases: &[(&str, f64, f64)] = &[
2015 ("x", 0.0, f64::INFINITY),
2016 ("y_var", -1.0, 1.0),
2017 ("z_int", 0.0, 10.0),
2018 ];
2019 let mut model = Model::new("try_var_name_ok");
2020 let mut vars = Vec::new();
2021 for &(name, lb, ub) in cases {
2022 vars.push((model.add_var(name, lb, ub), name));
2023 }
2024 for (var, expected_name) in &vars {
2025 assert_eq!(
2026 model.try_var_name(*var).unwrap(),
2027 *expected_name,
2028 "try_var_name mismatch"
2029 );
2030 assert_eq!(
2032 model.var_name(*var),
2033 model.try_var_name(*var).unwrap(),
2034 "var_name and try_var_name must return the same value for '{expected_name}'"
2035 );
2036 }
2037 }
2038
2039 #[test]
2043 fn try_var_name_err_cross_model() {
2044 let mut model_a = Model::new("model_a");
2045 let x = model_a.add_var("x_in_a", 0.0, 1.0);
2046
2047 let cases: &[&str] = &["model_b", "model_c", "model_d"];
2048 for &name in cases {
2049 let m = Model::new(name);
2050 let err = m.try_var_name(x).unwrap_err();
2051 assert!(
2052 matches!(err, ModelError::InvalidInput(_)),
2053 "[{name}] expected InvalidInput for cross-model var, got {err:?}"
2054 );
2055 }
2056 }
2057
2058 #[test]
2061 fn try_var_name_err_index_out_of_range() {
2062 let mut model = Model::new("range_check");
2063 let x = model.add_var("x", 0.0, 1.0); assert!(
2067 model.try_var_name(x).is_ok(),
2068 "index=0 (N-1) must be Ok"
2069 );
2070
2071 let oob_n = Variable { index: 1, model_id: x.model_id };
2073 let err_n = model.try_var_name(oob_n).unwrap_err();
2074 assert!(
2075 matches!(err_n, ModelError::InvalidInput(_)),
2076 "index=N=1: expected InvalidInput, got {err_n:?}"
2077 );
2078
2079 let oob_max = Variable { index: usize::MAX, model_id: x.model_id };
2081 let err_max = model.try_var_name(oob_max).unwrap_err();
2082 assert!(
2083 matches!(err_max, ModelError::InvalidInput(_)),
2084 "index=usize::MAX: expected InvalidInput, got {err_max:?}"
2085 );
2086 }
2087
2088 #[test]
2089 fn var_name_regression_unchecked_still_works() {
2090 let mut model = Model::new("regression");
2092 let cases: &[(&str, f64, f64)] = &[("a", 0.0, 1.0), ("b", 0.0, 2.0), ("c_int", 0.0, 5.0)];
2093 let mut pairs = Vec::new();
2094 for &(name, lb, ub) in cases {
2095 pairs.push((model.add_var(name, lb, ub), name));
2096 }
2097 for (var, expected) in &pairs {
2098 assert_eq!(model.var_name(*var), *expected);
2099 }
2100 }
2101
2102 #[test]
2112 fn var_name_cross_model_in_range_silently_returns_wrong_data() {
2113 let mut model_a = Model::new("a");
2117 let x_a = model_a.add_var("x_in_a", 0.0, 1.0); let mut model_b = Model::new("b");
2120 let _y_b = model_b.add_var("y_in_b", 0.0, 1.0); let returned = model_b.var_name(x_a);
2125 assert_ne!(returned, "x_in_a", "unchecked: cross-model var_name returns unrelated data");
2126 assert_eq!(returned, "y_in_b", "unchecked: returns model_b's var at the same index");
2127 }
2128
2129 #[test]
2133 #[should_panic(expected = "index out of bounds")]
2134 fn var_name_cross_model_out_of_range_panics() {
2135 let mut model_a = Model::new("a_2var");
2136 let _x0 = model_a.add_var("x0", 0.0, 1.0); let x1 = model_a.add_var("x1", 0.0, 1.0); let mut model_b = Model::new("b_1var");
2141 let _y_b = model_b.add_var("y_in_b", 0.0, 1.0);
2142
2143 let _ = model_b.var_name(x1);
2144 }
2145
2146 #[test]
2151 fn set_timeout_invalid_defers_error() {
2152 let cases: &[(&str, f64)] = &[
2153 ("negative", -1.0),
2154 ("NaN", f64::NAN),
2155 ("neg_inf", f64::NEG_INFINITY),
2156 ];
2157 for &(label, secs) in cases {
2158 let mut model = Model::new(label);
2159 let x = model.add_var("x", 0.0, f64::INFINITY);
2160 model.minimize(x);
2161 model.set_timeout(secs);
2162 let err = model.solve().expect_err(&format!("[{label}] expected Err for invalid timeout"));
2163 assert!(
2164 matches!(err, ModelError::InvalidInput(_)),
2165 "[{label}] expected InvalidInput, got {err:?}"
2166 );
2167 }
2168 }
2169
2170 #[test]
2171 fn try_set_timeout_returns_err_for_invalid() {
2172 let cases: &[(&str, f64)] = &[("negative", -0.001), ("NaN", f64::NAN), ("inf", f64::INFINITY)];
2173 for &(label, secs) in cases {
2174 let mut model = Model::new(label);
2175 assert!(
2176 model.try_set_timeout(secs).is_err(),
2177 "[{label}] try_set_timeout should return Err"
2178 );
2179 }
2180 }
2181
2182 #[test]
2183 fn try_set_timeout_ok_for_valid() {
2184 let valid = [0.0, 0.001, 1.0, 3600.0];
2185 for &secs in &valid {
2186 let mut model = Model::new("t");
2187 assert!(model.try_set_timeout(secs).is_ok(), "should be Ok for secs={secs}");
2188 }
2189 }
2190}
2191
2192#[cfg(test)]
2201mod mip_model_tests {
2202 use super::{Model, ModelError, SolveError};
2203
2204 const EPS: f64 = 1e-4;
2205
2206 #[test]
2207 fn model_add_int_var_maximize_branches() {
2208 let mut m = Model::new("milp_int");
2210 let x = m.add_int_var("x", 0.0, 5.0);
2211 m.add_constraint((2.0 * x).leq(3.0));
2212 m.maximize(x);
2213 let r = m.solve().unwrap();
2214 assert!((r.objective() - 1.0).abs() < EPS, "obj={}", r.objective());
2215 assert!((r[x] - 1.0).abs() < EPS, "x={}", r[x]);
2216 }
2217
2218 #[test]
2219 fn model_binary_knapsack() {
2220 let mut m = Model::new("knapsack");
2221 let a = m.add_binary_var("a");
2222 let b = m.add_binary_var("b");
2223 let c = m.add_binary_var("c");
2224 let d = m.add_binary_var("d");
2225 m.add_constraint((5.0 * a + 7.0 * b + 4.0 * c + 3.0 * d).leq(14.0));
2226 m.maximize(8.0 * a + 11.0 * b + 6.0 * c + 4.0 * d);
2227 let r = m.solve().unwrap();
2228 assert!((r.objective() - 21.0).abs() < EPS, "obj={}", r.objective());
2229 assert_eq!(
2230 (r[a].round(), r[b].round(), r[c].round(), r[d].round()),
2231 (0.0, 1.0, 1.0, 1.0)
2232 );
2233 }
2234
2235 #[test]
2236 fn model_integer_infeasible_errors() {
2237 let mut m = Model::new("infeasible");
2238 let x = m.add_int_var("x", 0.0, 10.0);
2239 m.add_constraint((1.0 * x).geq(1.2));
2240 m.add_constraint((1.0 * x).leq(1.8));
2241 m.minimize(x);
2242 let err = m.solve().unwrap_err();
2243 assert!(matches!(err, ModelError::SolveError(SolveError::Infeasible)), "got {err:?}");
2244 }
2245
2246 #[test]
2247 fn model_integer_unbounded_errors() {
2248 let mut m = Model::new("unbounded");
2249 let x = m.add_int_var("x", 0.0, f64::INFINITY);
2250 m.maximize(x);
2251 let err = m.solve().unwrap_err();
2252 assert!(matches!(err, ModelError::SolveError(SolveError::Unbounded)), "got {err:?}");
2253 }
2254
2255 #[test]
2256 fn model_convex_miqp_branches_to_integer_optimum() {
2257 let mut m = Model::new("convex_miqp");
2260 let x = m.add_int_var("x", 0.0, 5.0);
2261 m.minimize(x * x + (-5.0) * x);
2262 let r = m.solve().unwrap();
2263 assert!((r.objective() - (-6.0)).abs() < EPS, "obj={}", r.objective());
2264 let xr = r[x].round();
2265 assert!(xr == 2.0 || xr == 3.0, "x must be 2 or 3, got {}", r[x]);
2266 assert!((r[x] - xr).abs() < EPS, "x must be integral: {}", r[x]);
2267 }
2268
2269 #[test]
2270 fn model_nonconvex_miqp_errors() {
2271 let cases: &[(&str, &[f64], &[f64])] = &[
2273 ("single neg", &[-2.0], &[1.0]),
2274 ("neg-pos-2var", &[-3.0, 2.0], &[0.0, 1.0]),
2275 ];
2276 for &(name, q_diag, c_vec) in cases {
2277 let n = q_diag.len();
2278 let mut m = Model::new(name);
2279 let vars: Vec<_> = (0..n).map(|i| m.add_int_var(&format!("x{i}"), 0.0, 5.0)).collect();
2280 let obj = vars.iter().zip(q_diag).zip(c_vec).fold(
2282 crate::quad_expr::QuadExpr::from(0.0_f64),
2283 |acc, ((&v, &d), &c)| acc + (d / 2.0) * (v * v) + c * v,
2284 );
2285 m.minimize(obj);
2286 let err = m.solve().unwrap_err();
2287 assert!(
2288 matches!(err, ModelError::NonConvex(_)),
2289 "[{name}] expected ModelError::NonConvex, got {err:?}"
2290 );
2291 }
2292 }
2293
2294 #[test]
2295 fn model_mixed_integer_continuous() {
2296 let mut m = Model::new("mixed");
2299 let x = m.add_int_var("x", 0.0, 5.0);
2300 let y = m.add_var("y", 0.0, 5.0);
2301 m.add_constraint((x + y).leq(3.5));
2302 m.maximize(x + y);
2303 let r = m.solve().unwrap();
2304 assert!((r.objective() - 3.5).abs() < EPS, "obj={}", r.objective());
2305 assert!((r[x].round() - r[x]).abs() < EPS, "x must be integral, x={}", r[x]);
2306 }
2307
2308 #[test]
2312 fn test_linear_objective_constant_included() {
2313 let mut model = Model::new("lin_const");
2315 let x = model.add_var("x", 1.0, f64::INFINITY);
2316 model.minimize(2.0 * x + 3.0);
2317 let result = model.solve().unwrap();
2318 assert!((result[x] - 1.0).abs() < EPS, "x* should be 1, got {}", result[x]);
2319 assert!(
2320 (result.objective_value - 5.0).abs() < EPS,
2321 "obj* should be 5 (includes constant +3), got {}",
2322 result.objective_value
2323 );
2324 }
2325
2326 #[test]
2328 fn test_quad_objective_constant_included() {
2329 let mut model = Model::new("quad_const");
2332 let x = model.add_var("x", 0.0, f64::INFINITY);
2333 model.minimize(x * x + (-4.0) * x + 4.0);
2334 let result = model.solve().unwrap();
2335 assert!(
2336 (result[x] - 2.0).abs() < 1e-4,
2337 "quad_const: x* should be 2, got {}",
2338 result[x]
2339 );
2340 assert!(
2341 result.objective_value.abs() < 1e-4,
2342 "quad_const: obj* should be 0 (constant +4 included), got {}",
2343 result.objective_value
2344 );
2345 }
2346
2347 #[test]
2349 fn test_maximize_objective_constant_sign() {
2350 let mut model = Model::new("max_const");
2352 let x = model.add_var("x", 0.0, 10.0);
2353 model.maximize((-1.0) * x + 5.0);
2354 let result = model.solve().unwrap();
2355 assert!(
2356 (result[x]).abs() < EPS,
2357 "max_const: x* should be 0, got {}",
2358 result[x]
2359 );
2360 assert!(
2361 (result.objective_value - 5.0).abs() < EPS,
2362 "max_const: obj* should be 5 (constant not negated for max), got {}",
2363 result.objective_value
2364 );
2365 }
2366
2367 #[test]
2369 fn test_obj_offset_and_dsl_constant_no_double_count() {
2370 let mut model = Model::new("offset_plus_const");
2373 let x = model.add_var("x", 1.0, f64::INFINITY);
2374 model.minimize(1.0 * x + 3.0);
2375 model.set_obj_offset(10.0);
2376 let result = model.solve().unwrap();
2377 assert!(
2378 (result.objective_value - 14.0).abs() < EPS,
2379 "offset+const: obj* should be 14 (1+3+10), got {}",
2380 result.objective_value
2381 );
2382 }
2383
2384 #[test]
2386 fn test_reminimize_constant_not_double_counted() {
2387 let mut model = Model::new("reminimize");
2389 let x = model.add_var("x", 1.0, f64::INFINITY);
2390 model.minimize(1.0 * x + 3.0); model.minimize(1.0 * x + 7.0); let result = model.solve().unwrap();
2393 assert!(
2394 (result.objective_value - 8.0).abs() < EPS,
2395 "re-minimize: obj* should be 8 (1+7, not 1+3+7=11), got {}",
2396 result.objective_value
2397 );
2398 }
2399
2400 #[test]
2406 fn test_p2a_nan_dsl_quad_then_linear_is_optimal() {
2407 let mut model = Model::new("p2a_nan_then_linear");
2410 let x = model.add_var("x", 1.0, f64::INFINITY);
2411 model.minimize(f64::NAN * (x * x)); model.minimize(1.0 * x); let result = model.solve();
2414 assert!(result.is_ok(), "P2-a: should be Optimal after linear replaces NaN quad, got {result:?}");
2415 let r = result.unwrap();
2416 assert!((r[x] - 1.0).abs() < EPS, "P2-a: x* should be 1, got {}", r[x]);
2417 }
2418
2419 #[test]
2421 fn test_p2a_valid_quad_then_nan_errors() {
2422 let mut model = Model::new("p2a_valid_then_nan");
2423 let x = model.add_var("x", 0.0, f64::INFINITY);
2424 model.minimize(x * x + (-2.0) * x); model.minimize(f64::NAN * (x * x)); let result = model.solve();
2427 assert!(
2428 matches!(result, Err(ModelError::InvalidInput(_))),
2429 "P2-a: NaN quad must yield InvalidInput, got {result:?}"
2430 );
2431 }
2432
2433 #[test]
2435 fn test_p2a_nan_dsl_then_maximize_linear_is_optimal() {
2436 let mut model = Model::new("p2a_nan_then_max");
2437 let x = model.add_var("x", 0.0, 5.0);
2438 model.minimize(f64::NAN * (x * x)); model.maximize(1.0 * x); let result = model.solve();
2441 assert!(result.is_ok(), "P2-a: maximize should succeed after NaN DSL cleared, got {result:?}");
2442 let r = result.unwrap();
2443 assert!((r[x] - 5.0).abs() < EPS, "P2-a: maximize x → x*=5, got {}", r[x]);
2444 }
2445
2446 #[test]
2449 fn test_quad_state_invariants_transition_table() {
2450 fn mk() -> (Model, crate::variable::Variable) {
2452 let mut m = Model::new("t");
2453 let x = m.add_var("x", 0.0, f64::INFINITY);
2454 (m, x)
2455 }
2456
2457 {
2459 let (mut m, x) = mk();
2460 m.minimize(x * x);
2461 assert!(!m.has_quad_dsl_error(), "DSL ok: no error");
2462 assert!(m.is_quad_via_dsl(), "DSL ok: via_dsl=true");
2463 assert!(m.has_quadratic_objective(),"DSL ok: has_q=true");
2464 }
2465
2466 {
2468 let (mut m, x) = mk();
2469 m.minimize(f64::NAN * (x * x));
2470 assert!(m.has_quad_dsl_error(), "DSL fail: error recorded");
2471 assert!(!m.is_quad_via_dsl(), "DSL fail: via_dsl=false");
2472 assert!(!m.has_quadratic_objective(),"DSL fail: has_q=false");
2473 }
2474
2475 {
2477 let (mut m, x) = mk();
2478 m.minimize(x * x);
2479 m.minimize(f64::NAN * (x * x));
2480 assert!(m.has_quad_dsl_error(), "ok→fail: error recorded");
2481 assert!(!m.is_quad_via_dsl(), "ok→fail: via_dsl=false");
2482 assert!(!m.has_quadratic_objective(),"ok→fail: has_q=false");
2483 }
2484
2485 {
2487 let (mut m, x) = mk();
2488 m.minimize(x * x);
2489 m.minimize(1.0 * x);
2490 assert!(!m.has_quad_dsl_error(), "dsl→linear: no error");
2491 assert!(!m.is_quad_via_dsl(), "dsl→linear: via_dsl=false");
2492 assert!(!m.has_quadratic_objective(),"dsl→linear: DSL Q cleared");
2493 }
2494 }
2495
2496 #[test]
2498 fn test_quad_state_solve_outcome_table() {
2499 let cases: &[(&str, bool)] = &[
2500 ("nan_then_linear", true),
2503 ("nan_alone", false),
2505 ("ok_then_nan", false),
2507 ("nan_then_valid_quad", true),
2509 ("nan_nan_then_valid_quad", true),
2511 ];
2512
2513 for &(name, expect_ok) in cases {
2514 let mut m = Model::new(name);
2515 let x = m.add_var("x", 0.0, f64::INFINITY);
2516
2517 match name {
2518 "nan_then_linear" => {
2519 m.minimize(f64::NAN * (x * x) + x);
2520 m.minimize(1.0 * x);
2521 }
2522 "nan_alone" => {
2523 m.minimize(f64::NAN * (x * x) + x);
2524 }
2525 "ok_then_nan" => {
2526 m.minimize(x * x + (-4.0) * x);
2527 m.minimize(f64::NAN * (x * x) + x);
2528 }
2529 "nan_then_valid_quad" => {
2530 m.minimize(f64::NAN * (x * x));
2531 m.minimize(x * x + (-4.0) * x);
2532 }
2533 "nan_nan_then_valid_quad" => {
2534 m.minimize(f64::NAN * (x * x));
2535 m.minimize(f64::NAN * (x * x));
2536 m.minimize(x * x + (-4.0) * x);
2537 }
2538 _ => unreachable!(),
2539 }
2540
2541 let result = m.solve();
2542 if expect_ok {
2543 assert!(
2544 result.is_ok(),
2545 "case '{name}': expected Optimal, got {result:?}"
2546 );
2547 } else {
2548 assert!(
2549 matches!(result, Err(ModelError::InvalidInput(_))),
2550 "case '{name}': expected InvalidInput, got {result:?}"
2551 );
2552 }
2553 }
2554 }
2555
2556 #[test]
2562 fn test_p2f_mixed_quad_local_linear_foreign_rejected() {
2563 let mut m1 = Model::new("m1");
2564 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2565
2566 let mut m2 = Model::new("m2");
2567 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2568
2569 m1.minimize(x1 * x1 + y2);
2571 let result = m1.solve();
2572 assert!(
2573 matches!(result, Err(ModelError::InvalidInput(_))),
2574 "P2-f mixed: foreign linear term must give InvalidInput, got {result:?}"
2575 );
2576 }
2577
2578 #[test]
2580 fn test_p2f_pure_linear_foreign_rejected() {
2581 let mut m1 = Model::new("m1");
2582 let _x1 = m1.add_var("x", 0.0, f64::INFINITY);
2583
2584 let mut m2 = Model::new("m2");
2585 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2586
2587 m1.minimize(1.0 * y2);
2589 let result = m1.solve();
2590 assert!(
2591 matches!(result, Err(ModelError::InvalidInput(_))),
2592 "P2-f pure-linear: foreign variable must give InvalidInput (not silent drop), got {result:?}"
2593 );
2594 }
2595
2596 #[test]
2598 fn test_p2f_mixed_all_local_accepted() {
2599 let mut m = Model::new("m");
2600 let x = m.add_var("x", 0.0, f64::INFINITY);
2601 m.minimize(x * x + (-4.0) * x);
2603 let result = m.solve().unwrap();
2604 assert!(
2605 (result[x] - 2.0).abs() < EPS,
2606 "P2-f no false positive: x*=2, got {}",
2607 result[x]
2608 );
2609 }
2610
2611 #[test]
2613 fn test_p2f_cross_term_plus_linear_foreign() {
2614 let mut m1 = Model::new("m1");
2615 let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2616
2617 let mut m2 = Model::new("m2");
2618 let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2619
2620 m1.minimize(x1 * y2 + 2.0 * y2);
2623 let result = m1.solve();
2624 assert!(
2625 matches!(result, Err(ModelError::InvalidInput(_))),
2626 "P2-f cross+linear: must give InvalidInput, got {result:?}"
2627 );
2628 }
2629
2630 #[test]
2632 fn test_p2f_foreign_linear_maximize_rejected() {
2633 let mut m1 = Model::new("m1");
2634 let _x1 = m1.add_var("x", 0.0, 5.0);
2635
2636 let mut m2 = Model::new("m2");
2637 let y2 = m2.add_var("y", 0.0, 5.0);
2638
2639 m1.maximize(1.0 * y2);
2640 let result = m1.solve();
2641 assert!(
2642 matches!(result, Err(ModelError::InvalidInput(_))),
2643 "P2-f maximize foreign linear: must give InvalidInput, got {result:?}"
2644 );
2645 }
2646
2647 #[test]
2653 fn test_p2h_nan_linear_coef_rejected_at_solve() {
2654 let mut m = Model::new("p2h_nan_coef");
2655 let x = m.add_var("x", 0.0, f64::INFINITY);
2656 m.minimize(f64::NAN * x);
2657 let err = m.solve().unwrap_err();
2658 assert!(
2659 matches!(err, ModelError::InvalidInput(_)),
2660 "P2-h: NaN linear coefficient must give InvalidInput, got {err:?}"
2661 );
2662 }
2663
2664 #[test]
2666 fn test_p2h_inf_linear_coef_rejected_at_solve() {
2667 let mut m = Model::new("p2h_inf_coef");
2668 let x = m.add_var("x", 0.0, f64::INFINITY);
2669 m.minimize(f64::INFINITY * x);
2670 let err = m.solve().unwrap_err();
2671 assert!(
2672 matches!(err, ModelError::InvalidInput(_)),
2673 "P2-h: Inf linear coefficient must give InvalidInput, got {err:?}"
2674 );
2675 }
2676
2677 #[test]
2679 fn test_p2h_nan_constant_rejected_at_solve() {
2680 let mut m = Model::new("p2h_nan_const");
2681 let x = m.add_var("x", 0.0, f64::INFINITY);
2682 m.minimize(1.0 * x + f64::NAN);
2683 let err = m.solve().unwrap_err();
2684 assert!(
2685 matches!(err, ModelError::InvalidInput(_)),
2686 "P2-h: NaN constant term must give InvalidInput, got {err:?}"
2687 );
2688 }
2689
2690 #[test]
2692 fn test_p2h_inf_constant_rejected_at_solve() {
2693 let mut m = Model::new("p2h_inf_const");
2694 let x = m.add_var("x", 0.0, f64::INFINITY);
2695 m.minimize(1.0 * x + f64::INFINITY);
2696 let err = m.solve().unwrap_err();
2697 assert!(
2698 matches!(err, ModelError::InvalidInput(_)),
2699 "P2-h: Inf constant term must give InvalidInput, got {err:?}"
2700 );
2701 }
2702
2703 #[test]
2707 fn test_p2h_inf_q_coef_via_diagonal_overflow_rejected_at_solve() {
2708 let mut m = Model::new("p2h_inf_q_diagonal");
2709 let x = m.add_var("x", 0.0, f64::INFINITY);
2710 m.minimize(f64::MAX * (x * x));
2714 let err = m.solve().unwrap_err();
2715 assert!(
2716 matches!(err, ModelError::InvalidInput(_)),
2717 "P2-h: Inf Q diagonal (2*MAX overflow) must give InvalidInput, got {err:?}"
2718 );
2719 }
2720
2721 #[test]
2724 fn test_p2h_nan_q_coef_dsl_rejected_at_solve() {
2725 let mut m = Model::new("p2h_nan_q_dsl");
2726 let x = m.add_var("x", 0.0, f64::INFINITY);
2727 m.minimize(f64::NAN * (x * x));
2730 let err = m.solve().unwrap_err();
2731 assert!(
2732 matches!(err, ModelError::InvalidInput(_)),
2733 "P2-h: NaN DSL Q coefficient must give InvalidInput, got {err:?}"
2734 );
2735 }
2736
2737 #[test]
2743 fn test_p2i_nan_quad_replaced_by_valid_quad_is_optimal() {
2744 let mut model = Model::new("p2i_nan_then_valid");
2745 let x = model.add_var("x", 0.0, f64::INFINITY);
2746 model.minimize(f64::NAN * (x * x)); assert!(model.has_quad_dsl_error(), "P2-i setup: error must be recorded");
2748
2749 model.minimize(x * x + (-4.0) * x); assert!(!model.has_quad_dsl_error(), "P2-i: valid minimize must clear quad_dsl_error");
2751 assert!(model.has_quadratic_objective(), "P2-i: valid Q must be installed");
2752
2753 let result = model.solve();
2755 assert!(result.is_ok(), "P2-i: valid quad after NaN must be Optimal, got {result:?}");
2756 let r = result.unwrap();
2757 assert!((r[x] - 2.0).abs() < EPS, "P2-i: x*=2, got {}", r[x]);
2758 assert!((r.objective_value - (-4.0)).abs() < EPS, "P2-i: obj*=-4, got {}", r.objective_value);
2759 }
2760
2761 #[test]
2763 fn test_p2i_nan_quad_alone_is_invalid() {
2764 let mut model = Model::new("p2i_nan_alone");
2765 let x = model.add_var("x", 0.0, f64::INFINITY);
2766 model.minimize(f64::NAN * (x * x));
2767 let result = model.solve();
2768 assert!(
2769 matches!(result, Err(ModelError::InvalidInput(_))),
2770 "P2-i: NaN quad alone must give InvalidInput, got {result:?}"
2771 );
2772 }
2773
2774 #[test]
2776 fn test_p2i_setter_ordering_table() {
2777 type Setup = fn(&mut Model, crate::variable::Variable);
2779 let cases: &[(&str, Setup, bool)] = &[
2780 ("nan→valid_quad", |m, x| {
2782 m.minimize(f64::NAN * (x * x));
2783 m.minimize(x * x + (-4.0) * x);
2784 }, true),
2785 ("nan→nan→valid_quad", |m, x| {
2787 m.minimize(f64::NAN * (x * x));
2788 m.minimize(f64::NAN * (x * x));
2789 m.minimize(x * x + (-4.0) * x);
2790 }, true),
2791 ("valid→nan", |m, x| {
2793 m.minimize(x * x + (-4.0) * x);
2794 m.minimize(f64::NAN * (x * x));
2795 }, false),
2796 ("nan→linear", |m, x| {
2798 m.minimize(f64::NAN * (x * x));
2799 m.minimize(1.0 * x); }, true),
2801 ("nan→valid→nan", |m, x| {
2803 m.minimize(f64::NAN * (x * x));
2804 m.minimize(x * x + (-4.0) * x);
2805 m.minimize(f64::NAN * (x * x));
2806 }, false),
2807 ];
2808
2809 for &(label, setup, expect_optimal) in cases {
2810 let mut m = Model::new(label);
2811 let x = m.add_var("x", 0.0, f64::INFINITY);
2812 setup(&mut m, x);
2813 let result = m.solve();
2814 if expect_optimal {
2815 assert!(result.is_ok(), "P2-i [{label}]: expected Optimal, got {result:?}");
2816 } else {
2817 assert!(
2818 matches!(result, Err(ModelError::InvalidInput(_))),
2819 "P2-i [{label}]: expected InvalidInput, got {result:?}"
2820 );
2821 }
2822 }
2823 }
2824
2825}