solverpilot 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- solverpilot-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|