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
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()