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,297 @@
|
|
|
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, QuadraticProblem
|
|
12
|
+
from solverpilot.problem.quadratic import require_confirmed_convexity
|
|
13
|
+
|
|
14
|
+
from .base import BackendSolveResult, BackendUnavailableError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class OSQPData:
|
|
19
|
+
P: sparse.csc_matrix
|
|
20
|
+
q: np.ndarray
|
|
21
|
+
A: sparse.csc_matrix
|
|
22
|
+
l: np.ndarray
|
|
23
|
+
u: np.ndarray
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _osqp_data(problem: QuadraticProblem) -> OSQPData:
|
|
27
|
+
"""Translate canonical QP data into OSQP's l <= A x <= u form.
|
|
28
|
+
|
|
29
|
+
Canonical variable bounds are appended as identity rows. P is reduced to its
|
|
30
|
+
upper triangle because OSQP's Python interface consumes only that triangle.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
if not isinstance(problem, QuadraticProblem):
|
|
34
|
+
raise TypeError("OSQP native backend supports QuadraticProblem only")
|
|
35
|
+
n = problem.n_variables
|
|
36
|
+
P = sparse.triu(problem.P, format="csc")
|
|
37
|
+
base_A = problem.linear.A.tocsc()
|
|
38
|
+
identity = sparse.eye(n, format="csc")
|
|
39
|
+
A = sparse.vstack([base_A, identity], format="csc")
|
|
40
|
+
l = np.concatenate([problem.linear.constraint_lower, problem.linear.variable_lower])
|
|
41
|
+
u = np.concatenate([problem.linear.constraint_upper, problem.linear.variable_upper])
|
|
42
|
+
return OSQPData(
|
|
43
|
+
P=P,
|
|
44
|
+
q=np.asarray(problem.linear.c, dtype=np.float64),
|
|
45
|
+
A=A,
|
|
46
|
+
l=np.asarray(l, dtype=np.float64),
|
|
47
|
+
u=np.asarray(u, dtype=np.float64),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _osqp_status(status: str, has_solution: bool) -> str:
|
|
52
|
+
s = status.strip().lower()
|
|
53
|
+
if s == "solved":
|
|
54
|
+
return "optimal"
|
|
55
|
+
if s == "solved inaccurate":
|
|
56
|
+
return "converged_candidate"
|
|
57
|
+
if s == "primal infeasible":
|
|
58
|
+
return "infeasible"
|
|
59
|
+
if s == "primal infeasible inaccurate":
|
|
60
|
+
return "infeasible_candidate"
|
|
61
|
+
if s == "dual infeasible":
|
|
62
|
+
return "unbounded"
|
|
63
|
+
if s == "dual infeasible inaccurate":
|
|
64
|
+
return "unbounded_candidate"
|
|
65
|
+
if any(token in s for token in ("maximum iterations", "run time limit", "time limit")):
|
|
66
|
+
return "limit_feasible" if has_solution else "limit_no_solution"
|
|
67
|
+
if "non convex" in s or "error" in s:
|
|
68
|
+
return "solver_error"
|
|
69
|
+
return "unknown"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _primal_certificate_check(data: OSQPData, certificate: np.ndarray | None, *, eps: float) -> dict[str, object]:
|
|
73
|
+
if certificate is None:
|
|
74
|
+
return {"valid": False, "reason": "missing_certificate"}
|
|
75
|
+
v = np.asarray(certificate, dtype=np.float64)
|
|
76
|
+
if v.shape != (data.A.shape[0],) or not np.all(np.isfinite(v)):
|
|
77
|
+
return {"valid": False, "reason": "invalid_shape_or_nonfinite"}
|
|
78
|
+
atv = np.asarray(data.A.T @ v).reshape(-1)
|
|
79
|
+
stationarity_inf = float(np.max(np.abs(atv), initial=0.0))
|
|
80
|
+
pos = v > 0.0
|
|
81
|
+
neg = v < 0.0
|
|
82
|
+
if np.any(pos & np.isposinf(data.u)) or np.any(neg & np.isneginf(data.l)):
|
|
83
|
+
separating_value = float("inf")
|
|
84
|
+
else:
|
|
85
|
+
separating_value = float(np.dot(data.u[pos], v[pos]) + np.dot(data.l[neg], v[neg]))
|
|
86
|
+
valid = stationarity_inf <= eps and np.isfinite(separating_value) and separating_value < 0.0
|
|
87
|
+
return {
|
|
88
|
+
"valid": bool(valid),
|
|
89
|
+
"stationarity_inf": stationarity_inf,
|
|
90
|
+
"separating_value": separating_value,
|
|
91
|
+
"eps": float(eps),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dual_certificate_check(problem: QuadraticProblem, data: OSQPData, certificate: np.ndarray | None, *, eps: float) -> dict[str, object]:
|
|
96
|
+
if certificate is None:
|
|
97
|
+
return {"valid": False, "reason": "missing_certificate"}
|
|
98
|
+
s = np.asarray(certificate, dtype=np.float64)
|
|
99
|
+
if s.shape != (problem.n_variables,) or not np.all(np.isfinite(s)):
|
|
100
|
+
return {"valid": False, "reason": "invalid_shape_or_nonfinite"}
|
|
101
|
+
ps = np.asarray(problem.P @ s).reshape(-1)
|
|
102
|
+
ps_inf = float(np.max(np.abs(ps), initial=0.0))
|
|
103
|
+
q_dot_s = float(problem.linear.c @ s)
|
|
104
|
+
a_s = np.asarray(data.A @ s).reshape(-1)
|
|
105
|
+
finite_l = np.isfinite(data.l)
|
|
106
|
+
finite_u = np.isfinite(data.u)
|
|
107
|
+
both = finite_l & finite_u
|
|
108
|
+
lower_only = finite_l & ~finite_u
|
|
109
|
+
upper_only = ~finite_l & finite_u
|
|
110
|
+
row_violation = 0.0
|
|
111
|
+
if np.any(both):
|
|
112
|
+
row_violation = max(row_violation, float(np.max(np.abs(a_s[both]), initial=0.0)))
|
|
113
|
+
if np.any(lower_only):
|
|
114
|
+
row_violation = max(row_violation, float(np.max(np.maximum(-a_s[lower_only], 0.0), initial=0.0)))
|
|
115
|
+
if np.any(upper_only):
|
|
116
|
+
row_violation = max(row_violation, float(np.max(np.maximum(a_s[upper_only], 0.0), initial=0.0)))
|
|
117
|
+
valid = ps_inf <= eps and q_dot_s < 0.0 and row_violation <= eps
|
|
118
|
+
return {
|
|
119
|
+
"valid": bool(valid),
|
|
120
|
+
"ps_inf": ps_inf,
|
|
121
|
+
"q_dot_s": q_dot_s,
|
|
122
|
+
"row_sign_violation": row_violation,
|
|
123
|
+
"eps": float(eps),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(slots=True)
|
|
128
|
+
class OSQPNativeBackend:
|
|
129
|
+
"""Optional native OSQP adapter with same-sparsity state reuse.
|
|
130
|
+
|
|
131
|
+
The adapter is import-safe when OSQP is absent. When the same backend object is
|
|
132
|
+
reused for a QP whose structural hash is unchanged, it calls OSQP's documented
|
|
133
|
+
``update`` API and relies on OSQP's automatic primal/dual warm start.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
time_limit_s: float | None = None
|
|
137
|
+
max_iter: int | None = None
|
|
138
|
+
eps_abs: float | None = None
|
|
139
|
+
eps_rel: float | None = None
|
|
140
|
+
eps_prim_inf: float = 1e-4
|
|
141
|
+
eps_dual_inf: float = 1e-4
|
|
142
|
+
polishing: bool = False
|
|
143
|
+
_solver: object | None = field(default=None, init=False, repr=False)
|
|
144
|
+
_structural_hash: str | None = field(default=None, init=False, repr=False)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def manifest(self) -> BackendManifest:
|
|
148
|
+
pkg_version = None
|
|
149
|
+
if self.is_available():
|
|
150
|
+
try:
|
|
151
|
+
pkg_version = version("osqp")
|
|
152
|
+
except PackageNotFoundError:
|
|
153
|
+
pass
|
|
154
|
+
return BackendManifest(
|
|
155
|
+
name="osqp-native",
|
|
156
|
+
version=pkg_version,
|
|
157
|
+
capabilities={
|
|
158
|
+
Capability.LP: SupportLevel.UNSUPPORTED,
|
|
159
|
+
Capability.CONVEX_QP: SupportLevel.NATIVE,
|
|
160
|
+
Capability.MILP: SupportLevel.UNSUPPORTED,
|
|
161
|
+
Capability.MIP_START: SupportLevel.UNSUPPORTED,
|
|
162
|
+
Capability.PRIMAL_START: SupportLevel.NATIVE,
|
|
163
|
+
Capability.DUAL_START: SupportLevel.NATIVE,
|
|
164
|
+
Capability.BASIS_START: SupportLevel.UNSUPPORTED,
|
|
165
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.NATIVE,
|
|
166
|
+
Capability.STRUCTURAL_INCREMENTAL_UPDATE: SupportLevel.UNSUPPORTED,
|
|
167
|
+
Capability.NATIVE_REOPTIMIZATION: SupportLevel.UNSUPPORTED,
|
|
168
|
+
Capability.CALLBACK_PROGRESS: SupportLevel.UNSUPPORTED,
|
|
169
|
+
Capability.IIS: SupportLevel.UNSUPPORTED,
|
|
170
|
+
Capability.INFEASIBILITY_CERTIFICATE: SupportLevel.NATIVE,
|
|
171
|
+
},
|
|
172
|
+
metadata={
|
|
173
|
+
"native_adapter": True,
|
|
174
|
+
"stateful": True,
|
|
175
|
+
"reuse_semantics": "same sparsity update + OSQP automatic warm start",
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def is_available(self) -> bool:
|
|
180
|
+
return importlib.util.find_spec("osqp") is not None
|
|
181
|
+
|
|
182
|
+
def _settings(self) -> dict[str, object]:
|
|
183
|
+
settings: dict[str, object] = {
|
|
184
|
+
"verbose": False,
|
|
185
|
+
"warm_starting": True,
|
|
186
|
+
"polishing": bool(self.polishing),
|
|
187
|
+
}
|
|
188
|
+
if self.time_limit_s is not None:
|
|
189
|
+
if self.time_limit_s <= 0:
|
|
190
|
+
raise ValueError("time_limit_s must be positive")
|
|
191
|
+
settings["time_limit"] = float(self.time_limit_s)
|
|
192
|
+
if self.max_iter is not None:
|
|
193
|
+
if self.max_iter <= 0:
|
|
194
|
+
raise ValueError("max_iter must be positive")
|
|
195
|
+
settings["max_iter"] = int(self.max_iter)
|
|
196
|
+
if self.eps_abs is not None:
|
|
197
|
+
if self.eps_abs <= 0:
|
|
198
|
+
raise ValueError("eps_abs must be positive")
|
|
199
|
+
settings["eps_abs"] = float(self.eps_abs)
|
|
200
|
+
if self.eps_rel is not None:
|
|
201
|
+
if self.eps_rel <= 0:
|
|
202
|
+
raise ValueError("eps_rel must be positive")
|
|
203
|
+
settings["eps_rel"] = float(self.eps_rel)
|
|
204
|
+
if self.eps_prim_inf <= 0 or self.eps_dual_inf <= 0:
|
|
205
|
+
raise ValueError("OSQP infeasibility tolerances must be positive")
|
|
206
|
+
settings["eps_prim_inf"] = float(self.eps_prim_inf)
|
|
207
|
+
settings["eps_dual_inf"] = float(self.eps_dual_inf)
|
|
208
|
+
return settings
|
|
209
|
+
|
|
210
|
+
def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
211
|
+
if not self.is_available():
|
|
212
|
+
raise BackendUnavailableError("OSQP is not installed")
|
|
213
|
+
if not isinstance(problem, QuadraticProblem):
|
|
214
|
+
raise ValueError("osqp-native supports convex continuous QP only")
|
|
215
|
+
|
|
216
|
+
import osqp
|
|
217
|
+
|
|
218
|
+
data = _osqp_data(problem)
|
|
219
|
+
reuse_applied = self._solver is not None and self._structural_hash == problem.structural_hash
|
|
220
|
+
if not reuse_applied:
|
|
221
|
+
solver = osqp.OSQP()
|
|
222
|
+
solver.setup(P=data.P, q=data.q, A=data.A, l=data.l, u=data.u, **self._settings())
|
|
223
|
+
self._solver = solver
|
|
224
|
+
self._structural_hash = problem.structural_hash
|
|
225
|
+
reuse_mode = "cold_setup"
|
|
226
|
+
else:
|
|
227
|
+
# OSQP documents vector updates and P/A value updates with unchanged sparsity.
|
|
228
|
+
# Supplying all numerical values avoids guessing which mutation occurred.
|
|
229
|
+
self._solver.update(q=data.q, l=data.l, u=data.u, Px=data.P.data, Ax=data.A.data)
|
|
230
|
+
# Mutable settings may be changed without setup; keep configured values aligned.
|
|
231
|
+
update_settings = {
|
|
232
|
+
k: v
|
|
233
|
+
for k, v in self._settings().items()
|
|
234
|
+
if k in {"verbose", "warm_starting", "polishing", "time_limit", "max_iter", "eps_abs", "eps_rel", "eps_prim_inf", "eps_dual_inf"}
|
|
235
|
+
}
|
|
236
|
+
if update_settings:
|
|
237
|
+
self._solver.update_settings(**update_settings)
|
|
238
|
+
reuse_mode = "same_sparsity_update+automatic_warm_start"
|
|
239
|
+
|
|
240
|
+
result = self._solver.solve(raise_error=False)
|
|
241
|
+
info = result.info
|
|
242
|
+
status_text = str(info.status)
|
|
243
|
+
x_raw = getattr(result, "x", None)
|
|
244
|
+
x_candidate = None if x_raw is None else np.asarray(x_raw, dtype=np.float64)
|
|
245
|
+
has_finite_solution = x_candidate is not None and x_candidate.shape == (problem.n_variables,) and np.all(np.isfinite(x_candidate))
|
|
246
|
+
backend_status = _osqp_status(status_text, has_finite_solution)
|
|
247
|
+
certificate_kind = None
|
|
248
|
+
certificate_check = None
|
|
249
|
+
status_lower = status_text.strip().lower()
|
|
250
|
+
if status_lower.startswith("primal infeasible"):
|
|
251
|
+
certificate_kind = "primal_infeasibility"
|
|
252
|
+
multiplier = 10.0 if status_lower.endswith("inaccurate") else 1.0
|
|
253
|
+
certificate_check = _primal_certificate_check(
|
|
254
|
+
data, getattr(result, "prim_inf_cert", None), eps=self.eps_prim_inf * multiplier
|
|
255
|
+
)
|
|
256
|
+
if status_lower == "primal infeasible" and not certificate_check.get("valid"):
|
|
257
|
+
backend_status = "infeasible_candidate"
|
|
258
|
+
elif status_lower.startswith("dual infeasible"):
|
|
259
|
+
certificate_kind = "dual_infeasibility"
|
|
260
|
+
multiplier = 10.0 if status_lower.endswith("inaccurate") else 1.0
|
|
261
|
+
certificate_check = _dual_certificate_check(
|
|
262
|
+
problem, data, getattr(result, "dual_inf_cert", None), eps=self.eps_dual_inf * multiplier
|
|
263
|
+
)
|
|
264
|
+
if status_lower == "dual infeasible" and not certificate_check.get("valid"):
|
|
265
|
+
backend_status = "unbounded_candidate"
|
|
266
|
+
|
|
267
|
+
x = x_candidate if backend_status in {"optimal", "converged_candidate", "limit_feasible"} and has_finite_solution else None
|
|
268
|
+
|
|
269
|
+
objective = None
|
|
270
|
+
objective_internal = None
|
|
271
|
+
if x is not None:
|
|
272
|
+
raw_obj = getattr(info, "obj_val", None)
|
|
273
|
+
if raw_obj is not None and np.isfinite(float(raw_obj)):
|
|
274
|
+
objective_internal = float(raw_obj)
|
|
275
|
+
objective = objective_internal + float(problem.linear.objective_offset)
|
|
276
|
+
|
|
277
|
+
raw: dict[str, object] = {
|
|
278
|
+
"osqp_status": status_text,
|
|
279
|
+
"status_val": int(getattr(info, "status_val", 0)),
|
|
280
|
+
"iter": int(getattr(info, "iter", 0)),
|
|
281
|
+
"setup_time": float(getattr(info, "setup_time", 0.0)),
|
|
282
|
+
"solve_time": float(getattr(info, "solve_time", 0.0)),
|
|
283
|
+
"update_time": float(getattr(info, "update_time", 0.0)),
|
|
284
|
+
"polish_time": float(getattr(info, "polish_time", 0.0)),
|
|
285
|
+
"run_time": float(getattr(info, "run_time", 0.0)),
|
|
286
|
+
"reuse_applied": bool(reuse_applied),
|
|
287
|
+
"reuse_mode": reuse_mode,
|
|
288
|
+
"certificate_kind": certificate_kind,
|
|
289
|
+
"certificate_check": certificate_check,
|
|
290
|
+
"objective_internal": objective_internal,
|
|
291
|
+
}
|
|
292
|
+
return BackendSolveResult(
|
|
293
|
+
backend_status=backend_status,
|
|
294
|
+
x=x,
|
|
295
|
+
objective_reported=objective,
|
|
296
|
+
raw_statistics=raw,
|
|
297
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Mapping, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from solverpilot.capabilities import BackendCapabilityManifestV2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CallbackEventKind(str, Enum):
|
|
13
|
+
PROGRESS = "progress"
|
|
14
|
+
INCUMBENT = "incumbent"
|
|
15
|
+
NODE = "node"
|
|
16
|
+
MESSAGE = "message"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class CallbackEvent:
|
|
21
|
+
kind: CallbackEventKind
|
|
22
|
+
runtime_s: float | None = None
|
|
23
|
+
objective: float | None = None
|
|
24
|
+
bound: float | None = None
|
|
25
|
+
gap: float | None = None
|
|
26
|
+
node_count: int | None = None
|
|
27
|
+
payload: Mapping[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class BackendArtifactBundle:
|
|
32
|
+
"""Raw backend artifacts before original-space reconstruction/validation.
|
|
33
|
+
|
|
34
|
+
P3 intentionally keeps artifacts optional and independent. A backend may return a
|
|
35
|
+
primal vector without duals, or a basis without rays. Absence is represented by
|
|
36
|
+
``None`` rather than inferred from solver termination.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
primal: np.ndarray | None = None
|
|
40
|
+
objective: float | None = None
|
|
41
|
+
dual: np.ndarray | None = None
|
|
42
|
+
slacks: np.ndarray | None = None
|
|
43
|
+
reduced_costs: np.ndarray | None = None
|
|
44
|
+
primal_ray: np.ndarray | None = None
|
|
45
|
+
dual_ray: np.ndarray | None = None
|
|
46
|
+
basis: Any | None = None
|
|
47
|
+
iis: Any | None = None
|
|
48
|
+
solution_pool: tuple[np.ndarray, ...] = ()
|
|
49
|
+
infeasibility_certificate: Any | None = None
|
|
50
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class BackendSolveRequestV2:
|
|
55
|
+
time_limit_s: float | None = None
|
|
56
|
+
thread_limit: int | None = None
|
|
57
|
+
primal_start: np.ndarray | None = None
|
|
58
|
+
dual_start: np.ndarray | None = None
|
|
59
|
+
basis_start: Any | None = None
|
|
60
|
+
mip_start: np.ndarray | None = None
|
|
61
|
+
options: Mapping[str, Any] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class BackendSolveResultV2:
|
|
66
|
+
termination: str
|
|
67
|
+
artifacts: BackendArtifactBundle
|
|
68
|
+
statistics: Mapping[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@runtime_checkable
|
|
72
|
+
class BackendCallbackControllerV2(Protocol):
|
|
73
|
+
def terminate(self) -> None: ...
|
|
74
|
+
def add_lazy_constraint(self, constraint: Any) -> None: ...
|
|
75
|
+
def add_user_cut(self, constraint: Any) -> None: ...
|
|
76
|
+
def submit_heuristic_solution(self, values: np.ndarray) -> None: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@runtime_checkable
|
|
80
|
+
class BackendCallbackV2(Protocol):
|
|
81
|
+
def __call__(self, event: CallbackEvent, controller: BackendCallbackControllerV2) -> None: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@runtime_checkable
|
|
85
|
+
class BackendProtocolV2(Protocol):
|
|
86
|
+
"""Future adapter protocol consumed by P4/P5.
|
|
87
|
+
|
|
88
|
+
Current M-track adapters do not automatically satisfy this protocol. P3 freezes the
|
|
89
|
+
surface so later milestones can implement it capability-by-capability without
|
|
90
|
+
retrofitting callback/mutation semantics after the Bridge Engine is built.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def capability_manifest_v2(self) -> BackendCapabilityManifestV2: ...
|
|
95
|
+
|
|
96
|
+
def is_available(self) -> bool: ...
|
|
97
|
+
|
|
98
|
+
def solve_v2(
|
|
99
|
+
self,
|
|
100
|
+
problem: Any,
|
|
101
|
+
request: BackendSolveRequestV2,
|
|
102
|
+
callback: BackendCallbackV2 | None = None,
|
|
103
|
+
) -> BackendSolveResultV2: ...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@runtime_checkable
|
|
107
|
+
class IncrementalBackendProtocolV2(Protocol):
|
|
108
|
+
def update_variable_bounds(self, lower: np.ndarray, upper: np.ndarray) -> None: ...
|
|
109
|
+
def update_rhs(self, lower: np.ndarray, upper: np.ndarray) -> None: ...
|
|
110
|
+
def update_objective(self, linear: np.ndarray, quadratic: Any | None = None) -> None: ...
|
|
111
|
+
def update_matrix_values(self, values: np.ndarray, indices: np.ndarray | None = None) -> None: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@runtime_checkable
|
|
115
|
+
class StructuralIncrementalBackendProtocolV2(Protocol):
|
|
116
|
+
def add_variable(self, specification: Any) -> Any: ...
|
|
117
|
+
def delete_variable(self, handle: Any) -> None: ...
|
|
118
|
+
def add_constraint(self, specification: Any) -> Any: ...
|
|
119
|
+
def delete_constraint(self, handle: Any) -> None: ...
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import importlib.util
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from solverpilot.capabilities import BackendManifest, Capability, SupportLevel
|
|
10
|
+
from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem, VariableDomain
|
|
11
|
+
from solverpilot.problem.quadratic import require_confirmed_convexity
|
|
12
|
+
|
|
13
|
+
from .base import BackendSolveResult, BackendUnavailableError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _scip_status(status: str, has_solution: bool) -> str:
|
|
17
|
+
s = status.strip().lower().replace("_", "")
|
|
18
|
+
if s == "optimal":
|
|
19
|
+
return "optimal"
|
|
20
|
+
if s == "infeasible":
|
|
21
|
+
return "infeasible"
|
|
22
|
+
if s == "unbounded":
|
|
23
|
+
return "unbounded"
|
|
24
|
+
if s in {"inforunbd", "infeasibleorunbounded"}:
|
|
25
|
+
return "infeasible_or_unbounded"
|
|
26
|
+
if any(token in s for token in ("timelimit", "nodelimit", "stallnodelimit", "gaplimit", "sollimit", "bestsollimit", "memlimit")):
|
|
27
|
+
return "limit_feasible" if has_solution else "limit_no_solution"
|
|
28
|
+
if s in {"userinterrupt", "restartlimit"}:
|
|
29
|
+
return "limit_feasible" if has_solution else "limit_no_solution"
|
|
30
|
+
if s in {"unknown", "terminate"}:
|
|
31
|
+
return "unknown"
|
|
32
|
+
return "solver_error"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class SCIPIISResult:
|
|
37
|
+
constraint_names: tuple[str, ...]
|
|
38
|
+
variable_names: tuple[str, ...]
|
|
39
|
+
irreducible: bool
|
|
40
|
+
time_s: float
|
|
41
|
+
nodes: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class PySCIPOptNativeBackend:
|
|
46
|
+
"""Optional native SCIP adapter for LP/MILP plus IIS extraction.
|
|
47
|
+
|
|
48
|
+
The implementation intentionally uses only documented PySCIPOpt surface APIs.
|
|
49
|
+
Native reoptimization and solution injection are left unclaimed until exercised in
|
|
50
|
+
an environment containing PySCIPOpt/SCIP.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
time_limit_s: float | None = None
|
|
54
|
+
display: bool = False
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def manifest(self) -> BackendManifest:
|
|
58
|
+
pkg_version = None
|
|
59
|
+
if self.is_available():
|
|
60
|
+
try:
|
|
61
|
+
pkg_version = version("pyscipopt")
|
|
62
|
+
except PackageNotFoundError:
|
|
63
|
+
pass
|
|
64
|
+
return BackendManifest(
|
|
65
|
+
name="pyscipopt-native",
|
|
66
|
+
version=pkg_version,
|
|
67
|
+
capabilities={
|
|
68
|
+
Capability.LP: SupportLevel.NATIVE,
|
|
69
|
+
Capability.CONVEX_QP: SupportLevel.UNSUPPORTED,
|
|
70
|
+
Capability.MILP: SupportLevel.NATIVE,
|
|
71
|
+
Capability.MIP_START: SupportLevel.UNKNOWN,
|
|
72
|
+
Capability.PRIMAL_START: SupportLevel.UNKNOWN,
|
|
73
|
+
Capability.DUAL_START: SupportLevel.UNKNOWN,
|
|
74
|
+
Capability.BASIS_START: SupportLevel.UNKNOWN,
|
|
75
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.UNKNOWN,
|
|
76
|
+
Capability.STRUCTURAL_INCREMENTAL_UPDATE: SupportLevel.UNKNOWN,
|
|
77
|
+
Capability.NATIVE_REOPTIMIZATION: SupportLevel.UNKNOWN,
|
|
78
|
+
Capability.CALLBACK_PROGRESS: SupportLevel.UNKNOWN,
|
|
79
|
+
Capability.IIS: SupportLevel.NATIVE,
|
|
80
|
+
Capability.INFEASIBILITY_CERTIFICATE: SupportLevel.UNKNOWN,
|
|
81
|
+
},
|
|
82
|
+
metadata={
|
|
83
|
+
"native_adapter": True,
|
|
84
|
+
"iis_method": "Model.generateIIS",
|
|
85
|
+
"reuse_claim": "none in M2 adapter",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def is_available(self) -> bool:
|
|
90
|
+
return importlib.util.find_spec("pyscipopt") is not None
|
|
91
|
+
|
|
92
|
+
def _build_model(self, problem: LinearProblem):
|
|
93
|
+
if not self.is_available():
|
|
94
|
+
raise BackendUnavailableError("PySCIPOpt is not installed")
|
|
95
|
+
from pyscipopt import Model, quicksum
|
|
96
|
+
|
|
97
|
+
model = Model("solverpilot")
|
|
98
|
+
if not self.display:
|
|
99
|
+
model.hideOutput()
|
|
100
|
+
if self.time_limit_s is not None:
|
|
101
|
+
if self.time_limit_s <= 0:
|
|
102
|
+
raise ValueError("time_limit_s must be positive")
|
|
103
|
+
model.setParam("limits/time", float(self.time_limit_s))
|
|
104
|
+
|
|
105
|
+
variables = []
|
|
106
|
+
for j, domain in enumerate(problem.domains):
|
|
107
|
+
lbv = float(problem.variable_lower[j])
|
|
108
|
+
ubv = float(problem.variable_upper[j])
|
|
109
|
+
lb = None if np.isneginf(lbv) else lbv
|
|
110
|
+
ub = None if np.isposinf(ubv) else ubv
|
|
111
|
+
if domain == VariableDomain.BINARY.value:
|
|
112
|
+
vtype = "B"
|
|
113
|
+
elif domain == VariableDomain.INTEGER.value:
|
|
114
|
+
vtype = "I"
|
|
115
|
+
else:
|
|
116
|
+
vtype = "C"
|
|
117
|
+
variables.append(model.addVar(name=f"x_{j}", vtype=vtype, lb=lb, ub=ub))
|
|
118
|
+
|
|
119
|
+
objective = quicksum(float(problem.c[j]) * variables[j] for j in range(problem.n_variables) if problem.c[j] != 0.0)
|
|
120
|
+
sense = "minimize" if problem.objective_sense is ObjectiveSense.MINIMIZE else "maximize"
|
|
121
|
+
model.setObjective(objective, sense=sense)
|
|
122
|
+
if problem.objective_offset != 0.0:
|
|
123
|
+
# PySCIPOpt exposes objective constants separately from the linear expression.
|
|
124
|
+
# Keeping the offset in the native model makes solver-reported bounds/gaps and
|
|
125
|
+
# objective values semantically consistent with the canonical problem.
|
|
126
|
+
model.addObjoffset(float(problem.objective_offset))
|
|
127
|
+
|
|
128
|
+
A = problem.A.tocsr()
|
|
129
|
+
for i in range(problem.n_constraints):
|
|
130
|
+
start, end = A.indptr[i], A.indptr[i + 1]
|
|
131
|
+
expr = quicksum(float(A.data[k]) * variables[int(A.indices[k])] for k in range(start, end))
|
|
132
|
+
lo = float(problem.constraint_lower[i])
|
|
133
|
+
hi = float(problem.constraint_upper[i])
|
|
134
|
+
if np.isfinite(lo) and np.isfinite(hi) and lo == hi:
|
|
135
|
+
model.addCons(expr == lo, name=f"row_{i}_eq")
|
|
136
|
+
else:
|
|
137
|
+
if np.isfinite(lo):
|
|
138
|
+
model.addCons(expr >= lo, name=f"row_{i}_lower")
|
|
139
|
+
if np.isfinite(hi):
|
|
140
|
+
model.addCons(expr <= hi, name=f"row_{i}_upper")
|
|
141
|
+
return model, variables
|
|
142
|
+
|
|
143
|
+
def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
144
|
+
if isinstance(problem, QuadraticProblem):
|
|
145
|
+
raise ValueError("M2 PySCIPOpt adapter supports LP/MILP only")
|
|
146
|
+
if not isinstance(problem, LinearProblem):
|
|
147
|
+
raise TypeError(f"unsupported problem type: {type(problem)!r}")
|
|
148
|
+
model, variables = self._build_model(problem)
|
|
149
|
+
model.optimize()
|
|
150
|
+
status_text = str(model.getStatus())
|
|
151
|
+
has_solution = int(model.getNSols()) > 0
|
|
152
|
+
backend_status = _scip_status(status_text, has_solution)
|
|
153
|
+
x = None
|
|
154
|
+
if has_solution and backend_status in {"optimal", "limit_feasible"}:
|
|
155
|
+
sol = model.getBestSol()
|
|
156
|
+
x_candidate = np.asarray([model.getSolVal(sol, v) for v in variables], dtype=np.float64)
|
|
157
|
+
if np.all(np.isfinite(x_candidate)):
|
|
158
|
+
x = x_candidate
|
|
159
|
+
objective = None if x is None else float(problem.c @ x + problem.objective_offset)
|
|
160
|
+
raw: dict[str, object] = {
|
|
161
|
+
"scip_status": status_text,
|
|
162
|
+
"n_solutions": int(model.getNSols()),
|
|
163
|
+
"solve_time": float(model.getSolvingTime()),
|
|
164
|
+
"nodes": int(model.getNTotalNodes()),
|
|
165
|
+
"reuse_applied": False,
|
|
166
|
+
"reuse_mode": "cold_native_solve",
|
|
167
|
+
}
|
|
168
|
+
if has_solution:
|
|
169
|
+
try:
|
|
170
|
+
raw["gap"] = float(model.getGap())
|
|
171
|
+
raw["dual_bound"] = float(model.getDualbound())
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
return BackendSolveResult(
|
|
175
|
+
backend_status=backend_status,
|
|
176
|
+
x=x,
|
|
177
|
+
objective_reported=objective,
|
|
178
|
+
raw_statistics=raw,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def generate_iis(self, problem: LinearProblem) -> SCIPIISResult:
|
|
182
|
+
"""Generate a SCIP IIS for an infeasible LP/MILP.
|
|
183
|
+
|
|
184
|
+
Raises ValueError when SCIP does not classify the model as infeasible.
|
|
185
|
+
"""
|
|
186
|
+
if not isinstance(problem, LinearProblem):
|
|
187
|
+
raise TypeError("SCIP IIS adapter currently accepts LinearProblem only")
|
|
188
|
+
model, _ = self._build_model(problem)
|
|
189
|
+
model.optimize()
|
|
190
|
+
if str(model.getStatus()).strip().lower() != "infeasible":
|
|
191
|
+
raise ValueError("IIS requested for a model not classified infeasible by SCIP")
|
|
192
|
+
iis = model.generateIIS()
|
|
193
|
+
subscip = iis.getSubscip()
|
|
194
|
+
return SCIPIISResult(
|
|
195
|
+
constraint_names=tuple(sorted(str(c.name) for c in subscip.getConss())),
|
|
196
|
+
variable_names=tuple(sorted(str(v.name) for v in subscip.getVars())),
|
|
197
|
+
irreducible=bool(iis.isSubscipIrreducible()),
|
|
198
|
+
time_s=float(iis.getTime()),
|
|
199
|
+
nodes=int(iis.getNNodes()),
|
|
200
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import Backend
|
|
4
|
+
from solverpilot.exceptions import UnknownBackendError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BackendRegistry:
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self._backends: dict[str, Backend] = {}
|
|
10
|
+
|
|
11
|
+
def register(self, backend: Backend) -> None:
|
|
12
|
+
name = backend.manifest.name
|
|
13
|
+
if name in self._backends:
|
|
14
|
+
raise ValueError(f"backend already registered: {name}")
|
|
15
|
+
self._backends[name] = backend
|
|
16
|
+
|
|
17
|
+
def get(self, name: str) -> Backend:
|
|
18
|
+
try:
|
|
19
|
+
return self._backends[name]
|
|
20
|
+
except KeyError as exc:
|
|
21
|
+
raise UnknownBackendError(f"unknown backend: {name}") from exc
|
|
22
|
+
|
|
23
|
+
def all(self) -> tuple[Backend, ...]:
|
|
24
|
+
return tuple(self._backends.values())
|
|
25
|
+
|
|
26
|
+
def available(self) -> tuple[Backend, ...]:
|
|
27
|
+
return tuple(b for b in self._backends.values() if b.is_available())
|
|
28
|
+
|
|
29
|
+
def names(self) -> tuple[str, ...]:
|
|
30
|
+
return tuple(sorted(self._backends))
|
|
31
|
+
|
|
32
|
+
def capability_manifest_v2(self, name: str, *, verify: bool = False):
|
|
33
|
+
from solverpilot.capabilities import resolve_backend_capabilities
|
|
34
|
+
return resolve_backend_capabilities(self.get(name), verify=verify)
|
|
35
|
+
|
|
36
|
+
def capability_manifests_v2(self, *, verify: bool = False):
|
|
37
|
+
from solverpilot.capabilities import resolve_backend_capabilities
|
|
38
|
+
return tuple(resolve_backend_capabilities(b, verify=verify) for b in self.all())
|