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,60 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from math import isclose
5
+ from typing import Sequence
6
+
7
+ from .errors import TSPValidationError
8
+ from .model import TSPDistanceMatrix, TSPInstance
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class TSPDistanceAnalysis:
13
+ symmetric: bool
14
+ zero_diagonal: bool
15
+ triangle_inequality: bool | None
16
+ metric: bool | None
17
+ atol: float
18
+
19
+
20
+ def analyze_distance_matrix(
21
+ matrix: TSPDistanceMatrix | Sequence[Sequence[float]],
22
+ *,
23
+ atol: float = 1e-12,
24
+ check_triangle: bool = True,
25
+ ) -> TSPDistanceAnalysis:
26
+ if not isinstance(atol, (int, float)) or isinstance(atol, bool) or float(atol) < 0.0:
27
+ raise TSPValidationError("atol must be a non-negative finite number")
28
+ atol = float(atol)
29
+ if atol == float("inf") or atol != atol:
30
+ raise TSPValidationError("atol must be finite")
31
+ dm = matrix if isinstance(matrix, TSPDistanceMatrix) else TSPDistanceMatrix(matrix)
32
+ n = dm.n_nodes
33
+ symmetric = all(isclose(dm.values[i][j], dm.values[j][i], rel_tol=0.0, abs_tol=atol) for i in range(n) for j in range(i + 1, n))
34
+ zero_diagonal = all(abs(dm.values[i][i]) <= atol for i in range(n))
35
+ triangle: bool | None = None
36
+ metric: bool | None = None
37
+ if check_triangle:
38
+ triangle = True
39
+ for i in range(n):
40
+ for j in range(n):
41
+ dij = dm.values[i][j]
42
+ for k in range(n):
43
+ if dij > dm.values[i][k] + dm.values[k][j] + atol:
44
+ triangle = False
45
+ break
46
+ if not triangle:
47
+ break
48
+ if not triangle:
49
+ break
50
+ metric = symmetric and zero_diagonal and triangle
51
+ return TSPDistanceAnalysis(symmetric, zero_diagonal, triangle, metric, atol)
52
+
53
+
54
+ def route_cost(instance: TSPInstance, route: Sequence[int]) -> float:
55
+ if len(route) < 2:
56
+ raise TSPValidationError("route must contain at least two entries")
57
+ total = 0.0
58
+ for a, b in zip(route, route[1:]):
59
+ total += instance.distances.edge_cost(a, b)
60
+ return float(total)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class TSPError(ValueError):
5
+ """Base error for the SolverPilot TSP application pack."""
6
+
7
+
8
+ class TSPValidationError(TSPError):
9
+ """Raised when a TSP model, route, or solution is invalid."""
10
+
11
+
12
+ class TSPCompileError(TSPError):
13
+ """Raised when an exact compiler cannot preserve TSP semantics safely."""
14
+
15
+
16
+ class TSPReferenceLimitError(TSPError):
17
+ """Raised when an exponential exact reference solver hits its safety guard."""
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from time import perf_counter
4
+ from typing import Sequence
5
+
6
+ from .distance import analyze_distance_matrix, route_cost
7
+ from .errors import TSPValidationError
8
+ from .model import TSPInstance
9
+ from .solution import TSPSolution
10
+ from .validation import validate_tsp_route
11
+
12
+
13
+ def _start(instance: TSPInstance, value: int) -> int:
14
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value < instance.n_nodes:
15
+ raise TSPValidationError("start_index must be an in-range integer")
16
+ return value
17
+
18
+
19
+ def solve_tsp_nearest_neighbor(instance: TSPInstance, *, start_index: int = 0) -> TSPSolution:
20
+ start = _start(instance, start_index)
21
+ t0 = perf_counter()
22
+ unvisited = set(range(instance.n_nodes))
23
+ unvisited.remove(start)
24
+ route = [start]
25
+ current = start
26
+ while unvisited:
27
+ nxt = min(unvisited, key=lambda j: (instance.distances.values[current][j], j))
28
+ route.append(nxt)
29
+ unvisited.remove(nxt)
30
+ current = nxt
31
+ route.append(start)
32
+ objective = route_cost(instance, route)
33
+ return TSPSolution(route, objective, method="nearest-neighbor", is_exact=False, optimality_proven=False, runtime_seconds=perf_counter()-t0, metadata={"start_index": start})
34
+
35
+
36
+ def solve_tsp_multistart_nearest_neighbor(instance: TSPInstance, *, start_indices: Sequence[int] | None = None) -> TSPSolution:
37
+ if start_indices is None:
38
+ starts = tuple(range(instance.n_nodes))
39
+ else:
40
+ if isinstance(start_indices, (str, bytes)):
41
+ raise TSPValidationError("start_indices must be a sequence of integers")
42
+ starts = tuple(dict.fromkeys(_start(instance, x) for x in start_indices))
43
+ if not starts:
44
+ raise TSPValidationError("start_indices must not be empty")
45
+ t0 = perf_counter()
46
+ candidates = [solve_tsp_nearest_neighbor(instance, start_index=s) for s in starts]
47
+ best = min(candidates, key=lambda x: (x.objective, x.route))
48
+ return TSPSolution(best.route, best.objective, method="multistart-nearest-neighbor", is_exact=False, optimality_proven=False, runtime_seconds=perf_counter()-t0, metadata={"start_indices": starts, "candidate_objectives": tuple(x.objective for x in candidates)})
49
+
50
+
51
+ def solve_tsp_two_opt(
52
+ instance: TSPInstance,
53
+ *,
54
+ initial_route: Sequence[int] | TSPSolution | None = None,
55
+ start_index: int = 0,
56
+ max_iterations: int = 1000,
57
+ strategy: str = "best",
58
+ symmetry_atol: float = 1e-12,
59
+ ) -> TSPSolution:
60
+ if isinstance(max_iterations, bool) or not isinstance(max_iterations, int) or max_iterations < 1:
61
+ raise TSPValidationError("max_iterations must be a positive integer")
62
+ strategy = str(strategy).strip().lower()
63
+ if strategy not in {"best", "first"}:
64
+ raise TSPValidationError("strategy must be 'best' or 'first'")
65
+ start = _start(instance, start_index)
66
+ if initial_route is None:
67
+ route = (start, *(i for i in range(instance.n_nodes) if i != start), start)
68
+ elif isinstance(initial_route, TSPSolution):
69
+ route = tuple(initial_route.route)
70
+ else:
71
+ route = tuple(initial_route)
72
+ report = validate_tsp_route(instance, route)
73
+ if not report.valid:
74
+ raise TSPValidationError("initial_route is invalid: " + "; ".join(report.errors))
75
+ if route[0] != start:
76
+ raise TSPValidationError("initial_route must start at start_index")
77
+ t0 = perf_counter()
78
+ symmetric = analyze_distance_matrix(instance.distances, atol=symmetry_atol, check_triangle=False).symmetric
79
+ current = route
80
+ current_cost = route_cost(instance, current)
81
+ initial_cost = current_cost
82
+ moves_evaluated = 0
83
+ improvements = 0
84
+ iterations = 0
85
+ n = instance.n_nodes
86
+ for iteration in range(max_iterations):
87
+ iterations = iteration + 1
88
+ best_route: tuple[int, ...] | None = None
89
+ best_cost = current_cost
90
+ for i in range(1, n - 1):
91
+ for k in range(i + 1, n):
92
+ moves_evaluated += 1
93
+ candidate = current[:i] + tuple(reversed(current[i:k+1])) + current[k+1:]
94
+ if symmetric:
95
+ a, b, c, d = current[i-1], current[i], current[k], current[k+1]
96
+ candidate_cost = current_cost - instance.distances.values[a][b] - instance.distances.values[c][d] + instance.distances.values[a][c] + instance.distances.values[b][d]
97
+ else:
98
+ # Reversal changes all internal arc directions for an asymmetric TSP.
99
+ candidate_cost = route_cost(instance, candidate)
100
+ if candidate_cost + 1e-12 < best_cost:
101
+ best_route, best_cost = candidate, float(candidate_cost)
102
+ if strategy == "first":
103
+ break
104
+ if strategy == "first" and best_route is not None:
105
+ break
106
+ if best_route is None:
107
+ break
108
+ current = best_route
109
+ current_cost = route_cost(instance, current) # keep accumulated deltas from becoming the authority
110
+ improvements += 1
111
+ final_cost = route_cost(instance, current)
112
+ return TSPSolution(current, final_cost, method="two-opt", is_exact=False, optimality_proven=False, runtime_seconds=perf_counter()-t0, metadata={"start_index": start, "symmetric_fast_delta": symmetric, "initial_cost": initial_cost, "improvements": improvements, "moves_evaluated": moves_evaluated, "iterations": iterations, "strategy": strategy})
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from math import hypot, isfinite
5
+ from types import MappingProxyType
6
+ from typing import Any, Mapping, Sequence
7
+
8
+ from .errors import TSPValidationError
9
+
10
+
11
+ def _frozen_metadata(value: Mapping[str, Any] | None) -> Mapping[str, Any]:
12
+ return MappingProxyType(dict(value or {}))
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class TSPNode:
17
+ id: str
18
+ x: float | None = None
19
+ y: float | None = None
20
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False)
21
+
22
+ def __post_init__(self) -> None:
23
+ node_id = str(self.id).strip()
24
+ if not node_id:
25
+ raise TSPValidationError("TSP node id must be a non-empty string")
26
+ object.__setattr__(self, "id", node_id)
27
+ if (self.x is None) != (self.y is None):
28
+ raise TSPValidationError("TSP coordinates must provide both x and y or neither")
29
+ if self.x is not None:
30
+ if isinstance(self.x, bool) or isinstance(self.y, bool):
31
+ raise TSPValidationError("TSP node coordinates must be numeric, not bool")
32
+ try:
33
+ x = float(self.x)
34
+ y = float(self.y)
35
+ except (TypeError, ValueError) as exc:
36
+ raise TSPValidationError("TSP node coordinates must be numeric") from exc
37
+ if not isfinite(x) or not isfinite(y):
38
+ raise TSPValidationError("TSP node coordinates must be finite")
39
+ object.__setattr__(self, "x", x)
40
+ object.__setattr__(self, "y", y)
41
+ object.__setattr__(self, "metadata", _frozen_metadata(self.metadata))
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class TSPDistanceMatrix:
46
+ values: tuple[tuple[float, ...], ...]
47
+
48
+ def __init__(self, values: Sequence[Sequence[float]]):
49
+ try:
50
+ raw_rows = tuple(tuple(row) for row in values)
51
+ except TypeError as exc:
52
+ raise TSPValidationError("TSP distance matrix must be a sequence of rows") from exc
53
+ rows_list: list[tuple[float, ...]] = []
54
+ for i, row in enumerate(raw_rows):
55
+ normalized: list[float] = []
56
+ for j, value in enumerate(row):
57
+ if isinstance(value, bool):
58
+ raise TSPValidationError(f"distance at ({i}, {j}) must be numeric, not bool")
59
+ try:
60
+ normalized.append(float(value))
61
+ except (TypeError, ValueError) as exc:
62
+ raise TSPValidationError(f"distance at ({i}, {j}) must be numeric") from exc
63
+ rows_list.append(tuple(normalized))
64
+ rows = tuple(rows_list)
65
+ if len(rows) < 2:
66
+ raise TSPValidationError("a TSP distance matrix must contain at least two nodes")
67
+ n = len(rows)
68
+ if any(len(row) != n for row in rows):
69
+ raise TSPValidationError("TSP distance matrix must be square")
70
+ for i, row in enumerate(rows):
71
+ for j, value in enumerate(row):
72
+ if not isfinite(value):
73
+ raise TSPValidationError(f"distance at ({i}, {j}) must be finite")
74
+ if value < 0.0:
75
+ raise TSPValidationError(f"distance at ({i}, {j}) must be non-negative")
76
+ object.__setattr__(self, "values", rows)
77
+
78
+ @property
79
+ def n_nodes(self) -> int:
80
+ return len(self.values)
81
+
82
+ def edge_cost(self, start: int, end: int) -> float:
83
+ if isinstance(start, bool) or isinstance(end, bool):
84
+ raise TSPValidationError("node indices must be integers, not bool")
85
+ if not isinstance(start, int) or not isinstance(end, int):
86
+ raise TSPValidationError("node indices must be integers")
87
+ if not (0 <= start < self.n_nodes and 0 <= end < self.n_nodes):
88
+ raise TSPValidationError("node index outside TSP distance matrix")
89
+ return self.values[start][end]
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class TSPInstance:
94
+ nodes: tuple[TSPNode, ...]
95
+ distances: TSPDistanceMatrix
96
+ name: str = "tsp"
97
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False)
98
+
99
+ def __init__(
100
+ self,
101
+ nodes: Sequence[TSPNode],
102
+ distances: TSPDistanceMatrix | Sequence[Sequence[float]],
103
+ *,
104
+ name: str = "tsp",
105
+ metadata: Mapping[str, Any] | None = None,
106
+ ) -> None:
107
+ node_tuple = tuple(nodes)
108
+ matrix = distances if isinstance(distances, TSPDistanceMatrix) else TSPDistanceMatrix(distances)
109
+ if len(node_tuple) != matrix.n_nodes:
110
+ raise TSPValidationError("node count must match the distance matrix size")
111
+ ids = tuple(node.id for node in node_tuple)
112
+ if len(set(ids)) != len(ids):
113
+ raise TSPValidationError("TSP node ids must be unique")
114
+ clean_name = str(name).strip()
115
+ if not clean_name:
116
+ raise TSPValidationError("TSP instance name must be non-empty")
117
+ object.__setattr__(self, "nodes", node_tuple)
118
+ object.__setattr__(self, "distances", matrix)
119
+ object.__setattr__(self, "name", clean_name)
120
+ object.__setattr__(self, "metadata", _frozen_metadata(metadata))
121
+
122
+ @property
123
+ def n_nodes(self) -> int:
124
+ return len(self.nodes)
125
+
126
+ @property
127
+ def node_ids(self) -> tuple[str, ...]:
128
+ return tuple(node.id for node in self.nodes)
129
+
130
+ @classmethod
131
+ def from_distance_matrix(
132
+ cls,
133
+ matrix: Sequence[Sequence[float]],
134
+ *,
135
+ node_ids: Sequence[str] | None = None,
136
+ name: str = "tsp",
137
+ metadata: Mapping[str, Any] | None = None,
138
+ ) -> "TSPInstance":
139
+ distances = TSPDistanceMatrix(matrix)
140
+ ids = tuple(str(i) for i in range(distances.n_nodes)) if node_ids is None else tuple(node_ids)
141
+ if len(ids) != distances.n_nodes:
142
+ raise TSPValidationError("node_ids length must match the distance matrix")
143
+ return cls(tuple(TSPNode(node_id) for node_id in ids), distances, name=name, metadata=metadata)
144
+
145
+ @classmethod
146
+ def from_coordinates(
147
+ cls,
148
+ coordinates: Sequence[tuple[float, float]],
149
+ *,
150
+ node_ids: Sequence[str] | None = None,
151
+ round_digits: int | None = None,
152
+ name: str = "tsp",
153
+ metadata: Mapping[str, Any] | None = None,
154
+ ) -> "TSPInstance":
155
+ if len(coordinates) < 2:
156
+ raise TSPValidationError("a TSP instance requires at least two coordinates")
157
+ if round_digits is not None:
158
+ if isinstance(round_digits, bool) or not isinstance(round_digits, int) or round_digits < 0:
159
+ raise TSPValidationError("round_digits must be a non-negative integer or None")
160
+ ids = tuple(str(i) for i in range(len(coordinates))) if node_ids is None else tuple(node_ids)
161
+ if len(ids) != len(coordinates):
162
+ raise TSPValidationError("node_ids length must match coordinates")
163
+ nodes = tuple(TSPNode(node_id, x, y) for node_id, (x, y) in zip(ids, coordinates))
164
+ rows: list[tuple[float, ...]] = []
165
+ for a in nodes:
166
+ if a.x is None or a.y is None:
167
+ raise TSPValidationError("coordinate-backed TSP node unexpectedly lacks coordinates")
168
+ row: list[float] = []
169
+ for b in nodes:
170
+ if b.x is None or b.y is None:
171
+ raise TSPValidationError("coordinate-backed TSP node unexpectedly lacks coordinates")
172
+ value = hypot(a.x - b.x, a.y - b.y)
173
+ row.append(round(value, round_digits) if round_digits is not None else value)
174
+ rows.append(tuple(row))
175
+ return cls(nodes, rows, name=name, metadata=metadata)
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from itertools import permutations
4
+ from math import inf
5
+ from time import perf_counter
6
+
7
+ from .distance import route_cost
8
+ from .errors import TSPReferenceLimitError, TSPValidationError
9
+ from .model import TSPInstance
10
+ from .solution import TSPSolution, _INDEPENDENT_PROOF_TOKEN
11
+
12
+
13
+ def _validate_start(instance: TSPInstance, start_index: int) -> int:
14
+ if isinstance(start_index, bool) or not isinstance(start_index, int):
15
+ raise TSPValidationError("start_index must be an integer")
16
+ if not 0 <= start_index < instance.n_nodes:
17
+ raise TSPValidationError("start_index is outside the TSP instance")
18
+ return start_index
19
+
20
+
21
+ def _validate_limit(max_nodes: int, *, minimum: int = 2) -> int:
22
+ if isinstance(max_nodes, bool) or not isinstance(max_nodes, int) or max_nodes < minimum:
23
+ raise TSPValidationError(f"max_nodes must be an integer >= {minimum}")
24
+ return max_nodes
25
+
26
+
27
+ def solve_tsp_brute_force(instance: TSPInstance, *, start_index: int = 0, max_nodes: int = 10) -> TSPSolution:
28
+ start = _validate_start(instance, start_index)
29
+ limit = _validate_limit(max_nodes)
30
+ if instance.n_nodes > limit:
31
+ raise TSPReferenceLimitError(f"brute force refused {instance.n_nodes} nodes because max_nodes={limit}")
32
+ t0 = perf_counter()
33
+ rest = tuple(i for i in range(instance.n_nodes) if i != start)
34
+ best_route: tuple[int, ...] | None = None
35
+ best_cost = inf
36
+ examined = 0
37
+ for ordering in permutations(rest):
38
+ route = (start, *ordering, start)
39
+ cost = route_cost(instance, route)
40
+ examined += 1
41
+ if cost < best_cost or (cost == best_cost and (best_route is None or route < best_route)):
42
+ best_route, best_cost = route, cost
43
+ if best_route is None:
44
+ raise RuntimeError("TSP brute-force search produced no route")
45
+ return TSPSolution(
46
+ best_route,
47
+ best_cost,
48
+ method="brute-force",
49
+ is_exact=True,
50
+ optimality_proven=True,
51
+ _proof_token=_INDEPENDENT_PROOF_TOKEN,
52
+ runtime_seconds=perf_counter() - t0,
53
+ metadata={"start_index": start, "permutations_evaluated": examined, "max_nodes": limit},
54
+ )
55
+
56
+
57
+ def solve_tsp_held_karp(instance: TSPInstance, *, start_index: int = 0, max_nodes: int = 18) -> TSPSolution:
58
+ start = _validate_start(instance, start_index)
59
+ limit = _validate_limit(max_nodes)
60
+ if instance.n_nodes > limit:
61
+ raise TSPReferenceLimitError(f"Held-Karp refused {instance.n_nodes} nodes because max_nodes={limit}")
62
+ t0 = perf_counter()
63
+ nodes = tuple(i for i in range(instance.n_nodes) if i != start)
64
+ bit = {node: pos for pos, node in enumerate(nodes)}
65
+ full = (1 << len(nodes)) - 1
66
+ cost: dict[tuple[int, int], float] = {}
67
+ parent: dict[tuple[int, int], int | None] = {}
68
+ for node in nodes:
69
+ mask = 1 << bit[node]
70
+ cost[(mask, node)] = instance.distances.values[start][node]
71
+ parent[(mask, node)] = None
72
+ for mask in range(1, full + 1):
73
+ if mask.bit_count() <= 1:
74
+ continue
75
+ for endpoint in nodes:
76
+ ebit = 1 << bit[endpoint]
77
+ if not (mask & ebit):
78
+ continue
79
+ prev_mask = mask ^ ebit
80
+ best = inf
81
+ best_prev: int | None = None
82
+ for prev in nodes:
83
+ pbit = 1 << bit[prev]
84
+ if not (prev_mask & pbit):
85
+ continue
86
+ candidate = cost[(prev_mask, prev)] + instance.distances.values[prev][endpoint]
87
+ if candidate < best or (candidate == best and (best_prev is None or prev < best_prev)):
88
+ best = candidate
89
+ best_prev = prev
90
+ cost[(mask, endpoint)] = best
91
+ parent[(mask, endpoint)] = best_prev
92
+ best_total = inf
93
+ end: int | None = None
94
+ for endpoint in nodes:
95
+ candidate = cost[(full, endpoint)] + instance.distances.values[endpoint][start]
96
+ if candidate < best_total or (candidate == best_total and (end is None or endpoint < end)):
97
+ best_total = candidate
98
+ end = endpoint
99
+ if end is None:
100
+ raise RuntimeError("Held-Karp search produced no terminal node")
101
+ rev: list[int] = []
102
+ mask = full
103
+ cur: int | None = end
104
+ while cur is not None:
105
+ rev.append(cur)
106
+ prev = parent[(mask, cur)]
107
+ mask ^= 1 << bit[cur]
108
+ cur = prev
109
+ route = (start, *reversed(rev), start)
110
+ # Recompute independently from the reconstructed route; the DP value is not trusted as output cost.
111
+ objective = route_cost(instance, route)
112
+ return TSPSolution(
113
+ route,
114
+ objective,
115
+ method="held-karp",
116
+ is_exact=True,
117
+ optimality_proven=True,
118
+ _proof_token=_INDEPENDENT_PROOF_TOKEN,
119
+ runtime_seconds=perf_counter() - t0,
120
+ metadata={"start_index": start, "states_stored": len(cost), "max_nodes": limit, "dp_objective": best_total},
121
+ )
@@ -0,0 +1,65 @@
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 TSPValidationError
9
+
10
+
11
+ _INDEPENDENT_PROOF_TOKEN = object()
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class TSPSolution:
16
+ route: tuple[int, ...]
17
+ objective: float
18
+ method: str
19
+ is_exact: bool
20
+ optimality_proven: bool
21
+ runtime_seconds: float | None = None
22
+ metadata: Mapping[str, Any] = field(default_factory=dict, compare=False)
23
+
24
+ def __init__(
25
+ self,
26
+ route: Sequence[int],
27
+ objective: float,
28
+ *,
29
+ method: str,
30
+ is_exact: bool,
31
+ optimality_proven: bool,
32
+ runtime_seconds: float | None = None,
33
+ metadata: Mapping[str, Any] | None = None,
34
+ _proof_token: object | None = None,
35
+ ) -> None:
36
+ normalized: list[int] = []
37
+ for value in route:
38
+ if isinstance(value, bool) or not isinstance(value, int):
39
+ raise TSPValidationError("TSP route entries must be integer node indices")
40
+ normalized.append(int(value))
41
+ obj = float(objective)
42
+ if not isfinite(obj):
43
+ raise TSPValidationError("TSP objective must be finite")
44
+ if type(is_exact) is not bool or type(optimality_proven) is not bool:
45
+ raise TSPValidationError("is_exact and optimality_proven must be bool values")
46
+ if optimality_proven and not is_exact:
47
+ raise TSPValidationError("optimality_proven requires is_exact=True")
48
+ if optimality_proven and _proof_token is not _INDEPENDENT_PROOF_TOKEN:
49
+ raise TSPValidationError(
50
+ "independent optimality proof may only be issued by SolverPilot verified/reference paths"
51
+ )
52
+ method_value = str(method).strip()
53
+ if not method_value:
54
+ raise TSPValidationError("TSP solution method must be non-empty")
55
+ if runtime_seconds is not None:
56
+ runtime_seconds = float(runtime_seconds)
57
+ if not isfinite(runtime_seconds) or runtime_seconds < 0.0:
58
+ raise TSPValidationError("runtime_seconds must be finite and non-negative")
59
+ object.__setattr__(self, "route", tuple(normalized))
60
+ object.__setattr__(self, "objective", obj)
61
+ object.__setattr__(self, "method", method_value)
62
+ object.__setattr__(self, "is_exact", is_exact)
63
+ object.__setattr__(self, "optimality_proven", optimality_proven)
64
+ object.__setattr__(self, "runtime_seconds", runtime_seconds)
65
+ object.__setattr__(self, "metadata", MappingProxyType(dict(metadata or {})))
@@ -0,0 +1,92 @@
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 .distance import route_cost
9
+ from .model import TSPInstance
10
+ from .solution import TSPSolution
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class TSPValidationReport:
15
+ valid: bool
16
+ errors: tuple[str, ...]
17
+ warnings: tuple[str, ...]
18
+ objective_recomputed: float | None
19
+ objective_reported: float | None
20
+ objective_difference: float | None
21
+
22
+
23
+ def validate_tsp_route(
24
+ instance: TSPInstance,
25
+ route_or_solution: Sequence[int] | TSPSolution,
26
+ *,
27
+ reported_objective: float | None = None,
28
+ atol: float = 1e-9,
29
+ rtol: float = 1e-9,
30
+ ) -> TSPValidationReport:
31
+ if isinstance(atol, bool) or isinstance(rtol, bool):
32
+ raise ValueError("TSP validation tolerances must be non-negative finite numbers")
33
+ atol = float(atol); rtol = float(rtol)
34
+ if not isfinite(atol) or not isfinite(rtol) or atol < 0.0 or rtol < 0.0:
35
+ raise ValueError("TSP validation tolerances must be non-negative finite numbers")
36
+ errors: list[str] = []
37
+ warnings: list[str] = []
38
+ if isinstance(route_or_solution, TSPSolution):
39
+ route = tuple(route_or_solution.route)
40
+ if reported_objective is None:
41
+ reported_objective = route_or_solution.objective
42
+ else:
43
+ try:
44
+ route = tuple(route_or_solution)
45
+ except TypeError:
46
+ return TSPValidationReport(False, ("route must be iterable",), (), None, reported_objective, None)
47
+ if len(route) != instance.n_nodes + 1:
48
+ errors.append(f"route must contain n_nodes + 1 entries ({instance.n_nodes + 1})")
49
+ normalized: list[int] = []
50
+ for value in route:
51
+ if isinstance(value, bool) or not isinstance(value, int):
52
+ errors.append("route entries must be integer node indices")
53
+ continue
54
+ normalized.append(int(value))
55
+ if len(normalized) == len(route):
56
+ invalid = sorted({x for x in normalized if x < 0 or x >= instance.n_nodes})
57
+ if invalid:
58
+ errors.append(f"route contains out-of-range node indices: {invalid}")
59
+ if normalized and normalized[0] != normalized[-1]:
60
+ errors.append("route must be closed")
61
+ if not invalid and len(normalized) >= 2:
62
+ interior = normalized[:-1]
63
+ counts = Counter(interior)
64
+ missing = sorted(set(range(instance.n_nodes)) - set(interior))
65
+ duplicated = sorted(k for k, v in counts.items() if v != 1)
66
+ if missing:
67
+ errors.append(f"route is missing nodes: {missing}")
68
+ if duplicated:
69
+ errors.append(f"route does not visit nodes exactly once: {duplicated}")
70
+ recomputed: float | None = None
71
+ if not errors:
72
+ recomputed = route_cost(instance, normalized)
73
+ reported: float | None = None
74
+ difference: float | None = None
75
+ if reported_objective is not None:
76
+ if isinstance(reported_objective, bool):
77
+ errors.append("reported objective must be numeric, not bool")
78
+ reported_objective = None
79
+ try:
80
+ reported = None if reported_objective is None else float(reported_objective)
81
+ except (TypeError, ValueError):
82
+ errors.append("reported objective must be numeric")
83
+ else:
84
+ if reported is None:
85
+ pass
86
+ elif not isfinite(reported):
87
+ errors.append("reported objective must be finite")
88
+ elif recomputed is not None:
89
+ difference = reported - recomputed
90
+ if not isclose(reported, recomputed, rel_tol=rtol, abs_tol=atol):
91
+ errors.append("reported objective does not match independently recomputed route cost")
92
+ return TSPValidationReport(not errors, tuple(errors), tuple(warnings), recomputed, reported, difference)