\* the terminal model explorer *\
lp_diff opens an LP or MPS file (or two, in any combination) and gives you a
navigable model: coefficient-level diffs, fuzzy search, a background HiGHS solver,
what-if edits, solution-preserving rewrites, and a diagnostics pane that names the constraints slowing your solve down.
Getting oriented
The file count decides the mode. Format is detected by extension, and the two files do not have to match: comparing an LP against an MPS is a supported case.
# inspect a single model
lp_diff model.lp
# diff two models; formats may differ
lp_diff base.lp modified.mps
# non-interactive report, then exit
lp_diff base.lp modified.lp --summary
With one file the five sections describe the model rather than a comparison. Every variable, constraint and objective is listed plainly, and the detail panel carries the full entry: coefficients with names, operator and RHS, bounds and variable type, or SOS weights.
Everything that still makes sense for a single model keeps working: search, the command palette, sorting, the
solver, CSV export, rewrites, diagnostics and --watch. The diff-only actions (kind filters,
ignore-order, tolerance cycling, delta sorts and the raw side-by-side view) are hidden from the help and the
palette, and no-op with a brief status-bar hint if their key is pressed.
| Flag | Effect |
|---|---|
| --summary | Print a structured report to stdout and exit without launching the TUI. |
| --watch | Reload automatically when an input file changes on disk. |
| --abs-tol | Absolute tolerance: equal when |a - b| <= abs_tol. An epsilon floor always applies, so ordinary float noise never registers as a change. |
| --rel-tol | Relative tolerance, scaled by magnitude: equal when |a - b| <= rel_tol * max(|a|, |b|). |
| --rename | Regex rewrite applied to names in both files before matching. Repeatable; rules apply in order. |
| --theme | auto (detects from COLORFGBG), dark, or light. |
--rename exists. Renumbering a model, whether inserting a time period or reindexing a
set, changes every generated name and turns a small structural change into a diff where nothing matches. Collapsing
the indices first lets the real change surface:
--rename '\[\d+,\d+,[^]]*\]$' '[idx]'.The three panels
A section selector, a filterable name list, and a detail panel. The status bar carries total changes, per-section statistics, the active filter, and scroll position.
| # | Section | Contents |
|---|---|---|
| 1 | Summary | Change counts, problem dimensions, and the structural analysis: variable and constraint types, coefficient scaling, detected issues. |
| 2 | Variables | Variable type and bound changes. |
| 3 | Constraints | Constraint changes with coefficient-level detail, side-by-side for modified rows. |
| 4 | Objectives | Objective function changes. |
| 5 | Numerics | Per-file numerical conditioning: coefficient scaling, magnitude ranges, and analysis issues, including issues that are new in file 2. |
Jump directly with 1–5, cycle with [ and ], and press ? anywhere for scrollable in-app help.
Comparing models
A modified constraint is not just "modified". The two-column view puts old and new coefficients beside each other, so you can see which term moved and by how much.
Added coefficients render green, removed red, modified yellow, unchanged grey. Press r to swap the parsed view for the actual LP source lines of both files, side by side, useful when the parse is not what you expected, or when formatting matters.
| Key | Action |
|---|---|
| a + - m = | Show all / added / removed / modified / renamed entries. |
| o | Ignore coefficient order: hide rows whose terms were merely reordered. |
| s | Cycle sort: name → |Δ| → relative Δ. The delta sorts surface the biggest movers first. |
t and T cycle the relative and absolute tolerances and rebuild the diff in place, so you can dial numeric noise out interactively rather than restarting with different flags. The active values appear on the Summary panel, so a screenshot of the diff records the settings that produced it.
Finding things
A Telescope-style pop-up searches every section at once, with four modes chosen by prefix.
| Prefix | Mode |
|---|---|
| (none) | Fuzzy match, ranked by score. The default. |
| r: | Regex, case-insensitive. |
| s: | Substring, case-insensitive. |
| c: | Content: searches variable names, coefficients and RHS values inside entries, not just entry names. |
Tab completes the query with the highlighted result, Enter jumps to it, and once a search is committed n/N step through matches from the main view.
Positions are recorded automatically when you change section, apply a filter, or jump to a search result. Ctrl+o goes back and Ctrl+i forward through up to 100 positions, the same muscle memory as a vim jumplist. Ctrl+p opens a fuzzy command palette listing every action with its direct key, so nothing is discoverable only by reading the manual.
Running the model
S solves with HiGHS on a background thread. The interface stays responsive, and the elapsed time ticks while a long solve runs.
In diff mode a picker offers file 1, file 2, or both. "Both" runs the two solves in parallel and lands in a comparison view. Results are organised into five tabs (Summary, Variables, Constraints, Log, and Duals), switchable with 1–5 or Tab.
0.0 to
1.0) decides what counts as changed, and d hides everything that did not.y yanks the results to the clipboard and w writes them to CSV: the full comparison in "both" mode, the single solution otherwise.
A highs.opt in the directory you launch from is applied to every solve. It is the same
key = value format as the HiGHS CLI's --options_file, so one file serves both. No file is fine.
# highs.opt
solver = ipm
run_crossover = off
user_objective_scale = -8
time_limit = 300
This is where a model that solves slowly gets interrogated: switch to the interior point solver, scale a wide objective, cap a run that would otherwise outlast your patience. Values are typed by parse, so integers, floats, booleans and strings all pass through without a per-option table.
[lp_diff] highs.opt: solver = ipm, run_crossover = off), so a stray file in the directory cannot go unnoticed. Anything HiGHS rejects is written to the same log. log_file and
output_flag are ignored from the file; the Log tab needs both.Asking questions
Select a constraint, press E, type a new right-hand side. The baseline problem is cloned in memory, the RHS is changed, and both versions are solved in parallel into the standard comparison view.
Nothing is written to disk and the file on disk is untouched. The edit lives only in the clone. The comparison
label records the change (capacity rhs 200 → 260).
Making it smaller
P opens a rule picker. Each rule is a solution-preserving rewrite: it removes work from the model without changing the set of optimal solutions. Pick a set, press Enter, and the original and the rewritten model are solved one after the other. The two runs are never in flight at once, so they cannot compete for the machine and the timings mean something.
| Rule | What it does |
|---|---|
| Fixed column → rhs | A fixed variable's term is a constant, so it moves to the right-hand side and the non-zero disappears. This is what thins the densest rows: the other rules fix columns but leave their terms in the matrix, and every new fix feeds back through here on the next pass. |
| Singleton → bound | A row with one term is a bound in disguise: 3x <= 12 becomes x <= 4, and the row goes. |
| Bound propagation | Derives implied bounds from each row's minimum and maximum activity, tightening the box every other rule reasons about. |
| Integer rounding | Rounds fractional bounds inwards on integer variables: x <= 3.7 becomes x <= 3. |
| Redundant & forcing rows | Drops rows that can never bind; pins every variable in a row that is only satisfiable at a single point. |
| Empty rows & columns | Drops termless rows; fixes variables that appear in no row at whichever bound the objective prefers. |
| Row scaling | Divides each row by a power of two so its largest coefficient sits near 1, pulling the rows onto a common magnitude. Powers of two leave every mantissa intact, so the rewrite adds no rounding error of its own. |
| Column scaling | Rescales a continuous variable's units by a power of two, again so its largest coefficient sits near 1. Restricted to continuous columns: integrality, binariness, the semi-continuous rule and SOS weights are all statements about a variable's own units. |
| Split dense rows (what-if) | Breaks an n-term row into ceil(sqrt(n)) partial sums plus one aggregate row, capping the worst row density at about sqrt(n). The one rule that grows the model, and off by default (see below). |
| Relax integrality (what-if) | Turns every integer and binary column continuous, so the rewritten side is the LP relaxation. Off by default, and a no-op on a model that was already an LP. |
The last two are what-ifs. Every other rule preserves the set of optimal solutions; these two break that on purpose, which is why both start unticked. The comparison is their whole output. How much a structural change is worth is a question about your model and your solver, and the only honest way to answer it is to run both sides.
Splitting dense rows is there to be measured, not to be left on. A row of n terms
becomes k = ceil(sqrt(n)) defining equalities part_i - sum(chunk_i) = 0 plus an aggregate
sum(part_i) <op> rhs: the worst row density falls from n to about
sqrt(n) for about sqrt(n) extra rows, columns and non-zeros. At n = 1728
that is 1728 down to ~42 for a ~2% rise in non-zeros. A running-total chain reaches the same density for
n extra rows and columns and roughly three times the non-zeros, so it only earns its keep when the
cumulative quantity is itself wanted. Only rows of at least 64 non-zeros qualify; below that the aggregate row is
no sparser than the chunks it aggregates.
None of which makes it faster. The simplex factorises the basis and updates it, so a dense row costs it almost nothing and the extra rows and columns are pure overhead — against HiGHS's default expect neutral to slightly worse. The density collapse pays off for interior-point methods, where row density lands in the normal equations and squares. Enable the rule, press Enter, and read the comparison. The partial sums are genuinely new columns, so they appear as added rows in the comparison view; every original variable still lines up.
Relaxing integrality deletes the integrality constraints, so the relaxed objective is a bound on the original rather than equal to it. On a pure LP it does nothing. The point is the pair of numbers the comparison then shows: the objective difference is the integrality gap, or what optimality costs over the bound, and the time difference is how much of the run was branch-and-bound rather than simplex. A model that relaxes in milliseconds and takes minutes as a MIP has a branching problem, not a linear-algebra one, and no amount of scaling or row thinning will touch it.
Bounds are materialised while relaxing, because a kind carries some of them implicitly: a binary column's
[0, 1] is implied rather than written down, and turning it continuous without recording the box would
relax b in {0, 1} to b >= 0. Semi-continuous and SOS columns keep their kind, since
neither is integrality.
Those last three target what the diagnostics pane ranks rather than the row count, and the two scaling rules only
work as a pair. One factor per row cannot improve the worst-conditioned rows, because a row's own
max-to-min ratio is scale-invariant; it takes a different factor per column to change it. Neither fires on a row or
column already within a factor of two of 1, which is what makes the fixpoint terminate, and both skip anything that
would sink a coefficient into the zero tolerance. On boeing2.lp the worst column ratio drops from
3.5e4 to 3.9e3, at the cost of the worst row ratio moving from 3.0e3 to 3.9e3: equilibration redistributes
conditioning, it does not conjure it away.
w in the picker writes the rewritten model to <file>_presolved.lp in the working
directory instead of solving it, for feeding to a solver or diffing outside the TUI. This is the one place the
units do escape: a file on disk has nothing to unscale it, so after a scaling rule fires the written model is in
rewritten units and the status line says so.
The rules feed each other (a singleton becomes a bound, the tighter bound makes another row redundant), so they run to a fixpoint rather than once each. Reopening the picker shows the previous run's per-pass breakdown, which is where the cascade becomes visible.
Why is it slow
A solver log saying "200,000 iterations" and a numerics panel saying "coefficients span 1e-4 to 1e6" are both true and neither tells you which constraint to go and fix. D joins them.
Every constraint and every variable gets one record carrying both its structure (density, coefficient spread, bound width) and its behaviour in the last solve (activity, shadow price, whether it sat at a bound with a zero dual). The tables put those on the same line.
Worst-conditioned constraints
coefficient spread within the row
constraint nnz ratio rhs dual state
FLAVa3 7 3.0e3 0 -3.611e-2 binding
FLAVa2 21 2.5e3 45.0000 -2.889e-2 binding
LFRPMASM 78 1.2e3 0 0 slack
Degenerate constraints
active at the optimum but with a zero dual — the simplex pivots around these
constraint nnz ratio rhs dual state
CONTBOS4 10 1.0e0 0 0 degenerate
CONTLGA2 7 1.0e0 0 0 degenerate
The pane opens with a verdict, then the solver's own telemetry (iterations, its internal presolve reductions, run time, primal-dual objective error), then the model's magnitude ranges, then ranked tables for worst-conditioned, degenerate and densest, rows and columns each.
A row spanning many orders of magnitude makes the ratio test pick badly. Cured by scaling, or by re-expressing the row's units so coefficients sit nearer 1.
A dense row or column destroys the sparsity of the basis factorisation. It makes each iteration cost more. It does not make the solver take more of them.
Many constraints active at the same vertex, so the simplex shuffles between bases without improving the objective. The usual cause of a runaway iteration count, and scaling will not touch it.
The verdict checks degeneracy before conditioning, deliberately: it is both the more common cause and the one where rescaling is wasted effort. The number it judges on is iterations per row, not the raw count. 200,000 iterations is unremarkable at 100,000 rows and alarming at 500.
Living in a terminal
COLORFGBG, or force it with --theme. NO_COLOR forces monochrome, unless you passed --theme explicitly, which wins.--watch reloads when a file changes on disk and rebuilds the diff in place, keeping your position. Stale solve results are discarded; the model has moved under them.The whole keyboard
The in-app help (?) adapts to the mode you are in and is always current. This is the summary.
| Key | Action |
|---|---|
| j k / ↓ ↑ | Move down / up |
| g / G | Top / bottom |
| Ctrl+d Ctrl+u | Half page down / up |
| Ctrl+f Ctrl+b | Full page down / up |
| Ctrl+o Ctrl+i | Jumplist back / forward |
| Tab / ⇧Tab | Next / previous panel |
| h l | Sidebar / detail |
| 1–5, [ ] | Jump to section / cycle sections |
| Key | Action |
|---|---|
| S | Solve with HiGHS (picker in diff mode) |
| E | What-if: edit the selected constraint's RHS and re-solve |
| P | Rewrite: pick presolve rules, then compare original vs rewritten |
| D | Diagnostics: why is the solve slow, and which rows and variables are to blame |
| e | Diagnose infeasibility (in the solve overlay) |
| Key | Action |
|---|---|
| a + - m = | Filter: all / added / removed / modified / renamed |
| o s t T | Ignore order · cycle sort · cycle relative / absolute tolerance |
| r | Toggle raw side-by-side text view |
| / · n N | Open search · next / previous match |
| Ctrl+p | Command palette |
| yy yo yn Y | Yank name · old side · new side · detail panel |
| w | Export CSV |
| ? · q · Ctrl+C | Help · quit · force quit |
Getting it
# from a clone of the repository
cargo install --path tui
# or run straight from the workspace
cargo run -p lp_parser_tui -- base.lp modified.mps
Requires a terminal with colour support. The complete key-binding reference lives in the tui README, and in the app under ?.