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.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- 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
|
+
]
|