refactor(sos): add Model.apply/undo_sos_reformulation methods#690
refactor(sos): add Model.apply/undo_sos_reformulation methods#690FBumann wants to merge 3 commits into
Conversation
Introduce a stateful pair of methods on Model that own the SOS reformulation lifecycle: - apply_sos_reformulation() stashes the reformulation token on the model (new _sos_reformulation_state attribute). Raises if already applied. - undo_sos_reformulation() reads the stashed token and restores the original SOS form. No-op if nothing is applied. Model.solve(reformulate_sos=...) now delegates to these methods rather than threading the token through local state. The Solver path (which was previously raising via Model.solve's pre-flight check) now gets a clean ValueError directly from Solver._build() when an SOS-bearing model is handed to a solver without native SOS support — making the low-level API safe to use independently of Model.solve. Persistence: - copy() (and copy.copy / copy.deepcopy) carry the reformulation token with a deepcopy, so the copy is independently undoable. - to_netcdf() raises if a reformulation is active; users must undo first to serialize a stable model state. Context: motivated by the same investigation as #688 — while reviewing the new Solver.from_model() API surface introduced by #682, the SOS reformulation lifecycle stood out as load-bearing orchestration that the Solver path couldn't reproduce. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@FabianHofmann This is probably the heavy lifting of #688 |
Remove the sanitize_zeros / sanitize_infinities kwargs from Solver.from_model(). The Solver builder now never mutates the model. Sanitization is exposed where it has always lived — model.constraints.sanitize_zeros() / .sanitize_infinities() — and Model.solve() calls them inline as part of its orchestration. Rationale: model-state transformations should be Model-level primitives (matches the SOS reformulation pattern from #690). The Solver's job is to translate the model and run; it should not silently change the caller's model on the way in. Users who go through the lower-level Solver path apply sanitize explicitly when they want it. Replaces TestSanitizeKwargs with TestSolverDoesNotMutateModel, pinning the mutation-free invariant: building a Solver against a model with a near-zero coefficient leaves model.constraints["c"].coeffs unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@FabianHofmann Look at the reasoning in #691, where the guideline "Mutating the Model is done deliberately before using a Solver" also affects sanitization etc |
…ulation-methods # Conflicts: # linopy/model.py # linopy/solvers.py
|
yes, makes sense too. should we merge into #691 since they both go in the same direction? would make it easier for reviewing I'd say |
|
@FabianHofmann #691 is stacked on top anyway. So we can just clos this and point the other to master. But i thought this deserves its own deliberate PR with description and reasoning etc. |
was confused with the references. that makes sense. I am reviewing #691 quickly and if you don't mind we merge #691 to here and I have another look at the combined diff. would that be fine? |
|
I would rather merge them both into master sequentially, so we keep the PR's in master. But Its not really important to be honest. Do as you please |
…olver path (#691) * refactor(solver): lift feature checks + sanitize/wiring to Solver path Make Solver.from_name(...).solve() a real first-class entry point that doesn't lose Model.solve()'s safety nets: - Lift solver-feature gates into Solver._build() via a new _validate_model() hook: quadratic models against LP-only solvers and semi-continuous variables against solvers that don't support them. Removed the duplicate checks from Model.solve(). - Add sanitize_zeros / sanitize_infinities kwargs to Solver.from_model() (default True). The kwargs are processed in _build() before dispatch, so both file and direct io_apis honor them. Model.solve() forwards the kwargs through instead of pre-mutating the constraints itself. - Extend Model.assign_result(result, solver=None) so the Solver-path canonical pattern works: solver = Solver.from_name(...); result = solver.solve(); model.assign_result(result, solver=solver). When the solver kwarg is provided, model.solver gets wired the same way Model.solve() wires it, so compute_infeasibilities() and friends keep working through the low-level API. The empty-objective check stays on Model.solve() — to_gurobipy() / to_highspy() and similar build-only converters legitimately work against objectiveless models (gurobi/highs default to a zero objective), so the check belongs at the actual submit point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * move empty-objective check to Solver.solve() for entry-point parity The empty-objective UX guardrail was previously only on Model.solve(), leaving the lower-level Solver.from_name(...).solve() path with a silent gap. Move it to Solver.solve() — the actual submit primitive that both entry points go through — so the same check fires regardless of which API the user reaches for. Build-time translate-only paths (to_gurobipy(), to_highspy(), to_file()) are unaffected since they don't call solve(). The cost of catching the error after build instead of before is bounded and only hits a programming-error case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: parametrize empty-objective check across both entry points Consolidate the Model.solve() and Solver.from_name(...).solve() tests into one parametrized case — same check, two callers, one assertion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: collapse parametrize to a single test with two raises blocks Same property tested twice — no need for separate test IDs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * preserve empty-objective check for remote-solve path in Model.solve() The remote-solve branch in Model.solve() short-circuits to a RemoteHandler before reaching Solver.solve(), so the check now in Solver.solve() doesn't cover it. Restore the early raise in Model.solve() so behavior is unchanged for all Model.solve() callers (mock, remote, local) while Solver.solve() still covers direct-Solver callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * move remote-path empty-objective check inside the remote branch The early-position check was a workaround: the remote branch short-circuits before Solver.solve() (where the canonical check now lives), so empty-objective with remote=... wouldn't raise. Moving it into the remote branch itself makes the intent local to where it's needed, with a comment pointing at #683 where this duplication disappears once OETC becomes a Solver subclass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * keep sanitize on Model; Solver.from_model() stays mutation-free Remove the sanitize_zeros / sanitize_infinities kwargs from Solver.from_model(). The Solver builder now never mutates the model. Sanitization is exposed where it has always lived — model.constraints.sanitize_zeros() / .sanitize_infinities() — and Model.solve() calls them inline as part of its orchestration. Rationale: model-state transformations should be Model-level primitives (matches the SOS reformulation pattern from #690). The Solver's job is to translate the model and run; it should not silently change the caller's model on the way in. Users who go through the lower-level Solver path apply sanitize explicitly when they want it. Replaces TestSanitizeKwargs with TestSolverDoesNotMutateModel, pinning the mutation-free invariant: building a Solver against a model with a near-zero coefficient leaves model.constraints["c"].coeffs unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address review: SOS hint, lp_only_solver fixture, assign_result doc --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Fabian <fab.hof@gmx.de>
Code reviewFound 1 issue:
Lines 1786 to 1833 in 68d75cd 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Summary
On top of the following this PR includes changes from #691
Stateful pair of methods on
Modelthat own the SOS reformulation lifecycle.Model.solve(reformulate_sos=...)delegates to them; same external behavior.Model.apply_sos_reformulation()— stashes the token on_sos_reformulation_state. RaisesRuntimeErrorif already applied.Model.undo_sos_reformulation()— restores the original SOS form. No-op if nothing is applied.Solver._build()— raisesValueErrorwhen an SOS-bearing model meets a solver withoutSolverFeature.SOS_CONSTRAINTS, so the low-levelSolver.from_model(...).solve()path is safe without going throughModel.solve().Persistence:
Model.copy()/copy.copy/copy.deepcopydeep-copy the token; the copy is independently undoable.to_netcdf()raises if reformulation is active — undo first.Why the lifecycle lives on
Model, not onSolverI considered three alternatives and rejected all of them:
On
Solver.from_model()with undo onsolver.close(). Tempting because Solver already manages env lifecycle. Rejected: reformulation is a model transformation, and the Solver only queries a feature flag — putting the lifecycle there makes Solver responsible for work it doesn't perform.Build-time reformulation inside
_build_*, no model mutation. Cleanest in theory. Rejected because the mutation is a feature: users can print the reformulated MILP, export it to LP/MPS, or write additional constraints referencing the aux binaries. Hiding it inside the build step strips all of that.Standalone context manager. Rejected: it only handles the transient (auto-undo) case. The method pair supports transient, permanent, and decide-later workflows; a 3-line context manager over the methods can be added later if wanted.
What's left on
Solver: one feature-flag check in_build(). Layering:sos_reformulation.pyModelSolverModel.solve()Context
Same thread as #688. While reviewing the two-step
Solver.from_model().solve()API from #682, I noticedModel.solve()was doing orchestration the Solver path couldn't reproduce. This PR addresses the SOS slice; validation / sanitization lifting can follow separately.Test plan
pytest test/test_sos_reformulation.py— 69 tests, 9 new (apply/undo primitives, copy across all three protocols, netcdf guard, Solver-path raise).pytest test/test_sos_constraints.py test/test_optimization.py test/test_io.py— 1544 pass.ruff check,ruff format,mypy linopy/{model,solvers,io}.pyclean.🤖 Generated with Claude Code