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,192 @@
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_char, set_char, get_neighbors4, get_all_pos_to_idx_dict
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
6
+
7
+
8
+ class Board:
9
+ """
10
+ Per cell p:
11
+ val[p] ∈ {1..9} (respect givens)
12
+ region[p] ∈ {0..N-1} # region id is the linear index of the region's root
13
+ is_root[p] <=> (region[p] == idx[p])
14
+ # NOTE: root is the minimum index among its members via region[p] ≤ idx[p]
15
+
16
+ Per (p, k) where k is a root index (0..N-1):
17
+ in_region[p,k] <=> (region[p] == k)
18
+ dist[p,k] ∈ {0..INF}
19
+ - If in_region[p,k] = 0 ⇒ dist[p,k] = INF
20
+ - If p == pos_of(k) and is_root[pos_of(k)] = 1 ⇒ dist[p,k] = 0
21
+ - If in_region[p,k] = 1 and p != pos_of(k) ⇒
22
+ dist[p,k] = 1 + min_n masked_dist[n,k]
23
+ where masked_dist[n,k] = dist[n,k] + 1 if in_region[n,k] else INF
24
+
25
+ Edge (u,v):
26
+ same-digit neighbors must be in same region.
27
+
28
+ Region sizes:
29
+ For each k: size[k] == #{p : region[p] == k}
30
+ If is_root[pos_of(k)] → size[k] == val[pos_of(k)]
31
+ Else size[k] == 0
32
+ """
33
+
34
+ def __init__(self, board: np.ndarray):
35
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
36
+ self.board = board
37
+ self.V, self.H = board.shape
38
+ assert all((c == '*') or (str(c).isdecimal() and 0 <= int(c) <= 9)
39
+ for c in np.nditer(board)), "board must contain '*' or digits 0..9"
40
+
41
+ self.N = self.V * self.H
42
+ self.INF = self.N + 1 # a safe "infinity" upper bound for distances
43
+
44
+ # Linear index maps (keyed by Pos; do NOT construct tuples)
45
+ self.idx_of: dict[Pos, int] = get_all_pos_to_idx_dict(self.V, self.H)
46
+ self.pos_of: list[Pos] = [None] * self.N
47
+ for pos, idx in self.idx_of.items():
48
+ self.pos_of[idx] = pos
49
+
50
+ m = self.model = cp_model.CpModel()
51
+
52
+ # Variables
53
+ self.val = {} # val[p]
54
+ self.region = {} # region[p]
55
+ self.same_region = {} # same_region[(p,q)]
56
+ self.is_root = {} # is_root[p]
57
+ self.is_val = {} # is_val[(p,k)] (k=1..9)
58
+ self.in_region = {} # in_region[(p,k)] (k = 0..N-1)
59
+ self.dist = {} # dist[(p,k)] ∈ [0..INF]
60
+
61
+ # Per-cell vars and givens
62
+ for p in get_all_pos(self.V, self.H):
63
+ idx = self.idx_of[p]
64
+
65
+ v = m.NewIntVar(1, 9, f'val[{idx}]')
66
+ ch = get_char(self.board, p)
67
+ if str(ch).isdecimal():
68
+ m.Add(v == int(ch))
69
+ self.val[p] = v
70
+
71
+ r = m.NewIntVar(0, self.N - 1, f'region[{idx}]')
72
+ self.region[p] = r
73
+
74
+ b = m.NewBoolVar(f'is_root[{idx}]')
75
+ self.is_root[p] = b
76
+ m.Add(r == idx).OnlyEnforceIf(b)
77
+ m.Add(r != idx).OnlyEnforceIf(b.Not())
78
+
79
+ # is_val indicators (for same-digit merge)
80
+ for k in range(1, 10):
81
+ bv = m.NewBoolVar(f'is_val[{idx}=={k}]')
82
+ self.is_val[(p, k)] = bv
83
+ m.Add(self.val[p] == k).OnlyEnforceIf(bv)
84
+ m.Add(self.val[p] != k).OnlyEnforceIf(bv.Not())
85
+
86
+ # Root = minimum index among members
87
+ for p in get_all_pos(self.V, self.H):
88
+ m.Add(self.region[p] <= self.idx_of[p])
89
+
90
+ # Membership indicators in_region[p,k] <=> region[p] == k
91
+ for k in range(self.N):
92
+ for p in get_all_pos(self.V, self.H):
93
+ bmem = m.NewBoolVar(f'in_region[{self.idx_of[p]}=={k}]')
94
+ self.in_region[(p, k)] = bmem
95
+ m.Add(self.region[p] == k).OnlyEnforceIf(bmem)
96
+ m.Add(self.region[p] != k).OnlyEnforceIf(bmem.Not())
97
+
98
+ # same-digit neighbors must be in the same region
99
+ for u in get_all_pos(self.V, self.H):
100
+ for v in get_neighbors4(u, self.V, self.H):
101
+ if self.idx_of[v] < self.idx_of[u]:
102
+ continue # undirected pair once
103
+ # If val[u]==k and val[v]==k for any k in 1..9, then region[u]==region[v]
104
+ # Implement as: for each k, (is_val[u,k] & is_val[v,k]) ⇒ (region[u]==region[v])
105
+ for k in range(1, 10):
106
+ m.Add(self.region[u] == self.region[v])\
107
+ .OnlyEnforceIf([self.is_val[(u, k)], self.is_val[(v, k)]])
108
+
109
+ for u in get_all_pos(self.V, self.H):
110
+ for v in get_neighbors4(u, self.V, self.H):
111
+ b = self.model.NewBoolVar(f'same_region[{self.idx_of[u]},{self.idx_of[v]}]')
112
+ self.same_region[(u, v)] = b
113
+ self.model.Add(self.region[u] == self.region[v]).OnlyEnforceIf(b)
114
+ self.model.Add(self.region[u] != self.region[v]).OnlyEnforceIf(b.Not())
115
+ self.model.Add(self.val[u] == self.val[v]).OnlyEnforceIf(b)
116
+
117
+ # Distance variables dist[p,k] and masked AddMinEquality
118
+ for k in range(self.N):
119
+ root_pos = self.pos_of[k]
120
+
121
+ for p in get_all_pos(self.V, self.H):
122
+ dp = m.NewIntVar(0, self.INF, f'dist[{self.idx_of[p]},{k}]')
123
+ self.dist[(p, k)] = dp
124
+
125
+ # If p not in region k -> dist = INF
126
+ m.Add(dp == self.INF).OnlyEnforceIf(self.in_region[(p, k)].Not())
127
+
128
+ # Root distance: if k is active at its own position -> dist[root,k] = 0
129
+ if p == root_pos:
130
+ m.Add(dp == 0).OnlyEnforceIf(self.is_root[root_pos])
131
+ # If root_pos isn't the root for k, membership is 0 and above rule sets INF.
132
+
133
+ # For non-root members p of region k: dist[p,k] = 1 + min masked neighbor distances
134
+ for p in get_all_pos(self.V, self.H):
135
+ if p == root_pos:
136
+ continue # handled above
137
+
138
+ # Build masked neighbor candidates: INF if neighbor not in region k; else dist[n,k] + 1
139
+ cand_vars = []
140
+ for n in get_neighbors4(p, self.V, self.H):
141
+ cn = m.NewIntVar(0, self.INF, f'canddist[{self.idx_of[p]},{k}->{self.idx_of[n]}]')
142
+ cand_vars.append(cn)
143
+
144
+ both_in_region_k = m.NewBoolVar(f'both_in_region_k[{self.idx_of[p]} in {k} and {self.idx_of[n]} in {k}]')
145
+ and_constraint(m, both_in_region_k, [self.in_region[(p, k)], self.in_region[(n, k)]])
146
+
147
+ # Reified equality:
148
+ # in_region[n,k] => cn == dist[n,k] + 1
149
+ m.Add(cn == self.dist[(n, k)] + 1).OnlyEnforceIf(both_in_region_k)
150
+ # not in_region[n,k] => cn == INF
151
+ m.Add(cn == self.INF).OnlyEnforceIf(both_in_region_k.Not())
152
+
153
+ # Only enforce the min equation when p is actually in region k (and not the root position).
154
+ # If p ∉ region k, dp is already INF via the earlier rule.
155
+ if cand_vars:
156
+ m.AddMinEquality(self.dist[(p, k)], cand_vars)
157
+ for p in get_all_pos(self.V, self.H):
158
+ # every cell must have 1 dist != INF (lets just do at least 1 dist != INF)
159
+ not_infs = []
160
+ for k in range(self.N):
161
+ not_inf = m.NewBoolVar(f'not_inf[{self.idx_of[p]},{k}]')
162
+ m.Add(self.dist[(p, k)] != self.INF).OnlyEnforceIf(not_inf)
163
+ m.Add(self.dist[(p, k)] == self.INF).OnlyEnforceIf(not_inf.Not())
164
+ not_infs.append(not_inf)
165
+ m.AddBoolOr(not_infs)
166
+
167
+ # Region sizes
168
+ for k in range(self.N):
169
+ root_pos = self.pos_of[k]
170
+ members = [self.in_region[(p, k)] for p in get_all_pos(self.V, self.H)]
171
+ size_k = m.NewIntVar(0, self.N, f'size[{k}]')
172
+ m.Add(size_k == sum(members))
173
+ # Active root -> size equals its value
174
+ m.Add(size_k == self.val[root_pos]).OnlyEnforceIf(self.is_root[root_pos])
175
+ # Inactive root id -> size 0
176
+ m.Add(size_k == 0).OnlyEnforceIf(self.is_root[root_pos].Not())
177
+
178
+ def solve_and_print(self):
179
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
180
+ assignment: dict[Pos, int] = {}
181
+ for p in get_all_pos(board.V, board.H):
182
+ assignment[p] = solver.Value(board.val[p])
183
+ return SingleSolution(assignment=assignment)
184
+ def callback(single_res: SingleSolution):
185
+ print("Solution found")
186
+ res = np.full((self.V, self.H), ' ', dtype=object)
187
+ for pos in get_all_pos(self.V, self.H):
188
+ c = get_char(self.board, pos)
189
+ c = single_res.assignment[pos]
190
+ set_char(res, pos, c)
191
+ print(res)
192
+ return generic_solve_all(self, board_to_solution, callback=callback)
@@ -0,0 +1,231 @@
1
+ from collections import defaultdict
2
+ import numpy as np
3
+ from collections import Counter
4
+ from itertools import product
5
+
6
+ class Board:
7
+ def __init__(self, num_pegs: int = 4, all_colors: list[str] = ['R', 'Y', 'G', 'B', 'O', 'P'], show_warnings: bool = True, show_progress: bool = False):
8
+ assert num_pegs >= 1, 'num_pegs must be at least 1'
9
+ assert len(all_colors) == len(set(all_colors)), 'all_colors must contain only unique colors'
10
+ self.previous_guesses = []
11
+ self.num_pegs = num_pegs
12
+ self.all_colors = all_colors
13
+ self.show_progress = show_progress
14
+ self.tqdm = None
15
+ if self.show_progress:
16
+ try:
17
+ from tqdm import tqdm
18
+ self.tqdm = tqdm
19
+ except ImportError:
20
+ print('tqdm is not installed, so progress bar will not be shown')
21
+ self.tqdm = None
22
+ self.possible_triplets = set((i, j, num_pegs-i-j) for i in range(num_pegs+1) for j in range(num_pegs+1-i))
23
+ int_to_color = {i: c for i, c in enumerate(self.all_colors)}
24
+ c = len(self.all_colors)**num_pegs
25
+ if c > 10**5 and show_warnings:
26
+ print(f'Warning: len(all_colors)**num_pegs is too large (= {c:,}). The solver may take infinitely long to run.')
27
+ self.all_possible_pegs = tuple({(i, int_to_color[int_]) for i, int_ in enumerate(ints)} for ints in product(range(len(self.all_colors)), repeat=num_pegs))
28
+
29
+ def add_guess(self, guess: tuple[tuple[int, str]], guess_result: tuple[int, int, int]):
30
+ assert len(guess) == self.num_pegs, 'previous guess must have the same number of pegs as the game'
31
+ assert not set(guess) - set(self.all_colors), f'previous guess must contain only colors in all_colors; invalid colors: {set(guess) - set(self.all_colors)}'
32
+ assert sum(guess_result) == self.num_pegs, 'guess result must sum to num_pegs'
33
+ self.previous_guesses.append((guess, guess_result))
34
+
35
+ def get_possible_ground_truths(self):
36
+ """
37
+ Returns the possible ground truths based on the previous guesses.
38
+ """
39
+ previous_guesses = self.previous_guesses
40
+ all_possible_pegs = self.all_possible_pegs
41
+ possible_triplets = self.possible_triplets
42
+ if self.tqdm is not None:
43
+ previous_guesses = self.tqdm(previous_guesses, desc='Step 1/2: Filtering possible ground truths')
44
+ # filter possible ground truths based on previous guesses
45
+ pair_mask = np.full((len(all_possible_pegs), ), True, dtype=bool)
46
+ for previous_guess, guess_result in previous_guesses:
47
+ previous_guess = tuple(tuple((i, c) for i, c in enumerate(previous_guess)))
48
+ pairs = np_information_gain(guess=previous_guess, possible_ground_truths=all_possible_pegs, possible_triplets=possible_triplets, return_pairs=True)
49
+ mask = np.all(pairs == guess_result, axis=1)
50
+ pair_mask &= mask
51
+ possible_ground_truths = tuple(all_possible_pegs[i] for i in range(len(all_possible_pegs)) if pair_mask[i])
52
+ return possible_ground_truths
53
+
54
+ def best_next_guess(
55
+ self,
56
+ return_guess_entropy: bool = False,
57
+ verbose: bool = True,
58
+ ):
59
+ """
60
+ Returns the best next guess that would maximize the Shannon entropy of the next guess.
61
+ """
62
+ possible_triplets = self.possible_triplets
63
+ all_possible_pegs = self.all_possible_pegs
64
+ possible_ground_truths = self.get_possible_ground_truths()
65
+ ng = len(possible_ground_truths) # number of possible ground truths
66
+ if ng == 0:
67
+ print('No possible ground truths found. This should not happen in a real game, please check your inputted guesses.')
68
+ return ng, None
69
+ elif ng == 1:
70
+ answer = [c for i, c in sorted(possible_ground_truths[0], key=lambda x: x[0])]
71
+ if verbose:
72
+ print(f'Solution found! The solution is: {answer}')
73
+ return ng, answer
74
+ if verbose:
75
+ print(f'out of {len(all_possible_pegs)} possible ground truths, only {ng} are still possible.')
76
+
77
+ if self.tqdm is not None:
78
+ all_possible_pegs = self.tqdm(all_possible_pegs, desc='Step 2/2: Calculating entropy for each guess')
79
+ guess_entropy = []
80
+ possible_ground_truths_set = set(tuple((i, c) for i, c in guess) for guess in possible_ground_truths)
81
+ for guess in all_possible_pegs:
82
+ entropy = np_information_gain(guess=guess, possible_ground_truths=possible_ground_truths, possible_triplets=possible_triplets)
83
+ is_possible = tuple(guess) in possible_ground_truths_set
84
+ guess_entropy.append((guess, entropy, is_possible))
85
+ guess_entropy = sorted(guess_entropy, key=lambda x: (x[1], x[2]), reverse=True)
86
+ max_entropy_guess = guess_entropy[0]
87
+ if verbose:
88
+ answer = [c for i, c in sorted(max_entropy_guess[0], key=lambda x: x[0])]
89
+ print(f'max entropy guess is: {answer} with entropy {max_entropy_guess[1]:.4f}')
90
+ if return_guess_entropy:
91
+ return ng, max_entropy_guess, guess_entropy
92
+ else:
93
+ return ng, max_entropy_guess
94
+
95
+
96
+
97
+
98
+ def get_triplets(guess, ground_truth, verbose=False):
99
+ """
100
+ Returns
101
+ 1. Number of guesses that match the color and location
102
+ 2. Number of guesses that match the color but not the location
103
+ 3. Number of guesses that do not match the color or the location
104
+ e.g.
105
+ if guess is ((0, 'Y'), (1, 'R'), (2, 'R'), (3, 'R')) and ground_truth is ((0, 'Y'), (1, 'Y'), (2, 'R'), (3, 'R')), then the triplets are (3, 0, 1)
106
+ if guess is ((0, 'R'), (1, 'Y'), (2, 'Y'), (3, 'Y')) and ground_truth is ((0, 'Y'), (1, 'Y'), (2, 'R'), (3, 'R')), then the triplets are (1, 2, 1)
107
+ """
108
+ color_count = defaultdict(int)
109
+ for _, color in ground_truth:
110
+ color_count[color] += 1
111
+ matching_color_and_location = 0
112
+ matching_color_but_not_location = 0
113
+ not_matching = 0
114
+ done_locs = set()
115
+ for (loc, color) in guess:
116
+ if (loc, color) in ground_truth:
117
+ if verbose:
118
+ print(f'loc {loc} color {color} matched perfectly')
119
+ matching_color_and_location += 1
120
+ color_count[color] -= 1
121
+ done_locs.add(loc)
122
+ for (loc, color) in guess:
123
+ if loc in done_locs:
124
+ continue
125
+ if color_count.get(color, 0) > 0:
126
+ if verbose:
127
+ print(f'loc {loc} color {color} matched but not in the right location')
128
+ matching_color_but_not_location += 1
129
+ color_count[color] -= 1
130
+ else:
131
+ not_matching += 1
132
+ return matching_color_and_location, matching_color_but_not_location, not_matching
133
+
134
+ def slow_information_gain(guess: set[tuple[int, str]], possible_ground_truths: set[set[tuple[int, str]]], possible_triplets: set[tuple[int, int, int]]):
135
+ # safe but slow solution used as a reference
136
+ counts = {triplet: 0 for triplet in possible_triplets}
137
+ for ground_truth in possible_ground_truths:
138
+ counts[tuple(get_triplets(guess, ground_truth))] += 1
139
+ px = {triplet: count / len(possible_ground_truths) for triplet, count in counts.items()}
140
+ entropy = -sum(px[triplet] * np.log2(px[triplet]) for triplet in possible_triplets if px[triplet] > 0)
141
+ # print(counts)
142
+ return entropy
143
+
144
+
145
+ def np_information_gain(guess: tuple[tuple[int, str]], possible_ground_truths: tuple[set[tuple[int, str]]], possible_triplets: set[tuple[int, int, int]], return_pairs: bool = False):
146
+ # my attempt of a vectorized np solution
147
+ n = len(guess)
148
+ all_colors = set()
149
+ for _, color in guess:
150
+ all_colors.add(color)
151
+ for gt in possible_ground_truths:
152
+ for _, color in gt:
153
+ all_colors.add(color)
154
+ guess_mask = {c: np.full((n, 1), 0, dtype=np.int8) for c in all_colors}
155
+ for loc, color in guess:
156
+ guess_mask[color][loc] = 1
157
+ guess_mask_repeated = {c: np.repeat(guess_mask[c].T, len(possible_ground_truths), axis=0) for c in all_colors}
158
+
159
+ color_matrices = {c: np.full((len(possible_ground_truths), n), 0, dtype=np.int8) for c in all_colors}
160
+ for i, gt in enumerate(possible_ground_truths):
161
+ for loc, color in gt:
162
+ color_matrices[color][i, loc] = 1
163
+
164
+ pair_1 = sum(color_matrices[c] @ guess_mask[c] for c in all_colors)
165
+
166
+ pair_2_diff = {c: guess_mask_repeated[c] - color_matrices[c] for c in all_colors}
167
+ pos_mask = {c: pair_2_diff[c] > 0 for c in all_colors}
168
+ pair_2_extra_guess = {c: pair_2_diff[c].copy() for c in all_colors}
169
+ pair_2_extra_ground = {c: pair_2_diff[c].copy() for c in all_colors}
170
+ pair_2 = {}
171
+ for c in all_colors:
172
+ pair_2_extra_guess[c][~pos_mask[c]] = 0
173
+ pair_2_extra_guess[c] = np.sum(pair_2_extra_guess[c], axis=1)
174
+ pair_2_extra_ground[c][pos_mask[c]] = 0
175
+ pair_2_extra_ground[c] = np.abs(np.sum(pair_2_extra_ground[c], axis=1))
176
+ pair_2[c] = np.minimum(pair_2_extra_guess[c], pair_2_extra_ground[c])
177
+
178
+ pair_2 = sum(pair_2[c] for c in all_colors)
179
+ pair_2 = pair_2[:, None]
180
+
181
+ pair_3 = n - pair_1 - pair_2
182
+
183
+ pair = np.concatenate([pair_1, pair_2, pair_3], axis=1)
184
+ pair_counter = Counter(tuple(t) for t in pair)
185
+ counts = {triplet: pair_counter[triplet] for triplet in possible_triplets}
186
+ px = {triplet: count / len(possible_ground_truths) for triplet, count in counts.items()}
187
+ entropy = -sum(px[triplet] * np.log2(px[triplet]) for triplet in possible_triplets if px[triplet] > 0)
188
+ # print(counts)
189
+ if return_pairs:
190
+ return pair
191
+ else:
192
+ return entropy
193
+
194
+
195
+
196
+
197
+
198
+
199
+ def fast_information_gain(guess: set[tuple[int, str]],
200
+ possible_ground_truths: set[set[tuple[int, str]]],
201
+ possible_triplets: set[tuple[int, int, int]]):
202
+ # chatgpt fast solution + many modifications by me
203
+ counts = {t: 0 for t in possible_triplets}
204
+
205
+ for gt in possible_ground_truths:
206
+ color_count = {}
207
+ for _, c in gt:
208
+ color_count[c] = color_count.get(c, 0) + 1
209
+
210
+ H = 0
211
+ for loc, c in guess:
212
+ if (loc, c) in gt:
213
+ H += 1
214
+ color_count[c] -= 1 # safe: gt contributes this occurrence
215
+
216
+ color_only = 0
217
+ for loc, c in guess:
218
+ if (loc, c) in gt:
219
+ continue
220
+ remain = color_count.get(c, 0)
221
+ if remain > 0:
222
+ color_only += 1
223
+ color_count[c] = remain - 1
224
+
225
+ triplet = (H, color_only, len(guess) - H - color_only)
226
+ counts[triplet] += 1
227
+
228
+ px = {triplet: count / len(possible_ground_truths) for triplet, count in counts.items()}
229
+ entropy = -sum(px[triplet] * np.log2(px[triplet]) for triplet in possible_triplets if px[triplet] > 0)
230
+ # print(counts)
231
+ return entropy
@@ -0,0 +1,122 @@
1
+ from collections import defaultdict
2
+
3
+ import numpy as np
4
+
5
+ from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, get_char, in_bounds, get_next_pos
6
+
7
+ from . import tsp
8
+
9
+
10
+ def _jump(board: np.array, pos: Pos, direction: Direction8) -> tuple[Pos, list[Pos]]:
11
+ # jump from pos in direction, return the next position and the positions of the gems that would be achieved (mostly likely None)
12
+ initial_pos = pos
13
+ out = []
14
+ while True:
15
+ next_pos = get_next_pos(pos, direction)
16
+ if not in_bounds(next_pos, board.shape[0], board.shape[1]):
17
+ break
18
+ ch = get_char(board, next_pos)
19
+ if ch == 'W':
20
+ break
21
+ if ch == 'O':
22
+ pos = next_pos
23
+ break
24
+ if ch == 'B': # Note: the ball always starts ontop of an 'O' cell, thus hitting a 'B' is like hitting a 'O'
25
+ pos = next_pos
26
+ break
27
+ if ch == 'M': # WE HIT A MINE
28
+ return None, None
29
+ if ch == 'G':
30
+ pos = next_pos
31
+ out.append(next_pos)
32
+ continue
33
+ if ch == ' ':
34
+ pos = next_pos
35
+ continue
36
+ if pos == initial_pos: # we did not move
37
+ return None, None
38
+ return pos, out
39
+
40
+ def parse_nodes_and_edges(board: np.array):
41
+ "parses the board into a graph where an edge is 1 move, and each gem lists the edges that would get it"
42
+ assert board.ndim == 2, 'board must be 2d'
43
+ assert all(c.item() in [' ', 'W', 'O', 'B', 'M', 'G'] for c in np.nditer(board)), 'board must contain only spaces, W, O, B, M, or G'
44
+ todo_nodes: set[Pos] = set()
45
+ completed_nodes: set[Pos] = set()
46
+ edges_to_direction: dict[tuple[Pos, Pos], Direction8] = {} # edge (u: Pos, v: Pos) -> direction
47
+ gems_to_edges: dict[Pos, list[tuple[Pos, Pos]]] = defaultdict(list) # gem position -> list of edges that would get this gem
48
+ V, H = board.shape
49
+ start_pos = [p for p in get_all_pos(V, H) if get_char(board, p) == 'B']
50
+ assert len(start_pos) == 1, 'board must have exactly one start position'
51
+ start_pos = start_pos[0]
52
+ todo_nodes.add(start_pos)
53
+ while todo_nodes:
54
+ pos = todo_nodes.pop()
55
+ for direction in Direction8:
56
+ next_pos, gems = _jump(board, pos, direction)
57
+ if next_pos is None:
58
+ continue
59
+ e = (pos, next_pos)
60
+ assert e not in edges_to_direction, 'edge already exists'
61
+ edges_to_direction[e] = direction
62
+ if len(gems) > 0:
63
+ for gem in gems:
64
+ assert e not in gems_to_edges[gem], 'edge already in gems_to_edges'
65
+ gems_to_edges[gem].append(e)
66
+ if next_pos not in completed_nodes:
67
+ todo_nodes.add(next_pos)
68
+ completed_nodes.add(pos)
69
+ assert len(gems_to_edges) == len([p for p in get_all_pos(V, H) if get_char(board, p) == 'G']), 'some gems are not reachable'
70
+ edges = set(edges_to_direction.keys())
71
+ return start_pos, edges, edges_to_direction, gems_to_edges
72
+
73
+ def get_moves_from_walk(walk: list[tuple[Pos, Pos]], edges_to_direction: dict[tuple[Pos, Pos], Direction8], verbose: bool = True) -> list[str]:
74
+ direction_to_str = {Direction8.UP: '↑', Direction8.DOWN: '↓', Direction8.LEFT: '←', Direction8.RIGHT: '→', Direction8.UP_LEFT: '↖', Direction8.UP_RIGHT: '↗', Direction8.DOWN_LEFT: '↙', Direction8.DOWN_RIGHT: '↘'}
75
+ for edge in walk:
76
+ assert edge in edges_to_direction, f'edge {edge} not valid yet was in walk'
77
+ walk_directions = [edges_to_direction[edge] for edge in walk]
78
+ walk_directions_printable = [direction_to_str[x] for x in walk_directions]
79
+ if verbose:
80
+ print("number of moves", len(walk_directions))
81
+ for i, direction in enumerate(walk_directions_printable):
82
+ print(f"{direction}", end=' ')
83
+ if i % 10 == 9:
84
+ print()
85
+ print()
86
+ return walk_directions
87
+
88
+ def simulate_moves(board: np.array, moves: list[str]) -> bool:
89
+ V, H = board.shape
90
+ start_pos = [p for p in get_all_pos(V, H) if get_char(board, p) == 'B']
91
+ assert len(start_pos) == 1, 'board must have exactly one start position'
92
+ gems_collected_so_far = set()
93
+ start_pos = start_pos[0]
94
+ current_pos = start_pos
95
+ for move in moves:
96
+ next_pos, gems = _jump(board, current_pos, move)
97
+ if next_pos is None:
98
+ print(f'invalid move {move} from {current_pos}. Either hit a wall (considered illegal here) or a mine (dead)')
99
+ return set() # Running into a mine is fatal. Even if you picked up the last gem in the same move which then hit a mine, the game will count you as dead rather than victorious.
100
+ current_pos = next_pos
101
+ gems_collected_so_far.update(gems)
102
+ return gems_collected_so_far
103
+
104
+
105
+ def is_board_completed(board: np.array, moves: list[str]) -> bool:
106
+ V, H = board.shape
107
+ all_gems = set(p for p in get_all_pos(V, H) if get_char(board, p) == 'G')
108
+ gems_collected = simulate_moves(board, moves)
109
+ assert gems_collected.issubset(all_gems), f'collected gems that are not on the board??? should not happen, {gems_collected - all_gems}'
110
+ return gems_collected == all_gems
111
+
112
+ def solve_optimal_walk(
113
+ start_pos: Pos,
114
+ edges: set[tuple[Pos, Pos]],
115
+ gems_to_edges: defaultdict[Pos, list[tuple[Pos, Pos]]],
116
+ *,
117
+ restarts: int = 1, # try more for harder instances (e.g., 48–128)
118
+ time_limit_ms: int = 1000, # per restart
119
+ seed: int = 0,
120
+ verbose: bool = False
121
+ ) -> list[tuple[Pos, Pos]]:
122
+ return tsp.solve_optimal_walk(start_pos, edges, gems_to_edges, restarts=restarts, time_limit_ms=time_limit_ms, seed=seed, verbose=verbose)