multi-puzzle-solver 0.9.26__py3-none-any.whl → 0.9.30__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.

@@ -0,0 +1,158 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ import time
4
+
5
+ import numpy as np
6
+ from ortools.sat.python import cp_model
7
+
8
+ from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_neighbors4, get_next_pos, get_char, in_bounds
9
+ from puzzle_solver.core.utils_ortools import generic_solve_all, force_connected_component, and_constraint
10
+ from puzzle_solver.core.utils_visualizer import render_grid
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SingleSolution:
15
+ assignment: dict[tuple[Pos, Pos], int]
16
+
17
+ def get_hashable_solution(self) -> str:
18
+ result = []
19
+ for (pos, neighbor), v in self.assignment.items():
20
+ result.append((pos.x, pos.y, neighbor.x, neighbor.y, v))
21
+ return json.dumps(result, sort_keys=True)
22
+
23
+
24
+ def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[tuple[Pos, Pos]]:
25
+ out = []
26
+ prev_pos = pos
27
+ while True:
28
+ pos = get_next_pos(pos, direction)
29
+ if not in_bounds(pos, V, H):
30
+ break
31
+ out.append((prev_pos, pos))
32
+ prev_pos = pos
33
+ return out
34
+
35
+
36
+ class Board:
37
+ def __init__(self, board: np.array):
38
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
39
+ assert all((c.item().strip() == '') or (str(c.item())[:-1].isdecimal() and c.item()[-1].upper() in ['B', 'W']) for c in np.nditer(board)), 'board must contain only space or digits and B/W'
40
+
41
+ self.V, self.H = board.shape
42
+ self.board_numbers: dict[Pos, int] = {}
43
+ self.board_colors: dict[Pos, str] = {}
44
+ for pos in get_all_pos(self.V, self.H):
45
+ c = get_char(board, pos)
46
+ if c.strip() == '':
47
+ continue
48
+ self.board_numbers[pos] = int(c[:-1])
49
+ self.board_colors[pos] = c[-1].upper()
50
+ self.model = cp_model.CpModel()
51
+ self.edge_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
52
+
53
+ self.create_vars()
54
+ self.add_all_constraints()
55
+
56
+ def create_vars(self):
57
+ for pos in get_all_pos(self.V, self.H):
58
+ for neighbor in get_neighbors4(pos, self.V, self.H):
59
+ if (neighbor, pos) in self.edge_vars: # already added in opposite direction
60
+ self.edge_vars[(pos, neighbor)] = self.edge_vars[(neighbor, pos)]
61
+ else: # new edge
62
+ self.edge_vars[(pos, neighbor)] = self.model.NewBoolVar(f'{pos}-{neighbor}')
63
+
64
+ def add_all_constraints(self):
65
+ # each corners must have either 0 or 2 neighbors
66
+ for pos in get_all_pos(self.V, self.H):
67
+ corner_connections = [self.edge_vars[(pos, n)] for n in get_neighbors4(pos, self.V, self.H)]
68
+ if pos not in self.board_numbers: # no color, either 0 or 2 edges
69
+ self.model.AddLinearExpressionInDomain(sum(corner_connections), cp_model.Domain.FromValues([0, 2]))
70
+ else: # color, must have exactly 2 edges
71
+ self.model.Add(sum(corner_connections) == 2)
72
+
73
+ # enforce colors
74
+ for pos in get_all_pos(self.V, self.H):
75
+ if pos not in self.board_numbers:
76
+ continue
77
+ self.enforce_corner_color(pos, self.board_colors[pos])
78
+ self.enforce_corner_number(pos, self.board_numbers[pos])
79
+
80
+ # enforce single connected component
81
+ def is_neighbor(edge1: tuple[Pos, Pos], edge2: tuple[Pos, Pos]) -> bool:
82
+ return any(c1 == c2 for c1 in edge1 for c2 in edge2)
83
+ force_connected_component(self.model, self.edge_vars, is_neighbor=is_neighbor)
84
+
85
+ def enforce_corner_color(self, pos: Pos, pos_color: str):
86
+ assert pos_color in ['W', 'B'], f'Invalid color: {pos_color}'
87
+ pos_r = get_next_pos(pos, Direction.RIGHT)
88
+ var_r = self.edge_vars[(pos, pos_r)] if (pos, pos_r) in self.edge_vars else False
89
+ pos_d = get_next_pos(pos, Direction.DOWN)
90
+ var_d = self.edge_vars[(pos, pos_d)] if (pos, pos_d) in self.edge_vars else False
91
+ pos_l = get_next_pos(pos, Direction.LEFT)
92
+ var_l = self.edge_vars[(pos, pos_l)] if (pos, pos_l) in self.edge_vars else False
93
+ pos_u = get_next_pos(pos, Direction.UP)
94
+ var_u = self.edge_vars[(pos, pos_u)] if (pos, pos_u) in self.edge_vars else False
95
+ if pos_color == 'W': # White circles must be passed through in a straight line
96
+ self.model.Add(var_r == var_l)
97
+ self.model.Add(var_u == var_d)
98
+ elif pos_color == 'B': # Black circles must be turned upon
99
+ self.model.Add(var_r == 0).OnlyEnforceIf([var_l])
100
+ self.model.Add(var_l == 0).OnlyEnforceIf([var_r])
101
+ self.model.Add(var_u == 0).OnlyEnforceIf([var_d])
102
+ self.model.Add(var_d == 0).OnlyEnforceIf([var_u])
103
+ else:
104
+ raise ValueError(f'Invalid color: {pos_color}')
105
+
106
+ def enforce_corner_number(self, pos: Pos, pos_number: int):
107
+ # The numbers in the circles show the sum of the lengths of the 2 straight lines going out of that circle.
108
+ # Build visibility chains per direction (exclude self)
109
+ vis_vars: list[cp_model.IntVar] = []
110
+ for direction in Direction:
111
+ rays = get_ray(pos, self.V, self.H, direction) # cells outward
112
+ if not rays:
113
+ continue
114
+ # Chain: v0 = w[ray[0]]; vt = w[ray[t]] & vt-1
115
+ prev = None
116
+ for idx, (pos1, pos2) in enumerate(rays):
117
+ v = self.model.NewBoolVar(f"vis[{pos1}-{pos2}]->({direction.name})[{idx}]")
118
+ vis_vars.append(v)
119
+ if idx == 0:
120
+ # v0 == w[cell]
121
+ self.model.Add(v == self.edge_vars[(pos1, pos2)])
122
+ else:
123
+ and_constraint(self.model, target=v, cs=[self.edge_vars[(pos1, pos2)], prev])
124
+ prev = v
125
+ self.model.Add(sum(vis_vars) == pos_number)
126
+
127
+
128
+ def solve_and_print(self, verbose: bool = True):
129
+ tic = time.time()
130
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
131
+ assignment: dict[tuple[Pos, Pos], int] = {}
132
+ for (pos, neighbor), var in board.edge_vars.items():
133
+ assignment[(pos, neighbor)] = solver.Value(var)
134
+ return SingleSolution(assignment=assignment)
135
+ def callback(single_res: SingleSolution):
136
+ nonlocal tic
137
+ print(f"Solution found in {time.time() - tic:.2f} seconds")
138
+ tic = time.time()
139
+ res = np.full((self.V - 1, self.H - 1), ' ', dtype=object)
140
+ for (pos, neighbor), v in single_res.assignment.items():
141
+ if v == 0:
142
+ continue
143
+ min_x = min(pos.x, neighbor.x)
144
+ min_y = min(pos.y, neighbor.y)
145
+ dx = abs(pos.x - neighbor.x)
146
+ dy = abs(pos.y - neighbor.y)
147
+ if min_x == self.H - 1: # only way to get right
148
+ res[min_y][min_x - 1] += 'R'
149
+ elif min_y == self.V - 1: # only way to get down
150
+ res[min_y - 1][min_x] += 'D'
151
+ elif dx == 1:
152
+ res[min_y][min_x] += 'U'
153
+ elif dy == 1:
154
+ res[min_y][min_x] += 'L'
155
+ else:
156
+ raise ValueError(f'Invalid position: {pos} and {neighbor}')
157
+ print(render_grid(res, center_char='.'))
158
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -1,8 +1,9 @@
1
1
  import numpy as np
2
2
  from ortools.sat.python import cp_model
3
3
 
4
- from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_neighbors4, get_all_pos_to_idx_dict, get_row_pos, get_col_pos
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors4, get_all_pos_to_idx_dict, get_row_pos, get_col_pos, get_pos
5
5
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
6
+ from puzzle_solver.core.utils_visualizer import render_shaded_grid
6
7
 
7
8
 
8
9
  class Board:
@@ -14,62 +15,35 @@ class Board:
14
15
  self.H = board.shape[1]
15
16
  self.N = self.V * self.H
16
17
  self.idx_of: dict[Pos, int] = get_all_pos_to_idx_dict(self.V, self.H)
17
-
18
18
  self.model = cp_model.CpModel()
19
- self.B = {} # black squares
20
- self.W = {} # white squares
19
+ self.B = {}
20
+ self.W = {}
21
21
  self.Num = {} # value of squares (Num = N + idx if black, else board[pos])
22
-
23
22
  self.create_vars()
24
23
  self.add_all_constraints()
25
24
 
26
25
  def create_vars(self):
27
26
  for pos in get_all_pos(self.V, self.H):
28
27
  self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
29
- self.W[pos] = self.model.NewBoolVar(f'W:{pos}')
30
- # either black or white
31
- self.model.AddExactlyOne([self.B[pos], self.W[pos]])
28
+ self.W[pos] = self.B[pos].Not()
32
29
  self.Num[pos] = self.model.NewIntVar(0, 2*self.N, f'{pos}')
33
30
  self.model.Add(self.Num[pos] == self.N + self.idx_of[pos]).OnlyEnforceIf(self.B[pos])
34
- self.model.Add(self.Num[pos] == int(get_char(self.board, pos))).OnlyEnforceIf(self.B[pos].Not())
31
+ self.model.Add(self.Num[pos] == int(get_char(self.board, pos))).OnlyEnforceIf(self.W[pos])
35
32
 
36
33
  def add_all_constraints(self):
37
- self.no_adjacent_blacks()
38
- self.no_number_appears_twice()
39
- self.force_connected_component()
40
-
41
- def no_adjacent_blacks(self):
42
- # no two black squares are adjacent
43
- for pos in get_all_pos(self.V, self.H):
34
+ for pos in get_all_pos(self.V, self.H): # no two black squares are adjacent
44
35
  for neighbor in get_neighbors4(pos, self.V, self.H):
45
36
  self.model.Add(self.B[pos] + self.B[neighbor] <= 1)
46
-
47
- def no_number_appears_twice(self):
48
- # no number appears twice in any row or column (numbers are ignored if black)
49
- for row in range(self.V):
50
- var_list = [self.Num[pos] for pos in get_row_pos(row, self.H)]
51
- self.model.AddAllDifferent(var_list)
52
- for col in range(self.H):
53
- var_list = [self.Num[pos] for pos in get_col_pos(col, self.V)]
54
- self.model.AddAllDifferent(var_list)
55
-
56
- def force_connected_component(self):
57
- force_connected_component(self.model, self.W)
58
-
59
-
37
+ for row in range(self.V): # no number appears twice in any row (numbers are ignored if black)
38
+ self.model.AddAllDifferent([self.Num[pos] for pos in get_row_pos(row, self.H)])
39
+ for col in range(self.H): # no number appears twice in any column (numbers are ignored if black)
40
+ self.model.AddAllDifferent([self.Num[pos] for pos in get_col_pos(col, self.V)])
41
+ force_connected_component(self.model, self.W) # all white squares must be a single connected component
60
42
 
61
43
  def solve_and_print(self, verbose: bool = True):
62
44
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
63
- assignment: dict[Pos, int] = {}
64
- for pos, var in board.B.items():
65
- assignment[pos] = solver.value(var)
66
- return SingleSolution(assignment=assignment)
45
+ return SingleSolution(assignment={pos: 1 if solver.Value(val) == 1 else 0 for pos, val in board.B.items()})
67
46
  def callback(single_res: SingleSolution):
68
47
  print("Solution found")
69
- res = np.full((self.V, self.H), ' ', dtype=object)
70
- for pos in get_all_pos(self.V, self.H):
71
- c = get_char(self.board, pos)
72
- c = 'B' if single_res.assignment[pos] == 1 else ' '
73
- set_char(res, pos, c)
74
- print(res)
48
+ print(render_shaded_grid(self.V, self.H, lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, empty_text=lambda r, c: self.board[r, c]))
75
49
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -2,8 +2,9 @@ import numpy as np
2
2
  from collections import defaultdict
3
3
  from ortools.sat.python import cp_model
4
4
 
5
- from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, Direction, get_row_pos, get_col_pos, get_next_pos, in_bounds, get_opposite_direction, render_grid
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, Direction, get_row_pos, get_col_pos, get_next_pos, in_bounds, get_opposite_direction
6
6
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
7
+ from puzzle_solver.core.utils_visualizer import render_grid
7
8
 
8
9
 
9
10
  CellBorder = tuple[Pos, Direction]
@@ -242,4 +242,6 @@ if __name__ == '__main__':
242
242
  # main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
243
243
  # main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
244
244
  # main(Path(__file__).parent / 'input_output' / 'norinori_501d93110d6b4b818c268378973afbf268f96cfa8d7b4.png')
245
- main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
245
+ # main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
246
+ # main(Path(__file__).parent / 'input_output' / 'heyawake_MDoxNiwxNDQ=.png')
247
+ main(Path(__file__).parent / 'input_output' / 'heyawake_MTQ6ODQ4LDEzOQ==.png')
@@ -0,0 +1,98 @@
1
+ from typing import Union
2
+ from itertools import combinations
3
+
4
+ import numpy as np
5
+ from ortools.sat.python import cp_model
6
+
7
+ from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, set_char, get_char, in_bounds, Direction, get_next_pos, get_pos
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
9
+ from puzzle_solver.core.utils_visualizer import render_shaded_grid
10
+
11
+
12
+ def rotated_assignments_N_nums(Xs: tuple[int, ...], target_length: int = 8) -> set[tuple[bool, ...]]:
13
+ """ Given Xs = [X1, X2, ..., Xm] (each Xi >= 1), build all unique length-`target_length`
14
+ boolean lists of the form: [ True*X1, False*N1, True*X2, False*N2, ..., True*Xm, False*Nm ]
15
+ where each Ni >= 1 and sum(Xs) + sum(Ni) = target_length,
16
+ including all `target_length` wrap-around rotations, de-duplicated.
17
+ """
18
+ assert len(Xs) >= 1, "Xs must have at least one block length."
19
+ assert all(x >= 1 for x in Xs), "All Xi must be >= 1."
20
+ assert sum(Xs) + len(Xs) <= target_length, f"sum(Xs) + len(Xs) <= target_length required; got {sum(Xs)} + {len(Xs)} > {target_length}"
21
+ num_zero_blocks = len(Xs)
22
+ total_zeros = target_length - sum(Xs)
23
+ seen: set[tuple[bool, ...]] = set()
24
+ for cut_positions in combinations(range(1, total_zeros), num_zero_blocks - 1):
25
+ cut_positions = (*cut_positions, total_zeros)
26
+ Ns = [cut_positions[0]] # length of zero blocks
27
+ for i in range(1, len(cut_positions)):
28
+ Ns.append(cut_positions[i] - cut_positions[i - 1])
29
+ base: list[bool] = []
30
+ for x, n in zip(Xs, Ns):
31
+ base.extend([True] * x)
32
+ base.extend([False] * n)
33
+ for dx in range(target_length): # all rotations (wrap-around)
34
+ rot = tuple(base[dx:] + base[:dx])
35
+ seen.add(rot)
36
+ return seen
37
+
38
+
39
+ class Board:
40
+ def __init__(self, board: np.array, separator: str = '/'):
41
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
42
+ assert all(all(str(c).strip().isdecimal() or str(c).strip() == '' for c in cell.item().split(separator)) for cell in np.nditer(board)), 'board must contain only digits and separator'
43
+ self.V, self.H = board.shape
44
+ self.board = board
45
+ self.separator = separator
46
+ self.model = cp_model.CpModel()
47
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
48
+ self.create_vars()
49
+ self.add_all_constraints()
50
+
51
+ def create_vars(self):
52
+ for pos in get_all_pos(self.V, self.H):
53
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
54
+
55
+ def add_all_constraints(self):
56
+ # 2x2 blacks are not allowed
57
+ for pos in get_all_pos(self.V, self.H):
58
+ tl = pos
59
+ tr = get_next_pos(pos, Direction.RIGHT)
60
+ bl = get_next_pos(pos, Direction.DOWN)
61
+ br = get_next_pos(bl, Direction.RIGHT)
62
+ if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
63
+ continue
64
+ self.model.AddBoolOr([self.model_vars[tl].Not(), self.model_vars[tr].Not(), self.model_vars[bl].Not(), self.model_vars[br].Not()])
65
+ for pos in get_all_pos(self.V, self.H):
66
+ c = get_char(self.board, pos)
67
+ if c.strip() == '':
68
+ continue
69
+ clue = tuple(int(x.strip()) for x in c.split(self.separator))
70
+ self.model.Add(self.model_vars[pos] == 0) # clue cannot be black
71
+ self.enforce_clue(pos, clue) # each clue must be satisfied
72
+ # all blacks are connected
73
+ force_connected_component(self.model, self.model_vars)
74
+
75
+ def enforce_clue(self, pos: Pos, clue: Union[int, tuple[int, int]]):
76
+ neighbors = []
77
+ for direction in [Direction8.UP, Direction8.UP_RIGHT, Direction8.RIGHT, Direction8.DOWN_RIGHT, Direction8.DOWN, Direction8.DOWN_LEFT, Direction8.LEFT, Direction8.UP_LEFT]:
78
+ n = get_next_pos(pos, direction)
79
+ neighbors.append(self.model_vars[n] if in_bounds(n, self.V, self.H) else self.model.NewConstant(False))
80
+ valid_assignments = rotated_assignments_N_nums(Xs=clue)
81
+ self.model.AddAllowedAssignments(neighbors, valid_assignments)
82
+
83
+ def solve_and_print(self, verbose: bool = True):
84
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
85
+ assignment: dict[Pos, int] = {}
86
+ for pos, var in board.model_vars.items():
87
+ assignment[pos] = solver.Value(var)
88
+ return SingleSolution(assignment=assignment)
89
+ def callback(single_res: SingleSolution):
90
+ print("Solution found")
91
+ board_justified = np.full((self.V, self.H), ' ', dtype=object)
92
+ for pos in get_all_pos(self.V, self.H):
93
+ c = get_char(self.board, pos).strip()
94
+ if len(c) > 3:
95
+ c = '...'
96
+ set_char(board_justified, pos, ' ' * (2 - len(c)) + c)
97
+ print(render_shaded_grid(self.V, self.H, lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, empty_text=lambda r, c: str(board_justified[r, c])))
98
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=5)