solverpilot 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. solverpilot/__init__.py +250 -0
  2. solverpilot/_immutability.py +27 -0
  3. solverpilot/_version.py +3 -0
  4. solverpilot/applications/__init__.py +3 -0
  5. solverpilot/applications/routing/__init__.py +19 -0
  6. solverpilot/applications/routing/compile_milp.py +321 -0
  7. solverpilot/applications/routing/diagnostics.py +78 -0
  8. solverpilot/applications/routing/errors.py +14 -0
  9. solverpilot/applications/routing/heuristics.py +167 -0
  10. solverpilot/applications/routing/model.py +259 -0
  11. solverpilot/applications/routing/reference.py +81 -0
  12. solverpilot/applications/routing/solution.py +89 -0
  13. solverpilot/applications/routing/timeline.py +79 -0
  14. solverpilot/applications/routing/validation.py +169 -0
  15. solverpilot/applications/tsp/__init__.py +41 -0
  16. solverpilot/applications/tsp/compile_cp.py +121 -0
  17. solverpilot/applications/tsp/compile_milp.py +139 -0
  18. solverpilot/applications/tsp/distance.py +60 -0
  19. solverpilot/applications/tsp/errors.py +17 -0
  20. solverpilot/applications/tsp/heuristics.py +112 -0
  21. solverpilot/applications/tsp/model.py +175 -0
  22. solverpilot/applications/tsp/reference.py +121 -0
  23. solverpilot/applications/tsp/solution.py +65 -0
  24. solverpilot/applications/tsp/validation.py +92 -0
  25. solverpilot/backends/__init__.py +51 -0
  26. solverpilot/backends/_casadi_highs_worker.py +92 -0
  27. solverpilot/backends/base.py +28 -0
  28. solverpilot/backends/bundled_capi.py +910 -0
  29. solverpilot/backends/casadi_conic.py +303 -0
  30. solverpilot/backends/health.py +240 -0
  31. solverpilot/backends/highspy_native.py +217 -0
  32. solverpilot/backends/nlopt_native.py +237 -0
  33. solverpilot/backends/osqp_native.py +297 -0
  34. solverpilot/backends/protocol_v2.py +119 -0
  35. solverpilot/backends/pyscipopt_native.py +200 -0
  36. solverpilot/backends/registry.py +38 -0
  37. solverpilot/backends/scipy_highs.py +138 -0
  38. solverpilot/backends/scipy_highs_lp.py +195 -0
  39. solverpilot/backends/scipy_slsqp_qp.py +133 -0
  40. solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
  41. solverpilot/benchmark/__init__.py +23 -0
  42. solverpilot/benchmark/acquire.py +199 -0
  43. solverpilot/benchmark/baselines.py +70 -0
  44. solverpilot/benchmark/bundle.py +37 -0
  45. solverpilot/benchmark/campaign.py +150 -0
  46. solverpilot/benchmark/environment.py +115 -0
  47. solverpilot/benchmark/integrity.py +46 -0
  48. solverpilot/benchmark/model.py +146 -0
  49. solverpilot/benchmark/policy.py +182 -0
  50. solverpilot/benchmark/registry.py +38 -0
  51. solverpilot/benchmark/report.py +44 -0
  52. solverpilot/benchmark/runner.py +516 -0
  53. solverpilot/benchmark/slurm.py +55 -0
  54. solverpilot/benchmark/specs.py +75 -0
  55. solverpilot/benchmark/splits.py +181 -0
  56. solverpilot/benchmark/summary.py +384 -0
  57. solverpilot/benchmark/verify.py +142 -0
  58. solverpilot/benchmark/worker.py +71 -0
  59. solverpilot/bridges/__init__.py +19 -0
  60. solverpilot/bridges/engine.py +44 -0
  61. solverpilot/bridges/indicator.py +232 -0
  62. solverpilot/bridges/registry.py +78 -0
  63. solverpilot/bridges/transformation-tape.schema.json +53 -0
  64. solverpilot/bridges/types.py +149 -0
  65. solverpilot/bridges/validation.py +110 -0
  66. solverpilot/capabilities/__init__.py +32 -0
  67. solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
  68. solverpilot/capabilities/conformance.py +151 -0
  69. solverpilot/capabilities/enums.py +27 -0
  70. solverpilot/capabilities/manifest.py +19 -0
  71. solverpilot/capabilities/ontology.py +73 -0
  72. solverpilot/capabilities/requirements.py +37 -0
  73. solverpilot/capabilities/resolve.py +30 -0
  74. solverpilot/capabilities/v2.py +711 -0
  75. solverpilot/cli/__init__.py +1 -0
  76. solverpilot/cli/backend_health.py +77 -0
  77. solverpilot/cli/benchmark.py +331 -0
  78. solverpilot/cli/benchmark_worker.py +33 -0
  79. solverpilot/cli/capabilities.py +65 -0
  80. solverpilot/conic/__init__.py +11 -0
  81. solverpilot/conic/backend.py +263 -0
  82. solverpilot/conic/compiler.py +310 -0
  83. solverpilot/conic/conformance.py +60 -0
  84. solverpilot/conic/conic-ir-v1.schema.json +24 -0
  85. solverpilot/conic/ir.py +299 -0
  86. solverpilot/conic/validation.py +149 -0
  87. solverpilot/cp/__init__.py +7 -0
  88. solverpilot/cp/conformance.py +32 -0
  89. solverpilot/cp/cp-ir-v1.schema.json +13 -0
  90. solverpilot/cp/ir.py +170 -0
  91. solverpilot/cp/model.py +143 -0
  92. solverpilot/cp/ortools_backend.py +423 -0
  93. solverpilot/cp/ortools_worker.py +75 -0
  94. solverpilot/cp/reference.py +51 -0
  95. solverpilot/cp/serialization.py +117 -0
  96. solverpilot/cp/validate.py +83 -0
  97. solverpilot/diagnose/__init__.py +33 -0
  98. solverpilot/diagnose/infeasibility.py +463 -0
  99. solverpilot/diagnose/model.py +87 -0
  100. solverpilot/evaluation/__init__.py +53 -0
  101. solverpilot/evaluation/metrics.py +150 -0
  102. solverpilot/evaluation/oracle.py +288 -0
  103. solverpilot/evaluation/proof.py +53 -0
  104. solverpilot/evaluation/references.py +73 -0
  105. solverpilot/evaluation/regret.py +281 -0
  106. solverpilot/exceptions.py +34 -0
  107. solverpilot/experimental/__init__.py +32 -0
  108. solverpilot/extensions/__init__.py +56 -0
  109. solverpilot/extensions/entrypoints.py +34 -0
  110. solverpilot/extensions/errors.py +35 -0
  111. solverpilot/extensions/manager.py +651 -0
  112. solverpilot/extensions/manifest.py +232 -0
  113. solverpilot/extensions/model.py +101 -0
  114. solverpilot/extensions/versioning.py +136 -0
  115. solverpilot/history/__init__.py +22 -0
  116. solverpilot/history/errors.py +15 -0
  117. solverpilot/history/model.py +129 -0
  118. solverpilot/history/sqlite.py +651 -0
  119. solverpilot/inspect/__init__.py +3 -0
  120. solverpilot/inspect/fingerprint.py +199 -0
  121. solverpilot/intelligence/__init__.py +36 -0
  122. solverpilot/intelligence/errors.py +16 -0
  123. solverpilot/intelligence/fingerprint.py +108 -0
  124. solverpilot/intelligence/leakage.py +69 -0
  125. solverpilot/intelligence/schema.py +314 -0
  126. solverpilot/intelligence/shift.py +208 -0
  127. solverpilot/io/__init__.py +41 -0
  128. solverpilot/io/binary64.py +49 -0
  129. solverpilot/io/csv.py +230 -0
  130. solverpilot/io/errors.py +25 -0
  131. solverpilot/io/hashing.py +111 -0
  132. solverpilot/io/json.py +90 -0
  133. solverpilot/io/mapping.py +195 -0
  134. solverpilot/io/model.py +407 -0
  135. solverpilot/io/numeric.py +108 -0
  136. solverpilot/io/provenance.py +114 -0
  137. solverpilot/io/result.py +45 -0
  138. solverpilot/io/source.py +104 -0
  139. solverpilot/io/strict_json.py +133 -0
  140. solverpilot/minlp/__init__.py +6 -0
  141. solverpilot/minlp/compiler.py +66 -0
  142. solverpilot/minlp/conformance.py +34 -0
  143. solverpilot/minlp/convexity.py +85 -0
  144. solverpilot/minlp/ir.py +35 -0
  145. solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
  146. solverpilot/minlp/orchestrator.py +220 -0
  147. solverpilot/minlp/validation.py +28 -0
  148. solverpilot/model/__init__.py +27 -0
  149. solverpilot/model/compiler.py +1263 -0
  150. solverpilot/model/errors.py +22 -0
  151. solverpilot/model/expression.py +373 -0
  152. solverpilot/model/model.py +716 -0
  153. solverpilot/model/sets.py +60 -0
  154. solverpilot/model/types.py +36 -0
  155. solverpilot/nlp/__init__.py +7 -0
  156. solverpilot/nlp/ad.py +166 -0
  157. solverpilot/nlp/backend.py +113 -0
  158. solverpilot/nlp/compiler.py +116 -0
  159. solverpilot/nlp/conformance.py +58 -0
  160. solverpilot/nlp/ir.py +70 -0
  161. solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
  162. solverpilot/nlp/validation.py +60 -0
  163. solverpilot/plan/__init__.py +33 -0
  164. solverpilot/plan/model.py +79 -0
  165. solverpilot/plan/planner.py +201 -0
  166. solverpilot/plan/production.py +627 -0
  167. solverpilot/plan/selective_lp.py +65 -0
  168. solverpilot/problem/__init__.py +16 -0
  169. solverpilot/problem/enums.py +20 -0
  170. solverpilot/problem/hashing.py +88 -0
  171. solverpilot/problem/linear.py +196 -0
  172. solverpilot/problem/mps.py +447 -0
  173. solverpilot/problem/quadratic.py +192 -0
  174. solverpilot/reporting/__init__.py +27 -0
  175. solverpilot/reporting/explain.py +327 -0
  176. solverpilot/reporting/model.py +264 -0
  177. solverpilot/reporting/render.py +55 -0
  178. solverpilot/runtime/__init__.py +19 -0
  179. solverpilot/runtime/auto.py +193 -0
  180. solverpilot/runtime/budgeting.py +43 -0
  181. solverpilot/runtime/executor.py +126 -0
  182. solverpilot/runtime/portfolio.py +197 -0
  183. solverpilot/runtime/result.py +72 -0
  184. solverpilot/session/__init__.py +29 -0
  185. solverpilot/session/conformance_v2.py +239 -0
  186. solverpilot/session/mutations.py +115 -0
  187. solverpilot/session/persistent-session-trace.schema.json +45 -0
  188. solverpilot/session/persistent.py +491 -0
  189. solverpilot/session/reuse.py +81 -0
  190. solverpilot/session/session.py +236 -0
  191. solverpilot/trace/__init__.py +3 -0
  192. solverpilot/trace/schema.py +47 -0
  193. solverpilot/validate/__init__.py +10 -0
  194. solverpilot/validate/core.py +179 -0
  195. solverpilot/validate/result.py +55 -0
  196. solverpilot-0.1.dist-info/METADATA +554 -0
  197. solverpilot-0.1.dist-info/RECORD +201 -0
  198. solverpilot-0.1.dist-info/WHEEL +5 -0
  199. solverpilot-0.1.dist-info/entry_points.txt +4 -0
  200. solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
  201. solverpilot-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)