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.

Files changed (31) hide show
  1. multi_puzzle_solver-0.1.0.dist-info/METADATA +1897 -0
  2. multi_puzzle_solver-0.1.0.dist-info/RECORD +31 -0
  3. multi_puzzle_solver-0.1.0.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-0.1.0.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +26 -0
  6. puzzle_solver/core/utils.py +127 -0
  7. puzzle_solver/core/utils_ortools.py +78 -0
  8. puzzle_solver/puzzles/bridges/bridges.py +106 -0
  9. puzzle_solver/puzzles/dominosa/dominosa.py +136 -0
  10. puzzle_solver/puzzles/filling/filling.py +192 -0
  11. puzzle_solver/puzzles/guess/guess.py +231 -0
  12. puzzle_solver/puzzles/inertia/inertia.py +122 -0
  13. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +204 -0
  14. puzzle_solver/puzzles/inertia/tsp.py +398 -0
  15. puzzle_solver/puzzles/keen/keen.py +99 -0
  16. puzzle_solver/puzzles/light_up/light_up.py +95 -0
  17. puzzle_solver/puzzles/magnets/magnets.py +117 -0
  18. puzzle_solver/puzzles/map/map.py +56 -0
  19. puzzle_solver/puzzles/minesweeper/minesweeper.py +110 -0
  20. puzzle_solver/puzzles/mosaic/mosaic.py +48 -0
  21. puzzle_solver/puzzles/nonograms/nonograms.py +126 -0
  22. puzzle_solver/puzzles/pearl/pearl.py +151 -0
  23. puzzle_solver/puzzles/range/range.py +154 -0
  24. puzzle_solver/puzzles/signpost/signpost.py +95 -0
  25. puzzle_solver/puzzles/singles/singles.py +116 -0
  26. puzzle_solver/puzzles/sudoku/sudoku.py +90 -0
  27. puzzle_solver/puzzles/tents/tents.py +110 -0
  28. puzzle_solver/puzzles/towers/towers.py +139 -0
  29. puzzle_solver/puzzles/tracks/tracks.py +170 -0
  30. puzzle_solver/puzzles/undead/undead.py +168 -0
  31. 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)