1use std::borrow::Cow;
2use std::time::Duration;
3
4use oximo_core::{
5 ConstraintId, ConstraintRef, Expr, IndexKey, IndexedVar, Model, ObjectiveSense,
6 SocConstraintId, VarId,
7};
8use oximo_expr::{EvalContext, ExprArena, ExprId, ParamId, evaluate};
9use rustc_hash::FxHashMap;
10
11use crate::status::{PrimalStatus, TerminationStatus};
12
13#[derive(Clone, Debug, Default)]
19pub struct SolutionPoint {
20 pub primal: FxHashMap<VarId, f64>,
21 pub objective: Option<f64>,
22}
23
24struct PointContext<'a>(&'a FxHashMap<VarId, f64>);
25
26impl EvalContext for PointContext<'_> {
27 fn var(&self, id: VarId) -> Option<f64> {
28 self.0.get(&id).copied()
29 }
30
31 fn param(&self, _id: ParamId) -> Option<f64> {
32 None
33 }
34}
35
36impl SolutionPoint {
37 pub fn value(&self, id: VarId) -> Option<f64> {
39 self.primal.get(&id).copied()
40 }
41
42 pub fn value_of(&self, expr: Expr<'_>) -> Option<f64> {
48 let arena = expr.arena.borrow();
49 evaluate(&arena, expr.id, &PointContext(&self.primal)).ok()
50 }
51
52 pub fn value_of_idx<V, K: Into<IndexKey>>(
57 &self,
58 var: &IndexedVar<'_, V>,
59 key: K,
60 ) -> Option<f64> {
61 var.get(key).and_then(|e| self.value_of(e))
62 }
63
64 pub fn values_of<'iv, 'a, V>(
69 &'iv self,
70 var: &'iv IndexedVar<'a, V>,
71 ) -> impl Iterator<Item = (&'iv IndexKey, f64)> + 'iv {
72 var.iter().filter_map(|(k, e)| self.value_of(*e).map(|v| (k, v)))
73 }
74}
75
76#[non_exhaustive]
78#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
79pub enum DualStatus {
80 #[default]
82 NoSolution,
83 FeasiblePoint,
85 Unknown,
87}
88
89#[derive(Copy, Clone, Debug, PartialEq)]
91pub struct ConstraintEvaluation {
92 pub activity: f64,
93 pub lower_slack: Option<f64>,
94 pub upper_slack: Option<f64>,
95 pub violation: f64,
96}
97
98#[derive(Copy, Clone, Debug, PartialEq)]
100pub struct SocEvaluation {
101 pub norm: f64,
102 pub bound: f64,
103 pub slack: f64,
104 pub violation: f64,
105}
106
107fn evaluate_at(arena: &ExprArena, id: ExprId, point: &SolutionPoint) -> Option<f64> {
108 evaluate(arena, id, &PointContext(&point.primal)).ok()
109}
110
111fn evaluate_constraint_at(
112 point: &SolutionPoint,
113 model: &Model,
114 id: ConstraintId,
115) -> Option<ConstraintEvaluation> {
116 let arena = model.arena();
117 let constraints = model.constraints();
118 let constraint = constraints.algebraic().get(id.index())?;
119 let activity = evaluate_at(&arena, constraint.lhs, point)?;
120 let lower_slack = constraint.lower.is_finite().then_some(activity - constraint.lower);
121 let upper_slack = constraint.upper.is_finite().then_some(constraint.upper - activity);
122 let violation = lower_slack
123 .into_iter()
124 .chain(upper_slack)
125 .map(|slack| (-slack).max(0.0))
126 .fold(0.0, f64::max);
127 Some(ConstraintEvaluation { activity, lower_slack, upper_slack, violation })
128}
129
130fn evaluate_soc_at(
131 point: &SolutionPoint,
132 model: &Model,
133 id: SocConstraintId,
134) -> Option<SocEvaluation> {
135 let arena = model.arena();
136 let socs = model.soc_constraints();
137 let constraint = socs.get(id.index())?;
138 let squared_norm = constraint.terms.iter().try_fold(0.0, |sum, &term| {
139 evaluate_at(&arena, term, point).map(|value| sum + value * value)
140 })?;
141 let norm = squared_norm.sqrt();
142 let bound = evaluate_at(&arena, constraint.bound, point)?;
143 let slack = bound - norm;
144 Some(SocEvaluation { norm, bound, slack, violation: (-slack).max(0.0) })
145}
146
147#[derive(Clone, Debug)]
157pub struct SolverResult {
158 pub termination: TerminationStatus,
159 pub primal_status: PrimalStatus,
160 pub dual_status: DualStatus,
161 pub solutions: Vec<SolutionPoint>,
162 pub dual: FxHashMap<ConstraintId, f64>,
163 pub soc_dual: FxHashMap<SocConstraintId, f64>,
164 pub reduced_costs: FxHashMap<VarId, f64>,
165 pub best_bound: Option<f64>,
167 pub gap: Option<f64>,
169 pub solve_time: Duration,
170 pub iterations: u64,
172 pub node_count: Option<u64>,
173 pub raw_status: Option<Cow<'static, str>>,
175 pub raw_log: Option<String>,
176 pub solver_name: Option<Cow<'static, str>>,
177 pub solver_version: Option<Cow<'static, str>>,
178}
179
180impl Default for SolverResult {
181 fn default() -> Self {
182 Self {
183 termination: TerminationStatus::NotSolved,
184 primal_status: PrimalStatus::NoSolution,
185 dual_status: DualStatus::NoSolution,
186 solutions: Vec::new(),
187 dual: FxHashMap::default(),
188 soc_dual: FxHashMap::default(),
189 reduced_costs: FxHashMap::default(),
190 best_bound: None,
191 gap: None,
192 solve_time: Duration::ZERO,
193 iterations: 0,
194 node_count: None,
195 raw_status: None,
196 raw_log: None,
197 solver_name: None,
198 solver_version: None,
199 }
200 }
201}
202
203impl SolverResult {
204 pub fn result_count(&self) -> usize {
207 self.solutions.len()
208 }
209
210 pub fn solution(&self, i: usize) -> Option<&SolutionPoint> {
212 self.solutions.get(i)
213 }
214
215 pub fn best(&self) -> Option<&SolutionPoint> {
217 self.solutions.first()
218 }
219
220 pub fn has_solution(&self) -> bool {
224 self.primal_status.has_solution()
225 }
226
227 pub fn objective(&self) -> Option<f64> {
229 self.solutions.first().and_then(|s| s.objective)
230 }
231
232 pub fn primal(&self) -> Option<&FxHashMap<VarId, f64>> {
234 self.solutions.first().map(|s| &s.primal)
235 }
236
237 pub fn value(&self, id: VarId) -> Option<f64> {
239 self.solutions.first().and_then(|s| s.value(id))
240 }
241
242 pub fn value_of(&self, expr: Expr<'_>) -> Option<f64> {
244 self.solutions.first().and_then(|s| s.value_of(expr))
245 }
246
247 pub fn constraint_evaluation(
253 &self,
254 model: &Model,
255 id: ConstraintId,
256 ) -> Option<ConstraintEvaluation> {
257 self.constraint_evaluation_at(model, id, 0)
258 }
259
260 pub fn constraint_evaluation_at(
262 &self,
263 model: &Model,
264 id: ConstraintId,
265 solution_index: usize,
266 ) -> Option<ConstraintEvaluation> {
267 evaluate_constraint_at(self.solution(solution_index)?, model, id)
268 }
269
270 pub fn soc_evaluation(&self, model: &Model, id: SocConstraintId) -> Option<SocEvaluation> {
272 self.soc_evaluation_at(model, id, 0)
273 }
274
275 pub fn soc_evaluation_at(
278 &self,
279 model: &Model,
280 id: SocConstraintId,
281 solution_index: usize,
282 ) -> Option<SocEvaluation> {
283 evaluate_soc_at(self.solution(solution_index)?, model, id)
284 }
285
286 pub fn dual_of(&self, c: ConstraintId) -> Option<f64> {
287 self.dual.get(&c).copied()
288 }
289
290 pub fn soc_dual_of(&self, c: SocConstraintId) -> Option<f64> {
293 self.soc_dual.get(&c).copied()
294 }
295
296 pub fn value_of_idx<V, K: Into<IndexKey>>(
299 &self,
300 var: &IndexedVar<'_, V>,
301 key: K,
302 ) -> Option<f64> {
303 var.get(key).and_then(|e| self.value_of(e))
304 }
305
306 pub fn values_of<'iv, 'a, V>(
309 &'iv self,
310 var: &'iv IndexedVar<'a, V>,
311 ) -> impl Iterator<Item = (&'iv IndexKey, f64)> + 'iv {
312 var.iter().filter_map(|(k, e)| self.value_of(*e).map(|v| (k, v)))
313 }
314
315 pub fn report<'a>(&'a self, model: &'a Model) -> ModelReport<'a> {
322 ModelReport { result: self, model }
323 }
324}
325
326#[derive(Debug)]
329pub struct ModelReport<'a> {
330 result: &'a SolverResult,
331 model: &'a Model,
332}
333
334fn num(x: f64) -> String {
337 let s = format!("{x:.6}");
338 let trimmed = s.trim_end_matches('0').trim_end_matches('.');
339 if trimmed.is_empty() || trimmed == "-0" { "0".to_owned() } else { trimmed.to_owned() }
340}
341
342impl std::fmt::Display for ModelReport<'_> {
343 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 let r = self.result;
345 let m = self.model;
346
347 let sense = {
348 let obj = m.objective();
349 match obj.as_ref().map(|o| o.sense) {
350 Some(ObjectiveSense::Minimize) => "minimize",
351 Some(ObjectiveSense::Maximize) => "maximize",
352 None => "no objective",
353 }
354 };
355
356 writeln!(f, "solution summary")?;
357 let solver = match (r.solver_name.as_deref(), r.solver_version.as_deref()) {
358 (Some(name), Some(version)) => format!("{name} {version}"),
359 (Some(name), None) => name.to_owned(),
360 (None, _) => "(unknown)".to_owned(),
361 };
362 writeln!(f, " solver : {solver}")?;
363 writeln!(f, " model : {} ({:?}, {sense})", m.name, m.kind())?;
364 writeln!(f, " termination: {:?}", r.termination)?;
365 writeln!(f, " primal : {:?}", r.primal_status)?;
366 writeln!(f, " dual : {:?}", r.dual_status)?;
367 if let Some(raw) = r.raw_status.as_deref() {
368 writeln!(f, " raw status : {raw}")?;
369 }
370 writeln!(f, " solutions : {}", r.result_count())?;
371 match r.objective() {
372 Some(v) => writeln!(f, " objective : {}", num(v))?,
373 None => writeln!(f, " objective : (none)")?,
374 }
375 if let Some(b) = r.best_bound {
376 writeln!(f, " best bound : {}", num(b))?;
377 }
378 if let Some(g) = r.gap {
379 writeln!(f, " gap : {}", num(g))?;
380 }
381 writeln!(f, " solve time : {:?}", r.solve_time)?;
382 writeln!(f, " iterations : {}", r.iterations)?;
383 if let Some(nodes) = r.node_count {
384 writeln!(f, " nodes : {nodes}")?;
385 }
386
387 let vars = m.variables();
389 writeln!(f, "\nvariables ({})", vars.len())?;
390 if let Some(best) = r.best() {
391 let width = vars.iter().map(|v| v.name.len()).max().unwrap_or(0);
392 let show_rc = !r.reduced_costs.is_empty();
393 for v in vars.iter() {
394 let val = best.value(v.id).map_or_else(|| "n/a".to_owned(), num);
395 match (show_rc, r.reduced_costs.get(&v.id)) {
396 (true, Some(rc)) => {
397 writeln!(f, " {:<width$} = {val} (reduced cost {})", v.name, num(*rc))?;
398 }
399 _ => writeln!(f, " {:<width$} = {val}", v.name)?,
400 }
401 }
402 } else {
403 writeln!(f, " (no primal solution)")?;
404 }
405
406 if !r.dual.is_empty() {
408 let model_constraints = m.constraints();
409 let cons: Vec<_> = model_constraints
410 .iter()
411 .filter_map(|constraint| match constraint {
412 ConstraintRef::Algebraic { id, constraint } => Some((id, constraint)),
413 ConstraintRef::SecondOrderCone { .. } => None,
414 })
415 .collect();
416 writeln!(f, "\nconstraints ({})", cons.len())?;
417 let width = cons.iter().map(|(_, c)| c.name.len()).max().unwrap_or(0);
418 for (id, c) in cons {
419 let d = r.dual_of(id).map_or_else(|| "n/a".to_owned(), num);
420 writeln!(f, " {:<width$} dual = {d}", c.name)?;
421 }
422 }
423
424 if !r.soc_dual.is_empty() {
426 let socs = m.soc_constraints();
427 writeln!(f, "\nsoc constraints ({})", socs.len())?;
428 let width = socs.iter().map(|s| s.name.len()).max().unwrap_or(0);
429 for (i, s) in socs.iter().enumerate() {
430 let id = SocConstraintId(u32::try_from(i).expect("soc index fits u32"));
431 let d = r.soc_dual_of(id).map_or_else(|| "n/a".to_owned(), num);
432 writeln!(f, " {:<width$} dual = {d}", s.name)?;
433 }
434 }
435
436 Ok(())
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn empty_result_has_no_solution() {
446 let r = SolverResult::default();
447 assert_eq!(r.result_count(), 0);
448 assert!(r.best().is_none());
449 assert!(r.objective().is_none());
450 assert!(r.primal().is_none());
451 assert!(r.value(VarId(0)).is_none());
452 assert!(r.solution(0).is_none());
453 assert_eq!(r.dual_status, DualStatus::NoSolution);
454 assert!(r.raw_status.is_none());
455 assert!(r.solver_version.is_none());
456 assert!(r.node_count.is_none());
457 }
458
459 #[test]
460 fn value_of_evaluates_linear_quadratic_nonlinear_and_parameterized_expressions() {
461 use oximo_core::{param, variable};
462
463 let m = Model::new("expressions");
464 param!(m, p = 2.0);
465 variable!(m, x);
466 variable!(m, y);
467 let mut primal = FxHashMap::default();
468 primal.insert(x.var_id().unwrap(), 3.0);
469 primal.insert(y.var_id().unwrap(), 4.0);
470 let point = SolutionPoint { primal, objective: None };
471
472 assert_eq!(point.value_of(x), Some(3.0));
473 assert_eq!(point.value_of(2.0 * x + y - 1.0), Some(9.0));
474 assert_eq!(point.value_of(x.powi(2) + x * y), Some(21.0));
475 assert_eq!(point.value_of(x.sin()), Some(3.0_f64.sin()));
476 assert_eq!(point.value_of(p * x + y), Some(10.0));
477
478 let incomplete = SolutionPoint::default();
479 assert!(incomplete.value_of(x + y).is_none());
480 }
481
482 #[test]
483 fn algebraic_constraint_evaluations_cover_all_bound_shapes_and_solution_indices() {
484 use oximo_core::{constraint, variable};
485
486 let m = Model::new("constraint evaluation");
487 variable!(m, x);
488 let equality = constraint!(m, equality, x == 2.0);
489 let lower = constraint!(m, lower, x >= 1.0);
490 let upper = constraint!(m, upper, x <= 3.0);
491 constraint!(m, ranged, 1.5 <= x <= 2.5);
492 let ranged = m.constraint_id("ranged").unwrap();
493
494 let point = |value| {
495 let mut primal = FxHashMap::default();
496 primal.insert(x.var_id().unwrap(), value);
497 SolutionPoint { primal, objective: None }
498 };
499 let result = SolverResult {
500 primal_status: PrimalStatus::FeasiblePoint,
501 solutions: vec![point(2.0), point(4.0)],
502 ..Default::default()
503 };
504
505 assert_eq!(
506 result.constraint_evaluation(&m, equality),
507 Some(ConstraintEvaluation {
508 activity: 2.0,
509 lower_slack: Some(0.0),
510 upper_slack: Some(0.0),
511 violation: 0.0,
512 })
513 );
514 assert_eq!(result.constraint_evaluation(&m, lower).unwrap().lower_slack, Some(1.0));
515 assert_eq!(result.constraint_evaluation(&m, lower).unwrap().upper_slack, None);
516 assert_eq!(result.constraint_evaluation(&m, upper).unwrap().lower_slack, None);
517 assert_eq!(result.constraint_evaluation(&m, upper).unwrap().upper_slack, Some(1.0));
518 assert!(result.constraint_evaluation(&m, ranged).unwrap().violation.abs() < f64::EPSILON);
519 assert!(
520 (result.constraint_evaluation_at(&m, ranged, 1).unwrap().violation - 1.5).abs()
521 < f64::EPSILON
522 );
523 assert!(result.constraint_evaluation_at(&m, ranged, 2).is_none());
524 assert!(result.constraint_evaluation(&m, ConstraintId(u32::MAX)).is_none());
525 }
526
527 #[test]
528 fn soc_evaluation_reports_norm_slack_and_violation() {
529 use oximo_core::{soc_constraint, variable};
530
531 let m = Model::new("soc evaluation");
532 variable!(m, x);
533 variable!(m, y);
534 variable!(m, t);
535 let cone = soc_constraint!(m, cone, [x, y] <= t);
536 let point = |x_value, y_value, t_value| {
537 let mut primal = FxHashMap::default();
538 primal.insert(x.var_id().unwrap(), x_value);
539 primal.insert(y.var_id().unwrap(), y_value);
540 primal.insert(t.var_id().unwrap(), t_value);
541 SolutionPoint { primal, objective: None }
542 };
543 let result = SolverResult {
544 primal_status: PrimalStatus::FeasiblePoint,
545 solutions: vec![point(3.0, 4.0, 6.0), point(3.0, 4.0, 4.0)],
546 ..Default::default()
547 };
548
549 assert_eq!(
550 result.soc_evaluation(&m, cone),
551 Some(SocEvaluation { norm: 5.0, bound: 6.0, slack: 1.0, violation: 0.0 })
552 );
553 assert!(
554 (result.soc_evaluation_at(&m, cone, 1).unwrap().violation - 1.0).abs() < f64::EPSILON
555 );
556 assert!(result.soc_evaluation_at(&m, cone, 2).is_none());
557 }
558
559 #[test]
560 fn best_is_solution_zero() {
561 let mut p0 = FxHashMap::default();
562 p0.insert(VarId(0), 1.5);
563 let mut p1 = FxHashMap::default();
564 p1.insert(VarId(0), 2.5);
565 let r = SolverResult {
566 termination: TerminationStatus::Optimal,
567 primal_status: PrimalStatus::OptimalPoint,
568 solutions: vec![
569 SolutionPoint { primal: p0, objective: Some(10.0) },
570 SolutionPoint { primal: p1, objective: Some(9.0) },
571 ],
572 ..Default::default()
573 };
574 assert_eq!(r.result_count(), 2);
575 assert_eq!(r.objective(), Some(10.0));
576 assert_eq!(r.value(VarId(0)), Some(1.5));
577 assert_eq!(r.solution(1).unwrap().value(VarId(0)), Some(2.5));
578 }
579
580 #[test]
581 fn report_renders_sections() {
582 use oximo_core::{constraint, objective, variable};
583
584 let m = Model::new("toy");
585 variable!(m, x >= 0.0);
586 let c = constraint!(m, c, x <= 5.0);
587 objective!(m, Max, x);
588
589 let mut primal = FxHashMap::default();
590 primal.insert(x.var_id().unwrap(), 5.0);
591 let mut dual = FxHashMap::default();
592 dual.insert(c, 1.0);
593
594 let r = SolverResult {
595 termination: TerminationStatus::Optimal,
596 primal_status: PrimalStatus::OptimalPoint,
597 solutions: vec![SolutionPoint { primal, objective: Some(5.0) }],
598 dual,
599 solver_name: Some("TestSolver".into()),
600 solver_version: Some("1.2.3".into()),
601 raw_status: Some("native optimal".into()),
602 dual_status: DualStatus::FeasiblePoint,
603 node_count: Some(7),
604 ..Default::default()
605 };
606
607 let out = r.report(&m).to_string();
608 assert!(out.contains("solver : TestSolver 1.2.3"), "{out}");
609 assert!(out.contains("termination: Optimal"), "{out}");
610 assert!(out.contains("primal : OptimalPoint"), "{out}");
611 assert!(out.contains("dual : FeasiblePoint"), "{out}");
612 assert!(out.contains("raw status : native optimal"), "{out}");
613 assert!(out.contains("nodes : 7"), "{out}");
614 assert!(out.contains("objective : 5"), "{out}");
615 assert!(out.contains("(LP, maximize)"), "{out}");
616 assert!(out.contains("x = 5"), "{out}");
617 assert!(out.contains("dual = 1"), "{out}");
618 }
619
620 #[test]
621 fn report_keeps_algebraic_duals_paired_when_skipping_soc_rows() {
622 use oximo_core::{constraint, objective, variable};
623
624 let m = Model::new("mixed");
625 variable!(m, x >= 0.0);
626 variable!(m, t >= 0.0);
627 let first = constraint!(m, first, x <= 1.0);
628 let second = constraint!(m, second, x >= 0.5);
629 m.add_soc_constraint("cone", [x], t);
630 objective!(m, Min, x);
631
632 let mut dual = FxHashMap::default();
633 dual.insert(first, 1.0);
634 dual.insert(second, 2.0);
635 let r = SolverResult { dual, ..Default::default() };
636
637 let out = r.report(&m).to_string();
638 assert!(out.contains("first dual = 1"), "{out}");
639 assert!(out.contains("second dual = 2"), "{out}");
640 }
641}