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,299 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ import hashlib
6
+ import json
7
+ from types import MappingProxyType
8
+ from typing import Any, Mapping
9
+
10
+ import numpy as np
11
+ from scipy import sparse
12
+
13
+ from solverpilot.problem import ConvexityStatus, ObjectiveSense, QuadraticProblem, VariableDomain
14
+ from solverpilot.problem.linear import _readonly_csr, _readonly_f64, _require_bound_directions, _require_finite
15
+ from solverpilot.problem.quadratic import _canonicalize_hessian, _check_convexity
16
+ from solverpilot.problem.hashing import _hash_parts, hash_sparse_matrix
17
+
18
+
19
+ class ConeKind(str, Enum):
20
+ SECOND_ORDER = "second_order"
21
+ ROTATED_SECOND_ORDER = "rotated_second_order"
22
+ POSITIVE_SEMIDEFINITE = "positive_semidefinite"
23
+
24
+
25
+ def _readonly_shape(value: tuple[int, ...]) -> tuple[int, ...]:
26
+ out = tuple(int(x) for x in value)
27
+ if not out or any(x <= 0 for x in out):
28
+ raise ValueError("cone output_shape must contain positive dimensions")
29
+ return out
30
+
31
+
32
+ def _hash_array(arr: np.ndarray) -> str:
33
+ a = np.ascontiguousarray(np.asarray(arr, dtype=np.float64))
34
+ h = hashlib.sha256()
35
+ h.update(str(a.shape).encode())
36
+ h.update(a.tobytes())
37
+ return h.hexdigest()
38
+
39
+
40
+ def _json_float(value: float) -> float | str:
41
+ v = float(value)
42
+ if np.isposinf(v): return "+inf"
43
+ if np.isneginf(v): return "-inf"
44
+ if np.isnan(v): raise ValueError("NaN cannot be serialized")
45
+ return v
46
+
47
+
48
+ def _csr_dict(matrix: sparse.csr_matrix) -> dict[str, Any]:
49
+ m = sparse.csr_matrix(matrix)
50
+ return {
51
+ "shape": [int(m.shape[0]), int(m.shape[1])],
52
+ "indptr": [int(x) for x in m.indptr],
53
+ "indices": [int(x) for x in m.indices],
54
+ "data": [float(x) for x in m.data],
55
+ }
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ConeAffineBlock:
60
+ """Affine cone membership block ``F x + g in K``.
61
+
62
+ For vector cones, ``output_shape`` is ``(dim,)``. For PSD cones it is
63
+ ``(n, n)`` and the flattened affine map is row-major. The block itself is
64
+ solver-independent; backend-specific vectorization/scaling is performed by
65
+ the adapter.
66
+ """
67
+
68
+ kind: ConeKind
69
+ F: sparse.csr_matrix
70
+ g: np.ndarray
71
+ output_shape: tuple[int, ...]
72
+ source_id: str | None = None
73
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
74
+ structural_hash: str = field(init=False)
75
+ data_hash: str = field(init=False)
76
+
77
+ def __post_init__(self) -> None:
78
+ kind = ConeKind(self.kind)
79
+ F = _readonly_csr(self.F)
80
+ g = _readonly_f64(self.g, name="cone offset")
81
+ _require_finite(g, name="cone offset")
82
+ shape = _readonly_shape(self.output_shape)
83
+ expected = int(np.prod(shape, dtype=np.int64))
84
+ if F.shape[0] != expected:
85
+ raise ValueError(f"cone F has {F.shape[0]} rows but output_shape requires {expected}")
86
+ if g.shape != (expected,):
87
+ raise ValueError(f"cone g must have shape ({expected},), got {g.shape}")
88
+ if kind is ConeKind.SECOND_ORDER:
89
+ if len(shape) != 1 or shape[0] < 2:
90
+ raise ValueError("second-order cone requires vector dimension >= 2")
91
+ elif kind is ConeKind.ROTATED_SECOND_ORDER:
92
+ if len(shape) != 1 or shape[0] < 3:
93
+ raise ValueError("rotated second-order cone requires vector dimension >= 3")
94
+ elif kind is ConeKind.POSITIVE_SEMIDEFINITE:
95
+ if len(shape) != 2 or shape[0] != shape[1]:
96
+ raise ValueError("PSD cone requires a square matrix output_shape")
97
+ else: # pragma: no cover - Enum guards this
98
+ raise ValueError(kind)
99
+ object.__setattr__(self, "kind", kind)
100
+ object.__setattr__(self, "F", F)
101
+ object.__setattr__(self, "g", g)
102
+ object.__setattr__(self, "output_shape", shape)
103
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
104
+ structural = _hash_parts([
105
+ kind.value.encode(),
106
+ json.dumps(shape).encode(),
107
+ hash_sparse_matrix(F.astype(bool)).encode(),
108
+ ])
109
+ data = _hash_parts([
110
+ structural.encode(),
111
+ hash_sparse_matrix(F).encode(),
112
+ _hash_array(g).encode(),
113
+ ])
114
+ object.__setattr__(self, "structural_hash", structural)
115
+ object.__setattr__(self, "data_hash", data)
116
+
117
+ def to_canonical_dict(self) -> dict[str, Any]:
118
+ return {
119
+ "kind": self.kind.value,
120
+ "F": _csr_dict(self.F),
121
+ "g": [float(x) for x in self.g],
122
+ "output_shape": list(self.output_shape),
123
+ "source_id": self.source_id,
124
+ "metadata": dict(self.metadata),
125
+ "structural_hash": self.structural_hash,
126
+ "data_hash": self.data_hash,
127
+ }
128
+
129
+ @property
130
+ def n_variables(self) -> int:
131
+ return int(self.F.shape[1])
132
+
133
+ @property
134
+ def dimension(self) -> int:
135
+ return int(self.F.shape[0])
136
+
137
+ def value(self, x: np.ndarray) -> np.ndarray:
138
+ vec = np.asarray(self.F @ np.asarray(x, dtype=np.float64).reshape(-1), dtype=np.float64).reshape(-1) + self.g
139
+ return vec.reshape(self.output_shape)
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class ConicProblem:
144
+ """Convex quadratic objective + linear rows + affine cone memberships."""
145
+
146
+ P: sparse.csr_matrix
147
+ q: np.ndarray
148
+ A: sparse.csr_matrix
149
+ variable_lower: np.ndarray
150
+ variable_upper: np.ndarray
151
+ constraint_lower: np.ndarray
152
+ constraint_upper: np.ndarray
153
+ cones: tuple[ConeAffineBlock, ...]
154
+ objective_sense: ObjectiveSense = ObjectiveSense.MINIMIZE
155
+ objective_offset: float = 0.0
156
+ name: str | None = None
157
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
158
+ convexity_status: ConvexityStatus = ConvexityStatus.UNKNOWN
159
+ structural_hash: str = field(init=False)
160
+ data_hash: str = field(init=False)
161
+
162
+ def __post_init__(self) -> None:
163
+ P_raw = _readonly_csr(self.P)
164
+ q = _readonly_f64(self.q, name="q")
165
+ A = _readonly_csr(self.A)
166
+ vl = _readonly_f64(self.variable_lower, name="variable_lower")
167
+ vu = _readonly_f64(self.variable_upper, name="variable_upper")
168
+ cl = _readonly_f64(self.constraint_lower, name="constraint_lower")
169
+ cu = _readonly_f64(self.constraint_upper, name="constraint_upper")
170
+ sense = ObjectiveSense(self.objective_sense)
171
+ if sense is not ObjectiveSense.MINIMIZE:
172
+ raise ValueError("P6 ConicProblem supports convex minimization only")
173
+ m, n = A.shape
174
+ if P_raw.shape != (n, n):
175
+ raise ValueError(f"P must have shape ({n}, {n})")
176
+ P = _canonicalize_hessian(P_raw)
177
+ if q.shape != (n,) or vl.shape != (n,) or vu.shape != (n,):
178
+ raise ValueError("q/variable bounds must match n_variables")
179
+ _require_finite(q, name="q")
180
+ if cl.shape != (m,) or cu.shape != (m,):
181
+ raise ValueError("linear constraint bounds must match A rows")
182
+ if np.any(vl > vu) or np.any(cl > cu):
183
+ raise ValueError("inconsistent bounds")
184
+ _require_bound_directions(vl, vu, label="variable")
185
+ _require_bound_directions(cl, cu, label="constraint")
186
+ if not np.isfinite(float(self.objective_offset)):
187
+ raise ValueError("objective_offset must be finite")
188
+ cones = tuple(self.cones)
189
+ if not cones:
190
+ raise ValueError("ConicProblem requires at least one cone block")
191
+ for block in cones:
192
+ if block.n_variables != n:
193
+ raise ValueError("cone block variable dimension mismatch")
194
+ status = ConvexityStatus(self.convexity_status)
195
+ if status is ConvexityStatus.UNKNOWN:
196
+ status = _check_convexity(P)
197
+ elif status is ConvexityStatus.CONFIRMED and _check_convexity(P) is not ConvexityStatus.CONFIRMED:
198
+ raise ValueError("conic quadratic objective is not independently confirmed convex")
199
+ if status is ConvexityStatus.REJECTED:
200
+ raise ValueError("conic quadratic objective is not convex")
201
+ object.__setattr__(self, "P", P)
202
+ object.__setattr__(self, "q", q)
203
+ object.__setattr__(self, "A", A)
204
+ object.__setattr__(self, "variable_lower", vl)
205
+ object.__setattr__(self, "variable_upper", vu)
206
+ object.__setattr__(self, "constraint_lower", cl)
207
+ object.__setattr__(self, "constraint_upper", cu)
208
+ object.__setattr__(self, "cones", cones)
209
+ object.__setattr__(self, "objective_sense", sense)
210
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
211
+ object.__setattr__(self, "convexity_status", status)
212
+ structural = _hash_parts([
213
+ b"solverpilot-conic-ir-v1",
214
+ hash_sparse_matrix(P.astype(bool)).encode(),
215
+ hash_sparse_matrix(A.astype(bool)).encode(),
216
+ *(c.structural_hash.encode() for c in cones),
217
+ ])
218
+ data = _hash_parts([
219
+ structural.encode(),
220
+ hash_sparse_matrix(P).encode(),
221
+ _hash_array(q).encode(),
222
+ hash_sparse_matrix(A).encode(),
223
+ _hash_array(vl).encode(), _hash_array(vu).encode(),
224
+ _hash_array(cl).encode(), _hash_array(cu).encode(),
225
+ *(c.data_hash.encode() for c in cones),
226
+ sense.value.encode(), float(self.objective_offset).hex().encode(),
227
+ ])
228
+ object.__setattr__(self, "structural_hash", structural)
229
+ object.__setattr__(self, "data_hash", data)
230
+
231
+ def to_canonical_dict(self) -> dict[str, Any]:
232
+ return {
233
+ "schema": "solverpilot.conic-ir.v1",
234
+ "P": _csr_dict(self.P),
235
+ "q": [float(x) for x in self.q],
236
+ "A": _csr_dict(self.A),
237
+ "variable_lower": [_json_float(x) for x in self.variable_lower],
238
+ "variable_upper": [_json_float(x) for x in self.variable_upper],
239
+ "constraint_lower": [_json_float(x) for x in self.constraint_lower],
240
+ "constraint_upper": [_json_float(x) for x in self.constraint_upper],
241
+ "cones": [c.to_canonical_dict() for c in self.cones],
242
+ "objective_sense": self.objective_sense.value,
243
+ "objective_offset": float(self.objective_offset),
244
+ "name": self.name,
245
+ "metadata": dict(self.metadata),
246
+ "convexity_status": self.convexity_status.value,
247
+ "structural_hash": self.structural_hash,
248
+ "data_hash": self.data_hash,
249
+ }
250
+
251
+ @property
252
+ def n_variables(self) -> int:
253
+ return int(self.A.shape[1])
254
+
255
+ @property
256
+ def n_linear_constraints(self) -> int:
257
+ return int(self.A.shape[0])
258
+
259
+ @property
260
+ def n_cone_constraints(self) -> int:
261
+ return len(self.cones)
262
+
263
+ def objective_value(self, x: np.ndarray) -> float:
264
+ x = np.asarray(x, dtype=np.float64).reshape(-1)
265
+ return float(0.5 * x @ (self.P @ x) + self.q @ x + self.objective_offset)
266
+
267
+ @classmethod
268
+ def from_data(
269
+ cls,
270
+ *,
271
+ P: sparse.spmatrix | np.ndarray,
272
+ q: np.ndarray,
273
+ A: sparse.spmatrix | np.ndarray,
274
+ variable_lower: np.ndarray,
275
+ variable_upper: np.ndarray,
276
+ constraint_lower: np.ndarray,
277
+ constraint_upper: np.ndarray,
278
+ cones: tuple[ConeAffineBlock, ...] | list[ConeAffineBlock],
279
+ objective_offset: float = 0.0,
280
+ name: str | None = None,
281
+ metadata: Mapping[str, Any] | None = None,
282
+ verify_convexity: bool = True,
283
+ ) -> "ConicProblem":
284
+ P_csr = sparse.csr_matrix(P, dtype=np.float64)
285
+ status = _check_convexity(P_csr) if verify_convexity else ConvexityStatus.UNKNOWN
286
+ return cls(
287
+ P=P_csr,
288
+ q=np.asarray(q, dtype=np.float64),
289
+ A=sparse.csr_matrix(A, dtype=np.float64),
290
+ variable_lower=np.asarray(variable_lower, dtype=np.float64),
291
+ variable_upper=np.asarray(variable_upper, dtype=np.float64),
292
+ constraint_lower=np.asarray(constraint_lower, dtype=np.float64),
293
+ constraint_upper=np.asarray(constraint_upper, dtype=np.float64),
294
+ cones=tuple(cones),
295
+ objective_offset=float(objective_offset),
296
+ name=name,
297
+ metadata={} if metadata is None else metadata,
298
+ convexity_status=status,
299
+ )
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+ import numpy as np
6
+
7
+ from solverpilot.validate.core import _max_violation, _scaled_feasibility
8
+
9
+ from .ir import ConeKind, ConicProblem
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ConeCheck:
14
+ source_id: str | None
15
+ kind: ConeKind
16
+ valid: bool
17
+ violation: float
18
+ detail: str
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ConicValidationReport:
23
+ valid: bool
24
+ max_variable_violation: float
25
+ max_linear_violation: float
26
+ max_cone_violation: float
27
+ cone_checks: tuple[ConeCheck, ...]
28
+ objective: float | None
29
+
30
+
31
+ def _scaled_tol(atol: float, rtol: float, magnitude: float) -> float:
32
+ return float(atol + rtol * max(1.0, abs(float(magnitude))))
33
+
34
+
35
+ def _componentwise_feasibility(
36
+ values: np.ndarray,
37
+ lower: np.ndarray,
38
+ upper: np.ndarray,
39
+ *,
40
+ atol: float,
41
+ rtol: float,
42
+ extra_scale: np.ndarray | None = None,
43
+ ) -> tuple[float, bool]:
44
+ _, ok = _scaled_feasibility(values, lower, upper, atol=atol, rtol=rtol, extra_scale=extra_scale)
45
+ return _max_violation(values, lower, upper), ok
46
+
47
+
48
+ def _psd_check(M: np.ndarray, *, atol: float, rtol: float) -> tuple[bool, float, float, float]:
49
+ """Check symmetry and PSD after local congruence scaling.
50
+
51
+ The congruence transform preserves inertia, while removing the dangerous
52
+ dependence on an unrelated huge matrix entry that a single global norm creates.
53
+ """
54
+ if M.size == 0:
55
+ return True, 0.0, 0.0, 0.0
56
+ abs_m = np.abs(M)
57
+ sym_allowed = float(atol) + float(rtol) * np.maximum(1.0, np.maximum(abs_m, abs_m.T))
58
+ asym_matrix = np.abs(M - M.T)
59
+ asym = float(np.max(asym_matrix))
60
+ symmetry_ok = bool(np.all(asym_matrix <= sym_allowed))
61
+ sym = 0.5 * M + 0.5 * M.T
62
+ eigmin = float(np.linalg.eigvalsh(sym)[0])
63
+
64
+ row_scale = np.maximum(1.0, np.max(np.abs(sym), axis=1))
65
+ inv_sqrt = 1.0 / np.sqrt(row_scale)
66
+ scaled = inv_sqrt[:, None] * sym * inv_sqrt[None, :]
67
+ scaled_eigmin = float(np.linalg.eigvalsh(scaled)[0])
68
+ scaled_norm = float(np.linalg.norm(scaled, ord=np.inf))
69
+ psd_tol = float(atol) + float(rtol) * max(1.0, scaled_norm)
70
+ return bool(symmetry_ok and scaled_eigmin >= -psd_tol), asym, eigmin, scaled_eigmin
71
+
72
+
73
+ def _cone_check(kind: ConeKind, value: np.ndarray, *, atol: float, rtol: float, source_id: str | None) -> ConeCheck:
74
+ if not np.isfinite(value).all():
75
+ return ConeCheck(source_id, kind, False, float("inf"), "non-finite cone activity")
76
+ if kind is ConeKind.SECOND_ORDER:
77
+ y = np.asarray(value, dtype=float).reshape(-1)
78
+ t = float(y[0]); norm = math.hypot(*y[1:])
79
+ viol = max(0.0, norm - t)
80
+ tol = _scaled_tol(atol, rtol, max(abs(t), norm))
81
+ return ConeCheck(source_id, kind, math.isfinite(norm) and math.isfinite(tol) and viol <= tol, viol, f"norm={norm:.17g}, t={t:.17g}, tol={tol:.3g}")
82
+ if kind is ConeKind.ROTATED_SECOND_ORDER:
83
+ y = np.asarray(value, dtype=float).reshape(-1)
84
+ u = float(y[0]); v = float(y[1])
85
+ scale = max(1.0, float(np.max(np.abs(y))))
86
+ z = y / scale
87
+ norm2 = float(np.dot(z[2:], z[2:]))
88
+ product = 2.0 * float(z[0]) * float(z[1])
89
+ gap = norm2 - product
90
+ # Compare the squared inequality in normalized coordinates, avoiding
91
+ # inf - inf. Nonnegativity uses separate, unsquared tolerances.
92
+ tol = (atol / scale) / scale + rtol * max((1.0 / scale) / scale, norm2, abs(product))
93
+ nonnegative = u >= -_scaled_tol(atol, rtol, u) and v >= -_scaled_tol(atol, rtol, v)
94
+ viol = max(0.0, -u, -v, (max(0.0, gap) * scale) * scale)
95
+ return ConeCheck(source_id, kind, nonnegative and gap <= tol, viol, f"u={u:.17g}, v={v:.17g}, normalized_gap={gap:.17g}, tol={tol:.3g}")
96
+ if kind is ConeKind.POSITIVE_SEMIDEFINITE:
97
+ M = np.asarray(value, dtype=float)
98
+ ok, asym, eigmin, scaled_eigmin = _psd_check(M, atol=atol, rtol=rtol)
99
+ viol = max(asym, max(0.0, -eigmin))
100
+ return ConeCheck(
101
+ source_id,
102
+ kind,
103
+ ok,
104
+ viol,
105
+ f"asym={asym:.3g}, eigmin={eigmin:.17g}, scaled_eigmin={scaled_eigmin:.17g}",
106
+ )
107
+ raise AssertionError(kind)
108
+
109
+
110
+ def validate_conic_solution(
111
+ problem: ConicProblem,
112
+ x: np.ndarray | None,
113
+ *,
114
+ atol: float = 1e-7,
115
+ rtol: float = 1e-7,
116
+ ) -> ConicValidationReport:
117
+ for name, raw in (("atol", atol), ("rtol", rtol)):
118
+ if isinstance(raw, bool) or type(raw) not in (int, float) or not np.isfinite(float(raw)) or float(raw) < 0:
119
+ raise ValueError(f"{name} must be finite and non-negative")
120
+ atol = float(atol)
121
+ rtol = float(rtol)
122
+ if x is None:
123
+ return ConicValidationReport(False, float("inf"), float("inf"), float("inf"), (), None)
124
+ x = np.asarray(x, dtype=np.float64).reshape(-1)
125
+ if x.shape != (problem.n_variables,) or not np.all(np.isfinite(x)):
126
+ return ConicValidationReport(False, float("inf"), float("inf"), float("inf"), (), None)
127
+
128
+ var_viol, vars_ok = _componentwise_feasibility(
129
+ x,
130
+ problem.variable_lower,
131
+ problem.variable_upper,
132
+ atol=atol,
133
+ rtol=rtol,
134
+ )
135
+ ax = np.asarray(problem.A @ x, dtype=float).reshape(-1)
136
+ row_scale = np.asarray(np.abs(problem.A) @ np.abs(x), dtype=np.float64).reshape(-1)
137
+ lin_viol, linear_ok = _componentwise_feasibility(
138
+ ax,
139
+ problem.constraint_lower,
140
+ problem.constraint_upper,
141
+ atol=atol,
142
+ rtol=rtol,
143
+ extra_scale=row_scale,
144
+ )
145
+ checks = tuple(_cone_check(c.kind, c.value(x), atol=atol, rtol=rtol, source_id=c.source_id) for c in problem.cones)
146
+ cone_viol = max((c.violation for c in checks), default=0.0)
147
+ objective = problem.objective_value(x)
148
+ valid = bool(vars_ok and linear_ok and all(c.valid for c in checks) and np.isfinite(objective))
149
+ return ConicValidationReport(valid, var_viol, lin_viol, cone_viol, checks, objective)
@@ -0,0 +1,7 @@
1
+ from .ir import *
2
+ from .model import CPModel, CPIntVar, CPBoolVar, CPIntervalVar, CPLinearExpr
3
+ from .validate import CPValidationReport, validate_cp_solution
4
+ from .reference import CPSolveResult, ReferenceCPBackend
5
+ from .ortools_backend import ORToolsCPSATBackend, VERIFIED_ORTOOLS_VERSION
6
+ from .conformance import CPConformanceCheck, CPConformanceReport, reference_cp_conformance, ortools_cp_sat_conformance
7
+ __all__=[n for n in globals() if n.startswith('CP') or n in {'ReferenceCPBackend','ORToolsCPSATBackend','VERIFIED_ORTOOLS_VERSION','validate_cp_solution','reference_cp_conformance','ortools_cp_sat_conformance'}]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from .model import CPModel
4
+ from .reference import ReferenceCPBackend
5
+ from .ortools_backend import ORToolsCPSATBackend, VERIFIED_ORTOOLS_VERSION
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class CPConformanceCheck:
9
+ name:str; passed:bool; detail:str
10
+ @dataclass(frozen=True, slots=True)
11
+ class CPConformanceReport:
12
+ backend:str; available:bool; passed:bool; checks:tuple[CPConformanceCheck,...]; verified_version:str|None=None
13
+
14
+ def reference_cp_conformance():
15
+ checks=[]
16
+ # Latin/permutation core.
17
+ m=CPModel(); xs=[m.int_var(1,3,f'x{i}') for i in range(3)]; m.add_all_different(xs); m.add(xs[0]==1); m.minimize(xs[2])
18
+ r=m.solve(ReferenceCPBackend()); checks.append(CPConformanceCheck('all-different-linear',r.optimality_proven and r.validation.valid and r.objective==2,str(r.assignment)))
19
+ # Element/table/exactly-one.
20
+ m=CPModel(); i=m.int_var(0,2,'i'); t=m.int_var(4,9,'t'); bs=[m.bool_var(f'b{k}') for k in range(3)]; m.add_element(i,[4,7,9],t); m.add_allowed_assignments([i,t],[(0,4),(2,9)]); m.add_exactly_one(bs); m.add(i==2); r=m.solve(); checks.append(CPConformanceCheck('element-table-exactly-one',r.validation.valid and r.assignment[t.var_id]==9,str(r.assignment)))
21
+ # Scheduling core.
22
+ m=CPModel(); s1=m.int_var(0,3,'s1'); s2=m.int_var(0,3,'s2'); a=m.interval_var(s1,2,'a'); b=m.interval_var(s2,2,'b'); m.add_no_overlap([a,b]); m.minimize(s1+s2); r=m.solve(); checks.append(CPConformanceCheck('interval-no-overlap',r.optimality_proven and r.validation.valid and r.objective==2,str(r.assignment)))
23
+ return CPConformanceReport('reference-cp-enumeration',True,all(c.passed for c in checks),tuple(checks),'p9-reference-v1')
24
+
25
+ def ortools_cp_sat_conformance():
26
+ b=ORToolsCPSATBackend();
27
+ if not b.is_available():
28
+ return CPConformanceReport(b.name,False,False,(CPConformanceCheck('availability',False,f'OR-Tools {VERIFIED_ORTOOLS_VERSION} unavailable in release runtime'),),VERIFIED_ORTOOLS_VERSION)
29
+ # Same representative model against adapter.
30
+ m=CPModel(); x=[m.int_var(1,3,f'x{i}') for i in range(3)]; m.add_all_different(x); m.add(x[0]==1); m.minimize(x[2]); r=m.solve(b)
31
+ ok=r.optimality_proven and r.validation.valid and r.objective==2
32
+ return CPConformanceReport(b.name,True,ok,(CPConformanceCheck('solve-and-independent-validation',ok,str(r.assignment)),),VERIFIED_ORTOOLS_VERSION)
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema":"https://json-schema.org/draft/2020-12/schema",
3
+ "$id":"solverpilot.cp-ir.v1",
4
+ "type":"object",
5
+ "required":["schema_version","variables","intervals","constraints","objective_sense"],
6
+ "properties":{
7
+ "schema_version":{"const":"solverpilot.cp-ir.v1"},
8
+ "variables":{"type":"array"},
9
+ "intervals":{"type":"array"},
10
+ "constraints":{"type":"array"},
11
+ "objective_sense":{"enum":["minimize","maximize"]}
12
+ }
13
+ }
solverpilot/cp/ir.py ADDED
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum
4
+ from types import MappingProxyType
5
+ from typing import Any, Mapping
6
+ import hashlib, json
7
+
8
+ class CPObjectiveSense(str, Enum):
9
+ MINIMIZE='minimize'
10
+ MAXIMIZE='maximize'
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class CPIntVarIR:
14
+ var_id:int
15
+ name:str
16
+ domain:tuple[int,...]
17
+ kind:str='int'
18
+ derived_from:tuple[int,int] | None=None # (start_var_id, fixed_size)
19
+ def __post_init__(self):
20
+ if not self.domain: raise ValueError('CP variable domain cannot be empty')
21
+ vals=tuple(sorted(set(int(v) for v in self.domain)))
22
+ object.__setattr__(self,'domain',vals)
23
+ if self.kind not in {'int','bool'}: raise ValueError('invalid CP variable kind')
24
+ if self.kind=='bool' and not set(vals).issubset({0,1}): raise ValueError('bool domain must be subset of {0,1}')
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class CPLinearExprIR:
28
+ terms:tuple[tuple[int,int],...]=()
29
+ constant:int=0
30
+ def __post_init__(self):
31
+ merged={}
32
+ for vid,c in self.terms:
33
+ merged[int(vid)]=merged.get(int(vid),0)+int(c)
34
+ object.__setattr__(self,'terms',tuple(sorted((v,c) for v,c in merged.items() if c)))
35
+ object.__setattr__(self,'constant',int(self.constant))
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class CPIntervalIR:
39
+ interval_id:int
40
+ name:str
41
+ start_var:int
42
+ size:int
43
+ end_var:int
44
+ def __post_init__(self):
45
+ if self.size < 0: raise ValueError('interval size must be nonnegative')
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class CPLinearConstraintIR:
49
+ expr:CPLinearExprIR; lower:int|None=None; upper:int|None=None; kind:str='linear'
50
+ @dataclass(frozen=True, slots=True)
51
+ class CPAllDifferentIR:
52
+ var_ids:tuple[int,...]; kind:str='all_different'
53
+ @dataclass(frozen=True, slots=True)
54
+ class CPExactlyOneIR:
55
+ literal_ids:tuple[int,...]; kind:str='exactly_one'
56
+ @dataclass(frozen=True, slots=True)
57
+ class CPTableIR:
58
+ var_ids:tuple[int,...]; allowed_tuples:tuple[tuple[int,...],...]; kind:str='table'
59
+ @dataclass(frozen=True, slots=True)
60
+ class CPElementIR:
61
+ index_var:int; values:tuple[int,...]; target_var:int; kind:str='element'
62
+ @dataclass(frozen=True, slots=True)
63
+ class CPCircuitIR:
64
+ arcs:tuple[tuple[int,int,int],...]; kind:str='circuit' # tail, head, bool var
65
+ @dataclass(frozen=True, slots=True)
66
+ class CPNoOverlapIR:
67
+ interval_ids:tuple[int,...]; kind:str='no_overlap'
68
+ @dataclass(frozen=True, slots=True)
69
+ class CPCumulativeIR:
70
+ interval_ids:tuple[int,...]; demands:tuple[int,...]; capacity:int; kind:str='cumulative'
71
+
72
+ CPConstraintIR = CPLinearConstraintIR|CPAllDifferentIR|CPExactlyOneIR|CPTableIR|CPElementIR|CPCircuitIR|CPNoOverlapIR|CPCumulativeIR
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class CPProblem:
76
+ variables:tuple[CPIntVarIR,...]
77
+ intervals:tuple[CPIntervalIR,...]
78
+ constraints:tuple[CPConstraintIR,...]
79
+ objective:CPLinearExprIR|None=None
80
+ objective_sense:CPObjectiveSense=CPObjectiveSense.MINIMIZE
81
+ name:str|None=None
82
+ metadata:Mapping[str,Any]=field(default_factory=dict)
83
+ schema_version:str='solverpilot.cp-ir.v1'
84
+ def __post_init__(self):
85
+ ids=[v.var_id for v in self.variables]
86
+ if len(ids)!=len(set(ids)): raise ValueError('duplicate CP variable id')
87
+ iids=[i.interval_id for i in self.intervals]
88
+ if len(iids)!=len(set(iids)): raise ValueError('duplicate interval id')
89
+ var_map={v.var_id:v for v in self.variables}
90
+ interval_map={i.interval_id:i for i in self.intervals}
91
+
92
+ def require_var(vid:int, where:str):
93
+ if int(vid) not in var_map:
94
+ raise ValueError(f'{where} references unknown CP variable id {vid}')
95
+
96
+ def require_bool(vid:int, where:str):
97
+ require_var(vid,where)
98
+ v=var_map[int(vid)]
99
+ if v.kind!='bool' or not set(v.domain).issubset({0,1}):
100
+ raise ValueError(f'{where} requires Boolean literal variable id {vid}')
101
+
102
+ def require_expr(expr:CPLinearExprIR, where:str):
103
+ for vid,_ in expr.terms: require_var(vid,where)
104
+
105
+ for v in self.variables:
106
+ if v.derived_from is not None:
107
+ require_var(v.derived_from[0],f'variable {v.var_id} derived_from')
108
+ for i in self.intervals:
109
+ require_var(i.start_var,f'interval {i.interval_id} start_var')
110
+ require_var(i.end_var,f'interval {i.interval_id} end_var')
111
+ if self.objective is not None: require_expr(self.objective,'objective')
112
+
113
+ for c in self.constraints:
114
+ where=f'{c.kind} constraint'
115
+ if isinstance(c,CPLinearConstraintIR):
116
+ require_expr(c.expr,where)
117
+ elif isinstance(c,CPAllDifferentIR):
118
+ for vid in c.var_ids: require_var(vid,where)
119
+ elif isinstance(c,CPExactlyOneIR):
120
+ if not c.literal_ids: raise ValueError('exactly_one requires at least one literal')
121
+ for vid in c.literal_ids: require_bool(vid,where)
122
+ elif isinstance(c,CPTableIR):
123
+ for vid in c.var_ids: require_var(vid,where)
124
+ if any(len(t)!=len(c.var_ids) for t in c.allowed_tuples):
125
+ raise ValueError('table tuple arity mismatch')
126
+ elif isinstance(c,CPElementIR):
127
+ require_var(c.index_var,where); require_var(c.target_var,where)
128
+ if not c.values: raise ValueError('element values cannot be empty')
129
+ elif isinstance(c,CPCircuitIR):
130
+ for _,_,vid in c.arcs: require_bool(vid,where)
131
+ elif isinstance(c,CPNoOverlapIR):
132
+ for iid in c.interval_ids:
133
+ if iid not in interval_map: raise ValueError(f'{where} references unknown interval id {iid}')
134
+ elif isinstance(c,CPCumulativeIR):
135
+ if len(c.interval_ids)!=len(c.demands): raise ValueError('cumulative demands must align with interval_ids')
136
+ if c.capacity < 0 or any(int(d)<0 for d in c.demands):
137
+ raise ValueError('cumulative demands/capacity must be nonnegative')
138
+ for iid in c.interval_ids:
139
+ if iid not in interval_map: raise ValueError(f'{where} references unknown interval id {iid}')
140
+ object.__setattr__(self,'metadata',MappingProxyType(dict(self.metadata)))
141
+ @property
142
+ def variable_map(self): return {v.var_id:v for v in self.variables}
143
+ @property
144
+ def interval_map(self): return {i.interval_id:i for i in self.intervals}
145
+ @property
146
+ def base_variables(self): return tuple(v for v in self.variables if v.derived_from is None)
147
+ def canonical_dict(self):
148
+ def expr(e): return {'terms':[list(x) for x in e.terms],'constant':e.constant}
149
+ cs=[]
150
+ for c in self.constraints:
151
+ d={'kind':c.kind}
152
+ if isinstance(c,CPLinearConstraintIR): d.update(expr=expr(c.expr),lower=c.lower,upper=c.upper)
153
+ elif isinstance(c,CPAllDifferentIR): d['var_ids']=list(c.var_ids)
154
+ elif isinstance(c,CPExactlyOneIR): d['literal_ids']=list(c.literal_ids)
155
+ elif isinstance(c,CPTableIR): d.update(var_ids=list(c.var_ids),allowed_tuples=[list(t) for t in c.allowed_tuples])
156
+ elif isinstance(c,CPElementIR): d.update(index_var=c.index_var,values=list(c.values),target_var=c.target_var)
157
+ elif isinstance(c,CPCircuitIR): d['arcs']=[list(a) for a in c.arcs]
158
+ elif isinstance(c,CPNoOverlapIR): d['interval_ids']=list(c.interval_ids)
159
+ elif isinstance(c,CPCumulativeIR): d.update(interval_ids=list(c.interval_ids),demands=list(c.demands),capacity=c.capacity)
160
+ cs.append(d)
161
+ return {'schema_version':self.schema_version,'name':self.name,'variables':[{'id':v.var_id,'name':v.name,'domain':list(v.domain),'kind':v.kind,'derived_from':None if v.derived_from is None else list(v.derived_from)} for v in self.variables],'intervals':[{'id':i.interval_id,'name':i.name,'start_var':i.start_var,'size':i.size,'end_var':i.end_var} for i in self.intervals],'constraints':cs,'objective':None if self.objective is None else expr(self.objective),'objective_sense':self.objective_sense.value,'metadata':dict(self.metadata)}
162
+ @property
163
+ def structural_hash(self):
164
+ d=self.canonical_dict();
165
+ for v in d['variables']: v['domain']=['<domain>']
166
+ d['metadata']={}
167
+ return hashlib.sha256(json.dumps(d,sort_keys=True,separators=(',',':')).encode()).hexdigest()
168
+ @property
169
+ def data_hash(self):
170
+ return hashlib.sha256(json.dumps(self.canonical_dict(),sort_keys=True,separators=(',',':')).encode()).hexdigest()