Skip to content

refactor: stateful Solver instances and two-step solve API#682

Merged
FabianHofmann merged 44 commits into
masterfrom
solver-refac
May 18, 2026
Merged

refactor: stateful Solver instances and two-step solve API#682
FabianHofmann merged 44 commits into
masterfrom
solver-refac

Conversation

@FabianHofmann
Copy link
Copy Markdown
Collaborator

@FabianHofmann FabianHofmann commented May 13, 2026

closes #628 #583

Changes proposed in this Pull Request

Refactor of the solver layer to put solver state on a stateful Solver instance and expose a clean construct-then-solve workflow.

  • Stateful Solver on Model.solver. Solver state (native model, results) now lives on a Solver instance attached to Model.solver. Model.solver_model and Model.solver_name become read-only properties delegating to model.solver (assigning anything but None raises; setting None closes the solver). Model.solver_name may be None before a solve. These two properties are candidates for future deprecation. The solver exposes an explicit close() (also auto-invoked from __del__) that releases the native handle and any held license.
  • Construct-then-solve API. Build a solver via Solver.from_name(name, model, io_api=..., options=...) (or SolverClass.from_model(model, ...)), then call solver.solve() to run and obtain a Result, and model.assign_result(result) to write the solution back. Solver is a dataclass; subclasses no longer need __init__ overrides.
  • Clear lifecycle hooks per solver. Each subclass overrides at most three methods: _build_direct(**kwargs) (build the native model from self.model), _run_direct(**kwargs) (run the prebuilt native model), and _run_file(**kwargs) (invoke the solver on self._problem_fn). File-only solvers (CBC, GLPK, Cplex, SCIP, Xpress, Knitro, COPT, MindOpt) override only _run_file. Direct-API solvers (Highs, Gurobi, Mosek, cuPDLPx) override all three.
  • Legacy entry points deprecated. Solver.solve_problem, Solver.solve_problem_from_model, and Solver.solve_problem_from_file are kept on the base class as thin shims that route through the new pipeline and emit DeprecationWarning. To be removed in a future release.
  • Two ways to get the native solver model. Either via the module-level / Model-bound helpers (model.to_gurobipy(), model.to_highspy(), to_cupdlpx(model)), or directly via Solver.from_model(model, io_api=\"direct\").solver_model. The previous public Solver.to_solver_model method is removed and folded into the internal _build_direct hook to avoid exposing a third redundant path.
  • Declarative solver capabilities. Capabilities are declared as features: frozenset[SolverFeature] ClassVars on each Solver subclass (with an optional runtime_features() classmethod for version-dependent ones); query with Solver.supports(feature). SolverFeature is exported from linopy (and linopy.solvers); linopy.solver_capabilities remains as a back-compat shim with a lazy SOLVER_REGISTRY mapping and solver_supports() helper.
  • License-aware solver discovery. linopy.available_solvers is now lazy and no longer probes license-managed solvers (Gurobi, Mosek, Knitro, MindOpt, COPT, cuPDLPx) at import time — membership now means "package/binary installed", not "working license". Same lazy behaviour for quadratic_solvers. New linopy.licensed_solvers returns the installed solvers that currently pass a license probe. New linopy.solvers.check_solver_licenses(*names), plus per-class SolverClass.is_available() and SolverClass.license_status() returning a LicenseStatus dataclass (name, ok, message). All caches are refreshable via .refresh().
  • Result carries solver report. Result gains solver_name and report: SolverReport | None (runtime, MIP gap, dual bound, iteration counts) and prints them in __repr__. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate report; when possible also populate the MIP dual_bound. SolverReport is exported from linopy.constants.
  • Positional-indexed solution mapping. Solution.primal and Solution.dual are now dense np.ndarrays indexed by linopy label (length = max_label + 1); masked or solver-dropped slots are NaN. Previously pd.Series keyed by name. Each solver emits arrays in this label-indexed form — direct-API solvers via cached _vlabels/_clabels populated at _build_direct time, file-based solvers via the shared _solution_from_names helper. Robust to solver iteration order and to solvers dropping unused variables (e.g. CPLEX on LP read). The dense numpy arrays are assigned to Model.solution by simple reshaping.
  • Cached constraint labels. Constraints exposes a cached label_index (mirroring Variables.label_index), so assign_result and IIS extraction no longer trigger a full model.matrices rebuild. Invalidated on add/remove. ConstraintBase gains active_labels() and a range property, and CSRConstraint exposes coords to support this without forcing a CSR build.
  • Common helpers. linopy.common gains values_to_lookup_array and ConstraintLabelIndex; the legacy pandas-keyed helpers series_to_lookup_array and lookup_vals are removed.

Reviewer requests

  • @FBumann: align SolverReport with SolverMetrics (feat: add unified SolverMetrics #583); expose dual_bound (best bound)
  • @coroa: make Solver a dataclass; drop the solver_class(**solver_options) pattern
  • @coroa: add Solver.from_name(name, model, io_api=..., options=...) static constructor + SolverClass.from_model(...) classmethod
  • @coroa: drop prepare_solver / run_solver two-step API
  • @coroa: cache vlabels / clabels on the solver instead of going through model.matrices, see vlabels-clabels-flow.html for more details
  • @coroa: adopt the Model.solve = self.solver = Solver.from_name(...); assign_result(...) pattern in Model.solve
  • @coroa: add is_available() classmethod per solver + lazy SOLVER_REGISTRY so available_solvers discovery doesn't grab licenses prematurely
  • @coroa: refactor OETC as a Solver subclass (Refactor OETC as a Solver subclass #683)
  • @coroa: keep the interface extensible for asynchronous solving (Gurobi batch optimization, OETC) — return early with a job handle and retrieve later (Refactor OETC as a Solver subclass #683)
  • @coroa: incremental update path — solver holds a shallow copy of the linopy model, solver.update(model) diffs and pushes only changed bounds/rhs/coefficients (CoW on variable/constraint data) (defer to follow-up pr)

Checklist

  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in `doc`.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes `doc/release_notes.rst` of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

FabianHofmann and others added 16 commits May 12, 2026 06:24
Phase B of solver refactor (issue #628). Makes the Solver instance the
canonical owner of solver-side state.

- Base Solver.__init__ now initializes options, status, solution, report,
  solver_model, io_api, env, capability, _env_stack.
- Adds to_solver_model / update_solver_model / resolve / close / __del__
  on the base class; resolve dispatches to per-subclass _resolve.
- Adds _make_result helper that populates instance state and stamps
  solver_name and report onto Result.
- Gurobi: env creation moved off per-call ExitStack onto self._env_stack
  so the env remains valid after solve returns; to_solver_model and
  _resolve overrides wired.
- Highs / Mosek / cuPDLPx: to_solver_model + _resolve overrides; Mosek
  task is now kept alive via self._env_stack instead of being closed at
  function exit.
- CBC / GLPK / Cplex / SCIP / Xpress / Knitro / COPT / MindOpt: minimal
  wiring — populate self.status/self.solution/self.solver_model/self.io_api
  via _make_result and pass solver_name + report (where readily
  available) into the returned Result.

solve_problem dispatcher and the public solve_problem_from_model /
solve_problem_from_file signatures are unchanged. Model.solve is
untouched (Phase C).
Surfaces solver name, status, io_api, and solution/report summary.
Move SolverFeature and _xpress_supports_gpu into linopy.solvers; declare
features/display_name as ClassVars on each Solver subclass with a
Solver.supports() classmethod. solver_capabilities becomes a back-compat
shim with a lazy SOLVER_REGISTRY mapping. Model.solve uses the class API
directly; SolverFeature is re-exported at the package top level.
Stash `sense` on the Solver instance in `to_solver_model` and make
`Solver.resolve()` take no args. Add `Model.to_solver_model(name)` and
`Model.resolve()` wrappers so the two-step direct-API flow lives on the
model. Update the direct-API test and re-run the piecewise notebook.
Model.to_solver_model -> prepare_solver and Model.resolve -> run_solver
(plus Solver.resolve/_resolve -> run/_run). Avoids the awkward "resolve
on first call" reading. Solver.to_solver_model is kept since it
accurately produces the native solver model.
Replace Xpress-specific _xpress_supports_gpu with a generic
_installed_version_in helper, and add Solver.runtime_features() as an
override hook for version/env-conditional capabilities. Xpress now
declares its GPU support via runtime_features() instead of inline
frozenset arithmetic on the class body.
…method

Move the full parameter docstring onto Solver.solve_problem_from_model and
drop the per-subclass duplicates on Mosek and cuPDLPx; subclasses now inherit
the abstract method's docstring.
Unify per-solver _translate_to_* methods under a common _build_solver_model
name, hoist their local imports to module top-level, drop dead params from
cuPDLPx (moving its UserWarning into the public to_solver_model), and add
TYPE_CHECKING stubs. Expand to_* deprecation messages with step-by-step
migration paths, wrap existing tests in pytest.warns, and cover the
unknown-solver-name branch in prepare_solver.
@FBumann
Copy link
Copy Markdown
Collaborator

FBumann commented May 13, 2026

@FabianHofmann I suggest checking the new class SolverReport against my SolverMetrics in #583, the purpose is pretty much the same. This will also probably close #583.
One thing we definitely need is SolverReport.dual_bound (also known as best bound).
Peak memory was also added in #583, but might be less commonly exposed by the solver. I also dont really need it...

@coroa
Copy link
Copy Markdown
Member

coroa commented May 13, 2026

Can we make the solver class into a data class, too? And get rid of this strange instantiate solver_class(**solver_options) pattern.

I am not sure whether there is a benefit to holding a solver class instance without a model attached, so what about constructors like:

Solver.from_name("gurobi", linopy_model, io_api=..., options=options)
# a staticmethod

which dispatches to

Gurobi.from_model(linopy_model, io_api=..., options=options)
# the specific classmethod, even though that is also implemented on the main Solver class

which dispatches according to io_api.

I'd say this then gets rid of any case for prepare_solver or some such.

@coroa
Copy link
Copy Markdown
Member

coroa commented May 13, 2026

Benchmark: OETC should be able to become a solver class.

class Model:
    ...
    def solve(solver_name, io_api, **solver_options):
        self.solver = None
        self.solver = solver = Solver.from_name(solver_name, model=self, io_api=io_api, options=solver_options)
        result = solver.solve()
        self.apply_result(result)

@coroa
Copy link
Copy Markdown
Member

coroa commented May 13, 2026

Solvers should get a class method:

class Gurobi:
    @classmethod
    def is_available():
        try:
            from gurobipy import Model
            with Model():
                  return True
         except ImportError, LicenseError?:
             return False

We then use a derived Collection pattern similar to the following for available_solvers so not to grab licenses prematurely.

https://github.com/snakemake/snakemake/pull/3900/changes#diff-857b2ff1aff916efdddad6bcd645a90f388276460cf75e6c9a0362745a70371fR13-R58

@coroa
Copy link
Copy Markdown
Member

coroa commented May 13, 2026

ah yes, and OETC and some Gurobi compute like instances can have an asynchronous solving option. Where you do not want to block the process, but return early and only give back some sort of job identifier in hand with which to retrieve the solution later.

Gurobi docs: https://docs.gurobi.com/projects/optimizer/en/current/features/batchoptimization.html

And i don't mean implement this now, here, but the interface should be extensible to allow for that.

@coroa
Copy link
Copy Markdown
Member

coroa commented May 14, 2026

i am unsure how to effectively keep track of the state that was communicated to the solver. ie. when you then do modifications on the linopy model data after it was communicated to the solver. when do you send those updates (and which updates)

the most promising way in my mind would be the following:

the solver object holds a shallow copy of the linopy model (up until the individual constraint and variable objects). EDIT: probably only of the constraints and variable objects.

when you make an update to a variable bound or constraint you use some sort of cow to create a copy in the linopy model (this mostly means something like a v.data = v.data.assign pattern and maybe on mutable constraints and rhs an explicit cow numpy flag).

this means you share variable data/constraint data until you make the first modification.

then you do solver.update(linopy_model) which does diffing and sends changes to solver.

several nice characteristics that way:

  1. you don't send changes prematurely (ie. immediately communicate any change on rhs assignment)
  2. sending changes is minimal/ incremental
  3. its compatible with putting solver connections into a separate thread
  4. update does not need to care whether it is effectively the same model or a completely newly constructed one
  5. memory use is efficient

@coroa
Copy link
Copy Markdown
Member

coroa commented May 14, 2026

@FabianHofmann i reread the pr description. don't use vlabels, clabels through m.matrices. m.matrices is expensive when constraints are not frozen. but I think you know and that's why they are stored on solver, isn't it

@FabianHofmann
Copy link
Copy Markdown
Collaborator Author

@FabianHofmann I suggest checking the new class SolverReport against my SolverMetrics in #583, the purpose is pretty much the same. This will also probably close #583. One thing we definitely need is SolverReport.dual_bound (also known as best bound). Peak memory was also added in #583, but might be less commonly exposed by the solver. I also dont really need it...

thanks @FBumann for raising this. I pulled over the dual_bound feature from your pr. should cover all if it then

Replace pd.Series with dense NaN-padded np.ndarray keyed by integer
model labels. Adds values_to_lookup_array helper; simplifies
Model.apply_result; migrates every solver to build the lookup array
directly (direct-API solvers via cached _vlabels/_clabels, file-based
solvers via a shared _names_to_labels helper). Drops the now-unused
series_to_lookup_array and all name-based solution fallbacks.
Adds dual_bound field to SolverReport, populated by HiGHS, Gurobi
and Knitro. Declared via new SolverFeature.MIP_DUAL_BOUND_REPORT
so tests gate assertions on capability instead of solver names.
pre-commit-ci Bot and others added 2 commits May 15, 2026 10:24
Module import no longer probes license-managed solvers (Gurobi, Mosek,
Knitro, MindOpt, COPT, cuPDLPx). available_solvers / quadratic_solvers
are now lazy Sequence proxies; license probes move to
Solver.license_status() / check_solver_licenses(). Solver packages are
loaded lazily via a _LazyModule proxy.
FabianHofmann and others added 12 commits May 16, 2026 15:07
- Rename Model.apply_result() to Model.assign_result()
- Update all test function names to reflect new method name
- Update release notes documentation
Filters available_solvers by Solver.license_status().ok so tests and
runtime selection can skip installed-but-unlicensed solvers cleanly.
Exported from linopy. Tests parametrize over it instead of
available_solvers. Also fixes a case-mismatch in a knitro test regex.
doctest collection probed __wrapped__ via inspect.unwrap, triggering
imports of optional solver packages (pyscipopt, cupdlpx). Restrict
_LazyModule.__getattr__ to non-dunder names. Add narrowing asserts and
type fixes uncovered by mypy.
Comment thread doc/release_notes.rst

*Inspect the solver after solving*

* After ``model.solve()``, the solver object stays available on ``model.solver``. You can inspect it, reuse it, or release the underlying solver (and its license) by calling ``model.solver.close()`` or assigning ``model.solver = None``. It is also released automatically when the model is garbage-collected.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats a great and important note for commercial usage, as the license is blocked otherwise. We might want to add a notebook about the new solver stuff, as an example with highs or gurobi...? If you want i can craf one, exploring the new feature on the way and you can add what i missed?

Copy link
Copy Markdown
Collaborator Author

@FabianHofmann FabianHofmann May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, sounds good!

@FabianHofmann
Copy link
Copy Markdown
Collaborator Author

@FBumann I am done with my features for this pr. another will follow for the persistent solve. since jonas is on holiday he won't be able to make another review here. do you want to take another look? focus could be breaking changes and whether flexopt tests pass with it (pypsa does). feel free to pull in the notebook here or to raise another pr in case you are able to quickly approve (no pressure anyway)

@FBumann
Copy link
Copy Markdown
Collaborator

FBumann commented May 18, 2026

@FabianHofmann No, i think a followup for the notebook is better anyway.
I'll let the flixopt test suite run on this PR now

@FBumann
Copy link
Copy Markdown
Collaborator

FBumann commented May 18, 2026

@FabianHofmann All tests pass.

@FabianHofmann
Copy link
Copy Markdown
Collaborator Author

wonderful! thanks @FBumann

@FabianHofmann FabianHofmann merged commit 4daf611 into master May 18, 2026
21 checks passed
@FabianHofmann FabianHofmann deleted the solver-refac branch May 18, 2026 08:10
FBumann added a commit that referenced this pull request May 18, 2026
Covers `model.solver` inspection, `SolverReport`, the construct-then-solve
API (`Solver.from_name(...).solve()` + `model.assign_result`), solver
capabilities via `SolverFeature`, and `available_solvers` vs
`licensed_solvers` (see #682). Also extends `create-a-model.ipynb` with
a small tail demoing `model.solver`, the report, and `solver.close()`,
linking forward to the new page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Solver Refactor and Extension

3 participants