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