solverpilot 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. solverpilot/__init__.py +250 -0
  2. solverpilot/_immutability.py +27 -0
  3. solverpilot/_version.py +3 -0
  4. solverpilot/applications/__init__.py +3 -0
  5. solverpilot/applications/routing/__init__.py +19 -0
  6. solverpilot/applications/routing/compile_milp.py +321 -0
  7. solverpilot/applications/routing/diagnostics.py +78 -0
  8. solverpilot/applications/routing/errors.py +14 -0
  9. solverpilot/applications/routing/heuristics.py +167 -0
  10. solverpilot/applications/routing/model.py +259 -0
  11. solverpilot/applications/routing/reference.py +81 -0
  12. solverpilot/applications/routing/solution.py +89 -0
  13. solverpilot/applications/routing/timeline.py +79 -0
  14. solverpilot/applications/routing/validation.py +169 -0
  15. solverpilot/applications/tsp/__init__.py +41 -0
  16. solverpilot/applications/tsp/compile_cp.py +121 -0
  17. solverpilot/applications/tsp/compile_milp.py +139 -0
  18. solverpilot/applications/tsp/distance.py +60 -0
  19. solverpilot/applications/tsp/errors.py +17 -0
  20. solverpilot/applications/tsp/heuristics.py +112 -0
  21. solverpilot/applications/tsp/model.py +175 -0
  22. solverpilot/applications/tsp/reference.py +121 -0
  23. solverpilot/applications/tsp/solution.py +65 -0
  24. solverpilot/applications/tsp/validation.py +92 -0
  25. solverpilot/backends/__init__.py +51 -0
  26. solverpilot/backends/_casadi_highs_worker.py +92 -0
  27. solverpilot/backends/base.py +28 -0
  28. solverpilot/backends/bundled_capi.py +910 -0
  29. solverpilot/backends/casadi_conic.py +303 -0
  30. solverpilot/backends/health.py +240 -0
  31. solverpilot/backends/highspy_native.py +217 -0
  32. solverpilot/backends/nlopt_native.py +237 -0
  33. solverpilot/backends/osqp_native.py +297 -0
  34. solverpilot/backends/protocol_v2.py +119 -0
  35. solverpilot/backends/pyscipopt_native.py +200 -0
  36. solverpilot/backends/registry.py +38 -0
  37. solverpilot/backends/scipy_highs.py +138 -0
  38. solverpilot/backends/scipy_highs_lp.py +195 -0
  39. solverpilot/backends/scipy_slsqp_qp.py +133 -0
  40. solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
  41. solverpilot/benchmark/__init__.py +23 -0
  42. solverpilot/benchmark/acquire.py +199 -0
  43. solverpilot/benchmark/baselines.py +70 -0
  44. solverpilot/benchmark/bundle.py +37 -0
  45. solverpilot/benchmark/campaign.py +150 -0
  46. solverpilot/benchmark/environment.py +115 -0
  47. solverpilot/benchmark/integrity.py +46 -0
  48. solverpilot/benchmark/model.py +146 -0
  49. solverpilot/benchmark/policy.py +182 -0
  50. solverpilot/benchmark/registry.py +38 -0
  51. solverpilot/benchmark/report.py +44 -0
  52. solverpilot/benchmark/runner.py +516 -0
  53. solverpilot/benchmark/slurm.py +55 -0
  54. solverpilot/benchmark/specs.py +75 -0
  55. solverpilot/benchmark/splits.py +181 -0
  56. solverpilot/benchmark/summary.py +384 -0
  57. solverpilot/benchmark/verify.py +142 -0
  58. solverpilot/benchmark/worker.py +71 -0
  59. solverpilot/bridges/__init__.py +19 -0
  60. solverpilot/bridges/engine.py +44 -0
  61. solverpilot/bridges/indicator.py +232 -0
  62. solverpilot/bridges/registry.py +78 -0
  63. solverpilot/bridges/transformation-tape.schema.json +53 -0
  64. solverpilot/bridges/types.py +149 -0
  65. solverpilot/bridges/validation.py +110 -0
  66. solverpilot/capabilities/__init__.py +32 -0
  67. solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
  68. solverpilot/capabilities/conformance.py +151 -0
  69. solverpilot/capabilities/enums.py +27 -0
  70. solverpilot/capabilities/manifest.py +19 -0
  71. solverpilot/capabilities/ontology.py +73 -0
  72. solverpilot/capabilities/requirements.py +37 -0
  73. solverpilot/capabilities/resolve.py +30 -0
  74. solverpilot/capabilities/v2.py +711 -0
  75. solverpilot/cli/__init__.py +1 -0
  76. solverpilot/cli/backend_health.py +77 -0
  77. solverpilot/cli/benchmark.py +331 -0
  78. solverpilot/cli/benchmark_worker.py +33 -0
  79. solverpilot/cli/capabilities.py +65 -0
  80. solverpilot/conic/__init__.py +11 -0
  81. solverpilot/conic/backend.py +263 -0
  82. solverpilot/conic/compiler.py +310 -0
  83. solverpilot/conic/conformance.py +60 -0
  84. solverpilot/conic/conic-ir-v1.schema.json +24 -0
  85. solverpilot/conic/ir.py +299 -0
  86. solverpilot/conic/validation.py +149 -0
  87. solverpilot/cp/__init__.py +7 -0
  88. solverpilot/cp/conformance.py +32 -0
  89. solverpilot/cp/cp-ir-v1.schema.json +13 -0
  90. solverpilot/cp/ir.py +170 -0
  91. solverpilot/cp/model.py +143 -0
  92. solverpilot/cp/ortools_backend.py +423 -0
  93. solverpilot/cp/ortools_worker.py +75 -0
  94. solverpilot/cp/reference.py +51 -0
  95. solverpilot/cp/serialization.py +117 -0
  96. solverpilot/cp/validate.py +83 -0
  97. solverpilot/diagnose/__init__.py +33 -0
  98. solverpilot/diagnose/infeasibility.py +463 -0
  99. solverpilot/diagnose/model.py +87 -0
  100. solverpilot/evaluation/__init__.py +53 -0
  101. solverpilot/evaluation/metrics.py +150 -0
  102. solverpilot/evaluation/oracle.py +288 -0
  103. solverpilot/evaluation/proof.py +53 -0
  104. solverpilot/evaluation/references.py +73 -0
  105. solverpilot/evaluation/regret.py +281 -0
  106. solverpilot/exceptions.py +34 -0
  107. solverpilot/experimental/__init__.py +32 -0
  108. solverpilot/extensions/__init__.py +56 -0
  109. solverpilot/extensions/entrypoints.py +34 -0
  110. solverpilot/extensions/errors.py +35 -0
  111. solverpilot/extensions/manager.py +651 -0
  112. solverpilot/extensions/manifest.py +232 -0
  113. solverpilot/extensions/model.py +101 -0
  114. solverpilot/extensions/versioning.py +136 -0
  115. solverpilot/history/__init__.py +22 -0
  116. solverpilot/history/errors.py +15 -0
  117. solverpilot/history/model.py +129 -0
  118. solverpilot/history/sqlite.py +651 -0
  119. solverpilot/inspect/__init__.py +3 -0
  120. solverpilot/inspect/fingerprint.py +199 -0
  121. solverpilot/intelligence/__init__.py +36 -0
  122. solverpilot/intelligence/errors.py +16 -0
  123. solverpilot/intelligence/fingerprint.py +108 -0
  124. solverpilot/intelligence/leakage.py +69 -0
  125. solverpilot/intelligence/schema.py +314 -0
  126. solverpilot/intelligence/shift.py +208 -0
  127. solverpilot/io/__init__.py +41 -0
  128. solverpilot/io/binary64.py +49 -0
  129. solverpilot/io/csv.py +230 -0
  130. solverpilot/io/errors.py +25 -0
  131. solverpilot/io/hashing.py +111 -0
  132. solverpilot/io/json.py +90 -0
  133. solverpilot/io/mapping.py +195 -0
  134. solverpilot/io/model.py +407 -0
  135. solverpilot/io/numeric.py +108 -0
  136. solverpilot/io/provenance.py +114 -0
  137. solverpilot/io/result.py +45 -0
  138. solverpilot/io/source.py +104 -0
  139. solverpilot/io/strict_json.py +133 -0
  140. solverpilot/minlp/__init__.py +6 -0
  141. solverpilot/minlp/compiler.py +66 -0
  142. solverpilot/minlp/conformance.py +34 -0
  143. solverpilot/minlp/convexity.py +85 -0
  144. solverpilot/minlp/ir.py +35 -0
  145. solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
  146. solverpilot/minlp/orchestrator.py +220 -0
  147. solverpilot/minlp/validation.py +28 -0
  148. solverpilot/model/__init__.py +27 -0
  149. solverpilot/model/compiler.py +1263 -0
  150. solverpilot/model/errors.py +22 -0
  151. solverpilot/model/expression.py +373 -0
  152. solverpilot/model/model.py +716 -0
  153. solverpilot/model/sets.py +60 -0
  154. solverpilot/model/types.py +36 -0
  155. solverpilot/nlp/__init__.py +7 -0
  156. solverpilot/nlp/ad.py +166 -0
  157. solverpilot/nlp/backend.py +113 -0
  158. solverpilot/nlp/compiler.py +116 -0
  159. solverpilot/nlp/conformance.py +58 -0
  160. solverpilot/nlp/ir.py +70 -0
  161. solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
  162. solverpilot/nlp/validation.py +60 -0
  163. solverpilot/plan/__init__.py +33 -0
  164. solverpilot/plan/model.py +79 -0
  165. solverpilot/plan/planner.py +201 -0
  166. solverpilot/plan/production.py +627 -0
  167. solverpilot/plan/selective_lp.py +65 -0
  168. solverpilot/problem/__init__.py +16 -0
  169. solverpilot/problem/enums.py +20 -0
  170. solverpilot/problem/hashing.py +88 -0
  171. solverpilot/problem/linear.py +196 -0
  172. solverpilot/problem/mps.py +447 -0
  173. solverpilot/problem/quadratic.py +192 -0
  174. solverpilot/reporting/__init__.py +27 -0
  175. solverpilot/reporting/explain.py +327 -0
  176. solverpilot/reporting/model.py +264 -0
  177. solverpilot/reporting/render.py +55 -0
  178. solverpilot/runtime/__init__.py +19 -0
  179. solverpilot/runtime/auto.py +193 -0
  180. solverpilot/runtime/budgeting.py +43 -0
  181. solverpilot/runtime/executor.py +126 -0
  182. solverpilot/runtime/portfolio.py +197 -0
  183. solverpilot/runtime/result.py +72 -0
  184. solverpilot/session/__init__.py +29 -0
  185. solverpilot/session/conformance_v2.py +239 -0
  186. solverpilot/session/mutations.py +115 -0
  187. solverpilot/session/persistent-session-trace.schema.json +45 -0
  188. solverpilot/session/persistent.py +491 -0
  189. solverpilot/session/reuse.py +81 -0
  190. solverpilot/session/session.py +236 -0
  191. solverpilot/trace/__init__.py +3 -0
  192. solverpilot/trace/schema.py +47 -0
  193. solverpilot/validate/__init__.py +10 -0
  194. solverpilot/validate/core.py +179 -0
  195. solverpilot/validate/result.py +55 -0
  196. solverpilot-0.1.dist-info/METADATA +554 -0
  197. solverpilot-0.1.dist-info/RECORD +201 -0
  198. solverpilot-0.1.dist-info/WHEEL +5 -0
  199. solverpilot-0.1.dist-info/entry_points.txt +4 -0
  200. solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
  201. solverpilot-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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,3 @@
1
+ from .schema import TRACE_SCHEMA_VERSION, PhaseTimings, SolveTrace
2
+
3
+ __all__ = ["TRACE_SCHEMA_VERSION", "PhaseTimings", "SolveTrace"]
@@ -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