Skip to main content

oximo_solver/
result.rs

1use std::borrow::Cow;
2use std::time::Duration;
3
4use oximo_core::{
5    ConstraintId, Expr, ExprNode, IndexKey, IndexedVar, Model, ObjectiveSense, SocConstraintId,
6    VarId,
7};
8use rustc_hash::FxHashMap;
9
10use crate::status::{PrimalStatus, TerminationStatus};
11
12/// A single primal point returned by a solver.
13///
14/// Most solves yield one point, but a global solver asked to enumerate solutions
15/// may returns several. In a [`SolverResult`] the points live in [`SolverResult::solutions`].
16/// Index `0` is always the best/incumbent.
17#[derive(Clone, Debug, Default)]
18pub struct SolutionPoint {
19    pub primal: FxHashMap<VarId, f64>,
20    pub objective: Option<f64>,
21}
22
23impl SolutionPoint {
24    /// Look up a primal value by `VarId`.
25    pub fn value(&self, id: VarId) -> Option<f64> {
26        self.primal.get(&id).copied()
27    }
28
29    /// Look up the primal value for an `Expr` that points at a `Var` node.
30    /// Returns `None` for any expression that is not a single variable, so
31    /// callers that need the value of a derived expression should evaluate it
32    /// against the primal solution explicitly.
33    pub fn value_of(&self, expr: Expr<'_>) -> Option<f64> {
34        let arena = expr.arena.borrow();
35        match arena.get(expr.id) {
36            ExprNode::Var(v) => self.primal.get(v).copied(),
37            _ => None,
38        }
39    }
40
41    /// Look up the primal value for a specific index of an [`IndexedVar`].
42    ///
43    /// Returns `None` if `key` is not in the variable's set or the solver did
44    /// not return a primal value for that scalar.
45    pub fn value_of_idx<V, K: Into<IndexKey>>(
46        &self,
47        var: &IndexedVar<'_, V>,
48        key: K,
49    ) -> Option<f64> {
50        var.get(key).and_then(|e| self.value_of(e))
51    }
52
53    /// Iterate over primal values for all entries of an [`IndexedVar`].
54    ///
55    /// Yields `(&IndexKey, f64)` for every index whose primal value is present
56    /// in the solution.
57    pub fn values_of<'iv, 'a, V>(
58        &'iv self,
59        var: &'iv IndexedVar<'a, V>,
60    ) -> impl Iterator<Item = (&'iv IndexKey, f64)> + 'iv {
61        var.iter().filter_map(|(k, e)| self.value_of(*e).map(|v| (k, v)))
62    }
63}
64
65/// A solver's final result on a model.
66///
67/// `termination` expresses why the solver stopped and `primal_status` says
68/// whether the point in `solutions` is usable. Primal points are held in
69/// `solutions` (index `0` is the best/incumbent, empty when no solution was
70/// found). `dual` and `reduced_costs` apply to the best continuous point and are
71/// sparse maps, so a solver that does not return duals (e.g. MILP) can simply
72/// leave them empty. `best_bound` and `gap` are populated by branch-and-bound
73/// backends when available.
74#[derive(Clone, Debug)]
75pub struct SolverResult {
76    pub termination: TerminationStatus,
77    pub primal_status: PrimalStatus,
78    pub solutions: Vec<SolutionPoint>,
79    pub dual: FxHashMap<ConstraintId, f64>,
80    pub soc_dual: FxHashMap<SocConstraintId, f64>,
81    pub reduced_costs: FxHashMap<VarId, f64>,
82    pub best_bound: Option<f64>,
83    pub gap: Option<f64>,
84    pub solve_time: Duration,
85    pub iterations: u64,
86    pub raw_log: Option<String>,
87    pub solver_name: Option<Cow<'static, str>>,
88}
89
90impl Default for SolverResult {
91    fn default() -> Self {
92        Self {
93            termination: TerminationStatus::NotSolved,
94            primal_status: PrimalStatus::NoSolution,
95            solutions: Vec::new(),
96            dual: FxHashMap::default(),
97            soc_dual: FxHashMap::default(),
98            reduced_costs: FxHashMap::default(),
99            best_bound: None,
100            gap: None,
101            solve_time: Duration::ZERO,
102            iterations: 0,
103            raw_log: None,
104            solver_name: None,
105        }
106    }
107}
108
109impl SolverResult {
110    /// The number of primal points the solver returned (`0` when infeasible or
111    /// unsolved).
112    pub fn result_count(&self) -> usize {
113        self.solutions.len()
114    }
115
116    /// The `i`-th primal point, where index `0` is the best/incumbent.
117    pub fn solution(&self, i: usize) -> Option<&SolutionPoint> {
118        self.solutions.get(i)
119    }
120
121    /// The best primal point, or `None` when no solution was found.
122    pub fn best(&self) -> Option<&SolutionPoint> {
123        self.solutions.first()
124    }
125
126    /// Whether a usable primal point is available, regardless of why the solver
127    /// stopped. Driven by [`PrimalStatus`], so an incumbent returned at a time
128    /// or iteration limit still counts.
129    pub fn has_solution(&self) -> bool {
130        self.primal_status.has_solution()
131    }
132
133    /// The objective value of the best solution, or `None` when none was found.
134    pub fn objective(&self) -> Option<f64> {
135        self.solutions.first().and_then(|s| s.objective)
136    }
137
138    /// The best solution's primal map, or `None` when no solution was found.
139    pub fn primal(&self) -> Option<&FxHashMap<VarId, f64>> {
140        self.solutions.first().map(|s| &s.primal)
141    }
142
143    /// Look up a primal value by `VarId` in the best solution.
144    pub fn value(&self, id: VarId) -> Option<f64> {
145        self.solutions.first().and_then(|s| s.value(id))
146    }
147
148    /// Look up the best solution's primal value for an `Expr` that points at a
149    /// `Var` node. Returns `None` for any expression that is not a single
150    /// variable.
151    pub fn value_of(&self, expr: Expr<'_>) -> Option<f64> {
152        self.solutions.first().and_then(|s| s.value_of(expr))
153    }
154
155    pub fn dual_of(&self, c: ConstraintId) -> Option<f64> {
156        self.dual.get(&c).copied()
157    }
158
159    /// The norm-form bound multiplier of an explicit SOC constraint,
160    /// or `None` when the backend did not compute it.
161    pub fn soc_dual_of(&self, c: SocConstraintId) -> Option<f64> {
162        self.soc_dual.get(&c).copied()
163    }
164
165    /// Look up the best solution's primal value for a specific index of an
166    /// [`IndexedVar`].
167    pub fn value_of_idx<V, K: Into<IndexKey>>(
168        &self,
169        var: &IndexedVar<'_, V>,
170        key: K,
171    ) -> Option<f64> {
172        var.get(key).and_then(|e| self.value_of(e))
173    }
174
175    /// Iterate over the best solution's primal values for all entries of an
176    /// [`IndexedVar`]. Yields nothing when no solution was found.
177    pub fn values_of<'iv, 'a, V>(
178        &'iv self,
179        var: &'iv IndexedVar<'a, V>,
180    ) -> impl Iterator<Item = (&'iv IndexKey, f64)> + 'iv {
181        var.iter().filter_map(|(k, e)| self.value_of(*e).map(|v| (k, v)))
182    }
183
184    /// A human-readable, model-aware summary of this result.
185    ///
186    /// It lists the solver, model kind and sense, status,
187    /// objective and work counters, then every variable's value
188    /// (with its reduced cost when the solver returned duals) and every
189    /// constraint's dual.
190    pub fn report<'a>(&'a self, model: &'a Model) -> ModelReport<'a> {
191        ModelReport { result: self, model }
192    }
193}
194
195/// A printable, model-aware summary of a [`SolverResult`]. Created by
196/// [`SolverResult::report`].
197#[derive(Debug)]
198pub struct ModelReport<'a> {
199    result: &'a SolverResult,
200    model: &'a Model,
201}
202
203/// Format a value with up to six decimals, trimming trailing zeros so whole
204/// numbers render as `5` rather than `5.000000`.
205fn num(x: f64) -> String {
206    let s = format!("{x:.6}");
207    let trimmed = s.trim_end_matches('0').trim_end_matches('.');
208    if trimmed.is_empty() || trimmed == "-0" { "0".to_owned() } else { trimmed.to_owned() }
209}
210
211impl std::fmt::Display for ModelReport<'_> {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        let r = self.result;
214        let m = self.model;
215
216        let sense = {
217            let obj = m.objective();
218            match obj.as_ref().map(|o| o.sense) {
219                Some(ObjectiveSense::Minimize) => "minimize",
220                Some(ObjectiveSense::Maximize) => "maximize",
221                None => "no objective",
222            }
223        };
224
225        writeln!(f, "solution summary")?;
226        writeln!(f, "  solver     : {}", r.solver_name.as_deref().unwrap_or("(unknown)"))?;
227        writeln!(f, "  model      : {}  ({:?}, {sense})", m.name, m.kind())?;
228        writeln!(f, "  termination: {:?}", r.termination)?;
229        writeln!(f, "  primal     : {:?}", r.primal_status)?;
230        writeln!(f, "  solutions  : {}", r.result_count())?;
231        match r.objective() {
232            Some(v) => writeln!(f, "  objective  : {}", num(v))?,
233            None => writeln!(f, "  objective  : (none)")?,
234        }
235        if let Some(b) = r.best_bound {
236            writeln!(f, "  best bound : {}", num(b))?;
237        }
238        if let Some(g) = r.gap {
239            writeln!(f, "  gap        : {}", num(g))?;
240        }
241        writeln!(f, "  solve time : {:?}", r.solve_time)?;
242        writeln!(f, "  iterations : {}", r.iterations)?;
243
244        // Variables
245        let vars = m.variables();
246        writeln!(f, "\nvariables ({})", vars.len())?;
247        if let Some(best) = r.best() {
248            let width = vars.iter().map(|v| v.name.len()).max().unwrap_or(0);
249            let show_rc = !r.reduced_costs.is_empty();
250            for v in vars.iter() {
251                let val = best.value(v.id).map_or_else(|| "n/a".to_owned(), num);
252                match (show_rc, r.reduced_costs.get(&v.id)) {
253                    (true, Some(rc)) => {
254                        writeln!(f, "  {:<width$} = {val}   (reduced cost {})", v.name, num(*rc))?;
255                    }
256                    _ => writeln!(f, "  {:<width$} = {val}", v.name)?,
257                }
258            }
259        } else {
260            writeln!(f, "  (no primal solution)")?;
261        }
262
263        // Constraint duals, only when the solver returned any
264        if !r.dual.is_empty() {
265            let cons = m.constraints();
266            writeln!(f, "\nconstraints ({})", cons.len())?;
267            let width = cons.iter().map(|c| c.name.len()).max().unwrap_or(0);
268            for (i, c) in cons.iter().enumerate() {
269                let id = ConstraintId(u32::try_from(i).expect("constraint index fits u32"));
270                let d = r.dual_of(id).map_or_else(|| "n/a".to_owned(), num);
271                writeln!(f, "  {:<width$}  dual = {d}", c.name)?;
272            }
273        }
274
275        // SOC bound multipliers, only when the solver returned any
276        if !r.soc_dual.is_empty() {
277            let socs = m.soc_constraints();
278            writeln!(f, "\nsoc constraints ({})", socs.len())?;
279            let width = socs.iter().map(|s| s.name.len()).max().unwrap_or(0);
280            for (i, s) in socs.iter().enumerate() {
281                let id = SocConstraintId(u32::try_from(i).expect("soc index fits u32"));
282                let d = r.soc_dual_of(id).map_or_else(|| "n/a".to_owned(), num);
283                writeln!(f, "  {:<width$}  dual = {d}", s.name)?;
284            }
285        }
286
287        Ok(())
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn empty_result_has_no_solution() {
297        let r = SolverResult::default();
298        assert_eq!(r.result_count(), 0);
299        assert!(r.best().is_none());
300        assert!(r.objective().is_none());
301        assert!(r.primal().is_none());
302        assert!(r.value(VarId(0)).is_none());
303        assert!(r.solution(0).is_none());
304    }
305
306    #[test]
307    fn best_is_solution_zero() {
308        let mut p0 = FxHashMap::default();
309        p0.insert(VarId(0), 1.5);
310        let mut p1 = FxHashMap::default();
311        p1.insert(VarId(0), 2.5);
312        let r = SolverResult {
313            termination: TerminationStatus::Optimal,
314            primal_status: PrimalStatus::OptimalPoint,
315            solutions: vec![
316                SolutionPoint { primal: p0, objective: Some(10.0) },
317                SolutionPoint { primal: p1, objective: Some(9.0) },
318            ],
319            ..Default::default()
320        };
321        assert_eq!(r.result_count(), 2);
322        assert_eq!(r.objective(), Some(10.0));
323        assert_eq!(r.value(VarId(0)), Some(1.5));
324        assert_eq!(r.solution(1).unwrap().value(VarId(0)), Some(2.5));
325    }
326
327    #[test]
328    fn report_renders_sections() {
329        use oximo_core::{constraint, objective, variable};
330
331        let m = Model::new("toy");
332        variable!(m, x >= 0.0);
333        let c = constraint!(m, c, x <= 5.0);
334        objective!(m, Max, x);
335
336        let mut primal = FxHashMap::default();
337        primal.insert(x.var_id().unwrap(), 5.0);
338        let mut dual = FxHashMap::default();
339        dual.insert(c, 1.0);
340
341        let r = SolverResult {
342            termination: TerminationStatus::Optimal,
343            primal_status: PrimalStatus::OptimalPoint,
344            solutions: vec![SolutionPoint { primal, objective: Some(5.0) }],
345            dual,
346            solver_name: Some("TestSolver".into()),
347            ..Default::default()
348        };
349
350        let out = r.report(&m).to_string();
351        assert!(out.contains("solver     : TestSolver"), "{out}");
352        assert!(out.contains("termination: Optimal"), "{out}");
353        assert!(out.contains("primal     : OptimalPoint"), "{out}");
354        assert!(out.contains("objective  : 5"), "{out}");
355        assert!(out.contains("(LP, maximize)"), "{out}");
356        assert!(out.contains("x = 5"), "{out}");
357        assert!(out.contains("dual = 1"), "{out}");
358    }
359}