Skip to main content

oximo_core/
display.rs

1//! Human-readable rendering of models, constraints, objectives, and expressions.
2//!
3//! Everything here is a lazy adapter holding `&Model`. The model's `RefCell`s
4//! are borrowed at format time, so do not format one while holding a mutable
5//! borrow of the arena or registries.
6
7use std::fmt;
8
9use oximo_expr::{Expr, ExprId, render_expr};
10
11use crate::constraint::{ConstraintId, Sense};
12use crate::domain::Domain;
13use crate::model::Model;
14use crate::objective::ObjectiveSense;
15use crate::soc::SocConstraintId;
16use crate::var::{Variable, var_name};
17
18/// Compact `f64` rendering (shortest round-trip).
19fn fmt_num(v: f64) -> String {
20    if v == 0.0 { "0".to_string() } else { format!("{v}") }
21}
22
23/// Displays one expression as infix algebra, e.g. `3 x + 4 y - z`.
24/// Built by [`Model::display_expr`]/[`Model::display_expr_id`].
25#[derive(Debug)]
26pub struct ExprDisplay<'a> {
27    model: &'a Model,
28    id: ExprId,
29}
30
31impl fmt::Display for ExprDisplay<'_> {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        let arena = self.model.arena.borrow();
34        let vars = self.model.variables.borrow();
35        f.write_str(&render_expr(&arena, self.id, &|v| var_name(&vars, v)))
36    }
37}
38
39/// Displays one constraint as `name: lhs <= rhs` (or a range / equality).
40/// Built by [`Model::display_constraint`].
41#[derive(Debug)]
42pub struct ConstraintDisplay<'a> {
43    model: &'a Model,
44    id: ConstraintId,
45}
46
47impl fmt::Display for ConstraintDisplay<'_> {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        let arena = self.model.arena.borrow();
50        let vars = self.model.variables.borrow();
51        let constraints = self.model.constraints.borrow();
52        let c = &constraints[self.id.index()];
53        let resolve = |v| var_name(&vars, v);
54        let expr = render_expr(&arena, c.lhs, &resolve);
55        write!(f, "{}: ", c.name)?;
56        match c.as_single() {
57            Some((Sense::Le, rhs)) => write!(f, "{expr} <= {}", fmt_num(rhs))?,
58            Some((Sense::Ge, rhs)) => write!(f, "{expr} >= {}", fmt_num(rhs))?,
59            Some((Sense::Eq, rhs)) => write!(f, "{expr} = {}", fmt_num(rhs))?,
60            None if c.is_range() => {
61                write!(f, "{} <= {expr} <= {}", fmt_num(c.lower), fmt_num(c.upper))?;
62            }
63            None => write!(f, "{expr} free")?,
64        }
65        if !c.active {
66            f.write_str(" (inactive)")?;
67        }
68        Ok(())
69    }
70}
71
72/// Displays the objective as `min <expr>`/`max <expr>`/`feasibility`.
73/// Built by [`Model::display_objective`].
74#[derive(Debug)]
75pub struct ObjectiveDisplay<'a> {
76    model: &'a Model,
77}
78
79impl fmt::Display for ObjectiveDisplay<'_> {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        if self.model.is_feasibility() {
82            return f.write_str("feasibility");
83        }
84        // Copy `(sense, expr)` out so the objective borrow ends before the
85        // expression rendering takes its own borrows.
86        let obj = self.model.objective.borrow().as_ref().map(|o| (o.sense, o.expr));
87        let Some((sense, expr)) = obj else {
88            return f.write_str("(no objective)");
89        };
90        let word = match sense {
91            ObjectiveSense::Minimize => "min",
92            ObjectiveSense::Maximize => "max",
93        };
94        write!(f, "{word} {}", ExprDisplay { model: self.model, id: expr })
95    }
96}
97
98/// Displays one second-order cone constraint as `name: ||t1, t2|| <= bound`.
99/// Built by [`Model::display_soc`].
100#[derive(Debug)]
101pub struct SocDisplay<'a> {
102    model: &'a Model,
103    id: SocConstraintId,
104}
105
106impl fmt::Display for SocDisplay<'_> {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        let arena = self.model.arena.borrow();
109        let vars = self.model.variables.borrow();
110        let socs = self.model.soc_constraints.borrow();
111        let c = &socs[self.id.index()];
112        let resolve = |v| var_name(&vars, v);
113        write!(f, "{}: ||", c.name)?;
114        for (i, t) in c.terms.iter().enumerate() {
115            if i > 0 {
116                f.write_str(", ")?;
117            }
118            f.write_str(&render_expr(&arena, *t, &resolve))?;
119        }
120        write!(f, "|| <= {}", render_expr(&arena, c.bound, &resolve))?;
121        if !c.active {
122            f.write_str(" (inactive)")?;
123        }
124        Ok(())
125    }
126}
127
128/// One `vars` section line: bounds relation plus a domain suffix,
129/// e.g. `0 <= y <= 1, binary`.
130fn write_var_line(f: &mut fmt::Formatter<'_>, v: &Variable) -> fmt::Result {
131    match (v.lb.is_finite(), v.ub.is_finite()) {
132        (true, true) if v.lb.total_cmp(&v.ub).is_eq() => {
133            write!(f, "{} = {}", v.name, fmt_num(v.lb))?;
134        }
135        (true, true) => write!(f, "{} <= {} <= {}", fmt_num(v.lb), v.name, fmt_num(v.ub))?,
136        (true, false) => write!(f, "{} >= {}", v.name, fmt_num(v.lb))?,
137        (false, true) => write!(f, "{} <= {}", v.name, fmt_num(v.ub))?,
138        (false, false) => write!(f, "{} free", v.name)?,
139    }
140    match v.domain {
141        Domain::Real => Ok(()),
142        Domain::Integer => f.write_str(", integer"),
143        Domain::Binary => f.write_str(", binary"),
144        Domain::SemiContinuous { threshold } => {
145            write!(f, ", semicontinuous(threshold={})", fmt_num(threshold))
146        }
147        Domain::SemiInteger { threshold } => {
148            write!(f, ", semiinteger(threshold={})", fmt_num(threshold))
149        }
150    }
151}
152
153impl Model {
154    /// Display adapter for an expression handle, resolving variable names
155    /// against this model.
156    #[must_use]
157    pub fn display_expr(&self, e: Expr<'_>) -> ExprDisplay<'_> {
158        self.display_expr_id(e.id)
159    }
160
161    /// Display adapter for a raw [`ExprId`] (as stored in constraints and the
162    /// objective).
163    #[must_use]
164    pub fn display_expr_id(&self, id: ExprId) -> ExprDisplay<'_> {
165        ExprDisplay { model: self, id }
166    }
167
168    /// Display adapter for one algebraic constraint.
169    #[must_use]
170    pub fn display_constraint(&self, id: ConstraintId) -> ConstraintDisplay<'_> {
171        ConstraintDisplay { model: self, id }
172    }
173
174    /// Display adapter for the objective (`min <expr>`/`max <expr>`/
175    /// `feasibility`/`(no objective)`).
176    #[must_use]
177    pub fn display_objective(&self) -> ObjectiveDisplay<'_> {
178        ObjectiveDisplay { model: self }
179    }
180
181    /// Display adapter for one second-order cone constraint.
182    #[must_use]
183    pub fn display_soc(&self, id: SocConstraintId) -> SocDisplay<'_> {
184        SocDisplay { model: self, id }
185    }
186}
187
188/// Pretty-print the whole model as readable algebra:
189///
190/// ```text
191/// Model 'diet' (LP)
192/// min 3 x + 4 y
193/// s.t.
194///   c1: x + 2 y <= 14
195///   c2: 3 x - y >= 0
196/// vars
197///   x >= 0
198///   y >= 0
199/// ```
200///
201/// Sections (`s.t.`, `vars`, `params`) are omitted when empty. The objective
202/// line is omitted when no objective was declared.
203impl fmt::Display for Model {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        writeln!(f, "Model '{}' ({:?})", self.name, self.kind())?;
206        if self.is_feasibility() || self.objective.borrow().is_some() {
207            writeln!(f, "{}", self.display_objective())?;
208        }
209        let n_constraints = self.constraints.borrow().len();
210        let n_socs = self.soc_constraints.borrow().len();
211        if n_constraints + n_socs > 0 {
212            let n_constraints = u32::try_from(n_constraints).expect("constraint count fits u32");
213            let n_socs = u32::try_from(n_socs).expect("soc count fits u32");
214            writeln!(f, "s.t.")?;
215            for i in 0..n_constraints {
216                writeln!(f, "  {}", self.display_constraint(ConstraintId(i)))?;
217            }
218            for i in 0..n_socs {
219                writeln!(f, "  {}", self.display_soc(SocConstraintId(i)))?;
220            }
221        }
222        {
223            let vars = self.variables.borrow();
224            if !vars.is_empty() {
225                writeln!(f, "vars")?;
226                for v in vars.iter() {
227                    f.write_str("  ")?;
228                    write_var_line(f, v)?;
229                    writeln!(f)?;
230                }
231            }
232        }
233        let params = self.parameters.borrow();
234        if !params.is_empty() {
235            let arena = self.arena.borrow();
236            writeln!(f, "params")?;
237            for p in params.iter() {
238                writeln!(f, "  {} = {}", p.name, fmt_num(arena.param_value(p.id)))?;
239            }
240        }
241        Ok(())
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::constraint::Relate;
249
250    #[test]
251    fn full_model_snapshot() {
252        let m = Model::new("diet");
253        let x = m.__var("x").lb(0.0).build();
254        let y = m.__var("y").lb(0.0).build();
255        m.__add_constraint("c1", (x + 2.0 * y).le(14.0));
256        m.__add_constraint("c2", (3.0 * x - y).ge(0.0));
257        m.__minimize(3.0 * x + 4.0 * y);
258
259        let expected = "Model 'diet' (LP)\n\
260                        min 3 x + 4 y\n\
261                        s.t.\n\
262                        \x20 c1: x + 2 y <= 14\n\
263                        \x20 c2: 3 x - y >= 0\n\
264                        vars\n\
265                        \x20 x >= 0\n\
266                        \x20 y >= 0\n";
267        assert_eq!(format!("{m}"), expected);
268    }
269
270    #[test]
271    fn constraint_variants_render() {
272        let m = Model::new("t");
273        let x = m.__var("x").build();
274        let eq = m.__add_constraint("e", x.eq(5.0));
275        assert_eq!(m.display_constraint(eq).to_string(), "e: x = 5");
276
277        m.__add_range("r", x, 2.0, 10.0);
278        let r = m.constraint_id("r").unwrap();
279        assert_eq!(m.display_constraint(r).to_string(), "r: 2 <= x <= 10");
280
281        let ge = m.__add_constraint("g", x.ge(1.0));
282        m.constraints.borrow_mut()[ge.index()].active = false;
283        assert_eq!(m.display_constraint(ge).to_string(), "g: x >= 1 (inactive)");
284    }
285
286    #[test]
287    fn objective_and_feasibility() {
288        let m = Model::new("t");
289        let x = m.__var("x").build();
290        assert_eq!(m.display_objective().to_string(), "(no objective)");
291        m.__maximize(x);
292        assert_eq!(m.display_objective().to_string(), "max x");
293
294        let f = Model::new("f");
295        f.__feasibility();
296        assert_eq!(f.display_objective().to_string(), "feasibility");
297        assert!(format!("{f}").contains("feasibility\n"));
298    }
299
300    #[test]
301    fn var_lines_cover_domains_and_bounds() {
302        let m = Model::new("t");
303        m.__var("a").bounds(0.0, 5.0).build();
304        m.__var("b").binary().build();
305        m.__var("c").integer().build();
306        m.__var("d").build();
307        m.__var("e").ub(3.0).build();
308        m.__var("g").fix(2.0).build();
309        m.__var("h").lb(0.0).domain(crate::Domain::SemiContinuous { threshold: 2.0 }).build();
310
311        let out = format!("{m}");
312        assert!(out.contains("  0 <= a <= 5\n"), "{out}");
313        assert!(out.contains("  0 <= b <= 1, binary\n"), "{out}");
314        assert!(out.contains("  c free, integer\n"), "{out}");
315        assert!(out.contains("  d free\n"), "{out}");
316        assert!(out.contains("  e <= 3\n"), "{out}");
317        assert!(out.contains("  g = 2\n"), "{out}");
318        assert!(out.contains("  h >= 0, semicontinuous(threshold=2)\n"), "{out}");
319    }
320
321    #[test]
322    fn params_section_shows_current_values() {
323        let m = Model::new("t");
324        let x = m.__var("x").build();
325        let price = m.__param("price", 4.0);
326        m.__minimize(price * x);
327        let out = format!("{m}");
328        assert!(out.contains("min 4 x\n"), "{out}");
329        assert!(out.contains("params\n  price = 4\n"), "{out}");
330
331        price.set_param_value(7.5);
332        let out = format!("{m}");
333        assert!(out.contains("min 7.5 x\n"), "{out}");
334        assert!(out.contains("params\n  price = 7.5\n"), "{out}");
335    }
336
337    #[test]
338    fn soc_row_renders_in_model_display() {
339        let m = Model::new("t");
340        let x = m.__var("x").build();
341        let y = m.__var("y").build();
342        let t = m.__var("t").lb(0.0).build();
343        let id = m.add_soc_constraint("q1", [x, y], t);
344        assert_eq!(m.display_soc(id).to_string(), "q1: ||x, y|| <= t");
345        assert!(format!("{m}").contains("  q1: ||x, y|| <= t\n"));
346    }
347
348    #[test]
349    fn display_expr_renders_nonlinear() {
350        let m = Model::new("t");
351        let x = m.__var("x").build();
352        let y = m.__var("y").build();
353        let e = x * y - y;
354        assert_eq!(m.display_expr(e).to_string(), "-y + x * y");
355    }
356}