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,716 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Iterable
5
+ import hashlib
6
+ import json
7
+ import uuid
8
+
9
+ import numpy as np
10
+
11
+ from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem, VariableDomain
12
+
13
+ from .errors import DomainError, OwnershipError, ShapeError, SymbolicTruthValueError
14
+ from .expression import ExprNode, Expression, _shape_tuple, _sign_of_constant
15
+ from .sets import (ConstraintSet, EqualTo, GreaterThan, LessThan, ScalarSet, SecondOrderCone, RotatedSecondOrderCone, PositiveSemidefiniteCone)
16
+ from .types import Curvature, EntityId, SignDomain
17
+
18
+
19
+ def _readonly_array(value: Any, shape: tuple[int, ...], *, finite: bool, name: str) -> np.ndarray:
20
+ arr = np.asarray(value, dtype=np.float64)
21
+ try:
22
+ arr = np.broadcast_to(arr, shape if shape else ()).copy()
23
+ except ValueError as exc:
24
+ raise ShapeError(f"{name} cannot broadcast to shape {shape}") from exc
25
+ if np.isnan(arr).any():
26
+ raise DomainError(f"{name} must not contain NaN")
27
+ if finite and np.isinf(arr).any():
28
+ raise DomainError(f"{name} must be finite")
29
+ arr.flags.writeable = False
30
+ return arr
31
+
32
+
33
+ def _canonical_float(x: float) -> str:
34
+ x = float(x)
35
+ if np.isnan(x):
36
+ raise DomainError("NaN cannot be canonically serialized")
37
+ if np.isposinf(x):
38
+ return "+inf"
39
+ if np.isneginf(x):
40
+ return "-inf"
41
+ return x.hex()
42
+
43
+
44
+ def _array_semantic_payload(arr: np.ndarray) -> dict[str, Any]:
45
+ flat = np.asarray(arr, dtype=np.float64).reshape(-1)
46
+ return {"shape": list(arr.shape), "values": [_canonical_float(x) for x in flat]}
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class _VariableData:
51
+ entity_id: EntityId
52
+ shape: tuple[int, ...]
53
+ lower: np.ndarray
54
+ upper: np.ndarray
55
+ domain: VariableDomain
56
+ name: str | None
57
+ origin_id: str | None = None
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class _ParameterData:
62
+ entity_id: EntityId
63
+ shape: tuple[int, ...]
64
+ value: np.ndarray
65
+ sign: SignDomain
66
+ name: str | None
67
+ origin_id: str | None = None
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class PendingConstraint:
72
+ function: Expression
73
+ relation: str
74
+
75
+ def __bool__(self) -> bool:
76
+ raise SymbolicTruthValueError("symbolic constraint cannot be converted to bool")
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Constraint:
81
+ entity_id: EntityId
82
+ function: Expression
83
+ set: ConstraintSet
84
+ name: str | None = None
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class IndicatorConstraint:
89
+ entity_id: EntityId
90
+ indicator: "Variable"
91
+ active_value: int
92
+ function: Expression
93
+ set: ConstraintSet
94
+ name: str | None = None
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class Objective:
99
+ entity_id: EntityId
100
+ expression: Expression
101
+ sense: ObjectiveSense
102
+ name: str | None = None
103
+
104
+
105
+ class Variable(Expression):
106
+ __slots__ = ("_entity_id",)
107
+
108
+ def __init__(self, model: "Model", data: _VariableData) -> None:
109
+ sign = model._variable_sign(data)
110
+ node = ExprNode(
111
+ "variable", data.shape, payload=data.entity_id.value,
112
+ variable_dependencies=frozenset({data.entity_id.value}),
113
+ sign=sign, degree=1, curvature=Curvature.AFFINE,
114
+ )
115
+ super().__init__(model, node)
116
+ self._entity_id = data.entity_id
117
+
118
+ @property
119
+ def id(self) -> EntityId:
120
+ return self._entity_id
121
+
122
+ @property
123
+ def name(self) -> str | None:
124
+ return self._model._variables[self.id.value].name
125
+
126
+ @property
127
+ def lower(self) -> np.ndarray:
128
+ return self._model._variables[self.id.value].lower
129
+
130
+ @property
131
+ def upper(self) -> np.ndarray:
132
+ return self._model._variables[self.id.value].upper
133
+
134
+ @property
135
+ def domain(self) -> VariableDomain:
136
+ return self._model._variables[self.id.value].domain
137
+
138
+
139
+ class Parameter(Expression):
140
+ __slots__ = ("_entity_id",)
141
+
142
+ def __init__(self, model: "Model", data: _ParameterData) -> None:
143
+ node = ExprNode(
144
+ "parameter", data.shape, payload=data.entity_id.value,
145
+ parameter_dependencies=frozenset({data.entity_id.value}),
146
+ sign=data.sign, degree=0, curvature=Curvature.CONSTANT,
147
+ )
148
+ super().__init__(model, node)
149
+ self._entity_id = data.entity_id
150
+
151
+ @property
152
+ def id(self) -> EntityId:
153
+ return self._entity_id
154
+
155
+ @property
156
+ def name(self) -> str | None:
157
+ return self._model._parameters[self.id.value].name
158
+
159
+ @property
160
+ def value(self) -> np.ndarray:
161
+ return self._model._parameters[self.id.value].value.copy()
162
+
163
+ @value.setter
164
+ def value(self, new_value: Any) -> None:
165
+ self._model._set_parameter_value(self.id.value, new_value)
166
+
167
+
168
+ class Model:
169
+ def __init__(self, name: str | None = None) -> None:
170
+ self.name = name
171
+ self._namespace = uuid.uuid4().hex
172
+ self._next_ids = {"v": 0, "p": 0, "c": 0, "o": 0}
173
+ self._variables: dict[str, _VariableData] = {}
174
+ self._parameters: dict[str, _ParameterData] = {}
175
+ self._constraints: list[Constraint | IndicatorConstraint] = []
176
+ self._objective: Objective | None = None
177
+ # P2 compiler/cache state. Expression nodes remain immutable; only the
178
+ # model container tracks semantic/data revisions.
179
+ self._semantic_revision = 0
180
+ self._data_revision = 0
181
+ self._parameter_versions: dict[str, int] = {}
182
+ self._semantic_hash_cache: tuple[int, str] | None = None
183
+ self._data_hash_cache: tuple[int, int, str] | None = None
184
+ self._compiler_cache: dict[str, Any] = {}
185
+
186
+ def _new_id(self, prefix: str) -> EntityId:
187
+ self._next_ids[prefix] += 1
188
+ return EntityId(f"{prefix}{self._next_ids[prefix]:06d}", self._namespace)
189
+
190
+ @property
191
+ def variables(self) -> tuple[Variable, ...]:
192
+ return tuple(Variable(self, d) for d in self._variables.values())
193
+
194
+ @property
195
+ def parameters(self) -> tuple[Parameter, ...]:
196
+ return tuple(Parameter(self, d) for d in self._parameters.values())
197
+
198
+ @property
199
+ def constraints(self) -> tuple[Constraint | IndicatorConstraint, ...]:
200
+ return tuple(self._constraints)
201
+
202
+ @property
203
+ def objective(self) -> Objective | None:
204
+ return self._objective
205
+
206
+ def variable(
207
+ self,
208
+ shape: int | Iterable[int] | tuple[int, ...] | None = None,
209
+ *,
210
+ lower: Any = -np.inf,
211
+ upper: Any = np.inf,
212
+ domain: VariableDomain | str = VariableDomain.CONTINUOUS,
213
+ name: str | None = None,
214
+ ) -> Variable:
215
+ shape_t = _shape_tuple(shape)
216
+ dom = VariableDomain(domain)
217
+ lo = _readonly_array(lower, shape_t, finite=False, name="variable lower bound")
218
+ hi = _readonly_array(upper, shape_t, finite=False, name="variable upper bound")
219
+ if np.any(lo > hi):
220
+ raise DomainError("variable lower bound exceeds upper bound")
221
+ if dom is VariableDomain.BINARY and (np.any(lo < 0) or np.any(hi > 1)):
222
+ raise DomainError("binary variable bounds must lie inside [0, 1]")
223
+ entity_id = self._new_id("v")
224
+ data = _VariableData(entity_id, shape_t, lo, hi, dom, name)
225
+ self._variables[entity_id.value] = data
226
+ self._mark_semantic_change()
227
+ return Variable(self, data)
228
+
229
+ def binary(self, shape: int | Iterable[int] | tuple[int, ...] | None = None, *, name: str | None = None) -> Variable:
230
+ return self.variable(shape, lower=0.0, upper=1.0, domain=VariableDomain.BINARY, name=name)
231
+
232
+ def integer(
233
+ self,
234
+ shape: int | Iterable[int] | tuple[int, ...] | None = None,
235
+ *,
236
+ lower: Any = -np.inf,
237
+ upper: Any = np.inf,
238
+ name: str | None = None,
239
+ ) -> Variable:
240
+ return self.variable(shape, lower=lower, upper=upper, domain=VariableDomain.INTEGER, name=name)
241
+
242
+ def parameter(
243
+ self,
244
+ shape: int | Iterable[int] | tuple[int, ...] | None = None,
245
+ *,
246
+ value: Any | None = None,
247
+ sign: SignDomain | str = SignDomain.UNKNOWN,
248
+ name: str | None = None,
249
+ ) -> Parameter:
250
+ shape_t = _shape_tuple(shape)
251
+ sign_v = SignDomain(sign)
252
+ if value is None:
253
+ value = np.zeros(shape_t if shape_t else (), dtype=np.float64)
254
+ arr = _readonly_array(value, shape_t, finite=True, name="parameter value")
255
+ self._validate_parameter_sign(arr, sign_v)
256
+ entity_id = self._new_id("p")
257
+ data = _ParameterData(entity_id, shape_t, arr, sign_v, name)
258
+ self._parameters[entity_id.value] = data
259
+ self._parameter_versions[entity_id.value] = 0
260
+ self._mark_semantic_change()
261
+ return Parameter(self, data)
262
+
263
+ def constant(self, value: Any) -> Expression:
264
+ arr = np.asarray(value, dtype=np.float64)
265
+ if np.isnan(arr).any() or np.isinf(arr).any():
266
+ raise DomainError("expression constants must be finite")
267
+ arr = np.array(arr, dtype=np.float64, copy=True)
268
+ arr.flags.writeable = False
269
+ node = ExprNode("constant", tuple(arr.shape), payload=arr, sign=_sign_of_constant(arr), degree=0, curvature=Curvature.CONSTANT)
270
+ return Expression(self, node)
271
+
272
+ def add_in_set(self, function: Any, set_: ConstraintSet, *, name: str | None = None) -> Constraint:
273
+ expr = function if isinstance(function, Expression) else self.constant(function)
274
+ if expr._model is not self:
275
+ raise OwnershipError("constraint function belongs to another model")
276
+ if not isinstance(set_, (LessThan, GreaterThan, EqualTo, SecondOrderCone, RotatedSecondOrderCone, PositiveSemidefiniteCone)):
277
+ from .sets import Interval
278
+ if not isinstance(set_, Interval):
279
+ raise TypeError("unsupported constraint set")
280
+ if isinstance(set_, SecondOrderCone) and expr.shape != (set_.dimension,):
281
+ raise ShapeError(f"SOC function must have shape ({set_.dimension},), got {expr.shape}")
282
+ if isinstance(set_, RotatedSecondOrderCone) and expr.shape != (set_.dimension,):
283
+ raise ShapeError(f"rotated SOC function must have shape ({set_.dimension},), got {expr.shape}")
284
+ if isinstance(set_, PositiveSemidefiniteCone) and expr.shape != (set_.dimension, set_.dimension):
285
+ raise ShapeError(f"PSD function must have shape ({set_.dimension}, {set_.dimension}), got {expr.shape}")
286
+ constraint = Constraint(self._new_id("c"), expr, set_, name)
287
+ self._constraints.append(constraint)
288
+ self._mark_semantic_change()
289
+ return constraint
290
+
291
+ def add(self, relation: PendingConstraint, *, name: str | None = None) -> Constraint:
292
+ if not isinstance(relation, PendingConstraint):
293
+ raise TypeError("Model.add expects a symbolic relation such as x <= b")
294
+ if relation.function._model is not self:
295
+ raise OwnershipError("constraint belongs to another model")
296
+ if relation.relation == "le":
297
+ set_ = LessThan(0.0)
298
+ elif relation.relation == "ge":
299
+ set_ = GreaterThan(0.0)
300
+ elif relation.relation == "eq":
301
+ set_ = EqualTo(0.0)
302
+ else:
303
+ raise AssertionError(relation.relation)
304
+ return self.add_in_set(relation.function, set_, name=name)
305
+
306
+ def indicator(
307
+ self,
308
+ indicator: Variable,
309
+ relation: PendingConstraint,
310
+ *,
311
+ active_value: int = 1,
312
+ name: str | None = None,
313
+ ) -> IndicatorConstraint:
314
+ """Add a scalar binary indicator constraint without prematurely linearizing it.
315
+
316
+ P4 deliberately preserves the semantic indicator until compilation chooses an exact
317
+ lowering path. The current execution IR can transport only linear rows, so safe
318
+ compilation uses fixed-premise simplification or a bound-certified Big-M bridge.
319
+ """
320
+ if not isinstance(indicator, Variable) or indicator._model is not self:
321
+ raise OwnershipError("indicator variable must be a Variable owned by this model")
322
+ if indicator.shape != ():
323
+ raise ShapeError("P4 indicator variable must be scalar")
324
+ if indicator.domain is not VariableDomain.BINARY:
325
+ raise DomainError("P4 indicator variable must be binary")
326
+ if active_value not in {0, 1}:
327
+ raise DomainError("indicator active_value must be 0 or 1")
328
+ if not isinstance(relation, PendingConstraint):
329
+ raise TypeError("Model.indicator expects a symbolic relation such as x <= b")
330
+ if relation.function._model is not self:
331
+ raise OwnershipError("indicator body belongs to another model")
332
+ if relation.function.shape != ():
333
+ raise ShapeError("P4 indicator body must be scalar")
334
+ if relation.relation == "le":
335
+ set_ = LessThan(0.0)
336
+ elif relation.relation == "ge":
337
+ set_ = GreaterThan(0.0)
338
+ elif relation.relation == "eq":
339
+ set_ = EqualTo(0.0)
340
+ else:
341
+ raise AssertionError(relation.relation)
342
+ constraint = IndicatorConstraint(self._new_id("c"), indicator, int(active_value), relation.function, set_, name)
343
+ self._constraints.append(constraint)
344
+ self._mark_semantic_change()
345
+ return constraint
346
+
347
+ def _concat(self, *values: Any) -> Expression:
348
+ exprs: list[Expression] = []
349
+ total = 0
350
+ deps_v: frozenset[str] = frozenset()
351
+ deps_p: frozenset[str] = frozenset()
352
+ degree = 0
353
+ for value in values:
354
+ expr = value if isinstance(value, Expression) else self.constant(value)
355
+ if expr._model is not self:
356
+ raise OwnershipError("concatenated expression belongs to another model")
357
+ if expr.ndim > 1:
358
+ raise ShapeError("P6 cone packing supports scalar/vector expressions only")
359
+ exprs.append(expr)
360
+ total += expr.size
361
+ deps_v = deps_v | expr.variable_dependencies
362
+ deps_p = deps_p | expr.parameter_dependencies
363
+ if expr.polynomial_degree is None:
364
+ degree = None
365
+ elif degree is not None:
366
+ degree = max(degree, expr.polynomial_degree)
367
+ curvature = Curvature.CONSTANT if degree == 0 else Curvature.AFFINE if degree is not None and degree <= 1 else Curvature.UNKNOWN
368
+ return Expression(self, ExprNode("concat", (total,), args=tuple(e._node for e in exprs), variable_dependencies=deps_v, parameter_dependencies=deps_p, sign=SignDomain.UNKNOWN, degree=degree, curvature=curvature))
369
+
370
+ def soc(self, t: Any, vector: Any, *, name: str | None = None) -> Constraint:
371
+ """Add the exact semantic cone constraint ``||vector||_2 <= t``."""
372
+ t_expr = t if isinstance(t, Expression) else self.constant(t)
373
+ v_expr = vector if isinstance(vector, Expression) else self.constant(vector)
374
+ if t_expr._model is not self or v_expr._model is not self:
375
+ raise OwnershipError("SOC expressions must belong to this model")
376
+ if t_expr.shape != ():
377
+ raise ShapeError("SOC t must be scalar")
378
+ if v_expr.ndim != 1:
379
+ raise ShapeError("SOC vector must be one-dimensional")
380
+ packed = self._concat(t_expr, v_expr)
381
+ return self.add_in_set(packed, SecondOrderCone(packed.size), name=name)
382
+
383
+ def rotated_soc(self, u: Any, v: Any, vector: Any, *, name: str | None = None) -> Constraint:
384
+ """Add ``u>=0, v>=0, 2*u*v >= ||vector||_2^2`` as a semantic cone."""
385
+ u_expr = u if isinstance(u, Expression) else self.constant(u)
386
+ v_expr = v if isinstance(v, Expression) else self.constant(v)
387
+ x_expr = vector if isinstance(vector, Expression) else self.constant(vector)
388
+ if any(e._model is not self for e in (u_expr, v_expr, x_expr)):
389
+ raise OwnershipError("rotated SOC expressions must belong to this model")
390
+ if u_expr.shape != () or v_expr.shape != ():
391
+ raise ShapeError("rotated SOC u and v must be scalars")
392
+ if x_expr.ndim != 1:
393
+ raise ShapeError("rotated SOC vector must be one-dimensional")
394
+ packed = self._concat(u_expr, v_expr, x_expr)
395
+ return self.add_in_set(packed, RotatedSecondOrderCone(packed.size), name=name)
396
+
397
+ def psd(self, matrix: Any, *, name: str | None = None) -> Constraint:
398
+ """Add affine symmetric matrix membership in the PSD cone."""
399
+ expr = matrix if isinstance(matrix, Expression) else self.constant(matrix)
400
+ if expr._model is not self:
401
+ raise OwnershipError("PSD expression belongs to another model")
402
+ if expr.ndim != 2 or expr.shape[0] != expr.shape[1]:
403
+ raise ShapeError("PSD expression must be a square matrix")
404
+ return self.add_in_set(expr, PositiveSemidefiniteCone(expr.shape[0]), name=name)
405
+
406
+ def minimize(self, expression: Any, *, name: str | None = None) -> Objective:
407
+ return self._set_objective(expression, ObjectiveSense.MINIMIZE, name=name)
408
+
409
+ def maximize(self, expression: Any, *, name: str | None = None) -> Objective:
410
+ return self._set_objective(expression, ObjectiveSense.MAXIMIZE, name=name)
411
+
412
+ def _set_objective(self, expression: Any, sense: ObjectiveSense, *, name: str | None) -> Objective:
413
+ expr = expression if isinstance(expression, Expression) else self.constant(expression)
414
+ if expr._model is not self:
415
+ raise OwnershipError("objective belongs to another model")
416
+ if expr.shape != ():
417
+ raise ShapeError(f"objective must be scalar, got shape {expr.shape}")
418
+ objective = Objective(self._new_id("o"), expr, sense, name)
419
+ self._objective = objective
420
+ self._mark_semantic_change()
421
+ return objective
422
+
423
+ def compile(self, *, use_cache: bool = True, bridge_policy=None, capabilities=None):
424
+ from .compiler import compile_model
425
+ return compile_model(self, use_cache=use_cache, bridge_policy=bridge_policy, capabilities=capabilities)
426
+
427
+ def clear_compile_cache(self) -> None:
428
+ self._compiler_cache.clear()
429
+
430
+ @property
431
+ def compiler_cache_info(self):
432
+ from .compiler import compiler_cache_info
433
+ return compiler_cache_info(self)
434
+
435
+ def solve(self, **kwargs):
436
+ compiled = self.compile()
437
+ return compiled.solve(**kwargs)
438
+
439
+ @property
440
+ def semantic_hash(self) -> str:
441
+ cached = self._semantic_hash_cache
442
+ if cached is not None and cached[0] == self._semantic_revision:
443
+ return cached[1]
444
+ payload = self._semantic_payload(include_parameter_values=False)
445
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
446
+ value = hashlib.sha256(raw).hexdigest()
447
+ self._semantic_hash_cache = (self._semantic_revision, value)
448
+ return value
449
+
450
+ @property
451
+ def data_hash(self) -> str:
452
+ cached = self._data_hash_cache
453
+ if cached is not None and cached[:2] == (self._semantic_revision, self._data_revision):
454
+ return cached[2]
455
+ payload = {
456
+ "semantic_hash": self.semantic_hash,
457
+ "parameters": {
458
+ key: _array_semantic_payload(data.value)
459
+ for key, data in sorted(self._parameters.items())
460
+ },
461
+ }
462
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
463
+ value = hashlib.sha256(raw).hexdigest()
464
+ self._data_hash_cache = (self._semantic_revision, self._data_revision, value)
465
+ return value
466
+
467
+ def _semantic_payload(self, *, include_parameter_values: bool) -> dict[str, Any]:
468
+ variables = []
469
+ for key, data in self._variables.items():
470
+ variables.append({
471
+ "id": key,
472
+ "shape": list(data.shape),
473
+ "lower": _array_semantic_payload(data.lower),
474
+ "upper": _array_semantic_payload(data.upper),
475
+ "domain": data.domain.value,
476
+ })
477
+ parameters = []
478
+ for key, data in self._parameters.items():
479
+ item = {"id": key, "shape": list(data.shape), "sign": data.sign.value}
480
+ if include_parameter_values:
481
+ item["value"] = _array_semantic_payload(data.value)
482
+ parameters.append(item)
483
+ has_cones = any(isinstance(c.set, (SecondOrderCone, RotatedSecondOrderCone, PositiveSemidefiniteCone)) for c in self._constraints)
484
+ nonlinear_kinds = {"sin", "cos", "exp", "log", "sqrt", "tanh", "pow", "div"}
485
+ def has_nonlinear(node):
486
+ return node.kind in nonlinear_kinds or any(has_nonlinear(a) for a in node.args)
487
+ has_nlp = (self._objective is not None and has_nonlinear(self._objective.expression._node)) or any(has_nonlinear(c.function._node) for c in self._constraints if hasattr(c, "function"))
488
+ has_discrete = any(d.domain in {VariableDomain.INTEGER, VariableDomain.BINARY} for d in self._variables.values())
489
+ has_nonlinear_degree = (self._objective is not None and (self._objective.expression.polynomial_degree is None or self._objective.expression.polynomial_degree > 1)) or any((c.function.polynomial_degree is None or c.function.polynomial_degree > 1) for c in self._constraints if hasattr(c, "function"))
490
+ schema = "solverpilot.semantic-model.p8.v1" if (has_discrete and (has_nlp or has_nonlinear_degree)) else ("solverpilot.semantic-model.p7.v1" if has_nlp else ("solverpilot.semantic-model.p6.v1" if has_cones else "solverpilot.semantic-model.p1.v1"))
491
+ return {
492
+ "schema": schema,
493
+ "variables": variables,
494
+ "parameters": parameters,
495
+ "constraints": [self._serialize_constraint(c) for c in self._constraints],
496
+ "objective": None if self._objective is None else {
497
+ "id": self._objective.entity_id.value,
498
+ "sense": self._objective.sense.value,
499
+ "expression": self._serialize_node(self._objective.expression._node, include_parameter_values=False),
500
+ },
501
+ }
502
+
503
+ def _serialize_constraint(self, constraint: Constraint | IndicatorConstraint) -> dict[str, Any]:
504
+ base = {
505
+ "id": constraint.entity_id.value,
506
+ "function": self._serialize_node(constraint.function._node, include_parameter_values=False),
507
+ "set": self._serialize_set(constraint.set),
508
+ }
509
+ if isinstance(constraint, IndicatorConstraint):
510
+ base.update({
511
+ "kind": "IndicatorConstraint",
512
+ "indicator": constraint.indicator.id.value,
513
+ "active_value": constraint.active_value,
514
+ })
515
+ return base
516
+
517
+ def _serialize_set(self, set_: ScalarSet) -> dict[str, Any]:
518
+ if isinstance(set_, LessThan):
519
+ return {"kind": "LessThan", "upper": _canonical_float(set_.upper)}
520
+ if isinstance(set_, GreaterThan):
521
+ return {"kind": "GreaterThan", "lower": _canonical_float(set_.lower)}
522
+ if isinstance(set_, EqualTo):
523
+ return {"kind": "EqualTo", "value": _canonical_float(set_.value)}
524
+ from .sets import Interval
525
+ if isinstance(set_, Interval):
526
+ return {"kind": "Interval", "lower": _canonical_float(set_.lower), "upper": _canonical_float(set_.upper)}
527
+ if isinstance(set_, SecondOrderCone):
528
+ return {"kind": "SecondOrderCone", "dimension": set_.dimension}
529
+ if isinstance(set_, RotatedSecondOrderCone):
530
+ return {"kind": "RotatedSecondOrderCone", "dimension": set_.dimension}
531
+ if isinstance(set_, PositiveSemidefiniteCone):
532
+ return {"kind": "PositiveSemidefiniteCone", "dimension": set_.dimension}
533
+ raise TypeError(f"unsupported constraint set {type(set_).__name__}")
534
+
535
+ def _serialize_node(self, node: ExprNode, *, include_parameter_values: bool) -> dict[str, Any]:
536
+ out: dict[str, Any] = {"kind": node.kind, "shape": list(node.shape)}
537
+ if node.kind == "constant":
538
+ out["value"] = _array_semantic_payload(node.payload)
539
+ elif node.kind in {"variable", "parameter"}:
540
+ out["entity"] = node.payload
541
+ if node.kind == "parameter" and include_parameter_values:
542
+ out["value"] = _array_semantic_payload(self._parameters[node.payload].value)
543
+ elif node.kind == "index":
544
+ key = node.payload if isinstance(node.payload, tuple) else (node.payload,)
545
+ out["index"] = [
546
+ {"int": int(x)} if isinstance(x, (int, np.integer)) else {"slice": [x.start, x.stop, x.step]}
547
+ for x in key
548
+ ]
549
+ elif node.kind == "sum":
550
+ out["axis"] = node.payload
551
+ elif node.kind == "pow":
552
+ out["exponent"] = int(node.payload)
553
+ out["args"] = [self._serialize_node(arg, include_parameter_values=include_parameter_values) for arg in node.args]
554
+ return out
555
+
556
+ def _node_sort_key(self, node: ExprNode) -> str:
557
+ return json.dumps(self._serialize_node(node, include_parameter_values=False), sort_keys=True, separators=(",", ":"))
558
+
559
+ def _constant_value(self, node: ExprNode) -> np.ndarray:
560
+ if node.kind != "constant":
561
+ raise DomainError("expression is not a fixed constant")
562
+ return np.asarray(node.payload, dtype=np.float64)
563
+
564
+ def _variable_sign(self, data: _VariableData) -> SignDomain:
565
+ if np.all(data.lower == 0) and np.all(data.upper == 0):
566
+ return SignDomain.ZERO
567
+ if np.all(data.lower >= 0):
568
+ return SignDomain.NONNEGATIVE
569
+ if np.all(data.upper <= 0):
570
+ return SignDomain.NONPOSITIVE
571
+ return SignDomain.UNKNOWN
572
+
573
+ def _indexed_sign(self, node: ExprNode, key: Any) -> SignDomain:
574
+ if node.kind == "constant":
575
+ return _sign_of_constant(np.asarray(node.payload)[key])
576
+ if node.kind == "variable":
577
+ data = self._variables[node.payload]
578
+ lo = np.asarray(data.lower)[key]
579
+ hi = np.asarray(data.upper)[key]
580
+ if np.all(lo == 0) and np.all(hi == 0):
581
+ return SignDomain.ZERO
582
+ if np.all(lo >= 0):
583
+ return SignDomain.NONNEGATIVE
584
+ if np.all(hi <= 0):
585
+ return SignDomain.NONPOSITIVE
586
+ if node.kind == "parameter":
587
+ return self._parameters[node.payload].sign
588
+ return SignDomain.UNKNOWN
589
+
590
+ def _validate_parameter_sign(self, value: np.ndarray, sign: SignDomain) -> None:
591
+ if sign is SignDomain.NONNEGATIVE and np.any(value < 0):
592
+ raise DomainError("parameter declared nonnegative has negative values")
593
+ if sign is SignDomain.NONPOSITIVE and np.any(value > 0):
594
+ raise DomainError("parameter declared nonpositive has positive values")
595
+ if sign is SignDomain.ZERO and np.any(value != 0):
596
+ raise DomainError("parameter declared zero has nonzero values")
597
+
598
+ def _set_parameter_value(self, entity: str, value: Any) -> None:
599
+ data = self._parameters[entity]
600
+ arr = _readonly_array(value, data.shape, finite=True, name="parameter value")
601
+ self._validate_parameter_sign(arr, data.sign)
602
+ if np.array_equal(arr, data.value):
603
+ return
604
+ data.value = arr
605
+ self._parameter_versions[entity] = self._parameter_versions.get(entity, 0) + 1
606
+ self._data_revision += 1
607
+ self._data_hash_cache = None
608
+
609
+ def _mark_semantic_change(self) -> None:
610
+ self._semantic_revision += 1
611
+ self._data_revision += 1
612
+ self._semantic_hash_cache = None
613
+ self._data_hash_cache = None
614
+ # Semantic changes invalidate compiler templates. Parameter value updates
615
+ # deliberately do not clear this cache.
616
+ self._compiler_cache.clear()
617
+
618
+ @classmethod
619
+ def from_problem(cls, problem: LinearProblem | QuadraticProblem, *, name: str | None = None) -> "Model":
620
+ """Import the current legacy LP/QP execution IR into the P1 semantic layer.
621
+
622
+ This is a migration/debug path, not a replacement for the zero-overhead legacy direct path.
623
+ It preserves variable ordering, bounds/domains, objective convention and row bounds.
624
+ """
625
+ from .sets import Interval
626
+ linear = problem.linear if isinstance(problem, QuadraticProblem) else problem
627
+ model = cls(linear.name if name is None else name)
628
+ xs: list[Variable] = []
629
+ for j in range(linear.n_variables):
630
+ xs.append(model.variable(
631
+ lower=float(linear.variable_lower[j]),
632
+ upper=float(linear.variable_upper[j]),
633
+ domain=VariableDomain(str(linear.domains[j])),
634
+ name=f"x{j}",
635
+ ))
636
+ for i in range(linear.n_constraints):
637
+ row = linear.A.getrow(i)
638
+ expr: Expression = model.constant(0.0)
639
+ for j, value in zip(row.indices, row.data):
640
+ expr = expr + float(value) * xs[int(j)]
641
+ lo = float(linear.constraint_lower[i]); hi = float(linear.constraint_upper[i])
642
+ if lo == hi:
643
+ model.add_in_set(expr, EqualTo(lo), name=f"row{i}")
644
+ elif np.isneginf(lo):
645
+ model.add_in_set(expr, LessThan(hi), name=f"row{i}")
646
+ elif np.isposinf(hi):
647
+ model.add_in_set(expr, GreaterThan(lo), name=f"row{i}")
648
+ else:
649
+ model.add_in_set(expr, Interval(lo, hi), name=f"row{i}")
650
+ objective: Expression = model.constant(float(linear.objective_offset))
651
+ for j, value in enumerate(linear.c):
652
+ if value != 0.0:
653
+ objective = objective + float(value) * xs[j]
654
+ if isinstance(problem, QuadraticProblem):
655
+ coo = problem.P.tocoo()
656
+ for i, j, value in zip(coo.row, coo.col, coo.data):
657
+ if value != 0.0:
658
+ objective = objective + 0.5 * float(value) * xs[int(i)] * xs[int(j)]
659
+ if linear.objective_sense is ObjectiveSense.MINIMIZE:
660
+ model.minimize(objective)
661
+ else:
662
+ model.maximize(objective)
663
+ return model
664
+
665
+ def clone(self) -> "Model":
666
+ clone = Model(self.name)
667
+ node_map: dict[str, Expression] = {}
668
+ for key, data in self._variables.items():
669
+ v = clone.variable(data.shape, lower=data.lower, upper=data.upper, domain=data.domain, name=data.name)
670
+ node_map[key] = v
671
+ for key, data in self._parameters.items():
672
+ p = clone.parameter(data.shape, value=data.value, sign=data.sign, name=data.name)
673
+ node_map[key] = p
674
+
675
+ def rebuild(node: ExprNode) -> Expression:
676
+ if node.kind == "variable" or node.kind == "parameter":
677
+ return node_map[node.payload]
678
+ if node.kind == "constant":
679
+ return clone.constant(node.payload)
680
+ args = [rebuild(x) for x in node.args]
681
+ if node.kind == "add": return args[0] + args[1]
682
+ if node.kind == "mul": return args[0] * args[1]
683
+ if node.kind == "matmul": return args[0] @ args[1]
684
+ if node.kind == "neg": return -args[0]
685
+ if node.kind == "index": return args[0][node.payload]
686
+ if node.kind == "transpose": return args[0].T
687
+ if node.kind == "sum": return args[0].sum(axis=node.payload)
688
+ if node.kind == "concat": return clone._concat(*args)
689
+ raise DomainError(f"cannot clone unsupported expression node {node.kind!r}")
690
+
691
+ for c in self._constraints:
692
+ expr = rebuild(c.function._node)
693
+ if isinstance(c.set, (SecondOrderCone, RotatedSecondOrderCone, PositiveSemidefiniteCone)):
694
+ clone.add_in_set(expr, c.set, name=c.name)
695
+ continue
696
+ if isinstance(c.set, LessThan): relation = expr <= c.set.upper
697
+ elif isinstance(c.set, GreaterThan): relation = expr >= c.set.lower
698
+ elif isinstance(c.set, EqualTo): relation = expr == c.set.value
699
+ else:
700
+ if isinstance(c, IndicatorConstraint):
701
+ raise DomainError("P4 clone does not accept Interval indicator bodies")
702
+ clone.add(expr >= c.set.lower, name=c.name)
703
+ clone.add(expr <= c.set.upper, name=c.name)
704
+ continue
705
+ if isinstance(c, IndicatorConstraint):
706
+ new_indicator = node_map[c.indicator.id.value]
707
+ if not isinstance(new_indicator, Variable):
708
+ raise DomainError("cloned indicator did not resolve to a Variable")
709
+ clone.indicator(new_indicator, relation, active_value=c.active_value, name=c.name)
710
+ else:
711
+ clone.add(relation, name=c.name)
712
+ if self._objective is not None:
713
+ expr = rebuild(self._objective.expression._node)
714
+ if self._objective.sense is ObjectiveSense.MINIMIZE: clone.minimize(expr, name=self._objective.name)
715
+ else: clone.maximize(expr, name=self._objective.name)
716
+ return clone