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,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import importlib.util
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy import sparse
|
|
9
|
+
|
|
10
|
+
from solverpilot.capabilities import BackendManifest, Capability, SupportLevel
|
|
11
|
+
from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem, VariableDomain
|
|
12
|
+
from solverpilot.problem.quadratic import require_confirmed_convexity
|
|
13
|
+
|
|
14
|
+
from .base import BackendSolveResult, BackendUnavailableError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _highs_status_from_strings(model_status: str, primal_status: str) -> str:
|
|
18
|
+
model = model_status.strip().lower()
|
|
19
|
+
primal_feasible = "feasible" in primal_status.strip().lower() and "infeasible" not in primal_status.strip().lower()
|
|
20
|
+
if "optimal" in model:
|
|
21
|
+
return "optimal"
|
|
22
|
+
if "unbounded or infeasible" in model or "infeasible or unbounded" in model:
|
|
23
|
+
return "infeasible_or_unbounded"
|
|
24
|
+
if "infeasible" in model:
|
|
25
|
+
return "infeasible"
|
|
26
|
+
if "unbounded" in model:
|
|
27
|
+
return "unbounded"
|
|
28
|
+
if any(token in model for token in ("time limit", "iteration limit", "solution limit", "objective bound", "objective target", "interrupt")):
|
|
29
|
+
return "limit_feasible" if primal_feasible else "limit_no_solution"
|
|
30
|
+
if any(token in model for token in ("model error", "solve error", "memory limit")):
|
|
31
|
+
return "solver_error"
|
|
32
|
+
return "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _linear_csc(problem: LinearProblem) -> sparse.csc_matrix:
|
|
36
|
+
A = problem.A.tocsc(copy=True)
|
|
37
|
+
A.sort_indices()
|
|
38
|
+
return A
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lower_hessian_csc(problem: QuadraticProblem) -> sparse.csc_matrix:
|
|
42
|
+
P = sparse.tril(problem.P, format="csc")
|
|
43
|
+
P.sort_indices()
|
|
44
|
+
return P
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class HighspyNativeBackend:
|
|
49
|
+
"""Optional native HiGHS backend.
|
|
50
|
+
|
|
51
|
+
This adapter is import-safe in environments without ``highspy``. M2 implements
|
|
52
|
+
native LP/MILP/QP model construction and result normalization. Stateful basis and
|
|
53
|
+
MIP-start reuse are intentionally not claimed until they can be integration-tested.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
time_limit_s: float | None = None
|
|
57
|
+
threads: int | None = None
|
|
58
|
+
presolve: bool | None = None
|
|
59
|
+
solver: str | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def manifest(self) -> BackendManifest:
|
|
63
|
+
pkg_version = None
|
|
64
|
+
if self.is_available():
|
|
65
|
+
try:
|
|
66
|
+
pkg_version = version("highspy")
|
|
67
|
+
except PackageNotFoundError:
|
|
68
|
+
pass
|
|
69
|
+
return BackendManifest(
|
|
70
|
+
name="highspy-native",
|
|
71
|
+
version=pkg_version,
|
|
72
|
+
capabilities={
|
|
73
|
+
Capability.LP: SupportLevel.NATIVE,
|
|
74
|
+
Capability.CONVEX_QP: SupportLevel.NATIVE,
|
|
75
|
+
Capability.MILP: SupportLevel.NATIVE,
|
|
76
|
+
# Upstream supports these mechanisms, but M2 adapter does not yet apply them.
|
|
77
|
+
Capability.MIP_START: SupportLevel.UNKNOWN,
|
|
78
|
+
Capability.PRIMAL_START: SupportLevel.UNKNOWN,
|
|
79
|
+
Capability.DUAL_START: SupportLevel.UNKNOWN,
|
|
80
|
+
Capability.BASIS_START: SupportLevel.UNKNOWN,
|
|
81
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.UNKNOWN,
|
|
82
|
+
Capability.STRUCTURAL_INCREMENTAL_UPDATE: SupportLevel.UNKNOWN,
|
|
83
|
+
Capability.NATIVE_REOPTIMIZATION: SupportLevel.UNSUPPORTED,
|
|
84
|
+
Capability.CALLBACK_PROGRESS: SupportLevel.UNKNOWN,
|
|
85
|
+
Capability.IIS: SupportLevel.UNKNOWN,
|
|
86
|
+
Capability.INFEASIBILITY_CERTIFICATE: SupportLevel.UNKNOWN,
|
|
87
|
+
},
|
|
88
|
+
metadata={
|
|
89
|
+
"native_adapter": True,
|
|
90
|
+
"stateful": False,
|
|
91
|
+
"reuse_claim": "none in M2 adapter",
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def is_available(self) -> bool:
|
|
96
|
+
if importlib.util.find_spec("highspy") is None:
|
|
97
|
+
return False
|
|
98
|
+
try:
|
|
99
|
+
# Finding package metadata is insufficient: the extension may not
|
|
100
|
+
# load, or another HiGHS ABI may already occupy its library name.
|
|
101
|
+
# Resolve the dedicated native adapter before verification bridges
|
|
102
|
+
# are examined by the default registry.
|
|
103
|
+
importlib.import_module("highspy")
|
|
104
|
+
except (ImportError, OSError):
|
|
105
|
+
return False
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
109
|
+
if not self.is_available():
|
|
110
|
+
raise BackendUnavailableError("highspy is not installed")
|
|
111
|
+
import highspy
|
|
112
|
+
|
|
113
|
+
h = highspy.Highs()
|
|
114
|
+
h.setOptionValue("output_flag", False)
|
|
115
|
+
if self.time_limit_s is not None:
|
|
116
|
+
if self.time_limit_s <= 0:
|
|
117
|
+
raise ValueError("time_limit_s must be positive")
|
|
118
|
+
h.setOptionValue("time_limit", float(self.time_limit_s))
|
|
119
|
+
if self.threads is not None:
|
|
120
|
+
if self.threads <= 0:
|
|
121
|
+
raise ValueError("threads must be positive")
|
|
122
|
+
h.setOptionValue("threads", int(self.threads))
|
|
123
|
+
if self.presolve is not None:
|
|
124
|
+
h.setOptionValue("presolve", "on" if self.presolve else "off")
|
|
125
|
+
if self.solver is not None:
|
|
126
|
+
h.setOptionValue("solver", str(self.solver))
|
|
127
|
+
|
|
128
|
+
if isinstance(problem, QuadraticProblem):
|
|
129
|
+
require_confirmed_convexity(problem)
|
|
130
|
+
model = self._make_qp_model(highspy, problem)
|
|
131
|
+
elif isinstance(problem, LinearProblem):
|
|
132
|
+
model = self._make_lp_model(highspy, problem)
|
|
133
|
+
else:
|
|
134
|
+
raise TypeError(f"unsupported problem type: {type(problem)!r}")
|
|
135
|
+
|
|
136
|
+
pass_status = h.passModel(model)
|
|
137
|
+
if "error" in str(pass_status).lower():
|
|
138
|
+
raise RuntimeError(f"HiGHS rejected model: {pass_status}")
|
|
139
|
+
h.run()
|
|
140
|
+
info = h.getInfo()
|
|
141
|
+
model_status = h.modelStatusToString(h.getModelStatus())
|
|
142
|
+
primal_status = h.solutionStatusToString(info.primal_solution_status)
|
|
143
|
+
backend_status = _highs_status_from_strings(model_status, primal_status)
|
|
144
|
+
|
|
145
|
+
x = None
|
|
146
|
+
if backend_status in {"optimal", "limit_feasible"}:
|
|
147
|
+
solution = h.getSolution()
|
|
148
|
+
x_candidate = np.asarray(list(solution.col_value), dtype=np.float64)
|
|
149
|
+
if x_candidate.shape == (problem.n_variables,) and np.all(np.isfinite(x_candidate)):
|
|
150
|
+
x = x_candidate
|
|
151
|
+
|
|
152
|
+
objective = None
|
|
153
|
+
objective_internal = None
|
|
154
|
+
if x is not None:
|
|
155
|
+
try:
|
|
156
|
+
candidate = float(h.getObjectiveValue())
|
|
157
|
+
if np.isfinite(candidate):
|
|
158
|
+
objective_internal = candidate
|
|
159
|
+
objective = candidate
|
|
160
|
+
except Exception:
|
|
161
|
+
objective_internal = None
|
|
162
|
+
|
|
163
|
+
raw = {
|
|
164
|
+
"model_status": str(model_status),
|
|
165
|
+
"primal_solution_status": str(primal_status),
|
|
166
|
+
"simplex_iteration_count": int(getattr(info, "simplex_iteration_count", 0)),
|
|
167
|
+
"ipm_iteration_count": int(getattr(info, "ipm_iteration_count", 0)),
|
|
168
|
+
"qp_iteration_count": int(getattr(info, "qp_iteration_count", 0)),
|
|
169
|
+
"mip_node_count": int(getattr(info, "mip_node_count", 0)),
|
|
170
|
+
"reuse_applied": False,
|
|
171
|
+
"reuse_mode": "cold_native_solve",
|
|
172
|
+
"objective_internal": objective_internal,
|
|
173
|
+
}
|
|
174
|
+
return BackendSolveResult(
|
|
175
|
+
backend_status=backend_status,
|
|
176
|
+
x=x,
|
|
177
|
+
objective_reported=objective,
|
|
178
|
+
raw_statistics=raw,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _make_lp_model(highspy, problem: LinearProblem):
|
|
183
|
+
lp = highspy.HighsLp()
|
|
184
|
+
lp.num_col_ = problem.n_variables
|
|
185
|
+
lp.num_row_ = problem.n_constraints
|
|
186
|
+
lp.col_cost_ = np.asarray(problem.c, dtype=np.float64)
|
|
187
|
+
lp.col_lower_ = np.asarray(problem.variable_lower, dtype=np.float64)
|
|
188
|
+
lp.col_upper_ = np.asarray(problem.variable_upper, dtype=np.float64)
|
|
189
|
+
lp.row_lower_ = np.asarray(problem.constraint_lower, dtype=np.float64)
|
|
190
|
+
lp.row_upper_ = np.asarray(problem.constraint_upper, dtype=np.float64)
|
|
191
|
+
lp.sense_ = highspy.ObjSense.kMinimize if problem.objective_sense is ObjectiveSense.MINIMIZE else highspy.ObjSense.kMaximize
|
|
192
|
+
lp.offset_ = float(problem.objective_offset)
|
|
193
|
+
A = _linear_csc(problem)
|
|
194
|
+
lp.a_matrix_.format_ = highspy.MatrixFormat.kColwise
|
|
195
|
+
lp.a_matrix_.start_ = np.asarray(A.indptr, dtype=np.int64)
|
|
196
|
+
lp.a_matrix_.index_ = np.asarray(A.indices, dtype=np.int32)
|
|
197
|
+
lp.a_matrix_.value_ = np.asarray(A.data, dtype=np.float64)
|
|
198
|
+
if problem.has_integer_variables:
|
|
199
|
+
lp.integrality_ = [
|
|
200
|
+
highspy.HighsVarType.kInteger
|
|
201
|
+
if domain in {VariableDomain.INTEGER.value, VariableDomain.BINARY.value}
|
|
202
|
+
else highspy.HighsVarType.kContinuous
|
|
203
|
+
for domain in problem.domains
|
|
204
|
+
]
|
|
205
|
+
return lp
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def _make_qp_model(cls, highspy, problem: QuadraticProblem):
|
|
209
|
+
model = highspy.HighsModel()
|
|
210
|
+
model.lp_ = cls._make_lp_model(highspy, problem.linear)
|
|
211
|
+
P = _lower_hessian_csc(problem)
|
|
212
|
+
model.hessian_.dim_ = problem.n_variables
|
|
213
|
+
model.hessian_.format_ = highspy.HessianFormat.kTriangular
|
|
214
|
+
model.hessian_.start_ = np.asarray(P.indptr, dtype=np.int64)
|
|
215
|
+
model.hessian_.index_ = np.asarray(P.indices, dtype=np.int32)
|
|
216
|
+
model.hessian_.value_ = np.asarray(P.data, dtype=np.float64)
|
|
217
|
+
return model
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import importlib.util
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from solverpilot.capabilities import BackendManifest, Capability, SupportLevel
|
|
10
|
+
from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem
|
|
11
|
+
from solverpilot.problem.quadratic import require_confirmed_convexity
|
|
12
|
+
|
|
13
|
+
from .base import BackendSolveResult, BackendUnavailableError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _initial_point(lower: np.ndarray, upper: np.ndarray) -> np.ndarray:
|
|
17
|
+
x = np.zeros_like(lower, dtype=np.float64)
|
|
18
|
+
both = np.isfinite(lower) & np.isfinite(upper)
|
|
19
|
+
x[both] = 0.5 * (lower[both] + upper[both])
|
|
20
|
+
only_lower = np.isfinite(lower) & ~np.isfinite(upper)
|
|
21
|
+
x[only_lower] = np.maximum(0.0, lower[only_lower])
|
|
22
|
+
only_upper = ~np.isfinite(lower) & np.isfinite(upper)
|
|
23
|
+
x[only_upper] = np.minimum(0.0, upper[only_upper])
|
|
24
|
+
return np.minimum(np.maximum(x, lower), upper)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _nlopt_status(code: int, *, has_candidate: bool) -> str:
|
|
28
|
+
# NLopt positive result codes indicate a successful stop criterion, but not a
|
|
29
|
+
# mathematical optimality certificate. Preserve that distinction.
|
|
30
|
+
if code in {1, 2, 3, 4}:
|
|
31
|
+
return "converged_candidate" if has_candidate else "solver_error"
|
|
32
|
+
if code in {5, 6}: # max evaluations / max time
|
|
33
|
+
return "limit_feasible" if has_candidate else "limit_no_solution"
|
|
34
|
+
if code in {-4, -5}: # roundoff-limited / forced stop
|
|
35
|
+
return "limit_feasible" if has_candidate else "limit_no_solution"
|
|
36
|
+
return "solver_error"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class NLoptNativeBackend:
|
|
41
|
+
"""Optional cross-library continuous optimizer backend using NLopt SLSQP.
|
|
42
|
+
|
|
43
|
+
This adapter is deliberately conservative: it accepts continuous LP and convex
|
|
44
|
+
QP instances but never claims a proof of optimality. A positive NLopt stop is
|
|
45
|
+
normalized as a candidate and SolverPilot's independent validator remains the
|
|
46
|
+
authority on feasibility.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
time_limit_s: float | None = None
|
|
50
|
+
max_evals: int = 2000
|
|
51
|
+
ftol_rel: float = 1e-10
|
|
52
|
+
xtol_rel: float = 1e-10
|
|
53
|
+
constraint_tol: float = 1e-8
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if self.time_limit_s is not None and self.time_limit_s <= 0:
|
|
57
|
+
raise ValueError("time_limit_s must be positive")
|
|
58
|
+
if self.max_evals <= 0:
|
|
59
|
+
raise ValueError("max_evals must be positive")
|
|
60
|
+
if self.ftol_rel <= 0 or self.xtol_rel <= 0 or self.constraint_tol < 0:
|
|
61
|
+
raise ValueError("NLopt tolerances must be positive (constraint_tol may be zero)")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def manifest(self) -> BackendManifest:
|
|
65
|
+
pkg_version = None
|
|
66
|
+
if self.is_available():
|
|
67
|
+
try:
|
|
68
|
+
pkg_version = version("nlopt")
|
|
69
|
+
except PackageNotFoundError:
|
|
70
|
+
try:
|
|
71
|
+
import nlopt
|
|
72
|
+
pkg_version = getattr(nlopt, "__version__", None)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
return BackendManifest(
|
|
76
|
+
name="nlopt-slsqp-native",
|
|
77
|
+
version=pkg_version,
|
|
78
|
+
capabilities={
|
|
79
|
+
Capability.LP: SupportLevel.EMULATED_SAFE,
|
|
80
|
+
Capability.CONVEX_QP: SupportLevel.EMULATED_SAFE,
|
|
81
|
+
Capability.MILP: SupportLevel.UNSUPPORTED,
|
|
82
|
+
Capability.PRIMAL_START: SupportLevel.UNSUPPORTED,
|
|
83
|
+
Capability.DUAL_START: SupportLevel.UNSUPPORTED,
|
|
84
|
+
Capability.BASIS_START: SupportLevel.UNSUPPORTED,
|
|
85
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.UNSUPPORTED,
|
|
86
|
+
Capability.STRUCTURAL_INCREMENTAL_UPDATE: SupportLevel.UNSUPPORTED,
|
|
87
|
+
Capability.NATIVE_REOPTIMIZATION: SupportLevel.UNSUPPORTED,
|
|
88
|
+
Capability.CALLBACK_PROGRESS: SupportLevel.UNSUPPORTED,
|
|
89
|
+
Capability.IIS: SupportLevel.UNSUPPORTED,
|
|
90
|
+
Capability.INFEASIBILITY_CERTIFICATE: SupportLevel.UNSUPPORTED,
|
|
91
|
+
},
|
|
92
|
+
metadata={
|
|
93
|
+
"native_adapter": True,
|
|
94
|
+
"underlying_solver": "NLopt LD_SLSQP",
|
|
95
|
+
"continuous_only": True,
|
|
96
|
+
"no_optimality_certificate": True,
|
|
97
|
+
"cross_library_backend": True,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def is_available(self) -> bool:
|
|
102
|
+
return importlib.util.find_spec("nlopt") is not None
|
|
103
|
+
|
|
104
|
+
def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
105
|
+
if not self.is_available():
|
|
106
|
+
raise BackendUnavailableError("nlopt is not installed")
|
|
107
|
+
import nlopt
|
|
108
|
+
|
|
109
|
+
if isinstance(problem, QuadraticProblem):
|
|
110
|
+
require_confirmed_convexity(problem)
|
|
111
|
+
linear = problem.linear
|
|
112
|
+
P = ((problem.P + problem.P.T) * 0.5).tocsr()
|
|
113
|
+
elif isinstance(problem, LinearProblem):
|
|
114
|
+
if problem.has_integer_variables:
|
|
115
|
+
raise ValueError("NLopt backend supports continuous LP only")
|
|
116
|
+
linear = problem
|
|
117
|
+
P = None
|
|
118
|
+
else:
|
|
119
|
+
raise TypeError(f"unsupported problem type: {type(problem)!r}")
|
|
120
|
+
|
|
121
|
+
n = linear.n_variables
|
|
122
|
+
opt = nlopt.opt(nlopt.LD_SLSQP, n)
|
|
123
|
+
opt.set_lower_bounds(np.asarray(linear.variable_lower, dtype=np.float64))
|
|
124
|
+
opt.set_upper_bounds(np.asarray(linear.variable_upper, dtype=np.float64))
|
|
125
|
+
opt.set_maxeval(int(self.max_evals))
|
|
126
|
+
opt.set_ftol_rel(float(self.ftol_rel))
|
|
127
|
+
opt.set_xtol_rel(float(self.xtol_rel))
|
|
128
|
+
if self.time_limit_s is not None:
|
|
129
|
+
opt.set_maxtime(float(self.time_limit_s))
|
|
130
|
+
|
|
131
|
+
if P is None:
|
|
132
|
+
c = np.asarray(linear.c, dtype=np.float64)
|
|
133
|
+
|
|
134
|
+
def objective(x, grad):
|
|
135
|
+
if grad.size:
|
|
136
|
+
grad[:] = c
|
|
137
|
+
return float(c @ x + linear.objective_offset)
|
|
138
|
+
else:
|
|
139
|
+
q = np.asarray(linear.c, dtype=np.float64)
|
|
140
|
+
|
|
141
|
+
def objective(x, grad):
|
|
142
|
+
px = np.asarray(P @ x, dtype=np.float64)
|
|
143
|
+
if grad.size:
|
|
144
|
+
grad[:] = px + q
|
|
145
|
+
return float(0.5 * x @ px + q @ x + linear.objective_offset)
|
|
146
|
+
|
|
147
|
+
if linear.objective_sense is ObjectiveSense.MAXIMIZE:
|
|
148
|
+
if P is not None:
|
|
149
|
+
raise ValueError("convex QuadraticProblem only supports minimization")
|
|
150
|
+
opt.set_max_objective(objective)
|
|
151
|
+
else:
|
|
152
|
+
opt.set_min_objective(objective)
|
|
153
|
+
|
|
154
|
+
A = linear.A.tocsr()
|
|
155
|
+
tol = float(self.constraint_tol)
|
|
156
|
+
callbacks = [] # keep closures alive for the lifetime of optimize()
|
|
157
|
+
for i in range(linear.n_constraints):
|
|
158
|
+
start, end = A.indptr[i], A.indptr[i + 1]
|
|
159
|
+
indices = np.asarray(A.indices[start:end], dtype=np.int64).copy()
|
|
160
|
+
values = np.asarray(A.data[start:end], dtype=np.float64).copy()
|
|
161
|
+
lo = float(linear.constraint_lower[i])
|
|
162
|
+
hi = float(linear.constraint_upper[i])
|
|
163
|
+
|
|
164
|
+
def row_value(x, indices=indices, values=values):
|
|
165
|
+
return float(values @ x[indices]) if values.size else 0.0
|
|
166
|
+
|
|
167
|
+
if np.isfinite(lo) and np.isfinite(hi) and np.isclose(lo, hi, rtol=0.0, atol=0.0):
|
|
168
|
+
rhs = hi
|
|
169
|
+
|
|
170
|
+
def eq(x, grad, indices=indices, values=values, rhs=rhs):
|
|
171
|
+
if grad.size:
|
|
172
|
+
grad[:] = 0.0
|
|
173
|
+
grad[indices] = values
|
|
174
|
+
return float((values @ x[indices] if values.size else 0.0) - rhs)
|
|
175
|
+
|
|
176
|
+
callbacks.append(eq)
|
|
177
|
+
opt.add_equality_constraint(eq, tol)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if np.isfinite(hi):
|
|
181
|
+
rhs = hi
|
|
182
|
+
|
|
183
|
+
def upper(x, grad, indices=indices, values=values, rhs=rhs):
|
|
184
|
+
if grad.size:
|
|
185
|
+
grad[:] = 0.0
|
|
186
|
+
grad[indices] = values
|
|
187
|
+
return float((values @ x[indices] if values.size else 0.0) - rhs)
|
|
188
|
+
|
|
189
|
+
callbacks.append(upper)
|
|
190
|
+
opt.add_inequality_constraint(upper, tol)
|
|
191
|
+
|
|
192
|
+
if np.isfinite(lo):
|
|
193
|
+
rhs = lo
|
|
194
|
+
|
|
195
|
+
def lower(x, grad, indices=indices, values=values, rhs=rhs):
|
|
196
|
+
if grad.size:
|
|
197
|
+
grad[:] = 0.0
|
|
198
|
+
grad[indices] = -values
|
|
199
|
+
return float(rhs - (values @ x[indices] if values.size else 0.0))
|
|
200
|
+
|
|
201
|
+
callbacks.append(lower)
|
|
202
|
+
opt.add_inequality_constraint(lower, tol)
|
|
203
|
+
|
|
204
|
+
x0 = _initial_point(linear.variable_lower, linear.variable_upper)
|
|
205
|
+
x = None
|
|
206
|
+
objective_reported = None
|
|
207
|
+
code = None
|
|
208
|
+
error = None
|
|
209
|
+
try:
|
|
210
|
+
candidate = np.asarray(opt.optimize(x0), dtype=np.float64)
|
|
211
|
+
code = int(opt.last_optimize_result())
|
|
212
|
+
if candidate.shape == (n,) and np.all(np.isfinite(candidate)):
|
|
213
|
+
x = candidate
|
|
214
|
+
objective_reported = float(opt.last_optimum_value())
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
# NLopt exceptions may still leave a meaningful candidate unavailable to Python;
|
|
217
|
+
# do not manufacture one. Preserve the exception in raw statistics.
|
|
218
|
+
try:
|
|
219
|
+
code = int(opt.last_optimize_result())
|
|
220
|
+
except Exception:
|
|
221
|
+
code = None
|
|
222
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
223
|
+
|
|
224
|
+
backend_status = _nlopt_status(-1 if code is None else code, has_candidate=x is not None)
|
|
225
|
+
return BackendSolveResult(
|
|
226
|
+
backend_status=backend_status,
|
|
227
|
+
x=x,
|
|
228
|
+
objective_reported=objective_reported,
|
|
229
|
+
raw_statistics={
|
|
230
|
+
"nlopt_result_code": code,
|
|
231
|
+
"algorithm": "LD_SLSQP",
|
|
232
|
+
"error": error,
|
|
233
|
+
"reuse_applied": False,
|
|
234
|
+
"reuse_mode": "cold_native_solve",
|
|
235
|
+
"no_optimality_certificate": True,
|
|
236
|
+
},
|
|
237
|
+
)
|