Presolve#
Before any branch-and-bound or relaxation work, discopt runs a
presolve orchestrator that walks a registered list of passes over
the model to a fixed point. Each pass is a small, focused transformation
that either tightens variable bounds, eliminates variables, drops
constraints, rewrites constraint bodies, or detects structure. The
orchestrator iterates passes under a global iteration / time / work
budget until no pass reports progress.
The motivating numbers are large: classical MIP presolve already delivers a ~10.7× shifted geomean speedup on standard benchmarks [Achterberg et al., 2020], and for nonlinear and mixed-integer-nonlinear problems the literature documents 17–80% root-LP gap reductions and comparable wall-time savings [Belotti et al., 2009, Puranik and Sahinidis, 2017]. Every preprocessing dollar amortizes over the entire B&B tree, so leverage on MINLP is structurally larger than on MIP.
This notebook walks through the orchestrator’s API and demonstrates
the new structural passes that ship in discopt-core’s presolve
module.
Architecture#
The presolve module lives at crates/discopt-core/src/presolve/. Each
pass implements the PresolvePass trait and emits a PresolveDelta
describing what it changed. The orchestrator drives them to fixed point
and records the chronological delta log. Python passes (e.g.
JAX-backed reverse-mode AD interval tightening, NN-embedded MINLP
presolve) plug into the same protocol via discopt._relax.presolve.
All shipped passes are deterministic by construction — no RNG, no
unsorted hash iteration. The crate’s determinism harness
(crates/discopt-core/tests/presolve_determinism.rs) verifies that
byte-identical inputs produce byte-identical delta sequences.
Calling the orchestrator#
From Python, the orchestrator is reachable in two ways:
Implicitly through
Model.solve(presolve=True)(the default), which routes throughdiscopt._relax.presolve_pipeline.run_root_presolve.Explicitly via
PyModelRepr.presolve(passes=[...])for users who want to inspect or customise the pass schedule.
Let’s build a small MINLP and look at what presolve does to it.
import discopt as do
from discopt._rust import model_to_repr
m = do.Model("presolve_demo")
x = m.continuous("x", lb=0.0, ub=10.0)
y = m.continuous("y", lb=0.0, ub=10.0)
b = m.binary("b")
# Two constraints with intentional structure for presolve to find:
# - the equality fixes y in terms of x (factorable elimination, C2)
# - the big-M row admits coefficient strengthening (C4)
m.subject_to(y - x * x == 0.0)
m.subject_to(5.0 * b + x <= 5.0)
m.minimize(x + b)
repr_ = model_to_repr(m)
print(
f"before: {repr_.n_var_blocks} var blocks, "
f"{repr_.n_constraints} constraints, "
f"{repr_.n_nodes} arena nodes"
)
before: 3 var blocks, 2 constraints, 12 arena nodes
new_repr, stats = repr_.presolve()
print(f"after: {new_repr.n_var_blocks} var blocks, {new_repr.n_constraints} constraints")
print(f"terminated_by: {stats['terminated_by']}")
print(f"iterations: {stats['iterations']}")
for d in stats["deltas"]:
progress = (
int(d["bounds_tightened"]) > 0
or len(d["vars_fixed"]) > 0
or len(d["constraints_removed"]) > 0
or len(d["constraints_rewritten"]) > 0
)
if progress:
print(
f" {d['pass_name']:30s} "
f"removed={list(d['constraints_removed'])} "
f"rewritten={list(d['constraints_rewritten'])} "
f"tightened={int(d['bounds_tightened'])}"
)
after: 3 var blocks, 2 constraints
terminated_by: NoProgress
iterations: 2
fbbt removed=[] rewritten=[] tightened=1
The default schedule runs eliminate → factorable_elim → simplify → coefficient_strengthening → fbbt → probing. Each pass ships as a
first-class PresolvePass in crates/discopt-core/src/presolve/passes.rs
and is dispatched by name from Python.
Coefficient strengthening (C4)#
Following Savelsbergh [1994], for a row \(\sum_j a_j x_j \le b\) with binary \(x_k\) and positive \(a_k\), define \(U_{-k} = \sum_{j\ne k} \max(a_j \ell_j, a_j u_j)\) — the largest LHS contribution from the non-\(k\) terms. If the slack \(b - U_{-k}\) is strictly between \(0\) and \(a_k\), we can simultaneously shift both the coefficient and the right-hand side by that slack: replace \(a_k \leftarrow a_k - \text{slack}\) and \(b \leftarrow b - \text{slack}\). The integer corners of the row are unchanged; the LP relaxation strictly tightens.
m = do.Model("c4")
b = m.binary("b")
y = m.continuous("y", lb=0.0, ub=2.0)
m.subject_to(5.0 * b + y <= 5.0) # b=1 ⇒ y ≤ 0; b=0 ⇒ y ≤ 2 (slack 3)
m.minimize(b + y)
repr_ = model_to_repr(m)
_, stats = repr_.presolve(passes=["coefficient_strengthening"])
for d in stats["deltas"]:
if d["pass_name"] == "coefficient_strengthening":
print("row indices rewritten:", list(d["constraints_rewritten"]))
row indices rewritten: [0]
row indices rewritten: []
The original row 5b + y ≤ 5 becomes 2b + y ≤ 2 — same integer
feasible set, strictly tighter LP relaxation.
Factorable-expression elimination (C2)#
When an equality uniquely determines a variable that appears nowhere else — even when the right-hand side is nonlinear in other variables — the equation can be dropped after a bounded-range safety check:
The pass uses interval forward-propagation (the same machinery FBBT uses) to verify that the derived range of \(v\) stays inside \(v\)’s declared box for every feasible assignment of the other variables; otherwise it abstains. This generalises the M10 singleton-equality elimination pass to nonlinear right-hand sides.
m = do.Model("c2")
v = m.continuous("v", lb=-100.0, ub=100.0)
x = m.continuous("x", lb=0.0, ub=2.0)
y = m.continuous("y", lb=0.0, ub=3.0)
m.subject_to(v - x * y == 0.0) # v determined by x*y; v not in objective
m.minimize(x + y)
repr_ = model_to_repr(m)
new_repr, stats = repr_.presolve(passes=["factorable_elim"])
print(f"constraints before: {repr_.n_constraints}, after: {new_repr.n_constraints}")
constraints before: 1, after: 0
Symmetry detection (D4)#
Variable permutation symmetries inflate the search tree by a factor of the orbit size [Margot, 2010]. The detector compares each scalar variable’s column signature — its participation across constraints and the objective — and groups variables with identical fingerprints into candidate orbits. Detection is sound for fully-linear models; for nonlinear models the kernel emits orbits as candidates the caller can choose to break (or to leave alone, if structural verification is unavailable).
m = do.Model("symmetry")
x1 = m.binary("x1")
x2 = m.binary("x2")
x3 = m.binary("x3")
m.subject_to(x1 + x2 + x3 <= 2.0)
m.minimize(x1 + x2 + x3)
repr_ = model_to_repr(m)
result = repr_.detect_symmetries()
for orbit in result["orbits"]:
print("orbit:", orbit["vars"])
orbit: [0, 1, 2]
Persistent in-tree FBBT (B3)#
Root presolve only catches what’s visible at the root. Inside the B&B tree, every branching decision shifts variable bounds, and FBBT can tighten other variables in response — but only if it runs at the node. The in-tree FBBT pass [Belotti et al., 2009] re-runs FBBT on the node’s local bounds, gated by a depth-stride schedule so the cost amortises over the tree.
import numpy as np
m = do.Model("in_tree_demo")
x = m.continuous("x", lb=0.0, ub=10.0)
y = m.continuous("y", lb=0.0, ub=10.0)
m.subject_to(x + y <= 5.0)
m.minimize(x + y)
repr_ = model_to_repr(m)
# Imagine the B&B branched on x ∈ [3, 10] at this node. FBBT propagates
# x + y ≤ 5 to derive y ≤ 2 — a tightening that persists down the subtree.
delta = repr_.in_tree_presolve(
np.array([3.0, 0.0]),
np.array([10.0, 10.0]),
node_depth=0,
depth_stride=1,
)
print("ran: ", delta["ran"])
print("infeasible: ", delta["infeasible"])
print("tightened: ", int(delta["bounds_tightened"]))
print("new ub: ", list(delta["ub"]))
ran: True
infeasible: False
tightened: 2
new ub: [np.float64(5.0), np.float64(2.0)]
Pass in_tree_presolve_stride > 0 to Model.solve(...) to enable
this pass during the actual B&B search. The default is 0 (off) to
keep behaviour conservative; values of 4–8 are typical, trading a
small per-stride FBBT cost for tighter LP relaxations downstream.
Separability detection (D5)#
When the model splits into disjoint variable blocks linked by neither constraints nor objective, each block can be solved independently. Detection is a union-find over the constraint hypergraph; the result is reported as a list of variable blocks that the relaxation compiler can dispatch in parallel.
from discopt._relax.presolve import SeparabilityPass
m = do.Model("separable")
a = m.continuous("a", lb=0.0, ub=1.0)
b = m.continuous("b", lb=0.0, ub=1.0)
c = m.continuous("c", lb=0.0, ub=1.0)
d = m.continuous("d", lb=0.0, ub=1.0)
m.subject_to(a + b <= 1.5)
m.subject_to(c + d <= 1.5)
m.minimize(a + b + c + d)
repr_ = model_to_repr(m)
p = SeparabilityPass(m)
delta = p.run(repr_)
print("separable:", delta["separable"])
print("blocks: ", [sorted(b) for b in delta["separable_blocks"]])
separable: False
blocks: [['a', 'b', 'c', 'd']]
Periodic-variable bound reduction#
Angular variables are a recurring source of unbounded boxes in nonlinear models:
a voltage angle in AC optimal power flow [Carpentier, 1962, Molzahn and Hiskens, 2019], or any
variable that enters the model only as sin(theta) / cos(theta), is mathematically
free, yet spatial branch-and-bound and the local sub-NLP cannot converge over an
infinite domain.
The periodic-variable bound reduction pass
(PeriodicVariableBoundRule in discopt._relax.nonlinear_bound_tightening) detects
variables that appear solely as the bare argument of a periodic function and
restricts each one to a single 2*pi window. The reduction is lossless: because
the variable reaches the rest of the model only through (sin theta, cos theta), any
2*pi-wide window reproduces every (sin, cos) value the original box could take. A
free variable is anchored to [-pi, pi]; a one-sided-finite box is closed to one
period from its finite anchor; a > 2*pi finite box is shrunk to its first period. A
variable used anywhere outside sin/cos, or inside a non-bare argument such as
cos(2*theta), is conservatively skipped, so the pass only ever shrinks a box it can
prove periodic-only.
Below, theta is unbounded on both sides and appears only as sin(theta); the pass
anchors it to [-pi, pi], which is exactly what lets the solve converge.
import numpy as np
from discopt._relax.nonlinear_bound_tightening import (
build_flat_variable_metadata,
tighten_nonlinear_bounds,
)
m = do.Model("periodic")
theta = m.continuous("theta") # free on both sides: [-inf, +inf]
x = m.continuous("x", lb=-2.0, ub=2.0)
m.minimize(x)
m.subject_to(x - do.sin(theta) >= 0.7) # theta enters ONLY as sin(theta)
# Apply the nonlinear bound rules to the (unbounded) angular variable's box.
meta = build_flat_variable_metadata(m)
lb = np.array([-np.inf, -2.0])
ub = np.array([np.inf, 2.0])
new_lb, new_ub, stats = tighten_nonlinear_bounds(m, lb, ub)
print(f"theta before: [{lb[0]}, {ub[0]}]")
print(f"theta after : [{new_lb[0]:.4f}, {new_ub[0]:.4f}] (one period, width {new_ub[0] - new_lb[0]:.4f} = 2*pi)")
print(f"applied rules: {stats.applied_rules}")
theta before: [-inf, inf]
theta after : [-3.1416, 3.1416] (one period, width 6.2832 = 2*pi)
applied rules: ('periodic_variable_bound',)
With theta anchored to a single period, the otherwise-free angular variable is
finitely bounded and the global solve converges (the minimum of x is
min sin(theta) + 0.7 = -0.3).
result = m.solve()
print(f"status: {result.status}, objective: {float(result.objective):.4f}")
status: optimal, objective: -0.3000
/Users/jkitchin/projects/discopt/python/discopt/debug/__init__.py:98: UserWarning: Variables with very large or infinite declared bounds: theta (lb=-9.999e+19 [default], ub=9.999e+19 [default]). Nonlinear tightening can adjust 2 bounds via periodic_variable_bound. A bound marked [default] is the box applied to a column you declared with no bounds: 9.999e+19, which is FINITE (it sits just below the 1e+20 infinity sentinel), so the solve can return a certified 'optimal' sitting on that corner rather than 'unbounded'. NLP solvers may fail (NaN, iteration_limit) when bounds exceed ~1e15. Add tighter explicit bounds, e.g. m.continuous('x', lb=0, ub=1000).
return fn(*args, **kwargs)
What’s next#
The roadmap (in crates/discopt-core/src/presolve/ROADMAP.md)
documents the four-phase rollout (P1 foundations, P2 quick wins,
P3 structural detection, P4 advanced) and ties each pass to its
literature source. Items beyond detection — e.g. orbital branching
for D4, automatic symmetry-breaking constraint emission, decomposition
dispatch on D5 — are tracked there for future work.