Skip to main content

oximo_solver/
result.rs

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/// 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, 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    /// Look up a primal value by `VarId`.
38    pub fn value(&self, id: VarId) -> Option<f64> {
39        self.primal.get(&id).copied()
40    }
41
42    /// Evaluate an expression at this primal point.
43    ///
44    /// Returns `None` when any variable needed by the expression is absent.
45    /// Parameter values are read from the expression's model arena at query
46    /// time.
47    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    /// Look up the primal value for a specific index of an [`IndexedVar`].
53    ///
54    /// Returns `None` if `key` is not in the variable's set or the solver did
55    /// not return a primal value for that scalar.
56    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    /// Iterate over primal values for all entries of an [`IndexedVar`].
65    ///
66    /// Yields `(&IndexKey, f64)` for every index whose primal value is present
67    /// in the solution.
68    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/// Availability and quality of the dual solution returned by a solver.
77#[non_exhaustive]
78#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
79pub enum DualStatus {
80    /// The backend reports that no dual solution is available.
81    #[default]
82    NoSolution,
83    /// A usable dual point is available.
84    FeasiblePoint,
85    /// The backend cannot distinguish unavailable from unreported duals.
86    Unknown,
87}
88
89/// Activity and feasibility information for an algebraic constraint.
90#[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/// Activity and feasibility information for an explicit second-order cone.
99#[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/// A solver's final result on a model.
148///
149/// `termination` expresses why the solver stopped and `primal_status` says
150/// whether the point in `solutions` is usable. Primal points are held in
151/// `solutions` (index `0` is the best/incumbent, empty when no solution was
152/// found). `dual` and `reduced_costs` apply to the best continuous point and are
153/// sparse maps, so a solver that does not return duals (e.g. MILP) can simply
154/// leave them empty. `best_bound` and `gap` are populated by branch-and-bound
155/// backends when available.
156#[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    /// The best objective bound reported by the backend.
166    pub best_bound: Option<f64>,
167    /// The backend's relative optimality gap.
168    pub gap: Option<f64>,
169    pub solve_time: Duration,
170    /// A backend-defined aggregate iteration count.
171    pub iterations: u64,
172    pub node_count: Option<u64>,
173    /// A compact native status label or code, distinct from [`Self::raw_log`].
174    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    /// The number of primal points the solver returned (`0` when infeasible or
205    /// unsolved).
206    pub fn result_count(&self) -> usize {
207        self.solutions.len()
208    }
209
210    /// The `i`-th primal point, where index `0` is the best/incumbent.
211    pub fn solution(&self, i: usize) -> Option<&SolutionPoint> {
212        self.solutions.get(i)
213    }
214
215    /// The best primal point, or `None` when no solution was found.
216    pub fn best(&self) -> Option<&SolutionPoint> {
217        self.solutions.first()
218    }
219
220    /// Whether a usable primal point is available, regardless of why the solver
221    /// stopped. Driven by [`PrimalStatus`], so an incumbent returned at a time
222    /// or iteration limit still counts.
223    pub fn has_solution(&self) -> bool {
224        self.primal_status.has_solution()
225    }
226
227    /// The objective value of the best solution, or `None` when none was found.
228    pub fn objective(&self) -> Option<f64> {
229        self.solutions.first().and_then(|s| s.objective)
230    }
231
232    /// The best solution's primal map, or `None` when no solution was found.
233    pub fn primal(&self) -> Option<&FxHashMap<VarId, f64>> {
234        self.solutions.first().map(|s| &s.primal)
235    }
236
237    /// Look up a primal value by `VarId` in the best solution.
238    pub fn value(&self, id: VarId) -> Option<f64> {
239        self.solutions.first().and_then(|s| s.value(id))
240    }
241
242    /// Evaluate an expression at the best solution.
243    pub fn value_of(&self, expr: Expr<'_>) -> Option<f64> {
244        self.solutions.first().and_then(|s| s.value_of(expr))
245    }
246
247    /// Evaluate an algebraic constraint at the best solution.
248    ///
249    /// The result and model must describe the same solve. Parameter values are
250    /// read from `model` at query time, so do not combine an old result with a
251    /// subsequently modified model.
252    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    /// Evaluate an algebraic constraint at solution `solution_index`.
261    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    /// Evaluate an explicit second-order-cone constraint at the best solution.
271    pub fn soc_evaluation(&self, model: &Model, id: SocConstraintId) -> Option<SocEvaluation> {
272        self.soc_evaluation_at(model, id, 0)
273    }
274
275    /// Evaluate an explicit second-order-cone constraint at solution
276    /// `solution_index`.
277    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    /// The norm-form bound multiplier of an explicit SOC constraint,
291    /// or `None` when the backend did not compute it.
292    pub fn soc_dual_of(&self, c: SocConstraintId) -> Option<f64> {
293        self.soc_dual.get(&c).copied()
294    }
295
296    /// Look up the best solution's primal value for a specific index of an
297    /// [`IndexedVar`].
298    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    /// Iterate over the best solution's primal values for all entries of an
307    /// [`IndexedVar`]. Yields nothing when no solution was found.
308    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    /// A human-readable, model-aware summary of this result.
316    ///
317    /// It lists the solver, model kind and sense, status,
318    /// objective and work counters, then every variable's value
319    /// (with its reduced cost when the solver returned duals) and every
320    /// constraint's dual.
321    pub fn report<'a>(&'a self, model: &'a Model) -> ModelReport<'a> {
322        ModelReport { result: self, model }
323    }
324}
325
326/// A printable, model-aware summary of a [`SolverResult`]. Created by
327/// [`SolverResult::report`].
328#[derive(Debug)]
329pub struct ModelReport<'a> {
330    result: &'a SolverResult,
331    model: &'a Model,
332}
333
334/// Format a value with up to six decimals, trimming trailing zeros so whole
335/// numbers render as `5` rather than `5.000000`.
336fn 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        // Variables
388        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        // Constraint duals, only when the solver returned any
407        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        // SOC bound multipliers, only when the solver returned any
425        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}