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