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,516 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import random
8
+ import subprocess
9
+ import sys
10
+ from time import perf_counter
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from solverpilot import ObjectiveSense, PublicStatus, SolveBudget, read_mps, solve
16
+ from solverpilot.backends import BackendRegistry
17
+ from solverpilot.evaluation import assess_benchmark_row, parse_miplib_solu, parse_test_manifest
18
+
19
+ from .acquire import sha256_file
20
+ from .environment import (
21
+ benchmark_environment_fingerprint,
22
+ capture_environment,
23
+ thread_environment,
24
+ )
25
+ from .integrity import (
26
+ CURRENT_BENCHMARK_ROW_SCHEMA,
27
+ attach_row_integrity,
28
+ canonical_sha256,
29
+ experiment_identity as compute_experiment_identity,
30
+ verify_row_integrity,
31
+ )
32
+ from .model import RunSpec
33
+ from .registry import benchmark_registry
34
+
35
+
36
+ _BENCHMARK_PACKAGES = (
37
+ "solverpilot",
38
+ "numpy",
39
+ "scipy",
40
+ "highspy",
41
+ "osqp",
42
+ "pyscipopt",
43
+ "nlopt",
44
+ "casadi",
45
+ "benchopt",
46
+ )
47
+
48
+
49
+ def _instance_key(filename: str) -> str:
50
+ lower = filename.lower()
51
+ for suffix in (".mps.gz", ".mps", ".lp.gz", ".lp"):
52
+ if lower.endswith(suffix):
53
+ return filename[: -len(suffix)]
54
+ return Path(filename).stem
55
+
56
+
57
+ def _reference_check(*, result, problem, reference, atol: float, rtol: float) -> str:
58
+ if reference is None or reference.status == "unknown":
59
+ return "not_checkable"
60
+ if reference.status == "infeasible":
61
+ if result.status is PublicStatus.INFEASIBLE:
62
+ return "infeasible_matches"
63
+ if result.status is PublicStatus.INFEASIBLE_OR_UNBOUNDED:
64
+ return "not_proven_infeasible"
65
+ return "infeasibility_mismatch"
66
+ if result.objective is None or reference.objective is None:
67
+ return "missing_solution"
68
+ observed = float(result.objective)
69
+ target = float(reference.objective)
70
+ if reference.status == "optimal":
71
+ return "objective_matches_optimum" if np.isclose(observed, target, atol=atol, rtol=rtol) else "objective_mismatch"
72
+ tol = atol + rtol * max(1.0, abs(target))
73
+ if problem.objective_sense is ObjectiveSense.MINIMIZE:
74
+ return "meets_or_beats_best_known" if observed <= target + tol else "worse_than_best_known"
75
+ return "meets_or_beats_best_known" if observed >= target - tol else "worse_than_best_known"
76
+
77
+
78
+ def _row_id(protocol_id: str, instance: str, instance_sha256: str, backend: str, repetition: int) -> str:
79
+ raw = f"{protocol_id}\0{instance}\0{instance_sha256}\0{backend}\0{repetition}".encode()
80
+ return hashlib.sha256(raw).hexdigest()[:24]
81
+
82
+
83
+ def _protocol_id(spec: RunSpec, registry: BackendRegistry | None) -> str:
84
+ backend_versions: dict[str, str | None] = {}
85
+ if registry is not None:
86
+ for backend in registry.all():
87
+ backend_versions[backend.manifest.name] = backend.manifest.version
88
+ payload = {
89
+ "schema_version": CURRENT_BENCHMARK_ROW_SCHEMA,
90
+ "manifest_sha256": sha256_file(spec.manifest),
91
+ "reference_sha256": None if spec.reference is None else sha256_file(spec.reference),
92
+ "backends": list(spec.backends),
93
+ "backend_versions": backend_versions,
94
+ "repetitions": spec.repetitions,
95
+ "time_limit_s": spec.time_limit_s,
96
+ "hard_timeout_s": spec.hard_timeout_s,
97
+ "objective_atol": spec.objective_atol,
98
+ "objective_rtol": spec.objective_rtol,
99
+ "seed": spec.seed,
100
+ "thread_env_limit": spec.thread_env_limit,
101
+ "solver_threads": spec.solver_threads,
102
+ "worker_python_mode": spec.worker_python_mode,
103
+ }
104
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
105
+ return hashlib.sha256(raw).hexdigest()
106
+
107
+
108
+ def _load_completed(path: Path) -> set[str]:
109
+ if not path.exists():
110
+ return set()
111
+ data = path.read_bytes()
112
+ lines = data.splitlines(keepends=True)
113
+ completed: set[str] = set()
114
+ valid_end = 0
115
+ for idx, raw in enumerate(lines):
116
+ line_no = idx + 1
117
+ text = raw.decode("utf-8", errors="strict").strip()
118
+ if not text:
119
+ valid_end += len(raw)
120
+ continue
121
+ try:
122
+ row = json.loads(text)
123
+ except json.JSONDecodeError as exc:
124
+ # A killed worker/controller may leave only the final JSONL record truncated.
125
+ if idx == len(lines) - 1:
126
+ with path.open("r+b") as f:
127
+ f.truncate(valid_end)
128
+ break
129
+ raise ValueError(f"invalid JSONL at line {line_no}") from exc
130
+ verify_row_integrity(row, context=f"{path}:line {line_no}")
131
+ valid_end += len(raw)
132
+ if row.get("complete") is True and isinstance(row.get("run_id"), str):
133
+ completed.add(row["run_id"])
134
+ return completed
135
+
136
+
137
+ def _append_jsonl(path: Path, row: dict[str, Any]) -> None:
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+ row = dict(row)
140
+ if row.get("state") == "solved" and "proof_assessment" not in row:
141
+ row["proof_assessment"] = assess_benchmark_row(row).to_dict()
142
+ payload = attach_row_integrity(row)
143
+ with path.open("a", encoding="utf-8") as f:
144
+ f.write(json.dumps(payload, sort_keys=True) + "\n")
145
+ f.flush()
146
+ os.fsync(f.fileno())
147
+
148
+
149
+ def _resolve_instance(data_dir: Path, filename: str) -> Path | None:
150
+ candidates = [data_dir / filename, data_dir / "instances" / filename]
151
+ for candidate in candidates:
152
+ if candidate.exists():
153
+ return candidate
154
+ matches = list(data_dir.rglob(filename))
155
+ return matches[0] if len(matches) == 1 else None
156
+
157
+
158
+ def _stable_shard_index(filename: str, shard_count: int) -> int:
159
+ digest = hashlib.sha256(filename.encode("utf-8")).digest()
160
+ return int.from_bytes(digest[:8], "big") % shard_count
161
+
162
+
163
+ def _task_order(filename: str, *, backends: tuple[str, ...], repetitions: int, seed: int) -> list[tuple[str, int]]:
164
+ tasks = [(backend, rep) for backend in backends for rep in range(repetitions)]
165
+ raw = hashlib.sha256(f"{seed}\0{filename}".encode()).digest()
166
+ local_seed = int.from_bytes(raw[:8], "big")
167
+ random.Random(local_seed).shuffle(tasks)
168
+ return tasks
169
+
170
+
171
+ def _trace_payload(trace) -> dict[str, object]:
172
+ total = float(trace.timings.total_s)
173
+ solve_s = float(trace.timings.solve_s)
174
+ return {
175
+ "backend_version": trace.backend_version,
176
+ "inspect_s": float(trace.timings.inspect_s),
177
+ "plan_s": float(trace.timings.plan_s),
178
+ "backend_build_s": float(trace.timings.backend_build_s),
179
+ "solve_s": solve_s,
180
+ "validate_s": float(trace.timings.validate_s),
181
+ "diagnose_s": float(trace.timings.diagnose_s),
182
+ "total_s": total,
183
+ "runtime_overhead_s": max(0.0, total - solve_s),
184
+ "reuse_applied": trace.reuse_applied,
185
+ "reuse_mode": trace.reuse_mode,
186
+ "planner_selected_backend": trace.planner_selected_backend,
187
+ "planner_evidence_level": trace.planner_evidence_level,
188
+ }
189
+
190
+
191
+ def _clean_pythonpath() -> str:
192
+ """Build an explicit import path for ``python -S`` benchmark workers.
193
+
194
+ ``-S`` deliberately disables ``site`` and therefore platform/user sitecustomize
195
+ hooks. We re-expose only already-resolved import roots from the controller,
196
+ excluding known hook-only paths. The exact mode is protocol-versioned.
197
+ """
198
+
199
+ blocked_components = {"python-hooks", "pyvenv-overrides"}
200
+
201
+ def is_blocked(path: str) -> bool:
202
+ # ``sys.path`` may contain Windows paths even when tests run elsewhere.
203
+ # Normalize separators textually so the hook filter is OS-agnostic.
204
+ components = {part.casefold() for part in path.replace("\\", "/").split("/") if part}
205
+ return bool(components & blocked_components)
206
+
207
+ roots: list[str] = []
208
+ for raw in sys.path:
209
+ path = os.getcwd() if raw == "" else raw
210
+ if not path or is_blocked(path):
211
+ continue
212
+ try:
213
+ resolved = str(Path(path).resolve())
214
+ except Exception:
215
+ resolved = str(path)
216
+ if resolved not in roots and Path(resolved).exists():
217
+ roots.append(resolved)
218
+ return os.pathsep.join(roots)
219
+
220
+
221
+ def _isolated_solve(
222
+ path: Path,
223
+ *,
224
+ backend: str,
225
+ portfolio: tuple[str, ...],
226
+ time_limit_s: float | None,
227
+ hard_timeout_s: float | None,
228
+ thread_env_limit: int | None,
229
+ solver_threads: int | None,
230
+ worker_python_mode: str,
231
+ ) -> dict:
232
+ cmd = [sys.executable]
233
+ if worker_python_mode == "no_site":
234
+ cmd.append("-S")
235
+ cmd.extend(["-m", "solverpilot.cli.benchmark_worker", "--path", str(path), "--backend", backend])
236
+ for name in portfolio:
237
+ if name != "@auto":
238
+ cmd.extend(["--portfolio", name])
239
+ if time_limit_s is not None:
240
+ cmd.extend(["--time-limit-s", str(time_limit_s)])
241
+ if solver_threads is not None:
242
+ cmd.extend(["--solver-threads", str(solver_threads)])
243
+ env = os.environ.copy()
244
+ env.update(thread_environment(thread_env_limit))
245
+ if worker_python_mode == "no_site":
246
+ env["PYTHONPATH"] = _clean_pythonpath()
247
+ try:
248
+ proc = subprocess.run(cmd, text=True, capture_output=True, timeout=hard_timeout_s, env=env)
249
+ except subprocess.TimeoutExpired as exc:
250
+ return {"worker_state": "hard_timeout", "error_type": "TimeoutExpired", "error": str(exc)}
251
+ if proc.returncode != 0:
252
+ err = proc.stderr.strip()
253
+ try:
254
+ payload = json.loads(err.splitlines()[-1]) if err else {}
255
+ except Exception:
256
+ payload = {}
257
+ return {
258
+ "worker_state": "worker_error",
259
+ "error_type": payload.get("error_type", "WorkerProcessError"),
260
+ "error": payload.get("error", err[-2000:]),
261
+ "worker_returncode": proc.returncode,
262
+ }
263
+ try:
264
+ payload = json.loads(proc.stdout.strip().splitlines()[-1])
265
+ except Exception as exc:
266
+ return {"worker_state": "worker_error", "error_type": type(exc).__name__, "error": "invalid worker JSON output"}
267
+ return {"worker_state": "solved", **payload}
268
+
269
+
270
+ def _backend_identity(registry: BackendRegistry, backends: tuple[str, ...]) -> dict[str, dict[str, object]]:
271
+ out: dict[str, dict[str, object]] = {}
272
+ for name in backends:
273
+ if name == "@auto":
274
+ continue
275
+ backend = registry.get(name)
276
+ manifest = backend.manifest
277
+ out[name] = {
278
+ "name": manifest.name,
279
+ "version": manifest.version,
280
+ "capabilities": {k.value: v.value for k, v in sorted(manifest.capabilities.items(), key=lambda kv: kv[0].value)},
281
+ "metadata": dict(manifest.metadata),
282
+ "implementation": f"{type(backend).__module__}.{type(backend).__qualname__}",
283
+ }
284
+ return out
285
+
286
+
287
+ def _experiment_identity(backend_set_sha256: str, environment_id: str, thread_policy: dict[str, object], protocol_id: str) -> str:
288
+ return compute_experiment_identity(
289
+ protocol_id=protocol_id, environment_id=environment_id,
290
+ backend_set_sha256=backend_set_sha256, thread_policy=thread_policy,
291
+ )
292
+
293
+
294
+ def run_benchmark(spec: RunSpec, *, registry: BackendRegistry | None = None) -> dict:
295
+ registry = benchmark_registry(spec.backends) if registry is None else registry
296
+ manifest_text = spec.manifest.read_text(encoding="utf-8")
297
+ filenames = parse_test_manifest(manifest_text)
298
+ refs = {} if spec.reference is None else parse_miplib_solu(spec.reference.read_text(encoding="utf-8"))
299
+ completed = _load_completed(spec.output_jsonl) if spec.resume else set()
300
+ protocol_id = _protocol_id(spec, registry)
301
+ if not spec.resume and spec.output_jsonl.exists():
302
+ spec.output_jsonl.unlink()
303
+
304
+ environment = capture_environment(packages=_BENCHMARK_PACKAGES)
305
+ environment_id = benchmark_environment_fingerprint(
306
+ environment,
307
+ thread_env_limit=spec.thread_env_limit,
308
+ solver_threads=spec.solver_threads,
309
+ worker_python_mode=spec.worker_python_mode,
310
+ )
311
+ thread_policy = {
312
+ "thread_env_limit": spec.thread_env_limit,
313
+ "solver_threads": spec.solver_threads,
314
+ "worker_python_mode": spec.worker_python_mode,
315
+ "env_control": "process_start" if spec.thread_env_limit is not None else "none",
316
+ "backend_thread_control": "requested" if spec.solver_threads is not None else "not_requested",
317
+ }
318
+ backend_identity = _backend_identity(registry, tuple(b for b in spec.backends if b != "@auto"))
319
+ backend_set_sha256 = canonical_sha256(backend_identity)
320
+ experiment_identity = _experiment_identity(backend_set_sha256, environment_id, thread_policy, protocol_id)
321
+
322
+ chosen_files = [f for f in filenames if _stable_shard_index(f, spec.shard_count) == spec.shard_index]
323
+ rng = random.Random(spec.seed)
324
+ rng.shuffle(chosen_files)
325
+
326
+ counters = {"scheduled": 0, "skipped_resume": 0, "missing": 0, "completed": 0, "errors": 0}
327
+ started = perf_counter()
328
+ for filename in chosen_files:
329
+ instance = _instance_key(filename)
330
+ path = _resolve_instance(spec.dataset_dir, filename)
331
+ tasks = _task_order(filename, backends=spec.backends, repetitions=spec.repetitions, seed=spec.seed)
332
+ if path is None:
333
+ for backend, rep in tasks:
334
+ run_id = _row_id(protocol_id, instance, "missing", backend, rep)
335
+ if run_id in completed:
336
+ counters["skipped_resume"] += 1
337
+ continue
338
+ _append_jsonl(spec.output_jsonl, {
339
+ "schema_version": CURRENT_BENCHMARK_ROW_SCHEMA,
340
+ "protocol_id": protocol_id,
341
+ "environment_id": environment_id,
342
+ "thread_policy": thread_policy,
343
+ "experiment_identity": experiment_identity,
344
+ "backend_set_sha256": backend_set_sha256,
345
+ "backend_identity": backend_identity.get(backend, {}),
346
+ "backend_identity_sha256": canonical_sha256(backend_identity.get(backend, {})),
347
+ "run_id": run_id,
348
+ "complete": True,
349
+ "instance": instance,
350
+ "filename": filename,
351
+ "backend": backend,
352
+ "repetition": rep,
353
+ "state": "missing_instance",
354
+ })
355
+ counters["missing"] += 1
356
+ continue
357
+ try:
358
+ parse_t0 = perf_counter()
359
+ problem = read_mps(path)
360
+ parse_s = perf_counter() - parse_t0
361
+ file_sha = sha256_file(path)
362
+ parse_error = None
363
+ except Exception as exc:
364
+ problem = None
365
+ parse_s = None
366
+ file_sha = sha256_file(path)
367
+ parse_error = (type(exc).__name__, str(exc))
368
+
369
+ for backend, rep in tasks:
370
+ run_id = _row_id(protocol_id, instance, file_sha, backend, rep)
371
+ counters["scheduled"] += 1
372
+ if run_id in completed:
373
+ counters["skipped_resume"] += 1
374
+ continue
375
+ base = {
376
+ "schema_version": CURRENT_BENCHMARK_ROW_SCHEMA,
377
+ "protocol_id": protocol_id,
378
+ "environment_id": environment_id,
379
+ "thread_policy": thread_policy,
380
+ "experiment_identity": experiment_identity,
381
+ "backend_set_sha256": backend_set_sha256,
382
+ "backend_identity": backend_identity.get(backend, {}),
383
+ "backend_identity_sha256": canonical_sha256(backend_identity.get(backend, {})),
384
+ "run_id": run_id,
385
+ "complete": True,
386
+ "instance": instance,
387
+ "filename": filename,
388
+ "instance_sha256": file_sha,
389
+ "backend": backend,
390
+ "repetition": rep,
391
+ "parse_s": parse_s,
392
+ }
393
+ if parse_error is not None:
394
+ row = {**base, "state": "parse_error", "error_type": parse_error[0], "error": parse_error[1]}
395
+ counters["errors"] += 1
396
+ _append_jsonl(spec.output_jsonl, row)
397
+ continue
398
+ try:
399
+ wall_t0 = perf_counter()
400
+ ref = refs.get(instance)
401
+ isolate = spec.hard_timeout_s is not None or spec.thread_env_limit is not None or spec.solver_threads is not None
402
+ if isolate:
403
+ worker = _isolated_solve(
404
+ path,
405
+ backend=backend,
406
+ portfolio=spec.backends,
407
+ time_limit_s=spec.time_limit_s,
408
+ hard_timeout_s=spec.hard_timeout_s,
409
+ thread_env_limit=spec.thread_env_limit,
410
+ solver_threads=spec.solver_threads,
411
+ worker_python_mode=spec.worker_python_mode,
412
+ )
413
+ wall_s = perf_counter() - wall_t0
414
+ if worker["worker_state"] != "solved":
415
+ row = {
416
+ **base,
417
+ "state": worker["worker_state"],
418
+ "wall_s": wall_s,
419
+ "error_type": worker.get("error_type"),
420
+ "error": worker.get("error"),
421
+ }
422
+ counters["errors"] += 1
423
+ _append_jsonl(spec.output_jsonl, row)
424
+ continue
425
+
426
+ class _Result:
427
+ pass
428
+
429
+ result_proxy = _Result()
430
+ result_proxy.status = next(s for s in PublicStatus if s.value == worker["public_status"])
431
+ result_proxy.objective = worker.get("objective")
432
+ row = {
433
+ **base,
434
+ "state": "solved",
435
+ "wall_s": wall_s,
436
+ "public_status": worker["public_status"],
437
+ "objective": worker.get("objective"),
438
+ "validated": worker.get("validated"),
439
+ "reference_status": None if ref is None else ref.status,
440
+ "reference_objective": None if ref is None else ref.objective,
441
+ "reference_check": _reference_check(
442
+ result=result_proxy,
443
+ problem=problem,
444
+ reference=ref,
445
+ atol=spec.objective_atol,
446
+ rtol=spec.objective_rtol,
447
+ ),
448
+ "trace": worker.get("trace", {}),
449
+ "worker_parse_s": worker.get("parse_s"),
450
+ "worker_solve_wall_s": worker.get("worker_solve_wall_s"),
451
+ "optimality_evidence": worker.get("optimality_evidence"),
452
+ }
453
+ else:
454
+ budget = None
455
+ if spec.time_limit_s is not None or spec.solver_threads is not None:
456
+ budget = SolveBudget(wall_time_s=spec.time_limit_s, threads=spec.solver_threads)
457
+ result = solve(
458
+ problem,
459
+ registry=registry,
460
+ backend=None if backend == "@auto" else backend,
461
+ budget=budget,
462
+ )
463
+ wall_s = perf_counter() - wall_t0
464
+ row = {
465
+ **base,
466
+ "state": "solved",
467
+ "wall_s": wall_s,
468
+ "public_status": result.status.value,
469
+ "objective": result.objective,
470
+ "validated": None if result.validation is None else result.validation.valid,
471
+ "reference_status": None if ref is None else ref.status,
472
+ "reference_objective": None if ref is None else ref.objective,
473
+ "reference_check": _reference_check(
474
+ result=result,
475
+ problem=problem,
476
+ reference=ref,
477
+ atol=spec.objective_atol,
478
+ rtol=spec.objective_rtol,
479
+ ),
480
+ "trace": _trace_payload(result.trace),
481
+ "optimality_evidence": {
482
+ "backend_reported_optimal": result.optimality_evidence.backend_reported_optimal,
483
+ "primal_validated": result.optimality_evidence.primal_validated,
484
+ "dual_verified": result.optimality_evidence.dual_verified,
485
+ "gap_verified": result.optimality_evidence.gap_verified,
486
+ "certificate_verified": result.optimality_evidence.certificate_verified,
487
+ "independently_verified_optimal": result.optimality_evidence.independently_verified_optimal,
488
+ },
489
+ }
490
+ counters["completed"] += 1
491
+ except Exception as exc:
492
+ row = {**base, "state": "solve_error", "error_type": type(exc).__name__, "error": str(exc)}
493
+ counters["errors"] += 1
494
+ _append_jsonl(spec.output_jsonl, row)
495
+
496
+ return {
497
+ "schema_version": CURRENT_BENCHMARK_ROW_SCHEMA,
498
+ "protocol_id": protocol_id,
499
+ "environment_id": environment_id,
500
+ "experiment_identity": experiment_identity,
501
+ "backend_set_sha256": backend_set_sha256,
502
+ "manifest": str(spec.manifest),
503
+ "manifest_sha256": sha256_file(spec.manifest),
504
+ "reference": None if spec.reference is None else str(spec.reference),
505
+ "reference_sha256": None if spec.reference is None else sha256_file(spec.reference),
506
+ "dataset_dir": str(spec.dataset_dir),
507
+ "backends": list(spec.backends),
508
+ "repetitions": spec.repetitions,
509
+ "time_limit_s": spec.time_limit_s,
510
+ "hard_timeout_s": spec.hard_timeout_s,
511
+ "thread_policy": thread_policy,
512
+ "shard": {"index": spec.shard_index, "count": spec.shard_count, "instances": len(chosen_files)},
513
+ "counters": counters,
514
+ "elapsed_s": perf_counter() - started,
515
+ "environment": environment,
516
+ }
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import shlex
5
+
6
+
7
+ def render_slurm_array(
8
+ *,
9
+ spec_path: str,
10
+ dataset_dir: str,
11
+ manifest: str,
12
+ reference: str | None,
13
+ output_dir: str,
14
+ backends: tuple[str, ...],
15
+ shards: int,
16
+ repetitions: int,
17
+ time_limit_s: float | None,
18
+ job_name: str = "solverpilot-bench",
19
+ cpus_per_task: int = 1,
20
+ memory: str = "8G",
21
+ walltime: str = "02:00:00",
22
+ thread_env_limit: int | None = 1,
23
+ solver_threads: int | None = None,
24
+ worker_python_mode: str = "no_site",
25
+ ) -> str:
26
+ if shards < 1:
27
+ raise ValueError("shards must be >= 1")
28
+ backend_args = " ".join(f"--backend {shlex.quote(b)}" for b in backends)
29
+ ref_arg = "" if reference is None else f" --reference {shlex.quote(reference)}"
30
+ limit_arg = "" if time_limit_s is None else f" --time-limit-s {time_limit_s}"
31
+ thread_env_arg = " --thread-env-uncontrolled" if thread_env_limit is None else f" --thread-env-limit {thread_env_limit}"
32
+ solver_threads_arg = "" if solver_threads is None else f" --solver-threads {solver_threads}"
33
+ worker_mode_arg = f" --worker-python-mode {shlex.quote(worker_python_mode)}"
34
+ return f"""#!/bin/bash
35
+ #SBATCH --job-name={job_name}
36
+ #SBATCH --array=0-{shards - 1}
37
+ #SBATCH --cpus-per-task={cpus_per_task}
38
+ #SBATCH --mem={memory}
39
+ #SBATCH --time={walltime}
40
+ set -euo pipefail
41
+ mkdir -p {shlex.quote(output_dir)}
42
+ solverpilot-bench run \\
43
+ --dataset-dir {shlex.quote(dataset_dir)} \\
44
+ --manifest {shlex.quote(manifest)}{ref_arg} \\
45
+ {backend_args} \\
46
+ --repetitions {repetitions}{limit_arg}{thread_env_arg}{solver_threads_arg} \\
47
+ --shard-index "$SLURM_ARRAY_TASK_ID" --shard-count {shards} \\
48
+ --output {shlex.quote(output_dir)}/shard-${{SLURM_ARRAY_TASK_ID}}.jsonl
49
+ """
50
+
51
+
52
+ def write_slurm_array(path: str | Path, **kwargs) -> str:
53
+ text = render_slurm_array(**kwargs)
54
+ Path(path).write_text(text, encoding="utf-8")
55
+ return text
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from pathlib import Path
6
+
7
+ from .model import DatasetSpec
8
+
9
+
10
+ MIPLIB2017_BENCHMARK_V2 = DatasetSpec(
11
+ name="miplib2017-benchmark-v2",
12
+ kind="miplib2017-mps",
13
+ archive_url="https://miplib.zib.de/downloads/benchmark.zip",
14
+ manifest_url="https://miplib.zib.de/downloads/benchmark-v2.test",
15
+ reference_url="https://miplib.zib.de/downloads/miplib2017-v36.solu",
16
+ archive_filename="benchmark.zip",
17
+ archive_sha256="c756eefd544d83b31809306b45d3549a1a5b9378e6aa78b68738b1a3b6a418fa",
18
+ manifest_filename="benchmark-v2.test",
19
+ reference_filename="miplib2017-v36.solu",
20
+ provenance_level="PINNED_ARCHIVE_ONLY",
21
+ metadata={
22
+ "benchmark_instances": 240,
23
+ "benchmark_version": 2,
24
+ "solution_file_version": 36,
25
+ "solution_file_release": "2026-01-26",
26
+ "source": "Zuse Institute Berlin / MIPLIB 2017",
27
+ },
28
+ )
29
+
30
+
31
+ QPLIB_ARCHIVE = DatasetSpec(
32
+ name="qplib-current",
33
+ kind="qplib",
34
+ archive_url="https://qplib.zib.de/qplib.zip",
35
+ archive_filename="qplib.zip",
36
+ instance_suffixes=(".qplib",),
37
+ provenance_level="UNPINNED_EXTERNAL",
38
+ metadata={
39
+ "continuous_instances": 134,
40
+ "discrete_instances": 319,
41
+ "continuous_convex_instances": 32,
42
+ "solverpilot_supported_subset_count": None,
43
+ "solverpilot_scope_note": "32 is the official continuous-convex count, not the supported count; current IR also requires linear constraints and must parse/filter before benchmarking",
44
+ "license": "CC-BY-4.0",
45
+ "latest_site_update": "2026-08-07",
46
+ "archive_url_verified_from_official_site": True,
47
+ "runner_support": "acquisition-only in M12; QPLIB parser/filter not yet implemented",
48
+ "source": "Zuse Institute Berlin / QPLIB",
49
+ },
50
+ )
51
+
52
+
53
+ BUILTIN_DATASETS = {
54
+ MIPLIB2017_BENCHMARK_V2.name: MIPLIB2017_BENCHMARK_V2,
55
+ QPLIB_ARCHIVE.name: QPLIB_ARCHIVE,
56
+ }
57
+
58
+
59
+ def get_builtin_dataset(name: str) -> DatasetSpec:
60
+ try:
61
+ return BUILTIN_DATASETS[name]
62
+ except KeyError as exc:
63
+ raise KeyError(f"unknown built-in dataset: {name}") from exc
64
+
65
+
66
+ def save_dataset_spec(spec: DatasetSpec, path: str | Path) -> None:
67
+ payload = asdict(spec)
68
+ payload["instance_suffixes"] = list(spec.instance_suffixes)
69
+ Path(path).write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
70
+
71
+
72
+ def load_dataset_spec(path: str | Path) -> DatasetSpec:
73
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
74
+ payload["instance_suffixes"] = tuple(payload.get("instance_suffixes", (".mps.gz", ".mps")))
75
+ return DatasetSpec(**payload)