← back to overview

\* the terminal model explorer *\

A terminal viewer for LP and MPS models,with HiGHS built in.

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.

binary lp_diff built on ratatui formats LP · MPS

Getting oriented

One file explores. Two files diff.

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

Inspect mode

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.

Flags

FlagEffect
--summaryPrint 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.
--watchReload automatically when an input file changes on disk.
--abs-tolAbsolute tolerance: equal when |a - b| <= abs_tol. An epsilon floor always applies, so ordinary float noise never registers as a change.
--rel-tolRelative tolerance, scaled by magnitude: equal when |a - b| <= rel_tol * max(|a|, |b|).
--renameRegex rewrite applied to names in both files before matching. Repeatable; rules apply in order.
--themeauto (detects from COLORFGBG, then by asking the terminal for its background colour), dark, or light.
Why --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

Layout

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.

#SectionContents
1SummaryChange counts, problem dimensions, and the structural analysis: variable and constraint types, coefficient scaling, detected issues.
2VariablesVariable type and bound changes.
3ConstraintsConstraint changes with coefficient-level detail, side-by-side for modified rows.
4ObjectivesObjective function changes.
5NumericsPer-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

Diffing down to the coefficient

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.

Side-by-side and raw text

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.

Filters and sort

KeyAction
a + - m =Show all / added / removed / modified / renamed entries.
oIgnore coefficient order: hide rows whose terms were merely reordered.
sCycle sort: name → |Δ| → relative Δ. The delta sorts surface the biggest movers first.

Live tolerance

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.

Running the model

Solving in the background

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.

y yanks the results to the clipboard and w writes them to CSV: the full comparison in "both" mode, the single solution otherwise.

Solver options

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.

Applied options are logged. The first line of the Log tab lists what was applied ([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

Moving a bound to see what happens

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).

Why re-solve. A shadow price gives the marginal value of a constraint at the current solution, but not how far that holds. Moving the bound and re-solving does.

Making it smaller

Presolve rules you can measure

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.

RuleWhat it does
Fixed column → rhsA 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 → boundA row with one term is a bound in disguise: 3x <= 12 becomes x <= 4, and the row goes.
Bound propagationDerives implied bounds from each row's minimum and maximum activity, tightening the box every other rule reasons about.
Integer roundingRounds fractional bounds inwards on integer variables: x <= 3.7 becomes x <= 3.
Redundant & forcing rowsDrops rows that can never bind; pins every variable in a row that is only satisfiable at a single point.
Empty rows & columnsDrops termless rows; fixes variables that appear in no row at whichever bound the objective prefers.
Row scalingDivides 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 scalingRescales 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.

The change of units never escapes the rewrite. Scaling leaves the model in different units, so the factors are kept with the run and applied to the rewritten side's result on the way back. Variable values, reduced costs, row activities and shadow prices all arrive in the units you wrote, and the comparison diffs like for like. The objective value is invariant under both scalings anyway.

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.

Rows are removed; columns are only ever fixed. Keeping the variable set identical on both sides is what makes the comparison trustworthy: every variable still appears in both results, so a difference in the diff is a real difference and not an artefact of the rewrite. The objective values must agree, and that agreement is the check that the rewrite was sound. The solve times say whether it was worth it.

Why is it slow

Which row is actually the problem

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.

The three signals

Conditioning

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.

Density

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.

Degeneracy

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.

The degeneracy figures are proxies. They are computed from the final solution, not from a basis inspection, because HiGHS does not expose the basis through this binding. A binding row with a zero dual is strong evidence of a degenerate vertex, not proof, and the pane says so on screen. The solver telemetry is parsed from the log, which is not an API: every field is optional, so a format change leaves a gap in the pane.

When it does not solve

Four questions the status line cannot answer

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.

U: which variable runs away

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.

I: the minimal conflicting set

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

R: how far a coefficient can move

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.

A row's interval brackets its activity, not its right-hand side. That is what HiGHS computes, and the two coincide only when the row is binding. Pairing the interval with the RHS instead would, on every slack row, report a range that does not contain the RHS.

B: would another configuration be faster

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
Presets run one after another, not in parallel. Six solves competing for the same cores inflate each other's wall-clock by an amount that depends on how many finish early, which is the quantity being measured. Later presets are capped by a budget derived from the baseline, so one pathological configuration cannot hold the sweep open. Each row also carries its objective: a configuration that returns a different optimum is a bug to be flagged, not a speed-up to be taken.
A ray, an IIS and ranging are all LP concepts, resting on a basis a MIP does not have. Each pane relaxes integrality first and reports how many columns it relaxed, so a MIP still gets an answer and you know which model the answer is about.

Living in a terminal

Terminal manners

The whole keyboard

Key reference

The in-app help (?) matches the mode you are in. This is a summary.

Navigation

KeyAction
j k / ↓ ↑Move down / up
g G / Home EndTop / bottom
Ctrl+d Ctrl+uHalf page down / up
Ctrl+f Ctrl+bFull page down / up
Ctrl+o Ctrl+iJumplist back / forward
Tab / ⇧TabNext / previous panel
h l / EnterSidebar / detail
EscBack / clear search
1–5, [ ]Jump to section / cycle sections
< >Narrow / widen the sidebar (it grows to fit long names on its own)
MMouse capture on / off; off hands the mouse to the terminal to select text (or hold Shift while dragging)

Analysis

KeyAction
SSolve with HiGHS (picker in diff mode)
EWhat-if: edit the selected constraint's RHS and re-solve
PRewrite: pick presolve rules, then compare original vs rewritten
DDiagnostics: why is the solve slow, and which rows and variables are to blame
BSolve profile: the same model under several HiGHS configurations, times side by side
UUnbounded: which variables run to infinity, and the bound that would stop them
IInfeasible: the minimal conflicting set of constraints and bounds (IIS)
RRanging: how far each objective coefficient and row activity can move before the optimal basis changes
eDiagnose infeasibility (in the solve overlay): the cheapest set to relax

Diff, search and export

KeyAction
a + - m =Filter: all / added / removed / modified / renamed
o s t TIgnore order · cycle sort · cycle relative / absolute tolerance
rToggle raw side-by-side text view
/ · n NOpen search · next / previous match
Ctrl+pCommand palette
yy yo yn YYank name · old side · new side · detail panel
wExport CSV: the diff report in diff mode, the model's objectives, constraints and variables in inspect mode
? · q · Ctrl+CHelp · quit · force quit

Getting it

Install

# 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 ?.