simplex-trace 0.3.0

Exact-arithmetic simplex (primal, Big-M, two-phase, dual) that records every tableau and pivot as data, with sensitivity analysis.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
use std::fmt;

use crate::num::{Coef, Rational};
use crate::problem::Sense;

/// Stage of the algorithm a step belongs to.
///
/// New stages (for example from future methods) may be added in minor releases.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Phase {
    /// Plain simplex or Big-M: one optimization.
    Single,
    /// Two-phase method, phase 1: minimize the sum of artificial variables.
    One,
    /// Two-phase method, phase 2: optimize the real objective.
    Two,
    /// Dual simplex: restore feasibility while keeping the objective row optimal.
    Dual,
}

impl fmt::Display for Phase {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Phase::Single => write!(f, "simplex"),
            Phase::One => write!(f, "phase 1"),
            Phase::Two => write!(f, "phase 2"),
            Phase::Dual => write!(f, "dual simplex"),
        }
    }
}

/// A full copy of the tableau at one point in time.
///
/// The tableau is always expressed in the sense of the objective being optimized, as in
/// Taha's textbook: the objective row holds `z_j - c_j` with the real costs `c_j`, and
/// `z_rhs` is the real objective value. A maximization tableau is optimal when every
/// `z_j - c_j >= 0`; a minimization tableau when every `z_j - c_j <= 0` (see
/// [`Snapshot::is_optimal`]).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Snapshot {
    /// Sense of the objective in this tableau. Phase 1 of the two-phase method is always
    /// [`Sense::Minimize`] (it minimizes `r`, the sum of the artificial variables).
    pub sense: Sense,
    /// Name of the objective row: `"z"`, or `"r"` in phase 1 of the two-phase method.
    pub objective_label: String,
    /// Column names (original variables, then slack/surplus, then artificial).
    pub columns: Vec<String>,
    /// For each constraint row, the index of its basic column.
    pub basis: Vec<usize>,
    /// Constraint rows, without the right-hand side.
    pub rows: Vec<Vec<Rational>>,
    /// Right-hand side of each constraint row.
    pub rhs: Vec<Rational>,
    /// Objective row, `z_j - c_j` per column (may contain the symbolic `M`).
    pub z: Vec<Coef>,
    /// Current value of the objective (may contain the symbolic `M` in Big-M).
    pub z_rhs: Coef,
}

impl Snapshot {
    /// `true` when no column can improve the objective: all `z_j - c_j >= 0` when
    /// maximizing, all `<= 0` when minimizing.
    pub fn is_optimal(&self) -> bool {
        match self.sense {
            Sense::Maximize => !self.z.iter().any(Coef::is_negative),
            Sense::Minimize => !self.z.iter().any(Coef::is_positive),
        }
    }

    /// `true` when every right-hand side is `>= 0`. Primal methods keep this at every
    /// iteration; the dual simplex works towards it, so its intermediate tableaus are
    /// optimal but not feasible.
    pub fn is_feasible(&self) -> bool {
        !self.rhs.iter().any(Rational::is_negative)
    }
}

/// One entry of the minimum-ratio test.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct RatioTest {
    pub row: usize,
    /// `rhs / pivot-column entry`, or `None` when the entry is `<= 0` (row not eligible).
    pub ratio: Option<Rational>,
}

/// One entry of the dual simplex ratio test, which runs over the columns of the leaving row.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct DualRatioTest {
    pub column: usize,
    /// `|z_j - c_j| / |a_rj|`, or `None` when the entry `a_rj` is `>= 0` (column not eligible).
    pub ratio: Option<Rational>,
}

/// A variable name with its value.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct NamedValue {
    pub name: String,
    pub value: Rational,
}

impl NamedValue {
    pub(crate) fn new(name: String, value: Rational) -> Self {
        NamedValue { name, value }
    }
}

/// How a run ended.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Termination {
    Optimal,
    Infeasible,
    Unbounded,
}

/// One recorded event.
///
/// New kinds of steps (for example from future methods) may be added in minor releases, so
/// matches on `Step` need a wildcard arm. With the `serde` feature, a step is a JSON object
/// whose `"type"` field is the variant name in `snake_case` (`"tableau"`, `"pivot"`, ...).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Step {
    /// The tableau at the start of an iteration (`iteration` starts at 0).
    #[non_exhaustive]
    Tableau {
        phase: Phase,
        iteration: usize,
        snapshot: Snapshot,
    },
    /// A pivot: which column enters, which row leaves, and the ratio test behind the choice.
    #[non_exhaustive]
    Pivot {
        phase: Phase,
        iteration: usize,
        entering: usize,
        entering_name: String,
        leaving_row: usize,
        leaving: usize,
        leaving_name: String,
        pivot_value: Rational,
        ratios: Vec<RatioTest>,
    },
    /// Phase 1 of the two-phase method finished.
    #[non_exhaustive]
    PhaseOneFinished {
        artificial_sum: Rational,
        feasible: bool,
    },
    /// A zero-valued artificial variable was swapped out of the basis.
    #[non_exhaustive]
    ArtificialPivotedOut {
        row: usize,
        entering: usize,
        entering_name: String,
    },
    /// A row whose basic variable was artificial and had no usable column: it was redundant.
    #[non_exhaustive]
    RedundantRowRemoved { row: usize },
    /// Big-M: the penalty row (`M` part) can no longer improve, yet some artificial variables
    /// are still positive, so the problem is infeasible. `unbounded_direction` is `true` when
    /// the run stopped at an unbounded direction rather than at an optimal tableau.
    #[non_exhaustive]
    ArtificialRemainsPositive {
        /// Each positive artificial variable and its value.
        artificials: Vec<NamedValue>,
        unbounded_direction: bool,
    },
    /// A dual simplex pivot: which row leaves (it has a negative right-hand side), which
    /// column enters, and the ratio test over the columns behind the choice.
    #[non_exhaustive]
    DualPivot {
        phase: Phase,
        iteration: usize,
        leaving_row: usize,
        leaving: usize,
        leaving_name: String,
        entering: usize,
        entering_name: String,
        pivot_value: Rational,
        ratios: Vec<DualRatioTest>,
    },
    /// Dual simplex: the leaving row has a negative right-hand side but no negative entry, so
    /// its equation (non-negative terms adding up to a negative number) cannot hold: the
    /// problem is infeasible.
    #[non_exhaustive]
    InfeasibleRow {
        row: usize,
        /// The basic variable of that row and its (negative) value.
        basic: NamedValue,
    },
    /// [`reoptimize`](crate::reoptimize): constraint `constraint` of the extended problem was
    /// appended to the final tableau as row `row`, expressed in the current basis with its
    /// own slack basic. `satisfied` tells whether the previous optimum already meets it.
    #[non_exhaustive]
    ConstraintAdded {
        constraint: usize,
        row: usize,
        satisfied: bool,
    },
    /// Final outcome.
    #[non_exhaustive]
    Finished { termination: Termination },
}

/// The ordered list of everything the solver did.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Trace {
    pub sense: Sense,
    pub steps: Vec<Step>,
}

impl Trace {
    pub(crate) fn new(sense: Sense) -> Self {
        Trace {
            sense,
            steps: Vec::new(),
        }
    }

    pub(crate) fn push(&mut self, step: Step) {
        self.steps.push(step);
    }

    /// Number of pivots performed (artificial-variable swaps included).
    pub fn pivot_count(&self) -> usize {
        self.steps
            .iter()
            .filter(|s| {
                matches!(
                    s,
                    Step::Pivot { .. } | Step::DualPivot { .. } | Step::ArtificialPivotedOut { .. }
                )
            })
            .count()
    }

    /// All tableau snapshots, in order.
    pub fn tableaus(&self) -> impl Iterator<Item = &Snapshot> {
        self.steps.iter().filter_map(|s| match s {
            Step::Tableau { snapshot, .. } => Some(snapshot),
            _ => None,
        })
    }

    /// How the run ended (the last step).
    pub fn termination(&self) -> Option<Termination> {
        match self.steps.last() {
            Some(Step::Finished { termination }) => Some(*termination),
            _ => None,
        }
    }
}

impl fmt::Display for Snapshot {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut table: Vec<Vec<String>> = Vec::new();

        let mut header = vec!["basis".to_string()];
        header.extend(self.columns.iter().cloned());
        header.push("rhs".to_string());
        table.push(header);

        for (i, row) in self.rows.iter().enumerate() {
            let mut line = vec![self.columns[self.basis[i]].clone()];
            line.extend(row.iter().map(|v| v.to_string()));
            line.push(self.rhs[i].to_string());
            table.push(line);
        }

        let mut z_line = vec![self.objective_label.clone()];
        z_line.extend(self.z.iter().map(|v| v.to_string()));
        z_line.push(self.z_rhs.to_string());
        table.push(z_line);
        write_table(f, &table)
    }
}

/// Writes rows of cells as an aligned table: the first column left-aligned, the rest
/// right-aligned, with a rule under the header row.
pub(crate) fn write_table(f: &mut fmt::Formatter<'_>, table: &[Vec<String>]) -> fmt::Result {
    let widths: Vec<usize> = (0..table[0].len())
        .map(|c| {
            table
                .iter()
                .map(|r| r[c].chars().count())
                .max()
                .unwrap_or(0)
        })
        .collect();

    for (r, line) in table.iter().enumerate() {
        let cells: Vec<String> = line
            .iter()
            .zip(&widths)
            .enumerate()
            .map(|(c, (cell, w))| {
                if c == 0 {
                    format!("{cell:<w$}")
                } else {
                    format!("{cell:>w$}")
                }
            })
            .collect();
        writeln!(f, "{}", cells.join(" | "))?;
        if r == 0 {
            let rule: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
            writeln!(f, "{}", rule.join("-+-"))?;
        }
    }
    Ok(())
}

impl fmt::Display for Trace {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Column names of the latest tableau, to label the dual ratio test.
        let mut columns: &[String] = &[];
        for step in &self.steps {
            match step {
                Step::Tableau {
                    phase,
                    iteration,
                    snapshot,
                } => {
                    columns = &snapshot.columns;
                    writeln!(f, "== {phase}, iteration {iteration} ==")?;
                    write!(f, "{snapshot}")?;
                }
                Step::Pivot {
                    entering_name,
                    leaving_name,
                    leaving_row,
                    pivot_value,
                    ratios,
                    ..
                } => {
                    let tests: Vec<String> = ratios
                        .iter()
                        .map(|r| match &r.ratio {
                            Some(v) => format!("row {}: {v}", r.row + 1),
                            None => format!("row {}: -", r.row + 1),
                        })
                        .collect();
                    writeln!(
                        f,
                        "-> {entering_name} enters, {leaving_name} leaves (row {}, pivot {pivot_value}); ratios [{}]",
                        leaving_row + 1,
                        tests.join(", ")
                    )?;
                }
                Step::PhaseOneFinished {
                    artificial_sum,
                    feasible,
                } => {
                    writeln!(
                        f,
                        "== phase 1 finished: sum of artificials = {artificial_sum} ({}) ==",
                        if *feasible { "feasible" } else { "infeasible" }
                    )?;
                }
                Step::ArtificialPivotedOut {
                    row, entering_name, ..
                } => {
                    writeln!(
                        f,
                        "-> artificial variable in row {} replaced by {entering_name}",
                        row + 1
                    )?;
                }
                Step::RedundantRowRemoved { row } => {
                    writeln!(f, "-> row {} is redundant and was removed", row + 1)?;
                }
                Step::ArtificialRemainsPositive {
                    artificials,
                    unbounded_direction,
                } => {
                    let list: Vec<String> = artificials
                        .iter()
                        .map(|a| format!("{} = {}", a.name, a.value))
                        .collect();
                    let stop = if *unbounded_direction {
                        "an unbounded direction was found"
                    } else {
                        "the tableau is optimal"
                    };
                    writeln!(
                        f,
                        "-> {stop}, but the M row cannot improve and {} still positive: no feasible solution",
                        if list.len() == 1 {
                            format!("{} is", list[0])
                        } else {
                            format!("{} are", list.join(", "))
                        }
                    )?;
                }
                Step::DualPivot {
                    leaving_name,
                    leaving_row,
                    entering_name,
                    pivot_value,
                    ratios,
                    ..
                } => {
                    let tests: Vec<String> = ratios
                        .iter()
                        .filter_map(|r| r.ratio.as_ref().map(|v| (r.column, v)))
                        .map(|(j, v)| {
                            format!("{}: {v}", columns.get(j).map_or("?", String::as_str))
                        })
                        .collect();
                    writeln!(
                        f,
                        "-> {leaving_name} leaves (row {}), {entering_name} enters (pivot {pivot_value}); ratios [{}]",
                        leaving_row + 1,
                        tests.join(", ")
                    )?;
                }
                Step::InfeasibleRow { row, basic } => {
                    writeln!(
                        f,
                        "-> row {} has {} = {} but no negative entry: no feasible solution",
                        row + 1,
                        basic.name,
                        basic.value
                    )?;
                }
                Step::ConstraintAdded {
                    constraint,
                    row,
                    satisfied,
                } => {
                    writeln!(
                        f,
                        "-> constraint {} added as row {} ({} by the current solution)",
                        constraint + 1,
                        row + 1,
                        if *satisfied { "satisfied" } else { "violated" }
                    )?;
                }
                Step::Finished { termination } => writeln!(f, "== {termination:?} ==")?,
            }
            writeln!(f)?;
        }
        Ok(())
    }
}