ADR-0008: Forward-compatible PuLP API in the solver back-end¶
- Status: accepted
- Date: 2026-06-26
Context¶
The grounding back-end (solve/grounder.py) builds and solves models with
PuLP. PuLP 4.0 deprecates three APIs the back-end relied on, each emitting a
DeprecationWarning and slated for removal:
- Direct
LpVariable(name, ...)construction — PuLP 4.0 wants variables created throughprob.add_variable(name, lowBound, upBound, cat=...), which attaches them to the model at creation. PULP_CBC_CMD— the bundled CBC moves to thepulp[cbc]extra and the solver class is renamedCOIN_CMD.LpProblem.constraintsas a dict mapping —len(prob.constraints)triggers a mapping-deprecation; PuLP 4.0 returns constraints as a list.
Left unmigrated, the entire solve/ path breaks the day PuLP 4.0 is installed.
The back-end is the only executable boundary in an otherwise pure/deterministic
library, so a silent runtime break there is a real reliability risk. The
constraint was to migrate without breaking the currently-installed PuLP 3.x,
and without disturbing determinism or SolveResult values.
Decision¶
Use only PuLP APIs that exist in PuLP 3.x and are the sanctioned PuLP 4.0 form, and centralise the CBC default behind one helper:
- Build variables with
prob.add_variable(...). Verified:prob.variables()still returns only variables that appear in the objective/constraints, son_varsand the emitted model are unchanged for models with unused variables. - Count constraints with
prob.numConstraints()instead oflen(prob.constraints), avoiding the deprecated mapping access. - Add
solve.default_solver(msg=False), returning aCOIN_CMDinstance.COIN_CMDdoes not auto-discover PuLP's bundled CBC on 3.x, so when it reportsavailable()falsy we fall back to the bundled binary path read from thePULP_CBC_CMD.pulp_cbc_pathclass attribute — accessed without instantiating the deprecated solver, so no warning is emitted. Under PuLP 4.0 withpulp[cbc],COIN_CMDfinds CBC itself and the fallback is unused.grounder.solve()and the CLIsolve --solver cbcpath both route through this helper.
A regression test (tests/test_solve.py::test_solve_path_is_pulp4_clean) runs a
real solve with DeprecationWarning from pulp.* promoted to an error, so any
reintroduced deprecated call fails CI.
Rationale¶
- One code path works on both PuLP 3.x (today's pin) and 4.0 (the next major), so the upgrade is a no-op rather than a breaking event.
- The default solver lives in exactly one place (
default_solver), reused by the library and the CLI, instead of being duplicated as inline lambdas. - Determinism preserved: single-threaded CBC, identical objective /
n_vars/n_constraints/ variable values verified across repeated solves and against the known-optimum and cross-solver (CBC vs HiGHS) tests.
Consequences¶
- The default solver name reported in
SolveResult.solverchanges fromPULP_CBC_CMDtoCOIN_CMD. No code or test asserts that string, but downstream consumers reading it should expect the new value. - The bundled-CBC fallback depends on
PULP_CBC_CMD.pulp_cbc_pathexisting on PuLP 3.x; if a future PuLP removes that class attribute andpulp[cbc]is not installed,default_solver()returns an unavailable solver andsolve()raises a clearPulpSolverError— the documented "install CBC" failure, not a silent wrong answer. - The PuLP floor stays
pulp>=2.8; bumping past 3.x no longer requires code changes.
Alternatives considered¶
- Suppress the warnings with a
warnings.filter: rejected — hides the problem instead of fixing it; the code would still break under PuLP 4.0. - Pin
pulp<4: rejected — defers the break and freezes the solver stack; the forward-compatible APIs already exist in 3.x, so migration is strictly better. - Keep
PULP_CBC_CMDand read its.pathfrom an instance: rejected — instantiating it emits the very deprecation we are removing; the class attribute gives the same path without construction.
Addendum (2026-10-04): PuLP 4.0.0 broke more than its deprecations announced¶
PuLP 4.0.0 rebuilt its model classes on a Rust core (pulp._rustcore).
LpVariable(_var) and LpAffineExpression(_expr) now take core objects and
cannot be constructed from Python, so pulp.LpAffineExpression() and
LpVariable(name, lowBound=...) raise TypeError; PULP_CBC_CMD no longer
exists; constraint names come back as _C1, _C2. The migration above covered
add_variable and the COIN_CMD-first lookup, not these. The first CI run that
resolved 4.0.0 (optional-backends job, 2026-10-04) failed in the grounder, the
interop round trips and the object-API tests, and so did every fresh
pip install "lp2graph[solver]".
Until the back-end is ported (issue #65), the solver and all extras pin
pulp>=2.8,<4. The cap is the decision of this addendum; the port keeps the
constraints of the original decision (3.x keeps working, determinism and
SolveResult values unchanged) and lifts it.
Addendum (2026-10-13): the floor was never 2.8¶
The migration above moved variable creation to prob.add_variable(...) and
kept the floor at pulp>=2.8, on the grounds that "the forward-compatible APIs
already exist in 3.x". add_variable in fact first shipped in PuLP 3.3.1
(2026-05-05); 2.8 through 3.3.0 only have addVariable, so on those versions
every grounding raised AttributeError, and so did every script interop
generates. Checked against the 3.3.0 and 3.3.1 wheels on 2026-10-13: 20
solver-path tests fail on 3.3.0 and all pass on 3.3.1.
Nothing caught it because no job ever installed the floor: pip resolves the
newest release under the cap. The solver and all extras now require
pulp>=3.3.1,<4, and the CI job min-pulp reads the floor from
pyproject.toml, installs exactly that version and runs the solver paths, so
the next floor claim is tested rather than asserted.