Skip to main content

oximo_solver/
result.rs

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/// A single primal point returned by a solver.
14///
15/// Most solves yield one point, but a global solver asked to enumerate solutions
16/// may returns several. In a [`SolverResult`] the points live in [`SolverResult::solutions`].
17/// Index `0` is always the best/incumbent.
18#[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    /// Model that produced this point.
45    #[must_use]
46    pub const fn model_id(&self) -> ModelId {
47        self.model_id
48    }
49
50    /// Look up a primal value by raw `VarId`. Raw IDs carry no model provenance;
51    /// prefer [`Self::value_of`] when an expression handle is available.
52    pub fn value(&self, id: VarId) -> Option<f64> {
53        self.primal.get(&id).copied()
54    }
55
56    /// Evaluate an expression at this primal point.
57    ///
58    /// Returns `Ok(None)` when any variable needed by the expression is absent
59    /// and [`ModelMismatchError`] when the expression belongs to another model.
60    /// Parameter values are read from the expression's model arena at query
61    /// time.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`ModelMismatchError`] if `expr` belongs to another model.
66    #[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    /// Look up the primal value for a specific index of an [`IndexedVar`].
77    ///
78    /// Returns `Ok(None)` if `key` is not in the variable's set or the solver did
79    /// not return a primal value for that scalar. Returns [`ModelMismatchError`]
80    /// when the indexed variable belongs to another model.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`ModelMismatchError`] if `var` belongs to another model.
85    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    /// Iterate over primal values for all entries of an [`IndexedVar`].
95    ///
96    /// Yields `(&IndexKey, f64)` for every index whose primal value is present
97    /// in the solution.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`ModelMismatchError`] if `var` belongs to another model.
102    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/// Availability and quality of the dual solution returned by a solver.
123#[non_exhaustive]
124#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
125pub enum DualStatus {
126    /// The backend reports that no dual solution is available.
127    #[default]
128    NoSolution,
129    /// A usable dual point is available.
130    FeasiblePoint,
131    /// The backend cannot distinguish unavailable from unreported duals.
132    Unknown,
133}
134
135/// Activity and feasibility information for an algebraic constraint.
136#[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/// Activity and feasibility information for an explicit second-order cone.
145#[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/// A solver's final result on a model.
194///
195/// `termination` expresses why the solver stopped and `primal_status` says
196/// whether the point in `solutions` is usable. Primal points are held in
197/// `solutions` (index `0` is the best/incumbent, empty when no solution was
198/// found). `dual` and `reduced_costs` apply to the best continuous point and are
199/// sparse maps, so a solver that does not return duals (e.g. MILP) can simply
200/// leave them empty. `best_bound` is populated when a global bound is available.
201/// `gap` is the solver-reported gap, whose convention is backend-specific.
202#[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    /// The best objective bound reported by the backend.
213    pub best_bound: Option<f64>,
214    /// The backend's relative optimality gap.
215    pub gap: Option<f64>,
216    pub solve_time: Duration,
217    /// A backend-defined aggregate iteration count.
218    pub iterations: u64,
219    pub node_count: Option<u64>,
220    /// A compact native status label or code, distinct from [`Self::raw_log`].
221    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    /// Model that produced this result.
253    #[must_use]
254    pub const fn model_id(&self) -> ModelId {
255        self.model_id
256    }
257
258    /// The number of primal points the solver returned (`0` when infeasible or
259    /// unsolved).
260    pub fn result_count(&self) -> usize {
261        self.solutions.len()
262    }
263
264    /// The `i`-th primal point, where index `0` is the best/incumbent.
265    pub fn solution(&self, i: usize) -> Option<&SolutionPoint> {
266        self.solutions.get(i)
267    }
268
269    /// The best primal point, or `None` when no solution was found.
270    pub fn best(&self) -> Option<&SolutionPoint> {
271        self.solutions.first()
272    }
273
274    /// Whether a usable primal point is available, regardless of why the solver
275    /// stopped. Driven by [`PrimalStatus`], so an incumbent returned at a time
276    /// or iteration limit still counts.
277    pub fn has_solution(&self) -> bool {
278        self.primal_status.has_solution()
279    }
280
281    /// The objective value of the best solution, or `None` when none was found.
282    pub fn objective(&self) -> Option<f64> {
283        self.solutions.first().and_then(|s| s.objective)
284    }
285
286    /// The best solution's primal map, or `None` when no solution was found.
287    pub fn primal(&self) -> Option<&FxHashMap<VarId, f64>> {
288        self.solutions.first().map(|s| &s.primal)
289    }
290
291    /// Look up a primal value by raw `VarId` in the best solution. Raw IDs carry
292    /// no model provenance; prefer [`Self::value_of`] when possible.
293    pub fn value(&self, id: VarId) -> Option<f64> {
294        self.solutions.first().and_then(|s| s.value(id))
295    }
296
297    /// Evaluate an expression at the best solution, rejecting expressions from
298    /// another model with [`ModelMismatchError`].
299    ///
300    /// # Errors
301    ///
302    /// Returns [`ModelMismatchError`] if `expr` belongs to another model.
303    #[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    /// Evaluate an algebraic constraint at the best solution.
310    ///
311    /// The result and model must describe the same solve. Parameter values are
312    /// read from `model` at query time, so do not combine an old result with a
313    /// subsequently modified model.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`ModelMismatchError`] if `model` or the selected solution point
318    /// does not belong to this result's model.
319    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    /// Evaluate an algebraic constraint at solution `solution_index`.
328    ///
329    /// # Errors
330    ///
331    /// Returns [`ModelMismatchError`] if `model` or the selected solution point
332    /// does not belong to this result's model.
333    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    /// Evaluate an explicit second-order-cone constraint at the best solution.
347    ///
348    /// # Errors
349    ///
350    /// Returns [`ModelMismatchError`] if `model` or the best solution point
351    /// does not belong to this result's model.
352    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    /// Evaluate an explicit second-order-cone constraint at solution
361    /// `solution_index`.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`ModelMismatchError`] if `model` or the selected solution point
366    /// does not belong to this result's model.
367    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    /// Look up an algebraic constraint multiplier, rejecting a handle from a
381    /// different model.
382    ///
383    /// # Errors
384    ///
385    /// Returns [`ModelMismatchError`] if `constraint` belongs to another model.
386    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    /// The norm-form bound multiplier of an explicit SOC constraint,
392    /// or `None` when the backend did not compute it.
393    ///
394    /// # Errors
395    ///
396    /// Returns [`ModelMismatchError`] if `constraint` belongs to another model.
397    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    /// Look up the best solution's primal value for a specific index of an
406    /// [`IndexedVar`].
407    ///
408    /// # Errors
409    ///
410    /// Returns [`ModelMismatchError`] if `var` belongs to another model.
411    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    /// Iterate over the best solution's primal values for all entries of an
421    /// [`IndexedVar`]. Yields nothing when no solution was found.
422    ///
423    /// # Errors
424    ///
425    /// Returns [`ModelMismatchError`] if `var` or the best solution point does
426    /// not belong to this result's model.
427    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    /// A human-readable, model-aware summary of this result.
442    ///
443    /// It lists the solver, model kind and sense, status,
444    /// objective and work counters, then every variable's value
445    /// (with its reduced cost when the solver returned duals) and every
446    /// constraint's dual.
447    ///
448    /// # Errors
449    ///
450    /// Returns [`ModelMismatchError`] if `model` or the best solution point
451    /// does not belong to this result's model.
452    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/// A printable, model-aware summary of a [`SolverResult`]. Created by
462/// [`SolverResult::report`].
463#[derive(Debug)]
464pub struct ModelReport<'a> {
465    result: &'a SolverResult,
466    model: &'a Model,
467}
468
469/// Format a value with up to six decimals, trimming trailing zeros so whole
470/// numbers render as `5` rather than `5.000000`.
471fn 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        // Variables
519        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        // Constraint duals, only when the solver returned any
538        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        // SOC bound multipliers, only when the solver returned any
558        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}