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,491 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, fields, is_dataclass
4
+ from enum import Enum
5
+ from time import perf_counter
6
+ from typing import Any, Callable
7
+ import copy
8
+
9
+ from solverpilot.backends import Backend
10
+ from solverpilot.capabilities import (
11
+ BackendCapabilityManifestV2,
12
+ CapabilityKey,
13
+ resolve_backend_capabilities,
14
+ )
15
+ from solverpilot.model import CompiledModel, Model, Parameter
16
+ from solverpilot.problem import LinearProblem, QuadraticProblem
17
+ from solverpilot.runtime import SolveResult
18
+ from solverpilot.runtime.executor import execute
19
+
20
+ from .mutations import MutationKind, MutationRecord, classify_mutations
21
+
22
+
23
+ class PersistentSessionError(RuntimeError):
24
+ pass
25
+
26
+
27
+ class ReuseEvidenceMismatchError(PersistentSessionError):
28
+ pass
29
+
30
+
31
+ class MutationExecutionPath(str, Enum):
32
+ COLD_BUILD = "cold_build"
33
+ NATIVE_PATCH = "native_patch"
34
+ SAFE_REBUILD = "safe_rebuild"
35
+ FULL_REBUILD = "full_rebuild"
36
+ NO_MUTATION_REUSE = "no_mutation_reuse"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class SessionPolicy:
41
+ require_verified_native_patch: bool = True
42
+ require_backend_reuse_evidence: bool = True
43
+ fresh_backend_on_rebuild: bool = True
44
+ original_space_validation: bool = True
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class MutationDecision:
49
+ path: MutationExecutionPath
50
+ reason: str
51
+ mutation_kinds: tuple[MutationKind, ...] = ()
52
+ required_capabilities: tuple[CapabilityKey, ...] = ()
53
+ missing_capabilities: tuple[CapabilityKey, ...] = ()
54
+ semantic_changed: bool = False
55
+ execution_structure_changed: bool = False
56
+ compilation_plan_changed: bool = False
57
+ certificate_changed: bool = False
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class PersistentSessionTrace:
62
+ revision: int
63
+ path: MutationExecutionPath
64
+ decision: MutationDecision
65
+ compiler_cache_status: str
66
+ compile_s: float
67
+ dispatch_s: float
68
+ total_s: float
69
+ backend: str
70
+ backend_reuse_applied: bool | None
71
+ backend_reuse_mode: str | None
72
+ backend_reset: bool
73
+ original_validation_valid: bool | None
74
+ semantic_hash: str
75
+ data_hash: str
76
+ compilation_hash: str
77
+ transformation_count: int
78
+
79
+ def to_dict(self) -> dict[str, Any]:
80
+ return {
81
+ "revision": self.revision,
82
+ "path": self.path.value,
83
+ "decision": {
84
+ "path": self.decision.path.value,
85
+ "reason": self.decision.reason,
86
+ "mutation_kinds": [k.value for k in self.decision.mutation_kinds],
87
+ "required_capabilities": [k.value for k in self.decision.required_capabilities],
88
+ "missing_capabilities": [k.value for k in self.decision.missing_capabilities],
89
+ "semantic_changed": self.decision.semantic_changed,
90
+ "execution_structure_changed": self.decision.execution_structure_changed,
91
+ "compilation_plan_changed": self.decision.compilation_plan_changed,
92
+ "certificate_changed": self.decision.certificate_changed,
93
+ },
94
+ "compiler_cache_status": self.compiler_cache_status,
95
+ "compile_s": self.compile_s,
96
+ "dispatch_s": self.dispatch_s,
97
+ "total_s": self.total_s,
98
+ "backend": self.backend,
99
+ "backend_reuse_applied": self.backend_reuse_applied,
100
+ "backend_reuse_mode": self.backend_reuse_mode,
101
+ "backend_reset": self.backend_reset,
102
+ "original_validation_valid": self.original_validation_valid,
103
+ "semantic_hash": self.semantic_hash,
104
+ "data_hash": self.data_hash,
105
+ "compilation_hash": self.compilation_hash,
106
+ "transformation_count": self.transformation_count,
107
+ }
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class PersistentSolveOutcome:
112
+ result: SolveResult
113
+ compiled: CompiledModel
114
+ trace: PersistentSessionTrace
115
+ original_validation: Any | None = None
116
+
117
+ @property
118
+ def status(self):
119
+ return self.result.status
120
+
121
+ @property
122
+ def x(self):
123
+ return self.result.x
124
+
125
+ @property
126
+ def objective(self):
127
+ return self.result.objective
128
+
129
+
130
+ BackendFactory = Callable[[], Backend]
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class PersistentBackendBinding:
135
+ backend: Backend
136
+ capabilities: BackendCapabilityManifestV2
137
+ persistence_verified: bool
138
+ conformance: Any | None = None
139
+
140
+
141
+ def bind_persistent_backend(backend: Backend, *, verify: bool = True) -> PersistentBackendBinding:
142
+ if verify:
143
+ from .conformance_v2 import conform_persistent_backend
144
+ report = conform_persistent_backend(backend)
145
+ return PersistentBackendBinding(backend, report.manifest, report.passed, report)
146
+ manifest = resolve_backend_capabilities(backend, verify=True)
147
+ return PersistentBackendBinding(backend, manifest, False, None)
148
+
149
+
150
+ def _factory_from_dataclass_backend(backend: Backend) -> BackendFactory | None:
151
+ if not is_dataclass(backend):
152
+ return None
153
+ values: dict[str, Any] = {}
154
+ try:
155
+ for item in fields(backend):
156
+ if not item.init or item.name.startswith("_"):
157
+ continue
158
+ values[item.name] = copy.deepcopy(getattr(backend, item.name))
159
+ except Exception:
160
+ return None
161
+
162
+ def factory():
163
+ return type(backend)(**copy.deepcopy(values))
164
+
165
+ try:
166
+ probe = factory()
167
+ except Exception:
168
+ return None
169
+ close = getattr(probe, "close", None)
170
+ if callable(close):
171
+ try:
172
+ close()
173
+ except Exception:
174
+ pass
175
+ return factory
176
+
177
+
178
+ def _mutation_capability(kind: MutationKind) -> CapabilityKey | None:
179
+ return {
180
+ MutationKind.OBJECTIVE_VECTOR: CapabilityKey.INCREMENTAL_OBJECTIVE,
181
+ MutationKind.OBJECTIVE_OFFSET: CapabilityKey.INCREMENTAL_OBJECTIVE,
182
+ MutationKind.OBJECTIVE_SENSE: CapabilityKey.INCREMENTAL_OBJECTIVE,
183
+ MutationKind.OBJECTIVE_QUADRATIC_VALUES: CapabilityKey.INCREMENTAL_OBJECTIVE,
184
+ MutationKind.VARIABLE_BOUNDS: CapabilityKey.INCREMENTAL_VARIABLE_BOUNDS,
185
+ MutationKind.CONSTRAINT_BOUNDS: CapabilityKey.INCREMENTAL_RHS,
186
+ MutationKind.MATRIX_VALUES_SAME_SPARSITY: CapabilityKey.INCREMENTAL_MATRIX_VALUES,
187
+ }.get(kind)
188
+
189
+
190
+ _STRUCTURAL_KINDS = {
191
+ MutationKind.MATRIX_SPARSITY_CHANGED,
192
+ MutationKind.QUADRATIC_SPARSITY_CHANGED,
193
+ MutationKind.VARIABLES_ADDED_REMOVED,
194
+ MutationKind.CONSTRAINTS_ADDED_REMOVED,
195
+ MutationKind.INTEGRALITY_CHANGED,
196
+ }
197
+
198
+
199
+ def decide_mutation_path(
200
+ previous: CompiledModel | None,
201
+ current: CompiledModel,
202
+ capabilities: BackendCapabilityManifestV2,
203
+ *,
204
+ require_verified: bool = True,
205
+ ) -> tuple[MutationDecision, MutationRecord | None]:
206
+ if previous is None:
207
+ return MutationDecision(MutationExecutionPath.COLD_BUILD, "first solve has no bound backend state"), None
208
+
209
+ semantic_changed = previous.semantic_hash != current.semantic_hash
210
+ compilation_changed = previous.compilation_hash != current.compilation_hash
211
+ certificate_changed = previous.precondition_certificate_hashes != current.precondition_certificate_hashes
212
+
213
+ old = previous.execution_ir
214
+ new = current.execution_ir
215
+ if type(old) is not type(new):
216
+ return MutationDecision(
217
+ MutationExecutionPath.FULL_REBUILD,
218
+ "execution IR class changed",
219
+ semantic_changed=semantic_changed,
220
+ execution_structure_changed=True,
221
+ compilation_plan_changed=compilation_changed,
222
+ certificate_changed=certificate_changed,
223
+ ), None
224
+
225
+ record = classify_mutations(old, new, revision_before=0, revision_after=1)
226
+ kinds = record.kinds
227
+ if record.is_noop:
228
+ return MutationDecision(
229
+ MutationExecutionPath.NO_MUTATION_REUSE,
230
+ "execution data is unchanged; existing backend model may be solved again",
231
+ mutation_kinds=(),
232
+ semantic_changed=semantic_changed,
233
+ execution_structure_changed=False,
234
+ compilation_plan_changed=compilation_changed,
235
+ certificate_changed=certificate_changed,
236
+ ), record
237
+
238
+ if semantic_changed or any(kind in _STRUCTURAL_KINDS for kind in kinds):
239
+ return MutationDecision(
240
+ MutationExecutionPath.FULL_REBUILD,
241
+ "semantic or execution structure changed; P5 does not emulate structural incremental edits",
242
+ mutation_kinds=kinds,
243
+ semantic_changed=semantic_changed,
244
+ execution_structure_changed=record.structural_change,
245
+ compilation_plan_changed=compilation_changed,
246
+ certificate_changed=certificate_changed,
247
+ ), record
248
+
249
+ required_list: list[CapabilityKey] = [CapabilityKey.LIFECYCLE_PERSISTENT]
250
+ for kind in kinds:
251
+ cap = _mutation_capability(kind)
252
+ if cap is None:
253
+ return MutationDecision(
254
+ MutationExecutionPath.SAFE_REBUILD,
255
+ f"mutation {kind.value} has no verified P5 native-patch mapping",
256
+ mutation_kinds=kinds,
257
+ semantic_changed=semantic_changed,
258
+ execution_structure_changed=record.structural_change,
259
+ compilation_plan_changed=compilation_changed,
260
+ certificate_changed=certificate_changed,
261
+ ), record
262
+ if cap not in required_list:
263
+ required_list.append(cap)
264
+ required = tuple(required_list)
265
+ missing = tuple(
266
+ key for key in required
267
+ if not capabilities.check(key, require_verified=require_verified).usable
268
+ )
269
+ if missing:
270
+ return MutationDecision(
271
+ MutationExecutionPath.SAFE_REBUILD,
272
+ "same-structure update is correct but native patch lacks verified capability evidence",
273
+ mutation_kinds=kinds,
274
+ required_capabilities=required,
275
+ missing_capabilities=missing,
276
+ semantic_changed=semantic_changed,
277
+ execution_structure_changed=record.structural_change,
278
+ compilation_plan_changed=compilation_changed,
279
+ certificate_changed=certificate_changed,
280
+ ), record
281
+ return MutationDecision(
282
+ MutationExecutionPath.NATIVE_PATCH,
283
+ "all same-structure mutation operations and persistent lifecycle are runtime-verified",
284
+ mutation_kinds=kinds,
285
+ required_capabilities=required,
286
+ semantic_changed=semantic_changed,
287
+ execution_structure_changed=record.structural_change,
288
+ compilation_plan_changed=compilation_changed,
289
+ certificate_changed=certificate_changed,
290
+ ), record
291
+
292
+
293
+ class PersistentSession:
294
+ """Bind a semantic model to one backend lifecycle with fail-closed mutation routing.
295
+
296
+ P5 owns the *execution decision* only. P2 remains responsible for semantic/parameter
297
+ recompilation, P4 remains responsible for reformulation certificates, and the backend
298
+ remains responsible for the native numerical update implementation.
299
+ """
300
+
301
+ def __init__(
302
+ self,
303
+ model: Model,
304
+ backend: Backend,
305
+ *,
306
+ capabilities: BackendCapabilityManifestV2 | None = None,
307
+ backend_factory: BackendFactory | None = None,
308
+ bridge_policy=None,
309
+ policy: SessionPolicy | None = None,
310
+ ) -> None:
311
+ if not isinstance(model, Model):
312
+ raise TypeError("PersistentSession requires an solverpilot.model.Model")
313
+ self.model = model
314
+ self.backend = backend
315
+ self.capabilities = capabilities or resolve_backend_capabilities(backend, verify=True)
316
+ if self.capabilities.backend != backend.manifest.name:
317
+ raise PersistentSessionError("capability manifest backend identity does not match bound backend")
318
+ if (
319
+ self.capabilities.backend_version is not None
320
+ and backend.manifest.version is not None
321
+ and self.capabilities.backend_version != backend.manifest.version
322
+ ):
323
+ raise PersistentSessionError("capability manifest backend version does not match bound backend")
324
+ self.backend_factory = backend_factory or _factory_from_dataclass_backend(backend)
325
+ self.bridge_policy = bridge_policy
326
+ self.policy = policy or SessionPolicy()
327
+ self._compiled: CompiledModel | None = None
328
+ self._last_outcome: PersistentSolveOutcome | None = None
329
+ self._revision = 0
330
+ self._history: list[PersistentSessionTrace] = []
331
+ self._closed = False
332
+
333
+ @classmethod
334
+ def from_binding(cls, model: Model, binding: PersistentBackendBinding, **kwargs):
335
+ return cls(model, binding.backend, capabilities=binding.capabilities, **kwargs)
336
+
337
+ @classmethod
338
+ def bind(cls, model: Model, backend: Backend, *, verify: bool = True, **kwargs):
339
+ return cls.from_binding(model, bind_persistent_backend(backend, verify=verify), **kwargs)
340
+
341
+ @property
342
+ def revision(self) -> int:
343
+ return self._revision
344
+
345
+ @property
346
+ def history(self) -> tuple[PersistentSessionTrace, ...]:
347
+ return tuple(self._history)
348
+
349
+ @property
350
+ def last_outcome(self) -> PersistentSolveOutcome | None:
351
+ return self._last_outcome
352
+
353
+ @property
354
+ def compiled(self) -> CompiledModel | None:
355
+ return self._compiled
356
+
357
+ def set_parameter(self, parameter: Parameter, value: Any) -> None:
358
+ if not isinstance(parameter, Parameter) or parameter._model is not self.model:
359
+ raise TypeError("parameter must belong to this PersistentSession model")
360
+ parameter.value = value
361
+
362
+ def set_parameter_by_name(self, name: str, value: Any) -> None:
363
+ matches = [p for p in self.model.parameters if p.name == name]
364
+ if len(matches) != 1:
365
+ raise KeyError(f"parameter name must resolve uniquely: {name!r}")
366
+ matches[0].value = value
367
+
368
+ def _replace_backend_fresh(self) -> bool:
369
+ old = self.backend
370
+ if self.backend_factory is not None:
371
+ close = getattr(old, "close", None)
372
+ if callable(close):
373
+ try:
374
+ close()
375
+ except Exception:
376
+ pass
377
+ self.backend = self.backend_factory()
378
+ return True
379
+ close = getattr(old, "close", None)
380
+ if callable(close):
381
+ close()
382
+ return True
383
+ if self.policy.fresh_backend_on_rebuild:
384
+ raise PersistentSessionError(
385
+ "safe rebuild requires a backend factory or a backend exposing close(); "
386
+ "provide backend_factory explicitly"
387
+ )
388
+ return False
389
+
390
+ def solve(self) -> PersistentSolveOutcome:
391
+ if self._closed:
392
+ raise PersistentSessionError("PersistentSession is closed")
393
+ total_t0 = perf_counter()
394
+ compile_t0 = perf_counter()
395
+ compiled = self.model.compile(
396
+ use_cache=True,
397
+ bridge_policy=self.bridge_policy,
398
+ capabilities=self.capabilities,
399
+ )
400
+ compile_s = perf_counter() - compile_t0
401
+ decision, _ = decide_mutation_path(
402
+ self._compiled,
403
+ compiled,
404
+ self.capabilities,
405
+ require_verified=self.policy.require_verified_native_patch,
406
+ )
407
+
408
+ backend_reset = False
409
+ if decision.path in {MutationExecutionPath.SAFE_REBUILD, MutationExecutionPath.FULL_REBUILD}:
410
+ backend_reset = self._replace_backend_fresh()
411
+ # A fresh backend object may have identical runtime identity; capabilities remain
412
+ # valid only for the exact version signature already bound to this session.
413
+ fresh_caps = resolve_backend_capabilities(self.backend, verify=False)
414
+ if (
415
+ fresh_caps.backend != self.capabilities.backend
416
+ or fresh_caps.backend_version != self.capabilities.backend_version
417
+ ):
418
+ raise PersistentSessionError("fresh backend runtime identity drifted from bound capability evidence")
419
+
420
+ dispatch_t0 = perf_counter()
421
+ result = execute(compiled.execution_ir, self.backend)
422
+ dispatch_s = perf_counter() - dispatch_t0
423
+ raw = result.raw_statistics or {}
424
+ reuse_raw = raw.get("reuse_applied")
425
+ reuse_applied = reuse_raw if isinstance(reuse_raw, bool) else None
426
+ reuse_mode = None if raw.get("reuse_mode") is None else str(raw.get("reuse_mode"))
427
+
428
+ if decision.path is MutationExecutionPath.NATIVE_PATCH and self.policy.require_backend_reuse_evidence:
429
+ if reuse_applied is not True:
430
+ raise ReuseEvidenceMismatchError(
431
+ "P5 selected native_patch from verified capabilities, but backend did not report applied reuse"
432
+ )
433
+ if decision.path in {MutationExecutionPath.COLD_BUILD, MutationExecutionPath.SAFE_REBUILD, MutationExecutionPath.FULL_REBUILD}:
434
+ if backend_reset or decision.path is MutationExecutionPath.COLD_BUILD:
435
+ if reuse_applied is True:
436
+ raise ReuseEvidenceMismatchError(
437
+ "fresh/cold backend unexpectedly reported reuse; lifecycle evidence is inconsistent"
438
+ )
439
+
440
+ original_validation = None
441
+ original_valid = None
442
+ if self.policy.original_space_validation and result.x is not None:
443
+ original_validation = compiled.validate_original(self.model, result.x)
444
+ original_valid = bool(original_validation.valid)
445
+ if not original_valid:
446
+ raise PersistentSessionError("backend candidate failed P5 original-space semantic validation")
447
+
448
+ previous_data = None if self._compiled is None else self._compiled.data_hash
449
+ if previous_data is not None and previous_data != compiled.data_hash:
450
+ self._revision += 1
451
+ elif self._compiled is not None and self._compiled.semantic_hash != compiled.semantic_hash:
452
+ self._revision += 1
453
+
454
+ trace = PersistentSessionTrace(
455
+ revision=self._revision,
456
+ path=decision.path,
457
+ decision=decision,
458
+ compiler_cache_status=compiled.compilation_report.cache_status,
459
+ compile_s=float(compile_s),
460
+ dispatch_s=float(dispatch_s),
461
+ total_s=float(perf_counter() - total_t0),
462
+ backend=self.backend.manifest.name,
463
+ backend_reuse_applied=reuse_applied,
464
+ backend_reuse_mode=reuse_mode,
465
+ backend_reset=backend_reset,
466
+ original_validation_valid=original_valid,
467
+ semantic_hash=compiled.semantic_hash,
468
+ data_hash=compiled.data_hash,
469
+ compilation_hash=compiled.compilation_hash,
470
+ transformation_count=len(compiled.transformation_tape),
471
+ )
472
+ outcome = PersistentSolveOutcome(result=result, compiled=compiled, trace=trace, original_validation=original_validation)
473
+ self._compiled = compiled
474
+ self._last_outcome = outcome
475
+ self._history.append(trace)
476
+ return outcome
477
+
478
+ def close(self) -> None:
479
+ if self._closed:
480
+ return
481
+ close = getattr(self.backend, "close", None)
482
+ if callable(close):
483
+ close()
484
+ self._closed = True
485
+
486
+ def __enter__(self):
487
+ return self
488
+
489
+ def __exit__(self, exc_type, exc, tb):
490
+ self.close()
491
+ return False
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from solverpilot.capabilities import BackendManifest, Capability
6
+ from solverpilot.problem import LinearProblem, QuadraticProblem
7
+
8
+ from .mutations import MutationKind, MutationRecord
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ReuseAssessment:
13
+ backend: str
14
+ considered: tuple[Capability, ...]
15
+ supported: tuple[Capability, ...]
16
+ safe_candidate: bool
17
+ applied: bool
18
+ reason: str
19
+
20
+
21
+ def assess_reuse(
22
+ problem: LinearProblem | QuadraticProblem,
23
+ manifest: BackendManifest,
24
+ mutations: tuple[MutationRecord, ...],
25
+ ) -> ReuseAssessment:
26
+ """Assess *potential* reuse from declared capabilities.
27
+
28
+ This function never claims reuse was applied. Applying solver state requires a
29
+ backend-specific stateful interface, which M1 deliberately does not fake.
30
+ """
31
+
32
+ kinds = {kind for record in mutations for kind in record.kinds}
33
+ if not mutations or not kinds:
34
+ return ReuseAssessment(
35
+ backend=manifest.name,
36
+ considered=(),
37
+ supported=(),
38
+ safe_candidate=False,
39
+ applied=False,
40
+ reason="no model mutation since previous solve",
41
+ )
42
+
43
+ structural_kinds = {
44
+ MutationKind.VARIABLES_ADDED_REMOVED,
45
+ MutationKind.CONSTRAINTS_ADDED_REMOVED,
46
+ MutationKind.MATRIX_SPARSITY_CHANGED,
47
+ MutationKind.QUADRATIC_SPARSITY_CHANGED,
48
+ MutationKind.INTEGRALITY_CHANGED,
49
+ }
50
+ if kinds & structural_kinds:
51
+ considered = (
52
+ Capability.STRUCTURAL_INCREMENTAL_UPDATE,
53
+ Capability.NATIVE_REOPTIMIZATION,
54
+ )
55
+ else:
56
+ candidates: list[Capability] = [Capability.SAME_SPARSITY_DATA_UPDATE]
57
+ if isinstance(problem, QuadraticProblem):
58
+ candidates.extend([Capability.PRIMAL_START, Capability.DUAL_START])
59
+ elif problem.has_integer_variables:
60
+ candidates.append(Capability.MIP_START)
61
+ else:
62
+ candidates.extend([Capability.BASIS_START, Capability.PRIMAL_START])
63
+ candidates.append(Capability.NATIVE_REOPTIMIZATION)
64
+ considered = tuple(candidates)
65
+
66
+ supported = tuple(
67
+ cap for cap in considered if manifest.supports_without_risky_emulation(cap)
68
+ )
69
+ return ReuseAssessment(
70
+ backend=manifest.name,
71
+ considered=considered,
72
+ supported=supported,
73
+ safe_candidate=bool(supported),
74
+ applied=False,
75
+ reason=(
76
+ "backend declares potentially reusable mechanisms; execution evidence is required "
77
+ "before reuse is marked applied"
78
+ if supported
79
+ else "backend declares no compatible reuse mechanism for these mutations"
80
+ ),
81
+ )