\* the terminal model explorer *\
lp_diff opens one LP or MPS file to explore, or two (in any combination) to diff down to the
coefficient. It can solve either with HiGHS on a background thread, re-solve after an
RHS edit, apply presolve-style rewrites and compare the result, and point at the rows that make a solve slow.
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 makes sense for a single model still works: search, the command palette, the solver, CSV export,
rewrites, diagnostics and --watch. The diff-only actions (kind filters, ignore-order, tolerance cycling,
sort cycling and the raw side-by-side view) are hidden from the help and the palette, and pressing their key shows a
short status-bar hint instead.
| Flag | Effect |
|---|---|
| --summary | Print a text report to stdout and exit without launching the TUI: change counts for a diff, or counts and the top analysis issues for one model. |
| --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, then by asking the terminal for its background colour), dark, or light. |
--rename exists. Renumbering a model, for example by inserting a time period or
reindexing a set, changes every generated name, so a small structural change becomes a diff where nothing matches.
Collapsing the indices first leaves only the real change:
--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. Modified coefficients also show the change and the relative change. Press r to swap the parsed view for the source lines of both files, side by side, which helps when the parse is not what you expected.
| 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 tune out numeric noise without restarting. The active values are shown 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, case-insensitive substring: matches the variables, coefficients and RHS values inside entries rather than their names. |
In the pop-up, ↓/↑ (or Ctrl+n/Ctrl+p) move through results, Tab completes the query with the highlighted result and Enter jumps to it. 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, as in vim. Ctrl+p opens a fuzzy command palette listing every action with its key.
Running the model
S solves with HiGHS on a background thread. The interface stays responsive and shows the elapsed time while a long solve runs; Esc interrupts it.
In diff mode a picker offers file 1, file 2 or both. "Both" solves the two files one after the other, so they do not compete for the machine, and opens a comparison view. Results are split 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
Use it to try the interior point solver, scale a wide objective or cap a long run. Each value is tried as a boolean, then an integer, a float and finally a string until HiGHS accepts it, so no per-option table is needed.
[lp_diff] options: solver = ipm, run_crossover = off), so a stray file in the directory is visible.
An option HiGHS rejects is listed there as ignored rather than failing the solve. log_file and
output_flag are ignored from the file, because the Log tab depends on them.Asking questions
Select a constraint, press E and type a new right-hand side. The baseline problem (file 1 in diff mode) is cloned in memory, the RHS is changed, and both versions are solved one after the other into the standard comparison view.
Nothing is written to disk; the edit lives only in the clone. The comparison label records the change
(what-if: 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. Space toggles a rule, a toggles all, and Enter solves the original and the rewritten model one after the other, so the two runs do not compete for the machine and the timings are comparable.
| 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.
That does not make it faster by itself. The simplex factorises the basis and updates it, so a dense row costs it almost nothing and the extra rows and columns are overhead: against HiGHS's default, expect neutral to slightly worse. The lower density 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 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 rather than removing it.
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, at most 10 passes, rather than once each. Reopening the picker shows the previous run's per-pass breakdown.
Two logs show what happened. l in the picker runs the ticked rules and lists every action in order:
each row dropped and why, each column fixed and to what, each bound moved or row rescaled. H shows what
HiGHS's own presolve removes from the untouched file, by name. w in either log writes it to the working
directory, as <file>_presolve_log.txt or <file>_highs_presolve.txt.
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 fix. D puts the two together.
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, because it is 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.
When it does not solve
A solve that comes back Infeasible or Unbounded says the model is wrong but not where. These four panes ask HiGHS for more than the status.
An unbounded model has a direction you can travel forever while the objective keeps improving. HiGHS can produce that direction as a certificate; this names the variables in it, with the bound each one is missing.
Variables that run to infinity
give any one of these a finite bound in its ray direction to close the model
variable ray cost lower upper
production_x 1.0000 3.0000 0.0000 +∞
When HiGHS declines to produce a ray, the pane falls back to naming candidates by inspection: variables the objective pushes towards a bound they do not have, and which no row holds in that direction. Checking the matrix is what keeps the list short: most variables in a real model have no explicit bound and are capped by their rows, so without it the list would name nearly every column. It is labelled as a guess on screen.
The infeasibility diagnosis on a solve (e) answers what is cheapest to relax, by total violation. This answers the different question of what is minimally in conflict: a set of constraints and bounds no proper subset of which is infeasible. When you are hunting a modelling mistake rather than negotiating a capacity, the second is usually the one you want.
Irreducible infeasible subsystem
2 row(s) and 1 bound(s) that cannot hold together
no proper subset of these is infeasible: relaxing any one of them removes this conflict
name bound
cap upper
supply lower
What-if (E) edits one right-hand side and re-solves. Ranging reads the optimal basis for the whole interval at once, for every objective coefficient and every row, with no further solve, along with the objective at each end of it. The entry selected in the sidebar is shown in full at the top.
The same model solved under six HiGHS configurations, with times side by side. The last row applies the rewrite rules first and includes their cost in its total, so it shows whether the rewrite pays for itself.
preset status total solve iterations
default Optimal 0.041s 0.038s 312
presolve off Optimal 0.118s 0.115s 904
simplex dual Optimal 0.038s 0.035s 298
simplex primal Optimal 0.052s 0.049s 377
ipm Optimal 0.063s 0.060s 21
▸ local presolve Optimal 0.029s 0.021s 201
fastest: local presolve — 1.41x over default
Living in a terminal
COLORFGBG, or else by asking the terminal for its background colour (OSC 11, with a 100 ms limit); 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.set -g set-clipboard on).The whole keyboard
The in-app help (?) matches the mode you are in. This is a summary.
| Key | Action |
|---|---|
| j k / ↓ ↑ | Move down / up |
| g G / Home End | 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 / Enter | Sidebar / detail |
| Esc | Back / clear search |
| 1–5, [ ] | Jump to section / cycle sections |
| < > | Narrow / widen the sidebar (it grows to fit long names on its own) |
| M | Mouse capture on / off; off hands the mouse to the terminal to select text (or hold Shift while dragging) |
| 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 |
| B | Solve profile: the same model under several HiGHS configurations, times side by side |
| U | Unbounded: which variables run to infinity, and the bound that would stop them |
| I | Infeasible: the minimal conflicting set of constraints and bounds (IIS) |
| R | Ranging: how far each objective coefficient and row activity can move before the optimal basis changes |
| e | Diagnose infeasibility (in the solve overlay): the cheapest set to relax |
| 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: the diff report in diff mode, the model's objectives, constraints and variables in inspect mode |
| ? · 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 ?.