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,363 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import importlib.util
5
+
6
+ import numpy as np
7
+ from scipy import sparse
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 _available() -> bool:
17
+ return importlib.util.find_spec("scipy.optimize._highspy._core") is not None
18
+
19
+
20
+ def _status_name(core, status) -> str:
21
+ mapping = {
22
+ core.HighsModelStatus.kOptimal: "optimal",
23
+ core.HighsModelStatus.kInfeasible: "infeasible",
24
+ core.HighsModelStatus.kUnbounded: "unbounded",
25
+ core.HighsModelStatus.kUnboundedOrInfeasible: "infeasible_or_unbounded",
26
+ core.HighsModelStatus.kTimeLimit: "limit_no_solution",
27
+ core.HighsModelStatus.kIterationLimit: "limit_no_solution",
28
+ core.HighsModelStatus.kSolutionLimit: "limit_no_solution",
29
+ core.HighsModelStatus.kObjectiveBound: "limit_no_solution",
30
+ core.HighsModelStatus.kObjectiveTarget: "limit_no_solution",
31
+ core.HighsModelStatus.kInterrupt: "limit_no_solution",
32
+ core.HighsModelStatus.kModelError: "solver_error",
33
+ core.HighsModelStatus.kSolveError: "solver_error",
34
+ core.HighsModelStatus.kPresolveError: "solver_error",
35
+ core.HighsModelStatus.kPostsolveError: "solver_error",
36
+ core.HighsModelStatus.kLoadError: "solver_error",
37
+ core.HighsModelStatus.kMemoryLimit: "solver_error",
38
+ }
39
+ return mapping.get(status, "unknown")
40
+
41
+
42
+ def _ok(core, status) -> bool:
43
+ return status in {core.HighsStatus.kOk, core.HighsStatus.kWarning}
44
+
45
+
46
+ def _linear_csc(problem: LinearProblem) -> sparse.csc_matrix:
47
+ A = problem.A.tocsc(copy=True)
48
+ A.sort_indices()
49
+ return A
50
+
51
+
52
+ def _lower_hessian_csc(problem: QuadraticProblem) -> sparse.csc_matrix:
53
+ P = sparse.tril(problem.P, format="csc")
54
+ P.sort_indices()
55
+ return P
56
+
57
+
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class HighsDevIISResult:
62
+ valid: bool
63
+ row_indices: tuple[int, ...]
64
+ row_bound_statuses: tuple[int, ...]
65
+ col_indices: tuple[int, ...]
66
+ col_bound_statuses: tuple[int, ...]
67
+ strategy: int
68
+
69
+ @dataclass(slots=True)
70
+ class ScipyVendoredHighsDevBackend:
71
+ """Stateful development-only HiGHS backend using SciPy's private vendored bindings.
72
+
73
+ This class exists only to *verify architecture and reuse semantics* in environments
74
+ where the public ``highspy`` package cannot be installed. It must never be treated
75
+ as a production dependency: ``scipy.optimize._highspy`` is private API and may change
76
+ without notice.
77
+
78
+ For continuous LPs with unchanged structure, the backend updates the existing model,
79
+ restores the previous valid basis explicitly, and reruns HiGHS. For MILPs with
80
+ unchanged structure it injects the previous incumbent as a solution start. Structural
81
+ changes force a rebuild. QPs are solved natively but are rebuilt in this M3 verifier.
82
+ """
83
+
84
+ time_limit_s: float | None = None
85
+ threads: int | None = None
86
+ solver: str | None = None
87
+ presolve: bool | None = None
88
+ _highs: object | None = field(default=None, init=False, repr=False)
89
+ _last_problem: LinearProblem | QuadraticProblem | None = field(default=None, init=False, repr=False)
90
+ _last_x: np.ndarray | None = field(default=None, init=False, repr=False)
91
+
92
+ @property
93
+ def manifest(self) -> BackendManifest:
94
+ version = None
95
+ if self.is_available():
96
+ from scipy.optimize._highspy import _core as core
97
+ version = f"{core.HIGHS_VERSION_MAJOR}.{core.HIGHS_VERSION_MINOR}.{core.HIGHS_VERSION_PATCH}-scipy-vendored"
98
+ return BackendManifest(
99
+ name="scipy-vendored-highs-native-dev",
100
+ version=version,
101
+ capabilities={
102
+ Capability.LP: SupportLevel.NATIVE,
103
+ Capability.CONVEX_QP: SupportLevel.NATIVE,
104
+ Capability.MILP: SupportLevel.NATIVE,
105
+ Capability.MIP_START: SupportLevel.NATIVE,
106
+ Capability.PRIMAL_START: SupportLevel.NATIVE,
107
+ Capability.DUAL_START: SupportLevel.UNSUPPORTED,
108
+ Capability.BASIS_START: SupportLevel.NATIVE,
109
+ Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.NATIVE,
110
+ Capability.STRUCTURAL_INCREMENTAL_UPDATE: SupportLevel.UNSUPPORTED,
111
+ Capability.NATIVE_REOPTIMIZATION: SupportLevel.NATIVE,
112
+ Capability.CALLBACK_PROGRESS: SupportLevel.UNKNOWN,
113
+ Capability.IIS: SupportLevel.NATIVE,
114
+ Capability.INFEASIBILITY_CERTIFICATE: SupportLevel.UNKNOWN,
115
+ },
116
+ metadata={
117
+ "native_adapter": True,
118
+ "stateful": True,
119
+ "development_only": True,
120
+ "private_scipy_api": True,
121
+ "do_not_register_by_default": True,
122
+ },
123
+ )
124
+
125
+ def is_available(self) -> bool:
126
+ return _available()
127
+
128
+ def _new_highs(self):
129
+ if not self.is_available():
130
+ raise BackendUnavailableError("SciPy vendored HiGHS bindings are unavailable")
131
+ from scipy.optimize._highspy import _core as core
132
+ h = core._Highs()
133
+ h.setOptionValue("output_flag", False)
134
+ if self.time_limit_s is not None:
135
+ if self.time_limit_s <= 0:
136
+ raise ValueError("time_limit_s must be positive")
137
+ h.setOptionValue("time_limit", float(self.time_limit_s))
138
+ if self.threads is not None:
139
+ if self.threads <= 0:
140
+ raise ValueError("threads must be positive")
141
+ h.setOptionValue("threads", int(self.threads))
142
+ if self.solver is not None:
143
+ h.setOptionValue("solver", str(self.solver))
144
+ if self.presolve is not None:
145
+ h.setOptionValue("presolve", "on" if self.presolve else "off")
146
+ return core, h
147
+
148
+ @staticmethod
149
+ def _make_lp(core, problem: LinearProblem):
150
+ lp = core.HighsLp()
151
+ lp.num_col_ = problem.n_variables
152
+ lp.num_row_ = problem.n_constraints
153
+ lp.col_cost_ = np.asarray(problem.c, dtype=np.float64)
154
+ lp.col_lower_ = np.asarray(problem.variable_lower, dtype=np.float64)
155
+ lp.col_upper_ = np.asarray(problem.variable_upper, dtype=np.float64)
156
+ lp.row_lower_ = np.asarray(problem.constraint_lower, dtype=np.float64)
157
+ lp.row_upper_ = np.asarray(problem.constraint_upper, dtype=np.float64)
158
+ lp.sense_ = core.ObjSense.kMinimize if problem.objective_sense is ObjectiveSense.MINIMIZE else core.ObjSense.kMaximize
159
+ lp.offset_ = float(problem.objective_offset)
160
+ A = _linear_csc(problem)
161
+ lp.a_matrix_.format_ = core.MatrixFormat.kColwise
162
+ lp.a_matrix_.start_ = np.asarray(A.indptr, dtype=np.int32)
163
+ lp.a_matrix_.index_ = np.asarray(A.indices, dtype=np.int32)
164
+ lp.a_matrix_.value_ = np.asarray(A.data, dtype=np.float64)
165
+ if problem.has_integer_variables:
166
+ lp.integrality_ = [
167
+ core.HighsVarType.kInteger
168
+ if domain in {VariableDomain.INTEGER.value, VariableDomain.BINARY.value}
169
+ else core.HighsVarType.kContinuous
170
+ for domain in problem.domains
171
+ ]
172
+ return lp
173
+
174
+ @classmethod
175
+ def _make_model(cls, core, problem: LinearProblem | QuadraticProblem):
176
+ if isinstance(problem, QuadraticProblem):
177
+ model = core.HighsModel()
178
+ model.lp_ = cls._make_lp(core, problem.linear)
179
+ P = _lower_hessian_csc(problem)
180
+ model.hessian_.dim_ = problem.n_variables
181
+ model.hessian_.format_ = core.HessianFormat.kTriangular
182
+ model.hessian_.start_ = np.asarray(P.indptr, dtype=np.int32)
183
+ model.hessian_.index_ = np.asarray(P.indices, dtype=np.int32)
184
+ model.hessian_.value_ = np.asarray(P.data, dtype=np.float64)
185
+ return model
186
+ return cls._make_lp(core, problem)
187
+
188
+ def _rebuild(self, problem: LinearProblem | QuadraticProblem):
189
+ core, h = self._new_highs()
190
+ status = h.passModel(self._make_model(core, problem))
191
+ if not _ok(core, status):
192
+ raise RuntimeError(f"vendored HiGHS rejected model: {status}")
193
+ self._highs = h
194
+ return core, h
195
+
196
+ @staticmethod
197
+ def _apply_same_structure_lp_updates(core, h, old: LinearProblem, new: LinearProblem) -> None:
198
+ n = new.n_variables
199
+ idx = np.arange(n, dtype=np.int32)
200
+ status = h.changeColsCost(n, idx, np.asarray(new.c, dtype=np.float64))
201
+ if not _ok(core, status):
202
+ raise RuntimeError(f"HiGHS cost update failed: {status}")
203
+ status = h.changeColsBounds(
204
+ n,
205
+ idx,
206
+ np.asarray(new.variable_lower, dtype=np.float64),
207
+ np.asarray(new.variable_upper, dtype=np.float64),
208
+ )
209
+ if not _ok(core, status):
210
+ raise RuntimeError(f"HiGHS variable-bound update failed: {status}")
211
+ for i in range(new.n_constraints):
212
+ status = h.changeRowBounds(i, float(new.constraint_lower[i]), float(new.constraint_upper[i]))
213
+ if not _ok(core, status):
214
+ raise RuntimeError(f"HiGHS row-bound update failed at row {i}: {status}")
215
+ status = h.changeObjectiveOffset(float(new.objective_offset))
216
+ if not _ok(core, status):
217
+ raise RuntimeError(f"HiGHS objective-offset update failed: {status}")
218
+ sense = core.ObjSense.kMinimize if new.objective_sense is ObjectiveSense.MINIMIZE else core.ObjSense.kMaximize
219
+ status = h.changeObjectiveSense(sense)
220
+ if not _ok(core, status):
221
+ raise RuntimeError(f"HiGHS objective-sense update failed: {status}")
222
+
223
+ # Structural hash equality guarantees identical A sparsity and variable domains.
224
+ # Same structural hash means CSR indptr/indices are identical. Compare the
225
+ # contiguous data arrays directly so the common "A unchanged" path is O(nnz)
226
+ # vectorized rather than O(nnz) Python scalar indexing.
227
+ changed = np.flatnonzero(old.A.data != new.A.data)
228
+ if changed.size:
229
+ rows = np.searchsorted(new.A.indptr[1:], changed, side="right")
230
+ cols = new.A.indices[changed]
231
+ vals = new.A.data[changed]
232
+ for i, j, value in zip(rows, cols, vals, strict=True):
233
+ status = h.changeCoeff(int(i), int(j), float(value))
234
+ if not _ok(core, status):
235
+ raise RuntimeError(f"HiGHS coefficient update failed at ({i}, {j}): {status}")
236
+
237
+ def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
238
+ if isinstance(problem, QuadraticProblem):
239
+ require_confirmed_convexity(problem)
240
+ if not self.is_available():
241
+ raise BackendUnavailableError("SciPy vendored HiGHS bindings are unavailable")
242
+ from scipy.optimize._highspy import _core as core
243
+
244
+ reuse_applied = False
245
+ reuse_mode = "cold_rebuild"
246
+ old = self._last_problem
247
+ same_structure = (
248
+ old is not None
249
+ and type(old) is type(problem)
250
+ and old.structural_hash == problem.structural_hash
251
+ )
252
+
253
+ # QP data mutation is deliberately rebuilt in the M3 verifier because this private
254
+ # binding does not expose a stable Hessian-update contract we are willing to claim.
255
+ can_update_lp = same_structure and isinstance(old, LinearProblem) and isinstance(problem, LinearProblem)
256
+
257
+ if can_update_lp and self._highs is not None:
258
+ h = self._highs
259
+ basis = None
260
+ if not problem.has_integer_variables:
261
+ candidate = h.getBasis()
262
+ if bool(getattr(candidate, "valid", False)):
263
+ basis = candidate
264
+ self._apply_same_structure_lp_updates(core, h, old, problem)
265
+ if problem.has_integer_variables and self._last_x is not None:
266
+ indices = np.arange(problem.n_variables, dtype=np.int32)
267
+ status = h.setSolution(problem.n_variables, indices, np.asarray(self._last_x, dtype=np.float64))
268
+ if _ok(core, status):
269
+ reuse_applied = True
270
+ reuse_mode = "mip_solution_start"
271
+ elif basis is not None:
272
+ status = h.setBasis(basis)
273
+ if _ok(core, status):
274
+ reuse_applied = True
275
+ reuse_mode = "explicit_basis_hot_start"
276
+ else:
277
+ core, h = self._rebuild(problem)
278
+
279
+ runtime_before = float(h.getRunTime())
280
+ run_status = h.run()
281
+ runtime_after = float(h.getRunTime())
282
+ if not _ok(core, run_status):
283
+ raise RuntimeError(f"HiGHS run failed: {run_status}")
284
+
285
+ model_status_enum = h.getModelStatus()
286
+ backend_status = _status_name(core, model_status_enum)
287
+ info = h.getInfo()
288
+ primal_status = h.solutionStatusToString(info.primal_solution_status)
289
+ if backend_status.startswith("limit") and "feasible" in primal_status.lower() and "infeasible" not in primal_status.lower():
290
+ backend_status = "limit_feasible"
291
+
292
+ x = None
293
+ if backend_status in {"optimal", "limit_feasible"}:
294
+ sol = h.getSolution()
295
+ candidate = np.asarray(list(sol.col_value), dtype=np.float64)
296
+ if candidate.shape == (problem.n_variables,) and np.all(np.isfinite(candidate)):
297
+ x = candidate
298
+
299
+ objective = None
300
+ objective_internal = None
301
+ if x is not None:
302
+ try:
303
+ candidate = float(h.getObjectiveValue())
304
+ if np.isfinite(candidate):
305
+ objective_internal = candidate
306
+ objective = candidate
307
+ except Exception:
308
+ objective_internal = None
309
+
310
+ raw = {
311
+ "model_status": h.modelStatusToString(model_status_enum),
312
+ "primal_solution_status": primal_status,
313
+ "simplex_iteration_count": int(getattr(info, "simplex_iteration_count", 0)),
314
+ "ipm_iteration_count": int(getattr(info, "ipm_iteration_count", 0)),
315
+ "qp_iteration_count": int(getattr(info, "qp_iteration_count", 0)),
316
+ "mip_node_count": int(getattr(info, "mip_node_count", 0)),
317
+ "reuse_applied": reuse_applied,
318
+ "reuse_mode": reuse_mode,
319
+ "same_structure": bool(same_structure),
320
+ "highs_run_delta_s": max(0.0, runtime_after - runtime_before),
321
+ "highs_runtime_cumulative_s": runtime_after,
322
+ "private_scipy_api": True,
323
+ "objective_internal": objective_internal,
324
+ }
325
+ self._last_problem = problem
326
+ self._last_x = None if x is None else np.array(x, copy=True)
327
+ return BackendSolveResult(
328
+ backend_status=backend_status,
329
+ x=x,
330
+ objective_reported=objective,
331
+ raw_statistics=raw,
332
+ )
333
+
334
+ def compute_iis(self, problem: LinearProblem) -> HighsDevIISResult:
335
+ """Development verification of native HiGHS IIS plumbing.
336
+
337
+ This is intentionally a small typed surface. Bound-status integers are kept
338
+ solver-native because the SciPy-vendored HiGHS version is 1.8.0 and this
339
+ verifier must not pretend those enum semantics are our stable public schema.
340
+ """
341
+ if problem.has_integer_variables:
342
+ raise NotImplementedError("M3 vendored IIS verifier is limited to continuous LP")
343
+ core, h = self._rebuild(problem)
344
+ strategy = getattr(getattr(core, "IisStrategy", None), "kIisStrategyFromLpRowPriority", None)
345
+ if strategy is not None:
346
+ status = h.setOptionValue("iis_strategy", int(strategy))
347
+ if not _ok(core, status):
348
+ raise RuntimeError(f"HiGHS IIS strategy configuration failed: {status}")
349
+ h.run()
350
+ if h.getModelStatus() != core.HighsModelStatus.kInfeasible:
351
+ raise ValueError("IIS requested for a model not classified infeasible by HiGHS")
352
+ iis = core.HighsIis()
353
+ status = h.getIis(iis)
354
+ if not _ok(core, status):
355
+ raise RuntimeError(f"HiGHS getIis failed: {status}")
356
+ return HighsDevIISResult(
357
+ valid=bool(iis.valid),
358
+ row_indices=tuple(int(x) for x in iis.row_index),
359
+ row_bound_statuses=tuple(int(x) for x in iis.row_bound),
360
+ col_indices=tuple(int(x) for x in iis.col_index),
361
+ col_bound_statuses=tuple(int(x) for x in iis.col_bound),
362
+ strategy=int(iis.strategy),
363
+ )
@@ -0,0 +1,23 @@
1
+ from .acquire import acquire_dataset, dataset_spec_fingerprint, sha256_file
2
+ from .baselines import BaselineFit, fit_sbs_backend, fit_sbs_backend_from_split, fixed_backend_policy, seeded_random_policy, threshold_policy
3
+ from .bundle import create_result_bundle
4
+ from .campaign import CAMPAIGN_SCHEMA_VERSION, CampaignSpec, CampaignValidation, load_campaign_spec, miplib2017_campaign_template, qplib_campaign_template, run_campaign, save_campaign_spec, validate_campaign
5
+ from .environment import capture_environment, write_environment
6
+ from .model import AcquisitionFile, AcquisitionRecord, DatasetSpec, RunSpec
7
+ from .policy import evaluate_policy_jsonl, evaluate_policy_map, load_policy_map
8
+ from .registry import benchmark_registry
9
+ from .report import compute_scientific_metrics, render_scientific_report_markdown, scientific_report_payload, write_scientific_report
10
+ from .runner import run_benchmark
11
+ from .slurm import render_slurm_array, write_slurm_array
12
+ from .splits import SplitRecord, SplitValidation, filter_rows_by_split, generate_group_split, instances_by_split, load_split_records, save_split_records, validate_split_file, validate_split_records
13
+ from .specs import BUILTIN_DATASETS, MIPLIB2017_BENCHMARK_V2, QPLIB_ARCHIVE, get_builtin_dataset, load_dataset_spec, save_dataset_spec
14
+ from .summary import load_rows, summarize_jsonl, summarize_rows
15
+ from .verify import DatasetVerification, verify_acquired_dataset
16
+
17
+ __all__ = [
18
+ "AcquisitionFile","AcquisitionRecord","DatasetSpec","RunSpec","BUILTIN_DATASETS","MIPLIB2017_BENCHMARK_V2","QPLIB_ARCHIVE","get_builtin_dataset","load_dataset_spec","save_dataset_spec","acquire_dataset","dataset_spec_fingerprint","sha256_file",
19
+ "capture_environment","write_environment","run_benchmark","benchmark_registry","summarize_jsonl","summarize_rows","load_rows","render_slurm_array","write_slurm_array","create_result_bundle","DatasetVerification","verify_acquired_dataset",
20
+ "load_policy_map","evaluate_policy_map","evaluate_policy_jsonl","SplitRecord","SplitValidation","filter_rows_by_split","generate_group_split","instances_by_split","load_split_records","save_split_records","validate_split_file","validate_split_records",
21
+ "CAMPAIGN_SCHEMA_VERSION","CampaignSpec","CampaignValidation","load_campaign_spec","miplib2017_campaign_template","qplib_campaign_template","run_campaign","save_campaign_spec","validate_campaign",
22
+ "BaselineFit","fit_sbs_backend","fit_sbs_backend_from_split","fixed_backend_policy","seeded_random_policy","threshold_policy","compute_scientific_metrics","scientific_report_payload","render_scientific_report_markdown","write_scientific_report",
23
+ ]
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from dataclasses import asdict
7
+ from pathlib import Path, PureWindowsPath
8
+ import shutil
9
+ import tarfile
10
+ import tempfile
11
+ import time
12
+ import urllib.request
13
+ from urllib.parse import urlparse
14
+ import zipfile
15
+
16
+ from .model import AcquisitionFile, AcquisitionRecord, DatasetSpec
17
+
18
+
19
+
20
+ def dataset_spec_fingerprint(spec: DatasetSpec) -> str:
21
+ payload = asdict(spec)
22
+ payload["instance_suffixes"] = list(spec.instance_suffixes)
23
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
24
+ return hashlib.sha256(raw).hexdigest()
25
+
26
+
27
+ def sha256_file(path: str | Path, *, chunk_size: int = 1024 * 1024) -> str:
28
+ h = hashlib.sha256()
29
+ with Path(path).open("rb") as f:
30
+ while True:
31
+ chunk = f.read(chunk_size)
32
+ if not chunk:
33
+ break
34
+ h.update(chunk)
35
+ return h.hexdigest()
36
+
37
+
38
+ def _download(url: str, dest: Path, *, timeout_s: float = 60.0, retries: int = 3) -> None:
39
+ if retries < 1:
40
+ raise ValueError("retries must be >= 1")
41
+ dest.parent.mkdir(parents=True, exist_ok=True)
42
+ tmp = dest.with_name(dest.name + ".part")
43
+ last_error: Exception | None = None
44
+ for attempt in range(retries):
45
+ offset = tmp.stat().st_size if tmp.exists() else 0
46
+ headers = {"User-Agent": "solverpilot-benchmark/0.1"}
47
+ if offset:
48
+ headers["Range"] = f"bytes={offset}-"
49
+ req = urllib.request.Request(url, headers=headers)
50
+ try:
51
+ with urllib.request.urlopen(req, timeout=timeout_s) as response:
52
+ status = getattr(response, "status", None)
53
+ append = bool(offset and status == 206)
54
+ mode = "ab" if append else "wb"
55
+ with tmp.open(mode) as out:
56
+ shutil.copyfileobj(response, out, length=1024 * 1024)
57
+ out.flush()
58
+ os.fsync(out.fileno())
59
+ os.replace(tmp, dest)
60
+ return
61
+ except Exception as exc:
62
+ last_error = exc
63
+ if attempt + 1 < retries:
64
+ time.sleep(min(4.0, 0.5 * (2**attempt)))
65
+ if last_error is None:
66
+ raise RuntimeError("download failed without a captured exception")
67
+ raise last_error
68
+
69
+
70
+ def _safe_member_path(root: Path, member_name: str) -> Path:
71
+ candidate = (root / member_name).resolve()
72
+ root_resolved = root.resolve()
73
+ if candidate != root_resolved and root_resolved not in candidate.parents:
74
+ raise ValueError(f"archive member escapes target directory: {member_name!r}")
75
+ return candidate
76
+
77
+
78
+ def _extract_archive(archive: Path, target: Path) -> int:
79
+ """Extract an archive into a fresh directory and replace *target* only on success.
80
+
81
+ Extracting directly into an existing directory can leave stale files behind when
82
+ an upstream snapshot removes entries. That is unacceptable for a benchmark
83
+ corpus because a later run could silently mix two dataset snapshots.
84
+ """
85
+
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.extract-", dir=target.parent))
88
+ try:
89
+ count = 0
90
+ if zipfile.is_zipfile(archive):
91
+ with zipfile.ZipFile(archive) as zf:
92
+ for info in zf.infolist():
93
+ _safe_member_path(staging, info.filename)
94
+ zf.extractall(staging)
95
+ count = sum(not x.is_dir() for x in zf.infolist())
96
+ elif tarfile.is_tarfile(archive):
97
+ with tarfile.open(archive) as tf:
98
+ members = tf.getmembers()
99
+ for member in members:
100
+ _safe_member_path(staging, member.name)
101
+ if member.issym() or member.islnk():
102
+ raise ValueError("archive symlinks/hardlinks are not accepted")
103
+ tf.extractall(staging, filter="data")
104
+ count = sum(m.isfile() for m in members)
105
+ else:
106
+ raise ValueError(f"unsupported archive format: {archive}")
107
+
108
+ if target.exists():
109
+ shutil.rmtree(target)
110
+ os.replace(staging, target)
111
+ return count
112
+ except Exception:
113
+ shutil.rmtree(staging, ignore_errors=True)
114
+ raise
115
+
116
+
117
+ def _fetch_role(
118
+ *, role: str, url: str | None, filename: str | None, expected_sha256: str | None, target: Path,
119
+ timeout_s: float, retries: int, force: bool,
120
+ ) -> AcquisitionFile | None:
121
+ if url is None:
122
+ return None
123
+ name = filename or Path(urlparse(url).path).name
124
+ if not name:
125
+ raise ValueError(f"cannot derive filename from URL for {role}")
126
+ if (name in {".", ".."} or "/" in name or "\\" in name or ":" in name
127
+ or PureWindowsPath(name).is_absolute()):
128
+ raise ValueError(f"download filename must be a single local filename: {name!r}")
129
+ dest = target / name
130
+ _safe_member_path(target, name)
131
+ _safe_member_path(target, name + ".part")
132
+ if force or not dest.exists():
133
+ _download(url, dest, timeout_s=timeout_s, retries=retries)
134
+ digest = sha256_file(dest)
135
+ verified = expected_sha256 is not None and digest.lower() == expected_sha256.lower()
136
+ if expected_sha256 is not None and not verified:
137
+ raise ValueError(f"SHA-256 mismatch for {role}: expected {expected_sha256}, got {digest}")
138
+ return AcquisitionFile(
139
+ role=role,
140
+ url=url,
141
+ path=str(dest.resolve()),
142
+ size_bytes=dest.stat().st_size,
143
+ sha256=digest,
144
+ expected_sha256=expected_sha256,
145
+ verified=verified,
146
+ )
147
+
148
+
149
+ def acquire_dataset(
150
+ spec: DatasetSpec,
151
+ target_dir: str | Path,
152
+ *,
153
+ extract: bool = True,
154
+ timeout_s: float = 60.0,
155
+ retries: int = 3,
156
+ force: bool = False,
157
+ ) -> AcquisitionRecord:
158
+ target = Path(target_dir)
159
+ target.mkdir(parents=True, exist_ok=True)
160
+ files = []
161
+ for role, url, filename, expected in (
162
+ ("archive", spec.archive_url, spec.archive_filename, spec.archive_sha256),
163
+ ("manifest", spec.manifest_url, spec.manifest_filename, spec.manifest_sha256),
164
+ ("reference", spec.reference_url, spec.reference_filename, spec.reference_sha256),
165
+ ):
166
+ item = _fetch_role(
167
+ role=role, url=url, filename=filename, expected_sha256=expected,
168
+ target=target, timeout_s=timeout_s, retries=retries, force=force,
169
+ )
170
+ if item is not None:
171
+ files.append(item)
172
+ extracted_count = 0
173
+ if extract:
174
+ archive_file = next((f for f in files if f.role == "archive"), None)
175
+ if archive_file is not None:
176
+ extracted_count = _extract_archive(Path(archive_file.path), target / "instances")
177
+ remote_roles = tuple(f.role for f in files)
178
+ unpinned_roles = tuple(sorted(f.role for f in files if f.expected_sha256 is None))
179
+ record = AcquisitionRecord(
180
+ dataset=spec.name,
181
+ spec_kind=spec.kind,
182
+ target_dir=str(target.resolve()),
183
+ files=tuple(files),
184
+ extracted=bool(extract and any(f.role == "archive" for f in files)),
185
+ extracted_files=extracted_count,
186
+ metadata=dict(spec.metadata),
187
+ schema_version="2.0",
188
+ spec_fingerprint=dataset_spec_fingerprint(spec),
189
+ provenance_level=spec.provenance_level,
190
+ all_remote_inputs_pinned=bool(remote_roles) and not unpinned_roles,
191
+ unpinned_roles=unpinned_roles,
192
+ )
193
+ lock = target / "acquisition-lock.json"
194
+ lock.write_text(json.dumps(asdict(record), indent=2, sort_keys=True), encoding="utf-8")
195
+ lock_digest = sha256_file(lock)
196
+ (target / "acquisition-lock.sha256").write_text(
197
+ f"{lock_digest} {lock.name}\n", encoding="utf-8"
198
+ )
199
+ return record
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import random
6
+ from typing import Iterable, Mapping
7
+
8
+ from .summary import summarize_rows
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class BaselineFit:
13
+ baseline_type: str
14
+ selected_backend: str | None
15
+ training_instances_sha256: str
16
+ parameters: Mapping[str, object]
17
+
18
+
19
+ def _instance_digest(instances: Iterable[str]) -> str:
20
+ canonical = "\n".join(sorted(map(str, instances))).encode("utf-8")
21
+ return hashlib.sha256(canonical).hexdigest()
22
+
23
+
24
+ def _entry(backend: str, overhead_s: float, *, component: str) -> dict:
25
+ overhead=float(overhead_s)
26
+ if overhead < 0: raise ValueError("overhead_s must be non-negative")
27
+ return {"backend":str(backend),"overhead_s":overhead,"overhead_components_s":{component:overhead}}
28
+
29
+
30
+ def fixed_backend_policy(instances: Iterable[str], backend: str, *, overhead_s: float = 0.0) -> dict[str, dict]:
31
+ return {str(instance):_entry(backend,overhead_s,component="fixed_policy") for instance in sorted(set(instances))}
32
+
33
+
34
+ def seeded_random_policy(instances: Iterable[str], backends: Iterable[str], *, seed: int, overhead_s: float = 0.0) -> dict[str, dict]:
35
+ choices=tuple(sorted(set(map(str,backends))))
36
+ if not choices: raise ValueError("backends must not be empty")
37
+ out={}
38
+ for instance in sorted(set(map(str,instances))):
39
+ local_seed=int.from_bytes(hashlib.sha256(f"{seed}\0{instance}".encode()).digest()[:8],"big")
40
+ out[instance]=_entry(random.Random(local_seed).choice(choices),overhead_s,component="random_selection")
41
+ return out
42
+
43
+
44
+ def threshold_policy(features: Mapping[str, Mapping[str, float]], *, feature: str, threshold: float, le_backend: str, gt_backend: str, overhead_s: float) -> dict[str, dict]:
45
+ out={}
46
+ for instance in sorted(features):
47
+ row=features[instance]
48
+ if feature not in row: raise ValueError(f"feature {feature!r} missing for instance {instance!r}")
49
+ value=float(row[feature]); backend=le_backend if value <= threshold else gt_backend
50
+ entry=_entry(backend,overhead_s,component="rule_selection"); entry["rule"]={"feature":feature,"threshold":float(threshold),"value":value}; out[instance]=entry
51
+ return out
52
+
53
+
54
+ def fit_sbs_backend(rows: list[dict], *, training_instances: Iterable[str], cutoff_s: float, par_penalty: float = 10.0, cost_field: str = "wall_s") -> BaselineFit:
55
+ train=tuple(sorted(set(map(str,training_instances))))
56
+ if not train: raise ValueError("training_instances must not be empty")
57
+ train_set=set(train); filtered=[r for r in rows if str(r.get("instance")) in train_set and r.get("backend") != "@auto"]
58
+ if not filtered: raise ValueError("no benchmark rows match training_instances")
59
+ summary=summarize_rows(filtered,cutoff_s=cutoff_s,par_penalty=par_penalty,cost_field=cost_field,bootstrap_draws=100,bootstrap_seed=0)
60
+ return BaselineFit("sbs-on-training-split",str(summary["sbs_solver"]),_instance_digest(train),{"cutoff_s":float(cutoff_s),"par_penalty":float(par_penalty),"cost_field":cost_field,"training_instances":len(train)})
61
+
62
+
63
+ def fit_sbs_backend_from_split(rows: list[dict], split_records, *, fit_splits: tuple[str, ...] = ("train",), cutoff_s: float, par_penalty: float = 10.0, cost_field: str = "wall_s") -> BaselineFit:
64
+ normalized=tuple(str(x).lower() for x in fit_splits)
65
+ if "test" in normalized: raise ValueError("held-out test split must not be used to fit an SBS baseline")
66
+ from .splits import filter_rows_by_split, instances_by_split
67
+ filtered=filter_rows_by_split(rows,split_records,allowed_splits=normalized); grouped=instances_by_split(split_records)
68
+ training=[]
69
+ for name in normalized: training.extend(grouped[name])
70
+ return fit_sbs_backend(filtered,training_instances=training,cutoff_s=cutoff_s,par_penalty=par_penalty,cost_field=cost_field)