simplex-trace
An exact-arithmetic simplex solver that shows its work.
Solve linear programs with the primal simplex, Big-M, two-phase and dual simplex methods, and get back every tableau, ratio test and pivot as data, plus a full sensitivity analysis and post-optimality re-optimization.
Table of contents
- Why simplex-trace?
- Features
- Installation
- Quick start
- Usage
- Conventions
- How it is tested
- Scope and limitations
- Minimum supported Rust version
- Roadmap
- Contributing
- References
- License
Why simplex-trace?
Production solvers such as HiGHS, CBC or Gurobi are built to give you the answer, fast,
in floating point. simplex-trace is built to give you the answer and how it was
reached, exactly as the method is taught in an operations research course: no
0.9999999, no hidden iterations.
| Production solvers | simplex-trace |
|
|---|---|---|
| Arithmetic | Floating point | Exact rationals (1/3, never 0.333…) |
| Output | The optimum | The optimum and every tableau and pivot |
| Big-M | A large numeric M, if any |
A symbolic M (7M - 4), never a guessed value |
| Sensitivity | Floating-point ranges | Exact ranges, each price linked to a tableau cell |
| Post-optimality | Warm start, hidden | Dual simplex pivots you can follow, from the final tableau |
| Built for | Large real-world models | Teaching, learning tools, verification |
Typical uses: step-by-step solvers for students, interactive learning apps, checking homework, generating worked examples, and verifying the output of other solvers on small problems.
Features
- Four methods: primal simplex, Big-M (with
Mkept symbolic), two-phase and dual simplex. - Exact arithmetic: arbitrary-precision rationals, so every tableau matches a hand calculation digit for digit.
- Full trace: every tableau snapshot, the entering and leaving variables, the minimum-ratio test, phase 1 results, artificial variables driven out of the basis and redundant rows removed.
- Sensitivity analysis of every optimal solution: shadow prices, slacks, reduced costs, and the right-hand-side and cost ranges that keep the basis optimal.
- Post-optimality: add constraints to a solved problem and repair the solution with dual simplex pivots from the final tableau, instead of solving again.
- Always conclusive: every method reports optimal, infeasible or unbounded, and the trace explains why, including a diagnosis when Big-M proves infeasibility.
- Pivot rules: Dantzig (the textbook rule) or Bland (anti-cycling).
- Textbook conventions: tableaus follow Taha's Operations Research: An Introduction.
- JSON through the optional
serdefeature, with numbers as exact strings. - Pure library: no I/O, no
unsafe(#![forbid(unsafe_code)]), three small dependencies from thenumfamily.
Installation
[]
= "0.2"
# Or, to (de)serialize problems, solutions and traces as JSON:
# simplex-trace = { version = "0.2", features = ["serde"] }
Quick start
use ;
// Wyndor Glass Co. (Hillier & Lieberman): max 3x1 + 5x2
let problem = maximize
.subject_to
.subject_to
.subject_to;
let solution = solve.unwrap;
assert_eq!;
print!;
The printed trace (cargo run --example wyndor also prints the sensitivity report):
== simplex, iteration 0 ==
basis | x1 | x2 | s1 | s2 | s3 | rhs
------+----+----+----+----+----+----
s1 | 1 | 0 | 1 | 0 | 0 | 4
s2 | 0 | 2 | 0 | 1 | 0 | 12
s3 | 3 | 2 | 0 | 0 | 1 | 18
z | -3 | -5 | 0 | 0 | 0 | 0
-> x2 enters, s2 leaves (row 2, pivot 2); ratios [row 1: -, row 2: 6, row 3: 9]
== simplex, iteration 1 ==
basis | x1 | x2 | s1 | s2 | s3 | rhs
------+----+----+----+-----+----+----
s1 | 1 | 0 | 1 | 0 | 0 | 4
x2 | 0 | 1 | 0 | 1/2 | 0 | 6
s3 | 3 | 0 | 0 | -1 | 1 | 6
z | -3 | 0 | 0 | 5/2 | 0 | 30
-> x1 enters, s3 leaves (row 3, pivot 3); ratios [row 1: 4, row 2: -, row 3: 2]
== simplex, iteration 2 ==
basis | x1 | x2 | s1 | s2 | s3 | rhs
------+----+----+----+------+------+----
s1 | 0 | 0 | 1 | 1/3 | -1/3 | 2
x2 | 0 | 1 | 0 | 1/2 | 0 | 6
x1 | 1 | 0 | 0 | -1/3 | 1/3 | 2
z | 0 | 0 | 0 | 3/2 | 1 | 36
== Optimal ==
The text rendering is only a convenience: the trace is plain data that you can render in any interface.
Usage
Building a problem
Coefficients accept integers, (numerator, denominator) pairs and Rational values, so
fractions are entered exactly. Variables are named x1, x2, ... unless you name them.
use ;
let half: Rational = "1/2".parse.unwrap;
// min 2/3 chairs + tables
let problem = minimize
.subject_to // chairs + tables >= 4
.subject_to
.subject_to // as many chairs as tables
.with_names;
assert_eq!;
assert_eq!;
All variables are non-negative. Constraints with a negative right-hand side are normalized for you.
Choosing a method
| Method | Use it for |
|---|---|
Method::Simplex |
Problems whose constraints are all <= after normalization, so the slacks give the starting basis. Anything else returns Error::ArtificialVariablesRequired. |
Method::BigM |
Any problem. Artificial variables are penalized with a symbolic M. |
Method::TwoPhase |
Any problem. Phase 1 finds a feasible basis, phase 2 optimizes. |
Method::DualSimplex |
Problems whose slack basis is dual feasible: <= / >= constraints only, with non-negative costs when minimizing or non-positive when maximizing (the textbook case: minimize with >=). Starts infeasible and pivots until feasible; no M, no artificial variables. Anything else returns Error::NotDualFeasible. |
use ;
let problem = minimize
.subject_to
.subject_to
.subject_to;
// Bland's rule never cycles; the iteration limit applies per phase.
let options = default.pivot_rule.max_iterations;
assert!;
assert!;
solve only returns an error for a malformed problem, for Method::Simplex on a problem
that needs artificial variables, or when the iteration limit is reached. Infeasible and
unbounded problems are regular results.
Reading the result
use ;
let problem = maximize
.subject_to
.subject_to;
let solution = solve.unwrap;
match &solution.status
assert_eq!;
Walking the trace
solution.trace.steps is a list of steps: tableau snapshots, pivots, phase changes, and a
final Finished step. Each snapshot holds the full tableau (columns, basis, rows,
rhs, and the objective row z with its value z_rhs).
use ;
let problem = minimize
.subject_to
.subject_to
.subject_to;
let solution = solve.unwrap;
for step in &solution.trace.steps
assert_eq!;
With Big-M, objective values are printed with the symbolic M, such as 9M or 7M - 4.
The dual simplex records Step::DualPivot (its ratio test runs over the columns of the
leaving row) and, when a row proves infeasibility, Step::InfeasibleRow.
Sensitivity analysis
Every optimal solution comes with solution.sensitivity, read from the final basis and
expressed in terms of the original problem, whatever method produced it.
use ;
let problem = maximize
.subject_to
.subject_to
.subject_to;
let solution = solve.unwrap;
let s = solution.sensitivity.as_ref.unwrap;
// One more unit of resource 2 is worth 3/2, while b2 stays within [6, 18].
assert_eq!;
assert_eq!;
// (2, 6) stays optimal while c1 is within [0, 15/2].
assert_eq!;
print!;
constraint | shadow price | slack | rhs range | read from
-----------+--------------+-------+-----------+----------
#1 | 0 | 2 | [2, +inf) | s1
#2 | 3/2 | 0 | [6, 18] | s2
#3 | 1 | 0 | [12, 24] | s3
variable | value | reduced cost | cost range
---------+-------+--------------+-----------
x1 | 2 | 0 | [0, 15/2]
x2 | 6 | 0 | [2, +inf)
| Field | Meaning |
|---|---|
shadow_price |
dz*/db_i: the change of the optimal objective per unit increase of b_i. This is the rate of change of z itself, as in Hillier & Lieberman and Excel, also when minimizing. LINDO's "dual price" is the improvement instead: the same value when maximizing, negated when minimizing. |
rhs_range |
The values of b_i for which the basis, and so the shadow prices, stay valid. |
cost_range |
The values of c_j for which the current solution stays optimal. |
reduced_cost |
z_j - c_j in the final tableau. |
source |
The objective-row cell the price is read from: under the slack (<=), under the surplus negated (>=), or with Big-M the constant part under the artificial variable (=). |
Edge cases are reported, not hidden:
degenerateandalternative_optimaflag one-sided prices and the existence of other optima.- Redundant equalities get a zero price. They, and the equalities they depend on, get a single-point right-hand-side range, because none of them can move alone.
- The two-phase method drops artificial columns, so its equalities have no
source. - Big-M can leave an artificial variable in the basis at zero. The analysis then comes from
an equivalent optimal basis of the real problem (the same solution, reached by degenerate
pivots), and
from_final_tableauisfalse.
Adding constraints after the optimum
reoptimize takes a solved problem plus new <= / >= constraints and continues from the
previous final tableau: each new row is expressed in the current basis with its own slack
(Step::ConstraintAdded), and dual simplex pivots restore feasibility. Nothing is solved
again, and the trace shows exactly what changed.
use ;
// TOYCO (Taha): max 3x1 + 2x2 + 5x3, optimal at z = 1350 with x = (0, 100, 230).
let toyco = maximize
.subject_to
.subject_to
.subject_to;
let first = solve.unwrap;
// The optimum violates 3x1 + 3x2 + x3 <= 500 (530 > 500): one dual pivot repairs it.
let extended = toyco.subject_to;
let second = reoptimize.unwrap;
assert_eq!;
assert_eq!;
print!;
-> constraint 4 added as row 4 (violated by the current solution)
== dual simplex, iteration 0 ==
basis | x1 | x2 | x3 | s1 | s2 | s3 | s4 | rhs
------+------+----+----+------+------+----+----+-----
x2 | -1/4 | 1 | 0 | 1/2 | -1/4 | 0 | 0 | 100
x3 | 3/2 | 0 | 1 | 0 | 1/2 | 0 | 0 | 230
s3 | 2 | 0 | 0 | -2 | 1 | 1 | 0 | 20
s4 | 9/4 | 0 | 0 | -3/2 | 1/4 | 0 | 1 | -30
z | 4 | 0 | 0 | 1 | 2 | 0 | 0 | 1350
-> s4 leaves (row 4), s1 enters (pivot -3/2); ratios [s1: 2/3]
== dual simplex, iteration 1 ==
basis | x1 | x2 | x3 | s1 | s2 | s3 | s4 | rhs
------+------+----+----+----+------+----+------+-----
x2 | 1/2 | 1 | 0 | 0 | -1/6 | 0 | 1/3 | 90
x3 | 3/2 | 0 | 1 | 0 | 1/2 | 0 | 0 | 230
s3 | -1 | 0 | 0 | 0 | 2/3 | 1 | -4/3 | 60
s1 | -3/2 | 0 | 0 | 1 | -1/6 | 0 | -2/3 | 20
z | 11/2 | 0 | 0 | 0 | 13/6 | 0 | 2/3 | 1330
== Optimal ==
The result is an ordinary solution of the extended problem, with its own sensitivity report,
so it can be extended again. If the new constraints already hold at the optimum nothing
moves; if they cannot hold inside the feasible region, a row proves it and the status is
Infeasible. A solution only needs its final basis to be extended, which it carries, so this
also works on a solution loaded from JSON. The previous solution must be optimal, and the
extended problem must be the previous one with constraints appended (Error::InvalidProblem
otherwise). Run cargo run --example dual to see this and the dual simplex on Taha's example.
JSON
With the serde feature, problems, solutions, traces and sensitivity reports are
serializable. Numbers are always exact strings ("3", "-1/2"); JSON floats are
rejected, so nothing is ever rounded on the way to or from a front end.
use ;
let problem: Problem = from_str?;
let solution = solve?;
println!;
The status sits at the top level, next to the problem, the sensitivity report and the trace:
Every step has a "type". Values that may contain the symbolic M are objects such as
{"m": "7", "c": "-4"}, meaning 7M - 4:
Step types are tableau, pivot, phase_one_finished, artificial_pivoted_out,
redundant_row_removed, artificial_remains_positive, dual_pivot, infeasible_row,
constraint_added and finished. New types may appear in minor releases, so a client should
skip types it does not know. Infinite range ends are
null. Run cargo run --example json --features serde to see a complete solution.
Conventions
Tableaus follow Taha's Operations Research: An Introduction, so they match the textbook line by line:
- The problem is never silently converted: the objective row holds
z_j - c_jwith the real costs, and its right-hand side is the real objective value. - Maximize: the most negative
z_j - c_jenters, and the tableau is optimal when all are>= 0. Minimize: the most positive enters, and it is optimal when all are<= 0. - Ties: the entering column with the lowest index wins; in the ratio test, the row whose basic variable has the lowest index.
- Big-M: artificial variables cost
-Mwhen maximizing and+Mwhen minimizing. TheMpart of the objective row is improved first, so when the run stops the penalty cannot improve any further. An artificial variable that is still positive then proves the problem infeasible, and the trace records it. - Two-phase: phase 1 minimizes
r = a1 + a2 + ...in a row labelledr; phase 2 drops the artificial columns and prices out the real objective. - Dual simplex: every constraint is written as
<=(a>=is multiplied by-1), so the slacks form the starting basis and right-hand sides may be negative. The row with the most negative right-hand side leaves (Bland: the negative row with the lowest basic index); among the columns with a negative entry in that row, the one with the smallest|z_j - c_j| / |a_rj|enters, which keeps the objective row optimal. A negative row with no negative entry proves the problem infeasible. The method never ends unbounded. - Columns: original variables, then slack and surplus (
s1,s2, ...), then artificial variables (a1,a2, ...), numbered by constraint.
How it is tested
Correctness matters more than speed in a teaching tool, so every claim is checked against an independent source:
- Golden tests pin every tableau of textbook examples (Taha's Big-M and two-phase example, Hillier & Lieberman's Wyndor Glass), so the presentation cannot change silently.
- Brute-force oracle: all three methods, under both pivot rules, are compared with exact vertex enumeration on 600 random problems: optimal, infeasible, unbounded, degenerate and redundant. Every recorded step is checked too: non-negative right-hand sides, unit basic columns, the entering column chosen by the rule and the minimum-ratio leaving row.
- Sensitivity oracle: every report is checked for strong duality, complementary slackness, dual feasibility and agreement with the final tableau. Every range is probed by brute force: at its ends the objective must follow the claimed slope, and just beyond them the basis must stop being optimal.
- Dual simplex and re-optimization oracle: the dual simplex runs on every problem it applies to plus 150 problems built to be dual feasible, and every optimal solution of every method is re-optimized after appending random constraints (satisfied, violated or incompatible). Results are compared with brute force; each dual pivot is checked against the leaving-row and ratio-test rules, every dual tableau must keep an optimal objective row, and the re-optimization must start from the previous final tableau.
- Classic cases: infeasible, unbounded, redundant equalities, negative right-hand sides and Beale's cycling example.
- JSON contract: exact round trips, and the shape of every object is pinned by tests.
- Mutation checks: deliberately injected bugs (wrong ratio test, wrong pivot rule, wrong signs or ranges) are confirmed to make the suite fail.
- README examples are compiled and run as doctests.
- CI runs formatting, Clippy, documentation and all tests on stable and on the minimum
supported Rust version, with and without the
serdefeature.
Scope and limitations
- Exact arithmetic is much slower than floating point, and the full trace stores a copy of every tableau. The crate is meant for problems of classroom size (up to a few dozen variables and constraints), not for large models.
- All variables must be non-negative. Free variables are on the roadmap; until then, split a
free variable as
x = x' - x''. - Integer programming is out of scope.
Minimum supported Rust version
The MSRV is Rust 1.85, the first release with the 2024 edition, and it is checked in CI. It will only be raised in a minor release, with a note in the changelog.
Roadmap
- Primal simplex, Big-M and two-phase methods with a full trace
- Sensitivity analysis
- JSON support
- Dual simplex, and adding constraints after the optimum
- Adding variables after the optimum (pricing the new column, then primal pivots)
- Free (unrestricted-sign) variables
- The artificial-constraint technique, for starts that are neither primal nor dual feasible
Contributing
Issues and pull requests are welcome. Before opening a pull request, please run:
Commit messages follow Conventional Commits. For a change to the solver, a test against a worked textbook example or the brute-force oracle is the most convincing evidence.
References
- Hamdy A. Taha, Operations Research: An Introduction. Tableau conventions, the Big-M, two-phase and dual simplex examples, and the TOYCO post-optimality example.
- Frederick S. Hillier and Gerald J. Lieberman, Introduction to Operations Research. The Wyndor Glass Co. example and the shadow-price convention.
- E. M. L. Beale, "Cycling in the dual simplex algorithm", Naval Research Logistics Quarterly, 1955. The cycling example used to test Bland's rule.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.