← back to overview

\* the terminal model explorer *\

It started as a diff viewer.Now it solves, rewrites and diagnoses.

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.

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

Flags

FlagEffect
--summaryPrint a structured report to stdout and exit without launching the TUI.
--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), dark, or light.
Why --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

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 15, 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. 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.

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

Running the model

Solving in the background

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 15 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

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.

Applied options are never silent. The first line of the Log tab records what was read ([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

Moving a bound to see what happens

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

The point is the duals. A shadow price tells you the marginal value of a constraint at the current solution; it does not tell you how far that stays true. Actually 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. 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.

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.

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.

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 rather than once each. Reopening the picker shows the previous run's per-pass breakdown, which is where the cascade becomes visible.

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, and the solve times are the answer to 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 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.

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

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.

Living in a terminal

Terminal manners

The whole keyboard

Key reference

The in-app help (?) adapts to the mode you are in and is always current. This is the summary.

Navigation

KeyAction
j k / Move down / up
g / GTop / 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 lSidebar / detail
15, [ ]Jump to section / cycle sections

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
eDiagnose infeasibility (in the solve overlay)

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