multi-puzzle-solver 1.0.7__py3-none-any.whl → 1.0.9__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-1.0.7.dist-info → multi_puzzle_solver-1.0.9.dist-info}/METADATA +94 -9
- {multi_puzzle_solver-1.0.7.dist-info → multi_puzzle_solver-1.0.9.dist-info}/RECORD +11 -10
- puzzle_solver/__init__.py +3 -1
- puzzle_solver/core/utils_visualizer.py +565 -561
- puzzle_solver/puzzles/binairo/binairo.py +31 -59
- puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -221
- puzzle_solver/puzzles/palisade/palisade.py +91 -91
- puzzle_solver/puzzles/tracks/tracks.py +1 -1
- {multi_puzzle_solver-1.0.7.dist-info → multi_puzzle_solver-1.0.9.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-1.0.7.dist-info → multi_puzzle_solver-1.0.9.dist-info}/top_level.txt +0 -0
|
@@ -1,221 +1,220 @@
|
|
|
1
|
-
from collections import defaultdict
|
|
2
|
-
from typing import Optional
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from ortools.sat.python import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from puzzle_solver.core.
|
|
9
|
-
from puzzle_solver.core.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
assert_input(
|
|
30
|
-
|
|
31
|
-
self.
|
|
32
|
-
self.
|
|
33
|
-
self.
|
|
34
|
-
self.
|
|
35
|
-
self.
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
self.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ground_sequence
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
ground_sequence
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
min_needed
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
#
|
|
105
|
-
# If
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
self.extra_vars[f"{ns}
|
|
122
|
-
self.extra_vars[f"{ns}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
self.model.Add(s_i
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
end_expr
|
|
134
|
-
self.model.Add(end_expr
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
self.model.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
v
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
total_cells_k
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
print(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
from matplotlib import
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
plt.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_row_pos, get_col_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def assert_input(lines: list[list[tuple[int, str]]]):
|
|
13
|
+
for line in lines:
|
|
14
|
+
for i,c in enumerate(line):
|
|
15
|
+
if c == -1:
|
|
16
|
+
continue
|
|
17
|
+
elif isinstance(c, str):
|
|
18
|
+
assert c[:-1].isdigit(), f'strings must begin with a digit, got {c}'
|
|
19
|
+
line[i] = (int(c[:-1]), c[-1])
|
|
20
|
+
elif isinstance(c, tuple):
|
|
21
|
+
assert len(c) == 2 and isinstance(c[0], int) and isinstance(c[1], str), f'tuples must be (int, str), got {c}'
|
|
22
|
+
else:
|
|
23
|
+
raise ValueError(f'invalid cell value: {c}')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Board:
|
|
27
|
+
def __init__(self, top: list[list[tuple[int, str]]], side: list[list[tuple[int, str]]]):
|
|
28
|
+
assert_input(top)
|
|
29
|
+
assert_input(side)
|
|
30
|
+
self.top = top
|
|
31
|
+
self.side = side
|
|
32
|
+
self.V = len(side)
|
|
33
|
+
self.H = len(top)
|
|
34
|
+
self.unique_colors = list(set([i[1] for line in top for i in line if i != -1] + [i[1] for line in side for i in line if i != -1]))
|
|
35
|
+
self.model = cp_model.CpModel()
|
|
36
|
+
self.model_vars: dict[Pos, dict[str, cp_model.IntVar]] = defaultdict(dict)
|
|
37
|
+
self.extra_vars = {}
|
|
38
|
+
|
|
39
|
+
self.create_vars()
|
|
40
|
+
self.add_all_constraints()
|
|
41
|
+
|
|
42
|
+
def create_vars(self):
|
|
43
|
+
for pos in get_all_pos(self.V, self.H):
|
|
44
|
+
for color in self.unique_colors:
|
|
45
|
+
self.model_vars[pos][color] = self.model.NewBoolVar(f'{pos}:{color}')
|
|
46
|
+
|
|
47
|
+
def add_all_constraints(self):
|
|
48
|
+
for pos in get_all_pos(self.V, self.H):
|
|
49
|
+
self.model.Add(lxp.sum(list(self.model_vars[pos].values())) <= 1)
|
|
50
|
+
for i in range(self.V):
|
|
51
|
+
ground_sequence = self.side[i]
|
|
52
|
+
if tuple(ground_sequence) == (-1,):
|
|
53
|
+
continue
|
|
54
|
+
current_sequence = [self.model_vars[pos] for pos in get_row_pos(i, self.H)]
|
|
55
|
+
self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_side_{i}')
|
|
56
|
+
for i in range(self.H):
|
|
57
|
+
ground_sequence = self.top[i]
|
|
58
|
+
if tuple(ground_sequence) == (-1,):
|
|
59
|
+
continue
|
|
60
|
+
current_sequence = [self.model_vars[pos] for pos in get_col_pos(i, self.V)]
|
|
61
|
+
self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_top_{i}')
|
|
62
|
+
|
|
63
|
+
def constrain_nonogram_sequence(self, clues: list[tuple[int, str]], current_sequence: list[dict[str, cp_model.IntVar]], ns: str):
|
|
64
|
+
"""
|
|
65
|
+
Constrain a colored sequence (current_sequence) to match the nonogram clues in clues.
|
|
66
|
+
|
|
67
|
+
clues: e.g., [(3, 'R'), (1, 'G')] means: a run of 3 red ones, then a run of 1 green one. If two clues are next to each other and have the same color, they must be separated by at least one blank.
|
|
68
|
+
current_sequence: list of dicts of IntVar in {0,1} for each color.
|
|
69
|
+
|
|
70
|
+
steps:
|
|
71
|
+
- Create start position s_i for each run i.
|
|
72
|
+
- Enforce order and >=1 separation between runs.
|
|
73
|
+
- Link each cell j to exactly one run interval (or none) via coverage booleans.
|
|
74
|
+
- Force sum of ones to equal sum(clues).
|
|
75
|
+
"""
|
|
76
|
+
L = len(current_sequence)
|
|
77
|
+
R = len(clues)
|
|
78
|
+
|
|
79
|
+
# Early infeasibility check:
|
|
80
|
+
# Minimum required blanks equals number of adjacent pairs with same color.
|
|
81
|
+
same_color_separators = sum(1 for (len_i, col_i), (len_j, col_j) in zip(clues, clues[1:]) if col_i == col_j)
|
|
82
|
+
min_needed = sum(len_i for len_i, _ in clues) + same_color_separators
|
|
83
|
+
if min_needed > L:
|
|
84
|
+
print(f"Infeasible: clues {clues} need {min_needed} cells but line length is {L} for {ns}")
|
|
85
|
+
self.model.Add(0 == 1)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Collect the color set present in clues and in the line vars
|
|
89
|
+
clue_colors = {c for _, c in clues}
|
|
90
|
+
seq_colors = set()
|
|
91
|
+
for j in range(L):
|
|
92
|
+
seq_colors.update(current_sequence[j].keys())
|
|
93
|
+
colors = sorted(clue_colors | seq_colors)
|
|
94
|
+
|
|
95
|
+
# Start vars per run
|
|
96
|
+
starts: list[cp_model.IntVar] = []
|
|
97
|
+
self.extra_vars[f"{ns}_starts"] = starts
|
|
98
|
+
for i in range(len(clues)):
|
|
99
|
+
# s_i in [0, L] but we will bound by containment constraint below
|
|
100
|
+
s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
|
|
101
|
+
starts.append(s)
|
|
102
|
+
|
|
103
|
+
# Ordering + separation:
|
|
104
|
+
# If same color: s[i+1] >= s[i] + len[i] + 1
|
|
105
|
+
# If different color: s[i+1] >= s[i] + len[i]
|
|
106
|
+
for i in range(R - 1):
|
|
107
|
+
len_i, col_i = clues[i]
|
|
108
|
+
_, col_next = clues[i + 1]
|
|
109
|
+
gap = 1 if col_i == col_next else 0
|
|
110
|
+
self.model.Add(starts[i + 1] >= starts[i] + len_i + gap)
|
|
111
|
+
|
|
112
|
+
# Containment: s[i] + len[i] <= L
|
|
113
|
+
for i, (run_len, _) in enumerate(clues):
|
|
114
|
+
self.model.Add(starts[i] + run_len <= L)
|
|
115
|
+
|
|
116
|
+
# Coverage booleans: cover[i][j] <=> (starts[i] <= j) AND (j < starts[i] + run_len)
|
|
117
|
+
cover = [[None] * L for _ in range(R)]
|
|
118
|
+
list_b_le = [[None] * L for _ in range(R)]
|
|
119
|
+
list_b_lt_end = [[None] * L for _ in range(R)]
|
|
120
|
+
self.extra_vars[f"{ns}_cover"] = cover
|
|
121
|
+
self.extra_vars[f"{ns}_list_b_le"] = list_b_le
|
|
122
|
+
self.extra_vars[f"{ns}_list_b_lt_end"] = list_b_lt_end
|
|
123
|
+
|
|
124
|
+
for i, (run_len, _) in enumerate(clues):
|
|
125
|
+
s_i = starts[i]
|
|
126
|
+
for j in range(L):
|
|
127
|
+
b_le = self.model.NewBoolVar(f"{ns}_le[{i},{j}]") # s_i <= j
|
|
128
|
+
self.model.Add(s_i <= j).OnlyEnforceIf(b_le)
|
|
129
|
+
self.model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
|
|
130
|
+
|
|
131
|
+
b_lt_end = self.model.NewBoolVar(f"{ns}_lt_end[{i},{j}]") # j < s_i + run_len <=> s_i + run_len - 1 >= j
|
|
132
|
+
end_expr = s_i + run_len - 1
|
|
133
|
+
self.model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
|
|
134
|
+
self.model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not())
|
|
135
|
+
|
|
136
|
+
b_cov = self.model.NewBoolVar(f"{ns}_cov[{i},{j}]")
|
|
137
|
+
self.model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
|
|
138
|
+
self.model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
|
|
139
|
+
|
|
140
|
+
cover[i][j] = b_cov
|
|
141
|
+
list_b_le[i][j] = b_le
|
|
142
|
+
list_b_lt_end[i][j] = b_lt_end
|
|
143
|
+
|
|
144
|
+
# Link coverage to per-cell, per-color variables.
|
|
145
|
+
# For each color k and cell j:
|
|
146
|
+
# sum_{i: color_i == k} cover[i][j] == current_sequence[j][k]
|
|
147
|
+
# Also tie the total cover at j to the sum over all colors at j:
|
|
148
|
+
# sum_i cover[i][j] == sum_k current_sequence[j][k]
|
|
149
|
+
# This enforces that at most one color is active per cell (since the LHS is in {0,1} due to non-overlap).
|
|
150
|
+
# If a color var is missing in current_sequence[j], assume it’s an implicit 0 by creating a fixed zero var.
|
|
151
|
+
# (Alternatively, require the caller to provide all colors per cell.)
|
|
152
|
+
zero_cache = {}
|
|
153
|
+
def get_zero(name: str):
|
|
154
|
+
if name not in zero_cache:
|
|
155
|
+
z = self.model.NewConstant(0)
|
|
156
|
+
zero_cache[name] = z
|
|
157
|
+
return zero_cache[name]
|
|
158
|
+
|
|
159
|
+
# Pre-index runs by color for efficiency
|
|
160
|
+
runs_by_color = {k: [] for k in colors}
|
|
161
|
+
for i, (_, k) in enumerate(clues):
|
|
162
|
+
runs_by_color[k].append(i)
|
|
163
|
+
|
|
164
|
+
for j in range(L):
|
|
165
|
+
# Total coverage at cell j
|
|
166
|
+
total_cov_j = sum(cover[i][j] for i in range(R)) if R > 0 else 0
|
|
167
|
+
|
|
168
|
+
# Sum of color vars at cell j
|
|
169
|
+
color_vars_j = []
|
|
170
|
+
for k in colors:
|
|
171
|
+
v = current_sequence[j].get(k, None)
|
|
172
|
+
if v is None:
|
|
173
|
+
v = get_zero(f"{ns}_zero_{k}")
|
|
174
|
+
color_vars_j.append(v)
|
|
175
|
+
|
|
176
|
+
# Per-color coverage equality
|
|
177
|
+
if runs_by_color[k]:
|
|
178
|
+
self.model.Add(sum(cover[i][j] for i in runs_by_color[k]) == v)
|
|
179
|
+
else:
|
|
180
|
+
# No runs of this color -> force cell color var to 0
|
|
181
|
+
self.model.Add(v == 0)
|
|
182
|
+
|
|
183
|
+
# Tie total coverage to sum of color vars (blank vs exactly-one color)
|
|
184
|
+
if R > 0:
|
|
185
|
+
self.model.Add(total_cov_j == sum(color_vars_j))
|
|
186
|
+
else:
|
|
187
|
+
# No runs at all: all cells must be blank across all colors
|
|
188
|
+
for v in color_vars_j:
|
|
189
|
+
self.model.Add(v == 0)
|
|
190
|
+
|
|
191
|
+
# Optional but strong propagation: per-color totals must match total clue lengths of that color
|
|
192
|
+
total_len_by_color = {k: 0 for k in colors}
|
|
193
|
+
for length, k in clues:
|
|
194
|
+
total_len_by_color[k] += length
|
|
195
|
+
|
|
196
|
+
for k in colors:
|
|
197
|
+
total_cells_k = sum(current_sequence[j].get(k, get_zero(f"{ns}_zero_{k}")) for j in range(L))
|
|
198
|
+
self.model.Add(total_cells_k == total_len_by_color[k])
|
|
199
|
+
|
|
200
|
+
def solve_and_print(self, verbose: bool = True, visualize_colors: Optional[dict[str, str]] = None):
|
|
201
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
202
|
+
return SingleSolution(assignment={pos: color for pos, d in board.model_vars.items() for color, var in d.items() if solver.value(var) == 1})
|
|
203
|
+
def callback(single_res: SingleSolution):
|
|
204
|
+
print("Solution found")
|
|
205
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ' ')))
|
|
206
|
+
if visualize_colors is not None:
|
|
207
|
+
from matplotlib import pyplot as plt
|
|
208
|
+
from matplotlib.colors import ListedColormap
|
|
209
|
+
visualize_colors[' '] = 'black'
|
|
210
|
+
visualize_colors_keys = list(visualize_colors.keys())
|
|
211
|
+
char_to_int = {c: i for i, c in enumerate(visualize_colors_keys)}
|
|
212
|
+
nums = [[char_to_int[single_res.assignment.get(get_pos(x=c, y=r), ' ')] for c in range(self.H)] for r in range(self.V)]
|
|
213
|
+
plt.imshow(nums,
|
|
214
|
+
aspect='equal',
|
|
215
|
+
cmap=ListedColormap([visualize_colors[c] for c in visualize_colors_keys]),
|
|
216
|
+
extent=[0, self.H, self.V, 0])
|
|
217
|
+
plt.colorbar()
|
|
218
|
+
# plt.grid(True)
|
|
219
|
+
plt.show()
|
|
220
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|