solverpilot 0.1.0rc2__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.0rc2.dist-info/METADATA +554 -0
  197. solverpilot-0.1.0rc2.dist-info/RECORD +201 -0
  198. solverpilot-0.1.0rc2.dist-info/WHEEL +5 -0
  199. solverpilot-0.1.0rc2.dist-info/entry_points.txt +4 -0
  200. solverpilot-0.1.0rc2.dist-info/licenses/LICENSE +202 -0
  201. solverpilot-0.1.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from math import isfinite
5
+
6
+ from .model import VRPInstance
7
+ from .solution import VRPSolution
8
+ from .validation import VRPValidationReport, validate_vrp_solution
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class VRPDiagnosticAction:
13
+ code: str
14
+ target: str
15
+ severity: str
16
+ suggestion: str
17
+ evidence_code: str
18
+
19
+
20
+ _ACTIONS = {
21
+ "capacity_exceeded": ("blocking", "Split the route, reassign customers, or use a vehicle with more capacity."),
22
+ "time_window_violation": ("blocking", "Resequence the stop earlier, relax its time window, split the route, or reassign it."),
23
+ "vehicle_shift_late_return": ("blocking", "Split or shorten the route, extend the vehicle shift, or reassign late stops."),
24
+ "max_route_duration_exceeded": ("blocking", "Split the route, add a vehicle, or explicitly increase the permitted route duration."),
25
+ "unassigned_customer": ("blocking", "Add capacity/vehicles or relax the blocking routing constraints before retrying assignment."),
26
+ "duplicate_customer_assignment": ("blocking", "Remove duplicate service and recompute the route plan."),
27
+ "objective_mismatch": ("blocking", "Discard the reported objective and recompute it from the route and distance matrix."),
28
+ "unknown_vehicle": ("blocking", "Repair the vehicle reference before using the plan."),
29
+ "unknown_customer": ("blocking", "Repair the customer reference before using the plan."),
30
+ }
31
+
32
+
33
+ def diagnose_vrp_validation(report: VRPValidationReport) -> tuple[VRPDiagnosticAction, ...]:
34
+ actions: list[VRPDiagnosticAction] = []
35
+ seen: set[tuple[str, str]] = set()
36
+ for issue in report.issues:
37
+ severity, suggestion = _ACTIONS.get(issue.code, ("warning", "Review the validation evidence and repair the route before operational use."))
38
+ target = issue.customer_id or issue.vehicle_id or "solution"
39
+ key = (issue.code, target)
40
+ if key in seen:
41
+ continue
42
+ seen.add(key)
43
+ actions.append(VRPDiagnosticAction(issue.code, target, severity, suggestion, issue.code))
44
+ return tuple(actions)
45
+
46
+
47
+ def diagnose_vrp_solution(instance: VRPInstance, solution: VRPSolution, *, waiting_warning_threshold: float = 30.0) -> tuple[VRPDiagnosticAction, ...]:
48
+ """Build actionable diagnostics without promoting suggestions to proofs.
49
+
50
+ Validation blockers are emitted first. Feasible waiting time is reported as
51
+ informational/warning evidence because it can be operationally useful but is
52
+ not itself an infeasibility.
53
+ """
54
+ if isinstance(waiting_warning_threshold, bool):
55
+ raise ValueError("waiting_warning_threshold must be numeric")
56
+ threshold = float(waiting_warning_threshold)
57
+ if not isfinite(threshold) or threshold < 0.0:
58
+ raise ValueError("waiting_warning_threshold must be finite and non-negative")
59
+ report = validate_vrp_solution(instance, solution)
60
+ actions = list(diagnose_vrp_validation(report))
61
+ seen = {(a.code, a.target) for a in actions}
62
+ for timeline in report.timelines:
63
+ for visit in timeline.visits:
64
+ if visit.waiting <= 1e-12:
65
+ continue
66
+ key = ("waiting_time_detected", visit.customer_id)
67
+ if key in seen:
68
+ continue
69
+ seen.add(key)
70
+ severity = "warning" if visit.waiting >= threshold else "info"
71
+ actions.append(VRPDiagnosticAction(
72
+ "waiting_time_detected",
73
+ visit.customer_id,
74
+ severity,
75
+ "Accept the wait if it protects the time window, or consider resequencing; this is a suggestion, not an automatic improvement claim.",
76
+ "timeline_waiting",
77
+ ))
78
+ return tuple(actions)
@@ -0,0 +1,14 @@
1
+ class VRPError(Exception):
2
+ """Base error for the SolverPilot routing application pack."""
3
+
4
+
5
+ class VRPValidationError(VRPError, ValueError):
6
+ """Raised for invalid VRP data or solution objects."""
7
+
8
+
9
+ class VRPCompileError(VRPError, RuntimeError):
10
+ """Raised when a VRP formulation cannot be compiled or decoded safely."""
11
+
12
+
13
+ class VRPReferenceLimitError(VRPError, RuntimeError):
14
+ """Raised when an exact reference solve exceeds its declared safety limit."""
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from time import perf_counter
4
+
5
+ from .model import VRPInstance
6
+ from .solution import VRPRoute, VRPSolution
7
+ from .validation import route_distance, validate_vrp_solution
8
+
9
+
10
+ def _partial_valid(instance: VRPInstance, routes: list[list[str]]) -> bool:
11
+ # Check each route locally without requiring global coverage by constructing only capacity/timeline facts.
12
+ customers = instance.customer_map
13
+ for vehicle, ids in zip(instance.vehicles, routes):
14
+ if sum(customers[c].demand for c in ids) > vehicle.capacity + 1e-9:
15
+ return False
16
+ if instance.has_time_constraints:
17
+ from .timeline import build_route_timeline
18
+ if not build_route_timeline(instance, VRPRoute(vehicle.id, ids)).valid:
19
+ return False
20
+ return True
21
+
22
+
23
+ def nearest_feasible_insertion(instance: VRPInstance, *, improve: bool = True) -> VRPSolution:
24
+ started = perf_counter()
25
+ routes: list[list[str]] = [[] for _ in instance.vehicles]
26
+ unassigned: list[str] = []
27
+ for customer in instance.customers:
28
+ options: list[tuple[float, int, int, list[list[str]]]] = []
29
+ for vi, vehicle in enumerate(instance.vehicles):
30
+ for pos in range(len(routes[vi]) + 1):
31
+ candidate_routes = [list(r) for r in routes]
32
+ candidate_routes[vi].insert(pos, customer.id)
33
+ if not _partial_valid(instance, candidate_routes):
34
+ continue
35
+ before = route_distance(instance, VRPRoute(vehicle.id, routes[vi]))
36
+ after = route_distance(instance, VRPRoute(vehicle.id, candidate_routes[vi]))
37
+ options.append((after - before, vi, pos, candidate_routes))
38
+ if not options:
39
+ unassigned.append(customer.id)
40
+ continue
41
+ options.sort(key=lambda x: (x[0], x[1], x[2]))
42
+ routes = options[0][3]
43
+ if improve and not unassigned:
44
+ routes = _local_search(instance, routes)
45
+ route_objects = tuple(VRPRoute(v.id, ids) for v, ids in zip(instance.vehicles, routes))
46
+ objective = sum(route_distance(instance, r) for r in route_objects)
47
+ return VRPSolution(
48
+ route_objects,
49
+ objective,
50
+ method="nearest-feasible-insertion+local-search" if improve else "nearest-feasible-insertion",
51
+ is_exact=False,
52
+ optimality_proven=False,
53
+ unassigned_customer_ids=tuple(unassigned),
54
+ runtime_seconds=perf_counter() - started,
55
+ metadata={"heuristic": True},
56
+ )
57
+
58
+
59
+ def _local_search(instance: VRPInstance, routes: list[list[str]], *, max_passes: int = 20) -> list[list[str]]:
60
+ current = [list(r) for r in routes]
61
+ for _ in range(max_passes):
62
+ best = [list(r) for r in current]
63
+ best_obj = sum(route_distance(instance, VRPRoute(v.id, ids)) for v, ids in zip(instance.vehicles, current))
64
+ improved = False
65
+ # Intra-route reversal; full recomputation is used so asymmetric costs are safe.
66
+ for vi, ids in enumerate(current):
67
+ for i in range(len(ids)):
68
+ for j in range(i + 1, len(ids)):
69
+ cand = [list(r) for r in current]
70
+ cand[vi] = ids[:i] + list(reversed(ids[i : j + 1])) + ids[j + 1 :]
71
+ if not _partial_valid(instance, cand):
72
+ continue
73
+ obj = sum(route_distance(instance, VRPRoute(v.id, x)) for v, x in zip(instance.vehicles, cand))
74
+ if obj < best_obj - 1e-12:
75
+ best_obj, best, improved = obj, cand, True
76
+ # Relocate one customer across any two routes (or within a route).
77
+ for src in range(len(current)):
78
+ for idx in range(len(current[src])):
79
+ cid = current[src][idx]
80
+ for dst in range(len(current)):
81
+ base_without = [list(r) for r in current]
82
+ base_without[src].pop(idx)
83
+ for pos in range(len(base_without[dst]) + 1):
84
+ cand = [list(r) for r in base_without]
85
+ cand[dst].insert(pos, cid)
86
+ if not _partial_valid(instance, cand):
87
+ continue
88
+ obj = sum(route_distance(instance, VRPRoute(v.id, x)) for v, x in zip(instance.vehicles, cand))
89
+ if obj < best_obj - 1e-12:
90
+ best_obj, best, improved = obj, cand, True
91
+ current = best
92
+ if not improved:
93
+ break
94
+ return current
95
+
96
+
97
+ def clarke_wright_savings(instance: VRPInstance) -> VRPSolution:
98
+ """Classic parallel Clarke-Wright savings heuristic under explicit preconditions.
99
+
100
+ S8 intentionally limits this implementation to the classical symmetric,
101
+ single-depot, homogeneous-capacity CVRP. It fails closed outside those
102
+ assumptions rather than implying a guarantee for heterogeneous/asymmetric or
103
+ time-window routing.
104
+ """
105
+ from .errors import VRPValidationError
106
+ if instance.has_time_constraints:
107
+ raise VRPValidationError("Clarke-Wright S8 heuristic is qualified only for CVRP without time constraints")
108
+ starts={v.start_node_id for v in instance.vehicles}; ends={v.end_node_id for v in instance.vehicles}
109
+ if len(starts)!=1 or starts!=ends:
110
+ raise VRPValidationError("Clarke-Wright S8 heuristic requires one common start/end depot")
111
+ capacities={v.capacity for v in instance.vehicles}
112
+ if len(capacities)!=1:
113
+ raise VRPValidationError("Clarke-Wright S8 heuristic requires homogeneous vehicle capacities")
114
+ vals=instance.distances.values
115
+ n=len(vals)
116
+ for i in range(n):
117
+ for j in range(i+1,n):
118
+ if abs(vals[i][j]-vals[j][i])>1e-9:
119
+ raise VRPValidationError("Clarke-Wright S8 heuristic requires a symmetric distance matrix")
120
+ depot=next(iter(starts)); cap=next(iter(capacities)); cmap=instance.customer_map
121
+ routes: dict[int,list[str]]={i:[c.id] for i,c in enumerate(instance.customers)}
122
+ loads: dict[int,float]={i:c.demand for i,c in enumerate(instance.customers)}
123
+ owner={c.id:i for i,c in enumerate(instance.customers)}
124
+ savings=[]
125
+ for a in instance.customers:
126
+ for b in instance.customers:
127
+ if a.id>=b.id: continue
128
+ s=instance.distance(depot,a.node_id)+instance.distance(depot,b.node_id)-instance.distance(a.node_id,b.node_id)
129
+ savings.append((-s,a.id,b.id))
130
+ savings.sort()
131
+ for neg_s,a,b in savings:
132
+ # Once the fleet limit is met, do not accept a non-positive merge.
133
+ # Before that point a non-positive merge may still be necessary to
134
+ # reduce the number of routes to the available vehicle count.
135
+ if -neg_s <= 1e-12 and len(routes) <= len(instance.vehicles):
136
+ break
137
+ ra=owner[a]; rb=owner[b]
138
+ if ra==rb or ra not in routes or rb not in routes: continue
139
+ A=routes[ra]; B=routes[rb]
140
+ if loads[ra]+loads[rb] > cap+1e-9: continue
141
+ candidates=[]
142
+ # Classical endpoint merges, including route reversal because costs are symmetric.
143
+ if A[-1]==a and B[0]==b: candidates.append(A+B)
144
+ if A[0]==a and B[-1]==b: candidates.append(B+A)
145
+ if A[0]==a and B[0]==b: candidates.append(list(reversed(A))+B)
146
+ if A[-1]==a and B[-1]==b: candidates.append(A+list(reversed(B)))
147
+ if not candidates: continue
148
+ merged=min(candidates, key=lambda ids: (route_distance(instance, VRPRoute(instance.vehicles[0].id,ids)), tuple(ids)))
149
+ routes[ra]=merged; loads[ra]+=loads[rb]
150
+ del routes[rb]; del loads[rb]
151
+ for cid in merged: owner[cid]=ra
152
+ route_lists=sorted(routes.values(), key=lambda ids: (tuple(ids),len(ids)))
153
+ if len(route_lists)>len(instance.vehicles):
154
+ fallback = nearest_feasible_insertion(instance, improve=True)
155
+ return VRPSolution(
156
+ fallback.routes, fallback.objective, method="clarke-wright-savings:fallback-nearest",
157
+ is_exact=False, optimality_proven=False,
158
+ unassigned_customer_ids=fallback.unassigned_customer_ids,
159
+ runtime_seconds=fallback.runtime_seconds,
160
+ metadata={"qualified_scope":"symmetric-single-depot-homogeneous-cvrp","fallback":"nearest-feasible-insertion"},
161
+ )
162
+ assigned=route_lists; unassigned=()
163
+ route_objects=[]
164
+ for i,v in enumerate(instance.vehicles):
165
+ route_objects.append(VRPRoute(v.id, assigned[i] if i<len(assigned) else ()))
166
+ objective=sum(route_distance(instance,r) for r in route_objects)
167
+ return VRPSolution(route_objects,objective,method="clarke-wright-savings",is_exact=False,optimality_proven=False,unassigned_customer_ids=unassigned,metadata={"qualified_scope":"symmetric-single-depot-homogeneous-cvrp"})
@@ -0,0 +1,259 @@
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
+ def _meta(value: Mapping[str, Any] | None) -> Mapping[str, Any]:
12
+ return MappingProxyType(dict(value or {}))
13
+
14
+
15
+ def _clean_id(value: object, label: str) -> str:
16
+ text = str(value).strip()
17
+ if not text:
18
+ raise VRPValidationError(f"{label} must be a non-empty string")
19
+ return text
20
+
21
+
22
+ def _number(value: object, label: str, *, positive: bool = False) -> float:
23
+ if isinstance(value, bool):
24
+ raise VRPValidationError(f"{label} must be numeric, not bool")
25
+ try:
26
+ out = float(value)
27
+ except (TypeError, ValueError) as exc:
28
+ raise VRPValidationError(f"{label} must be numeric") from exc
29
+ if not isfinite(out):
30
+ raise VRPValidationError(f"{label} must be finite")
31
+ if positive and out <= 0.0:
32
+ raise VRPValidationError(f"{label} must be positive")
33
+ if not positive and out < 0.0:
34
+ raise VRPValidationError(f"{label} must be non-negative")
35
+ return out
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class VRPTimeWindow:
40
+ start: float
41
+ end: float
42
+
43
+ def __post_init__(self) -> None:
44
+ start = _number(self.start, "time-window start")
45
+ end = _number(self.end, "time-window end")
46
+ if end < start:
47
+ raise VRPValidationError("time-window end must be >= start")
48
+ object.__setattr__(self, "start", start)
49
+ object.__setattr__(self, "end", end)
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class VRPNode:
54
+ id: str
55
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False)
56
+
57
+ def __post_init__(self) -> None:
58
+ object.__setattr__(self, "id", _clean_id(self.id, "node id"))
59
+ object.__setattr__(self, "metadata", _meta(self.metadata))
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class VRPCustomer:
64
+ id: str
65
+ node_id: str
66
+ demand: float = 0.0
67
+ service_duration: float = 0.0
68
+ time_window: VRPTimeWindow | None = None
69
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False)
70
+
71
+ def __post_init__(self) -> None:
72
+ object.__setattr__(self, "id", _clean_id(self.id, "customer id"))
73
+ object.__setattr__(self, "node_id", _clean_id(self.node_id, "customer node_id"))
74
+ object.__setattr__(self, "demand", _number(self.demand, f"customer {self.id} demand"))
75
+ object.__setattr__(self, "service_duration", _number(self.service_duration, f"customer {self.id} service_duration"))
76
+ if self.time_window is not None and not isinstance(self.time_window, VRPTimeWindow):
77
+ raise VRPValidationError("customer time_window must be VRPTimeWindow or None")
78
+ object.__setattr__(self, "metadata", _meta(self.metadata))
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class VRPVehicle:
83
+ id: str
84
+ start_node_id: str
85
+ end_node_id: str | None = None
86
+ capacity: float = 1.0
87
+ shift_window: VRPTimeWindow | None = None
88
+ max_route_duration: float | None = None
89
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False)
90
+
91
+ def __post_init__(self) -> None:
92
+ vehicle_id = _clean_id(self.id, "vehicle id")
93
+ start = _clean_id(self.start_node_id, f"vehicle {vehicle_id} start_node_id")
94
+ end = start if self.end_node_id is None else _clean_id(self.end_node_id, f"vehicle {vehicle_id} end_node_id")
95
+ capacity = _number(self.capacity, f"vehicle {vehicle_id} capacity", positive=True)
96
+ if self.shift_window is not None and not isinstance(self.shift_window, VRPTimeWindow):
97
+ raise VRPValidationError("vehicle shift_window must be VRPTimeWindow or None")
98
+ duration = None
99
+ if self.max_route_duration is not None:
100
+ duration = _number(self.max_route_duration, f"vehicle {vehicle_id} max_route_duration", positive=True)
101
+ object.__setattr__(self, "id", vehicle_id)
102
+ object.__setattr__(self, "start_node_id", start)
103
+ object.__setattr__(self, "end_node_id", end)
104
+ object.__setattr__(self, "capacity", capacity)
105
+ object.__setattr__(self, "max_route_duration", duration)
106
+ object.__setattr__(self, "metadata", _meta(self.metadata))
107
+
108
+ @property
109
+ def start_time(self) -> float:
110
+ return 0.0 if self.shift_window is None else self.shift_window.start
111
+
112
+ @property
113
+ def latest_return(self) -> float | None:
114
+ return None if self.shift_window is None else self.shift_window.end
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class VRPMatrix:
119
+ values: tuple[tuple[float, ...], ...]
120
+
121
+ def __init__(self, values: Sequence[Sequence[float]], *, label: str = "matrix") -> None:
122
+ try:
123
+ raw = tuple(tuple(row) for row in values)
124
+ except TypeError as exc:
125
+ raise VRPValidationError(f"{label} must be a sequence of rows") from exc
126
+ if len(raw) < 2:
127
+ raise VRPValidationError(f"{label} must contain at least two nodes")
128
+ n = len(raw)
129
+ if any(len(row) != n for row in raw):
130
+ raise VRPValidationError(f"{label} must be square")
131
+ rows: list[tuple[float, ...]] = []
132
+ for i, row in enumerate(raw):
133
+ out: list[float] = []
134
+ for j, value in enumerate(row):
135
+ if isinstance(value, bool):
136
+ raise VRPValidationError(f"{label}[{i},{j}] must be numeric, not bool")
137
+ try:
138
+ v = float(value)
139
+ except (TypeError, ValueError) as exc:
140
+ raise VRPValidationError(f"{label}[{i},{j}] must be numeric") from exc
141
+ if not isfinite(v) or v < 0.0:
142
+ raise VRPValidationError(f"{label}[{i},{j}] must be finite and non-negative")
143
+ out.append(v)
144
+ rows.append(tuple(out))
145
+ object.__setattr__(self, "values", tuple(rows))
146
+
147
+ @property
148
+ def n_nodes(self) -> int:
149
+ return len(self.values)
150
+
151
+ def edge(self, i: int, j: int) -> float:
152
+ if isinstance(i, bool) or isinstance(j, bool) or not isinstance(i, int) or not isinstance(j, int):
153
+ raise VRPValidationError("matrix indices must be integers")
154
+ if not 0 <= i < self.n_nodes or not 0 <= j < self.n_nodes:
155
+ raise VRPValidationError("matrix index out of range")
156
+ return self.values[i][j]
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class VRPInstance:
161
+ nodes: tuple[VRPNode, ...]
162
+ customers: tuple[VRPCustomer, ...]
163
+ vehicles: tuple[VRPVehicle, ...]
164
+ distances: VRPMatrix
165
+ travel_times: VRPMatrix | None = None
166
+ name: str = "vrp"
167
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False)
168
+
169
+ def __init__(
170
+ self,
171
+ nodes: Sequence[VRPNode],
172
+ customers: Sequence[VRPCustomer],
173
+ vehicles: Sequence[VRPVehicle],
174
+ distances: VRPMatrix | Sequence[Sequence[float]],
175
+ *,
176
+ travel_times: VRPMatrix | Sequence[Sequence[float]] | None = None,
177
+ name: str = "vrp",
178
+ metadata: Mapping[str, Any] | None = None,
179
+ ) -> None:
180
+ node_tuple = tuple(nodes)
181
+ customer_tuple = tuple(customers)
182
+ vehicle_tuple = tuple(vehicles)
183
+ if len(node_tuple) < 2:
184
+ raise VRPValidationError("VRP requires at least two nodes")
185
+ if not customer_tuple:
186
+ raise VRPValidationError("VRP requires at least one customer")
187
+ if not vehicle_tuple:
188
+ raise VRPValidationError("VRP requires at least one vehicle")
189
+ node_ids = tuple(n.id for n in node_tuple)
190
+ customer_ids = tuple(c.id for c in customer_tuple)
191
+ vehicle_ids = tuple(v.id for v in vehicle_tuple)
192
+ if len(set(node_ids)) != len(node_ids):
193
+ raise VRPValidationError("VRP node ids must be unique")
194
+ if len(set(customer_ids)) != len(customer_ids):
195
+ raise VRPValidationError("VRP customer ids must be unique")
196
+ if len(set(vehicle_ids)) != len(vehicle_ids):
197
+ raise VRPValidationError("VRP vehicle ids must be unique")
198
+ node_set = set(node_ids)
199
+ unknown_customer_nodes = sorted({c.node_id for c in customer_tuple} - node_set)
200
+ unknown_vehicle_nodes = sorted({x for v in vehicle_tuple for x in (v.start_node_id, v.end_node_id)} - node_set)
201
+ if unknown_customer_nodes:
202
+ raise VRPValidationError(f"customers reference unknown nodes: {unknown_customer_nodes}")
203
+ if unknown_vehicle_nodes:
204
+ raise VRPValidationError(f"vehicles reference unknown nodes: {unknown_vehicle_nodes}")
205
+ d = distances if isinstance(distances, VRPMatrix) else VRPMatrix(distances, label="distance matrix")
206
+ if d.n_nodes != len(node_tuple):
207
+ raise VRPValidationError("distance matrix size must match node count")
208
+ tt = None
209
+ if travel_times is not None:
210
+ tt = travel_times if isinstance(travel_times, VRPMatrix) else VRPMatrix(travel_times, label="travel-time matrix")
211
+ if tt.n_nodes != len(node_tuple):
212
+ raise VRPValidationError("travel-time matrix size must match node count")
213
+ requires_time = any(c.time_window is not None for c in customer_tuple) or any(
214
+ v.shift_window is not None or v.max_route_duration is not None for v in vehicle_tuple
215
+ )
216
+ if requires_time and tt is None:
217
+ raise VRPValidationError("time-constrained VRP requires an explicit travel-time matrix; distance is never used as implicit time")
218
+ clean_name = _clean_id(name, "VRP name")
219
+ object.__setattr__(self, "nodes", node_tuple)
220
+ object.__setattr__(self, "customers", customer_tuple)
221
+ object.__setattr__(self, "vehicles", vehicle_tuple)
222
+ object.__setattr__(self, "distances", d)
223
+ object.__setattr__(self, "travel_times", tt)
224
+ object.__setattr__(self, "name", clean_name)
225
+ object.__setattr__(self, "metadata", _meta(metadata))
226
+
227
+ @property
228
+ def node_index(self) -> Mapping[str, int]:
229
+ return MappingProxyType({node.id: i for i, node in enumerate(self.nodes)})
230
+
231
+ @property
232
+ def customer_map(self) -> Mapping[str, VRPCustomer]:
233
+ return MappingProxyType({c.id: c for c in self.customers})
234
+
235
+ @property
236
+ def vehicle_map(self) -> Mapping[str, VRPVehicle]:
237
+ return MappingProxyType({v.id: v for v in self.vehicles})
238
+
239
+ @property
240
+ def has_time_constraints(self) -> bool:
241
+ return any(c.time_window is not None for c in self.customers) or any(
242
+ v.shift_window is not None or v.max_route_duration is not None for v in self.vehicles
243
+ )
244
+
245
+ def distance(self, from_node_id: str, to_node_id: str) -> float:
246
+ idx = self.node_index
247
+ try:
248
+ return self.distances.values[idx[from_node_id]][idx[to_node_id]]
249
+ except KeyError as exc:
250
+ raise VRPValidationError(f"unknown node in distance lookup: {exc.args[0]}") from exc
251
+
252
+ def travel_time(self, from_node_id: str, to_node_id: str) -> float:
253
+ if self.travel_times is None:
254
+ raise VRPValidationError("travel-time matrix is unavailable")
255
+ idx = self.node_index
256
+ try:
257
+ return self.travel_times.values[idx[from_node_id]][idx[to_node_id]]
258
+ except KeyError as exc:
259
+ raise VRPValidationError(f"unknown node in travel-time lookup: {exc.args[0]}") from exc
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from itertools import permutations
5
+ from math import comb, factorial
6
+ from time import perf_counter
7
+
8
+ from .errors import VRPReferenceLimitError
9
+ from .model import VRPInstance
10
+ from .solution import VRPRoute, VRPSolution, _INDEPENDENT_PROOF_TOKEN
11
+ from .validation import route_distance, validate_vrp_solution
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class VRPReferenceResult:
16
+ solution: VRPSolution | None
17
+ evaluations: int
18
+ search_space_upper_bound: int
19
+
20
+
21
+ def _compositions(total: int, parts: int):
22
+ if parts == 1:
23
+ yield (total,)
24
+ return
25
+ for first in range(total + 1):
26
+ for rest in _compositions(total - first, parts - 1):
27
+ yield (first,) + rest
28
+
29
+
30
+ def solve_vrp_reference(
31
+ instance: VRPInstance,
32
+ *,
33
+ max_customers: int = 8,
34
+ max_evaluations: int = 2_000_000,
35
+ ) -> VRPReferenceResult:
36
+ n = len(instance.customers)
37
+ m = len(instance.vehicles)
38
+ if isinstance(max_customers, bool) or not isinstance(max_customers, int) or max_customers < 1:
39
+ raise ValueError("max_customers must be a positive integer")
40
+ if isinstance(max_evaluations, bool) or not isinstance(max_evaluations, int) or max_evaluations < 1:
41
+ raise ValueError("max_evaluations must be a positive integer")
42
+ if n > max_customers:
43
+ raise VRPReferenceLimitError(f"reference VRP solver is limited to {max_customers} customers")
44
+ search_space = factorial(n) * comb(n + m - 1, m - 1)
45
+ if search_space > max_evaluations:
46
+ raise VRPReferenceLimitError(f"reference VRP search-space upper bound {search_space} exceeds max_evaluations={max_evaluations}")
47
+ customer_ids = tuple(c.id for c in instance.customers)
48
+ best_routes: tuple[VRPRoute, ...] | None = None
49
+ best_objective = float("inf")
50
+ evaluations = 0
51
+ started = perf_counter()
52
+ for order in permutations(customer_ids):
53
+ for sizes in _compositions(n, m):
54
+ evaluations += 1
55
+ routes: list[VRPRoute] = []
56
+ pos = 0
57
+ for vehicle, size in zip(instance.vehicles, sizes):
58
+ route_ids = order[pos : pos + size]
59
+ pos += size
60
+ routes.append(VRPRoute(vehicle.id, route_ids))
61
+ objective = sum(route_distance(instance, route) for route in routes)
62
+ if objective >= best_objective - 1e-12:
63
+ continue
64
+ candidate = VRPSolution(routes, objective, method="reference-exhaustive", is_exact=True, optimality_proven=True, _proof_token=_INDEPENDENT_PROOF_TOKEN)
65
+ report = validate_vrp_solution(instance, candidate)
66
+ if report.valid:
67
+ best_objective = objective
68
+ best_routes = tuple(routes)
69
+ if best_routes is None:
70
+ return VRPReferenceResult(None, evaluations, search_space)
71
+ solution = VRPSolution(
72
+ best_routes,
73
+ best_objective,
74
+ method="reference-exhaustive",
75
+ is_exact=True,
76
+ optimality_proven=True,
77
+ runtime_seconds=perf_counter() - started,
78
+ _proof_token=_INDEPENDENT_PROOF_TOKEN,
79
+ metadata={"evaluations": evaluations, "search_space_upper_bound": search_space},
80
+ )
81
+ return VRPReferenceResult(solution, evaluations, search_space)