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,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy import sparse
|
|
8
|
+
|
|
9
|
+
from solverpilot.backends import Backend, BackendRegistry
|
|
10
|
+
from solverpilot.plan import PlannerContext, SolveBudget, SolveIntent
|
|
11
|
+
from solverpilot.problem import LinearProblem, QuadraticProblem
|
|
12
|
+
from solverpilot.runtime import SolveResult, solve as runtime_solve
|
|
13
|
+
|
|
14
|
+
from .mutations import MutationRecord, classify_mutations
|
|
15
|
+
from .reuse import ReuseAssessment, assess_reuse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Session:
|
|
19
|
+
"""Versioned repeated-solve session with explicit mutation tracking.
|
|
20
|
+
|
|
21
|
+
M1 tracks and assesses reuse safely but does not pretend to apply native solver
|
|
22
|
+
state. Actual basis/factorization/incumbent injection is reserved for native
|
|
23
|
+
backend adapters that expose a stateful contract.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
problem: LinearProblem | QuadraticProblem,
|
|
29
|
+
*,
|
|
30
|
+
registry: BackendRegistry | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._problem = problem
|
|
33
|
+
if registry is None:
|
|
34
|
+
from solverpilot.runtime import default_registry
|
|
35
|
+
registry = default_registry()
|
|
36
|
+
self._registry = registry
|
|
37
|
+
self._revision = 0
|
|
38
|
+
self._history: list[MutationRecord] = []
|
|
39
|
+
self._last_result: SolveResult | None = None
|
|
40
|
+
self._last_solve_revision: int | None = None
|
|
41
|
+
self._last_reuse_assessment: ReuseAssessment | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def problem(self) -> LinearProblem | QuadraticProblem:
|
|
45
|
+
return self._problem
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def revision(self) -> int:
|
|
49
|
+
return self._revision
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def history(self) -> tuple[MutationRecord, ...]:
|
|
53
|
+
return tuple(self._history)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def last_result(self) -> SolveResult | None:
|
|
57
|
+
return self._last_result
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def last_reuse_assessment(self) -> ReuseAssessment | None:
|
|
61
|
+
return self._last_reuse_assessment
|
|
62
|
+
|
|
63
|
+
def update(self, **changes: Any) -> MutationRecord:
|
|
64
|
+
old = self._problem
|
|
65
|
+
new = _updated_problem(old, changes)
|
|
66
|
+
next_revision = self._revision if old.data_hash == new.data_hash else self._revision + 1
|
|
67
|
+
record = classify_mutations(
|
|
68
|
+
old,
|
|
69
|
+
new,
|
|
70
|
+
revision_before=self._revision,
|
|
71
|
+
revision_after=next_revision,
|
|
72
|
+
)
|
|
73
|
+
# Descriptive updates do not change the mathematical hash/revision,
|
|
74
|
+
# but must still be visible to the caller.
|
|
75
|
+
self._problem = new
|
|
76
|
+
if not record.is_noop:
|
|
77
|
+
self._revision = next_revision
|
|
78
|
+
self._history.append(record)
|
|
79
|
+
return record
|
|
80
|
+
|
|
81
|
+
def solve(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
backend: str | Backend | None = None,
|
|
85
|
+
intent: SolveIntent | str = SolveIntent.BALANCED,
|
|
86
|
+
budget: SolveBudget | None = None,
|
|
87
|
+
diagnose_infeasible: bool = False,
|
|
88
|
+
) -> SolveResult:
|
|
89
|
+
previous_backend = None if self._last_result is None else self._last_result.trace.backend
|
|
90
|
+
context = PlannerContext(
|
|
91
|
+
previous_backend=previous_backend,
|
|
92
|
+
session_revision=self._revision,
|
|
93
|
+
)
|
|
94
|
+
result = runtime_solve(
|
|
95
|
+
self._problem,
|
|
96
|
+
registry=self._registry,
|
|
97
|
+
backend=backend,
|
|
98
|
+
intent=intent,
|
|
99
|
+
budget=budget,
|
|
100
|
+
context=context,
|
|
101
|
+
diagnose_infeasible=diagnose_infeasible,
|
|
102
|
+
)
|
|
103
|
+
result = replace(result, session_revision=self._revision)
|
|
104
|
+
|
|
105
|
+
if self._last_result is not None and result.trace.backend is not None:
|
|
106
|
+
since = self._mutations_since_last_solve()
|
|
107
|
+
if backend is not None and not isinstance(backend, str):
|
|
108
|
+
manifest = backend.manifest
|
|
109
|
+
else:
|
|
110
|
+
manifest = self._registry.get(result.trace.backend).manifest
|
|
111
|
+
assessment = assess_reuse(self._problem, manifest, since)
|
|
112
|
+
if result.trace.reuse_applied is True:
|
|
113
|
+
mode = result.trace.reuse_mode or "backend-reported reuse"
|
|
114
|
+
assessment = replace(
|
|
115
|
+
assessment,
|
|
116
|
+
applied=True,
|
|
117
|
+
safe_candidate=True,
|
|
118
|
+
reason=f"backend reported applied reuse: {mode}",
|
|
119
|
+
)
|
|
120
|
+
self._last_reuse_assessment = assessment
|
|
121
|
+
else:
|
|
122
|
+
self._last_reuse_assessment = None
|
|
123
|
+
|
|
124
|
+
self._last_result = result
|
|
125
|
+
self._last_solve_revision = self._revision
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
def _mutations_since_last_solve(self) -> tuple[MutationRecord, ...]:
|
|
129
|
+
if self._last_solve_revision is None:
|
|
130
|
+
return ()
|
|
131
|
+
return tuple(
|
|
132
|
+
record for record in self._history if record.revision_after > self._last_solve_revision
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _updated_problem(
|
|
137
|
+
problem: LinearProblem | QuadraticProblem,
|
|
138
|
+
changes: dict[str, Any],
|
|
139
|
+
) -> LinearProblem | QuadraticProblem:
|
|
140
|
+
allowed_linear = {
|
|
141
|
+
"A",
|
|
142
|
+
"c",
|
|
143
|
+
"variable_lower",
|
|
144
|
+
"variable_upper",
|
|
145
|
+
"constraint_lower",
|
|
146
|
+
"constraint_upper",
|
|
147
|
+
"domains",
|
|
148
|
+
"objective_sense",
|
|
149
|
+
"objective_offset",
|
|
150
|
+
"name",
|
|
151
|
+
"metadata",
|
|
152
|
+
}
|
|
153
|
+
allowed_qp = {
|
|
154
|
+
"P",
|
|
155
|
+
"A",
|
|
156
|
+
"q",
|
|
157
|
+
"variable_lower",
|
|
158
|
+
"variable_upper",
|
|
159
|
+
"constraint_lower",
|
|
160
|
+
"constraint_upper",
|
|
161
|
+
"objective_sense",
|
|
162
|
+
"objective_offset",
|
|
163
|
+
"metadata",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if isinstance(problem, LinearProblem):
|
|
167
|
+
unknown = set(changes) - allowed_linear
|
|
168
|
+
if unknown:
|
|
169
|
+
raise TypeError(f"unknown LinearProblem update fields: {sorted(unknown)}")
|
|
170
|
+
data = {
|
|
171
|
+
"A": problem.A,
|
|
172
|
+
"c": problem.c,
|
|
173
|
+
"variable_lower": problem.variable_lower,
|
|
174
|
+
"variable_upper": problem.variable_upper,
|
|
175
|
+
"constraint_lower": problem.constraint_lower,
|
|
176
|
+
"constraint_upper": problem.constraint_upper,
|
|
177
|
+
"domains": problem.domains,
|
|
178
|
+
"objective_sense": problem.objective_sense,
|
|
179
|
+
"objective_offset": problem.objective_offset,
|
|
180
|
+
"name": problem.name,
|
|
181
|
+
"metadata": problem.metadata,
|
|
182
|
+
}
|
|
183
|
+
data.update(changes)
|
|
184
|
+
return LinearProblem.from_data(**data)
|
|
185
|
+
|
|
186
|
+
if isinstance(problem, QuadraticProblem):
|
|
187
|
+
unknown = set(changes) - allowed_qp
|
|
188
|
+
if unknown:
|
|
189
|
+
raise TypeError(f"unknown QuadraticProblem update fields: {sorted(unknown)}")
|
|
190
|
+
linear = problem.linear
|
|
191
|
+
P = changes.get("P", problem.P)
|
|
192
|
+
A = changes.get("A", linear.A)
|
|
193
|
+
q = changes.get("q", linear.c)
|
|
194
|
+
vl = changes.get("variable_lower", linear.variable_lower)
|
|
195
|
+
vu = changes.get("variable_upper", linear.variable_upper)
|
|
196
|
+
cl = changes.get("constraint_lower", linear.constraint_lower)
|
|
197
|
+
cu = changes.get("constraint_upper", linear.constraint_upper)
|
|
198
|
+
sense = changes.get("objective_sense", linear.objective_sense)
|
|
199
|
+
offset = changes.get("objective_offset", linear.objective_offset)
|
|
200
|
+
metadata = changes.get("metadata", problem.metadata)
|
|
201
|
+
|
|
202
|
+
# Avoid an unnecessary eigenvalue calculation when P is unchanged.
|
|
203
|
+
if "P" not in changes:
|
|
204
|
+
new_linear = LinearProblem.from_data(
|
|
205
|
+
A=A,
|
|
206
|
+
c=q,
|
|
207
|
+
variable_lower=vl,
|
|
208
|
+
variable_upper=vu,
|
|
209
|
+
constraint_lower=cl,
|
|
210
|
+
constraint_upper=cu,
|
|
211
|
+
objective_sense=sense,
|
|
212
|
+
objective_offset=offset,
|
|
213
|
+
metadata=metadata,
|
|
214
|
+
)
|
|
215
|
+
return QuadraticProblem(
|
|
216
|
+
linear=new_linear,
|
|
217
|
+
P=problem.P,
|
|
218
|
+
convexity_status=problem.convexity_status,
|
|
219
|
+
metadata=metadata,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return QuadraticProblem.from_data(
|
|
223
|
+
P=P,
|
|
224
|
+
A=A,
|
|
225
|
+
q=q,
|
|
226
|
+
variable_lower=vl,
|
|
227
|
+
variable_upper=vu,
|
|
228
|
+
constraint_lower=cl,
|
|
229
|
+
constraint_upper=cu,
|
|
230
|
+
objective_sense=sense,
|
|
231
|
+
objective_offset=offset,
|
|
232
|
+
verify_convexity=True,
|
|
233
|
+
metadata=metadata,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
raise TypeError(f"unsupported problem type: {type(problem)!r}")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
TRACE_SCHEMA_VERSION = "0.3"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class PhaseTimings:
|
|
13
|
+
inspect_s: float = 0.0
|
|
14
|
+
plan_s: float = 0.0
|
|
15
|
+
backend_build_s: float = 0.0
|
|
16
|
+
solve_s: float = 0.0
|
|
17
|
+
validate_s: float = 0.0
|
|
18
|
+
diagnose_s: float = 0.0
|
|
19
|
+
total_s: float = 0.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class SolveTrace:
|
|
24
|
+
problem_structural_hash: str
|
|
25
|
+
problem_data_hash: str
|
|
26
|
+
problem_class: str
|
|
27
|
+
backend: str | None = None
|
|
28
|
+
backend_version: str | None = None
|
|
29
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
fingerprint: dict[str, Any] | None = None
|
|
31
|
+
planner_selected_backend: str | None = None
|
|
32
|
+
planner_evidence_level: str | None = None
|
|
33
|
+
planner_health_policy: str | None = None
|
|
34
|
+
transformations: tuple[str, ...] = ()
|
|
35
|
+
seed: int | None = None
|
|
36
|
+
threads: int | None = None
|
|
37
|
+
timings: PhaseTimings = field(default_factory=PhaseTimings)
|
|
38
|
+
termination: str | None = None
|
|
39
|
+
validation_valid: bool | None = None
|
|
40
|
+
reuse_applied: bool | None = None
|
|
41
|
+
reuse_mode: str | None = None
|
|
42
|
+
warnings: tuple[str, ...] = ()
|
|
43
|
+
schema_version: str = TRACE_SCHEMA_VERSION
|
|
44
|
+
created_at_utc: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return asdict(self)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .core import validate_solution
|
|
2
|
+
from .result import CandidateSolution, PublicStatus, ValidationReport, ValidationTolerances
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"CandidateSolution",
|
|
6
|
+
"PublicStatus",
|
|
7
|
+
"ValidationReport",
|
|
8
|
+
"ValidationTolerances",
|
|
9
|
+
"validate_solution",
|
|
10
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from solverpilot.problem import LinearProblem, QuadraticProblem, VariableDomain
|
|
6
|
+
|
|
7
|
+
from .result import CandidateSolution, ValidationReport, ValidationTolerances
|
|
8
|
+
|
|
9
|
+
_FEASIBILITY_REL = 1e-9
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _max_violation(values: np.ndarray, lower: np.ndarray, upper: np.ndarray) -> float:
|
|
13
|
+
if not np.isfinite(values).all():
|
|
14
|
+
return float("inf")
|
|
15
|
+
below = np.maximum(lower - values, 0.0)
|
|
16
|
+
above = np.maximum(values - upper, 0.0)
|
|
17
|
+
v = np.maximum(below, above)
|
|
18
|
+
return float(np.max(v)) if v.size else 0.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _scaled_feasibility(
|
|
22
|
+
values: np.ndarray,
|
|
23
|
+
lower: np.ndarray,
|
|
24
|
+
upper: np.ndarray,
|
|
25
|
+
*,
|
|
26
|
+
atol: float,
|
|
27
|
+
rtol: float,
|
|
28
|
+
extra_scale: np.ndarray | None = None,
|
|
29
|
+
) -> tuple[float, bool]:
|
|
30
|
+
"""Return max normalized violation and whether every row is within tolerance.
|
|
31
|
+
|
|
32
|
+
The absolute tolerance remains the numerical floor. The relative term scales
|
|
33
|
+
against the magnitude of the candidate, finite bounds and (for constraints)
|
|
34
|
+
an independently supplied row/activity scale.
|
|
35
|
+
"""
|
|
36
|
+
if not np.isfinite(values).all():
|
|
37
|
+
return float("inf"), False
|
|
38
|
+
scale = np.maximum(1.0, np.abs(values))
|
|
39
|
+
if extra_scale is not None:
|
|
40
|
+
if not np.isfinite(extra_scale).all():
|
|
41
|
+
return float("inf"), False
|
|
42
|
+
scale = np.maximum(scale, np.asarray(extra_scale, dtype=np.float64))
|
|
43
|
+
max_normalized = 0.0
|
|
44
|
+
ok = True
|
|
45
|
+
# Each side uses its own bound: a distant upper bound must not relax a
|
|
46
|
+
# violated lower bound (or vice versa).
|
|
47
|
+
for bound, direction in ((lower, -1.0), (upper, 1.0)):
|
|
48
|
+
mask = np.isfinite(bound)
|
|
49
|
+
with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
|
|
50
|
+
violation = np.maximum(direction * (values[mask] - bound[mask]), 0.0)
|
|
51
|
+
allowed = float(atol) + float(rtol) * np.maximum(scale[mask], np.abs(bound[mask]))
|
|
52
|
+
normalized = np.divide(violation, allowed, out=np.full_like(violation, np.inf), where=allowed > 0)
|
|
53
|
+
normalized[violation == 0] = 0.0
|
|
54
|
+
if normalized.size:
|
|
55
|
+
max_normalized = max(max_normalized, float(np.max(normalized)))
|
|
56
|
+
ok = ok and bool(np.all(np.isfinite(violation) & np.isfinite(allowed) & (violation <= allowed)))
|
|
57
|
+
return max_normalized, ok
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _linear_objective(problem: LinearProblem, x: np.ndarray) -> float:
|
|
61
|
+
return float(problem.c @ x + problem.objective_offset)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _quadratic_objective(problem: QuadraticProblem, x: np.ndarray) -> float:
|
|
65
|
+
return float(0.5 * x @ (problem.P @ x) + problem.linear.c @ x + problem.linear.objective_offset)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_solution(
|
|
69
|
+
problem: LinearProblem | QuadraticProblem,
|
|
70
|
+
solution: CandidateSolution,
|
|
71
|
+
*,
|
|
72
|
+
tolerances: ValidationTolerances | None = None,
|
|
73
|
+
) -> ValidationReport:
|
|
74
|
+
tol = ValidationTolerances() if tolerances is None else tolerances
|
|
75
|
+
linear = problem.linear if isinstance(problem, QuadraticProblem) else problem
|
|
76
|
+
x = np.asarray(solution.x, dtype=np.float64)
|
|
77
|
+
|
|
78
|
+
if x.shape != (linear.n_variables,):
|
|
79
|
+
raise ValueError(f"solution x must have shape ({linear.n_variables},), got {x.shape}")
|
|
80
|
+
if not np.isfinite(x).all():
|
|
81
|
+
return ValidationReport(
|
|
82
|
+
valid=False,
|
|
83
|
+
max_bound_violation=float("inf"),
|
|
84
|
+
max_constraint_violation=float("inf"),
|
|
85
|
+
max_integrality_violation=float("inf") if linear.has_integer_variables else None,
|
|
86
|
+
objective_recomputed=None,
|
|
87
|
+
objective_reported=solution.objective_reported,
|
|
88
|
+
objective_difference=None,
|
|
89
|
+
objective_consistent=False if solution.objective_reported is not None else None,
|
|
90
|
+
warnings=("solution contains NaN or infinity",),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
bound_v = _max_violation(x, linear.variable_lower, linear.variable_upper)
|
|
94
|
+
bound_scaled_v, bounds_ok = _scaled_feasibility(
|
|
95
|
+
x,
|
|
96
|
+
linear.variable_lower,
|
|
97
|
+
linear.variable_upper,
|
|
98
|
+
atol=tol.feasibility,
|
|
99
|
+
rtol=_FEASIBILITY_REL,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
activity = np.asarray(linear.A @ x, dtype=np.float64).reshape(-1)
|
|
103
|
+
constraint_v = _max_violation(activity, linear.constraint_lower, linear.constraint_upper)
|
|
104
|
+
row_scale = np.asarray(np.abs(linear.A) @ np.abs(x), dtype=np.float64).reshape(-1)
|
|
105
|
+
constraint_scaled_v, constraints_ok = _scaled_feasibility(
|
|
106
|
+
activity,
|
|
107
|
+
linear.constraint_lower,
|
|
108
|
+
linear.constraint_upper,
|
|
109
|
+
atol=tol.feasibility,
|
|
110
|
+
rtol=_FEASIBILITY_REL,
|
|
111
|
+
extra_scale=row_scale,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
integer_mask = np.isin(
|
|
115
|
+
linear.domains,
|
|
116
|
+
[VariableDomain.INTEGER.value, VariableDomain.BINARY.value],
|
|
117
|
+
)
|
|
118
|
+
if np.any(integer_mask):
|
|
119
|
+
int_v = float(np.max(np.abs(x[integer_mask] - np.rint(x[integer_mask]))))
|
|
120
|
+
else:
|
|
121
|
+
int_v = None
|
|
122
|
+
|
|
123
|
+
objective = (
|
|
124
|
+
_quadratic_objective(problem, x)
|
|
125
|
+
if isinstance(problem, QuadraticProblem)
|
|
126
|
+
else _linear_objective(problem, x)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
reported = solution.objective_reported
|
|
130
|
+
if not np.isfinite(objective):
|
|
131
|
+
objective_diff = None if reported is None else float("inf")
|
|
132
|
+
objective_ok = False
|
|
133
|
+
elif reported is None:
|
|
134
|
+
objective_diff = None
|
|
135
|
+
objective_ok = None
|
|
136
|
+
elif not np.isfinite(reported):
|
|
137
|
+
objective_diff = float("inf")
|
|
138
|
+
objective_ok = False
|
|
139
|
+
else:
|
|
140
|
+
objective_diff = float(abs(objective - reported))
|
|
141
|
+
objective_ok = bool(
|
|
142
|
+
objective_diff
|
|
143
|
+
<= tol.objective_abs + tol.objective_rel * max(abs(objective), abs(reported))
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
valid = (
|
|
147
|
+
bounds_ok
|
|
148
|
+
and constraints_ok
|
|
149
|
+
and (int_v is None or int_v <= tol.integrality)
|
|
150
|
+
and (objective_ok is not False)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
warnings: list[str] = []
|
|
154
|
+
if not np.isfinite(objective):
|
|
155
|
+
warnings.append("canonical objective is non-finite")
|
|
156
|
+
if not bounds_ok:
|
|
157
|
+
warnings.append("variable bound violation")
|
|
158
|
+
elif bound_v > tol.feasibility:
|
|
159
|
+
warnings.append("variable bound residual accepted by scale-aware feasibility tolerance")
|
|
160
|
+
if not constraints_ok:
|
|
161
|
+
warnings.append("constraint violation")
|
|
162
|
+
elif constraint_v > tol.feasibility:
|
|
163
|
+
warnings.append("constraint residual accepted by scale-aware feasibility tolerance")
|
|
164
|
+
if int_v is not None and int_v > tol.integrality:
|
|
165
|
+
warnings.append("integrality violation")
|
|
166
|
+
if objective_ok is False:
|
|
167
|
+
warnings.append("reported objective is inconsistent with canonical objective")
|
|
168
|
+
|
|
169
|
+
return ValidationReport(
|
|
170
|
+
valid=valid,
|
|
171
|
+
max_bound_violation=bound_v,
|
|
172
|
+
max_constraint_violation=constraint_v,
|
|
173
|
+
max_integrality_violation=int_v,
|
|
174
|
+
objective_recomputed=objective,
|
|
175
|
+
objective_reported=reported,
|
|
176
|
+
objective_difference=objective_diff,
|
|
177
|
+
objective_consistent=objective_ok,
|
|
178
|
+
warnings=tuple(warnings),
|
|
179
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PublicStatus(str, Enum):
|
|
10
|
+
VALID_OPTIMAL = "valid_optimal"
|
|
11
|
+
VALID_FEASIBLE = "valid_feasible"
|
|
12
|
+
FEASIBLE_LIMIT = "feasible_limit"
|
|
13
|
+
INFEASIBLE = "infeasible"
|
|
14
|
+
UNBOUNDED = "unbounded"
|
|
15
|
+
INFEASIBLE_OR_UNBOUNDED = "infeasible_or_unbounded"
|
|
16
|
+
INVALID_SOLUTION = "invalid_solution"
|
|
17
|
+
ERROR = "error"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class ValidationTolerances:
|
|
23
|
+
feasibility: float = 1e-7
|
|
24
|
+
integrality: float = 1e-6
|
|
25
|
+
objective_abs: float = 1e-7
|
|
26
|
+
objective_rel: float = 1e-7
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
for name in ("feasibility", "integrality", "objective_abs", "objective_rel"):
|
|
30
|
+
raw = getattr(self, name)
|
|
31
|
+
if isinstance(raw, bool) or type(raw) not in (int, float):
|
|
32
|
+
raise ValueError(f"{name} tolerance must be a finite number")
|
|
33
|
+
value = float(raw)
|
|
34
|
+
if not np.isfinite(value) or value < 0:
|
|
35
|
+
raise ValueError(f"{name} tolerance must be finite and non-negative")
|
|
36
|
+
object.__setattr__(self, name, value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ValidationReport:
|
|
41
|
+
valid: bool
|
|
42
|
+
max_bound_violation: float
|
|
43
|
+
max_constraint_violation: float
|
|
44
|
+
max_integrality_violation: float | None
|
|
45
|
+
objective_recomputed: float | None
|
|
46
|
+
objective_reported: float | None
|
|
47
|
+
objective_difference: float | None
|
|
48
|
+
objective_consistent: bool | None
|
|
49
|
+
warnings: tuple[str, ...] = ()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class CandidateSolution:
|
|
54
|
+
x: np.ndarray
|
|
55
|
+
objective_reported: float | None = None
|