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