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,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from math import isfinite
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Any, Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
from .errors import VRPValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_INDEPENDENT_PROOF_TOKEN = object()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class VRPRoute:
|
|
16
|
+
vehicle_id: str
|
|
17
|
+
customer_ids: tuple[str, ...]
|
|
18
|
+
|
|
19
|
+
def __init__(self, vehicle_id: str, customer_ids: Sequence[str] = ()) -> None:
|
|
20
|
+
vehicle = str(vehicle_id).strip()
|
|
21
|
+
if not vehicle:
|
|
22
|
+
raise VRPValidationError("route vehicle_id must be non-empty")
|
|
23
|
+
ids = tuple(str(x).strip() for x in customer_ids)
|
|
24
|
+
if any(not x for x in ids):
|
|
25
|
+
raise VRPValidationError("route customer ids must be non-empty strings")
|
|
26
|
+
object.__setattr__(self, "vehicle_id", vehicle)
|
|
27
|
+
object.__setattr__(self, "customer_ids", ids)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class VRPSolution:
|
|
32
|
+
routes: tuple[VRPRoute, ...]
|
|
33
|
+
objective: float
|
|
34
|
+
method: str
|
|
35
|
+
is_exact: bool
|
|
36
|
+
optimality_proven: bool
|
|
37
|
+
unassigned_customer_ids: tuple[str, ...] = ()
|
|
38
|
+
runtime_seconds: float | None = None
|
|
39
|
+
metadata: Mapping[str, Any] = field(default_factory=dict, compare=False)
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
routes: Sequence[VRPRoute],
|
|
44
|
+
objective: float,
|
|
45
|
+
*,
|
|
46
|
+
method: str,
|
|
47
|
+
is_exact: bool,
|
|
48
|
+
optimality_proven: bool,
|
|
49
|
+
unassigned_customer_ids: Sequence[str] = (),
|
|
50
|
+
runtime_seconds: float | None = None,
|
|
51
|
+
metadata: Mapping[str, Any] | None = None,
|
|
52
|
+
_proof_token: object | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
route_tuple = tuple(routes)
|
|
55
|
+
if any(not isinstance(r, VRPRoute) for r in route_tuple):
|
|
56
|
+
raise VRPValidationError("routes must contain VRPRoute objects")
|
|
57
|
+
if isinstance(objective, bool):
|
|
58
|
+
raise VRPValidationError("VRP objective must be numeric, not bool")
|
|
59
|
+
obj = float(objective)
|
|
60
|
+
if not isfinite(obj) or obj < 0.0:
|
|
61
|
+
raise VRPValidationError("VRP objective must be finite and non-negative")
|
|
62
|
+
if type(is_exact) is not bool or type(optimality_proven) is not bool:
|
|
63
|
+
raise VRPValidationError("is_exact and optimality_proven must be bool")
|
|
64
|
+
if optimality_proven and not is_exact:
|
|
65
|
+
raise VRPValidationError("optimality_proven requires is_exact=True")
|
|
66
|
+
if optimality_proven and _proof_token is not _INDEPENDENT_PROOF_TOKEN:
|
|
67
|
+
raise VRPValidationError(
|
|
68
|
+
"independent optimality proof may only be issued by SolverPilot verified/reference paths"
|
|
69
|
+
)
|
|
70
|
+
method_value = str(method).strip()
|
|
71
|
+
if not method_value:
|
|
72
|
+
raise VRPValidationError("VRP solution method must be non-empty")
|
|
73
|
+
unassigned = tuple(str(x).strip() for x in unassigned_customer_ids)
|
|
74
|
+
if any(not x for x in unassigned) or len(set(unassigned)) != len(unassigned):
|
|
75
|
+
raise VRPValidationError("unassigned customer ids must be unique non-empty strings")
|
|
76
|
+
if runtime_seconds is not None:
|
|
77
|
+
if isinstance(runtime_seconds, bool):
|
|
78
|
+
raise VRPValidationError("runtime_seconds must be numeric, not bool")
|
|
79
|
+
runtime_seconds = float(runtime_seconds)
|
|
80
|
+
if not isfinite(runtime_seconds) or runtime_seconds < 0.0:
|
|
81
|
+
raise VRPValidationError("runtime_seconds must be finite and non-negative")
|
|
82
|
+
object.__setattr__(self, "routes", route_tuple)
|
|
83
|
+
object.__setattr__(self, "objective", obj)
|
|
84
|
+
object.__setattr__(self, "method", method_value)
|
|
85
|
+
object.__setattr__(self, "is_exact", is_exact)
|
|
86
|
+
object.__setattr__(self, "optimality_proven", optimality_proven)
|
|
87
|
+
object.__setattr__(self, "unassigned_customer_ids", unassigned)
|
|
88
|
+
object.__setattr__(self, "runtime_seconds", runtime_seconds)
|
|
89
|
+
object.__setattr__(self, "metadata", MappingProxyType(dict(metadata or {})))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from math import isfinite
|
|
5
|
+
|
|
6
|
+
from .errors import VRPValidationError
|
|
7
|
+
from .model import VRPInstance
|
|
8
|
+
from .solution import VRPRoute
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class VRPVisitTimeline:
|
|
13
|
+
customer_id: str
|
|
14
|
+
arrival: float
|
|
15
|
+
waiting: float
|
|
16
|
+
service_start: float
|
|
17
|
+
departure: float
|
|
18
|
+
within_time_window: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class VRPRouteTimeline:
|
|
23
|
+
vehicle_id: str
|
|
24
|
+
used: bool
|
|
25
|
+
start_time: float
|
|
26
|
+
return_time: float
|
|
27
|
+
route_duration: float
|
|
28
|
+
visits: tuple[VRPVisitTimeline, ...]
|
|
29
|
+
violations: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def valid(self) -> bool:
|
|
33
|
+
return not self.violations
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_route_timeline(instance: VRPInstance, route: VRPRoute, *, tolerance: float = 1e-9) -> VRPRouteTimeline:
|
|
37
|
+
if isinstance(tolerance, bool):
|
|
38
|
+
raise VRPValidationError("timeline tolerance must be numeric")
|
|
39
|
+
tolerance = float(tolerance)
|
|
40
|
+
if not isfinite(tolerance) or tolerance < 0.0:
|
|
41
|
+
raise VRPValidationError("timeline tolerance must be finite and non-negative")
|
|
42
|
+
if instance.travel_times is None:
|
|
43
|
+
raise VRPValidationError("route timeline requires an explicit travel-time matrix")
|
|
44
|
+
vehicles = instance.vehicle_map
|
|
45
|
+
customers = instance.customer_map
|
|
46
|
+
if route.vehicle_id not in vehicles:
|
|
47
|
+
raise VRPValidationError(f"unknown vehicle {route.vehicle_id!r}")
|
|
48
|
+
vehicle = vehicles[route.vehicle_id]
|
|
49
|
+
current_time = vehicle.start_time
|
|
50
|
+
previous_node = vehicle.start_node_id
|
|
51
|
+
visits: list[VRPVisitTimeline] = []
|
|
52
|
+
violations: list[str] = []
|
|
53
|
+
for customer_id in route.customer_ids:
|
|
54
|
+
customer = customers.get(customer_id)
|
|
55
|
+
if customer is None:
|
|
56
|
+
raise VRPValidationError(f"unknown customer {customer_id!r}")
|
|
57
|
+
arrival = current_time + instance.travel_time(previous_node, customer.node_id)
|
|
58
|
+
service_start = arrival
|
|
59
|
+
if customer.time_window is not None:
|
|
60
|
+
service_start = max(arrival, customer.time_window.start)
|
|
61
|
+
waiting = service_start - arrival
|
|
62
|
+
within = customer.time_window is None or service_start <= customer.time_window.end + tolerance
|
|
63
|
+
if not within:
|
|
64
|
+
violations.append(f"late_arrival:{customer.id}")
|
|
65
|
+
departure = service_start + customer.service_duration
|
|
66
|
+
visits.append(VRPVisitTimeline(customer.id, arrival, waiting, service_start, departure, within))
|
|
67
|
+
current_time = departure
|
|
68
|
+
previous_node = customer.node_id
|
|
69
|
+
if route.customer_ids:
|
|
70
|
+
current_time += instance.travel_time(previous_node, vehicle.end_node_id)
|
|
71
|
+
# Unused vehicle is not dispatched; it incurs no route duration or depot-to-depot travel.
|
|
72
|
+
used = bool(route.customer_ids)
|
|
73
|
+
return_time = current_time if used else vehicle.start_time
|
|
74
|
+
duration = return_time - vehicle.start_time
|
|
75
|
+
if vehicle.latest_return is not None and return_time > vehicle.latest_return + tolerance:
|
|
76
|
+
violations.append("vehicle_shift_late_return")
|
|
77
|
+
if vehicle.max_route_duration is not None and duration > vehicle.max_route_duration + tolerance:
|
|
78
|
+
violations.append("max_route_duration_exceeded")
|
|
79
|
+
return VRPRouteTimeline(route.vehicle_id, used, vehicle.start_time, return_time, duration, tuple(visits), tuple(violations))
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from math import isclose, isfinite
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from .model import VRPInstance
|
|
9
|
+
from .solution import VRPRoute, VRPSolution
|
|
10
|
+
from .timeline import VRPRouteTimeline, build_route_timeline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class VRPValidationIssue:
|
|
15
|
+
code: str
|
|
16
|
+
message: str
|
|
17
|
+
vehicle_id: str | None = None
|
|
18
|
+
customer_id: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class VRPValidationReport:
|
|
23
|
+
valid: bool
|
|
24
|
+
issues: tuple[VRPValidationIssue, ...]
|
|
25
|
+
objective_recomputed: float | None
|
|
26
|
+
objective_reported: float | None
|
|
27
|
+
objective_difference: float | None
|
|
28
|
+
route_loads: tuple[tuple[str, float], ...]
|
|
29
|
+
route_distances: tuple[tuple[str, float], ...]
|
|
30
|
+
timelines: tuple[VRPRouteTimeline, ...]
|
|
31
|
+
validated_dimensions: tuple[str, ...]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def errors(self) -> tuple[str, ...]:
|
|
35
|
+
return tuple(i.message for i in self.issues)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def route_distance(instance: VRPInstance, route: VRPRoute) -> float:
|
|
39
|
+
if route.vehicle_id not in instance.vehicle_map:
|
|
40
|
+
from .errors import VRPValidationError
|
|
41
|
+
raise VRPValidationError(f"unknown vehicle {route.vehicle_id!r}")
|
|
42
|
+
unknown = [cid for cid in route.customer_ids if cid not in instance.customer_map]
|
|
43
|
+
if unknown:
|
|
44
|
+
from .errors import VRPValidationError
|
|
45
|
+
raise VRPValidationError(f"unknown customer(s) in route: {sorted(set(unknown))}")
|
|
46
|
+
if not route.customer_ids:
|
|
47
|
+
return 0.0
|
|
48
|
+
vehicles = instance.vehicle_map
|
|
49
|
+
customers = instance.customer_map
|
|
50
|
+
vehicle = vehicles[route.vehicle_id]
|
|
51
|
+
total = 0.0
|
|
52
|
+
previous = vehicle.start_node_id
|
|
53
|
+
for cid in route.customer_ids:
|
|
54
|
+
c = customers[cid]
|
|
55
|
+
total += instance.distance(previous, c.node_id)
|
|
56
|
+
previous = c.node_id
|
|
57
|
+
total += instance.distance(previous, vehicle.end_node_id)
|
|
58
|
+
return float(total)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _route_load(instance: VRPInstance, route: VRPRoute) -> float:
|
|
62
|
+
customers = instance.customer_map
|
|
63
|
+
return float(sum(customers[cid].demand for cid in route.customer_ids if cid in customers))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_vrp_solution(
|
|
67
|
+
instance: VRPInstance,
|
|
68
|
+
solution_or_routes: VRPSolution | Sequence[VRPRoute],
|
|
69
|
+
*,
|
|
70
|
+
reported_objective: float | None = None,
|
|
71
|
+
atol: float = 1e-8,
|
|
72
|
+
rtol: float = 1e-8,
|
|
73
|
+
) -> VRPValidationReport:
|
|
74
|
+
if isinstance(atol, bool) or isinstance(rtol, bool):
|
|
75
|
+
raise ValueError("VRP validation tolerances must be numeric")
|
|
76
|
+
atol = float(atol); rtol = float(rtol)
|
|
77
|
+
if not isfinite(atol) or not isfinite(rtol) or atol < 0.0 or rtol < 0.0:
|
|
78
|
+
raise ValueError("VRP validation tolerances must be finite and non-negative")
|
|
79
|
+
if isinstance(solution_or_routes, VRPSolution):
|
|
80
|
+
solution = solution_or_routes
|
|
81
|
+
routes = solution.routes
|
|
82
|
+
if reported_objective is None:
|
|
83
|
+
reported_objective = solution.objective
|
|
84
|
+
declared_unassigned = set(solution.unassigned_customer_ids)
|
|
85
|
+
else:
|
|
86
|
+
solution = None
|
|
87
|
+
routes = tuple(solution_or_routes)
|
|
88
|
+
declared_unassigned = set()
|
|
89
|
+
issues: list[VRPValidationIssue] = []
|
|
90
|
+
vehicle_map = instance.vehicle_map
|
|
91
|
+
customer_map = instance.customer_map
|
|
92
|
+
seen_vehicles: Counter[str] = Counter()
|
|
93
|
+
served: list[str] = []
|
|
94
|
+
route_loads: list[tuple[str, float]] = []
|
|
95
|
+
route_distances: list[tuple[str, float]] = []
|
|
96
|
+
timelines: list[VRPRouteTimeline] = []
|
|
97
|
+
objective = 0.0
|
|
98
|
+
for route in routes:
|
|
99
|
+
if not isinstance(route, VRPRoute):
|
|
100
|
+
issues.append(VRPValidationIssue("invalid_route_type", "solution routes must contain VRPRoute objects"))
|
|
101
|
+
continue
|
|
102
|
+
seen_vehicles[route.vehicle_id] += 1
|
|
103
|
+
vehicle = vehicle_map.get(route.vehicle_id)
|
|
104
|
+
if vehicle is None:
|
|
105
|
+
issues.append(VRPValidationIssue("unknown_vehicle", f"route references unknown vehicle {route.vehicle_id!r}", route.vehicle_id))
|
|
106
|
+
continue
|
|
107
|
+
if seen_vehicles[route.vehicle_id] > 1:
|
|
108
|
+
issues.append(VRPValidationIssue("duplicate_vehicle_route", f"vehicle {route.vehicle_id!r} has multiple routes", route.vehicle_id))
|
|
109
|
+
local = Counter(route.customer_ids)
|
|
110
|
+
for cid, count in local.items():
|
|
111
|
+
if cid not in customer_map:
|
|
112
|
+
issues.append(VRPValidationIssue("unknown_customer", f"route references unknown customer {cid!r}", route.vehicle_id, cid))
|
|
113
|
+
elif count > 1:
|
|
114
|
+
issues.append(VRPValidationIssue("duplicate_customer_in_route", f"customer {cid!r} is repeated in one route", route.vehicle_id, cid))
|
|
115
|
+
if any(cid not in customer_map for cid in route.customer_ids):
|
|
116
|
+
continue
|
|
117
|
+
served.extend(route.customer_ids)
|
|
118
|
+
load = _route_load(instance, route)
|
|
119
|
+
route_loads.append((route.vehicle_id, load))
|
|
120
|
+
if load > vehicle.capacity + atol:
|
|
121
|
+
issues.append(VRPValidationIssue("capacity_exceeded", f"vehicle {route.vehicle_id!r} load {load} exceeds capacity {vehicle.capacity}", route.vehicle_id))
|
|
122
|
+
dist = route_distance(instance, route)
|
|
123
|
+
route_distances.append((route.vehicle_id, dist))
|
|
124
|
+
objective += dist
|
|
125
|
+
if instance.has_time_constraints:
|
|
126
|
+
timeline = build_route_timeline(instance, route, tolerance=atol)
|
|
127
|
+
timelines.append(timeline)
|
|
128
|
+
for code in timeline.violations:
|
|
129
|
+
customer_id = code.split(":", 1)[1] if code.startswith("late_arrival:") else None
|
|
130
|
+
base_code = "time_window_violation" if customer_id is not None else code
|
|
131
|
+
issues.append(VRPValidationIssue(base_code, f"route {route.vehicle_id!r} violates {code}", route.vehicle_id, customer_id))
|
|
132
|
+
served_counts = Counter(served)
|
|
133
|
+
duplicated_global = sorted(cid for cid, count in served_counts.items() if count > 1)
|
|
134
|
+
for cid in duplicated_global:
|
|
135
|
+
issues.append(VRPValidationIssue("duplicate_customer_assignment", f"customer {cid!r} is assigned more than once", customer_id=cid))
|
|
136
|
+
required = set(customer_map)
|
|
137
|
+
served_set = set(served_counts)
|
|
138
|
+
missing = sorted(required - served_set)
|
|
139
|
+
for cid in missing:
|
|
140
|
+
issues.append(VRPValidationIssue("unassigned_customer", f"customer {cid!r} is not assigned", customer_id=cid))
|
|
141
|
+
unknown_unassigned = sorted(declared_unassigned - required)
|
|
142
|
+
for cid in unknown_unassigned:
|
|
143
|
+
issues.append(VRPValidationIssue("unknown_unassigned_customer", f"unassigned list contains unknown customer {cid!r}", customer_id=cid))
|
|
144
|
+
contradictory = sorted(declared_unassigned & served_set)
|
|
145
|
+
for cid in contradictory:
|
|
146
|
+
issues.append(VRPValidationIssue("served_and_unassigned", f"customer {cid!r} is both served and declared unassigned", customer_id=cid))
|
|
147
|
+
if solution is not None and set(missing) != declared_unassigned:
|
|
148
|
+
issues.append(VRPValidationIssue("unassigned_declaration_mismatch", "declared unassigned customers do not exactly match missing coverage"))
|
|
149
|
+
reported: float | None = None
|
|
150
|
+
difference: float | None = None
|
|
151
|
+
if reported_objective is not None:
|
|
152
|
+
if isinstance(reported_objective, bool):
|
|
153
|
+
issues.append(VRPValidationIssue("invalid_objective", "reported objective must be numeric, not bool"))
|
|
154
|
+
else:
|
|
155
|
+
try:
|
|
156
|
+
reported = float(reported_objective)
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
issues.append(VRPValidationIssue("invalid_objective", "reported objective must be numeric"))
|
|
159
|
+
else:
|
|
160
|
+
if not isfinite(reported):
|
|
161
|
+
issues.append(VRPValidationIssue("invalid_objective", "reported objective must be finite"))
|
|
162
|
+
else:
|
|
163
|
+
difference = reported - objective
|
|
164
|
+
if not isclose(reported, objective, abs_tol=atol, rel_tol=rtol):
|
|
165
|
+
issues.append(VRPValidationIssue("objective_mismatch", "reported objective does not match independently recomputed route distance"))
|
|
166
|
+
dims = ["coverage", "vehicle_references", "capacity", "distance", "objective"]
|
|
167
|
+
if instance.has_time_constraints:
|
|
168
|
+
dims.extend(("travel_time", "customer_time_windows", "vehicle_shift", "route_duration"))
|
|
169
|
+
return VRPValidationReport(not issues, tuple(issues), objective, reported, difference, tuple(route_loads), tuple(route_distances), tuple(timelines), tuple(dims))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Trust-aware Traveling Salesperson Problem application pack.
|
|
2
|
+
|
|
3
|
+
The pack deliberately keeps exact references, heuristics, formulation compilers,
|
|
4
|
+
and independent validation separate. It does not replace SolverPilot's canonical
|
|
5
|
+
optimization runtime or backend registries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .compile_cp import (
|
|
9
|
+
MAX_CP_OBJECTIVE,
|
|
10
|
+
MAX_EXACT_BINARY64_INTEGER,
|
|
11
|
+
TSPCPCompilation,
|
|
12
|
+
TSPCPSolveResult,
|
|
13
|
+
compile_tsp_cp,
|
|
14
|
+
decode_tsp_cp_solution,
|
|
15
|
+
solve_tsp_cp,
|
|
16
|
+
)
|
|
17
|
+
from .compile_milp import (
|
|
18
|
+
TSPMILPCompilation,
|
|
19
|
+
TSPMILPSolveResult,
|
|
20
|
+
compile_tsp_milp,
|
|
21
|
+
decode_tsp_milp_solution,
|
|
22
|
+
solve_tsp_milp,
|
|
23
|
+
)
|
|
24
|
+
from .distance import TSPDistanceAnalysis, analyze_distance_matrix, route_cost
|
|
25
|
+
from .errors import TSPCompileError, TSPError, TSPReferenceLimitError, TSPValidationError
|
|
26
|
+
from .heuristics import solve_tsp_multistart_nearest_neighbor, solve_tsp_nearest_neighbor, solve_tsp_two_opt
|
|
27
|
+
from .model import TSPDistanceMatrix, TSPInstance, TSPNode
|
|
28
|
+
from .reference import solve_tsp_brute_force, solve_tsp_held_karp
|
|
29
|
+
from .solution import TSPSolution
|
|
30
|
+
from .validation import TSPValidationReport, validate_tsp_route
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"MAX_CP_OBJECTIVE", "MAX_EXACT_BINARY64_INTEGER",
|
|
34
|
+
"TSPCPCompilation", "TSPCPSolveResult", "TSPCompileError", "TSPDistanceAnalysis",
|
|
35
|
+
"TSPDistanceMatrix", "TSPError", "TSPInstance", "TSPMILPCompilation", "TSPMILPSolveResult",
|
|
36
|
+
"TSPNode", "TSPReferenceLimitError", "TSPSolution", "TSPValidationError", "TSPValidationReport",
|
|
37
|
+
"analyze_distance_matrix", "compile_tsp_cp", "compile_tsp_milp", "decode_tsp_cp_solution",
|
|
38
|
+
"decode_tsp_milp_solution", "route_cost", "solve_tsp_brute_force", "solve_tsp_cp",
|
|
39
|
+
"solve_tsp_held_karp", "solve_tsp_milp", "solve_tsp_multistart_nearest_neighbor",
|
|
40
|
+
"solve_tsp_nearest_neighbor", "solve_tsp_two_opt", "validate_tsp_route",
|
|
41
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Mapping
|
|
6
|
+
|
|
7
|
+
from solverpilot.cp import CPModel, CPProblem, CPSolveResult, ReferenceCPBackend, validate_cp_solution
|
|
8
|
+
|
|
9
|
+
from .distance import route_cost
|
|
10
|
+
from .errors import TSPCompileError, TSPValidationError
|
|
11
|
+
from .model import TSPInstance
|
|
12
|
+
from .solution import TSPSolution, _INDEPENDENT_PROOF_TOKEN
|
|
13
|
+
from .validation import TSPValidationReport, validate_tsp_route
|
|
14
|
+
|
|
15
|
+
MAX_EXACT_BINARY64_INTEGER = (1 << 53) - 1
|
|
16
|
+
MAX_CP_OBJECTIVE = (1 << 63) - 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class TSPCPCompilation:
|
|
21
|
+
problem: CPProblem
|
|
22
|
+
arc_variables: Mapping[tuple[int, int], int]
|
|
23
|
+
start_index: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class TSPCPSolveResult:
|
|
28
|
+
solution: TSPSolution | None
|
|
29
|
+
validation: TSPValidationReport | None
|
|
30
|
+
core_result: CPSolveResult
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _start(instance: TSPInstance, start_index: int) -> int:
|
|
34
|
+
if isinstance(start_index, bool) or not isinstance(start_index, int) or not 0 <= start_index < instance.n_nodes:
|
|
35
|
+
raise TSPValidationError("start_index must be an in-range integer")
|
|
36
|
+
return start_index
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _exact_costs(instance: TSPInstance) -> dict[tuple[int,int],int]:
|
|
40
|
+
costs: dict[tuple[int,int],int] = {}
|
|
41
|
+
max_cost=0
|
|
42
|
+
for i in range(instance.n_nodes):
|
|
43
|
+
for j in range(instance.n_nodes):
|
|
44
|
+
if i==j: continue
|
|
45
|
+
value=instance.distances.values[i][j]
|
|
46
|
+
if not value.is_integer():
|
|
47
|
+
raise TSPCompileError("CP TSP compiler requires exactly integer-valued distances; use MILP for float costs")
|
|
48
|
+
integer=int(value)
|
|
49
|
+
if integer > MAX_EXACT_BINARY64_INTEGER:
|
|
50
|
+
raise TSPCompileError("CP TSP distance exceeds the conservative exact binary64 integer range")
|
|
51
|
+
max_cost=max(max_cost,integer)
|
|
52
|
+
costs[(i,j)]=integer
|
|
53
|
+
if instance.n_nodes * max_cost > MAX_CP_OBJECTIVE:
|
|
54
|
+
raise TSPCompileError("CP TSP objective can overflow signed int64")
|
|
55
|
+
return costs
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compile_tsp_cp(instance:TSPInstance, *, start_index:int=0) -> TSPCPCompilation:
|
|
59
|
+
start=_start(instance,start_index)
|
|
60
|
+
costs=_exact_costs(instance)
|
|
61
|
+
model=CPModel(name=f"tsp-circuit:{instance.name}")
|
|
62
|
+
literals={arc:model.bool_var(name=f"x_{arc[0]}_{arc[1]}") for arc in costs}
|
|
63
|
+
model.add_circuit((i,j,literals[(i,j)]) for i,j in costs)
|
|
64
|
+
objective=0
|
|
65
|
+
for arc,lit in literals.items():
|
|
66
|
+
objective = objective + costs[arc]*lit
|
|
67
|
+
model.minimize(objective)
|
|
68
|
+
problem=model.compile()
|
|
69
|
+
# CPModel metadata cannot currently be set through the builder; keep the canonical IR untouched.
|
|
70
|
+
return TSPCPCompilation(problem, MappingProxyType({arc:lit.var_id for arc,lit in literals.items()}), start)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def decode_tsp_cp_solution(instance:TSPInstance, compilation:TSPCPCompilation, assignment:Mapping[int,int]) -> tuple[int,...]:
|
|
74
|
+
for key, value in assignment.items():
|
|
75
|
+
if isinstance(key, bool) or not isinstance(key, int):
|
|
76
|
+
raise TSPCompileError("CP assignment keys must be integer variable ids")
|
|
77
|
+
if isinstance(value, bool):
|
|
78
|
+
continue
|
|
79
|
+
if not isinstance(value, int):
|
|
80
|
+
raise TSPCompileError("CP assignment values must be exact integers")
|
|
81
|
+
canonical = validate_cp_solution(compilation.problem, assignment)
|
|
82
|
+
if not canonical.valid:
|
|
83
|
+
raise TSPCompileError("CP candidate failed canonical formulation validation")
|
|
84
|
+
selected={(i,j) for (i,j),vid in compilation.arc_variables.items() if int(assignment.get(vid,0))==1}
|
|
85
|
+
n=instance.n_nodes
|
|
86
|
+
outgoing:dict[int,int]={}; incoming:dict[int,int]={}
|
|
87
|
+
for i,j in selected:
|
|
88
|
+
if i in outgoing or j in incoming:
|
|
89
|
+
raise TSPCompileError("CP candidate does not encode exactly one incoming/outgoing arc")
|
|
90
|
+
outgoing[i]=j; incoming[j]=i
|
|
91
|
+
if set(outgoing)!=set(range(n)) or set(incoming)!=set(range(n)):
|
|
92
|
+
raise TSPCompileError("CP candidate does not cover every TSP node")
|
|
93
|
+
route=[compilation.start_index]; cur=compilation.start_index
|
|
94
|
+
for _ in range(n):
|
|
95
|
+
cur=outgoing[cur]; route.append(cur)
|
|
96
|
+
if cur!=compilation.start_index or len(set(route[:-1]))!=n:
|
|
97
|
+
raise TSPCompileError("CP candidate contains a subtour instead of one Hamiltonian cycle")
|
|
98
|
+
return tuple(route)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def solve_tsp_cp(instance:TSPInstance, *, backend=None, start_index:int=0, **solve_kwargs) -> TSPCPSolveResult:
|
|
102
|
+
compilation=compile_tsp_cp(instance,start_index=start_index)
|
|
103
|
+
cp_backend=ReferenceCPBackend() if backend is None else backend
|
|
104
|
+
core=cp_backend.solve(compilation.problem,**solve_kwargs)
|
|
105
|
+
if core.assignment is None:
|
|
106
|
+
return TSPCPSolveResult(None,None,core)
|
|
107
|
+
if getattr(core.validation, "valid", False) is not True:
|
|
108
|
+
raise TSPCompileError("CP backend returned a candidate that failed SolverPilot CP validation")
|
|
109
|
+
route=decode_tsp_cp_solution(instance,compilation,core.assignment)
|
|
110
|
+
objective=route_cost(instance,route)
|
|
111
|
+
independent_proof = bool(core.optimality_proven and isinstance(cp_backend, ReferenceCPBackend))
|
|
112
|
+
solution=TSPSolution(
|
|
113
|
+
route, objective, method=f"cp-circuit:{core.backend}", is_exact=True,
|
|
114
|
+
optimality_proven=independent_proof,
|
|
115
|
+
_proof_token=_INDEPENDENT_PROOF_TOKEN,
|
|
116
|
+
metadata={"cp_status":core.status, "backend_optimality_proven": bool(core.optimality_proven), "independent_reference_proof": independent_proof},
|
|
117
|
+
)
|
|
118
|
+
report=validate_tsp_route(instance,solution)
|
|
119
|
+
if not report.valid:
|
|
120
|
+
raise TSPCompileError("decoded CP solution failed independent TSP validation")
|
|
121
|
+
return TSPCPSolveResult(solution,report,core)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Mapping
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy import sparse
|
|
9
|
+
|
|
10
|
+
from solverpilot.backends import ScipyHighsBackend
|
|
11
|
+
from solverpilot.problem import LinearProblem, ObjectiveSense, VariableDomain
|
|
12
|
+
from solverpilot.runtime import SolveResult, execute
|
|
13
|
+
from solverpilot.validate import CandidateSolution, validate_solution
|
|
14
|
+
|
|
15
|
+
from .errors import TSPCompileError, TSPValidationError
|
|
16
|
+
from .model import TSPInstance
|
|
17
|
+
from .solution import TSPSolution, _INDEPENDENT_PROOF_TOKEN
|
|
18
|
+
from .validation import TSPValidationReport, validate_tsp_route
|
|
19
|
+
from .distance import route_cost
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class TSPMILPCompilation:
|
|
24
|
+
problem: LinearProblem
|
|
25
|
+
arc_variables: Mapping[tuple[int, int], int]
|
|
26
|
+
order_variables: Mapping[int, int]
|
|
27
|
+
start_index: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class TSPMILPSolveResult:
|
|
32
|
+
solution: TSPSolution | None
|
|
33
|
+
validation: TSPValidationReport | None
|
|
34
|
+
core_result: SolveResult
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _start(instance: TSPInstance, start_index: int) -> int:
|
|
38
|
+
if isinstance(start_index, bool) or not isinstance(start_index, int) or not 0 <= start_index < instance.n_nodes:
|
|
39
|
+
raise TSPValidationError("start_index must be an in-range integer")
|
|
40
|
+
return start_index
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def compile_tsp_milp(instance: TSPInstance, *, start_index: int = 0) -> TSPMILPCompilation:
|
|
44
|
+
start = _start(instance, start_index)
|
|
45
|
+
n = instance.n_nodes
|
|
46
|
+
arcs = {(i, j): k for k, (i, j) in enumerate(( (i,j) for i in range(n) for j in range(n) if i != j ))}
|
|
47
|
+
order_nodes = tuple(i for i in range(n) if i != start)
|
|
48
|
+
orders = {node: len(arcs) + k for k, node in enumerate(order_nodes)}
|
|
49
|
+
nvars = len(arcs) + len(orders)
|
|
50
|
+
c = np.zeros(nvars, dtype=np.float64)
|
|
51
|
+
lower = np.zeros(nvars, dtype=np.float64)
|
|
52
|
+
upper = np.ones(nvars, dtype=np.float64)
|
|
53
|
+
domains: list[VariableDomain] = [VariableDomain.BINARY] * len(arcs) + [VariableDomain.CONTINUOUS] * len(orders)
|
|
54
|
+
for (i, j), idx in arcs.items():
|
|
55
|
+
c[idx] = instance.distances.values[i][j]
|
|
56
|
+
for node, idx in orders.items():
|
|
57
|
+
lower[idx] = 1.0
|
|
58
|
+
upper[idx] = float(max(1, n - 1))
|
|
59
|
+
row_i: list[int] = []
|
|
60
|
+
col_i: list[int] = []
|
|
61
|
+
data: list[float] = []
|
|
62
|
+
cl: list[float] = []
|
|
63
|
+
cu: list[float] = []
|
|
64
|
+
row = 0
|
|
65
|
+
for i in range(n):
|
|
66
|
+
for j in range(n):
|
|
67
|
+
if i != j:
|
|
68
|
+
row_i.append(row); col_i.append(arcs[(i,j)]); data.append(1.0)
|
|
69
|
+
cl.append(1.0); cu.append(1.0); row += 1
|
|
70
|
+
for j in range(n):
|
|
71
|
+
for i in range(n):
|
|
72
|
+
if i != j:
|
|
73
|
+
row_i.append(row); col_i.append(arcs[(i,j)]); data.append(1.0)
|
|
74
|
+
cl.append(1.0); cu.append(1.0); row += 1
|
|
75
|
+
# MTZ subtour elimination on non-start nodes: u_i - u_j + n*x_ij <= n-1.
|
|
76
|
+
for i in order_nodes:
|
|
77
|
+
for j in order_nodes:
|
|
78
|
+
if i == j:
|
|
79
|
+
continue
|
|
80
|
+
row_i.extend((row,row,row))
|
|
81
|
+
col_i.extend((orders[i],orders[j],arcs[(i,j)]))
|
|
82
|
+
data.extend((1.0,-1.0,float(n)))
|
|
83
|
+
cl.append(-np.inf); cu.append(float(n-1)); row += 1
|
|
84
|
+
A = sparse.csr_matrix((data,(row_i,col_i)), shape=(row,nvars), dtype=np.float64)
|
|
85
|
+
problem = LinearProblem.from_data(
|
|
86
|
+
A=A,
|
|
87
|
+
c=c,
|
|
88
|
+
variable_lower=lower,
|
|
89
|
+
variable_upper=upper,
|
|
90
|
+
constraint_lower=np.asarray(cl,dtype=np.float64),
|
|
91
|
+
constraint_upper=np.asarray(cu,dtype=np.float64),
|
|
92
|
+
domains=domains,
|
|
93
|
+
objective_sense=ObjectiveSense.MINIMIZE,
|
|
94
|
+
name=f"tsp-mtz:{instance.name}",
|
|
95
|
+
metadata={"application":"tsp","formulation":"directed-mtz","n_nodes":n,"start_index":start},
|
|
96
|
+
)
|
|
97
|
+
return TSPMILPCompilation(problem, MappingProxyType(dict(arcs)), MappingProxyType(dict(orders)), start)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decode_tsp_milp_solution(instance: TSPInstance, compilation: TSPMILPCompilation, x: np.ndarray) -> tuple[int, ...]:
|
|
101
|
+
values = np.asarray(x, dtype=np.float64)
|
|
102
|
+
if values.ndim != 1 or values.shape[0] != compilation.problem.n_variables or not np.isfinite(values).all():
|
|
103
|
+
raise TSPCompileError("MILP candidate vector has invalid shape or non-finite values")
|
|
104
|
+
canonical = validate_solution(compilation.problem, CandidateSolution(x=values))
|
|
105
|
+
if not canonical.valid:
|
|
106
|
+
raise TSPCompileError("MILP candidate failed canonical formulation validation: " + "; ".join(canonical.warnings))
|
|
107
|
+
selected = {(i,j) for (i,j), idx in compilation.arc_variables.items() if values[idx] > 0.5}
|
|
108
|
+
n = instance.n_nodes
|
|
109
|
+
outgoing: dict[int,int] = {}
|
|
110
|
+
incoming: dict[int,int] = {}
|
|
111
|
+
for i,j in selected:
|
|
112
|
+
if i in outgoing or j in incoming:
|
|
113
|
+
raise TSPCompileError("MILP candidate does not encode exactly one incoming/outgoing arc")
|
|
114
|
+
outgoing[i]=j; incoming[j]=i
|
|
115
|
+
if set(outgoing) != set(range(n)) or set(incoming) != set(range(n)):
|
|
116
|
+
raise TSPCompileError("MILP candidate does not cover every TSP node")
|
|
117
|
+
route=[compilation.start_index]
|
|
118
|
+
cur=compilation.start_index
|
|
119
|
+
for _ in range(n):
|
|
120
|
+
cur=outgoing[cur]; route.append(cur)
|
|
121
|
+
if cur != compilation.start_index or len(set(route[:-1])) != n:
|
|
122
|
+
raise TSPCompileError("MILP candidate contains a subtour instead of one Hamiltonian cycle")
|
|
123
|
+
return tuple(route)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def solve_tsp_milp(instance: TSPInstance, *, backend=None, start_index: int = 0) -> TSPMILPSolveResult:
|
|
127
|
+
compilation = compile_tsp_milp(instance, start_index=start_index)
|
|
128
|
+
core = execute(compilation.problem, ScipyHighsBackend() if backend is None else backend)
|
|
129
|
+
if core.x is None:
|
|
130
|
+
return TSPMILPSolveResult(None, None, core)
|
|
131
|
+
if core.validation is None or not core.validation.valid:
|
|
132
|
+
raise TSPCompileError("MILP backend returned a candidate that failed SolverPilot core validation")
|
|
133
|
+
route = decode_tsp_milp_solution(instance, compilation, core.x)
|
|
134
|
+
objective = route_cost(instance, route)
|
|
135
|
+
solution = TSPSolution(route, objective, method="milp-mtz", is_exact=True, optimality_proven=core.optimality_evidence.independently_verified_optimal, _proof_token=_INDEPENDENT_PROOF_TOKEN, metadata={"backend_status": core.backend_status, "backend": core.trace.backend})
|
|
136
|
+
report = validate_tsp_route(instance, solution)
|
|
137
|
+
if not report.valid:
|
|
138
|
+
raise TSPCompileError("decoded MILP solution failed independent TSP validation")
|
|
139
|
+
return TSPMILPSolveResult(solution, report, core)
|