refactor(solver): validation, sanitize kwargs, and result wiring on Solver path#691
Merged
FabianHofmann merged 9 commits intoMay 18, 2026
Conversation
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>
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>
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>
Same property tested twice — no need for separate test IDs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
This was referenced May 18, 2026
…r-from-model-options # Conflicts: # linopy/solvers.py
Collaborator
FabianHofmann
left a comment
There was a problem hiding this comment.
this is extremely sensible! I did not think about model mutations in the solver refactor pr. thanks for addressing this. let me know when this is ready to review
Comment on lines
+601
to
+603
| Passing ``solver=`` to :meth:`Model.assign_result` wires | ||
| ``model.solver`` so post-solve helpers like | ||
| :meth:`Model.compute_infeasibilities` keep working. |
Comment on lines
+472
to
+474
| @pytest.mark.skipif( | ||
| "highs" not in solvers.available_solvers, reason="HiGHS not installed" | ||
| ) |
Collaborator
There was a problem hiding this comment.
we definitely need a helper function that makes these skips snappy
Collaborator
Author
It is ready, but i think its less about correctness, but more about the conceptual split between Model and Solver, and the ergonomics for users using the new Solver class |
68d75cd
into
refactor/sos-reformulation-methods
2 checks passed
FabianHofmann
added a commit
that referenced
this pull request
May 19, 2026
* refactor(sos): add Model.apply/undo_sos_reformulation methods 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> * refactor(solver): validation, sanitize kwargs, and result wiring on Solver 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> * refactor(sos): tighten undo semantics and error hints - undo_sos_reformulation() now raises if no state is applied (fail-fast) - to_netcdf error no longer suggests poking the private state slot - Solver._build runs _validate_model before _check_sos_unmasked so SOS on an LP-only solver surfaces the reformulate-first hint - reformulate_sos_constraints docstring points at the stateful API * fix(sos): auto-undo SOS reformulation when build/solve raises `Model.solve(reformulate_sos=...)` left `_sos_reformulation_state` set if `Solver.from_name`, `solver.solve`, or the file-cleanup `finally` raised, since the undo lived in a second `try` around `assign_result` that those failures never reached. The next solve then hit `RuntimeError: SOS reformulation has already been applied`. Wrap sanitize, build/solve, file cleanup, and assign_result in a single outer try/finally so the undo always runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sos): support reformulation through remote/oetc netcdf path `to_netcdf` previously raised when the model had an active SOS reformulation, blocking `Model.solve(remote=...)` for users who passed `reformulate_sos`. Beyond the raise, the remote branch was silently ignoring `reformulate_sos` entirely. Changes: - `to_netcdf` warns (UserWarning) instead of raising; the reformulated MILP form is what gets serialized. The `_sos_reformulation_state` token is not persisted — it lives only on the in-memory caller's Model, where the apply/undo bracket keeps its lifecycle intact. - `Model.solve(remote=...)` now brackets the remote dispatch with `apply_sos_reformulation` / `undo_sos_reformulation`, exactly like the local path. The `to_netcdf` warning emitted inside the remote helper is suppressed via `warnings.catch_warnings`. - New `Model._resolve_sos_reformulation(solver_name, reformulate_sos)` helper deduplicates the should-reformulate decision between the local and remote branches and uses `solver_supports(...)` instead of the ad-hoc `getattr(solvers, SolverName(...).name)` pattern. - `solver_name=None` with `reformulate_sos="auto"` now raises a sharp error pointing users at either passing `solver_name=...` or using `True`/`False` to skip the lookup. The local path is unaffected because its existing default (`solver_name = available_solvers[0]`) runs before the helper sees None. Addresses the open thread on #690 from FabianHofmann. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(sos): fix mypy errors on remote-bracket and resolve tests - Drop now-unused type: ignore on _resolve_sos_reformulation call where mypy correctly narrows (True, False, "auto") to bool | Literal["auto"]. - Type _fake_handler as RemoteHandler via cast so the three Model.solve(remote=handler, ...) calls satisfy the RemoteHandler | OetcHandler | None signature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(sos): move reformulation lifecycle into remote handlers * fix(types): tighten reformulate_sos to bool | Literal["auto"] * test(ssh): cover SOS bracket in RemoteHandler.solve_on_remote --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Fabian <fab.hof@gmx.de>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #690. Closes the gap between
Model.solve()andSolver.from_name(...).solve().Core principle
Solver.from_model()does not mutate the model. Model-state transformations are Model-level primitives, called explicitly:Model.solve()calls these internally as orchestration. The low-level Solver path expects the caller to apply them — no silent mutation on build.Canonical low-level pattern:
What lives where after this PR
Port?= should it move toSolver.Status= ✅ done · ⬜ todo · — n/a.Solveror why not)Solver._validate_model()(build-time, called from_build())Solver._validate_model()(build-time)Solver.solve()(run-time); scoped dup inModel.solve()'s remote branch, removed by #683model.solver+ primal/dual/status)Model.assign_result(result, solver=...)(post-solve)io_api,explicit_coordinate_names,set_names,problem_fn,slice_size,progress,**solver_optionsSolver.from_model(...)basis_fn,warmstart_fnSolver.solve(...)env,log_fnfrom_model()andsolve()today; PR3 consolidates tofrom_model()only (13 subclasses)sanitize_zeros/sanitize_infinitiesmodel.constraints.sanitize_*(). Mutates — caller-owned.model.apply_sos_reformulation()/undo_sos_reformulation()(#690). Mutates — caller-owned.reformulate_sos(True/False/"auto")model.apply_sos_reformulation(). Solver-path users call apply/undo directly.remote=OetcHandler(...))Solversubclassremote=RemoteHandler(...)mock_solve=TrueMockSolversubclassSolver.__post_init__covers "this-installed"; "any-installed" is module-levelsolver_nameauto-pick, defaultsolution_fn,keep_filescleanup, info logs,reset_solution()