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,463 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ from scipy import sparse
8
+ from scipy.optimize import Bounds, LinearConstraint, milp
9
+
10
+ from solverpilot.problem import LinearProblem, VariableDomain
11
+
12
+ from .model import (
13
+ ConflictAtom,
14
+ ConflictSetResult,
15
+ DiagnosticIssue,
16
+ ElasticRelaxationResult,
17
+ EvidenceKind,
18
+ InfeasibilityReport,
19
+ NormalizedIISEvidence,
20
+ RelaxationViolation,
21
+ ViolationKind,
22
+ )
23
+
24
+
25
+ def find_static_infeasibility(problem: LinearProblem, *, atol: float = 1e-12) -> tuple[DiagnosticIssue, ...]:
26
+ """Find contradictions that require no optimization solve.
27
+
28
+ The checks are deliberately proof-producing: every returned issue is independently
29
+ sufficient to prove infeasibility. Heuristic numerical warnings belong elsewhere.
30
+ """
31
+ issues: list[DiagnosticIssue] = []
32
+
33
+ # A zero row has activity exactly zero. If zero is outside the ranged row bounds,
34
+ # that row alone proves infeasibility.
35
+ row_nnz = np.diff(problem.A.indptr)
36
+ for i in np.flatnonzero(row_nnz == 0):
37
+ lo = float(problem.constraint_lower[i])
38
+ hi = float(problem.constraint_upper[i])
39
+ if lo > atol or hi < -atol:
40
+ distance = lo if lo > 0 else -hi
41
+ issues.append(
42
+ DiagnosticIssue(
43
+ kind=ViolationKind.EMPTY_ROW,
44
+ index=int(i),
45
+ amount=float(distance),
46
+ message=(
47
+ f"constraint row {int(i)} has no nonzero coefficients, so its activity is 0, "
48
+ f"outside [{lo}, {hi}]"
49
+ ),
50
+ )
51
+ )
52
+
53
+ # Continuous feasibility of bounds is enforced by LinearProblem. Integer variables
54
+ # have the additional requirement that [lb, ub] contains at least one integer.
55
+ for j, domain in enumerate(problem.domains):
56
+ if domain not in {VariableDomain.INTEGER.value, VariableDomain.BINARY.value}:
57
+ continue
58
+ lo = float(problem.variable_lower[j])
59
+ hi = float(problem.variable_upper[j])
60
+ if not np.isfinite(lo) or not np.isfinite(hi):
61
+ continue
62
+ ceil_lo = np.ceil(lo - atol)
63
+ floor_hi = np.floor(hi + atol)
64
+ if ceil_lo > floor_hi:
65
+ issues.append(
66
+ DiagnosticIssue(
67
+ kind=ViolationKind.INTEGER_DOMAIN,
68
+ index=j,
69
+ amount=float(ceil_lo - floor_hi),
70
+ message=(
71
+ f"integer variable {j} has bounds [{lo}, {hi}] containing no integer value"
72
+ ),
73
+ )
74
+ )
75
+
76
+ return tuple(issues)
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class _SlackSource:
81
+ kind: ViolationKind
82
+ index: int
83
+ bound_value: float
84
+
85
+
86
+ def elastic_relaxation(
87
+ problem: LinearProblem,
88
+ *,
89
+ time_limit_s: float | None = None,
90
+ relative_weighting: bool = True,
91
+ violation_tol: float = 1e-9,
92
+ ) -> ElasticRelaxationResult:
93
+ """Solve a phase-I elastic model and report which bounds must move.
94
+
95
+ Every finite row and variable bound gets its own nonnegative slack. Original
96
+ integrality is preserved. This is *not* an IIS: it finds one minimum-weight
97
+ relaxation, which may contain redundant violations and may not be unique.
98
+ """
99
+ n = problem.n_variables
100
+ sources: list[_SlackSource] = []
101
+ rows: list[sparse.csr_matrix] = []
102
+ lower: list[float] = []
103
+ upper: list[float] = []
104
+
105
+ # We build one inequality per finite side. Slack columns are appended after x.
106
+ row_specs: list[tuple[sparse.csr_matrix, float, float, _SlackSource, float]] = []
107
+ A = problem.A.tocsr()
108
+ for i in range(problem.n_constraints):
109
+ arow = A.getrow(i)
110
+ lo = float(problem.constraint_lower[i])
111
+ hi = float(problem.constraint_upper[i])
112
+ if np.isfinite(lo):
113
+ source = _SlackSource(ViolationKind.ROW_LOWER, i, lo)
114
+ row_specs.append((arow, lo, np.inf, source, +1.0))
115
+ if np.isfinite(hi):
116
+ source = _SlackSource(ViolationKind.ROW_UPPER, i, hi)
117
+ row_specs.append((arow, -np.inf, hi, source, -1.0))
118
+
119
+ eye = sparse.eye(n, format="csr")
120
+ for j in range(n):
121
+ lo = float(problem.variable_lower[j])
122
+ hi = float(problem.variable_upper[j])
123
+ erow = eye.getrow(j)
124
+ if np.isfinite(lo):
125
+ source = _SlackSource(ViolationKind.VARIABLE_LOWER, j, lo)
126
+ row_specs.append((erow, lo, np.inf, source, +1.0))
127
+ if np.isfinite(hi):
128
+ source = _SlackSource(ViolationKind.VARIABLE_UPPER, j, hi)
129
+ row_specs.append((erow, -np.inf, hi, source, -1.0))
130
+
131
+ k = len(row_specs)
132
+ if k == 0:
133
+ # No finite bounds means the model cannot be infeasible from linear constraints.
134
+ return ElasticRelaxationResult(
135
+ feasible=True,
136
+ objective=0.0,
137
+ x=np.zeros(n, dtype=np.float64),
138
+ violations=(),
139
+ solver_status="trivial",
140
+ message="no finite row or variable bounds required relaxation",
141
+ )
142
+
143
+ # Sparse [original variables | one private slack per bound side].
144
+ pieces: list[sparse.csr_matrix] = []
145
+ for slack_idx, (base, lo, hi, source, slack_sign) in enumerate(row_specs):
146
+ slack = sparse.csr_matrix(
147
+ ([slack_sign], ([0], [slack_idx])), shape=(1, k), dtype=np.float64
148
+ )
149
+ pieces.append(sparse.hstack([base, slack], format="csr"))
150
+ lower.append(lo)
151
+ upper.append(hi)
152
+ sources.append(source)
153
+
154
+ M = sparse.vstack(pieces, format="csr")
155
+
156
+ weights = np.ones(k, dtype=np.float64)
157
+ if relative_weighting:
158
+ weights = np.asarray(
159
+ [1.0 / max(1.0, abs(src.bound_value)) for src in sources], dtype=np.float64
160
+ )
161
+ objective = np.concatenate([np.zeros(n, dtype=np.float64), weights])
162
+
163
+ integrality = np.zeros(n + k, dtype=np.int32)
164
+ for j, domain in enumerate(problem.domains):
165
+ if domain in {VariableDomain.INTEGER.value, VariableDomain.BINARY.value}:
166
+ integrality[j] = 1
167
+
168
+ # Original variable bounds are represented as elastic rows above, so x itself is free.
169
+ lb = np.concatenate([np.full(n, -np.inf), np.zeros(k)])
170
+ ub = np.full(n + k, np.inf)
171
+ options: dict[str, Any] = {"disp": False}
172
+ if time_limit_s is not None:
173
+ if time_limit_s <= 0:
174
+ raise ValueError("time_limit_s must be positive")
175
+ options["time_limit"] = float(time_limit_s)
176
+
177
+ result = milp(
178
+ c=objective,
179
+ integrality=integrality,
180
+ bounds=Bounds(lb, ub),
181
+ constraints=LinearConstraint(M, np.asarray(lower), np.asarray(upper)),
182
+ options=options,
183
+ )
184
+
185
+ if result.x is None or not bool(result.success):
186
+ return ElasticRelaxationResult(
187
+ feasible=False,
188
+ objective=None,
189
+ x=None,
190
+ violations=(),
191
+ solver_status=str(getattr(result, "status", "unknown")),
192
+ message=str(getattr(result, "message", "elastic relaxation failed")),
193
+ )
194
+
195
+ z = np.asarray(result.x, dtype=np.float64)
196
+ x = z[:n].copy()
197
+ slack_values = z[n:]
198
+ violations: list[RelaxationViolation] = []
199
+ for idx, amount in enumerate(slack_values):
200
+ raw = float(max(0.0, amount))
201
+ if raw <= violation_tol:
202
+ continue
203
+ src = sources[idx]
204
+ violations.append(
205
+ RelaxationViolation(
206
+ kind=src.kind,
207
+ index=src.index,
208
+ amount=raw,
209
+ normalized_amount=float(raw * weights[idx]),
210
+ )
211
+ )
212
+ violations.sort(key=lambda v: (-v.normalized_amount, v.kind.value, v.index))
213
+ return ElasticRelaxationResult(
214
+ feasible=True,
215
+ objective=float(result.fun),
216
+ x=x,
217
+ violations=tuple(violations),
218
+ solver_status=str(getattr(result, "status", "0")),
219
+ message=str(getattr(result, "message", "")),
220
+ )
221
+
222
+
223
+
224
+ def _all_bound_atoms(problem: LinearProblem) -> list[ConflictAtom]:
225
+ atoms: list[ConflictAtom] = []
226
+ for i in range(problem.n_constraints):
227
+ if np.isfinite(problem.constraint_lower[i]):
228
+ atoms.append(ConflictAtom(ViolationKind.ROW_LOWER, i))
229
+ if np.isfinite(problem.constraint_upper[i]):
230
+ atoms.append(ConflictAtom(ViolationKind.ROW_UPPER, i))
231
+ for j in range(problem.n_variables):
232
+ if np.isfinite(problem.variable_lower[j]):
233
+ atoms.append(ConflictAtom(ViolationKind.VARIABLE_LOWER, j))
234
+ if np.isfinite(problem.variable_upper[j]):
235
+ atoms.append(ConflictAtom(ViolationKind.VARIABLE_UPPER, j))
236
+ return atoms
237
+
238
+
239
+ def _feasibility_status_for_atoms(
240
+ problem: LinearProblem,
241
+ atoms: list[ConflictAtom],
242
+ *,
243
+ time_limit_s: float | None,
244
+ ) -> str:
245
+ n = problem.n_variables
246
+ rows: list[sparse.csr_matrix] = []
247
+ lo: list[float] = []
248
+ hi: list[float] = []
249
+ A = problem.A.tocsr()
250
+ eye = sparse.eye(n, format="csr")
251
+ for atom in atoms:
252
+ if atom.kind is ViolationKind.ROW_LOWER:
253
+ rows.append(A.getrow(atom.index)); lo.append(float(problem.constraint_lower[atom.index])); hi.append(np.inf)
254
+ elif atom.kind is ViolationKind.ROW_UPPER:
255
+ rows.append(A.getrow(atom.index)); lo.append(-np.inf); hi.append(float(problem.constraint_upper[atom.index]))
256
+ elif atom.kind is ViolationKind.VARIABLE_LOWER:
257
+ rows.append(eye.getrow(atom.index)); lo.append(float(problem.variable_lower[atom.index])); hi.append(np.inf)
258
+ elif atom.kind is ViolationKind.VARIABLE_UPPER:
259
+ rows.append(eye.getrow(atom.index)); lo.append(-np.inf); hi.append(float(problem.variable_upper[atom.index]))
260
+ else:
261
+ raise ValueError(f"unsupported conflict atom kind: {atom.kind}")
262
+
263
+ constraints = ()
264
+ if rows:
265
+ M = sparse.vstack(rows, format="csr")
266
+ constraints = LinearConstraint(M, np.asarray(lo), np.asarray(hi))
267
+
268
+ integrality = np.zeros(n, dtype=np.int32)
269
+ for j, domain in enumerate(problem.domains):
270
+ if domain in {VariableDomain.INTEGER.value, VariableDomain.BINARY.value}:
271
+ integrality[j] = 1
272
+
273
+ options: dict[str, Any] = {"disp": False}
274
+ if time_limit_s is not None:
275
+ options["time_limit"] = float(time_limit_s)
276
+ # Variable bounds are represented only through selected atoms. Keep x otherwise free.
277
+ result = milp(
278
+ c=np.zeros(n, dtype=np.float64),
279
+ integrality=integrality,
280
+ bounds=Bounds(np.full(n, -np.inf), np.full(n, np.inf)),
281
+ constraints=constraints,
282
+ options=options,
283
+ )
284
+ status = int(getattr(result, "status", -1))
285
+ if status == 2:
286
+ return "infeasible"
287
+ if status == 0:
288
+ return "feasible"
289
+ if status == 1:
290
+ return "limit"
291
+ if status == 3:
292
+ # With zero objective this normally means the feasibility model is feasible but
293
+ # solver termination classified it unbounded; it does not prove infeasibility.
294
+ return "feasible_or_unbounded"
295
+ return "error"
296
+
297
+
298
+ def deletion_filter_conflict(
299
+ problem: LinearProblem,
300
+ *,
301
+ max_checks: int = 250,
302
+ time_limit_s_per_check: float | None = None,
303
+ ) -> ConflictSetResult:
304
+ """Find an irreducible set of row/variable bound sides by deletion filtering.
305
+
306
+ This is a solver-agnostic fallback built on SciPy/HiGHS feasibility solves. If the
307
+ check budget is exhausted, ``complete`` and ``irreducible`` are false; callers must
308
+ not present the partial set as an IIS.
309
+ """
310
+ if max_checks <= 0:
311
+ raise ValueError("max_checks must be positive")
312
+ if time_limit_s_per_check is not None and time_limit_s_per_check <= 0:
313
+ raise ValueError("time_limit_s_per_check must be positive")
314
+
315
+ active = _all_bound_atoms(problem)
316
+ checks = 1
317
+ initial = _feasibility_status_for_atoms(problem, active, time_limit_s=time_limit_s_per_check)
318
+ if initial != "infeasible":
319
+ return ConflictSetResult(
320
+ atoms=(), irreducible=False, complete=True, checks=checks,
321
+ solver_status=initial,
322
+ message="full bound-side model was not proven infeasible",
323
+ )
324
+
325
+ pos = 0
326
+ while pos < len(active):
327
+ if checks >= max_checks:
328
+ return ConflictSetResult(
329
+ atoms=tuple(active), irreducible=False, complete=False, checks=checks,
330
+ solver_status="check_limit",
331
+ message="deletion-filter check budget exhausted before irreducibility proof",
332
+ )
333
+ trial = active[:pos] + active[pos + 1 :]
334
+ status = _feasibility_status_for_atoms(problem, trial, time_limit_s=time_limit_s_per_check)
335
+ checks += 1
336
+ if status == "infeasible":
337
+ # Atom was unnecessary; keep it deleted and test the new atom at this position.
338
+ active = trial
339
+ continue
340
+ if status == "limit":
341
+ return ConflictSetResult(
342
+ atoms=tuple(active), irreducible=False, complete=False, checks=checks,
343
+ solver_status="time_limit",
344
+ message="a feasibility subcheck hit its time limit",
345
+ )
346
+ if status == "error":
347
+ return ConflictSetResult(
348
+ atoms=tuple(active), irreducible=False, complete=False, checks=checks,
349
+ solver_status="solver_error",
350
+ message="a feasibility subcheck failed",
351
+ )
352
+ # Removing this atom made the set feasible, so it is necessary. By monotonicity
353
+ # it will remain necessary after later deletions.
354
+ pos += 1
355
+
356
+ return ConflictSetResult(
357
+ atoms=tuple(active), irreducible=True, complete=True, checks=checks,
358
+ solver_status="infeasible",
359
+ message="every retained atom is necessary for infeasibility",
360
+ )
361
+
362
+ def _normalize_native_iis(backend: Any, native: Any) -> NormalizedIISEvidence:
363
+ backend_name = getattr(getattr(backend, "manifest", None), "name", type(backend).__name__)
364
+ # HiGHS development/public-style result.
365
+ if hasattr(native, "row_indices") and hasattr(native, "col_indices"):
366
+ return NormalizedIISEvidence(
367
+ backend=str(backend_name),
368
+ valid=bool(getattr(native, "valid", True)),
369
+ row_indices=tuple(int(x) for x in getattr(native, "row_indices", ())),
370
+ variable_indices=tuple(int(x) for x in getattr(native, "col_indices", ())),
371
+ raw_type=type(native).__name__,
372
+ )
373
+ # SCIP-style normalized result.
374
+ if hasattr(native, "constraint_names") and hasattr(native, "variable_names"):
375
+ return NormalizedIISEvidence(
376
+ backend=str(backend_name),
377
+ valid=True,
378
+ constraint_names=tuple(str(x) for x in getattr(native, "constraint_names", ())),
379
+ variable_names=tuple(str(x) for x in getattr(native, "variable_names", ())),
380
+ irreducible=bool(getattr(native, "irreducible", True)),
381
+ raw_type=type(native).__name__,
382
+ )
383
+ raise TypeError(f"unsupported native IIS evidence type: {type(native)!r}")
384
+
385
+
386
+ def _try_native_iis(problem: LinearProblem, backend: Any) -> NormalizedIISEvidence | None:
387
+ if hasattr(backend, "compute_iis"):
388
+ return _normalize_native_iis(backend, backend.compute_iis(problem))
389
+ if hasattr(backend, "generate_iis"):
390
+ return _normalize_native_iis(backend, backend.generate_iis(problem))
391
+ return None
392
+
393
+
394
+ def diagnose_infeasibility(
395
+ problem: LinearProblem,
396
+ *,
397
+ backend: Any | None = None,
398
+ include_elastic: bool = True,
399
+ include_conflict: bool = False,
400
+ conflict_max_checks: int = 250,
401
+ time_limit_s: float | None = None,
402
+ ) -> InfeasibilityReport:
403
+ """Build typed infeasibility evidence without conflating IIS and relaxation.
404
+
405
+ The function does not infer infeasibility merely because an elastic model has a
406
+ positive objective; callers should normally invoke it after a solver has classified
407
+ the original model infeasible. Static contradictions *do* independently prove it.
408
+ """
409
+ static = find_static_infeasibility(problem)
410
+ order: list[EvidenceKind] = []
411
+ warnings: list[str] = []
412
+ if static:
413
+ order.append(EvidenceKind.STATIC_CONTRADICTION)
414
+
415
+ iis = None
416
+ if backend is not None:
417
+ try:
418
+ iis = _try_native_iis(problem, backend)
419
+ if iis is not None:
420
+ order.append(EvidenceKind.NATIVE_IIS)
421
+ except (NotImplementedError, ValueError) as exc:
422
+ warnings.append(f"native IIS unavailable for this model: {exc}")
423
+ except Exception as exc: # diagnostic failure must not corrupt the solve result
424
+ warnings.append(f"native IIS failed: {type(exc).__name__}: {exc}")
425
+
426
+ elastic = None
427
+ if include_elastic:
428
+ try:
429
+ elastic = elastic_relaxation(problem, time_limit_s=time_limit_s)
430
+ order.append(EvidenceKind.ELASTIC_RELAXATION)
431
+ except Exception as exc:
432
+ warnings.append(f"elastic relaxation failed: {type(exc).__name__}: {exc}")
433
+
434
+ conflict = None
435
+ if include_conflict:
436
+ try:
437
+ conflict = deletion_filter_conflict(
438
+ problem,
439
+ max_checks=conflict_max_checks,
440
+ time_limit_s_per_check=time_limit_s,
441
+ )
442
+ order.append(EvidenceKind.DELETION_FILTER)
443
+ except Exception as exc:
444
+ warnings.append(f"deletion filter failed: {type(exc).__name__}: {exc}")
445
+
446
+ elastic_proves_infeasible = bool(
447
+ elastic is not None
448
+ and elastic.feasible
449
+ and elastic.objective is not None
450
+ and elastic.objective > 1e-9
451
+ )
452
+ iis_proves_infeasible = bool(iis is not None and iis.valid)
453
+ conflict_proves_infeasible = bool(conflict is not None and conflict.complete and conflict.irreducible)
454
+ confirmed = bool(static) or iis_proves_infeasible or elastic_proves_infeasible or conflict_proves_infeasible
455
+ return InfeasibilityReport(
456
+ confirmed_infeasible=confirmed,
457
+ static_issues=static,
458
+ iis=iis,
459
+ elastic=elastic,
460
+ conflict=conflict,
461
+ evidence_order=tuple(order),
462
+ warnings=tuple(warnings),
463
+ )
@@ -0,0 +1,87 @@
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 EvidenceKind(str, Enum):
10
+ STATIC_CONTRADICTION = "static_contradiction"
11
+ NATIVE_IIS = "native_iis"
12
+ ELASTIC_RELAXATION = "elastic_relaxation"
13
+ DELETION_FILTER = "deletion_filter"
14
+
15
+
16
+ class ViolationKind(str, Enum):
17
+ ROW_LOWER = "row_lower"
18
+ ROW_UPPER = "row_upper"
19
+ VARIABLE_LOWER = "variable_lower"
20
+ VARIABLE_UPPER = "variable_upper"
21
+ INTEGER_DOMAIN = "integer_domain"
22
+ EMPTY_ROW = "empty_row"
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class DiagnosticIssue:
27
+ kind: ViolationKind
28
+ index: int
29
+ message: str
30
+ amount: float | None = None
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class RelaxationViolation:
35
+ kind: ViolationKind
36
+ index: int
37
+ amount: float
38
+ normalized_amount: float
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ElasticRelaxationResult:
43
+ feasible: bool
44
+ objective: float | None
45
+ x: np.ndarray | None
46
+ violations: tuple[RelaxationViolation, ...]
47
+ solver_status: str
48
+ message: str
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class NormalizedIISEvidence:
53
+ backend: str
54
+ valid: bool
55
+ row_indices: tuple[int, ...] = ()
56
+ variable_indices: tuple[int, ...] = ()
57
+ constraint_names: tuple[str, ...] = ()
58
+ variable_names: tuple[str, ...] = ()
59
+ irreducible: bool | None = None
60
+ raw_type: str | None = None
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class ConflictAtom:
65
+ kind: ViolationKind
66
+ index: int
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class ConflictSetResult:
71
+ atoms: tuple[ConflictAtom, ...]
72
+ irreducible: bool
73
+ complete: bool
74
+ checks: int
75
+ solver_status: str
76
+ message: str
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class InfeasibilityReport:
81
+ confirmed_infeasible: bool
82
+ static_issues: tuple[DiagnosticIssue, ...]
83
+ iis: NormalizedIISEvidence | None
84
+ elastic: ElasticRelaxationResult | None
85
+ conflict: ConflictSetResult | None
86
+ evidence_order: tuple[EvidenceKind, ...]
87
+ warnings: tuple[str, ...] = ()
@@ -0,0 +1,53 @@
1
+ from .metrics import PortfolioMetrics, performance_profile, portfolio_metrics
2
+ from .references import ObjectiveReference, parse_miplib_solu, parse_test_manifest
3
+ from .oracle import (
4
+ CandidateExclusion,
5
+ CandidateExclusionReason,
6
+ ObjectiveSense,
7
+ OracleCandidate,
8
+ OracleEntry,
9
+ OraclePolicy,
10
+ OracleStatus,
11
+ OracleTable,
12
+ build_oracle_table,
13
+ )
14
+ from .regret import (
15
+ MetricSummary,
16
+ RegretStatus,
17
+ RegretVector,
18
+ SelectionDecision,
19
+ SelectorRegretSummary,
20
+ evaluate_selection_decisions,
21
+ summarize_regret_vectors,
22
+ )
23
+
24
+ __all__ = [
25
+ "PortfolioMetrics",
26
+ "performance_profile",
27
+ "portfolio_metrics",
28
+ "ObjectiveReference",
29
+ "parse_miplib_solu",
30
+ "parse_test_manifest",
31
+ "CandidateExclusion",
32
+ "CandidateExclusionReason",
33
+ "ObjectiveSense",
34
+ "OracleCandidate",
35
+ "OracleEntry",
36
+ "OraclePolicy",
37
+ "OracleStatus",
38
+ "OracleTable",
39
+ "build_oracle_table",
40
+ "MetricSummary",
41
+ "RegretStatus",
42
+ "RegretVector",
43
+ "SelectionDecision",
44
+ "SelectorRegretSummary",
45
+ "evaluate_selection_decisions",
46
+ "summarize_regret_vectors",
47
+ "ProofAssessment",
48
+ "ProofLevel",
49
+ "assess_benchmark_row",
50
+ "assess_solve_result",
51
+ ]
52
+
53
+ from .proof import ProofAssessment, ProofLevel, assess_benchmark_row, assess_solve_result