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.
- multi_puzzle_solver-0.1.0.dist-info/METADATA +1897 -0
- multi_puzzle_solver-0.1.0.dist-info/RECORD +31 -0
- multi_puzzle_solver-0.1.0.dist-info/WHEEL +5 -0
- multi_puzzle_solver-0.1.0.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +26 -0
- puzzle_solver/core/utils.py +127 -0
- puzzle_solver/core/utils_ortools.py +78 -0
- puzzle_solver/puzzles/bridges/bridges.py +106 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +136 -0
- puzzle_solver/puzzles/filling/filling.py +192 -0
- puzzle_solver/puzzles/guess/guess.py +231 -0
- puzzle_solver/puzzles/inertia/inertia.py +122 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +204 -0
- puzzle_solver/puzzles/inertia/tsp.py +398 -0
- puzzle_solver/puzzles/keen/keen.py +99 -0
- puzzle_solver/puzzles/light_up/light_up.py +95 -0
- puzzle_solver/puzzles/magnets/magnets.py +117 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +110 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +48 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +126 -0
- puzzle_solver/puzzles/pearl/pearl.py +151 -0
- puzzle_solver/puzzles/range/range.py +154 -0
- puzzle_solver/puzzles/signpost/signpost.py +95 -0
- puzzle_solver/puzzles/singles/singles.py +116 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +90 -0
- puzzle_solver/puzzles/tents/tents.py +110 -0
- puzzle_solver/puzzles/towers/towers.py +139 -0
- puzzle_solver/puzzles/tracks/tracks.py +170 -0
- puzzle_solver/puzzles/undead/undead.py +168 -0
- 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)
|