Skip to main content

simplex_trace/
trace.rs

1use std::fmt;
2
3use crate::num::{Coef, Rational};
4use crate::problem::Sense;
5
6/// Stage of the algorithm a step belongs to.
7///
8/// New stages (for example from future methods) may be added in minor releases.
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum Phase {
14    /// Plain simplex or Big-M: one optimization.
15    Single,
16    /// Two-phase method, phase 1: minimize the sum of artificial variables.
17    One,
18    /// Two-phase method, phase 2: optimize the real objective.
19    Two,
20    /// Dual simplex: restore feasibility while keeping the objective row optimal.
21    Dual,
22}
23
24impl fmt::Display for Phase {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Phase::Single => write!(f, "simplex"),
28            Phase::One => write!(f, "phase 1"),
29            Phase::Two => write!(f, "phase 2"),
30            Phase::Dual => write!(f, "dual simplex"),
31        }
32    }
33}
34
35/// A full copy of the tableau at one point in time.
36///
37/// The tableau is always expressed in the sense of the objective being optimized, as in
38/// Taha's textbook: the objective row holds `z_j - c_j` with the real costs `c_j`, and
39/// `z_rhs` is the real objective value. A maximization tableau is optimal when every
40/// `z_j - c_j >= 0`; a minimization tableau when every `z_j - c_j <= 0` (see
41/// [`Snapshot::is_optimal`]).
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43#[derive(Clone, Debug, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct Snapshot {
46    /// Sense of the objective in this tableau. Phase 1 of the two-phase method is always
47    /// [`Sense::Minimize`] (it minimizes `r`, the sum of the artificial variables).
48    pub sense: Sense,
49    /// Name of the objective row: `"z"`, or `"r"` in phase 1 of the two-phase method.
50    pub objective_label: String,
51    /// Column names (original variables, then slack/surplus, then artificial).
52    pub columns: Vec<String>,
53    /// For each constraint row, the index of its basic column.
54    pub basis: Vec<usize>,
55    /// Constraint rows, without the right-hand side.
56    pub rows: Vec<Vec<Rational>>,
57    /// Right-hand side of each constraint row.
58    pub rhs: Vec<Rational>,
59    /// Objective row, `z_j - c_j` per column (may contain the symbolic `M`).
60    pub z: Vec<Coef>,
61    /// Current value of the objective (may contain the symbolic `M` in Big-M).
62    pub z_rhs: Coef,
63}
64
65impl Snapshot {
66    /// `true` when no column can improve the objective: all `z_j - c_j >= 0` when
67    /// maximizing, all `<= 0` when minimizing.
68    pub fn is_optimal(&self) -> bool {
69        match self.sense {
70            Sense::Maximize => !self.z.iter().any(Coef::is_negative),
71            Sense::Minimize => !self.z.iter().any(Coef::is_positive),
72        }
73    }
74
75    /// `true` when every right-hand side is `>= 0`. Primal methods keep this at every
76    /// iteration; the dual simplex works towards it, so its intermediate tableaus are
77    /// optimal but not feasible.
78    pub fn is_feasible(&self) -> bool {
79        !self.rhs.iter().any(Rational::is_negative)
80    }
81}
82
83/// One entry of the minimum-ratio test.
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85#[derive(Clone, Debug, PartialEq, Eq)]
86#[non_exhaustive]
87pub struct RatioTest {
88    pub row: usize,
89    /// `rhs / pivot-column entry`, or `None` when the entry is `<= 0` (row not eligible).
90    pub ratio: Option<Rational>,
91}
92
93/// One entry of the dual simplex ratio test, which runs over the columns of the leaving row.
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95#[derive(Clone, Debug, PartialEq, Eq)]
96#[non_exhaustive]
97pub struct DualRatioTest {
98    pub column: usize,
99    /// `|z_j - c_j| / |a_rj|`, or `None` when the entry `a_rj` is `>= 0` (column not eligible).
100    pub ratio: Option<Rational>,
101}
102
103/// A variable name with its value.
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105#[derive(Clone, Debug, PartialEq, Eq)]
106#[non_exhaustive]
107pub struct NamedValue {
108    pub name: String,
109    pub value: Rational,
110}
111
112impl NamedValue {
113    pub(crate) fn new(name: String, value: Rational) -> Self {
114        NamedValue { name, value }
115    }
116}
117
118/// How a run ended.
119#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
120#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum Termination {
123    Optimal,
124    Infeasible,
125    Unbounded,
126}
127
128/// One recorded event.
129///
130/// New kinds of steps (for example from future methods) may be added in minor releases, so
131/// matches on `Step` need a wildcard arm. With the `serde` feature, a step is a JSON object
132/// whose `"type"` field is the variant name in `snake_case` (`"tableau"`, `"pivot"`, ...).
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))]
135#[derive(Clone, Debug, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum Step {
138    /// The tableau at the start of an iteration (`iteration` starts at 0).
139    #[non_exhaustive]
140    Tableau {
141        phase: Phase,
142        iteration: usize,
143        snapshot: Snapshot,
144    },
145    /// A pivot: which column enters, which row leaves, and the ratio test behind the choice.
146    #[non_exhaustive]
147    Pivot {
148        phase: Phase,
149        iteration: usize,
150        entering: usize,
151        entering_name: String,
152        leaving_row: usize,
153        leaving: usize,
154        leaving_name: String,
155        pivot_value: Rational,
156        ratios: Vec<RatioTest>,
157    },
158    /// Phase 1 of the two-phase method finished.
159    #[non_exhaustive]
160    PhaseOneFinished {
161        artificial_sum: Rational,
162        feasible: bool,
163    },
164    /// A zero-valued artificial variable was swapped out of the basis.
165    #[non_exhaustive]
166    ArtificialPivotedOut {
167        row: usize,
168        entering: usize,
169        entering_name: String,
170    },
171    /// A row whose basic variable was artificial and had no usable column: it was redundant.
172    #[non_exhaustive]
173    RedundantRowRemoved { row: usize },
174    /// Big-M: the penalty row (`M` part) can no longer improve, yet some artificial variables
175    /// are still positive, so the problem is infeasible. `unbounded_direction` is `true` when
176    /// the run stopped at an unbounded direction rather than at an optimal tableau.
177    #[non_exhaustive]
178    ArtificialRemainsPositive {
179        /// Each positive artificial variable and its value.
180        artificials: Vec<NamedValue>,
181        unbounded_direction: bool,
182    },
183    /// A dual simplex pivot: which row leaves (it has a negative right-hand side), which
184    /// column enters, and the ratio test over the columns behind the choice.
185    #[non_exhaustive]
186    DualPivot {
187        phase: Phase,
188        iteration: usize,
189        leaving_row: usize,
190        leaving: usize,
191        leaving_name: String,
192        entering: usize,
193        entering_name: String,
194        pivot_value: Rational,
195        ratios: Vec<DualRatioTest>,
196    },
197    /// Dual simplex: the leaving row has a negative right-hand side but no negative entry, so
198    /// its equation (non-negative terms adding up to a negative number) cannot hold: the
199    /// problem is infeasible.
200    #[non_exhaustive]
201    InfeasibleRow {
202        row: usize,
203        /// The basic variable of that row and its (negative) value.
204        basic: NamedValue,
205    },
206    /// [`reoptimize`](crate::reoptimize): constraint `constraint` of the extended problem was
207    /// appended to the final tableau as row `row`, expressed in the current basis with its
208    /// own slack basic. `satisfied` tells whether the previous optimum already meets it.
209    #[non_exhaustive]
210    ConstraintAdded {
211        constraint: usize,
212        row: usize,
213        satisfied: bool,
214    },
215    /// Final outcome.
216    #[non_exhaustive]
217    Finished { termination: Termination },
218}
219
220/// The ordered list of everything the solver did.
221#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
222#[derive(Clone, Debug, PartialEq, Eq)]
223#[non_exhaustive]
224pub struct Trace {
225    pub sense: Sense,
226    pub steps: Vec<Step>,
227}
228
229impl Trace {
230    pub(crate) fn new(sense: Sense) -> Self {
231        Trace {
232            sense,
233            steps: Vec::new(),
234        }
235    }
236
237    pub(crate) fn push(&mut self, step: Step) {
238        self.steps.push(step);
239    }
240
241    /// Number of pivots performed (artificial-variable swaps included).
242    pub fn pivot_count(&self) -> usize {
243        self.steps
244            .iter()
245            .filter(|s| {
246                matches!(
247                    s,
248                    Step::Pivot { .. } | Step::DualPivot { .. } | Step::ArtificialPivotedOut { .. }
249                )
250            })
251            .count()
252    }
253
254    /// All tableau snapshots, in order.
255    pub fn tableaus(&self) -> impl Iterator<Item = &Snapshot> {
256        self.steps.iter().filter_map(|s| match s {
257            Step::Tableau { snapshot, .. } => Some(snapshot),
258            _ => None,
259        })
260    }
261
262    /// How the run ended (the last step).
263    pub fn termination(&self) -> Option<Termination> {
264        match self.steps.last() {
265            Some(Step::Finished { termination }) => Some(*termination),
266            _ => None,
267        }
268    }
269}
270
271impl fmt::Display for Snapshot {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        let mut table: Vec<Vec<String>> = Vec::new();
274
275        let mut header = vec!["basis".to_string()];
276        header.extend(self.columns.iter().cloned());
277        header.push("rhs".to_string());
278        table.push(header);
279
280        for (i, row) in self.rows.iter().enumerate() {
281            let mut line = vec![self.columns[self.basis[i]].clone()];
282            line.extend(row.iter().map(|v| v.to_string()));
283            line.push(self.rhs[i].to_string());
284            table.push(line);
285        }
286
287        let mut z_line = vec![self.objective_label.clone()];
288        z_line.extend(self.z.iter().map(|v| v.to_string()));
289        z_line.push(self.z_rhs.to_string());
290        table.push(z_line);
291        write_table(f, &table)
292    }
293}
294
295/// Writes rows of cells as an aligned table: the first column left-aligned, the rest
296/// right-aligned, with a rule under the header row.
297pub(crate) fn write_table(f: &mut fmt::Formatter<'_>, table: &[Vec<String>]) -> fmt::Result {
298    let widths: Vec<usize> = (0..table[0].len())
299        .map(|c| {
300            table
301                .iter()
302                .map(|r| r[c].chars().count())
303                .max()
304                .unwrap_or(0)
305        })
306        .collect();
307
308    for (r, line) in table.iter().enumerate() {
309        let cells: Vec<String> = line
310            .iter()
311            .zip(&widths)
312            .enumerate()
313            .map(|(c, (cell, w))| {
314                if c == 0 {
315                    format!("{cell:<w$}")
316                } else {
317                    format!("{cell:>w$}")
318                }
319            })
320            .collect();
321        writeln!(f, "{}", cells.join(" | "))?;
322        if r == 0 {
323            let rule: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
324            writeln!(f, "{}", rule.join("-+-"))?;
325        }
326    }
327    Ok(())
328}
329
330impl fmt::Display for Trace {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        // Column names of the latest tableau, to label the dual ratio test.
333        let mut columns: &[String] = &[];
334        for step in &self.steps {
335            match step {
336                Step::Tableau {
337                    phase,
338                    iteration,
339                    snapshot,
340                } => {
341                    columns = &snapshot.columns;
342                    writeln!(f, "== {phase}, iteration {iteration} ==")?;
343                    write!(f, "{snapshot}")?;
344                }
345                Step::Pivot {
346                    entering_name,
347                    leaving_name,
348                    leaving_row,
349                    pivot_value,
350                    ratios,
351                    ..
352                } => {
353                    let tests: Vec<String> = ratios
354                        .iter()
355                        .map(|r| match &r.ratio {
356                            Some(v) => format!("row {}: {v}", r.row + 1),
357                            None => format!("row {}: -", r.row + 1),
358                        })
359                        .collect();
360                    writeln!(
361                        f,
362                        "-> {entering_name} enters, {leaving_name} leaves (row {}, pivot {pivot_value}); ratios [{}]",
363                        leaving_row + 1,
364                        tests.join(", ")
365                    )?;
366                }
367                Step::PhaseOneFinished {
368                    artificial_sum,
369                    feasible,
370                } => {
371                    writeln!(
372                        f,
373                        "== phase 1 finished: sum of artificials = {artificial_sum} ({}) ==",
374                        if *feasible { "feasible" } else { "infeasible" }
375                    )?;
376                }
377                Step::ArtificialPivotedOut {
378                    row, entering_name, ..
379                } => {
380                    writeln!(
381                        f,
382                        "-> artificial variable in row {} replaced by {entering_name}",
383                        row + 1
384                    )?;
385                }
386                Step::RedundantRowRemoved { row } => {
387                    writeln!(f, "-> row {} is redundant and was removed", row + 1)?;
388                }
389                Step::ArtificialRemainsPositive {
390                    artificials,
391                    unbounded_direction,
392                } => {
393                    let list: Vec<String> = artificials
394                        .iter()
395                        .map(|a| format!("{} = {}", a.name, a.value))
396                        .collect();
397                    let stop = if *unbounded_direction {
398                        "an unbounded direction was found"
399                    } else {
400                        "the tableau is optimal"
401                    };
402                    writeln!(
403                        f,
404                        "-> {stop}, but the M row cannot improve and {} still positive: no feasible solution",
405                        if list.len() == 1 {
406                            format!("{} is", list[0])
407                        } else {
408                            format!("{} are", list.join(", "))
409                        }
410                    )?;
411                }
412                Step::DualPivot {
413                    leaving_name,
414                    leaving_row,
415                    entering_name,
416                    pivot_value,
417                    ratios,
418                    ..
419                } => {
420                    let tests: Vec<String> = ratios
421                        .iter()
422                        .filter_map(|r| r.ratio.as_ref().map(|v| (r.column, v)))
423                        .map(|(j, v)| {
424                            format!("{}: {v}", columns.get(j).map_or("?", String::as_str))
425                        })
426                        .collect();
427                    writeln!(
428                        f,
429                        "-> {leaving_name} leaves (row {}), {entering_name} enters (pivot {pivot_value}); ratios [{}]",
430                        leaving_row + 1,
431                        tests.join(", ")
432                    )?;
433                }
434                Step::InfeasibleRow { row, basic } => {
435                    writeln!(
436                        f,
437                        "-> row {} has {} = {} but no negative entry: no feasible solution",
438                        row + 1,
439                        basic.name,
440                        basic.value
441                    )?;
442                }
443                Step::ConstraintAdded {
444                    constraint,
445                    row,
446                    satisfied,
447                } => {
448                    writeln!(
449                        f,
450                        "-> constraint {} added as row {} ({} by the current solution)",
451                        constraint + 1,
452                        row + 1,
453                        if *satisfied { "satisfied" } else { "violated" }
454                    )?;
455                }
456                Step::Finished { termination } => writeln!(f, "== {termination:?} ==")?,
457            }
458            writeln!(f)?;
459        }
460        Ok(())
461    }
462}