multi-puzzle-solver 0.1.0__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.

Potentially problematic release.


This version of multi-puzzle-solver might be problematic. Click here for more details.

Files changed (31) hide show
  1. multi_puzzle_solver-0.1.0.dist-info/METADATA +1897 -0
  2. multi_puzzle_solver-0.1.0.dist-info/RECORD +31 -0
  3. multi_puzzle_solver-0.1.0.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-0.1.0.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +26 -0
  6. puzzle_solver/core/utils.py +127 -0
  7. puzzle_solver/core/utils_ortools.py +78 -0
  8. puzzle_solver/puzzles/bridges/bridges.py +106 -0
  9. puzzle_solver/puzzles/dominosa/dominosa.py +136 -0
  10. puzzle_solver/puzzles/filling/filling.py +192 -0
  11. puzzle_solver/puzzles/guess/guess.py +231 -0
  12. puzzle_solver/puzzles/inertia/inertia.py +122 -0
  13. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +204 -0
  14. puzzle_solver/puzzles/inertia/tsp.py +398 -0
  15. puzzle_solver/puzzles/keen/keen.py +99 -0
  16. puzzle_solver/puzzles/light_up/light_up.py +95 -0
  17. puzzle_solver/puzzles/magnets/magnets.py +117 -0
  18. puzzle_solver/puzzles/map/map.py +56 -0
  19. puzzle_solver/puzzles/minesweeper/minesweeper.py +110 -0
  20. puzzle_solver/puzzles/mosaic/mosaic.py +48 -0
  21. puzzle_solver/puzzles/nonograms/nonograms.py +126 -0
  22. puzzle_solver/puzzles/pearl/pearl.py +151 -0
  23. puzzle_solver/puzzles/range/range.py +154 -0
  24. puzzle_solver/puzzles/signpost/signpost.py +95 -0
  25. puzzle_solver/puzzles/singles/singles.py +116 -0
  26. puzzle_solver/puzzles/sudoku/sudoku.py +90 -0
  27. puzzle_solver/puzzles/tents/tents.py +110 -0
  28. puzzle_solver/puzzles/towers/towers.py +139 -0
  29. puzzle_solver/puzzles/tracks/tracks.py +170 -0
  30. puzzle_solver/puzzles/undead/undead.py +168 -0
  31. puzzle_solver/puzzles/unruly/unruly.py +86 -0
@@ -0,0 +1,204 @@
1
+ """
2
+ This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
3
+ Look at the ./input_output/ directory for examples of input images and output json files.
4
+ The output json is used in the test_solve.py file to test the solver.
5
+ """
6
+ from pathlib import Path
7
+ import numpy as np
8
+ import numpy as np
9
+ try:
10
+ import cv2 as cv
11
+ except ImportError:
12
+ cv = None
13
+ try:
14
+ from PIL import Image
15
+ except ImportError:
16
+ Image = None
17
+
18
+ def load_cell_templates(p: Path) -> dict[str, dict]:
19
+ img = Image.open(p)
20
+ src = cv.imread(p, cv.IMREAD_COLOR)
21
+ rgb = np.asarray(img).astype(np.float32) / 255.0
22
+ if len(src.shape) != 2:
23
+ gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
24
+ else:
25
+ gray = src
26
+ gray = cv.bitwise_not(gray)
27
+ bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
28
+ cv.THRESH_BINARY, 15, -2)
29
+ return {"gray": gray}
30
+
31
+
32
+
33
+ def _grad_mag(img: np.ndarray) -> np.ndarray:
34
+ """Fast gradient magnitude (no dependencies)."""
35
+ img = img.astype(np.float32)
36
+ # forward diffs with clamped prepend to keep shape
37
+ gx = np.diff(img, axis=1, prepend=img[:, :1])
38
+ gy = np.diff(img, axis=0, prepend=img[:1, :])
39
+ return np.hypot(gx, gy)
40
+
41
+ def get_distance_robust(cell: np.ndarray, template: np.ndarray, max_shift: int = 2, use_edges: bool = True) -> float:
42
+ """
43
+ Distance robust to small translations & brightness/contrast changes.
44
+ - Compares gradient magnitude images (toggle with use_edges).
45
+ - Z-score normalizes each overlap region.
46
+ - Returns the minimum mean squared error across integer shifts
47
+ in [-max_shift, max_shift] for both axes.
48
+ """
49
+ A = cell.astype(np.float32)
50
+ B = template.astype(np.float32)
51
+
52
+ if use_edges:
53
+ A = _grad_mag(A)
54
+ B = _grad_mag(B)
55
+
56
+ H, W = A.shape
57
+ best = np.inf
58
+
59
+ for dy in range(-max_shift, max_shift + 1):
60
+ for dx in range(-max_shift, max_shift + 1):
61
+ # compute overlapping slices for this shift
62
+ y0a = max(0, dy); y1a = H + min(0, dy)
63
+ x0a = max(0, dx); x1a = W + min(0, dx)
64
+ y0b = max(0, -dy); y1b = H + min(0, -dy)
65
+ x0b = max(0, -dx); x1b = W + min(0, -dx)
66
+
67
+ if y1a <= y0a or x1a <= x0a: # no overlap
68
+ continue
69
+
70
+ Aa = A[y0a:y1a, x0a:x1a]
71
+ Bb = B[y0b:y1b, x0b:x1b]
72
+
73
+ # per-overlap z-score to remove brightness/contrast bias
74
+ Aa = (Aa - Aa.mean()) / (Aa.std() + 1e-6)
75
+ Bb = (Bb - Bb.mean()) / (Bb.std() + 1e-6)
76
+
77
+ mse = np.mean((Aa - Bb) ** 2)
78
+ if mse < best:
79
+ best = mse
80
+ return float(best)
81
+
82
+ def distance_to_cell_templates(cell_rgb_image, templates: dict[str, dict]) -> dict[str, float]:
83
+ W, H = 100, 100
84
+ cell = cv.resize(cell_rgb_image, (W, H), interpolation=cv.INTER_LINEAR)
85
+ distances = {}
86
+ for name, rec in templates.items():
87
+ if rec["gray"].shape != (W, H):
88
+ rec["gray"] = cv.resize(rec["gray"], (W, H), interpolation=cv.INTER_LINEAR)
89
+ distances[name] = get_distance_robust(cell, rec["gray"])
90
+ return distances
91
+
92
+ def extract_lines(bw):
93
+ # Create the images that will use to extract the horizontal and vertical lines
94
+ horizontal = np.copy(bw)
95
+ vertical = np.copy(bw)
96
+
97
+ cols = horizontal.shape[1]
98
+ horizontal_size = cols // 5
99
+ # Create structure element for extracting horizontal lines through morphology operations
100
+ horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
101
+ horizontal = cv.erode(horizontal, horizontalStructure)
102
+ horizontal = cv.dilate(horizontal, horizontalStructure)
103
+ horizontal_means = np.mean(horizontal, axis=1)
104
+ horizontal_cutoff = np.percentile(horizontal_means, 50)
105
+ # location where the horizontal lines are
106
+ horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
107
+ # print(f"horizontal_idx: {horizontal_idx}")
108
+ height = len(horizontal_idx)
109
+ # show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
110
+
111
+ rows = vertical.shape[0]
112
+ verticalsize = rows // 5
113
+ # Create structure element for extracting vertical lines through morphology operations
114
+ verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
115
+ vertical = cv.erode(vertical, verticalStructure)
116
+ vertical = cv.dilate(vertical, verticalStructure)
117
+ vertical_means = np.mean(vertical, axis=0)
118
+ vertical_cutoff = np.percentile(vertical_means, 50)
119
+ vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
120
+ # print(f"vertical_idx: {vertical_idx}")
121
+ width = len(vertical_idx)
122
+ # print(f"height: {height}, width: {width}")
123
+ # print(f"vertical_means: {vertical_means}")
124
+ # show_wait_destroy("vertical", vertical) # this has the vertical lines
125
+
126
+ vertical = cv.bitwise_not(vertical)
127
+ # show_wait_destroy("vertical_bit", vertical)
128
+
129
+ return (width, height), (horizontal_idx, vertical_idx)
130
+
131
+ def show_wait_destroy(winname, img):
132
+ cv.imshow(winname, img)
133
+ cv.moveWindow(winname, 500, 0)
134
+ cv.waitKey(0)
135
+ cv.destroyWindow(winname)
136
+
137
+
138
+
139
+ def main(image):
140
+ CELL_BLANK = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_blank.png')
141
+ CELL_WALL = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_wall.png')
142
+ CELL_GEM = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_gem.png')
143
+ CELL_MINE = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_mine.png')
144
+ CELL_STOP = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_stop.png')
145
+ CELL_START = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_start.png')
146
+ TEMPLATES = {
147
+ "blank": CELL_BLANK,
148
+ "gem": CELL_GEM,
149
+ "mine": CELL_MINE,
150
+ "stop": CELL_STOP,
151
+ "start": CELL_START,
152
+ "wall": CELL_WALL,
153
+ }
154
+
155
+
156
+ image_path = Path(image)
157
+ output_path = image_path.parent / (image_path.stem + '.json')
158
+ src = cv.imread(image, cv.IMREAD_COLOR)
159
+ assert src is not None, f'Error opening image: {image}'
160
+ if len(src.shape) != 2:
161
+ gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
162
+ else:
163
+ gray = src
164
+ # now the image is in grayscale
165
+
166
+ # Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
167
+ gray = cv.bitwise_not(gray)
168
+ bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
169
+ cv.THRESH_BINARY, 15, -2)
170
+ # show_wait_destroy("binary", bw)
171
+
172
+ # show_wait_destroy("src", src)
173
+ (width, height), (horizontal_idx, vertical_idx) = extract_lines(bw)
174
+ print(f"width: {width}, height: {height}")
175
+ print(f"horizontal_idx: {horizontal_idx}")
176
+ print(f"vertical_idx: {vertical_idx}")
177
+ output = np.zeros((height - 1, width - 1), dtype=object)
178
+ output_map = {'blank': ' ', 'gem': 'G', 'mine': 'M', 'stop': 'O', 'start': 'B', 'wall': 'W'}
179
+ for j in range(height - 1):
180
+ for i in range(width - 1):
181
+ hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
182
+ vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
183
+ cell = gray[hidx1:hidx2, vidx1:vidx2]
184
+ # show_wait_destroy(f"cell_{i}_{j}", cell)
185
+ distances = distance_to_cell_templates(cell, TEMPLATES)
186
+ # print(f"distances: {distances}")
187
+ best_match = min(distances, key=distances.get)
188
+ # print(f"best_match: {best_match}")
189
+ # cv.imwrite(f"i_{i}_j_{j}.png", cell)
190
+ output[j, i] = output_map[best_match]
191
+
192
+ with open(output_path, 'w') as f:
193
+ f.write('[\n')
194
+ for i, row in enumerate(output):
195
+ f.write(' ' + str(row.tolist()).replace("'", '"'))
196
+ if i != len(output) - 1:
197
+ f.write(',')
198
+ f.write('\n')
199
+ f.write(']')
200
+
201
+ if __name__ == '__main__':
202
+ main(Path(__file__).parent / 'input_output' / 'inertia.html#15x12%23919933974949365.png')
203
+ main(Path(__file__).parent / 'input_output' / 'inertia.html#15x12%23518193627142459.png')
204
+ main(Path(__file__).parent / 'input_output' / 'inertia.html#20x16%23200992952951435.png')
@@ -0,0 +1,398 @@
1
+ from collections import deque, defaultdict
2
+ from typing import Dict, List, Tuple, Set, Any, Optional
3
+ import random
4
+ from ortools.constraint_solver import pywrapcp, routing_enums_pb2
5
+
6
+ Pos = Any # Hashable node id
7
+
8
+ def solve_optimal_walk(
9
+ start_pos: Pos,
10
+ edges: Set[Tuple[Pos, Pos]],
11
+ gems_to_edges: "defaultdict[Pos, List[Tuple[Pos, Pos]]]",
12
+ *,
13
+ restarts: int, # try more for harder instances (e.g., 48–128)
14
+ time_limit_ms: int, # per restart
15
+ seed: int,
16
+ verbose: bool
17
+ ) -> List[Tuple[Pos, Pos]]:
18
+ """
19
+ Directed edges. For each gem (key in gems_to_edges), traverse >=1 of its directed edges.
20
+ Returns the actual directed walk (edge-by-edge) from start_pos.
21
+ Uses multi-start Noon–Bean + OR-Tools and post-optimizes the representative order.
22
+
23
+ I significantly used AI for the implementation of this function which is why it is a bit messy with useless comments.
24
+ """
25
+ # ---- Multi-start Noon–Bean + metaheuristic sweeps ----
26
+ meta_list = [
27
+ # routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH,
28
+ # routing_enums_pb2.LocalSearchMetaheuristic.SIMULATED_ANNEALING,
29
+ routing_enums_pb2.LocalSearchMetaheuristic.TABU_SEARCH,
30
+ ]
31
+ expected_runtime = time_limit_ms * restarts * len(meta_list)
32
+ if verbose:
33
+ print(f'minimum runtime: {expected_runtime/1000:.1f} seconds')
34
+ rng = random.Random(seed)
35
+
36
+ assert start_pos is not None, 'start_pos is required'
37
+ assert edges, 'edges must be non-empty'
38
+ assert gems_to_edges, 'gems_to_edges must be non-empty'
39
+ assert all(all(e in edges for e in elist) for elist in gems_to_edges.values()), \
40
+ 'all gem edges must be in edges'
41
+
42
+ nodes = set(u for (u, v) in edges) | set(v for (u, v) in edges)
43
+ assert start_pos in nodes, 'start_pos must be in edges'
44
+ assert all(u in nodes and v in nodes for (u, v) in edges)
45
+
46
+ # ---------- Directed adjacency ----------
47
+ adj: Dict[Pos, List[Pos]] = {u: [] for u in nodes}
48
+ for (u, v) in edges:
49
+ adj[u].append(v)
50
+
51
+ # ---------- States: ONLY the given directed gem edges ----------
52
+ states: List[Tuple[Pos, Pos]] = [] # index -> (tail, head)
53
+ state_group: List[Pos] = [] # index -> gem_id
54
+ group_to_state_indices_original: Dict[Pos, List[int]] = defaultdict(list)
55
+
56
+ for gem_id, elist in gems_to_edges.items():
57
+ for (u, v) in elist:
58
+ idx = len(states)
59
+ states.append((u, v))
60
+ state_group.append(gem_id)
61
+ group_to_state_indices_original[gem_id].append(idx)
62
+
63
+ # Depot node
64
+ DEPOT = len(states)
65
+ states.append((None, None))
66
+ state_group.append("__DEPOT__")
67
+
68
+ N_no_depot = DEPOT
69
+ N = len(states)
70
+ gem_groups = list(group_to_state_indices_original.keys())
71
+ all_gems: Set[Pos] = set(gem_groups)
72
+
73
+ # ---------- Directed shortest paths among relevant nodes ----------
74
+ relevant: Set[Pos] = {start_pos}
75
+ for (tail, head) in states[:N_no_depot]:
76
+ relevant.add(tail)
77
+ relevant.add(head)
78
+
79
+ def bfs_dir(src: Pos) -> Tuple[Dict[Pos, int], Dict[Pos, Optional[Pos]]]:
80
+ dist = {n: float('inf') for n in nodes}
81
+ prev = {n: None for n in nodes}
82
+ if src not in adj:
83
+ return dist, prev
84
+ from collections import deque
85
+ q = deque([src])
86
+ dist[src] = 0
87
+ while q:
88
+ u = q.popleft()
89
+ for w in adj[u]:
90
+ if dist[w] == float('inf'):
91
+ dist[w] = dist[u] + 1
92
+ prev[w] = u
93
+ q.append(w)
94
+ return dist, prev
95
+
96
+ sp_dist: Dict[Pos, Dict[Pos, int]] = {}
97
+ sp_prev: Dict[Pos, Dict[Pos, Optional[Pos]]] = {}
98
+ for s in relevant:
99
+ d, p = bfs_dir(s)
100
+ sp_dist[s], sp_prev[s] = d, p
101
+
102
+ def reconstruct_path(a: Pos, b: Pos) -> List[Pos]:
103
+ if a == b:
104
+ return [a]
105
+ if sp_dist[a][b] == float('inf'):
106
+ raise ValueError(f"No directed path {a} -> {b}.")
107
+ path = [b]
108
+ cur = b
109
+ prev_map = sp_prev[a]
110
+ while cur != a:
111
+ cur = prev_map[cur]
112
+ if cur is None:
113
+ raise RuntimeError("Predecessor chain broken.")
114
+ path.append(cur)
115
+ path.reverse()
116
+ return path
117
+
118
+ BIG = 10**9
119
+
120
+ def build_base_cost_matrix() -> Tuple[List[List[int]], int]:
121
+ # dist(i->j) = sp(head_i, tail_j) + 1
122
+ # dist(DEPOT->j) = sp(start_pos, tail_j) + 1
123
+ # dist(i->DEPOT) = 0 (end anywhere)
124
+ C = [[0]*N for _ in range(N)]
125
+ max_base = 0
126
+ for i in range(N):
127
+ for j in range(N):
128
+ if i == j:
129
+ c = 0
130
+ elif i == DEPOT and j == DEPOT:
131
+ c = 0
132
+ elif i == DEPOT:
133
+ tail_j, _ = states[j]
134
+ d = 0 if start_pos == tail_j else sp_dist[start_pos][tail_j]
135
+ c = BIG if d == float('inf') else d + 1
136
+ elif j == DEPOT:
137
+ c = 0
138
+ else:
139
+ _, head_i = states[i]
140
+ tail_j, _ = states[j]
141
+ d = 0 if head_i == tail_j else sp_dist[head_i][tail_j]
142
+ c = BIG if d == float('inf') else d + 1
143
+ C[i][j] = c
144
+ if i != j and i != DEPOT and j != DEPOT and c < BIG:
145
+ if c > max_base:
146
+ max_base = c
147
+ return C, max_base
148
+
149
+ C_base, max_base = build_base_cost_matrix()
150
+
151
+ edge_to_gems: Dict[Tuple[Pos, Pos], Set[Pos]] = defaultdict(set)
152
+ for g, elist in gems_to_edges.items():
153
+ for e in elist:
154
+ edge_to_gems[e].add(g)
155
+
156
+ # ---- Coverage-aware stitching cost for a sequence of representatives ----
157
+ def build_walk_from_reps(rep_seq: List[int]) -> Tuple[List[Pos], Set[Pos]]:
158
+ """Return (walk_nodes, covered_gems) for given representative state indices."""
159
+ covered: Set[Pos] = set()
160
+ walk_nodes: List[Pos] = [start_pos]
161
+ cur = start_pos
162
+ # map states idx -> (tail, head)
163
+ for st in rep_seq:
164
+ tail, head = states[st]
165
+ # skip if gem already covered
166
+ g = state_group[st]
167
+ if g in covered:
168
+ continue
169
+ # connector
170
+ if cur != tail:
171
+ path = reconstruct_path(cur, tail)
172
+ # mark gems on connector
173
+ for i in range(len(path)-1):
174
+ e = (path[i], path[i+1])
175
+ if e in edge_to_gems:
176
+ covered.update(edge_to_gems[e])
177
+ walk_nodes.extend(path[1:])
178
+ cur = tail
179
+ if g in covered:
180
+ continue
181
+ # traverse rep edge
182
+ walk_nodes.append(head)
183
+ cur = head
184
+ if (tail, head) in edge_to_gems:
185
+ covered.update(edge_to_gems[(tail, head)])
186
+ return walk_nodes, covered
187
+
188
+ def walk_edges(nodes_seq: List[Pos]) -> List[Tuple[Pos, Pos]]:
189
+ return [(nodes_seq[i], nodes_seq[i+1]) for i in range(len(nodes_seq)-1)]
190
+
191
+ def simplify_ping_pongs(nodes_seq: List[Pos]) -> List[Pos]:
192
+ """Remove u->v->u pairs if they don't lose coverage."""
193
+ ns = list(nodes_seq)
194
+ changed = True
195
+ while changed:
196
+ changed = False
197
+ i = 0
198
+ while i + 3 < len(ns):
199
+ u, v, w, z = ns[i], ns[i+1], ns[i+2], ns[i+3]
200
+ if w == u: # u->v, v->u
201
+ before_edges = walk_edges(ns[:i+1])
202
+ removed_edges = [(u, v), (v, u)]
203
+ after_edges = walk_edges([u] + ns[i+3:])
204
+ covered_before = set()
205
+ for e in before_edges:
206
+ if e in edge_to_gems:
207
+ covered_before.update(edge_to_gems[e])
208
+ covered_removed = set()
209
+ for e in removed_edges:
210
+ if e in edge_to_gems:
211
+ covered_removed.update(edge_to_gems[e])
212
+ covered_after = set()
213
+ for e in after_edges:
214
+ if e in edge_to_gems:
215
+ covered_after.update(edge_to_gems[e])
216
+ if all_gems.issubset(covered_before | covered_after):
217
+ del ns[i+1:i+3] # drop v,u
218
+ changed = True
219
+ continue
220
+ i += 1
221
+ return ns
222
+
223
+ def true_walk_cost(nodes_seq: List[Pos]) -> int:
224
+ # Number of edges (unit cost)
225
+ return len(nodes_seq) - 1
226
+
227
+ # ---- Noon–Bean + OR-Tools single run (with given cluster ring orders and metaheuristic) ----
228
+ def solve_once(cluster_orders: Dict[Pos, List[int]], metaheuristic):
229
+ # Build Noon–Bean cost matrix from C_base
230
+ M = (max_base + 1) * (N + 5)
231
+ D = [row[:] for row in C_base] # copy
232
+
233
+ # add M to inter-cluster (excluding depot)
234
+ for i in range(N_no_depot):
235
+ gi = state_group[i]
236
+ for j in range(N_no_depot):
237
+ if i == j:
238
+ continue
239
+ gj = state_group[j]
240
+ if gi != gj:
241
+ D[i][j] += M
242
+
243
+ # ring + shift
244
+ INF = 10**12
245
+ succ_in_cluster: Dict[int, int] = {}
246
+ for g, order in cluster_orders.items():
247
+ k = len(order)
248
+ if k == 0:
249
+ continue
250
+ pred = {}
251
+ for idx, v in enumerate(order):
252
+ pred[v] = order[(idx - 1) % k]
253
+ succ_in_cluster[v] = order[(idx + 1) % k]
254
+ # block intra except ring
255
+ for a in order:
256
+ for b in order:
257
+ if a != b:
258
+ D[a][b] = INF
259
+ # ring arcs
260
+ for a in order:
261
+ D[a][succ_in_cluster[a]] = 0
262
+ # shift outgoing to pred
263
+ for v in order:
264
+ pv = pred[v]
265
+ for t in range(N):
266
+ if t in order or v == t or v == DEPOT:
267
+ continue
268
+ if state_group[t] == "__DEPOT__":
269
+ if D[v][t] < INF:
270
+ D[pv][t] = min(D[pv][t], D[v][t])
271
+ D[v][t] = INF
272
+ else:
273
+ if D[v][t] < INF:
274
+ D[pv][t] = min(D[pv][t], D[v][t])
275
+ D[v][t] = INF
276
+
277
+ # OR-Tools
278
+ manager = pywrapcp.RoutingIndexManager(N, 1, DEPOT)
279
+ routing = pywrapcp.RoutingModel(manager)
280
+
281
+ def transit_cb(from_index, to_index):
282
+ i = manager.IndexToNode(from_index)
283
+ j = manager.IndexToNode(to_index)
284
+ return int(D[i][j])
285
+
286
+ transit_cb_index = routing.RegisterTransitCallback(transit_cb)
287
+ routing.SetArcCostEvaluatorOfAllVehicles(transit_cb_index)
288
+
289
+ params = pywrapcp.DefaultRoutingSearchParameters()
290
+ params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
291
+ params.local_search_metaheuristic = metaheuristic
292
+ params.time_limit.FromMilliseconds(max(0.01, time_limit_ms))
293
+ params.log_search = False
294
+
295
+ solution = routing.SolveWithParameters(params)
296
+ if solution is None:
297
+ return None, None, None, None
298
+
299
+ # decode tour
300
+ route: List[int] = []
301
+ idx = routing.Start(0)
302
+ while not routing.IsEnd(idx):
303
+ route.append(manager.IndexToNode(idx))
304
+ idx = solution.Value(routing.NextVar(idx))
305
+ route.append(manager.IndexToNode(idx)) # DEPOT
306
+
307
+ # representatives via leaving events (succ(p))
308
+ rep_idxs: List[int] = []
309
+ seen_gems: Set[Pos] = set()
310
+ for a, b in zip(route, route[1:]):
311
+ ga, gb = state_group[a], state_group[b]
312
+ if ga == "__DEPOT__" or ga == gb:
313
+ continue
314
+ if ga in seen_gems:
315
+ continue
316
+ # chosen representative is succ(a)
317
+ if a in cluster_orders.get(ga, []):
318
+ # find succ
319
+ order = cluster_orders[ga]
320
+ ai = order.index(a)
321
+ rep = order[(ai + 1) % len(order)]
322
+ rep_idxs.append(rep)
323
+ seen_gems.add(ga)
324
+
325
+ return rep_idxs, succ_in_cluster, D, route
326
+
327
+ best_nodes = None
328
+ best_cost = float('inf')
329
+ best_reps = None
330
+
331
+ # initial deterministic order as a baseline
332
+ def shuffled_cluster_orders():
333
+ orders = {}
334
+ for g, idxs in group_to_state_indices_original.items():
335
+ order = idxs[:] # copy existing indexing order
336
+ rng.shuffle(order) # randomize ring to mitigate Noon–Bean bias
337
+ orders[g] = order
338
+ return orders
339
+
340
+ attempts = max(1, restarts)
341
+ for attempt in range(attempts):
342
+ cluster_orders = shuffled_cluster_orders()
343
+ for meta in meta_list:
344
+ rep_idxs, _, _, _ = solve_once(cluster_orders, meta)
345
+ if rep_idxs is None:
346
+ continue
347
+
348
+ # -------- Local 2-opt on representative order (under true walk cost) --------
349
+ # Start from the order returned by the solver
350
+ reps = rep_idxs[:]
351
+
352
+ def reps_to_nodes_and_cost(rep_seq: List[int]) -> Tuple[List[Pos], int]:
353
+ nodes_seq, covered = build_walk_from_reps(rep_seq)
354
+ # ensure full coverage; otherwise penalize
355
+ if not all_gems.issubset(covered):
356
+ return nodes_seq, len(nodes_seq) - 1 + 10**6
357
+ nodes_seq = simplify_ping_pongs(nodes_seq)
358
+ return nodes_seq, true_walk_cost(nodes_seq)
359
+
360
+ improved = True
361
+ nodes_seq, cost = reps_to_nodes_and_cost(reps)
362
+ while improved:
363
+ improved = False
364
+ n = len(reps)
365
+ # classic 2-opt swap on the order of representatives
366
+ for i in range(n):
367
+ for j in range(i+1, n):
368
+ new_reps = reps[:i] + reps[i:j+1][::-1] + reps[j+1:]
369
+ new_nodes, new_cost = reps_to_nodes_and_cost(new_reps)
370
+ if new_cost < cost:
371
+ reps = new_reps
372
+ nodes_seq, cost = new_nodes, new_cost
373
+ improved = True
374
+ break
375
+ if improved:
376
+ break
377
+
378
+ if cost < best_cost:
379
+ best_cost = cost
380
+ best_nodes = nodes_seq
381
+ best_reps = reps
382
+
383
+ if best_nodes is None:
384
+ raise RuntimeError("No solution found.")
385
+
386
+ # Final checks and edge list
387
+ edge_walk: List[Tuple[Pos, Pos]] = [(best_nodes[i], best_nodes[i+1]) for i in range(len(best_nodes)-1)]
388
+ assert all(e in edges for e in edge_walk), "Output contains an edge not in the input directed edges."
389
+
390
+ # Ensure all gems covered
391
+ covered_final: Set[Pos] = set()
392
+ for e in edge_walk:
393
+ if e in edge_to_gems:
394
+ covered_final.update(edge_to_gems[e])
395
+ missing = all_gems - covered_final
396
+ assert not missing, f"Walk lost coverage for gems: {missing}"
397
+
398
+ return edge_walk