solverpilot 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- solverpilot-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import importlib.util
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy import sparse
|
|
9
|
+
|
|
10
|
+
from solverpilot.capabilities import BackendManifest, Capability, SupportLevel
|
|
11
|
+
from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem, VariableDomain
|
|
12
|
+
from solverpilot.problem.quadratic import require_confirmed_convexity
|
|
13
|
+
|
|
14
|
+
from .base import BackendSolveResult, BackendUnavailableError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _casadi_version() -> str | None:
|
|
18
|
+
try:
|
|
19
|
+
return version("casadi")
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
try:
|
|
22
|
+
import casadi as ca
|
|
23
|
+
return getattr(ca, "__version__", None)
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _plugin_available(plugin: str) -> bool:
|
|
29
|
+
if importlib.util.find_spec("casadi") is None:
|
|
30
|
+
return False
|
|
31
|
+
try:
|
|
32
|
+
import casadi as ca
|
|
33
|
+
token = f"Conic::{plugin}"
|
|
34
|
+
return token in str(ca.CasadiMeta_plugins()).split(";")
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dm_from_scipy(matrix: sparse.spmatrix):
|
|
40
|
+
import casadi as ca
|
|
41
|
+
csc = sparse.csc_matrix(matrix, dtype=np.float64, copy=True)
|
|
42
|
+
csc.sum_duplicates(); csc.eliminate_zeros(); csc.sort_indices()
|
|
43
|
+
sp = ca.Sparsity(csc.shape[0], csc.shape[1], csc.indptr.tolist(), csc.indices.tolist())
|
|
44
|
+
return ca.DM(sp, csc.data.tolist()), sp
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _status_from_stats(stats: dict[str, object], *, has_candidate: bool) -> str:
|
|
48
|
+
raw = " ".join(
|
|
49
|
+
str(stats.get(k, "")) for k in ("return_status", "secondary_return_status", "unified_return_status")
|
|
50
|
+
).lower()
|
|
51
|
+
if "infeasible or unbounded" in raw:
|
|
52
|
+
return "infeasible_or_unbounded"
|
|
53
|
+
if "primal infeasible" in raw or "not feasible" in raw or ("infeasible" in raw and "dual infeasible" not in raw):
|
|
54
|
+
return "infeasible"
|
|
55
|
+
if "dual infeasible" in raw or "unbounded" in raw:
|
|
56
|
+
return "unbounded"
|
|
57
|
+
if any(tok in raw for tok in ("time limit", "maximum iterations", "max_iter", "iteration limit")):
|
|
58
|
+
return "limit_feasible" if has_candidate else "limit_no_solution"
|
|
59
|
+
if bool(stats.get("success")):
|
|
60
|
+
return "optimal" if has_candidate else "solver_error"
|
|
61
|
+
return "converged_candidate" if has_candidate else "solver_error"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class CasadiConicBackend:
|
|
66
|
+
"""Development bridge to native conic plugins bundled with CasADi.
|
|
67
|
+
|
|
68
|
+
This is intentionally provenance-explicit: it verifies underlying solver engines in
|
|
69
|
+
environments where their dedicated Python packages are unavailable, but it is not a
|
|
70
|
+
substitute for the public highspy/osqp/PySCIPOpt adapters.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
plugin: str
|
|
74
|
+
time_limit_s: float | None = None
|
|
75
|
+
enable_primal_warm_start: bool = True
|
|
76
|
+
_solver_cache: object | None = field(default=None, init=False, repr=False)
|
|
77
|
+
_structure_hash_cache: str | None = field(default=None, init=False, repr=False)
|
|
78
|
+
_last_x_cache: np.ndarray | None = field(default=None, init=False, repr=False)
|
|
79
|
+
|
|
80
|
+
def __post_init__(self) -> None:
|
|
81
|
+
self.plugin = str(self.plugin).lower()
|
|
82
|
+
if self.plugin not in {"highs", "osqp", "cbc"}:
|
|
83
|
+
raise ValueError("supported CasADi conic plugins: highs, osqp, cbc")
|
|
84
|
+
if self.time_limit_s is not None and self.time_limit_s <= 0:
|
|
85
|
+
raise ValueError("time_limit_s must be positive")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def manifest(self) -> BackendManifest:
|
|
89
|
+
common = {
|
|
90
|
+
Capability.PRIMAL_START: SupportLevel.UNKNOWN,
|
|
91
|
+
Capability.DUAL_START: SupportLevel.UNKNOWN,
|
|
92
|
+
Capability.BASIS_START: SupportLevel.UNKNOWN,
|
|
93
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.UNKNOWN,
|
|
94
|
+
Capability.STRUCTURAL_INCREMENTAL_UPDATE: SupportLevel.UNSUPPORTED,
|
|
95
|
+
Capability.NATIVE_REOPTIMIZATION: SupportLevel.UNKNOWN,
|
|
96
|
+
Capability.CALLBACK_PROGRESS: SupportLevel.UNSUPPORTED,
|
|
97
|
+
Capability.IIS: SupportLevel.UNSUPPORTED,
|
|
98
|
+
Capability.INFEASIBILITY_CERTIFICATE: SupportLevel.UNKNOWN,
|
|
99
|
+
}
|
|
100
|
+
if self.plugin == "osqp":
|
|
101
|
+
caps = {
|
|
102
|
+
**common,
|
|
103
|
+
Capability.LP: SupportLevel.EMULATED_SAFE,
|
|
104
|
+
Capability.CONVEX_QP: SupportLevel.NATIVE,
|
|
105
|
+
Capability.MILP: SupportLevel.UNSUPPORTED,
|
|
106
|
+
Capability.PRIMAL_START: SupportLevel.NATIVE,
|
|
107
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.EMULATED_SAFE,
|
|
108
|
+
}
|
|
109
|
+
metadata = {"underlying_solver": "OSQP", "no_integer_support": True, "supports_wall_time_budget": False}
|
|
110
|
+
elif self.plugin == "cbc":
|
|
111
|
+
caps = {
|
|
112
|
+
Capability.LP: SupportLevel.NATIVE,
|
|
113
|
+
Capability.CONVEX_QP: SupportLevel.UNSUPPORTED,
|
|
114
|
+
Capability.MILP: SupportLevel.NATIVE,
|
|
115
|
+
**common,
|
|
116
|
+
}
|
|
117
|
+
metadata = {"underlying_solver": "CBC", "no_qp_support": True, "supports_wall_time_budget": False}
|
|
118
|
+
else:
|
|
119
|
+
caps = {
|
|
120
|
+
Capability.LP: SupportLevel.NATIVE,
|
|
121
|
+
Capability.CONVEX_QP: SupportLevel.NATIVE,
|
|
122
|
+
Capability.MILP: SupportLevel.NATIVE,
|
|
123
|
+
**common,
|
|
124
|
+
}
|
|
125
|
+
metadata = {"underlying_solver": "HiGHS", "supports_wall_time_budget": True}
|
|
126
|
+
return BackendManifest(
|
|
127
|
+
name=f"casadi-{self.plugin}-bridge",
|
|
128
|
+
version=_casadi_version(),
|
|
129
|
+
capabilities=caps,
|
|
130
|
+
metadata={
|
|
131
|
+
**metadata,
|
|
132
|
+
"native_adapter": False,
|
|
133
|
+
"development_bridge": True,
|
|
134
|
+
"verification_only": True,
|
|
135
|
+
"bridge_package": "casadi",
|
|
136
|
+
"bridge_package_version": _casadi_version(),
|
|
137
|
+
"verified_bridge_package_versions": ("3.7.2",),
|
|
138
|
+
"cross_library_backend": self.plugin in {"osqp", "cbc"},
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def is_available(self) -> bool:
|
|
143
|
+
return _plugin_available(self.plugin)
|
|
144
|
+
|
|
145
|
+
def _opts(self, discrete: list[bool]) -> dict[str, object]:
|
|
146
|
+
opts: dict[str, object] = {"error_on_fail": False}
|
|
147
|
+
if self.plugin in {"highs", "cbc"}:
|
|
148
|
+
opts["discrete"] = discrete
|
|
149
|
+
if self.plugin == "highs":
|
|
150
|
+
inner: dict[str, object] = {"output_flag": False}
|
|
151
|
+
if self.time_limit_s is not None:
|
|
152
|
+
inner["time_limit"] = float(self.time_limit_s)
|
|
153
|
+
opts["highs"] = inner
|
|
154
|
+
elif self.plugin == "osqp":
|
|
155
|
+
if self.time_limit_s is not None:
|
|
156
|
+
raise NotImplementedError("CasADi-OSQP time budget mapping is not verified")
|
|
157
|
+
opts["osqp"] = {
|
|
158
|
+
"verbose": False,
|
|
159
|
+
"polish": True,
|
|
160
|
+
"eps_abs": 1e-7,
|
|
161
|
+
"eps_rel": 1e-7,
|
|
162
|
+
"max_iter": 10000,
|
|
163
|
+
}
|
|
164
|
+
elif self.plugin == "cbc":
|
|
165
|
+
inner = {"logLevel": 0}
|
|
166
|
+
# CBC option names vary across Osi/Cbc layers. Do not claim time-limit
|
|
167
|
+
# enforcement until a dedicated integration test proves the mapping.
|
|
168
|
+
if self.time_limit_s is not None:
|
|
169
|
+
raise NotImplementedError("CasADi-CBC time budget mapping is not verified")
|
|
170
|
+
opts["cbc"] = inner
|
|
171
|
+
return opts
|
|
172
|
+
|
|
173
|
+
def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
174
|
+
if self.plugin == "highs":
|
|
175
|
+
# CasADi's bundled HiGHS and highspy may expose incompatible native
|
|
176
|
+
# ABIs under the same library name. A fresh process keeps the plugin
|
|
177
|
+
# loader away from libraries already loaded by other backends.
|
|
178
|
+
from ._casadi_highs_worker import solve_isolated
|
|
179
|
+
return solve_isolated(self, problem)
|
|
180
|
+
return self._solve_in_process(problem)
|
|
181
|
+
|
|
182
|
+
def _solve_in_process(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
183
|
+
if not self.is_available():
|
|
184
|
+
raise BackendUnavailableError(f"CasADi conic plugin is unavailable: {self.plugin}")
|
|
185
|
+
import casadi as ca
|
|
186
|
+
|
|
187
|
+
if isinstance(problem, QuadraticProblem):
|
|
188
|
+
require_confirmed_convexity(problem)
|
|
189
|
+
if self.plugin == "cbc":
|
|
190
|
+
raise ValueError("CBC bridge does not support QP")
|
|
191
|
+
linear = problem.linear
|
|
192
|
+
P = problem.P.tocsc()
|
|
193
|
+
elif isinstance(problem, LinearProblem):
|
|
194
|
+
linear = problem
|
|
195
|
+
P = sparse.csc_matrix((linear.n_variables, linear.n_variables), dtype=np.float64)
|
|
196
|
+
if linear.has_integer_variables and self.plugin == "osqp":
|
|
197
|
+
raise ValueError("OSQP bridge does not support integer variables")
|
|
198
|
+
else:
|
|
199
|
+
raise TypeError(f"unsupported problem type: {type(problem)!r}")
|
|
200
|
+
|
|
201
|
+
if isinstance(problem, QuadraticProblem) and linear.objective_sense is ObjectiveSense.MAXIMIZE:
|
|
202
|
+
raise ValueError("convex QP bridge supports minimization only")
|
|
203
|
+
|
|
204
|
+
n = linear.n_variables
|
|
205
|
+
A_dm, A_sp = _dm_from_scipy(linear.A)
|
|
206
|
+
P_dm, P_sp = _dm_from_scipy(P)
|
|
207
|
+
# CBC asserts that the Hessian sparsity itself has no nonzeros for LP/MILP.
|
|
208
|
+
if self.plugin == "cbc" and not isinstance(problem, QuadraticProblem):
|
|
209
|
+
P_sp = ca.Sparsity(n, n)
|
|
210
|
+
P_dm = ca.DM.zeros(P_sp)
|
|
211
|
+
|
|
212
|
+
discrete = [d != VariableDomain.CONTINUOUS.value for d in linear.domains]
|
|
213
|
+
g = np.asarray(linear.c, dtype=np.float64).copy()
|
|
214
|
+
sign = 1.0
|
|
215
|
+
if linear.objective_sense is ObjectiveSense.MAXIMIZE:
|
|
216
|
+
g *= -1.0
|
|
217
|
+
sign = -1.0
|
|
218
|
+
|
|
219
|
+
reused_primal_start = False
|
|
220
|
+
if self.plugin == "osqp" and self.enable_primal_warm_start and self._solver_cache is not None and self._structure_hash_cache == problem.structural_hash:
|
|
221
|
+
solver = self._solver_cache
|
|
222
|
+
else:
|
|
223
|
+
solver = ca.conic(
|
|
224
|
+
"solverpilot_casadi_conic",
|
|
225
|
+
self.plugin,
|
|
226
|
+
{"h": P_sp, "a": A_sp},
|
|
227
|
+
self._opts(discrete),
|
|
228
|
+
)
|
|
229
|
+
if self.plugin == "osqp" and self.enable_primal_warm_start:
|
|
230
|
+
self._solver_cache = solver
|
|
231
|
+
self._structure_hash_cache = problem.structural_hash
|
|
232
|
+
self._last_x_cache = None
|
|
233
|
+
kwargs = dict(
|
|
234
|
+
h=P_dm,
|
|
235
|
+
g=ca.DM(g),
|
|
236
|
+
a=A_dm,
|
|
237
|
+
lba=ca.DM(np.asarray(linear.constraint_lower, dtype=np.float64)),
|
|
238
|
+
uba=ca.DM(np.asarray(linear.constraint_upper, dtype=np.float64)),
|
|
239
|
+
lbx=ca.DM(np.asarray(linear.variable_lower, dtype=np.float64)),
|
|
240
|
+
ubx=ca.DM(np.asarray(linear.variable_upper, dtype=np.float64)),
|
|
241
|
+
)
|
|
242
|
+
if self.plugin == "osqp" and self.enable_primal_warm_start and self._last_x_cache is not None and self._last_x_cache.shape == (n,):
|
|
243
|
+
kwargs["x0"] = ca.DM(self._last_x_cache)
|
|
244
|
+
reused_primal_start = True
|
|
245
|
+
error = None
|
|
246
|
+
result = None
|
|
247
|
+
try:
|
|
248
|
+
result = solver(**kwargs)
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
stats = dict(solver.stats())
|
|
254
|
+
except Exception:
|
|
255
|
+
stats = {}
|
|
256
|
+
|
|
257
|
+
x = None
|
|
258
|
+
objective_reported = None
|
|
259
|
+
if result is not None and "x" in result:
|
|
260
|
+
arr = np.asarray(result["x"], dtype=np.float64).reshape(-1)
|
|
261
|
+
raw_status = " ".join(str(stats.get(k, "")) for k in ("return_status", "secondary_return_status", "unified_return_status")).lower()
|
|
262
|
+
terminal_bad = any(tok in raw_status for tok in ("infeasible", "not feasible", "unbounded"))
|
|
263
|
+
if arr.shape == (n,) and np.all(np.isfinite(arr)) and not terminal_bad:
|
|
264
|
+
x = arr
|
|
265
|
+
if self.plugin == "osqp" and self.enable_primal_warm_start:
|
|
266
|
+
self._last_x_cache = arr.copy()
|
|
267
|
+
try:
|
|
268
|
+
solver_cost = float(result["cost"])
|
|
269
|
+
objective_reported = sign * solver_cost + float(linear.objective_offset)
|
|
270
|
+
except Exception:
|
|
271
|
+
objective_reported = None
|
|
272
|
+
|
|
273
|
+
backend_status = _status_from_stats(stats, has_candidate=x is not None)
|
|
274
|
+
stats.update(
|
|
275
|
+
{
|
|
276
|
+
"casadi_plugin": self.plugin,
|
|
277
|
+
"casadi_version": _casadi_version(),
|
|
278
|
+
"error": error,
|
|
279
|
+
"reuse_applied": bool(reused_primal_start),
|
|
280
|
+
"reuse_mode": "casadi_osqp_primal_warm_start" if reused_primal_start else "cold_casadi_bridge_solve",
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
return BackendSolveResult(
|
|
284
|
+
backend_status=backend_status,
|
|
285
|
+
x=x,
|
|
286
|
+
objective_reported=objective_reported,
|
|
287
|
+
raw_statistics=stats,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(slots=True)
|
|
292
|
+
class CasadiOSQPBridgeBackend(CasadiConicBackend):
|
|
293
|
+
plugin: str = field(default="osqp", init=False)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(slots=True)
|
|
297
|
+
class CasadiHighsBridgeBackend(CasadiConicBackend):
|
|
298
|
+
plugin: str = field(default="highs", init=False)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(slots=True)
|
|
302
|
+
class CasadiCBCBridgeBackend(CasadiConicBackend):
|
|
303
|
+
plugin: str = field(default="cbc", init=False)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from time import perf_counter
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from scipy import sparse
|
|
10
|
+
|
|
11
|
+
from solverpilot.capabilities import Capability, SupportLevel
|
|
12
|
+
from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem, VariableDomain
|
|
13
|
+
|
|
14
|
+
from .base import Backend
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BackendProbeStatus(str, Enum):
|
|
18
|
+
HEALTHY = "healthy"
|
|
19
|
+
UNHEALTHY = "unhealthy"
|
|
20
|
+
UNAVAILABLE = "unavailable"
|
|
21
|
+
NO_SMOKE_CAPABILITY = "no_smoke_capability"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class BackendProbeCheck:
|
|
26
|
+
capability: Capability
|
|
27
|
+
passed: bool
|
|
28
|
+
public_status: str | None
|
|
29
|
+
validation_valid: bool | None
|
|
30
|
+
objective: float | None
|
|
31
|
+
expected_objective: float
|
|
32
|
+
objective_abs_error: float | None
|
|
33
|
+
duration_s: float
|
|
34
|
+
error: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class BackendHealthReport:
|
|
39
|
+
backend: str
|
|
40
|
+
version: str | None
|
|
41
|
+
available: bool
|
|
42
|
+
status: BackendProbeStatus
|
|
43
|
+
checks: tuple[BackendProbeCheck, ...]
|
|
44
|
+
metadata: dict[str, object]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def healthy(self) -> bool:
|
|
48
|
+
return self.status is BackendProbeStatus.HEALTHY
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _supports_smoke(manifest, capability: Capability) -> bool:
|
|
52
|
+
return manifest.support(capability) in {SupportLevel.NATIVE, SupportLevel.EMULATED_SAFE}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _lp_smoke() -> tuple[LinearProblem, float]:
|
|
56
|
+
# min x, 1 <= x <= 2 -> optimum 1
|
|
57
|
+
problem = LinearProblem.from_data(
|
|
58
|
+
A=[[1.0]],
|
|
59
|
+
c=[1.0],
|
|
60
|
+
variable_lower=[0.0],
|
|
61
|
+
variable_upper=[2.0],
|
|
62
|
+
constraint_lower=[1.0],
|
|
63
|
+
constraint_upper=[np.inf],
|
|
64
|
+
domains=[VariableDomain.CONTINUOUS],
|
|
65
|
+
name="solverpilot_backend_probe_lp",
|
|
66
|
+
)
|
|
67
|
+
return problem, 1.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _milp_smoke() -> tuple[LinearProblem, float]:
|
|
71
|
+
# max x + 2y, x+y <= 1, x,y binary -> optimum 2
|
|
72
|
+
problem = LinearProblem.from_data(
|
|
73
|
+
A=[[1.0, 1.0]],
|
|
74
|
+
c=[1.0, 2.0],
|
|
75
|
+
variable_lower=[0.0, 0.0],
|
|
76
|
+
variable_upper=[1.0, 1.0],
|
|
77
|
+
constraint_lower=[-np.inf],
|
|
78
|
+
constraint_upper=[1.0],
|
|
79
|
+
domains=[VariableDomain.BINARY, VariableDomain.BINARY],
|
|
80
|
+
objective_sense=ObjectiveSense.MAXIMIZE,
|
|
81
|
+
name="solverpilot_backend_probe_milp",
|
|
82
|
+
)
|
|
83
|
+
return problem, 2.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# QuadraticProblem.from_data deliberately has no name parameter. Keep the smoke factory
|
|
87
|
+
# separate from public metadata so backend probing never relies on optional labels.
|
|
88
|
+
def _qp_smoke() -> tuple[QuadraticProblem, float]: # type: ignore[no-redef]
|
|
89
|
+
problem = QuadraticProblem.from_data(
|
|
90
|
+
P=[[2.0]],
|
|
91
|
+
A=sparse.csr_matrix((0, 1), dtype=np.float64),
|
|
92
|
+
q=[-4.0],
|
|
93
|
+
variable_lower=[0.0],
|
|
94
|
+
variable_upper=[3.0],
|
|
95
|
+
constraint_lower=[],
|
|
96
|
+
constraint_upper=[],
|
|
97
|
+
)
|
|
98
|
+
return problem, -4.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check_one(
|
|
102
|
+
backend: Backend,
|
|
103
|
+
capability: Capability,
|
|
104
|
+
*,
|
|
105
|
+
atol: float,
|
|
106
|
+
rtol: float,
|
|
107
|
+
) -> BackendProbeCheck:
|
|
108
|
+
if capability is Capability.LP:
|
|
109
|
+
problem, expected = _lp_smoke()
|
|
110
|
+
elif capability is Capability.MILP:
|
|
111
|
+
problem, expected = _milp_smoke()
|
|
112
|
+
elif capability is Capability.CONVEX_QP:
|
|
113
|
+
problem, expected = _qp_smoke()
|
|
114
|
+
else: # pragma: no cover - guarded by caller
|
|
115
|
+
raise ValueError(f"unsupported smoke capability: {capability}")
|
|
116
|
+
|
|
117
|
+
# Lazy import avoids a backends -> runtime -> backends import cycle.
|
|
118
|
+
from solverpilot.runtime.executor import execute
|
|
119
|
+
|
|
120
|
+
t0 = perf_counter()
|
|
121
|
+
try:
|
|
122
|
+
result = execute(problem, backend)
|
|
123
|
+
duration = perf_counter() - t0
|
|
124
|
+
valid = bool(result.validation and result.validation.valid)
|
|
125
|
+
objective = None if result.objective is None else float(result.objective)
|
|
126
|
+
error = None if objective is None else abs(objective - expected)
|
|
127
|
+
passed = bool(
|
|
128
|
+
valid
|
|
129
|
+
and objective is not None
|
|
130
|
+
and np.isclose(objective, expected, atol=atol, rtol=rtol)
|
|
131
|
+
)
|
|
132
|
+
return BackendProbeCheck(
|
|
133
|
+
capability=capability,
|
|
134
|
+
passed=passed,
|
|
135
|
+
public_status=result.status.value,
|
|
136
|
+
validation_valid=None if result.validation is None else bool(result.validation.valid),
|
|
137
|
+
objective=objective,
|
|
138
|
+
expected_objective=expected,
|
|
139
|
+
objective_abs_error=error,
|
|
140
|
+
duration_s=duration,
|
|
141
|
+
error=None if passed else "smoke solve did not match validated expected objective",
|
|
142
|
+
)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
return BackendProbeCheck(
|
|
145
|
+
capability=capability,
|
|
146
|
+
passed=False,
|
|
147
|
+
public_status=None,
|
|
148
|
+
validation_valid=None,
|
|
149
|
+
objective=None,
|
|
150
|
+
expected_objective=expected,
|
|
151
|
+
objective_abs_error=None,
|
|
152
|
+
duration_s=perf_counter() - t0,
|
|
153
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def probe_backend(
|
|
158
|
+
backend: Backend,
|
|
159
|
+
*,
|
|
160
|
+
atol: float = 1e-6,
|
|
161
|
+
rtol: float = 1e-7,
|
|
162
|
+
) -> BackendHealthReport:
|
|
163
|
+
"""Actively verify an adapter on tiny canonical problems.
|
|
164
|
+
|
|
165
|
+
``is_available()`` only proves that an import path exists. This probe additionally
|
|
166
|
+
asks every supported core problem class (LP, MILP, convex QP) to produce a solution
|
|
167
|
+
that passes SolverPilot's independent validator and matches a known objective.
|
|
168
|
+
|
|
169
|
+
Probing is explicit because it executes solvers and may initialize native libraries.
|
|
170
|
+
It is therefore never run implicitly inside :func:`solverpilot.solve`.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
manifest = backend.manifest
|
|
174
|
+
metadata = dict(manifest.metadata)
|
|
175
|
+
if not backend.is_available():
|
|
176
|
+
return BackendHealthReport(
|
|
177
|
+
backend=manifest.name,
|
|
178
|
+
version=manifest.version,
|
|
179
|
+
available=False,
|
|
180
|
+
status=BackendProbeStatus.UNAVAILABLE,
|
|
181
|
+
checks=(),
|
|
182
|
+
metadata=metadata,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
capabilities = tuple(
|
|
186
|
+
cap
|
|
187
|
+
for cap in (Capability.LP, Capability.MILP, Capability.CONVEX_QP)
|
|
188
|
+
if _supports_smoke(manifest, cap)
|
|
189
|
+
)
|
|
190
|
+
if not capabilities:
|
|
191
|
+
return BackendHealthReport(
|
|
192
|
+
backend=manifest.name,
|
|
193
|
+
version=manifest.version,
|
|
194
|
+
available=True,
|
|
195
|
+
status=BackendProbeStatus.NO_SMOKE_CAPABILITY,
|
|
196
|
+
checks=(),
|
|
197
|
+
metadata=metadata,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
cleanup_error: str | None = None
|
|
201
|
+
try:
|
|
202
|
+
checks = tuple(_check_one(backend, cap, atol=atol, rtol=rtol) for cap in capabilities)
|
|
203
|
+
finally:
|
|
204
|
+
close = getattr(backend, "close", None)
|
|
205
|
+
if callable(close):
|
|
206
|
+
try:
|
|
207
|
+
close()
|
|
208
|
+
reset_scheduler = getattr(backend, "reset_global_scheduler", None)
|
|
209
|
+
if callable(reset_scheduler):
|
|
210
|
+
# Health probes are sequential. HiGHS documents that its global
|
|
211
|
+
# scheduler survives Highs_destroy and must be reset to release
|
|
212
|
+
# all scheduler resources between independent verification runs.
|
|
213
|
+
reset_scheduler(blocking=True)
|
|
214
|
+
except Exception as exc: # cleanup failures are part of backend health
|
|
215
|
+
cleanup_error = f"{type(exc).__name__}: {exc}"
|
|
216
|
+
|
|
217
|
+
if cleanup_error is not None:
|
|
218
|
+
metadata["cleanup_error"] = cleanup_error
|
|
219
|
+
status = (
|
|
220
|
+
BackendProbeStatus.HEALTHY
|
|
221
|
+
if all(check.passed for check in checks) and cleanup_error is None
|
|
222
|
+
else BackendProbeStatus.UNHEALTHY
|
|
223
|
+
)
|
|
224
|
+
return BackendHealthReport(
|
|
225
|
+
backend=manifest.name,
|
|
226
|
+
version=manifest.version,
|
|
227
|
+
available=True,
|
|
228
|
+
status=status,
|
|
229
|
+
checks=checks,
|
|
230
|
+
metadata=metadata,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def probe_backends(
|
|
235
|
+
backends: Iterable[Backend],
|
|
236
|
+
*,
|
|
237
|
+
atol: float = 1e-6,
|
|
238
|
+
rtol: float = 1e-7,
|
|
239
|
+
) -> tuple[BackendHealthReport, ...]:
|
|
240
|
+
return tuple(probe_backend(b, atol=atol, rtol=rtol) for b in backends)
|