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,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())