1use std::borrow::Cow;
2use std::time::Duration;
3
4use oximo_core::{
5 ConstraintHandle, ConstraintId, ConstraintRef, Expr, IndexKey, IndexedVar, Model, ModelId,
6 ModelMismatchError, SocConstraintHandle, SocConstraintId, VarId,
7};
8use oximo_expr::{EvalContext, ExprArena, ExprId, ExprNode, ParamId, evaluate};
9use rustc_hash::FxHashMap;
10
11use crate::status::{PrimalStatus, TerminationStatus};
12
13#[derive(Clone, Debug)]
19pub struct SolutionPoint {
20 pub model_id: ModelId,
21 pub primal: FxHashMap<VarId, f64>,
22 pub objective: Option<f64>,
23}
24
25impl Default for SolutionPoint {
26 fn default() -> Self {
27 Self { model_id: ModelId::UNASSIGNED, primal: FxHashMap::default(), objective: None }
28 }
29}
30
31struct PointContext<'a>(&'a FxHashMap<VarId, f64>);
32
33impl EvalContext for PointContext<'_> {
34 fn var(&self, id: VarId) -> Option<f64> {
35 self.0.get(&id).copied()
36 }
37
38 fn param(&self, _id: ParamId) -> Option<f64> {
39 None
40 }
41}
42
43impl SolutionPoint {
44 #[must_use]
46 pub const fn model_id(&self) -> ModelId {
47 self.model_id
48 }
49
50 pub fn value(&self, id: VarId) -> Option<f64> {
53 self.primal.get(&id).copied()
54 }
55
56 #[inline]
67 pub fn value_of(&self, expr: Expr<'_>) -> Result<Option<f64>, ModelMismatchError> {
68 ensure_model_id(self.model_id, expr.model_id())?;
69 let arena = expr.arena.borrow();
70 if let ExprNode::Var(id) = arena.get(expr.id) {
71 return Ok(self.value(*id));
72 }
73 Ok(evaluate(&arena, expr.id, &PointContext(&self.primal)).ok())
74 }
75
76 pub fn value_of_idx<V, K: Into<IndexKey>>(
86 &self,
87 var: &IndexedVar<'_, V>,
88 key: K,
89 ) -> Result<Option<f64>, ModelMismatchError> {
90 ensure_model_id(self.model_id, var.model_id())?;
91 var.get(key).map_or(Ok(None), |e| self.value_of(e))
92 }
93
94 pub fn values_of<'iv, 'a, V>(
103 &'iv self,
104 var: &'iv IndexedVar<'a, V>,
105 ) -> Result<impl Iterator<Item = (&'iv IndexKey, f64)> + 'iv, ModelMismatchError> {
106 ensure_model_id(self.model_id, var.model_id())?;
107 Ok(var.iter().filter_map(|(k, e)| e.var_id().and_then(|id| self.value(id)).map(|v| (k, v))))
108 }
109}
110
111#[inline]
112fn ensure_model_id(expected: ModelId, actual: ModelId) -> Result<(), ModelMismatchError> {
113 if actual == expected { Ok(()) } else { model_mismatch(expected, actual) }
114}
115
116#[cold]
117#[inline(never)]
118fn model_mismatch(expected: ModelId, actual: ModelId) -> Result<(), ModelMismatchError> {
119 Err(ModelMismatchError::new(expected, actual))
120}
121
122#[non_exhaustive]
124#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
125pub enum DualStatus {
126 #[default]
128 NoSolution,
129 FeasiblePoint,
131 Unknown,
133}
134
135#[derive(Copy, Clone, Debug, PartialEq)]
137pub struct ConstraintEvaluation {
138 pub activity: f64,
139 pub lower_slack: Option<f64>,
140 pub upper_slack: Option<f64>,
141 pub violation: f64,
142}
143
144#[derive(Copy, Clone, Debug, PartialEq)]
146pub struct SocEvaluation {
147 pub norm: f64,
148 pub bound: f64,
149 pub slack: f64,
150 pub violation: f64,
151}
152
153fn evaluate_at(arena: &ExprArena, id: ExprId, point: &SolutionPoint) -> Option<f64> {
154 evaluate(arena, id, &PointContext(&point.primal)).ok()
155}
156
157fn evaluate_constraint_at(
158 point: &SolutionPoint,
159 model: &Model,
160 id: ConstraintId,
161) -> Option<ConstraintEvaluation> {
162 let arena = model.arena();
163 let constraints = model.constraints();
164 let constraint = constraints.algebraic().get(id.index())?;
165 let activity = evaluate_at(&arena, constraint.lhs, point)?;
166 let lower_slack = constraint.lower.is_finite().then_some(activity - constraint.lower);
167 let upper_slack = constraint.upper.is_finite().then_some(constraint.upper - activity);
168 let violation = lower_slack
169 .into_iter()
170 .chain(upper_slack)
171 .map(|slack| (-slack).max(0.0))
172 .fold(0.0, f64::max);
173 Some(ConstraintEvaluation { activity, lower_slack, upper_slack, violation })
174}
175
176fn evaluate_soc_at(
177 point: &SolutionPoint,
178 model: &Model,
179 id: SocConstraintId,
180) -> Option<SocEvaluation> {
181 let arena = model.arena();
182 let socs = model.soc_constraints();
183 let constraint = socs.get(id.index())?;
184 let squared_norm = constraint.terms.iter().try_fold(0.0, |sum, &term| {
185 evaluate_at(&arena, term, point).map(|value| sum + value * value)
186 })?;
187 let norm = squared_norm.sqrt();
188 let bound = evaluate_at(&arena, constraint.bound, point)?;
189 let slack = bound - norm;
190 Some(SocEvaluation { norm, bound, slack, violation: (-slack).max(0.0) })
191}
192
193#[derive(Clone, Debug)]
203pub struct SolverResult {
204 pub model_id: ModelId,
205 pub termination: TerminationStatus,
206 pub primal_status: PrimalStatus,
207 pub dual_status: DualStatus,
208 pub solutions: Vec<SolutionPoint>,
209 pub dual: FxHashMap<ConstraintId, f64>,
210 pub soc_dual: FxHashMap<SocConstraintId, f64>,
211 pub reduced_costs: FxHashMap<VarId, f64>,
212 pub best_bound: Option<f64>,
214 pub gap: Option<f64>,
216 pub solve_time: Duration,
217 pub iterations: u64,
219 pub node_count: Option<u64>,
220 pub raw_status: Option<Cow<'static, str>>,
222 pub raw_log: Option<String>,
223 pub solver_name: Option<Cow<'static, str>>,
224 pub solver_version: Option<Cow<'static, str>>,
225}
226
227impl Default for SolverResult {
228 fn default() -> Self {
229 Self {
230 model_id: ModelId::UNASSIGNED,
231 termination: TerminationStatus::NotSolved,
232 primal_status: PrimalStatus::NoSolution,
233 dual_status: DualStatus::NoSolution,
234 solutions: Vec::new(),
235 dual: FxHashMap::default(),
236 soc_dual: FxHashMap::default(),
237 reduced_costs: FxHashMap::default(),
238 best_bound: None,
239 gap: None,
240 solve_time: Duration::ZERO,
241 iterations: 0,
242 node_count: None,
243 raw_status: None,
244 raw_log: None,
245 solver_name: None,
246 solver_version: None,
247 }
248 }
249}
250
251impl SolverResult {
252 #[must_use]
254 pub const fn model_id(&self) -> ModelId {
255 self.model_id
256 }
257
258 pub fn result_count(&self) -> usize {
261 self.solutions.len()
262 }
263
264 pub fn solution(&self, i: usize) -> Option<&SolutionPoint> {
266 self.solutions.get(i)
267 }
268
269 pub fn best(&self) -> Option<&SolutionPoint> {
271 self.solutions.first()
272 }
273
274 pub fn has_solution(&self) -> bool {
278 self.primal_status.has_solution()
279 }
280
281 pub fn objective(&self) -> Option<f64> {
283 self.solutions.first().and_then(|s| s.objective)
284 }
285
286 pub fn primal(&self) -> Option<&FxHashMap<VarId, f64>> {
288 self.solutions.first().map(|s| &s.primal)
289 }
290
291 pub fn value(&self, id: VarId) -> Option<f64> {
294 self.solutions.first().and_then(|s| s.value(id))
295 }
296
297 #[inline]
304 pub fn value_of(&self, expr: Expr<'_>) -> Result<Option<f64>, ModelMismatchError> {
305 ensure_model_id(self.model_id, expr.model_id())?;
306 self.solutions.first().map_or(Ok(None), |s| s.value_of(expr))
307 }
308
309 pub fn constraint_evaluation(
320 &self,
321 model: &Model,
322 constraint: ConstraintHandle,
323 ) -> Result<Option<ConstraintEvaluation>, ModelMismatchError> {
324 self.constraint_evaluation_at(model, constraint, 0)
325 }
326
327 pub fn constraint_evaluation_at(
334 &self,
335 model: &Model,
336 constraint: ConstraintHandle,
337 solution_index: usize,
338 ) -> Result<Option<ConstraintEvaluation>, ModelMismatchError> {
339 ensure_model_id(self.model_id, model.id())?;
340 ensure_model_id(self.model_id, constraint.model_id())?;
341 let Some(point) = self.solution(solution_index) else { return Ok(None) };
342 ensure_model_id(self.model_id, point.model_id)?;
343 Ok(evaluate_constraint_at(point, model, constraint.id()))
344 }
345
346 pub fn soc_evaluation(
353 &self,
354 model: &Model,
355 constraint: SocConstraintHandle,
356 ) -> Result<Option<SocEvaluation>, ModelMismatchError> {
357 self.soc_evaluation_at(model, constraint, 0)
358 }
359
360 pub fn soc_evaluation_at(
368 &self,
369 model: &Model,
370 constraint: SocConstraintHandle,
371 solution_index: usize,
372 ) -> Result<Option<SocEvaluation>, ModelMismatchError> {
373 ensure_model_id(self.model_id, model.id())?;
374 ensure_model_id(self.model_id, constraint.model_id())?;
375 let Some(point) = self.solution(solution_index) else { return Ok(None) };
376 ensure_model_id(self.model_id, point.model_id)?;
377 Ok(evaluate_soc_at(point, model, constraint.id()))
378 }
379
380 pub fn dual_of(&self, constraint: ConstraintHandle) -> Result<Option<f64>, ModelMismatchError> {
387 ensure_model_id(self.model_id, constraint.model_id())?;
388 Ok(self.dual.get(&constraint.id()).copied())
389 }
390
391 pub fn soc_dual_of(
398 &self,
399 constraint: SocConstraintHandle,
400 ) -> Result<Option<f64>, ModelMismatchError> {
401 ensure_model_id(self.model_id, constraint.model_id())?;
402 Ok(self.soc_dual.get(&constraint.id()).copied())
403 }
404
405 pub fn value_of_idx<V, K: Into<IndexKey>>(
412 &self,
413 var: &IndexedVar<'_, V>,
414 key: K,
415 ) -> Result<Option<f64>, ModelMismatchError> {
416 ensure_model_id(self.model_id, var.model_id())?;
417 var.get(key).map_or(Ok(None), |e| self.value_of(e))
418 }
419
420 pub fn values_of<'iv, 'a, V>(
428 &'iv self,
429 var: &'iv IndexedVar<'a, V>,
430 ) -> Result<impl Iterator<Item = (&'iv IndexKey, f64)> + 'iv, ModelMismatchError> {
431 ensure_model_id(self.model_id, var.model_id())?;
432 if let Some(point) = self.best() {
433 ensure_model_id(self.model_id, point.model_id)?;
434 }
435 let point = self.best();
436 Ok(var.iter().filter_map(move |(k, e)| {
437 e.var_id().and_then(|id| point.and_then(|solution| solution.value(id))).map(|v| (k, v))
438 }))
439 }
440
441 pub fn report<'a>(&'a self, model: &'a Model) -> Result<ModelReport<'a>, ModelMismatchError> {
453 ensure_model_id(self.model_id, model.id())?;
454 if let Some(point) = self.best() {
455 ensure_model_id(self.model_id, point.model_id)?;
456 }
457 Ok(ModelReport { result: self, model })
458 }
459}
460
461#[derive(Debug)]
464pub struct ModelReport<'a> {
465 result: &'a SolverResult,
466 model: &'a Model,
467}
468
469fn num(x: f64) -> String {
472 let s = format!("{x:.6}");
473 let trimmed = s.trim_end_matches('0').trim_end_matches('.');
474 if trimmed.is_empty() || trimmed == "-0" { "0".to_owned() } else { trimmed.to_owned() }
475}
476
477impl std::fmt::Display for ModelReport<'_> {
478 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479 let r = self.result;
480 let m = self.model;
481
482 writeln!(f, "solution summary")?;
483 let solver = match (r.solver_name.as_deref(), r.solver_version.as_deref()) {
484 (Some(name), Some(version)) => format!("{name} {version}"),
485 (Some(name), None) => name.to_owned(),
486 (None, _) => "(unknown)".to_owned(),
487 };
488 writeln!(f, " solver : {solver}")?;
489 let objective = m.objective();
490 if let Some(objective) = objective.as_ref() {
491 writeln!(f, " model : {} ({}, {})", m.name, m.kind(), objective.sense)?;
492 } else {
493 writeln!(f, " model : {} ({}, no objective)", m.name, m.kind())?;
494 }
495 writeln!(f, " termination: {:?}", r.termination)?;
496 writeln!(f, " primal : {:?}", r.primal_status)?;
497 writeln!(f, " dual : {:?}", r.dual_status)?;
498 if let Some(raw) = r.raw_status.as_deref() {
499 writeln!(f, " raw status : {raw}")?;
500 }
501 writeln!(f, " solutions : {}", r.result_count())?;
502 match r.objective() {
503 Some(v) => writeln!(f, " objective : {}", num(v))?,
504 None => writeln!(f, " objective : (none)")?,
505 }
506 if let Some(b) = r.best_bound {
507 writeln!(f, " best bound : {}", num(b))?;
508 }
509 if let Some(g) = r.gap {
510 writeln!(f, " gap : {}", num(g))?;
511 }
512 writeln!(f, " solve time : {:?}", r.solve_time)?;
513 writeln!(f, " iterations : {}", r.iterations)?;
514 if let Some(nodes) = r.node_count {
515 writeln!(f, " nodes : {nodes}")?;
516 }
517
518 let vars = m.variables();
520 writeln!(f, "\nvariables ({})", vars.len())?;
521 if let Some(best) = r.best() {
522 let width = vars.iter().map(|v| v.name.len()).max().unwrap_or(0);
523 let show_rc = !r.reduced_costs.is_empty();
524 for v in vars.iter() {
525 let val = best.value(v.id).map_or_else(|| "n/a".to_owned(), num);
526 match (show_rc, r.reduced_costs.get(&v.id)) {
527 (true, Some(rc)) => {
528 writeln!(f, " {:<width$} = {val} (reduced cost {})", v.name, num(*rc))?;
529 }
530 _ => writeln!(f, " {:<width$} = {val}", v.name)?,
531 }
532 }
533 } else {
534 writeln!(f, " (no primal solution)")?;
535 }
536
537 if !r.dual.is_empty() {
539 let model_constraints = m.constraints();
540 let cons: Vec<_> = model_constraints
541 .iter()
542 .filter_map(|constraint| match constraint {
543 ConstraintRef::Algebraic { id, constraint } => Some((id, constraint)),
544 ConstraintRef::SecondOrderCone { .. }
545 | ConstraintRef::SpecialOrderedSet { .. }
546 | ConstraintRef::Indicator { .. } => None,
547 })
548 .collect();
549 writeln!(f, "\nconstraints ({})", cons.len())?;
550 let width = cons.iter().map(|(_, c)| c.name.len()).max().unwrap_or(0);
551 for (id, c) in cons {
552 let d = r.dual.get(&id).copied().map_or_else(|| "n/a".to_owned(), num);
553 writeln!(f, " {:<width$} dual = {d}", c.name)?;
554 }
555 }
556
557 if !r.soc_dual.is_empty() {
559 let socs = m.soc_constraints();
560 writeln!(f, "\nsoc constraints ({})", socs.len())?;
561 let width = socs.iter().map(|s| s.name.len()).max().unwrap_or(0);
562 for (i, s) in socs.iter().enumerate() {
563 let id = SocConstraintId(u32::try_from(i).expect("soc index fits u32"));
564 let d = r.soc_dual.get(&id).copied().map_or_else(|| "n/a".to_owned(), num);
565 writeln!(f, " {:<width$} dual = {d}", s.name)?;
566 }
567 }
568
569 Ok(())
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn empty_result_has_no_solution() {
579 let r = SolverResult::default();
580 assert_eq!(r.result_count(), 0);
581 assert!(r.best().is_none());
582 assert!(r.objective().is_none());
583 assert!(r.primal().is_none());
584 assert!(r.value(VarId(0)).is_none());
585 assert!(r.solution(0).is_none());
586 assert_eq!(r.dual_status, DualStatus::NoSolution);
587 assert!(r.raw_status.is_none());
588 assert!(r.solver_version.is_none());
589 assert!(r.node_count.is_none());
590 }
591
592 #[test]
593 fn value_of_evaluates_linear_quadratic_nonlinear_and_parameterized_expressions() {
594 use oximo_core::{param, variable};
595
596 let m = Model::new("expressions");
597 param!(m, p = 2.0);
598 variable!(m, x);
599 variable!(m, y);
600 let mut primal = FxHashMap::default();
601 primal.insert(x.var_id().unwrap(), 3.0);
602 primal.insert(y.var_id().unwrap(), 4.0);
603 let point = SolutionPoint { model_id: m.id(), primal, objective: None };
604
605 assert_eq!(point.value_of(x), Ok(Some(3.0)));
606 assert_eq!(point.value_of(2.0 * x + y - 1.0), Ok(Some(9.0)));
607 assert_eq!(point.value_of(x.powi(2) + x * y), Ok(Some(21.0)));
608 assert_eq!(point.value_of(x.sin()), Ok(Some(3.0_f64.sin())));
609 assert_eq!(point.value_of(p * x + y), Ok(Some(10.0)));
610
611 let incomplete = SolutionPoint { model_id: m.id(), ..Default::default() };
612 assert!(incomplete.value_of(x + y).unwrap().is_none());
613 }
614
615 #[test]
616 fn expression_queries_reject_foreign_models_with_colliding_variable_ids() {
617 use oximo_core::variable;
618
619 let source = Model::new("source");
620 variable!(source, x);
621 let foreign = Model::new("foreign");
622 variable!(foreign, y);
623 assert_eq!(x.var_id(), y.var_id());
624
625 let point = SolutionPoint {
626 model_id: source.id(),
627 primal: [(x.var_id().unwrap(), 4.0)].into_iter().collect(),
628 objective: None,
629 };
630 let result = SolverResult {
631 model_id: source.id(),
632 primal_status: PrimalStatus::FeasiblePoint,
633 solutions: vec![point.clone()],
634 ..Default::default()
635 };
636 let mismatch = ModelMismatchError::new(source.id(), foreign.id());
637
638 assert_eq!(point.value_of(y), Err(mismatch));
639 assert_eq!(result.value_of(y), Err(mismatch));
640 assert!(matches!(result.report(&foreign), Err(error) if error == mismatch));
641 assert_eq!(point.value_of(x), Ok(Some(4.0)));
642 }
643
644 #[test]
645 fn constraint_queries_reject_foreign_handles_with_colliding_ids() {
646 use oximo_core::{constraint, soc_constraint, variable};
647
648 let source = Model::new("source");
649 variable!(source, x);
650 variable!(source, t);
651 let source_row = constraint!(source, row, x <= 1.0);
652 let source_cone = soc_constraint!(source, cone, [x] <= t);
653
654 let foreign = Model::new("foreign");
655 variable!(foreign, y);
656 variable!(foreign, u);
657 let foreign_row = constraint!(foreign, row, y <= 2.0);
658 let foreign_cone = soc_constraint!(foreign, cone, [y] <= u);
659 assert_eq!(source_row.id(), foreign_row.id());
660 assert_eq!(source_cone.id(), foreign_cone.id());
661
662 let result = SolverResult {
663 model_id: source.id(),
664 dual: [(source_row.id(), 3.0)].into_iter().collect(),
665 soc_dual: [(source_cone.id(), 4.0)].into_iter().collect(),
666 ..Default::default()
667 };
668 let mismatch = ModelMismatchError::new(source.id(), foreign.id());
669
670 assert_eq!(result.dual_of(foreign_row), Err(mismatch));
671 assert_eq!(result.soc_dual_of(foreign_cone), Err(mismatch));
672 assert_eq!(result.constraint_evaluation(&source, foreign_row), Err(mismatch));
673 assert_eq!(result.soc_evaluation(&source, foreign_cone), Err(mismatch));
674 assert_eq!(result.dual_of(source_row), Ok(Some(3.0)));
675 assert_eq!(result.soc_dual_of(source_cone), Ok(Some(4.0)));
676 }
677
678 #[test]
679 fn algebraic_constraint_evaluations_cover_all_bound_shapes_and_solution_indices() {
680 use oximo_core::{constraint, variable};
681
682 let m = Model::new("constraint evaluation");
683 variable!(m, x);
684 let equality = constraint!(m, equality, x == 2.0);
685 let lower = constraint!(m, lower, x >= 1.0);
686 let upper = constraint!(m, upper, x <= 3.0);
687 constraint!(m, ranged, 1.5 <= x <= 2.5);
688 let ranged = m.constraint_handle("ranged").unwrap();
689
690 let point = |value| {
691 let mut primal = FxHashMap::default();
692 primal.insert(x.var_id().unwrap(), value);
693 SolutionPoint { model_id: m.id(), primal, objective: None }
694 };
695 let result = SolverResult {
696 model_id: m.id(),
697 primal_status: PrimalStatus::FeasiblePoint,
698 solutions: vec![point(2.0), point(4.0)],
699 ..Default::default()
700 };
701
702 assert_eq!(
703 result.constraint_evaluation(&m, equality),
704 Ok(Some(ConstraintEvaluation {
705 activity: 2.0,
706 lower_slack: Some(0.0),
707 upper_slack: Some(0.0),
708 violation: 0.0,
709 }))
710 );
711 assert_eq!(
712 result.constraint_evaluation(&m, lower).unwrap().unwrap().lower_slack,
713 Some(1.0)
714 );
715 assert_eq!(result.constraint_evaluation(&m, lower).unwrap().unwrap().upper_slack, None);
716 assert_eq!(result.constraint_evaluation(&m, upper).unwrap().unwrap().lower_slack, None);
717 assert_eq!(
718 result.constraint_evaluation(&m, upper).unwrap().unwrap().upper_slack,
719 Some(1.0)
720 );
721 assert!(
722 result.constraint_evaluation(&m, ranged).unwrap().unwrap().violation.abs()
723 < f64::EPSILON
724 );
725 assert!(
726 (result.constraint_evaluation_at(&m, ranged, 1).unwrap().unwrap().violation - 1.5)
727 .abs()
728 < f64::EPSILON
729 );
730 assert!(result.constraint_evaluation_at(&m, ranged, 2).unwrap().is_none());
731 assert!(m.constraint_handle_from_id(ConstraintId(u32::MAX)).is_none());
732 }
733
734 #[test]
735 fn soc_evaluation_reports_norm_slack_and_violation() {
736 use oximo_core::{soc_constraint, variable};
737
738 let m = Model::new("soc evaluation");
739 variable!(m, x);
740 variable!(m, y);
741 variable!(m, t);
742 let cone = soc_constraint!(m, cone, [x, y] <= t);
743 let point = |x_value, y_value, t_value| {
744 let mut primal = FxHashMap::default();
745 primal.insert(x.var_id().unwrap(), x_value);
746 primal.insert(y.var_id().unwrap(), y_value);
747 primal.insert(t.var_id().unwrap(), t_value);
748 SolutionPoint { model_id: m.id(), primal, objective: None }
749 };
750 let result = SolverResult {
751 model_id: m.id(),
752 primal_status: PrimalStatus::FeasiblePoint,
753 solutions: vec![point(3.0, 4.0, 6.0), point(3.0, 4.0, 4.0)],
754 ..Default::default()
755 };
756
757 assert_eq!(
758 result.soc_evaluation(&m, cone),
759 Ok(Some(SocEvaluation { norm: 5.0, bound: 6.0, slack: 1.0, violation: 0.0 }))
760 );
761 assert!(
762 (result.soc_evaluation_at(&m, cone, 1).unwrap().unwrap().violation - 1.0).abs()
763 < f64::EPSILON
764 );
765 assert!(result.soc_evaluation_at(&m, cone, 2).unwrap().is_none());
766 }
767
768 #[test]
769 fn best_is_solution_zero() {
770 let mut p0 = FxHashMap::default();
771 p0.insert(VarId(0), 1.5);
772 let mut p1 = FxHashMap::default();
773 p1.insert(VarId(0), 2.5);
774 let r = SolverResult {
775 termination: TerminationStatus::Optimal,
776 primal_status: PrimalStatus::OptimalPoint,
777 solutions: vec![
778 SolutionPoint { primal: p0, objective: Some(10.0), ..Default::default() },
779 SolutionPoint { primal: p1, objective: Some(9.0), ..Default::default() },
780 ],
781 ..Default::default()
782 };
783 assert_eq!(r.result_count(), 2);
784 assert_eq!(r.objective(), Some(10.0));
785 assert_eq!(r.value(VarId(0)), Some(1.5));
786 assert_eq!(r.solution(1).unwrap().value(VarId(0)), Some(2.5));
787 }
788
789 #[test]
790 fn report_renders_sections() {
791 use oximo_core::{constraint, objective, variable};
792
793 let m = Model::new("toy");
794 variable!(m, x >= 0.0);
795 let c = constraint!(m, c, x <= 5.0);
796 objective!(m, Max, x);
797
798 let mut primal = FxHashMap::default();
799 primal.insert(x.var_id().unwrap(), 5.0);
800 let mut dual = FxHashMap::default();
801 dual.insert(c.id(), 1.0);
802
803 let r = SolverResult {
804 model_id: m.id(),
805 termination: TerminationStatus::Optimal,
806 primal_status: PrimalStatus::OptimalPoint,
807 solutions: vec![SolutionPoint { model_id: m.id(), primal, objective: Some(5.0) }],
808 dual,
809 solver_name: Some("TestSolver".into()),
810 solver_version: Some("1.2.3".into()),
811 raw_status: Some("native optimal".into()),
812 dual_status: DualStatus::FeasiblePoint,
813 node_count: Some(7),
814 ..Default::default()
815 };
816
817 let out = r.report(&m).unwrap().to_string();
818 assert!(out.contains("solver : TestSolver 1.2.3"), "{out}");
819 assert!(out.contains("termination: Optimal"), "{out}");
820 assert!(out.contains("primal : OptimalPoint"), "{out}");
821 assert!(out.contains("dual : FeasiblePoint"), "{out}");
822 assert!(out.contains("raw status : native optimal"), "{out}");
823 assert!(out.contains("nodes : 7"), "{out}");
824 assert!(out.contains("objective : 5"), "{out}");
825 assert!(out.contains("(LP, maximize)"), "{out}");
826 assert!(out.contains("x = 5"), "{out}");
827 assert!(out.contains("dual = 1"), "{out}");
828 }
829
830 #[test]
831 fn report_keeps_algebraic_duals_paired_when_skipping_soc_rows() {
832 use oximo_core::{constraint, objective, variable};
833
834 let m = Model::new("mixed");
835 variable!(m, x >= 0.0);
836 variable!(m, t >= 0.0);
837 let first = constraint!(m, first, x <= 1.0);
838 let second = constraint!(m, second, x >= 0.5);
839 m.add_soc_constraint("cone", [x], t);
840 objective!(m, Min, x);
841
842 let mut dual = FxHashMap::default();
843 dual.insert(first.id(), 1.0);
844 dual.insert(second.id(), 2.0);
845 let r = SolverResult { model_id: m.id(), dual, ..Default::default() };
846
847 let out = r.report(&m).unwrap().to_string();
848 assert!(out.contains("first dual = 1"), "{out}");
849 assert!(out.contains("second dual = 2"), "{out}");
850 }
851}