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,151 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
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, 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
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Board:
|
|
12
|
+
def __init__(self, board: np.ndarray):
|
|
13
|
+
assert board.ndim == 2 and board.shape[0] > 0 and board.shape[1] > 0, f'board must be 2d, got {board.ndim}'
|
|
14
|
+
assert all(i.item() in [' ', 'B', 'W'] for i in np.nditer(board)), f'board must be space, B, or W, got {list(np.nditer(board))}'
|
|
15
|
+
self.V = board.shape[0]
|
|
16
|
+
self.H = board.shape[1]
|
|
17
|
+
self.board = board
|
|
18
|
+
self.model = cp_model.CpModel()
|
|
19
|
+
self.cell_active: dict[Pos, cp_model.IntVar] = {}
|
|
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
|
+
|
|
23
|
+
self.create_vars()
|
|
24
|
+
self.add_all_constraints()
|
|
25
|
+
|
|
26
|
+
def create_vars(self):
|
|
27
|
+
for pos in get_all_pos(self.V, self.H):
|
|
28
|
+
self.cell_active[pos] = self.model.NewBoolVar(f"a[{pos}]")
|
|
29
|
+
for direction in Direction:
|
|
30
|
+
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
|
+
|
|
39
|
+
def add_all_constraints(self):
|
|
40
|
+
self.force_direction_constraints()
|
|
41
|
+
self.force_wb_constraints()
|
|
42
|
+
self.connectivity_percolation()
|
|
43
|
+
|
|
44
|
+
def force_wb_constraints(self):
|
|
45
|
+
for pos in get_all_pos(self.V, self.H):
|
|
46
|
+
c = get_char(self.board, pos)
|
|
47
|
+
if c == 'B':
|
|
48
|
+
# must be active
|
|
49
|
+
self.model.Add(self.cell_active[pos] == 1)
|
|
50
|
+
# black circle must be a corner not connected directly to another corner
|
|
51
|
+
# must be a corner
|
|
52
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] != self.cell_direction[(pos, Direction.DOWN)])
|
|
53
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] != self.cell_direction[(pos, Direction.RIGHT)])
|
|
54
|
+
# must not be connected directly to another corner
|
|
55
|
+
for direction in Direction:
|
|
56
|
+
q = get_next_pos(pos, direction)
|
|
57
|
+
if not in_bounds(q, self.V, self.H):
|
|
58
|
+
continue
|
|
59
|
+
self.model.AddImplication(self.cell_direction[(pos, direction)], self.cell_direction[(q, direction)])
|
|
60
|
+
elif c == 'W':
|
|
61
|
+
# must be active
|
|
62
|
+
self.model.Add(self.cell_active[pos] == 1)
|
|
63
|
+
# white circle must be a straight which is connected to at least one corner
|
|
64
|
+
# must be straight
|
|
65
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] == self.cell_direction[(pos, Direction.DOWN)])
|
|
66
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == self.cell_direction[(pos, Direction.RIGHT)])
|
|
67
|
+
# must be connected to at least one corner (i.e. UP-RIGHT or UP-LEFT or DOWN-RIGHT or DOWN-LEFT or RIGHT-UP or RIGHT-DOWN or LEFT-UP or LEFT-DOWN)
|
|
68
|
+
aux_list: list[cp_model.IntVar] = []
|
|
69
|
+
for direction in Direction:
|
|
70
|
+
q = get_next_pos(pos, direction)
|
|
71
|
+
if not in_bounds(q, self.V, self.H):
|
|
72
|
+
continue
|
|
73
|
+
ortho_directions = {Direction.UP, Direction.DOWN, Direction.LEFT, Direction.RIGHT} - {direction, get_opposite_direction(direction)}
|
|
74
|
+
for ortho_direction in ortho_directions:
|
|
75
|
+
aux = self.model.NewBoolVar(f"A[{pos}]<-({q})")
|
|
76
|
+
and_constraint(self.model, target=aux, cs=[self.cell_direction[(q, ortho_direction)], self.cell_direction[(pos, direction)]])
|
|
77
|
+
aux_list.append(aux)
|
|
78
|
+
self.model.Add(lxp.Sum(aux_list) >= 1)
|
|
79
|
+
|
|
80
|
+
def force_direction_constraints(self):
|
|
81
|
+
for pos in get_all_pos(self.V, self.H):
|
|
82
|
+
# cell active means exactly 2 directions are active, cell not active means no directions are active
|
|
83
|
+
s = sum([self.cell_direction[(pos, direction)] for direction in Direction])
|
|
84
|
+
self.model.Add(s == 2).OnlyEnforceIf(self.cell_active[pos])
|
|
85
|
+
self.model.Add(s == 0).OnlyEnforceIf(self.cell_active[pos].Not())
|
|
86
|
+
# X having right means the cell to its right has left and so on for all directions
|
|
87
|
+
for direction in Direction:
|
|
88
|
+
q = get_next_pos(pos, direction)
|
|
89
|
+
if in_bounds(q, self.V, self.H):
|
|
90
|
+
self.model.Add(self.cell_direction[(pos, direction)] == self.cell_direction[(q, get_opposite_direction(direction))])
|
|
91
|
+
else:
|
|
92
|
+
self.model.Add(self.cell_direction[(pos, direction)] == 0)
|
|
93
|
+
|
|
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])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def solve_and_print(self):
|
|
131
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
132
|
+
assignment: dict[Pos, str] = defaultdict(str)
|
|
133
|
+
for (pos, direction), var in board.cell_direction.items():
|
|
134
|
+
assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
|
|
135
|
+
for pos in get_all_pos(self.V, self.H):
|
|
136
|
+
if len(assignment[pos]) == 0:
|
|
137
|
+
assignment[pos] = ' '
|
|
138
|
+
else:
|
|
139
|
+
assignment[pos] = ''.join(sorted(assignment[pos]))
|
|
140
|
+
return SingleSolution(assignment=assignment)
|
|
141
|
+
def callback(single_res: SingleSolution):
|
|
142
|
+
print("Solution found")
|
|
143
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
144
|
+
pretty_dict = {'DU': '┃ ', 'LR': '━━', 'DL': '━┒', 'DR': '┏━', 'RU': '┗━', 'LU': '━┛', ' ': ' '}
|
|
145
|
+
for pos in get_all_pos(self.V, self.H):
|
|
146
|
+
c = get_char(self.board, pos)
|
|
147
|
+
c = single_res.assignment[pos]
|
|
148
|
+
c = pretty_dict[c]
|
|
149
|
+
set_char(res, pos, c)
|
|
150
|
+
print(res)
|
|
151
|
+
return generic_solve_all(self, board_to_solution, callback=callback, max_solutions=20)
|
|
@@ -0,0 +1,154 @@
|
|
|
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, 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
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
9
|
+
out = []
|
|
10
|
+
while True:
|
|
11
|
+
pos = get_next_pos(pos, direction)
|
|
12
|
+
if not in_bounds(pos, V, H):
|
|
13
|
+
break
|
|
14
|
+
out.append(pos)
|
|
15
|
+
return out
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Board:
|
|
19
|
+
def __init__(self, clues: np.ndarray):
|
|
20
|
+
assert clues.ndim == 2 and clues.shape[0] > 0 and clues.shape[1] > 0, f'clues must be 2d, got {clues.ndim}'
|
|
21
|
+
assert all(isinstance(i.item(), int) and i.item() >= -1 for i in np.nditer(clues)), f'clues must be -1 or >= 0, got {list(np.nditer(clues))}'
|
|
22
|
+
self.V = clues.shape[0]
|
|
23
|
+
self.H = clues.shape[1]
|
|
24
|
+
self.clues = clues
|
|
25
|
+
self.model = cp_model.CpModel()
|
|
26
|
+
|
|
27
|
+
# Core vars
|
|
28
|
+
self.b: dict[Pos, cp_model.IntVar] = {} # 1=black, 0=white
|
|
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
|
+
|
|
34
|
+
self.create_vars()
|
|
35
|
+
self.add_all_constraints()
|
|
36
|
+
|
|
37
|
+
def create_vars(self):
|
|
38
|
+
# Cell color vars
|
|
39
|
+
for pos in get_all_pos(self.V, self.H):
|
|
40
|
+
self.b[pos] = self.model.NewBoolVar(f"b[{pos}]")
|
|
41
|
+
self.w[pos] = self.model.NewBoolVar(f"w[{pos}]")
|
|
42
|
+
self.model.AddExactlyOne([self.b[pos], self.w[pos]])
|
|
43
|
+
|
|
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
|
+
def add_all_constraints(self):
|
|
57
|
+
self.no_adjacent_blacks()
|
|
58
|
+
self.white_connectivity_percolation()
|
|
59
|
+
self.range_clues()
|
|
60
|
+
|
|
61
|
+
def no_adjacent_blacks(self):
|
|
62
|
+
cache = set()
|
|
63
|
+
for p in get_all_pos(self.V, self.H):
|
|
64
|
+
for q in get_neighbors4(p, self.V, self.H):
|
|
65
|
+
if (p, q) in cache:
|
|
66
|
+
continue
|
|
67
|
+
cache.add((p, q))
|
|
68
|
+
self.model.Add(self.b[p] + self.b[q] <= 1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
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])
|
|
107
|
+
|
|
108
|
+
def range_clues(self):
|
|
109
|
+
# For each numbered cell c with value k:
|
|
110
|
+
# - Force it white (cannot be black)
|
|
111
|
+
# - Build visibility chains in four directions (excluding the cell itself)
|
|
112
|
+
# - Sum of visible whites = 1 (itself) + sum(chains) == k
|
|
113
|
+
for pos in get_all_pos(self.V, self.H):
|
|
114
|
+
k = get_char(self.clues, pos)
|
|
115
|
+
if k == -1:
|
|
116
|
+
continue
|
|
117
|
+
# Numbered cell must be white
|
|
118
|
+
self.model.Add(self.b[pos] == 0)
|
|
119
|
+
|
|
120
|
+
# Build visibility chains per direction (exclude self)
|
|
121
|
+
vis_vars: list[cp_model.IntVar] = []
|
|
122
|
+
for direction in Direction:
|
|
123
|
+
ray = get_ray(pos, self.V, self.H, direction) # cells outward
|
|
124
|
+
if not ray:
|
|
125
|
+
continue
|
|
126
|
+
# Chain: v0 = w[ray[0]]; vt = w[ray[t]] & vt-1
|
|
127
|
+
prev = None
|
|
128
|
+
for idx, cell in enumerate(ray):
|
|
129
|
+
v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
|
|
130
|
+
vis_vars.append(v)
|
|
131
|
+
if idx == 0:
|
|
132
|
+
# v0 == w[cell]
|
|
133
|
+
self.model.Add(v == self.w[cell])
|
|
134
|
+
else:
|
|
135
|
+
and_constraint(self.model, target=v, cs=[self.w[cell], prev])
|
|
136
|
+
prev = v
|
|
137
|
+
|
|
138
|
+
# 1 (self) + sum(vis_vars) == k
|
|
139
|
+
self.model.Add(1 + sum(vis_vars) == k)
|
|
140
|
+
|
|
141
|
+
def solve_and_print(self):
|
|
142
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
143
|
+
assignment: dict[Pos, int] = {}
|
|
144
|
+
for pos, var in board.b.items():
|
|
145
|
+
assignment[pos] = solver.Value(var)
|
|
146
|
+
return SingleSolution(assignment=assignment)
|
|
147
|
+
def callback(single_res: SingleSolution):
|
|
148
|
+
print("Solution:")
|
|
149
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
150
|
+
for pos in get_all_pos(self.V, self.H):
|
|
151
|
+
c = 'B' if single_res.assignment[pos] == 1 else ' '
|
|
152
|
+
set_char(res, pos, c)
|
|
153
|
+
print(res)
|
|
154
|
+
return generic_solve_all(self, board_to_solution, callback=callback)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
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_char, set_char, in_bounds, get_next_pos, Direction, get_row_pos, get_col_pos, Direction8
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CHAR_TO_DIRECTION8 = {
|
|
12
|
+
'Q': Direction8.UP_LEFT,
|
|
13
|
+
'W': Direction8.UP,
|
|
14
|
+
'E': Direction8.UP_RIGHT,
|
|
15
|
+
'A': Direction8.LEFT,
|
|
16
|
+
'D': Direction8.RIGHT,
|
|
17
|
+
'Z': Direction8.DOWN_LEFT,
|
|
18
|
+
'X': Direction8.DOWN,
|
|
19
|
+
'C': Direction8.DOWN_RIGHT,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def beam(pos: Pos, V: int, H: int, direction: Direction8) -> list[Pos]:
|
|
24
|
+
out = []
|
|
25
|
+
while True:
|
|
26
|
+
pos = get_next_pos(pos, direction)
|
|
27
|
+
if not in_bounds(pos, V, H):
|
|
28
|
+
break
|
|
29
|
+
out.append(pos)
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
class Board:
|
|
33
|
+
def __init__(self, board: np.array, values: np.array):
|
|
34
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
35
|
+
assert values.ndim == 2, f'values must be 2d, got {values.ndim}'
|
|
36
|
+
assert board.shape == values.shape, f'board and values must have the same shape, got {board.shape} and {values.shape}'
|
|
37
|
+
self.board = board
|
|
38
|
+
self.values = values
|
|
39
|
+
self.V = board.shape[0]
|
|
40
|
+
self.H = board.shape[1]
|
|
41
|
+
self.N = self.V * self.H
|
|
42
|
+
assert all(int(c.item()) >= 0 and int(c.item()) <= self.N for c in np.nditer(values)), 'values must contain only integers between 0 and N'
|
|
43
|
+
|
|
44
|
+
self.model = cp_model.CpModel()
|
|
45
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
46
|
+
|
|
47
|
+
self.create_vars()
|
|
48
|
+
self.add_all_constraints()
|
|
49
|
+
|
|
50
|
+
def create_vars(self):
|
|
51
|
+
for pos in get_all_pos(V=self.V, H=self.H):
|
|
52
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
|
|
53
|
+
|
|
54
|
+
def add_all_constraints(self):
|
|
55
|
+
# constrain clues
|
|
56
|
+
for pos in get_all_pos(V=self.V, H=self.H):
|
|
57
|
+
c = int(get_char(self.values, pos))
|
|
58
|
+
if c == 0:
|
|
59
|
+
continue
|
|
60
|
+
self.model.Add(self.model_vars[pos] == c)
|
|
61
|
+
# all values are unique
|
|
62
|
+
self.model.AddAllDifferent(list(self.model_vars.values()))
|
|
63
|
+
# arrow for x points to x+1
|
|
64
|
+
for pos in get_all_pos(V=self.V, H=self.H):
|
|
65
|
+
c = get_char(self.board, pos)
|
|
66
|
+
if c == ' ':
|
|
67
|
+
continue
|
|
68
|
+
direction = CHAR_TO_DIRECTION8[c]
|
|
69
|
+
self.constrain_plus_one(pos, direction)
|
|
70
|
+
|
|
71
|
+
def constrain_plus_one(self, pos: Pos, direction: Direction8):
|
|
72
|
+
beam_res = beam(pos, self.V, self.H, direction)
|
|
73
|
+
is_eq_list = []
|
|
74
|
+
for p in beam_res:
|
|
75
|
+
aux = self.model.NewBoolVar(f'{pos}:{p}')
|
|
76
|
+
self.model.Add(self.model_vars[p] == self.model_vars[pos] + 1).OnlyEnforceIf(aux)
|
|
77
|
+
self.model.Add(self.model_vars[p] != self.model_vars[pos] + 1).OnlyEnforceIf(aux.Not())
|
|
78
|
+
is_eq_list.append(aux)
|
|
79
|
+
self.model.Add(lxp.Sum(is_eq_list) == 1)
|
|
80
|
+
|
|
81
|
+
def solve_and_print(self):
|
|
82
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
83
|
+
assignment: dict[Pos, str] = {}
|
|
84
|
+
for pos, var in board.model_vars.items():
|
|
85
|
+
assignment[pos] = solver.Value(var)
|
|
86
|
+
return SingleSolution(assignment=assignment)
|
|
87
|
+
def callback(single_res: SingleSolution):
|
|
88
|
+
print("Solution found")
|
|
89
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
90
|
+
for pos in get_all_pos(V=self.V, H=self.H):
|
|
91
|
+
c = get_char(self.board, pos)
|
|
92
|
+
c = single_res.assignment[pos]
|
|
93
|
+
set_char(res, pos, c)
|
|
94
|
+
print(res)
|
|
95
|
+
return generic_solve_all(self, board_to_solution, callback=callback, max_solutions=20)
|
|
@@ -0,0 +1,116 @@
|
|
|
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, get_row_pos, get_col_pos
|
|
5
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint, or_constraint
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Board:
|
|
9
|
+
def __init__(self, board: np.array):
|
|
10
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
11
|
+
assert board.shape[0] == board.shape[1], 'board must be square'
|
|
12
|
+
self.board = board
|
|
13
|
+
self.V = board.shape[0]
|
|
14
|
+
self.H = board.shape[1]
|
|
15
|
+
self.N = self.V * self.H
|
|
16
|
+
self.idx_of: dict[Pos, int] = get_all_pos_to_idx_dict(self.V, self.H)
|
|
17
|
+
|
|
18
|
+
self.model = cp_model.CpModel()
|
|
19
|
+
self.B = {} # black squares
|
|
20
|
+
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
|
+
|
|
25
|
+
self.create_vars()
|
|
26
|
+
self.add_all_constraints()
|
|
27
|
+
|
|
28
|
+
def create_vars(self):
|
|
29
|
+
for pos in get_all_pos(self.V, self.H):
|
|
30
|
+
self.B[pos] = self.model.NewBoolVar(f'{pos}')
|
|
31
|
+
self.Num[pos] = self.model.NewIntVar(0, 2*self.N, f'{pos}')
|
|
32
|
+
self.model.Add(self.Num[pos] == self.N + self.idx_of[pos]).OnlyEnforceIf(self.B[pos])
|
|
33
|
+
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
|
+
|
|
44
|
+
def add_all_constraints(self):
|
|
45
|
+
self.no_adjacent_blacks()
|
|
46
|
+
self.no_number_appears_twice()
|
|
47
|
+
self.white_connectivity_percolation()
|
|
48
|
+
|
|
49
|
+
def no_adjacent_blacks(self):
|
|
50
|
+
# no two black squares are adjacent
|
|
51
|
+
for pos in get_all_pos(self.V, self.H):
|
|
52
|
+
for neighbor in get_neighbors4(pos, self.V, self.H):
|
|
53
|
+
self.model.Add(self.B[pos] + self.B[neighbor] <= 1)
|
|
54
|
+
|
|
55
|
+
def no_number_appears_twice(self):
|
|
56
|
+
# no number appears twice in any row or column (numbers are ignored if black)
|
|
57
|
+
for row in range(self.V):
|
|
58
|
+
var_list = [self.Num[pos] for pos in get_row_pos(row, self.H)]
|
|
59
|
+
self.model.AddAllDifferent(var_list)
|
|
60
|
+
for col in range(self.H):
|
|
61
|
+
var_list = [self.Num[pos] for pos in get_col_pos(col, self.V)]
|
|
62
|
+
self.model.AddAllDifferent(var_list)
|
|
63
|
+
|
|
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)
|
|
95
|
+
|
|
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
|
+
|
|
101
|
+
|
|
102
|
+
def solve_and_print(self):
|
|
103
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
104
|
+
assignment: dict[Pos, int] = {}
|
|
105
|
+
for pos, var in board.B.items():
|
|
106
|
+
assignment[pos] = solver.value(var)
|
|
107
|
+
return SingleSolution(assignment=assignment)
|
|
108
|
+
def callback(single_res: SingleSolution):
|
|
109
|
+
print("Solution found")
|
|
110
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
111
|
+
for pos in get_all_pos(self.V, self.H):
|
|
112
|
+
c = get_char(self.board, pos)
|
|
113
|
+
c = 'B' if single_res.assignment[pos] == 1 else ' '
|
|
114
|
+
set_char(res, pos, c)
|
|
115
|
+
print(res)
|
|
116
|
+
return generic_solve_all(self, board_to_solution, callback=callback)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils import Pos, get_pos, get_all_pos, get_char, set_char, get_row_pos, get_col_pos
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_value(board: np.array, pos: Pos) -> Union[int, str]:
|
|
11
|
+
c = get_char(board, pos)
|
|
12
|
+
if c == '*':
|
|
13
|
+
return c
|
|
14
|
+
if str(c).isdecimal():
|
|
15
|
+
return int(c)
|
|
16
|
+
# a,b,... maps to 10,11,...
|
|
17
|
+
return ord(c) - ord('a') + 10
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_value(board: np.array, pos: Pos, value: Union[int, str]):
|
|
21
|
+
if value == '*':
|
|
22
|
+
value = '*'
|
|
23
|
+
elif value < 10:
|
|
24
|
+
value = str(value)
|
|
25
|
+
else:
|
|
26
|
+
value = chr(value - 10 + ord('a'))
|
|
27
|
+
set_char(board, pos, value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_block_pos(i: int, B: int) -> list[Pos]:
|
|
31
|
+
top_left_x = (i%B)*B
|
|
32
|
+
top_left_y = (i//B)*B
|
|
33
|
+
return [get_pos(x=top_left_x + x, y=top_left_y + y) for x in range(B) for y in range(B)]
|
|
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 board.shape[0] == board.shape[1], 'board must be square'
|
|
40
|
+
assert all(isinstance(i.item(), str) and len(i.item()) == 1 and (i.item().isalnum() or i.item() == '*') for i in np.nditer(board)), 'board must contain only alphanumeric characters or *'
|
|
41
|
+
self.board = board
|
|
42
|
+
self.N = board.shape[0]
|
|
43
|
+
self.B = np.sqrt(self.N) # block size
|
|
44
|
+
assert self.B.is_integer(), 'board size must be a perfect square'
|
|
45
|
+
self.B = int(self.B)
|
|
46
|
+
self.model = cp_model.CpModel()
|
|
47
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
48
|
+
|
|
49
|
+
self.create_vars()
|
|
50
|
+
self.add_all_constraints()
|
|
51
|
+
|
|
52
|
+
def create_vars(self):
|
|
53
|
+
for pos in get_all_pos(self.N):
|
|
54
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
|
|
55
|
+
|
|
56
|
+
def add_all_constraints(self):
|
|
57
|
+
# some squares are already filled
|
|
58
|
+
for pos in get_all_pos(self.N):
|
|
59
|
+
c = get_value(self.board, pos)
|
|
60
|
+
if c != '*':
|
|
61
|
+
self.model.Add(self.model_vars[pos] == c)
|
|
62
|
+
# every number appears exactly once in each row, each column and each block
|
|
63
|
+
# each row
|
|
64
|
+
for row in range(self.N):
|
|
65
|
+
row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.N)]
|
|
66
|
+
self.model.AddAllDifferent(row_vars)
|
|
67
|
+
# each column
|
|
68
|
+
for col in range(self.N):
|
|
69
|
+
col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.N)]
|
|
70
|
+
self.model.AddAllDifferent(col_vars)
|
|
71
|
+
# each block
|
|
72
|
+
for block_i in range(self.N):
|
|
73
|
+
block_vars = [self.model_vars[p] for p in get_block_pos(block_i, self.B)]
|
|
74
|
+
self.model.AddAllDifferent(block_vars)
|
|
75
|
+
|
|
76
|
+
def solve_and_print(self):
|
|
77
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
78
|
+
assignment: dict[Pos, int] = {}
|
|
79
|
+
for pos, var in board.model_vars.items():
|
|
80
|
+
assignment[pos] = solver.value(var)
|
|
81
|
+
return SingleSolution(assignment=assignment)
|
|
82
|
+
def callback(single_res: SingleSolution):
|
|
83
|
+
print("Solution found")
|
|
84
|
+
res = np.full((self.N, self.N), ' ', dtype=object)
|
|
85
|
+
for pos in get_all_pos(self.N):
|
|
86
|
+
c = get_value(self.board, pos)
|
|
87
|
+
c = single_res.assignment[pos]
|
|
88
|
+
set_value(res, pos, c)
|
|
89
|
+
print(res)
|
|
90
|
+
return generic_solve_all(self, board_to_solution, callback=callback)
|