solverpilot 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- solverpilot-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|