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,193 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, replace
4
+ from time import perf_counter
5
+
6
+ from solverpilot.diagnose import diagnose_infeasibility
7
+ from solverpilot.validate import PublicStatus
8
+ from solverpilot.backends import (
9
+ Backend,
10
+ BackendRegistry,
11
+ BackendHealthReport,
12
+ ScipyHighsBackend,
13
+ ScipyHighsLPBackend,
14
+ ScipySLSQPQPBackend,
15
+ HighspyNativeBackend,
16
+ OSQPNativeBackend,
17
+ PySCIPOptNativeBackend,
18
+ NLoptNativeBackend,
19
+ CasadiOSQPBridgeBackend,
20
+ CasadiHighsBridgeBackend,
21
+ CasadiCBCBridgeBackend,
22
+ BundledOSQPCAPIBackend,
23
+ BundledHighsCAPIBackend,
24
+ )
25
+ from solverpilot.inspect import inspect_problem
26
+ from solverpilot.plan import HealthPolicy, PlannerContext, SolveBudget, SolveIntent, plan_solve, PerformancePolicy, ProductionEvidence, M22_OFFICIAL_EVIDENCE, ProductionDecision, plan_production_solve
27
+ from solverpilot.problem import LinearProblem, QuadraticProblem
28
+ from solverpilot.trace import PhaseTimings
29
+
30
+ from .budgeting import apply_budget
31
+ from .executor import execute
32
+ from .result import SolveResult
33
+
34
+
35
+ def builtin_backend_candidates() -> tuple[Backend, ...]:
36
+ """Return all supported adapter candidates, including unavailable/verification-only ones.
37
+
38
+ This audit surface is intentionally broader than ``default_registry``: verification
39
+ bridges and unavailable optional native packages remain visible for provenance and
40
+ health checks, while production solving still excludes verification-only adapters.
41
+ """
42
+
43
+ return (
44
+ HighspyNativeBackend(),
45
+ OSQPNativeBackend(),
46
+ PySCIPOptNativeBackend(),
47
+ ScipyHighsLPBackend(method="highs-ds"),
48
+ ScipyHighsLPBackend(method="highs-ipm"),
49
+ ScipyHighsBackend(),
50
+ ScipySLSQPQPBackend(),
51
+ NLoptNativeBackend(),
52
+ CasadiOSQPBridgeBackend(),
53
+ CasadiHighsBridgeBackend(),
54
+ CasadiCBCBridgeBackend(),
55
+ BundledOSQPCAPIBackend(),
56
+ BundledHighsCAPIBackend(),
57
+ )
58
+
59
+
60
+ def default_registry() -> BackendRegistry:
61
+ """Create the conservative built-in runtime registry.
62
+
63
+ The registry contains only adapters that can actually be imported in the base
64
+ dependency set. Native highspy/OSQP/SCIP backends are intentionally not
65
+ invented when their dependencies are absent.
66
+ """
67
+
68
+ registry = BackendRegistry()
69
+ for backend in builtin_backend_candidates():
70
+ # Optional native adapters remain absent from the solving registry when their
71
+ # dependency is absent. ``builtin_backend_candidates`` is the explicit audit surface.
72
+ if backend.manifest.metadata.get("native_adapter") and not backend.is_available():
73
+ continue
74
+ if backend.manifest.metadata.get("verification_only") is True:
75
+ continue
76
+ registry.register(backend)
77
+ return registry
78
+
79
+
80
+ def solve(
81
+ problem: LinearProblem | QuadraticProblem,
82
+ *,
83
+ registry: BackendRegistry | None = None,
84
+ backend: str | Backend | None = None,
85
+ intent: SolveIntent | str = SolveIntent.BALANCED,
86
+ budget: SolveBudget | None = None,
87
+ context: PlannerContext | None = None,
88
+ health_reports: tuple[BackendHealthReport, ...] | None = None,
89
+ health_policy: HealthPolicy | str = HealthPolicy.IGNORE,
90
+ diagnose_infeasible: bool = False,
91
+ ) -> SolveResult:
92
+ """Solve a problem either with an explicit backend or the deterministic planner."""
93
+
94
+ total_t0 = perf_counter()
95
+ registry = default_registry() if registry is None else registry
96
+
97
+ inspect_t0 = perf_counter()
98
+ fingerprint = inspect_problem(problem)
99
+ inspect_s = perf_counter() - inspect_t0
100
+
101
+ plan_s = 0.0
102
+ plan = None
103
+ if backend is None:
104
+ plan_t0 = perf_counter()
105
+ plan = plan_solve(
106
+ problem,
107
+ registry,
108
+ intent=intent,
109
+ budget=budget,
110
+ fingerprint=fingerprint,
111
+ context=context,
112
+ health_reports=health_reports,
113
+ health_policy=health_policy,
114
+ )
115
+ plan_s = perf_counter() - plan_t0
116
+ chosen = registry.get(plan.selected_backend)
117
+ elif isinstance(backend, str):
118
+ chosen = registry.get(backend)
119
+ else:
120
+ chosen = backend
121
+
122
+ chosen = apply_budget(chosen, budget)
123
+ result = execute(problem, chosen)
124
+
125
+ diagnose_s = 0.0
126
+ diagnostics = None
127
+ if diagnose_infeasible and result.status is PublicStatus.INFEASIBLE and isinstance(problem, LinearProblem):
128
+ diagnose_t0 = perf_counter()
129
+ diagnostics = diagnose_infeasibility(
130
+ problem,
131
+ backend=chosen,
132
+ time_limit_s=None if budget is None else budget.wall_time_s,
133
+ )
134
+ diagnose_s = perf_counter() - diagnose_t0
135
+ result = replace(result, diagnostics=diagnostics)
136
+
137
+ total_s = perf_counter() - total_t0
138
+ old = result.trace.timings
139
+ timings = PhaseTimings(
140
+ inspect_s=inspect_s,
141
+ plan_s=plan_s,
142
+ backend_build_s=old.backend_build_s,
143
+ solve_s=old.solve_s,
144
+ validate_s=old.validate_s,
145
+ diagnose_s=old.diagnose_s + diagnose_s,
146
+ total_s=total_s,
147
+ )
148
+ trace = replace(
149
+ result.trace,
150
+ timings=timings,
151
+ fingerprint=asdict(fingerprint),
152
+ planner_selected_backend=None if plan is None else plan.selected_backend,
153
+ planner_evidence_level=None if plan is None else plan.evidence_level,
154
+ planner_health_policy=None if plan is None else plan.health_policy.value,
155
+ )
156
+ return replace(result, trace=trace, plan=plan)
157
+
158
+
159
+ # Backward-compatible internal alias retained for M1-M7 regression tests and callers
160
+ # that imported the previous private helper. New code should import apply_budget from
161
+ # solverpilot.runtime.
162
+ _apply_budget = apply_budget
163
+
164
+
165
+ def solve_production(
166
+ problem: LinearProblem | QuadraticProblem,
167
+ *,
168
+ registry: BackendRegistry | None = None,
169
+ intent: SolveIntent | str = SolveIntent.BALANCED,
170
+ budget: SolveBudget | None = None,
171
+ context: PlannerContext | None = None,
172
+ health_reports: tuple[BackendHealthReport, ...] | None = None,
173
+ health_policy: HealthPolicy | str = HealthPolicy.PREFER_HEALTHY,
174
+ evidence: ProductionEvidence = M22_OFFICIAL_EVIDENCE,
175
+ performance_policy: PerformancePolicy | str = PerformancePolicy.REQUIRE_COMPARATIVE,
176
+ performance_override: dict[str, str] | None = None,
177
+ diagnose_infeasible: bool = False,
178
+ ) -> tuple[SolveResult, ProductionDecision]:
179
+ """Conservative proof-safe solve plus the auditable production routing decision."""
180
+ registry = default_registry() if registry is None else registry
181
+ fingerprint = inspect_problem(problem)
182
+ decision = plan_production_solve(
183
+ problem, registry, evidence=evidence, intent=intent, budget=budget,
184
+ fingerprint=fingerprint, context=context, health_reports=health_reports,
185
+ health_policy=health_policy, performance_policy=performance_policy,
186
+ performance_override=performance_override,
187
+ )
188
+ result = solve(
189
+ problem, registry=registry, backend=decision.plan.selected_backend, intent=intent,
190
+ budget=budget, context=context, health_reports=health_reports,
191
+ health_policy=health_policy, diagnose_infeasible=diagnose_infeasible,
192
+ )
193
+ return replace(result, plan=decision.plan), decision
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import is_dataclass, replace
4
+
5
+ from solverpilot.backends import Backend
6
+ from solverpilot.plan import SolveBudget
7
+ from solverpilot.exceptions import BudgetNotSupportedError
8
+
9
+
10
+ def apply_budget(backend: Backend, budget: SolveBudget | None) -> Backend:
11
+ if budget is None:
12
+ return backend
13
+ if budget.memory_mb is not None:
14
+ raise BudgetNotSupportedError(
15
+ "memory budgets are modeled but are not yet enforceable by built-in backends"
16
+ )
17
+
18
+ updates: dict[str, object] = {}
19
+ if budget.wall_time_s is not None:
20
+ if backend.manifest.metadata.get("supports_wall_time_budget") is False:
21
+ raise BudgetNotSupportedError(
22
+ f"backend {backend.manifest.name!r} has no verified wall-time budget mapping"
23
+ )
24
+ if not hasattr(backend, "time_limit_s"):
25
+ raise BudgetNotSupportedError(
26
+ f"backend {backend.manifest.name!r} cannot enforce a wall-time budget"
27
+ )
28
+ updates["time_limit_s"] = float(budget.wall_time_s)
29
+
30
+ if budget.threads is not None:
31
+ if not hasattr(backend, "threads"):
32
+ raise BudgetNotSupportedError(
33
+ f"backend {backend.manifest.name!r} cannot enforce a thread budget"
34
+ )
35
+ updates["threads"] = int(budget.threads)
36
+
37
+ if not updates:
38
+ return backend
39
+ if not is_dataclass(backend):
40
+ raise TypeError(
41
+ f"backend {backend.manifest.name!r} exposes budget fields but is not safely cloneable"
42
+ )
43
+ return replace(backend, **updates)
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+ from time import perf_counter
5
+
6
+ from solverpilot.backends import Backend
7
+ from solverpilot.exceptions import BackendUnavailableError, CapabilityMismatchError
8
+ from solverpilot.capabilities import compatible, requirements_for
9
+ from solverpilot.problem import LinearProblem, QuadraticProblem
10
+ from solverpilot.trace import PhaseTimings, SolveTrace
11
+ from solverpilot.validate import CandidateSolution, PublicStatus, validate_solution
12
+
13
+ from .result import OptimalityEvidence, SolveResult
14
+
15
+
16
+ def _problem_class(problem: LinearProblem | QuadraticProblem) -> str:
17
+ if isinstance(problem, QuadraticProblem):
18
+ return "convex_qp"
19
+ return "milp" if problem.has_integer_variables else "lp"
20
+
21
+
22
+ def _normalize_status(backend_status: str, validation_valid: bool | None) -> PublicStatus:
23
+ if validation_valid is False:
24
+ return PublicStatus.INVALID_SOLUTION
25
+ if backend_status == "optimal":
26
+ return PublicStatus.VALID_OPTIMAL if validation_valid else PublicStatus.ERROR
27
+ if backend_status == "converged_candidate":
28
+ return PublicStatus.VALID_FEASIBLE if validation_valid else PublicStatus.ERROR
29
+ if backend_status == "limit_feasible":
30
+ return PublicStatus.FEASIBLE_LIMIT if validation_valid else PublicStatus.ERROR
31
+ if backend_status == "infeasible":
32
+ return PublicStatus.INFEASIBLE
33
+ if backend_status == "unbounded":
34
+ return PublicStatus.UNBOUNDED
35
+ if backend_status == "infeasible_or_unbounded":
36
+ return PublicStatus.INFEASIBLE_OR_UNBOUNDED
37
+ if backend_status in {"solver_error", "limit_no_solution"}:
38
+ return PublicStatus.ERROR
39
+ return PublicStatus.UNKNOWN
40
+
41
+
42
+ def execute(
43
+ problem: LinearProblem | QuadraticProblem,
44
+ backend: Backend,
45
+ ) -> SolveResult:
46
+ total_t0 = perf_counter()
47
+ if not backend.is_available():
48
+ raise BackendUnavailableError(f"backend is unavailable: {backend.manifest.name}")
49
+ requirements = requirements_for(problem)
50
+ if not compatible(backend.manifest, requirements):
51
+ raise CapabilityMismatchError(
52
+ f"backend {backend.manifest.name!r} is incompatible with "
53
+ f"required capabilities {sorted(x.value for x in requirements.required)}"
54
+ )
55
+
56
+ solve_t0 = perf_counter()
57
+ backend_result = backend.solve(problem)
58
+ solve_s = perf_counter() - solve_t0
59
+
60
+ validation = None
61
+ validate_s = 0.0
62
+ if backend_result.x is not None:
63
+ validate_t0 = perf_counter()
64
+ validation = validate_solution(
65
+ problem,
66
+ CandidateSolution(
67
+ x=backend_result.x,
68
+ objective_reported=backend_result.objective_reported,
69
+ ),
70
+ )
71
+ validate_s = perf_counter() - validate_t0
72
+
73
+ public_status = _normalize_status(
74
+ backend_result.backend_status,
75
+ None if validation is None else validation.valid,
76
+ )
77
+ total_s = perf_counter() - total_t0
78
+
79
+ raw = backend_result.raw_statistics or {}
80
+ reuse_applied_raw = raw.get("reuse_applied")
81
+ reuse_applied = reuse_applied_raw if isinstance(reuse_applied_raw, bool) else None
82
+ reuse_mode_raw = raw.get("reuse_mode")
83
+ reuse_mode = str(reuse_mode_raw) if reuse_mode_raw is not None else None
84
+
85
+ trace = SolveTrace(
86
+ problem_structural_hash=problem.structural_hash,
87
+ problem_data_hash=problem.data_hash,
88
+ problem_class=_problem_class(problem),
89
+ backend=backend.manifest.name,
90
+ backend_version=backend.manifest.version,
91
+ timings=PhaseTimings(solve_s=solve_s, validate_s=validate_s, total_s=total_s),
92
+ termination=backend_result.backend_status,
93
+ validation_valid=None if validation is None else validation.valid,
94
+ reuse_applied=reuse_applied,
95
+ reuse_mode=reuse_mode,
96
+ warnings=() if validation is None else validation.warnings,
97
+ )
98
+
99
+ objective = None if validation is None else validation.objective_recomputed
100
+ raw_stats = dict(backend_result.raw_statistics or {})
101
+ evidence = OptimalityEvidence(
102
+ backend_reported_optimal=backend_result.backend_status == "optimal",
103
+ primal_validated=bool(validation is not None and validation.valid),
104
+ # Backend statistics are claims, not an independently checked proof.
105
+ # Keep them in raw_stats, but only a canonical verifier may promote them.
106
+ dual_verified=False,
107
+ gap_verified=False,
108
+ certificate_verified=False,
109
+ )
110
+ raw_stats["solverpilot_trust"] = {
111
+ "backend_reported_optimal": evidence.backend_reported_optimal,
112
+ "primal_validated": evidence.primal_validated,
113
+ "dual_verified": evidence.dual_verified,
114
+ "gap_verified": evidence.gap_verified,
115
+ "certificate_verified": evidence.certificate_verified,
116
+ "independently_verified_optimal": evidence.independently_verified_optimal,
117
+ }
118
+ return SolveResult(
119
+ status=public_status,
120
+ x=backend_result.x,
121
+ objective=objective,
122
+ backend_status=backend_result.backend_status,
123
+ validation=validation,
124
+ trace=trace,
125
+ raw_statistics=raw_stats,
126
+ )
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from time import perf_counter
5
+ from typing import Iterable
6
+
7
+ from solverpilot.backends import Backend, BackendHealthReport, BackendProbeStatus
8
+ from solverpilot.plan import SolveBudget
9
+ from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem
10
+ from solverpilot.validate import PublicStatus
11
+
12
+ from .budgeting import apply_budget
13
+ from .executor import execute
14
+ from .result import SolveResult
15
+
16
+
17
+ _VALID_SOLUTION_STATUSES = {
18
+ PublicStatus.VALID_OPTIMAL,
19
+ PublicStatus.VALID_FEASIBLE,
20
+ PublicStatus.FEASIBLE_LIMIT,
21
+ }
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class PortfolioAttempt:
26
+ backend: str
27
+ status: str | None
28
+ objective: float | None
29
+ validation_valid: bool | None
30
+ duration_s: float
31
+ error: str | None = None
32
+ skipped_reason: str | None = None
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class PortfolioSolveResult:
37
+ chosen: SolveResult | None
38
+ attempts: tuple[PortfolioAttempt, ...]
39
+ total_s: float
40
+ stopped_reason: str
41
+ budget_exhausted: bool
42
+
43
+ @property
44
+ def chosen_backend(self) -> str | None:
45
+ return None if self.chosen is None else self.chosen.trace.backend
46
+
47
+
48
+ def _better(
49
+ problem: LinearProblem | QuadraticProblem,
50
+ incumbent: SolveResult | None,
51
+ candidate: SolveResult,
52
+ ) -> bool:
53
+ if candidate.status not in _VALID_SOLUTION_STATUSES or candidate.objective is None:
54
+ return False
55
+ if incumbent is None or incumbent.status not in _VALID_SOLUTION_STATUSES or incumbent.objective is None:
56
+ return True
57
+ if problem.objective_sense is ObjectiveSense.MINIMIZE:
58
+ return float(candidate.objective) < float(incumbent.objective)
59
+ return float(candidate.objective) > float(incumbent.objective)
60
+
61
+
62
+ def _health_map(reports: Iterable[BackendHealthReport] | None) -> dict[str, BackendHealthReport]:
63
+ return {} if reports is None else {r.backend: r for r in reports}
64
+
65
+
66
+ def execute_portfolio(
67
+ problem: LinearProblem | QuadraticProblem,
68
+ backends: Iterable[Backend],
69
+ *,
70
+ budget: SolveBudget | None = None,
71
+ stop_on_valid_optimal: bool = True,
72
+ health_reports: Iterable[BackendHealthReport] | None = None,
73
+ require_healthy: bool = False,
74
+ ) -> PortfolioSolveResult:
75
+ """Execute a sequential validated backend portfolio.
76
+
77
+ The portfolio is deliberately conservative: every candidate goes through the same
78
+ capability checks and independent validator as :func:`execute`; exceptions are
79
+ isolated to one attempt. If a global wall-time budget is supplied, remaining time
80
+ is passed to each backend and backends that cannot enforce it are skipped rather
81
+ than silently violating the budget.
82
+
83
+ This is an execution/fallback primitive, not an empirical solver-selection policy.
84
+ """
85
+
86
+ t0 = perf_counter()
87
+ attempts: list[PortfolioAttempt] = []
88
+ chosen: SolveResult | None = None
89
+ terminal_without_solution: list[SolveResult] = []
90
+ health = _health_map(health_reports)
91
+ stopped_reason = "portfolio_exhausted"
92
+ budget_exhausted = False
93
+
94
+ for backend in tuple(backends):
95
+ name = backend.manifest.name
96
+ report = health.get(name)
97
+ if report is not None and report.version != backend.manifest.version:
98
+ report = None
99
+ if report is not None and report.status in {BackendProbeStatus.UNHEALTHY, BackendProbeStatus.UNAVAILABLE}:
100
+ attempts.append(
101
+ PortfolioAttempt(
102
+ backend=name,
103
+ status=None,
104
+ objective=None,
105
+ validation_valid=None,
106
+ duration_s=0.0,
107
+ skipped_reason=f"health:{report.status.value}",
108
+ )
109
+ )
110
+ continue
111
+ if require_healthy and (report is None or report.status is not BackendProbeStatus.HEALTHY):
112
+ attempts.append(
113
+ PortfolioAttempt(
114
+ backend=name,
115
+ status=None,
116
+ objective=None,
117
+ validation_valid=None,
118
+ duration_s=0.0,
119
+ skipped_reason="health:not_confirmed_healthy",
120
+ )
121
+ )
122
+ continue
123
+
124
+ elapsed = perf_counter() - t0
125
+ remaining: float | None = None
126
+ if budget is not None and budget.wall_time_s is not None:
127
+ remaining = float(budget.wall_time_s) - elapsed
128
+ if remaining <= 0:
129
+ budget_exhausted = True
130
+ stopped_reason = "budget_exhausted"
131
+ break
132
+
133
+ attempt_budget = budget
134
+ if budget is not None and remaining is not None:
135
+ attempt_budget = SolveBudget(
136
+ wall_time_s=remaining,
137
+ memory_mb=budget.memory_mb,
138
+ threads=budget.threads,
139
+ )
140
+
141
+ a0 = perf_counter()
142
+ try:
143
+ configured = apply_budget(backend, attempt_budget)
144
+ result = execute(problem, configured)
145
+ duration = perf_counter() - a0
146
+ attempts.append(
147
+ PortfolioAttempt(
148
+ backend=name,
149
+ status=result.status.value,
150
+ objective=None if result.objective is None else float(result.objective),
151
+ validation_valid=None if result.validation is None else bool(result.validation.valid),
152
+ duration_s=duration,
153
+ )
154
+ )
155
+
156
+ if _better(problem, chosen, result):
157
+ chosen = result
158
+ elif chosen is None and result.status in {
159
+ PublicStatus.INFEASIBLE,
160
+ PublicStatus.UNBOUNDED,
161
+ PublicStatus.INFEASIBLE_OR_UNBOUNDED,
162
+ }:
163
+ terminal_without_solution.append(result)
164
+
165
+ if stop_on_valid_optimal and result.status is PublicStatus.VALID_OPTIMAL:
166
+ chosen = result
167
+ stopped_reason = "valid_optimal_found"
168
+ break
169
+ except Exception as exc:
170
+ attempts.append(
171
+ PortfolioAttempt(
172
+ backend=name,
173
+ status=None,
174
+ objective=None,
175
+ validation_valid=None,
176
+ duration_s=perf_counter() - a0,
177
+ error=f"{type(exc).__name__}: {exc}",
178
+ )
179
+ )
180
+ continue
181
+
182
+ if chosen is None and terminal_without_solution:
183
+ terminal_statuses = {r.status for r in terminal_without_solution}
184
+ if len(terminal_statuses) == 1:
185
+ chosen = terminal_without_solution[0]
186
+ else:
187
+ stopped_reason = "conflicting_terminal_statuses"
188
+ total_s = perf_counter() - t0
189
+ if not attempts:
190
+ stopped_reason = "no_attempts"
191
+ return PortfolioSolveResult(
192
+ chosen=chosen,
193
+ attempts=tuple(attempts),
194
+ total_s=total_s,
195
+ stopped_reason=stopped_reason,
196
+ budget_exhausted=budget_exhausted,
197
+ )
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+
9
+ from solverpilot._immutability import deep_freeze, readonly_array
10
+ from solverpilot.plan import SolvePlan
11
+ from solverpilot.trace import SolveTrace
12
+ from solverpilot.validate import PublicStatus, ValidationReport
13
+
14
+ if TYPE_CHECKING:
15
+ from solverpilot.diagnose import InfeasibilityReport
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class OptimalityEvidence:
20
+ """Trust metadata for an apparent optimal termination.
21
+
22
+ ``VALID_OPTIMAL`` remains a compatibility status meaning the backend reported
23
+ optimality and the primal candidate passed canonical validation. It does not
24
+ imply that SolverPilot independently reconstructed a complete optimality proof.
25
+ """
26
+
27
+ backend_reported_optimal: bool = False
28
+ primal_validated: bool = False
29
+ dual_verified: bool = False
30
+ gap_verified: bool = False
31
+ certificate_verified: bool = False
32
+
33
+ @property
34
+ def independently_verified_optimal(self) -> bool:
35
+ return bool(self.primal_validated and self.dual_verified and (self.gap_verified or self.certificate_verified))
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class SolveResult:
40
+ status: PublicStatus
41
+ x: np.ndarray | None
42
+ objective: float | None
43
+ backend_status: str
44
+ validation: ValidationReport | None
45
+ trace: SolveTrace
46
+ raw_statistics: dict[str, object] | None = None
47
+ plan: SolvePlan | None = None
48
+ session_revision: int | None = None
49
+ diagnostics: "InfeasibilityReport | None" = None
50
+ _optimality_evidence: OptimalityEvidence = field(init=False, repr=False, compare=False)
51
+
52
+ def __post_init__(self) -> None:
53
+ if self.x is not None:
54
+ object.__setattr__(self, "x", readonly_array(self.x, dtype=np.float64))
55
+ frozen_raw = None if self.raw_statistics is None else deep_freeze(self.raw_statistics)
56
+ object.__setattr__(self, "raw_statistics", frozen_raw)
57
+
58
+ raw = frozen_raw or {}
59
+ trust = raw.get("solverpilot_trust", {})
60
+ if not isinstance(trust, Mapping):
61
+ trust = {}
62
+ object.__setattr__(self, "_optimality_evidence", OptimalityEvidence(
63
+ backend_reported_optimal=bool(trust.get("backend_reported_optimal", self.backend_status == "optimal")),
64
+ primal_validated=bool(trust.get("primal_validated", self.validation is not None and self.validation.valid)),
65
+ dual_verified=bool(trust.get("dual_verified", False)),
66
+ gap_verified=bool(trust.get("gap_verified", False)),
67
+ certificate_verified=bool(trust.get("certificate_verified", False)),
68
+ ))
69
+
70
+ @property
71
+ def optimality_evidence(self) -> OptimalityEvidence:
72
+ return self._optimality_evidence
@@ -0,0 +1,29 @@
1
+ from .mutations import MutationKind, MutationRecord, classify_mutations
2
+ from .reuse import ReuseAssessment, assess_reuse
3
+ from .session import Session
4
+ from .persistent import (
5
+ MutationDecision,
6
+ MutationExecutionPath,
7
+ PersistentBackendBinding,
8
+ PersistentSession,
9
+ PersistentSessionError,
10
+ PersistentSessionTrace,
11
+ PersistentSolveOutcome,
12
+ ReuseEvidenceMismatchError,
13
+ SessionPolicy,
14
+ bind_persistent_backend,
15
+ decide_mutation_path,
16
+ )
17
+ from .conformance_v2 import (
18
+ PersistenceConformanceCheck,
19
+ PersistenceConformanceReport,
20
+ conform_persistent_backend,
21
+ )
22
+
23
+ __all__ = [
24
+ "MutationKind", "MutationRecord", "ReuseAssessment", "Session", "assess_reuse", "classify_mutations",
25
+ "MutationDecision", "MutationExecutionPath", "PersistentBackendBinding", "PersistentSession", "PersistentSessionError",
26
+ "PersistentSessionTrace", "PersistentSolveOutcome", "ReuseEvidenceMismatchError", "SessionPolicy",
27
+ "bind_persistent_backend", "decide_mutation_path", "PersistenceConformanceCheck", "PersistenceConformanceReport",
28
+ "conform_persistent_backend",
29
+ ]