multi-puzzle-solver 0.9.12__py3-none-any.whl → 0.9.14__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,162 @@
1
+ import json
2
+ import time
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Union
5
+
6
+ from ortools.sat.python import cp_model
7
+ import numpy as np
8
+
9
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, in_bounds, Direction, get_next_pos, polyominoes_with_shape_id
10
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
11
+
12
+
13
+ # a shape on the 2d board is just a set of positions
14
+ Shape = frozenset[Pos]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SingleSolution:
19
+ assignment: dict[Pos, Union[str, int]]
20
+ all_other_variables: dict
21
+
22
+ def get_hashable_solution(self) -> str:
23
+ result = []
24
+ for pos, v in self.assignment.items():
25
+ result.append((pos.x, pos.y, v))
26
+ return json.dumps(result, sort_keys=True)
27
+
28
+
29
+
30
+ @dataclass
31
+ class ShapeOnBoard:
32
+ is_active: cp_model.IntVar
33
+ shape: Shape
34
+ shape_id: int
35
+ body: set[Pos]
36
+ disallow_same_shape: set[Pos]
37
+
38
+
39
+ class Board:
40
+ def __init__(self, board: np.array, polyomino_degrees: int = 4):
41
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
42
+ self.V = board.shape[0]
43
+ self.H = board.shape[1]
44
+ assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
45
+ self.board = board
46
+ self.polyomino_degrees = polyomino_degrees
47
+ self.polyominoes = polyominoes_with_shape_id(self.polyomino_degrees)
48
+
49
+ self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
50
+ self.blocks = {i: set() for i in self.block_numbers}
51
+ for cell in get_all_pos(self.V, self.H):
52
+ self.blocks[int(get_char(self.board, cell))].add(cell)
53
+
54
+ self.model = cp_model.CpModel()
55
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
56
+ self.connected_components: dict[Pos, cp_model.IntVar] = {}
57
+ self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
58
+
59
+ self.create_vars()
60
+ self.init_shapes_on_board()
61
+ self.add_all_constraints()
62
+
63
+ def create_vars(self):
64
+ for pos in get_all_pos(self.V, self.H):
65
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
66
+ # print('base vars:', len(self.model_vars))
67
+
68
+ def init_shapes_on_board(self):
69
+ for idx, (shape, shape_id) in enumerate(self.polyominoes):
70
+ for translate in get_all_pos(self.V, self.H): # body of shape is translated to be at pos
71
+ body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape}
72
+ if any(not in_bounds(p, self.V, self.H) for p in body):
73
+ continue
74
+ # shape must be fully contained in one block
75
+ if len(set(get_char(self.board, p) for p in body)) > 1:
76
+ continue
77
+ # 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
78
+ disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
79
+ disallow_same_shape -= body
80
+ self.shapes_on_board.append(ShapeOnBoard(
81
+ is_active=self.model.NewBoolVar(f'{idx}:{translate}:is_active'),
82
+ shape=shape,
83
+ shape_id=shape_id,
84
+ body=body,
85
+ disallow_same_shape=disallow_same_shape,
86
+ ))
87
+ # print('shapes on board:', len(self.shapes_on_board))
88
+
89
+ def add_all_constraints(self):
90
+ # RULES:
91
+ # 1- You have to place one tetromino in each region in such a way that:
92
+ # 2- 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
93
+ # 3- The shaded cells should form a single connected area.
94
+ # 4- 2x2 shaded areas are not allowed
95
+
96
+ # each cell must be part of a shape, every shape must be fully on the board. Core constraint, otherwise shapes on the board make no sense.
97
+ self.only_allow_shapes_on_board()
98
+
99
+ self.force_one_shape_per_block() # Rule #1
100
+ self.disallow_same_shape_touching() # Rule #2
101
+ self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
102
+ # print('force connected vars:', len(fc))
103
+ shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
104
+ self.disallow_shape(shape_2_by_2) # Rule #4
105
+
106
+
107
+ def only_allow_shapes_on_board(self):
108
+ for shape_on_board in self.shapes_on_board:
109
+ # if shape is active then all its body cells must be active
110
+ self.model.Add(sum(self.model_vars[p] for p in shape_on_board.body) == len(shape_on_board.body)).OnlyEnforceIf(shape_on_board.is_active)
111
+ # each cell must be part of a shape
112
+ for p in get_all_pos(self.V, self.H):
113
+ shapes_on_p = [s for s in self.shapes_on_board if p in s.body]
114
+ self.model.Add(sum(s.is_active for s in shapes_on_p) == 1).OnlyEnforceIf(self.model_vars[p])
115
+
116
+ def force_one_shape_per_block(self):
117
+ # You have to place exactly one tetromino in each region
118
+ for block_i in self.block_numbers:
119
+ shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
120
+ assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
121
+ # print(f'shapes on block {block_i} has {len(shapes_on_block)} shapes')
122
+ self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
123
+
124
+ def disallow_same_shape_touching(self):
125
+ # if shape is active then it must not touch any other shape of the same type
126
+ for shape_on_board in self.shapes_on_board:
127
+ similar_shapes = [s for s in self.shapes_on_board if s.shape_id == shape_on_board.shape_id]
128
+ for s in similar_shapes:
129
+ if shape_on_board.disallow_same_shape & s.body: # this shape disallows having s be on the board
130
+ self.model.Add(s.is_active == 0).OnlyEnforceIf(shape_on_board.is_active)
131
+
132
+ def disallow_shape(self, shape_to_disallow: Shape):
133
+ # for every position in the board, force sum of body < len(body)
134
+ for translate in get_all_pos(self.V, self.H):
135
+ cur_body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape_to_disallow}
136
+ if any(not in_bounds(p, self.V, self.H) for p in cur_body):
137
+ continue
138
+ self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
139
+
140
+
141
+
142
+
143
+ def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
144
+ if verbose_callback is None:
145
+ verbose_callback = verbose
146
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
147
+ assignment: dict[Pos, int] = {}
148
+ for pos, var in board.model_vars.items():
149
+ assignment[pos] = solver.Value(var)
150
+ all_other_variables = {
151
+ 'fc': {k: solver.Value(v) for k, v in board.fc.items()}
152
+ }
153
+ return SingleSolution(assignment=assignment, all_other_variables=all_other_variables)
154
+ def callback(single_res: SingleSolution):
155
+ print("Solution found")
156
+ res = np.full((self.V, self.H), ' ', dtype=str)
157
+ for pos, val in single_res.assignment.items():
158
+ c = 'X' if val == 1 else ' '
159
+ set_char(res, pos, c)
160
+ print('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
161
+ pass
162
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
@@ -5,7 +5,7 @@ from ortools.sat.python import cp_model
5
5
  from ortools.sat.python.cp_model import LinearExpr as lxp
6
6
 
7
7
  from puzzle_solver.core.utils import Pos, get_all_pos, set_char, in_bounds, Direction, get_next_pos, get_char, get_opposite_direction
8
- from puzzle_solver.core.utils_ortools import and_constraint, or_constraint, generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
9
9
 
10
10
 
11
11
  class Board:
@@ -18,7 +18,6 @@ class Board:
18
18
  self.model = cp_model.CpModel()
19
19
  self.cell_active: dict[Pos, cp_model.IntVar] = {}
20
20
  self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
21
- self.reach_layers: list[dict[Pos, cp_model.IntVar]] = [] # R_t[p] booleans, t = 0..T
22
21
 
23
22
  self.create_vars()
24
23
  self.add_all_constraints()
@@ -28,18 +27,11 @@ class Board:
28
27
  self.cell_active[pos] = self.model.NewBoolVar(f"a[{pos}]")
29
28
  for direction in Direction:
30
29
  self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f"b[{pos}]->({direction.name})")
31
- # Percolation layers R_t (monotone flood fill)
32
- T = self.V * self.H # large enough to cover whole board
33
- for t in range(T + 1):
34
- Rt: dict[Pos, cp_model.IntVar] = {}
35
- for pos in get_all_pos(self.V, self.H):
36
- Rt[pos] = self.model.NewBoolVar(f"R[{t}][{pos}]")
37
- self.reach_layers.append(Rt)
38
30
 
39
31
  def add_all_constraints(self):
40
32
  self.force_direction_constraints()
41
33
  self.force_wb_constraints()
42
- self.connectivity_percolation()
34
+ self.force_connected_component()
43
35
 
44
36
  def force_wb_constraints(self):
45
37
  for pos in get_all_pos(self.V, self.H):
@@ -91,40 +83,16 @@ class Board:
91
83
  else:
92
84
  self.model.Add(self.cell_direction[(pos, direction)] == 0)
93
85
 
94
- def connectivity_percolation(self):
95
- """
96
- Layered percolation:
97
- - root is exactly the first cell
98
- - R_t is monotone nondecreasing in t (R_t+1 >= R_t)
99
- - A cell can 'turn on' at layer t+1 iff has a neighbor on at layer t and the neighbor is pointing to it (or is root)
100
- - Final layer is all connected
101
- """
102
- # Seed: R0 = root
103
- for i, pos in enumerate(get_all_pos(self.V, self.H)):
104
- if i == 0:
105
- self.model.Add(self.reach_layers[0][pos] == 1) # first cell is root
106
- else:
107
- self.model.Add(self.reach_layers[0][pos] == 0)
108
-
109
- for t in range(1, len(self.reach_layers)):
110
- Rt_prev = self.reach_layers[t - 1]
111
- Rt = self.reach_layers[t]
112
- for p in get_all_pos(self.V, self.H):
113
- # Rt[p] = Rt_prev[p] | (white[p] & Rt_prev[neighbour #1]) | (white[p] & Rt_prev[neighbour #2]) | ...
114
- # Create helper (white[p] & Rt_prev[neighbour #X]) for each neighbor q
115
- neigh_helpers: list[cp_model.IntVar] = []
116
- for direction in Direction:
117
- q = get_next_pos(p, direction)
118
- if not in_bounds(q, self.V, self.H):
119
- continue
120
- a = self.model.NewBoolVar(f"A[{t}][{p}]<-({q})")
121
- and_constraint(self.model, target=a, cs=[Rt_prev[q], self.cell_direction[(q, get_opposite_direction(direction))]])
122
- neigh_helpers.append(a)
123
- or_constraint(self.model, target=Rt[p], cs=[Rt_prev[p]] + neigh_helpers)
124
-
125
- # every pearl must be reached by the final layer
126
- for p in get_all_pos(self.V, self.H):
127
- self.model.Add(self.reach_layers[-1][p] == 1).OnlyEnforceIf(self.cell_active[p])
86
+ def force_connected_component(self):
87
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
88
+ p1, d1 = pd1
89
+ p2, d2 = pd2
90
+ if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
91
+ return True
92
+ if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
93
+ return True
94
+ return False
95
+ force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
128
96
 
129
97
 
130
98
  def solve_and_print(self, verbose: bool = True):
@@ -2,7 +2,7 @@ import numpy as np
2
2
  from ortools.sat.python import cp_model
3
3
 
4
4
  from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_neighbors4, in_bounds, Direction, get_next_pos, get_char
5
- from puzzle_solver.core.utils_ortools import and_constraint, or_constraint, generic_solve_all, SingleSolution
5
+ from puzzle_solver.core.utils_ortools import and_constraint, or_constraint, generic_solve_all, SingleSolution, force_connected_component
6
6
 
7
7
 
8
8
  def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
@@ -27,9 +27,6 @@ class Board:
27
27
  # Core vars
28
28
  self.b: dict[Pos, cp_model.IntVar] = {} # 1=black, 0=white
29
29
  self.w: dict[Pos, cp_model.IntVar] = {} # 1=white, 0=black
30
- # Connectivity helpers
31
- self.root: dict[Pos, cp_model.IntVar] = {} # exactly one root; root <= w
32
- self.reach_layers: list[dict[Pos, cp_model.IntVar]] = [] # R_t[p] booleans, t = 0..T
33
30
 
34
31
  self.create_vars()
35
32
  self.add_all_constraints()
@@ -41,18 +38,6 @@ class Board:
41
38
  self.w[pos] = self.model.NewBoolVar(f"w[{pos}]")
42
39
  self.model.AddExactlyOne([self.b[pos], self.w[pos]])
43
40
 
44
- # Root
45
- for pos in get_all_pos(self.V, self.H):
46
- self.root[pos] = self.model.NewBoolVar(f"root[{pos}]")
47
-
48
- # Percolation layers R_t (monotone flood fill)
49
- T = self.V * self.H # large enough to cover whole board
50
- for t in range(T + 1):
51
- Rt: dict[Pos, cp_model.IntVar] = {}
52
- for pos in get_all_pos(self.V, self.H):
53
- Rt[pos] = self.model.NewBoolVar(f"R[{t}][{pos}]")
54
- self.reach_layers.append(Rt)
55
-
56
41
  def add_all_constraints(self):
57
42
  self.no_adjacent_blacks()
58
43
  self.white_connectivity_percolation()
@@ -69,41 +54,7 @@ class Board:
69
54
 
70
55
 
71
56
  def white_connectivity_percolation(self):
72
- """
73
- Layered percolation:
74
- - root is exactly the first white cell
75
- - R_t is monotone nondecreasing in t (R_t+1 >= R_t)
76
- - A cell can 'turn on' at layer t+1 iff it's white and has a neighbor on at layer t (or is root)
77
- - Final layer is equal to the white mask: R_T[p] == w[p] => all whites are connected to the unique root
78
- """
79
- # to find unique solutions easily, we make only 1 possible root allowed; root is exactly the first white cell
80
- prev_cells_black: list[cp_model.IntVar] = []
81
- for pos in get_all_pos(self.V, self.H):
82
- and_constraint(self.model, target=self.root[pos], cs=[self.w[pos]] + prev_cells_black)
83
- prev_cells_black.append(self.b[pos])
84
-
85
- # Seed: R0 = root
86
- for pos in get_all_pos(self.V, self.H):
87
- self.model.Add(self.reach_layers[0][pos] == self.root[pos])
88
-
89
- T = len(self.reach_layers)
90
- for t in range(1, T):
91
- Rt_prev = self.reach_layers[t - 1]
92
- Rt = self.reach_layers[t]
93
- for p in get_all_pos(self.V, self.H):
94
- # Rt[p] = Rt_prev[p] | (white[p] & Rt_prev[neighbour #1]) | (white[p] & Rt_prev[neighbour #2]) | ...
95
- # Create helper (white[p] & Rt_prev[neighbour #X]) for each neighbor q
96
- neigh_helpers: list[cp_model.IntVar] = []
97
- for q in get_neighbors4(p, self.V, self.H):
98
- a = self.model.NewBoolVar(f"A[{t}][{p}]<-({q})")
99
- and_constraint(self.model, target=a, cs=[self.w[p], Rt_prev[q]])
100
- neigh_helpers.append(a)
101
- or_constraint(self.model, target=Rt[p], cs=[Rt_prev[p]] + neigh_helpers)
102
-
103
- # All whites must be reached by the final layer
104
- RT = self.reach_layers[T - 1]
105
- for p in get_all_pos(self.V, self.H):
106
- self.model.Add(RT[p] == self.w[p])
57
+ force_connected_component(self.model, self.w)
107
58
 
108
59
  def range_clues(self):
109
60
  # For each numbered cell c with value k:
@@ -2,7 +2,7 @@ import numpy as np
2
2
  from ortools.sat.python import cp_model
3
3
 
4
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
5
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint, or_constraint
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
6
6
 
7
7
 
8
8
  class Board:
@@ -17,34 +17,26 @@ class Board:
17
17
 
18
18
  self.model = cp_model.CpModel()
19
19
  self.B = {} # black squares
20
+ self.W = {} # white squares
20
21
  self.Num = {} # value of squares (Num = N + idx if black, else board[pos])
21
- # Connectivity helpers
22
- self.root: dict[Pos, cp_model.IntVar] = {} # exactly one root; root <= w
23
- self.reach_layers: list[dict[Pos, cp_model.IntVar]] = [] # R_t[p] booleans, t = 0..T
24
22
 
25
23
  self.create_vars()
26
24
  self.add_all_constraints()
27
25
 
28
26
  def create_vars(self):
29
27
  for pos in get_all_pos(self.V, self.H):
30
- self.B[pos] = self.model.NewBoolVar(f'{pos}')
28
+ 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]])
31
32
  self.Num[pos] = self.model.NewIntVar(0, 2*self.N, f'{pos}')
32
33
  self.model.Add(self.Num[pos] == self.N + self.idx_of[pos]).OnlyEnforceIf(self.B[pos])
33
34
  self.model.Add(self.Num[pos] == int(get_char(self.board, pos))).OnlyEnforceIf(self.B[pos].Not())
34
- # Root
35
- for pos in get_all_pos(self.V, self.H):
36
- self.root[pos] = self.model.NewBoolVar(f"root[{pos}]")
37
- # Percolation layers R_t (monotone flood fill)
38
- for t in range(self.N + 1):
39
- Rt: dict[Pos, cp_model.IntVar] = {}
40
- for pos in get_all_pos(self.V, self.H):
41
- Rt[pos] = self.model.NewBoolVar(f"R[{t}][{pos}]")
42
- self.reach_layers.append(Rt)
43
35
 
44
36
  def add_all_constraints(self):
45
37
  self.no_adjacent_blacks()
46
38
  self.no_number_appears_twice()
47
- self.white_connectivity_percolation()
39
+ self.force_connected_component()
48
40
 
49
41
  def no_adjacent_blacks(self):
50
42
  # no two black squares are adjacent
@@ -61,42 +53,9 @@ class Board:
61
53
  var_list = [self.Num[pos] for pos in get_col_pos(col, self.V)]
62
54
  self.model.AddAllDifferent(var_list)
63
55
 
64
- def white_connectivity_percolation(self):
65
- """
66
- Layered percolation:
67
- - root is exactly the first white cell
68
- - R_t is monotone nondecreasing in t (R_t+1 >= R_t)
69
- - A cell can 'turn on' at layer t+1 iff it's white and has a neighbor on at layer t (or is root)
70
- - Final layer is equal to the white mask: R_T[p] == w[p] => all whites are connected to the unique root
71
- """
72
- # to find unique solutions easily, we make only 1 possible root allowed; root is exactly the first white cell
73
- prev_cells_black: list[cp_model.IntVar] = []
74
- for pos in get_all_pos(self.V, self.H):
75
- and_constraint(self.model, target=self.root[pos], cs=[self.B[pos].Not()] + prev_cells_black)
76
- prev_cells_black.append(self.B[pos])
77
-
78
- # Seed: R0 = root
79
- for pos in get_all_pos(self.V, self.H):
80
- self.model.Add(self.reach_layers[0][pos] == self.root[pos])
81
-
82
- T = len(self.reach_layers)
83
- for t in range(1, T):
84
- Rt_prev = self.reach_layers[t - 1]
85
- Rt = self.reach_layers[t]
86
- for p in get_all_pos(self.V, self.H):
87
- # Rt[p] = Rt_prev[p] | (white[p] & Rt_prev[neighbour #1]) | (white[p] & Rt_prev[neighbour #2]) | ...
88
- # Create helper (white[p] & Rt_prev[neighbour #X]) for each neighbor q
89
- neigh_helpers: list[cp_model.IntVar] = []
90
- for q in get_neighbors4(p, self.V, self.H):
91
- a = self.model.NewBoolVar(f"A[{t}][{p}]<-({q})")
92
- and_constraint(self.model, target=a, cs=[self.B[p].Not(), Rt_prev[q]])
93
- neigh_helpers.append(a)
94
- or_constraint(self.model, target=Rt[p], cs=[Rt_prev[p]] + neigh_helpers)
56
+ def force_connected_component(self):
57
+ force_connected_component(self.model, self.W)
95
58
 
96
- # All whites must be reached by the final layer
97
- RT = self.reach_layers[T - 1]
98
- for p in get_all_pos(self.V, self.H):
99
- self.model.Add(RT[p] == self.B[p].Not())
100
59
 
101
60
 
102
61
  def solve_and_print(self, verbose: bool = True):