Skip to main content

simplex_trace/
solver.rs

1use num_traits::{One, Signed, Zero};
2
3use crate::error::Error;
4use crate::num::{Coef, Rational};
5use crate::problem::{Problem, Relation, Sense};
6use crate::sensitivity::{self, Cells, Sensitivity, StandardForm};
7use crate::trace::{NamedValue, Phase, RatioTest, Snapshot, Step, Termination, Trace};
8
9/// Which variant of the simplex method to run.
10///
11/// More methods (such as the dual simplex) may be added in minor releases.
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Method {
17    /// Primal simplex. Only for problems whose constraints are all `<=` with a non-negative
18    /// right-hand side after normalization (the slacks give the starting basis).
19    Simplex,
20    /// Big-M method, with `M` kept symbolic (no numeric value is ever chosen).
21    BigM,
22    /// Two-phase method. Falls back to a single phase when no artificial variable is needed.
23    TwoPhase,
24}
25
26/// Rule for choosing the entering column.
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum PivotRule {
32    /// The textbook rule: the most negative `z_j - c_j` when maximizing, the most positive
33    /// when minimizing. Ties go to the lowest column index.
34    Dantzig,
35    /// The lowest-index column that improves the objective. Slower, but never cycles.
36    Bland,
37}
38
39/// Solver settings.
40///
41/// ```
42/// use simplex_trace::{Options, PivotRule};
43///
44/// let options = Options::default().pivot_rule(PivotRule::Bland).max_iterations(500);
45/// assert_eq!(options.pivot_rule, PivotRule::Bland);
46/// ```
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48#[cfg_attr(feature = "serde", serde(default))]
49#[derive(Clone, Debug, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct Options {
52    pub pivot_rule: PivotRule,
53    /// Maximum number of pivots per phase before giving up with [`Error::IterationLimit`].
54    pub max_iterations: usize,
55}
56
57impl Default for Options {
58    fn default() -> Self {
59        Options {
60            pivot_rule: PivotRule::Dantzig,
61            max_iterations: 10_000,
62        }
63    }
64}
65
66impl Options {
67    pub fn pivot_rule(mut self, rule: PivotRule) -> Self {
68        self.pivot_rule = rule;
69        self
70    }
71
72    pub fn max_iterations(mut self, n: usize) -> Self {
73        self.max_iterations = n;
74        self
75    }
76}
77
78/// Final result of a solve.
79///
80/// With the `serde` feature: `{"status": "optimal", "objective": "17/5", "values": ["2/5", "9/5"]}`,
81/// `{"status": "infeasible"}` or `{"status": "unbounded"}`.
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum Status {
86    /// `values` holds one entry per original variable; `objective` is in the problem's own sense.
87    Optimal {
88        #[cfg_attr(feature = "serde", serde(with = "crate::serde_rational::one"))]
89        objective: Rational,
90        #[cfg_attr(feature = "serde", serde(with = "crate::serde_rational::vec"))]
91        values: Vec<Rational>,
92    },
93    Infeasible,
94    Unbounded,
95}
96
97/// The status, the sensitivity analysis (when optimal) and the full step-by-step trace.
98///
99/// With the `serde` feature the status fields sit at the top level:
100/// `{"status": "optimal", "objective": "17/5", "values": [...], "sensitivity": {...}, "trace": {...}}`.
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102#[derive(Clone, Debug, PartialEq, Eq)]
103#[non_exhaustive]
104pub struct Solution {
105    #[cfg_attr(feature = "serde", serde(flatten))]
106    pub status: Status,
107    /// Shadow prices, reduced costs and ranges, computed for every optimal solution.
108    ///
109    /// `None` exactly when the status is not optimal.
110    #[cfg_attr(
111        feature = "serde",
112        serde(default, skip_serializing_if = "Option::is_none")
113    )]
114    pub sensitivity: Option<Sensitivity>,
115    pub trace: Trace,
116}
117
118/// Solves `problem` with the given method, recording every step.
119///
120/// ```
121/// use simplex_trace::{solve, Method, Options, Problem, Relation, Status, ratio};
122///
123/// let problem = Problem::maximize([3, 5])
124///     .subject_to([1, 0], Relation::Le, 4)
125///     .subject_to([0, 2], Relation::Le, 12)
126///     .subject_to([3, 2], Relation::Le, 18);
127///
128/// let solution = solve(&problem, Method::Simplex, &Options::default()).unwrap();
129/// assert_eq!(
130///     solution.status,
131///     Status::Optimal { objective: ratio(36, 1), values: vec![ratio(2, 1), ratio(6, 1)] }
132/// );
133/// println!("{}", solution.trace);
134/// ```
135pub fn solve(problem: &Problem, method: Method, options: &Options) -> Result<Solution, Error> {
136    problem.validate()?;
137    let mut trace = Trace::new(problem.sense);
138    let mut t = Tableau::standard_form(problem);
139    let sense = problem.sense;
140    let costs = &problem.objective;
141
142    let status = match method {
143        Method::Simplex if t.has_artificials() => return Err(Error::ArtificialVariablesRequired),
144        Method::Simplex => t.single_phase(sense, costs, Coef::zero(), options, &mut trace)?,
145        Method::TwoPhase if !t.has_artificials() => {
146            t.single_phase(sense, costs, Coef::zero(), options, &mut trace)?
147        }
148        Method::TwoPhase => t.two_phase(sense, costs, options, &mut trace)?,
149        Method::BigM => {
150            // The penalty always works against the objective: -M when maximizing, +M when minimizing.
151            let penalty = match sense {
152                Sense::Maximize => Coef::big_m(-Rational::one()),
153                Sense::Minimize => Coef::big_m(Rational::one()),
154            };
155            t.single_phase(sense, costs, penalty, options, &mut trace)?
156        }
157    };
158
159    let sensitivity = match status {
160        End::Optimal => t.sensitivity(problem),
161        _ => None,
162    };
163    let (status, termination) = match status {
164        End::Optimal => {
165            let status = Status::Optimal {
166                objective: t.z_rhs.c.clone(),
167                values: t.values(problem.num_vars()),
168            };
169            (status, Termination::Optimal)
170        }
171        End::Infeasible => (Status::Infeasible, Termination::Infeasible),
172        End::Unbounded => (Status::Unbounded, Termination::Unbounded),
173    };
174    trace.push(Step::Finished { termination });
175    Ok(Solution {
176        status,
177        sensitivity,
178        trace,
179    })
180}
181
182enum End {
183    Optimal,
184    Infeasible,
185    Unbounded,
186}
187
188#[derive(Clone)]
189struct Tableau {
190    /// Sense of the objective currently in the `z` row (phase 1 is always `Minimize`).
191    sense: Sense,
192    objective_label: &'static str,
193    names: Vec<String>,
194    artificial: Vec<bool>,
195    /// For each artificial column, the constraint it was added to.
196    artificial_owner: Vec<Option<usize>>,
197    /// Redundant equalities whose row was removed (by constraint index).
198    removed: Vec<usize>,
199    rows: Vec<Vec<Rational>>,
200    rhs: Vec<Rational>,
201    basis: Vec<usize>,
202    z: Vec<Coef>,
203    z_rhs: Coef,
204}
205
206impl Tableau {
207    /// Normalizes every row to a non-negative right-hand side and adds one slack (`<=`),
208    /// surplus (`>=`) and/or artificial (`>=`, `=`) column per constraint.
209    /// Column order: original variables, slack/surplus, artificial.
210    fn standard_form(problem: &Problem) -> Self {
211        let n = problem.num_vars();
212        let normalized: Vec<(Vec<Rational>, Relation, Rational)> = problem
213            .constraints
214            .iter()
215            .map(|c| {
216                if c.rhs.is_negative() {
217                    let flipped = match c.relation {
218                        Relation::Le => Relation::Ge,
219                        Relation::Ge => Relation::Le,
220                        Relation::Eq => Relation::Eq,
221                    };
222                    (
223                        c.coeffs.iter().map(|v| -v.clone()).collect(),
224                        flipped,
225                        -c.rhs.clone(),
226                    )
227                } else {
228                    (c.coeffs.clone(), c.relation, c.rhs.clone())
229                }
230            })
231            .collect();
232
233        let mut names: Vec<String> = (0..n).map(|j| problem.var_name(j)).collect();
234        let mut slack_col = vec![None; normalized.len()];
235        for (i, (_, relation, _)) in normalized.iter().enumerate() {
236            if *relation != Relation::Eq {
237                slack_col[i] = Some(names.len());
238                names.push(format!("s{}", i + 1));
239            }
240        }
241        let first_artificial = names.len();
242        let mut art_col = vec![None; normalized.len()];
243        let mut artificial_owner = vec![None; first_artificial];
244        for (i, (_, relation, _)) in normalized.iter().enumerate() {
245            if *relation != Relation::Le {
246                art_col[i] = Some(names.len());
247                artificial_owner.push(Some(i));
248                names.push(format!("a{}", i + 1));
249            }
250        }
251
252        let width = names.len();
253        let mut rows = Vec::with_capacity(normalized.len());
254        let mut rhs = Vec::with_capacity(normalized.len());
255        let mut basis = Vec::with_capacity(normalized.len());
256        for (i, (coeffs, relation, b)) in normalized.into_iter().enumerate() {
257            let mut row = vec![Rational::zero(); width];
258            row[..n].clone_from_slice(&coeffs);
259            if let Some(s) = slack_col[i] {
260                row[s] = if relation == Relation::Le {
261                    Rational::one()
262                } else {
263                    -Rational::one()
264                };
265            }
266            if let Some(a) = art_col[i] {
267                row[a] = Rational::one();
268            }
269            basis.push(
270                art_col[i]
271                    .or(slack_col[i])
272                    .expect("every row has a basic column"),
273            );
274            rows.push(row);
275            rhs.push(b);
276        }
277
278        Tableau {
279            sense: problem.sense,
280            objective_label: "z",
281            artificial: (0..width).map(|j| j >= first_artificial).collect(),
282            artificial_owner,
283            removed: Vec::new(),
284            names,
285            rows,
286            rhs,
287            basis,
288            z: vec![Coef::zero(); width],
289            z_rhs: Coef::zero(),
290        }
291    }
292
293    fn has_artificials(&self) -> bool {
294        self.artificial.iter().any(|&a| a)
295    }
296
297    fn snapshot(&self) -> Snapshot {
298        Snapshot {
299            sense: self.sense,
300            objective_label: self.objective_label.to_string(),
301            columns: self.names.clone(),
302            basis: self.basis.clone(),
303            rows: self.rows.clone(),
304            rhs: self.rhs.clone(),
305            z: self.z.clone(),
306            z_rhs: self.z_rhs.clone(),
307        }
308    }
309
310    /// Sets the objective row to `z_j - c_j` for the objective `sense` with costs `costs`
311    /// (original variables; slack/surplus cost 0, artificials cost `artificial_cost`), then
312    /// prices out the basic columns so the row is consistent with the current basis.
313    fn set_objective(
314        &mut self,
315        sense: Sense,
316        label: &'static str,
317        costs: &[Rational],
318        artificial_cost: &Coef,
319    ) {
320        self.sense = sense;
321        self.objective_label = label;
322        self.z = (0..self.names.len())
323            .map(|j| {
324                if j < costs.len() {
325                    -Coef::constant(costs[j].clone())
326                } else if self.artificial[j] {
327                    -artificial_cost.clone()
328                } else {
329                    Coef::zero()
330                }
331            })
332            .collect();
333        self.z_rhs = Coef::zero();
334        for i in 0..self.rows.len() {
335            let factor = self.z[self.basis[i]].clone();
336            if factor.is_zero() {
337                continue;
338            }
339            for (zj, aij) in self.z.iter_mut().zip(&self.rows[i]) {
340                zj.sub_mul(&factor, aij);
341            }
342            self.z_rhs.sub_mul(&factor, &self.rhs[i]);
343        }
344    }
345
346    fn pivot(&mut self, row: usize, col: usize) {
347        let pivot = self.rows[row][col].clone();
348        for v in self.rows[row].iter_mut() {
349            *v /= &pivot;
350        }
351        self.rhs[row] /= &pivot;
352
353        let pivot_row = self.rows[row].clone();
354        let pivot_rhs = self.rhs[row].clone();
355        for i in 0..self.rows.len() {
356            if i == row || self.rows[i][col].is_zero() {
357                continue;
358            }
359            let factor = self.rows[i][col].clone();
360            for (v, p) in self.rows[i].iter_mut().zip(&pivot_row) {
361                *v -= &factor * p;
362            }
363            self.rhs[i] -= &factor * &pivot_rhs;
364        }
365
366        let factor = self.z[col].clone();
367        if !factor.is_zero() {
368            for (zj, p) in self.z.iter_mut().zip(&pivot_row) {
369                zj.sub_mul(&factor, p);
370            }
371            self.z_rhs.sub_mul(&factor, &pivot_rhs);
372        }
373        self.basis[row] = col;
374    }
375
376    /// Maximizing: a column improves when `z_j - c_j < 0`, Dantzig picks the most negative.
377    /// Minimizing: when `z_j - c_j > 0`, Dantzig picks the most positive.
378    ///
379    /// With Big-M the comparison is lexicographic: the `M` part (the penalty on artificials)
380    /// is improved first, the real objective only once the penalty cannot improve. Dantzig
381    /// gets this from the ordering of [`Coef`]; Bland picks the lowest-index column that
382    /// improves the `M` part, and only if none does, the lowest-index column that improves
383    /// the constant part. That is Bland applied to two problems in sequence (the `M` row does
384    /// not change while columns with no `M` part enter), so it still cannot cycle.
385    fn entering(&self, rule: PivotRule) -> Option<usize> {
386        let sense = self.sense;
387        let improves = |j: &usize| match sense {
388            Sense::Maximize => self.z[*j].is_negative(),
389            Sense::Minimize => self.z[*j].is_positive(),
390        };
391        let improves_penalty = |j: &usize| match sense {
392            Sense::Maximize => self.z[*j].m.is_negative(),
393            Sense::Minimize => self.z[*j].m.is_positive(),
394        };
395        let mut candidates = (0..self.z.len()).filter(improves);
396        match rule {
397            PivotRule::Bland => (0..self.z.len())
398                .find(improves_penalty)
399                .or_else(|| candidates.next()),
400            // `min_by` keeps the first of equal elements, so ties go to the lowest index.
401            PivotRule::Dantzig => candidates.min_by(|&a, &b| match sense {
402                Sense::Maximize => self.z[a].cmp(&self.z[b]),
403                Sense::Minimize => self.z[b].cmp(&self.z[a]),
404            }),
405        }
406    }
407
408    /// Minimum-ratio test. Ties go to the row whose basic variable has the lowest index.
409    fn ratio_test(&self, col: usize) -> (Vec<RatioTest>, Option<usize>) {
410        let ratios: Vec<RatioTest> = (0..self.rows.len())
411            .map(|i| {
412                let a = &self.rows[i][col];
413                let ratio = if a.is_positive() {
414                    Some(&self.rhs[i] / a)
415                } else {
416                    None
417                };
418                RatioTest { row: i, ratio }
419            })
420            .collect();
421        let leaving = ratios
422            .iter()
423            .filter_map(|r| r.ratio.as_ref().map(|v| (v, self.basis[r.row], r.row)))
424            .min()
425            .map(|(_, _, row)| row);
426        (ratios, leaving)
427    }
428
429    /// Runs simplex iterations from the current (feasible) basis until optimal or unbounded.
430    fn optimize(
431        &mut self,
432        phase: Phase,
433        options: &Options,
434        trace: &mut Trace,
435    ) -> Result<End, Error> {
436        let mut iteration = 0;
437        loop {
438            trace.push(Step::Tableau {
439                phase,
440                iteration,
441                snapshot: self.snapshot(),
442            });
443
444            let Some(entering) = self.entering(options.pivot_rule) else {
445                return Ok(End::Optimal);
446            };
447            let (ratios, leaving_row) = self.ratio_test(entering);
448            let Some(leaving_row) = leaving_row else {
449                return Ok(End::Unbounded);
450            };
451            if iteration == options.max_iterations {
452                return Err(Error::IterationLimit(options.max_iterations));
453            }
454
455            let leaving = self.basis[leaving_row];
456            trace.push(Step::Pivot {
457                phase,
458                iteration,
459                entering,
460                entering_name: self.names[entering].clone(),
461                leaving_row,
462                leaving,
463                leaving_name: self.names[leaving].clone(),
464                pivot_value: self.rows[leaving_row][entering].clone(),
465                ratios,
466            });
467            self.pivot(leaving_row, entering);
468            iteration += 1;
469        }
470    }
471
472    /// Basic artificial variables with a positive value.
473    fn positive_artificials(&self) -> Vec<NamedValue> {
474        (0..self.rows.len())
475            .filter(|&i| self.artificial[self.basis[i]] && self.rhs[i].is_positive())
476            .map(|i| NamedValue::new(self.names[self.basis[i]].clone(), self.rhs[i].clone()))
477            .collect()
478    }
479
480    /// Plain simplex (`artificial_cost == 0`, no artificials present) or Big-M (`∓M`).
481    fn single_phase(
482        &mut self,
483        sense: Sense,
484        costs: &[Rational],
485        artificial_cost: Coef,
486        options: &Options,
487        trace: &mut Trace,
488    ) -> Result<End, Error> {
489        self.set_objective(sense, "z", costs, &artificial_cost);
490        let end = self.optimize(Phase::Single, options, trace)?;
491
492        // Big-M stops in two ways: an optimal tableau, or an unbounded direction. The entering
493        // column of an unbounded direction cannot improve the `M` part (the penalty is
494        // bounded), and columns that improve the `M` part always enter first. So in both cases
495        // the penalty can no longer improve: the sum of artificials is at its minimum. If an
496        // artificial is still positive, no feasible point exists.
497        let positive = self.positive_artificials();
498        if positive.is_empty() {
499            return Ok(end);
500        }
501        trace.push(Step::ArtificialRemainsPositive {
502            artificials: positive,
503            unbounded_direction: matches!(end, End::Unbounded),
504        });
505        Ok(End::Infeasible)
506    }
507
508    fn two_phase(
509        &mut self,
510        sense: Sense,
511        costs: &[Rational],
512        options: &Options,
513        trace: &mut Trace,
514    ) -> Result<End, Error> {
515        // Phase 1: minimize r = sum of artificials (each artificial costs 1, the rest 0).
516        let zeros = vec![Rational::zero(); costs.len()];
517        self.set_objective(
518            Sense::Minimize,
519            "r",
520            &zeros,
521            &Coef::constant(Rational::one()),
522        );
523        // r is bounded below by 0, so phase 1 always ends optimal.
524        self.optimize(Phase::One, options, trace)?;
525
526        let artificial_sum = self.z_rhs.c.clone();
527        let feasible = artificial_sum.is_zero();
528        trace.push(Step::PhaseOneFinished {
529            artificial_sum,
530            feasible,
531        });
532        if !feasible {
533            return Ok(End::Infeasible);
534        }
535
536        self.drop_artificials(trace);
537        self.set_objective(sense, "z", costs, &Coef::zero());
538        self.optimize(Phase::Two, options, trace)
539    }
540
541    /// After a feasible phase 1: swaps zero-valued artificials out of the basis (or removes
542    /// their row if it is redundant), then deletes the artificial columns.
543    fn drop_artificials(&mut self, trace: &mut Trace) {
544        let mut i = 0;
545        while i < self.rows.len() {
546            if !self.artificial[self.basis[i]] {
547                i += 1;
548                continue;
549            }
550            let replacement =
551                (0..self.names.len()).find(|&j| !self.artificial[j] && !self.rows[i][j].is_zero());
552            match replacement {
553                Some(j) => {
554                    self.pivot(i, j);
555                    trace.push(Step::ArtificialPivotedOut {
556                        row: i,
557                        entering: j,
558                        entering_name: self.names[j].clone(),
559                    });
560                    i += 1;
561                }
562                None => {
563                    // The basic artificial's own constraint is the redundant one: its row
564                    // is the only one with a 1 under that artificial's column.
565                    self.removed
566                        .push(self.artificial_owner[self.basis[i]].expect("artificial column"));
567                    self.rows.remove(i);
568                    self.rhs.remove(i);
569                    self.basis.remove(i);
570                    trace.push(Step::RedundantRowRemoved { row: i });
571                }
572            }
573        }
574
575        let keep: Vec<usize> = (0..self.names.len())
576            .filter(|&j| !self.artificial[j])
577            .collect();
578        let mut new_index = vec![usize::MAX; self.names.len()];
579        for (new, &old) in keep.iter().enumerate() {
580            new_index[old] = new;
581        }
582        self.names = keep.iter().map(|&j| self.names[j].clone()).collect();
583        self.artificial = vec![false; keep.len()];
584        self.artificial_owner = vec![None; keep.len()];
585        for row in self.rows.iter_mut() {
586            *row = keep.iter().map(|&j| row[j].clone()).collect();
587        }
588        for b in self.basis.iter_mut() {
589            *b = new_index[*b];
590        }
591        self.z = vec![Coef::zero(); keep.len()];
592        self.z_rhs = Coef::zero();
593    }
594
595    /// Sensitivity of the final (optimal) basis.
596    ///
597    /// With Big-M a basic artificial may remain at zero. If its row is zero in every real
598    /// column it is a redundant equality and is dropped, as the two-phase method does.
599    /// Otherwise the basis is not one of the real problem: on a copy, the artificials are
600    /// pivoted out and the real objective re-optimized. Those pivots are all degenerate (the
601    /// solution is already optimal, so no pivot can move it), so the analysis still describes
602    /// the reported solution.
603    fn sensitivity(&self, problem: &Problem) -> Option<Sensitivity> {
604        let mut removed = self.removed.clone();
605        let mut basis = Vec::with_capacity(self.basis.len());
606        for (i, &b) in self.basis.iter().enumerate() {
607            if !self.artificial[b] {
608                basis.push(b);
609                continue;
610            }
611            let redundant =
612                (0..self.names.len()).all(|j| self.artificial[j] || self.rows[i][j].is_zero());
613            if !redundant {
614                return self.rebased_sensitivity(problem);
615            }
616            removed.push(self.artificial_owner[b].expect("artificial column"));
617        }
618        let cells = Cells::FinalTableau {
619            artificial_columns: self.has_artificials(),
620        };
621        self.analyze(problem, &basis, &removed, cells)
622    }
623
624    fn rebased_sensitivity(&self, problem: &Problem) -> Option<Sensitivity> {
625        let mut t = self.clone();
626        let mut scratch = Trace::new(problem.sense);
627        t.drop_artificials(&mut scratch);
628        t.set_objective(problem.sense, "z", &problem.objective, &Coef::zero());
629        let options = Options::default().pivot_rule(PivotRule::Bland);
630        match t.optimize(Phase::Two, &options, &mut scratch) {
631            Ok(End::Optimal) => t.analyze(problem, &t.basis, &t.removed, Cells::None),
632            _ => None, // unreachable: the starting basis is feasible and optimal in value
633        }
634    }
635
636    fn analyze(
637        &self,
638        problem: &Problem,
639        basis: &[usize],
640        removed: &[usize],
641        cells: Cells,
642    ) -> Option<Sensitivity> {
643        let start = Tableau::standard_form(problem);
644        let form = StandardForm {
645            rows: &start.rows,
646            rhs: &start.rhs,
647            names: &start.names,
648            artificial: &start.artificial,
649        };
650        sensitivity::analyze(problem, form, basis, removed, cells)
651    }
652
653    fn values(&self, n: usize) -> Vec<Rational> {
654        let mut values = vec![Rational::zero(); n];
655        for (i, &b) in self.basis.iter().enumerate() {
656            if b < n {
657                values[b] = self.rhs[i].clone();
658            }
659        }
660        values
661    }
662}