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.
Files changed (201) hide show
  1. solverpilot/__init__.py +250 -0
  2. solverpilot/_immutability.py +27 -0
  3. solverpilot/_version.py +3 -0
  4. solverpilot/applications/__init__.py +3 -0
  5. solverpilot/applications/routing/__init__.py +19 -0
  6. solverpilot/applications/routing/compile_milp.py +321 -0
  7. solverpilot/applications/routing/diagnostics.py +78 -0
  8. solverpilot/applications/routing/errors.py +14 -0
  9. solverpilot/applications/routing/heuristics.py +167 -0
  10. solverpilot/applications/routing/model.py +259 -0
  11. solverpilot/applications/routing/reference.py +81 -0
  12. solverpilot/applications/routing/solution.py +89 -0
  13. solverpilot/applications/routing/timeline.py +79 -0
  14. solverpilot/applications/routing/validation.py +169 -0
  15. solverpilot/applications/tsp/__init__.py +41 -0
  16. solverpilot/applications/tsp/compile_cp.py +121 -0
  17. solverpilot/applications/tsp/compile_milp.py +139 -0
  18. solverpilot/applications/tsp/distance.py +60 -0
  19. solverpilot/applications/tsp/errors.py +17 -0
  20. solverpilot/applications/tsp/heuristics.py +112 -0
  21. solverpilot/applications/tsp/model.py +175 -0
  22. solverpilot/applications/tsp/reference.py +121 -0
  23. solverpilot/applications/tsp/solution.py +65 -0
  24. solverpilot/applications/tsp/validation.py +92 -0
  25. solverpilot/backends/__init__.py +51 -0
  26. solverpilot/backends/_casadi_highs_worker.py +92 -0
  27. solverpilot/backends/base.py +28 -0
  28. solverpilot/backends/bundled_capi.py +910 -0
  29. solverpilot/backends/casadi_conic.py +303 -0
  30. solverpilot/backends/health.py +240 -0
  31. solverpilot/backends/highspy_native.py +217 -0
  32. solverpilot/backends/nlopt_native.py +237 -0
  33. solverpilot/backends/osqp_native.py +297 -0
  34. solverpilot/backends/protocol_v2.py +119 -0
  35. solverpilot/backends/pyscipopt_native.py +200 -0
  36. solverpilot/backends/registry.py +38 -0
  37. solverpilot/backends/scipy_highs.py +138 -0
  38. solverpilot/backends/scipy_highs_lp.py +195 -0
  39. solverpilot/backends/scipy_slsqp_qp.py +133 -0
  40. solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
  41. solverpilot/benchmark/__init__.py +23 -0
  42. solverpilot/benchmark/acquire.py +199 -0
  43. solverpilot/benchmark/baselines.py +70 -0
  44. solverpilot/benchmark/bundle.py +37 -0
  45. solverpilot/benchmark/campaign.py +150 -0
  46. solverpilot/benchmark/environment.py +115 -0
  47. solverpilot/benchmark/integrity.py +46 -0
  48. solverpilot/benchmark/model.py +146 -0
  49. solverpilot/benchmark/policy.py +182 -0
  50. solverpilot/benchmark/registry.py +38 -0
  51. solverpilot/benchmark/report.py +44 -0
  52. solverpilot/benchmark/runner.py +516 -0
  53. solverpilot/benchmark/slurm.py +55 -0
  54. solverpilot/benchmark/specs.py +75 -0
  55. solverpilot/benchmark/splits.py +181 -0
  56. solverpilot/benchmark/summary.py +384 -0
  57. solverpilot/benchmark/verify.py +142 -0
  58. solverpilot/benchmark/worker.py +71 -0
  59. solverpilot/bridges/__init__.py +19 -0
  60. solverpilot/bridges/engine.py +44 -0
  61. solverpilot/bridges/indicator.py +232 -0
  62. solverpilot/bridges/registry.py +78 -0
  63. solverpilot/bridges/transformation-tape.schema.json +53 -0
  64. solverpilot/bridges/types.py +149 -0
  65. solverpilot/bridges/validation.py +110 -0
  66. solverpilot/capabilities/__init__.py +32 -0
  67. solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
  68. solverpilot/capabilities/conformance.py +151 -0
  69. solverpilot/capabilities/enums.py +27 -0
  70. solverpilot/capabilities/manifest.py +19 -0
  71. solverpilot/capabilities/ontology.py +73 -0
  72. solverpilot/capabilities/requirements.py +37 -0
  73. solverpilot/capabilities/resolve.py +30 -0
  74. solverpilot/capabilities/v2.py +711 -0
  75. solverpilot/cli/__init__.py +1 -0
  76. solverpilot/cli/backend_health.py +77 -0
  77. solverpilot/cli/benchmark.py +331 -0
  78. solverpilot/cli/benchmark_worker.py +33 -0
  79. solverpilot/cli/capabilities.py +65 -0
  80. solverpilot/conic/__init__.py +11 -0
  81. solverpilot/conic/backend.py +263 -0
  82. solverpilot/conic/compiler.py +310 -0
  83. solverpilot/conic/conformance.py +60 -0
  84. solverpilot/conic/conic-ir-v1.schema.json +24 -0
  85. solverpilot/conic/ir.py +299 -0
  86. solverpilot/conic/validation.py +149 -0
  87. solverpilot/cp/__init__.py +7 -0
  88. solverpilot/cp/conformance.py +32 -0
  89. solverpilot/cp/cp-ir-v1.schema.json +13 -0
  90. solverpilot/cp/ir.py +170 -0
  91. solverpilot/cp/model.py +143 -0
  92. solverpilot/cp/ortools_backend.py +423 -0
  93. solverpilot/cp/ortools_worker.py +75 -0
  94. solverpilot/cp/reference.py +51 -0
  95. solverpilot/cp/serialization.py +117 -0
  96. solverpilot/cp/validate.py +83 -0
  97. solverpilot/diagnose/__init__.py +33 -0
  98. solverpilot/diagnose/infeasibility.py +463 -0
  99. solverpilot/diagnose/model.py +87 -0
  100. solverpilot/evaluation/__init__.py +53 -0
  101. solverpilot/evaluation/metrics.py +150 -0
  102. solverpilot/evaluation/oracle.py +288 -0
  103. solverpilot/evaluation/proof.py +53 -0
  104. solverpilot/evaluation/references.py +73 -0
  105. solverpilot/evaluation/regret.py +281 -0
  106. solverpilot/exceptions.py +34 -0
  107. solverpilot/experimental/__init__.py +32 -0
  108. solverpilot/extensions/__init__.py +56 -0
  109. solverpilot/extensions/entrypoints.py +34 -0
  110. solverpilot/extensions/errors.py +35 -0
  111. solverpilot/extensions/manager.py +651 -0
  112. solverpilot/extensions/manifest.py +232 -0
  113. solverpilot/extensions/model.py +101 -0
  114. solverpilot/extensions/versioning.py +136 -0
  115. solverpilot/history/__init__.py +22 -0
  116. solverpilot/history/errors.py +15 -0
  117. solverpilot/history/model.py +129 -0
  118. solverpilot/history/sqlite.py +651 -0
  119. solverpilot/inspect/__init__.py +3 -0
  120. solverpilot/inspect/fingerprint.py +199 -0
  121. solverpilot/intelligence/__init__.py +36 -0
  122. solverpilot/intelligence/errors.py +16 -0
  123. solverpilot/intelligence/fingerprint.py +108 -0
  124. solverpilot/intelligence/leakage.py +69 -0
  125. solverpilot/intelligence/schema.py +314 -0
  126. solverpilot/intelligence/shift.py +208 -0
  127. solverpilot/io/__init__.py +41 -0
  128. solverpilot/io/binary64.py +49 -0
  129. solverpilot/io/csv.py +230 -0
  130. solverpilot/io/errors.py +25 -0
  131. solverpilot/io/hashing.py +111 -0
  132. solverpilot/io/json.py +90 -0
  133. solverpilot/io/mapping.py +195 -0
  134. solverpilot/io/model.py +407 -0
  135. solverpilot/io/numeric.py +108 -0
  136. solverpilot/io/provenance.py +114 -0
  137. solverpilot/io/result.py +45 -0
  138. solverpilot/io/source.py +104 -0
  139. solverpilot/io/strict_json.py +133 -0
  140. solverpilot/minlp/__init__.py +6 -0
  141. solverpilot/minlp/compiler.py +66 -0
  142. solverpilot/minlp/conformance.py +34 -0
  143. solverpilot/minlp/convexity.py +85 -0
  144. solverpilot/minlp/ir.py +35 -0
  145. solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
  146. solverpilot/minlp/orchestrator.py +220 -0
  147. solverpilot/minlp/validation.py +28 -0
  148. solverpilot/model/__init__.py +27 -0
  149. solverpilot/model/compiler.py +1263 -0
  150. solverpilot/model/errors.py +22 -0
  151. solverpilot/model/expression.py +373 -0
  152. solverpilot/model/model.py +716 -0
  153. solverpilot/model/sets.py +60 -0
  154. solverpilot/model/types.py +36 -0
  155. solverpilot/nlp/__init__.py +7 -0
  156. solverpilot/nlp/ad.py +166 -0
  157. solverpilot/nlp/backend.py +113 -0
  158. solverpilot/nlp/compiler.py +116 -0
  159. solverpilot/nlp/conformance.py +58 -0
  160. solverpilot/nlp/ir.py +70 -0
  161. solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
  162. solverpilot/nlp/validation.py +60 -0
  163. solverpilot/plan/__init__.py +33 -0
  164. solverpilot/plan/model.py +79 -0
  165. solverpilot/plan/planner.py +201 -0
  166. solverpilot/plan/production.py +627 -0
  167. solverpilot/plan/selective_lp.py +65 -0
  168. solverpilot/problem/__init__.py +16 -0
  169. solverpilot/problem/enums.py +20 -0
  170. solverpilot/problem/hashing.py +88 -0
  171. solverpilot/problem/linear.py +196 -0
  172. solverpilot/problem/mps.py +447 -0
  173. solverpilot/problem/quadratic.py +192 -0
  174. solverpilot/reporting/__init__.py +27 -0
  175. solverpilot/reporting/explain.py +327 -0
  176. solverpilot/reporting/model.py +264 -0
  177. solverpilot/reporting/render.py +55 -0
  178. solverpilot/runtime/__init__.py +19 -0
  179. solverpilot/runtime/auto.py +193 -0
  180. solverpilot/runtime/budgeting.py +43 -0
  181. solverpilot/runtime/executor.py +126 -0
  182. solverpilot/runtime/portfolio.py +197 -0
  183. solverpilot/runtime/result.py +72 -0
  184. solverpilot/session/__init__.py +29 -0
  185. solverpilot/session/conformance_v2.py +239 -0
  186. solverpilot/session/mutations.py +115 -0
  187. solverpilot/session/persistent-session-trace.schema.json +45 -0
  188. solverpilot/session/persistent.py +491 -0
  189. solverpilot/session/reuse.py +81 -0
  190. solverpilot/session/session.py +236 -0
  191. solverpilot/trace/__init__.py +3 -0
  192. solverpilot/trace/schema.py +47 -0
  193. solverpilot/validate/__init__.py +10 -0
  194. solverpilot/validate/core.py +179 -0
  195. solverpilot/validate/result.py +55 -0
  196. solverpilot-0.1.dist-info/METADATA +554 -0
  197. solverpilot-0.1.dist-info/RECORD +201 -0
  198. solverpilot-0.1.dist-info/WHEEL +5 -0
  199. solverpilot-0.1.dist-info/entry_points.txt +4 -0
  200. solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
  201. 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)