Skip to content

Adopt linopy's solver design: no forced solver, declared capabilities, lazy availability #106

Description

@FBumann

Thread A of #105, researched. Read against linopy 0.9.x as installed.

linopy already forces no solver

Its hard dependencies contain no solver at all:

numpy, scipy, bottleneck, toolz, numexpr, xarray>=2024.2.0,
dask>=0.18.0, polars>=1.31.1, tqdm, deprecation, packaging

Solvers live behind one extra:

[solvers] = gurobipy, highspy, cplex (non-Darwin), mosek, mindoptpy,
            coptpy, xpress (non-Darwin), pyscipopt (non-Darwin), knitro

with platform markers doing the work where a wheel does not exist everywhere. Since our compat lane is a pure consumer of linopy's public API, matching this is coherent rather than merely imitative — and it settles the question in #105: highspy moves out of dependencies.

What is worth adopting

1. Two implementation paths behind one ABC. Solver declares _run_file (LP/MPS) and _build_direct / _run_direct (in-process API), with accepted_io_apis: ClassVar[frozenset[str]] saying which a given solver supports. That maps exactly onto our two sinks — lp_file and solver_direct — and is the seam we need, because solver_direct is currently highspy-specific (addCols / addRows / changeColsIntegrality are highspy APIs).

2. io_api as an axis orthogonal to solver choice.

FILE_IO_APIS = ["lp", "lp-polars", "mps"]
IO_APIS = FILE_IO_APIS + ["direct"]

We already have this distinction and have never named it. Naming it makes "which solvers can this model reach, and how" answerable.

3. Availability probing with no import side effects. Three separate mechanisms, all worth copying:

  • _has_module uses importlib.util.find_spec — never executes the package's __init__.
  • _LazyModule is a proxy importing on first attribute access, so gurobipy.Env can be referenced at module level while deferring the import "and its license-server side effects, for mindoptpy/coptpy".
  • _AvailableSolvers is a lazy Sequence[str] behind functools.cached_property, with an explicit refresh().

The license-server point is the non-obvious one: for commercial solvers, importing is not free.

4. Licensing is a separate, opt-in probe. Membership in available_solvers means importable, explicitly not licensed; check_solver_licenses() is the eager check. Conflating the two would make availability slow and network-dependent.

5. SolverFeature — Track 4, already built upstream. A 15-member enum declared per solver class as ClassVar[frozenset[SolverFeature]], queried via cls.supports(feature):

INTEGER_VARIABLES, QUADRATIC_OBJECTIVE, DIRECT_API, LP_FILE_NAMES,
READ_MODEL_FROM_FILE, SOLUTION_FILE_NOT_NEEDED, GPU_ACCELERATION, GPU_ONLY,
IIS_COMPUTATION, SOS_CONSTRAINTS, INDICATOR_CONSTRAINTS,
SEMI_CONTINUOUS_VARIABLES, SOLVER_ATTRIBUTE_ACCESS, MIP_DUAL_BOUND_REPORT

That list covers precisely what ARCHITECTURE names as the sink upgrade path (sos_sets, genconstr, a semi-continuous threshold on cols) plus what ROADMAP Track 2b wants (IIS_COMPUTATION).

The one place adoption is not 1:1 — and it matters

linopy attaches capability to the solver. ARCHITECTURE.md attaches it to the sink, and is explicit that "capability is not the ceiling":

lp_file carries [SOS] as a text section and Gurobi natively

Both models are right about different things. An LP file can serialise SOS whether or not the solver eventually reading it understands it; a direct API can accept SOS whether or not any file format was involved. So capability is really a property of the (sink, solver) pair, not of either alone.

Deciding this is the substantive design question here, and it should be settled before any code:

  • declare features on sinks only, and treat the solver as one more sink?
  • declare on both and intersect?
  • keep linopy's solver-only model and accept that check(model, sink=...) answers a narrower question than it appears to?

What we should not adopt

  • Stateful Solver objects. linopy's carries status, solution, solver_model, snapshot, _rebuilds, _in_place_updates, plus track_updates persistent-update machinery. Our api.py is deliberately three verbs and the executor owns lifetime; importing that state model would fight it. Warm starts are a separate conversation.
  • One 4,520-line module holding 13 solver classes.
  • Default-by-probe-order. _SOLVER_PROBE_ORDER makes the first installed solver the default in Model.solve. That means the same YAML solves with a different solver on a different machine — the opposite of "the file is the source of truth". We should require an explicit solver=, or at minimum make the implicit choice loud.

Incidental finding, relevant to #105

linopy hard-depends on polars>=1.31.1 and ships an lp-polars io_api — it writes LP files through polars. So the compat extra already pulls polars in transitively, and "polars is exotic in this ecosystem" is not a defensible argument against it.

Work this implies

  1. Move highspy to [highs]; add a [solvers] bundle; nothing installed by default.
  2. solve() with no solver installed raises naming the extras, as to_dataarray already does for xarray.
  3. Implement .mps — with no solver by default, the file sinks become the universal interface (see Nothing should be forced: make the solver, the dataframe library, and Arrow all optional #105).
  4. Split solver_direct behind a small ABC before a second direct solver exists, not after.
  5. Decide the sink-vs-solver capability axis above; that is ROADMAP Track 4.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineLowering, IR, relational executor, the sink contractarea:packagingDistribution, extras, dependency footprintarea:solverSolver sink, options, statuses, duals, value read-backdecisionA question to be answered, not work to be done; closes by resolution

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions