Skip to content

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:

  1. Direct LpVariable(name, ...) construction — PuLP 4.0 wants variables created through prob.add_variable(name, lowBound, upBound, cat=...), which attaches them to the model at creation.
  2. PULP_CBC_CMD — the bundled CBC moves to the pulp[cbc] extra and the solver class is renamed COIN_CMD.
  3. LpProblem.constraints as 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:

  1. Build variables with prob.add_variable(...). Verified: prob.variables() still returns only variables that appear in the objective/constraints, so n_vars and the emitted model are unchanged for models with unused variables.
  2. Count constraints with prob.numConstraints() instead of len(prob.constraints), avoiding the deprecated mapping access.
  3. Add solve.default_solver(msg=False), returning a COIN_CMD instance. COIN_CMD does not auto-discover PuLP's bundled CBC on 3.x, so when it reports available() falsy we fall back to the bundled binary path read from the PULP_CBC_CMD.pulp_cbc_path class attribute — accessed without instantiating the deprecated solver, so no warning is emitted. Under PuLP 4.0 with pulp[cbc], COIN_CMD finds CBC itself and the fallback is unused. grounder.solve() and the CLI solve --solver cbc path 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.solver changes from PULP_CBC_CMD to COIN_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_path existing on PuLP 3.x; if a future PuLP removes that class attribute and pulp[cbc] is not installed, default_solver() returns an unavailable solver and solve() raises a clear PulpSolverError — 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_CMD and read its .path from 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.