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,447 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ import gzip
5
+ from pathlib import Path
6
+ from typing import Iterable, TextIO
7
+
8
+ import numpy as np
9
+ from scipy import sparse
10
+
11
+ from .enums import ObjectiveSense, VariableDomain
12
+ from .linear import LinearProblem
13
+
14
+
15
+ class MPSParseError(ValueError):
16
+ """Raised when an MPS input is malformed or semantically ambiguous."""
17
+
18
+
19
+ class MPSUnsupportedFeatureError(MPSParseError):
20
+ """Raised when an MPS construct cannot be represented by the v0.1 IR."""
21
+
22
+
23
+ _SECTION_NAMES = {
24
+ "NAME",
25
+ "OBJSENSE",
26
+ "OBJNAME",
27
+ "ROWS",
28
+ "COLUMNS",
29
+ "RHS",
30
+ "RANGES",
31
+ "BOUNDS",
32
+ "ENDATA",
33
+ }
34
+ _UNSUPPORTED_SECTIONS = {
35
+ "QMATRIX",
36
+ "QUADOBJ",
37
+ "QSECTION",
38
+ "DMATRIX",
39
+ "SOS",
40
+ "INDICATORS",
41
+ "GENCONS",
42
+ }
43
+
44
+
45
+ def _number(token: str, *, line_no: int) -> float:
46
+ # Fortran-style D exponents are still common in old MPS corpora.
47
+ token = token.replace("D", "E").replace("d", "e")
48
+ try:
49
+ value = float(token)
50
+ except ValueError as exc:
51
+ raise MPSParseError(f"line {line_no}: invalid numeric value {token!r}") from exc
52
+ if not np.isfinite(value):
53
+ raise MPSParseError(f"line {line_no}: non-finite numeric value {token!r}")
54
+ return value
55
+
56
+
57
+ def _clean_marker(token: str) -> str:
58
+ return token.strip().strip("'\"").upper()
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class _MPSState:
63
+ name: str | None = None
64
+ objective_sense: ObjectiveSense = ObjectiveSense.MINIMIZE
65
+ objective_row: str | None = None
66
+ explicit_objective_row: str | None = None
67
+ row_types: dict[str, str] = None # type: ignore[assignment]
68
+ row_order: list[str] = None # type: ignore[assignment]
69
+ variable_order: list[str] = None # type: ignore[assignment]
70
+ variable_index: dict[str, int] = None # type: ignore[assignment]
71
+ entries: dict[tuple[str, str], float] = None # type: ignore[assignment]
72
+ objective: dict[str, float] = None # type: ignore[assignment]
73
+ rhs: dict[str, float] = None # type: ignore[assignment]
74
+ ranges: dict[str, float] = None # type: ignore[assignment]
75
+ bounds_ops: list[tuple[str, str, float | None]] = None # type: ignore[assignment]
76
+ integer_marked: set[str] = None # type: ignore[assignment]
77
+ explicit_binary: set[str] = None # type: ignore[assignment]
78
+ explicit_integer: set[str] = None # type: ignore[assignment]
79
+ active_rhs_name: str | None = None
80
+ active_range_name: str | None = None
81
+ active_bound_name: str | None = None
82
+ integer_mode: bool = False
83
+
84
+ def __post_init__(self) -> None:
85
+ self.row_types = {}
86
+ self.row_order = []
87
+ self.variable_order = []
88
+ self.variable_index = {}
89
+ self.entries = {}
90
+ self.objective = {}
91
+ self.rhs = {}
92
+ self.ranges = {}
93
+ self.bounds_ops = []
94
+ self.integer_marked = set()
95
+ self.explicit_binary = set()
96
+ self.explicit_integer = set()
97
+
98
+ def ensure_variable(self, name: str) -> None:
99
+ if name not in self.variable_index:
100
+ self.variable_index[name] = len(self.variable_order)
101
+ self.variable_order.append(name)
102
+
103
+
104
+ def _iter_lines(source: Iterable[str]):
105
+ for line_no, raw in enumerate(source, start=1):
106
+ # MPS comments are indicated by '*' in column 1. Blank lines are harmless.
107
+ if not raw.strip() or raw.startswith("*"):
108
+ continue
109
+ yield line_no, raw.rstrip("\r\n")
110
+
111
+
112
+ def _parse_stream(source: Iterable[str]) -> LinearProblem:
113
+ state = _MPSState()
114
+ section: str | None = None
115
+
116
+ for line_no, raw in _iter_lines(source):
117
+ stripped = raw.strip()
118
+ tokens = stripped.split()
119
+ if not tokens:
120
+ continue
121
+
122
+ first_upper = tokens[0].upper()
123
+ # Section cards begin in column 1 in fixed MPS. Accept free-MPS section cards too.
124
+ is_section_card = (not raw[:1].isspace()) and (
125
+ first_upper in _SECTION_NAMES or first_upper in _UNSUPPORTED_SECTIONS
126
+ )
127
+ if is_section_card:
128
+ if first_upper in _UNSUPPORTED_SECTIONS:
129
+ raise MPSUnsupportedFeatureError(
130
+ f"line {line_no}: section {first_upper} is outside the v0.1 linear/MILP MPS reader"
131
+ )
132
+ section = first_upper
133
+ if section == "NAME":
134
+ if len(tokens) >= 2:
135
+ state.name = tokens[1]
136
+ continue
137
+ if section == "ENDATA":
138
+ break
139
+ continue
140
+
141
+ if section is None:
142
+ raise MPSParseError(f"line {line_no}: data before first MPS section")
143
+
144
+ if section == "OBJSENSE":
145
+ sense = first_upper
146
+ if sense in {"MIN", "MINIMIZE", "MINIMUM"}:
147
+ state.objective_sense = ObjectiveSense.MINIMIZE
148
+ elif sense in {"MAX", "MAXIMIZE", "MAXIMUM"}:
149
+ state.objective_sense = ObjectiveSense.MAXIMIZE
150
+ else:
151
+ raise MPSParseError(f"line {line_no}: unknown OBJSENSE value {tokens[0]!r}")
152
+ continue
153
+
154
+ if section == "OBJNAME":
155
+ state.explicit_objective_row = tokens[0]
156
+ continue
157
+
158
+ if section == "ROWS":
159
+ if len(tokens) < 2:
160
+ raise MPSParseError(f"line {line_no}: ROWS entry requires type and name")
161
+ row_type, row_name = tokens[0].upper(), tokens[1]
162
+ if row_type not in {"N", "E", "L", "G"}:
163
+ raise MPSParseError(f"line {line_no}: unsupported row type {row_type!r}")
164
+ if row_name in state.row_types:
165
+ raise MPSParseError(f"line {line_no}: duplicate row {row_name!r}")
166
+ state.row_types[row_name] = row_type
167
+ state.row_order.append(row_name)
168
+ if row_type == "N" and state.objective_row is None:
169
+ state.objective_row = row_name
170
+ continue
171
+
172
+ if section == "COLUMNS":
173
+ # Marker cards tokenize as: MARK0000 'MARKER' 'INTORG' (or INTEND).
174
+ if len(tokens) >= 3 and _clean_marker(tokens[1]) == "MARKER":
175
+ marker = _clean_marker(tokens[-1])
176
+ if marker == "INTORG":
177
+ state.integer_mode = True
178
+ elif marker == "INTEND":
179
+ state.integer_mode = False
180
+ else:
181
+ raise MPSParseError(f"line {line_no}: unknown integer marker {tokens[-1]!r}")
182
+ continue
183
+
184
+ if len(tokens) not in {3, 5}:
185
+ raise MPSParseError(
186
+ f"line {line_no}: COLUMNS entry must contain one or two row/value pairs"
187
+ )
188
+ var = tokens[0]
189
+ state.ensure_variable(var)
190
+ if state.integer_mode:
191
+ state.integer_marked.add(var)
192
+ pairs = [(tokens[1], tokens[2])]
193
+ if len(tokens) == 5:
194
+ pairs.append((tokens[3], tokens[4]))
195
+ objective_row = state.explicit_objective_row or state.objective_row
196
+ for row, raw_value in pairs:
197
+ if row not in state.row_types:
198
+ raise MPSParseError(f"line {line_no}: unknown row {row!r} in COLUMNS")
199
+ value = _number(raw_value, line_no=line_no)
200
+ if row == objective_row:
201
+ state.objective[var] = state.objective.get(var, 0.0) + value
202
+ elif state.row_types[row] == "N":
203
+ # Additional N rows are free rows; they are not constraints in the canonical IR.
204
+ continue
205
+ else:
206
+ key = (row, var)
207
+ state.entries[key] = state.entries.get(key, 0.0) + value
208
+ continue
209
+
210
+ if section in {"RHS", "RANGES"}:
211
+ if len(tokens) not in {3, 5}:
212
+ raise MPSParseError(
213
+ f"line {line_no}: {section} entry must contain one or two row/value pairs"
214
+ )
215
+ vector_name = tokens[0]
216
+ active_attr = "active_rhs_name" if section == "RHS" else "active_range_name"
217
+ active = getattr(state, active_attr)
218
+ if active is None:
219
+ setattr(state, active_attr, vector_name)
220
+ active = vector_name
221
+ if vector_name != active:
222
+ raise MPSUnsupportedFeatureError(
223
+ f"line {line_no}: multiple {section} vectors are ambiguous in canonical v0.1; "
224
+ f"active={active!r}, encountered={vector_name!r}"
225
+ )
226
+ pairs = [(tokens[1], tokens[2])]
227
+ if len(tokens) == 5:
228
+ pairs.append((tokens[3], tokens[4]))
229
+ target = state.rhs if section == "RHS" else state.ranges
230
+ for row, raw_value in pairs:
231
+ if row not in state.row_types:
232
+ raise MPSParseError(f"line {line_no}: unknown row {row!r} in {section}")
233
+ target[row] = _number(raw_value, line_no=line_no)
234
+ continue
235
+
236
+ if section == "BOUNDS":
237
+ if len(tokens) < 3:
238
+ raise MPSParseError(f"line {line_no}: malformed BOUNDS entry")
239
+ bound_type = tokens[0].upper()
240
+ vector_name = tokens[1]
241
+ var = tokens[2]
242
+ if state.active_bound_name is None:
243
+ state.active_bound_name = vector_name
244
+ if vector_name != state.active_bound_name:
245
+ raise MPSUnsupportedFeatureError(
246
+ f"line {line_no}: multiple BOUNDS vectors are ambiguous in canonical v0.1; "
247
+ f"active={state.active_bound_name!r}, encountered={vector_name!r}"
248
+ )
249
+ state.ensure_variable(var)
250
+ value = _number(tokens[3], line_no=line_no) if len(tokens) >= 4 else None
251
+ if bound_type in {"SC", "SI"}:
252
+ raise MPSUnsupportedFeatureError(
253
+ f"line {line_no}: semi-continuous/semi-integer bound {bound_type} is unsupported"
254
+ )
255
+ if bound_type not in {"LO", "UP", "FX", "FR", "MI", "PL", "BV", "LI", "UI"}:
256
+ raise MPSUnsupportedFeatureError(
257
+ f"line {line_no}: unsupported bound type {bound_type!r}"
258
+ )
259
+ if bound_type in {"LO", "UP", "FX", "LI", "UI"} and value is None:
260
+ raise MPSParseError(f"line {line_no}: bound type {bound_type} requires a value")
261
+ if bound_type == "BV":
262
+ state.explicit_binary.add(var)
263
+ if bound_type in {"LI", "UI"}:
264
+ state.explicit_integer.add(var)
265
+ state.bounds_ops.append((bound_type, var, value))
266
+ continue
267
+
268
+ # NAME was already handled as a section card; ENDATA exits above.
269
+ if section not in {"NAME"}:
270
+ raise MPSParseError(f"line {line_no}: unexpected data in section {section}")
271
+
272
+ if not state.row_types:
273
+ raise MPSParseError("MPS input contains no ROWS entries")
274
+ if not state.variable_order:
275
+ raise MPSParseError("MPS input contains no variables")
276
+
277
+ objective_row = state.explicit_objective_row or state.objective_row
278
+ if state.explicit_objective_row is not None:
279
+ if state.explicit_objective_row not in state.row_types:
280
+ raise MPSParseError(f"OBJNAME references unknown row {state.explicit_objective_row!r}")
281
+ if state.row_types[state.explicit_objective_row] != "N":
282
+ raise MPSParseError("OBJNAME must reference an N row")
283
+
284
+ constraint_rows = [r for r in state.row_order if state.row_types[r] != "N"]
285
+ row_index = {name: i for i, name in enumerate(constraint_rows)}
286
+ n = len(state.variable_order)
287
+ m = len(constraint_rows)
288
+
289
+ rr: list[int] = []
290
+ cc: list[int] = []
291
+ vv: list[float] = []
292
+ for (row, var), value in state.entries.items():
293
+ if value == 0.0:
294
+ continue
295
+ rr.append(row_index[row])
296
+ cc.append(state.variable_index[var])
297
+ vv.append(value)
298
+ A = sparse.coo_matrix((vv, (rr, cc)), shape=(m, n), dtype=np.float64).tocsr()
299
+
300
+ c = np.zeros(n, dtype=np.float64)
301
+ for var, value in state.objective.items():
302
+ c[state.variable_index[var]] += value
303
+
304
+ rhs = np.zeros(m, dtype=np.float64)
305
+ for row, value in state.rhs.items():
306
+ if row in row_index:
307
+ rhs[row_index[row]] = value
308
+
309
+ lower = np.full(m, -np.inf, dtype=np.float64)
310
+ upper = np.full(m, np.inf, dtype=np.float64)
311
+ for row, i in row_index.items():
312
+ b = rhs[i]
313
+ row_type = state.row_types[row]
314
+ if row_type == "E":
315
+ lower[i] = upper[i] = b
316
+ elif row_type == "L":
317
+ upper[i] = b
318
+ elif row_type == "G":
319
+ lower[i] = b
320
+
321
+ if row in state.ranges:
322
+ r = state.ranges[row]
323
+ width = abs(r)
324
+ if row_type == "G":
325
+ lower[i], upper[i] = b, b + width
326
+ elif row_type == "L":
327
+ lower[i], upper[i] = b - width, b
328
+ elif row_type == "E":
329
+ if r >= 0:
330
+ lower[i], upper[i] = b, b + width
331
+ else:
332
+ lower[i], upper[i] = b - width, b
333
+
334
+ # Standard MPS integer markers imply default bounds [0, 1] for marked variables.
335
+ integer_vars = state.integer_marked | state.explicit_integer | state.explicit_binary
336
+ vl = np.zeros(n, dtype=np.float64)
337
+ vu = np.full(n, np.inf, dtype=np.float64)
338
+ for var in state.integer_marked:
339
+ vu[state.variable_index[var]] = 1.0
340
+
341
+ explicit_upper = np.zeros(n, dtype=bool)
342
+ for bound_type, var, value in state.bounds_ops:
343
+ j = state.variable_index[var]
344
+ if bound_type == "LO":
345
+ if value is None:
346
+ raise MPSParseError("LO bound requires a numeric value")
347
+ vl[j] = value
348
+ elif bound_type == "UP":
349
+ if value is None:
350
+ raise MPSParseError("UP bound requires a numeric value")
351
+ vu[j] = value
352
+ explicit_upper[j] = True
353
+ # Widely used MPS interpretation: a negative UP releases the default 0 lower bound.
354
+ if value < 0.0 and vl[j] == 0.0:
355
+ vl[j] = -np.inf
356
+ elif bound_type == "FX":
357
+ if value is None:
358
+ raise MPSParseError("FX bound requires a numeric value")
359
+ vl[j] = vu[j] = value
360
+ explicit_upper[j] = True
361
+ elif bound_type == "FR":
362
+ vl[j], vu[j] = -np.inf, np.inf
363
+ explicit_upper[j] = True
364
+ elif bound_type == "MI":
365
+ vl[j] = -np.inf
366
+ elif bound_type == "PL":
367
+ vu[j] = np.inf
368
+ explicit_upper[j] = True
369
+ elif bound_type == "BV":
370
+ vl[j], vu[j] = 0.0, 1.0
371
+ explicit_upper[j] = True
372
+ elif bound_type == "LI":
373
+ if value is None:
374
+ raise MPSParseError("LI bound requires a numeric value")
375
+ vl[j] = value
376
+ # LI explicitly declares a general integer. If no upper bound was already
377
+ # supplied, it overrides the INTORG marker's implicit binary upper bound.
378
+ if not explicit_upper[j]:
379
+ vu[j] = np.inf
380
+ elif bound_type == "UI":
381
+ if value is None:
382
+ raise MPSParseError("UI bound requires a numeric value")
383
+ vu[j] = value
384
+ explicit_upper[j] = True
385
+
386
+ domains: list[VariableDomain] = []
387
+ for var in state.variable_order:
388
+ j = state.variable_index[var]
389
+ if var in state.explicit_binary or (
390
+ var in integer_vars and vl[j] == 0.0 and vu[j] == 1.0
391
+ ):
392
+ domains.append(VariableDomain.BINARY)
393
+ elif var in integer_vars:
394
+ domains.append(VariableDomain.INTEGER)
395
+ else:
396
+ domains.append(VariableDomain.CONTINUOUS)
397
+
398
+ objective_offset = 0.0
399
+ if objective_row is not None and objective_row in state.rhs:
400
+ # In MPS the RHS value attached to the objective N-row is -c0.
401
+ objective_offset = -float(state.rhs[objective_row])
402
+
403
+ return LinearProblem.from_data(
404
+ A=A,
405
+ c=c,
406
+ variable_lower=vl,
407
+ variable_upper=vu,
408
+ constraint_lower=lower,
409
+ constraint_upper=upper,
410
+ domains=domains,
411
+ objective_sense=state.objective_sense,
412
+ objective_offset=objective_offset,
413
+ name=state.name,
414
+ metadata={
415
+ "source_format": "mps",
416
+ "variable_names": tuple(state.variable_order),
417
+ "constraint_names": tuple(constraint_rows),
418
+ "objective_row": objective_row,
419
+ "rhs_name": state.active_rhs_name,
420
+ "ranges_name": state.active_range_name,
421
+ "bounds_name": state.active_bound_name,
422
+ },
423
+ )
424
+
425
+
426
+ def parse_mps(text: str) -> LinearProblem:
427
+ """Parse a linear/MILP MPS document into the canonical sparse IR.
428
+
429
+ Supported v0.1 constructs: NAME, OBJSENSE, OBJNAME, ROWS, COLUMNS,
430
+ integer MARKER cards, RHS, RANGES, BOUNDS (LO/UP/FX/FR/MI/PL/BV/LI/UI),
431
+ and ENDATA. Quadratic, SOS, indicator and semi-continuous constructs are rejected
432
+ rather than silently lowered.
433
+ """
434
+
435
+ return _parse_stream(text.splitlines())
436
+
437
+
438
+ def read_mps(path: str | Path) -> LinearProblem:
439
+ """Read ``.mps`` or ``.mps.gz`` into the canonical sparse IR."""
440
+
441
+ p = Path(path)
442
+ opener = gzip.open if p.suffix.lower() == ".gz" else open
443
+ with opener(p, "rt", encoding="ascii", errors="strict") as f: # type: ignore[arg-type]
444
+ problem = _parse_stream(f)
445
+ metadata = dict(problem.metadata)
446
+ metadata["source_path"] = str(p)
447
+ return replace(problem, metadata=metadata)
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Sequence
5
+
6
+ import numpy as np
7
+ from scipy import sparse
8
+ from scipy.sparse.linalg import ArpackError, ArpackNoConvergence, eigsh
9
+
10
+ from .enums import ConvexityStatus, ObjectiveSense, VariableDomain
11
+ from .hashing import _hash_parts, hash_sparse_matrix
12
+ from .linear import LinearProblem, _readonly_csr
13
+
14
+
15
+ def _canonicalize_hessian(
16
+ P: sparse.csr_matrix,
17
+ *,
18
+ atol: float = 1e-12,
19
+ rtol: float = 1e-12,
20
+ ) -> sparse.csr_matrix:
21
+ """Return the single symmetric Hessian used by every SolverPilot component.
22
+
23
+ Symmetry is checked *pairwise*. An unrelated large coefficient is therefore
24
+ unable to relax the tolerance for a small asymmetric pair. Once accepted,
25
+ the Hessian is explicitly symmetrized so hash/objective/backend semantics all
26
+ consume the same matrix.
27
+ """
28
+ diff = (P - P.T).tocoo()
29
+ if diff.nnz == 0:
30
+ return _readonly_csr(P)
31
+ if diff.nnz:
32
+ rows = diff.row
33
+ cols = diff.col
34
+ pij = np.asarray(P[rows, cols]).reshape(-1)
35
+ pji = np.asarray(P[cols, rows]).reshape(-1)
36
+ scale = np.maximum.reduce(
37
+ [np.ones(diff.nnz, dtype=np.float64), np.abs(pij), np.abs(pji)]
38
+ )
39
+ allowed = float(atol) + float(rtol) * scale
40
+ if np.any(np.abs(diff.data) > allowed):
41
+ raise ValueError("P must be symmetric within pairwise numerical tolerance")
42
+ return _readonly_csr((P * 0.5 + P.T * 0.5).tocsr())
43
+
44
+
45
+ def _scaled_psd_matrix(P: sparse.csr_matrix) -> sparse.csr_matrix:
46
+ """Diagonal congruence scaling that preserves positive semidefiniteness.
47
+
48
+ Scaling each row/column by its own magnitude prevents a huge, unrelated
49
+ coefficient from masking a materially negative eigenvalue elsewhere.
50
+ """
51
+ if P.shape[0] == 0:
52
+ return P
53
+ row_max = np.asarray(abs(P).max(axis=1).toarray(), dtype=np.float64).reshape(-1)
54
+ row_max = np.maximum(1.0, row_max)
55
+ inv_sqrt = 1.0 / np.sqrt(row_max)
56
+ D = sparse.diags(inv_sqrt, offsets=0, format="csr")
57
+ scaled = (D @ P @ D).tocsr()
58
+ scaled.sum_duplicates()
59
+ scaled.eliminate_zeros()
60
+ scaled.sort_indices()
61
+ return scaled
62
+
63
+
64
+ def _check_convexity(
65
+ P: sparse.csr_matrix,
66
+ *,
67
+ atol: float = 1e-10,
68
+ rtol: float = 1e-9,
69
+ ) -> ConvexityStatus:
70
+ n = P.shape[0]
71
+ if n == 0 or P.nnz == 0:
72
+ return ConvexityStatus.CONFIRMED
73
+ try:
74
+ sym = _canonicalize_hessian(P)
75
+ except ValueError:
76
+ return ConvexityStatus.REJECTED
77
+ scaled = _scaled_psd_matrix(sym)
78
+ row_sums = np.asarray(abs(scaled).sum(axis=1), dtype=np.float64).reshape(-1)
79
+ scale = max(1.0, float(np.max(row_sums)) if row_sums.size else 0.0)
80
+ # The matrix has already undergone a PSD-preserving congruence scaling. The
81
+ # remaining tolerance is therefore about the local normalized matrix, not the
82
+ # largest raw coefficient in the original problem.
83
+ tol = float(atol) + float(rtol) * scale
84
+ try:
85
+ if n <= 256:
86
+ lam_min = float(np.linalg.eigvalsh(scaled.toarray())[0])
87
+ else:
88
+ lam_min = float(eigsh(scaled, k=1, which="SA", return_eigenvectors=False)[0])
89
+ except (np.linalg.LinAlgError, ArpackError, ArpackNoConvergence):
90
+ return ConvexityStatus.UNKNOWN
91
+ return ConvexityStatus.CONFIRMED if lam_min >= -tol else ConvexityStatus.REJECTED
92
+
93
+
94
+ def require_confirmed_convexity(problem: "QuadraticProblem") -> None:
95
+ """Fail closed before any backend consumes a quadratic problem directly."""
96
+ if problem.convexity_status is not ConvexityStatus.CONFIRMED:
97
+ raise ValueError("unverified quadratic problem cannot be solved as convex_qp")
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class QuadraticProblem:
102
+ linear: LinearProblem
103
+ P: sparse.csr_matrix
104
+ convexity_status: ConvexityStatus = ConvexityStatus.UNKNOWN
105
+ metadata: dict[str, object] = field(default_factory=dict, compare=False, repr=False)
106
+ structural_hash: str = field(init=False)
107
+ data_hash: str = field(init=False)
108
+
109
+ def __post_init__(self) -> None:
110
+ if self.linear.objective_sense is not ObjectiveSense.MINIMIZE:
111
+ raise ValueError("v0.1 QuadraticProblem currently supports convex minimization only")
112
+ if self.linear.has_integer_variables:
113
+ raise ValueError("v0.1 QuadraticProblem supports continuous convex QP only")
114
+ P_raw = _readonly_csr(self.P)
115
+ n = self.linear.n_variables
116
+ if P_raw.shape != (n, n):
117
+ raise ValueError(f"P must have shape ({n}, {n}), got {P_raw.shape}")
118
+ P = _canonicalize_hessian(P_raw)
119
+ status = ConvexityStatus(self.convexity_status)
120
+ if status is ConvexityStatus.REJECTED:
121
+ raise ValueError("nonconvex quadratic objectives are out of scope for v0.1")
122
+ if status is ConvexityStatus.CONFIRMED:
123
+ checked = _check_convexity(P)
124
+ if checked is not ConvexityStatus.CONFIRMED:
125
+ raise ValueError("P is not independently confirmed positive semidefinite")
126
+ object.__setattr__(self, "P", P)
127
+ object.__setattr__(self, "convexity_status", status)
128
+ object.__setattr__(self, "metadata", dict(self.metadata))
129
+ object.__setattr__(
130
+ self,
131
+ "structural_hash",
132
+ _hash_parts([self.linear.structural_hash.encode(), hash_sparse_matrix(P.astype(bool)).encode()]),
133
+ )
134
+ object.__setattr__(
135
+ self,
136
+ "data_hash",
137
+ _hash_parts([self.linear.data_hash.encode(), hash_sparse_matrix(P).encode()]),
138
+ )
139
+
140
+ @property
141
+ def n_variables(self) -> int:
142
+ return self.linear.n_variables
143
+
144
+ @property
145
+ def n_constraints(self) -> int:
146
+ return self.linear.n_constraints
147
+
148
+ @property
149
+ def objective_sense(self) -> ObjectiveSense:
150
+ """Expose the common problem-level objective sense used by runtime code."""
151
+ return self.linear.objective_sense
152
+
153
+ @property
154
+ def objective_offset(self) -> float:
155
+ return float(self.linear.objective_offset)
156
+
157
+ @classmethod
158
+ def from_data(
159
+ cls,
160
+ *,
161
+ P: sparse.spmatrix | np.ndarray,
162
+ A: sparse.spmatrix | np.ndarray,
163
+ q: Sequence[float] | np.ndarray,
164
+ variable_lower: Sequence[float] | np.ndarray,
165
+ variable_upper: Sequence[float] | np.ndarray,
166
+ constraint_lower: Sequence[float] | np.ndarray,
167
+ constraint_upper: Sequence[float] | np.ndarray,
168
+ objective_sense: ObjectiveSense | str = ObjectiveSense.MINIMIZE,
169
+ objective_offset: float = 0.0,
170
+ verify_convexity: bool = True,
171
+ metadata: dict[str, object] | None = None,
172
+ ) -> "QuadraticProblem":
173
+ sense = ObjectiveSense(objective_sense)
174
+ if sense is ObjectiveSense.MAXIMIZE:
175
+ raise ValueError("v0.1 QuadraticProblem currently supports convex minimization only")
176
+ linear = LinearProblem.from_data(
177
+ A=A,
178
+ c=q,
179
+ variable_lower=variable_lower,
180
+ variable_upper=variable_upper,
181
+ constraint_lower=constraint_lower,
182
+ constraint_upper=constraint_upper,
183
+ domains=[VariableDomain.CONTINUOUS] * sparse.csr_matrix(A).shape[1],
184
+ objective_sense=sense,
185
+ objective_offset=objective_offset,
186
+ metadata={} if metadata is None else metadata,
187
+ )
188
+ P_csr = _canonicalize_hessian(_readonly_csr(P))
189
+ status = _check_convexity(P_csr) if verify_convexity else ConvexityStatus.UNKNOWN
190
+ if status is ConvexityStatus.REJECTED:
191
+ raise ValueError("P is not positive semidefinite within scale-aware tolerance")
192
+ return cls(linear=linear, P=P_csr, convexity_status=status, metadata={} if metadata is None else metadata)
@@ -0,0 +1,27 @@
1
+ """Structured, claim-safe explanations for SolverPilot solve results.
2
+
3
+ This package is intentionally not re-exported from the frozen top-level ``solverpilot``
4
+ namespace. It explains existing ``SolveResult`` evidence without changing solve behavior.
5
+ """
6
+
7
+ from .explain import explain_result, explain_status
8
+ from .model import (
9
+ REPORT_SCHEMA_VERSION,
10
+ ClaimDisposition,
11
+ ClaimKind,
12
+ ExplanationClaim,
13
+ ExplanationReport,
14
+ )
15
+ from .render import render_json, render_markdown
16
+
17
+ __all__ = [
18
+ "REPORT_SCHEMA_VERSION",
19
+ "ClaimDisposition",
20
+ "ClaimKind",
21
+ "ExplanationClaim",
22
+ "ExplanationReport",
23
+ "explain_result",
24
+ "explain_status",
25
+ "render_json",
26
+ "render_markdown",
27
+ ]