multi-puzzle-solver 1.0.4__py3-none-any.whl → 1.0.7__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 (42) hide show
  1. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/METADATA +1075 -556
  2. multi_puzzle_solver-1.0.7.dist-info/RECORD +74 -0
  3. puzzle_solver/__init__.py +5 -1
  4. puzzle_solver/core/utils.py +17 -1
  5. puzzle_solver/core/utils_visualizer.py +257 -201
  6. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  7. puzzle_solver/puzzles/aquarium/aquarium.py +8 -23
  8. puzzle_solver/puzzles/battleships/battleships.py +39 -53
  9. puzzle_solver/puzzles/binairo/binairo.py +2 -2
  10. puzzle_solver/puzzles/black_box/black_box.py +6 -70
  11. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +4 -2
  12. puzzle_solver/puzzles/filling/filling.py +11 -34
  13. puzzle_solver/puzzles/galaxies/galaxies.py +4 -2
  14. puzzle_solver/puzzles/heyawake/heyawake.py +72 -14
  15. puzzle_solver/puzzles/kakurasu/kakurasu.py +5 -13
  16. puzzle_solver/puzzles/kakuro/kakuro.py +6 -2
  17. puzzle_solver/puzzles/lits/lits.py +4 -2
  18. puzzle_solver/puzzles/mosaic/mosaic.py +8 -18
  19. puzzle_solver/puzzles/nonograms/nonograms.py +80 -85
  20. puzzle_solver/puzzles/nonograms/nonograms_colored.py +221 -0
  21. puzzle_solver/puzzles/norinori/norinori.py +5 -12
  22. puzzle_solver/puzzles/nurikabe/nurikabe.py +6 -2
  23. puzzle_solver/puzzles/palisade/palisade.py +8 -22
  24. puzzle_solver/puzzles/pearl/pearl.py +15 -27
  25. puzzle_solver/puzzles/pipes/pipes.py +2 -1
  26. puzzle_solver/puzzles/range/range.py +19 -55
  27. puzzle_solver/puzzles/rectangles/rectangles.py +4 -2
  28. puzzle_solver/puzzles/shingoki/shingoki.py +62 -105
  29. puzzle_solver/puzzles/singles/singles.py +6 -2
  30. puzzle_solver/puzzles/slant/slant.py +13 -19
  31. puzzle_solver/puzzles/slitherlink/slitherlink.py +2 -2
  32. puzzle_solver/puzzles/star_battle/star_battle.py +5 -2
  33. puzzle_solver/puzzles/stitches/stitches.py +8 -21
  34. puzzle_solver/puzzles/sudoku/sudoku.py +5 -11
  35. puzzle_solver/puzzles/tapa/tapa.py +6 -2
  36. puzzle_solver/puzzles/tents/tents.py +50 -80
  37. puzzle_solver/puzzles/tracks/tracks.py +19 -66
  38. puzzle_solver/puzzles/unruly/unruly.py +17 -49
  39. puzzle_solver/puzzles/yin_yang/yin_yang.py +3 -10
  40. multi_puzzle_solver-1.0.4.dist-info/RECORD +0 -72
  41. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/WHEEL +0 -0
  42. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/top_level.txt +0 -0
@@ -1,9 +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_neighbors4, get_pos, get_char
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_col_pos, get_neighbors4, get_pos, get_char, get_row_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
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
7
7
 
8
8
 
9
9
  def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
@@ -20,6 +20,17 @@ def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
20
20
  out.append((begin_idx, end_idx))
21
21
  return out
22
22
 
23
+
24
+ def get_diagonal(pos1: Pos, pos2: Pos) -> list[Pos]:
25
+ assert pos1 != pos2, 'positions must be different'
26
+ dx = pos1.x - pos2.x
27
+ dy = pos1.y - pos2.y
28
+ assert abs(dx) == abs(dy), 'positions must be on a diagonal'
29
+ sdx = 1 if dx > 0 else -1
30
+ sdy = 1 if dy > 0 else -1
31
+ return [get_pos(x=pos2.x + i*sdx, y=pos2.y + i*sdy) for i in range(abs(dx) + 1)]
32
+
33
+
23
34
  class Board:
24
35
  def __init__(self, board: np.array, region_to_clue: dict[str, int]):
25
36
  assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
@@ -59,6 +70,10 @@ class Board:
59
70
  force_connected_component(self.model, self.W)
60
71
  # A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
61
72
  self.disallow_white_lines_spanning_3_regions()
73
+ # straight diagonal black lines from side wall to horizontal wall are not allowed; because they would disconnect the white cells
74
+ self.disallow_full_black_diagonal()
75
+ # disallow a diagonal black line coming out of a wall of length N then coming back in on the same wall; because it would disconnect the white cells
76
+ self.disallow_zigzag_on_wall()
62
77
 
63
78
  def disallow_white_lines_spanning_3_regions(self):
64
79
  # A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
@@ -77,18 +92,61 @@ class Board:
77
92
  pos_list = [get_pos(x=col_num, y=y) for y in range(begin_idx, end_idx+1)]
78
93
  self.model.AddBoolOr([self.B[p] for p in pos_list])
79
94
 
80
- def solve_and_print(self, verbose: bool = True):
95
+ def disallow_full_black_diagonal(self):
96
+ corners_dx_dy = [
97
+ ((0, 0), 1, 1),
98
+ ((self.H-1, 0), -1, 1),
99
+ ((0, self.V-1), 1, -1),
100
+ ((self.H-1, self.V-1), -1, -1),
101
+ ]
102
+ for (corner_x, corner_y), dx, dy in corners_dx_dy:
103
+ for delta in range(1, min(self.V, self.H)):
104
+ pos1 = get_pos(x=corner_x, y=corner_y + delta*dy)
105
+ pos2 = get_pos(x=corner_x + delta*dx, y=corner_y)
106
+ diagonal_line = get_diagonal(pos1, pos2)
107
+ self.model.AddBoolOr([self.W[p] for p in diagonal_line])
108
+
109
+ def disallow_zigzag_on_wall(self):
110
+ for pos in get_row_pos(0, self.H): # top line
111
+ for end_x in range(pos.x + 2, self.H, 2): # end pos is even distance away from start pos
112
+ end_pos = get_pos(x=end_x, y=pos.y)
113
+ dx = end_x - pos.x
114
+ mid_pos = get_pos(x=pos.x + dx//2, y=pos.y + dx//2)
115
+ diag_1 = get_diagonal(pos, mid_pos) # from top wall to bottom triangle tip "\"
116
+ diag_2 = get_diagonal(end_pos, mid_pos) # from bottom triangle tip to top wall "/"
117
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
118
+ for pos in get_row_pos(self.V-1, self.H): # bottom line
119
+ for end_x in range(pos.x + 2, self.H, 2): # end pos is even distance away from start pos
120
+ end_pos = get_pos(x=end_x, y=pos.y)
121
+ dx = end_x - pos.x
122
+ mid_pos = get_pos(x=pos.x + dx//2, y=pos.y - dx//2)
123
+ diag_1 = get_diagonal(pos, mid_pos) # from bottom wall to top triangle tip "/"
124
+ diag_2 = get_diagonal(end_pos, mid_pos) # from top triangle tip to bottom wall "\"
125
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
126
+ for pos in get_col_pos(0, self.V): # left line
127
+ for end_y in range(pos.y + 2, self.V, 2): # end pos is even distance away from start pos
128
+ end_pos = get_pos(x=pos.x, y=end_y)
129
+ dy = end_y - pos.y
130
+ mid_pos = get_pos(x=pos.x + dy//2, y=pos.y + dy//2)
131
+ diag_1 = get_diagonal(pos, mid_pos) # from left wall to right triangle tip "\"
132
+ diag_2 = get_diagonal(end_pos, mid_pos) # from right triangle tip to left wall "/"
133
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
134
+ for pos in get_col_pos(self.H-1, self.V): # right line
135
+ for end_y in range(pos.y + 2, self.V, 2): # end pos is even distance away from start pos
136
+ end_pos = get_pos(x=pos.x, y=end_y)
137
+ dy = end_y - pos.y
138
+ mid_pos = get_pos(x=pos.x - dy//2, y=pos.y + dy//2)
139
+ diag_1 = get_diagonal(pos, mid_pos) # from right wall to left triangle tip "/"
140
+
141
+ def solve_and_print(self, verbose: bool = True, max_solutions: int = 20):
81
142
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
82
- assignment: dict[Pos, int] = {}
83
- for pos, var in board.B.items():
84
- assignment[pos] = 1 if solver.Value(var) == 1 else 0
85
- return SingleSolution(assignment=assignment)
143
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.B.items()})
86
144
  def callback(single_res: SingleSolution):
87
145
  print("Solution found")
88
- # res = np.full((self.V, self.H), ' ', dtype=object)
89
- # for pos in get_all_pos(self.V, self.H):
90
- # c = 'B' if single_res.assignment[pos] == 1 else ' '
91
- # set_char(res, pos, c)
92
- # print(res)
93
- 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.region_to_clue.get(int(self.board[r, c]), ' ')))
94
- return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=1)
146
+ print(combined_function(self.V, self.H,
147
+ cell_flags=id_board_to_wall_fn(self.board),
148
+ is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
149
+ center_char=lambda r, c: self.region_to_clue.get(int(self.board[r, c]), ''),
150
+ text_on_shaded_cells=False
151
+ ))
152
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=max_solutions)
@@ -1,22 +1,21 @@
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, set_char, get_pos
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_pos
5
5
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import combined_function
6
7
 
7
8
 
8
9
  class Board:
9
10
  def __init__(self, side: np.array, bottom: np.array):
10
11
  assert side.ndim == 1, f'side must be 1d, got {side.ndim}'
11
- self.V = side.shape[0]
12
12
  assert bottom.ndim == 1, f'bottom must be 1d, got {bottom.ndim}'
13
+ self.V = side.shape[0]
13
14
  self.H = bottom.shape[0]
14
15
  self.side = side
15
16
  self.bottom = bottom
16
-
17
17
  self.model = cp_model.CpModel()
18
18
  self.model_vars: dict[Pos, cp_model.IntVar] = {}
19
-
20
19
  self.create_vars()
21
20
  self.add_all_constraints()
22
21
 
@@ -32,15 +31,8 @@ class Board:
32
31
 
33
32
  def solve_and_print(self, verbose: bool = True):
34
33
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
35
- assignment: dict[Pos, int] = {}
36
- for pos, var in board.model_vars.items():
37
- assignment[pos] = solver.value(var)
38
- return SingleSolution(assignment=assignment)
34
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
39
35
  def callback(single_res: SingleSolution):
40
36
  print("Solution found")
41
- res = np.full((self.V, self.H), ' ', dtype=object)
42
- for pos in get_all_pos(self.V, self.H):
43
- c = 'X' if single_res.assignment[pos] else ' '
44
- set_char(res, pos, c)
45
- print(res)
37
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
46
38
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -6,7 +6,7 @@ from ortools.sat.python.cp_model import LinearExpr as lxp
6
6
 
7
7
  from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, get_char
8
8
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
- from puzzle_solver.core.utils_visualizer import render_shaded_grid
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
10
 
11
11
 
12
12
  class Board:
@@ -73,5 +73,9 @@ class Board:
73
73
  return SingleSolution(assignment=assignment)
74
74
  def callback(single_res: SingleSolution):
75
75
  print("Solution found")
76
- print(render_shaded_grid(self.V, self.H, is_shaded=lambda r, c: self.board[r, c] == '#', empty_text=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)])))
76
+ print(combined_function(self.V, self.H,
77
+ is_shaded=lambda r, c: self.board[r, c] == '#',
78
+ center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]),
79
+ text_on_shaded_cells=False
80
+ ))
77
81
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -6,7 +6,7 @@ import numpy as np
6
6
 
7
7
  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
8
8
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
9
- from puzzle_solver.core.utils_visualizer import id_board_to_wall_board, render_grid
9
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
10
10
 
11
11
 
12
12
  # a shape on the 2d board is just a set of positions
@@ -132,5 +132,7 @@ class Board:
132
132
  res = np.full((self.V, self.H), ' ', dtype=object)
133
133
  for pos, val in single_res.assignment.items():
134
134
  set_char(res, pos, '▒▒▒' if val == 1 else ' ')
135
- print(render_grid(id_board_to_wall_board(self.board), center_char=lambda r, c: res[r][c]))
135
+ print(combined_function(self.V, self.H,
136
+ cell_flags=id_board_to_wall_fn(self.board),
137
+ center_char=lambda r, c: res[r][c]))
136
138
  return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
@@ -2,47 +2,37 @@ import numpy as np
2
2
  from ortools.sat.python import cp_model
3
3
  from ortools.sat.python.cp_model import LinearExpr as lxp
4
4
 
5
- from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_neighbors8
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors8, get_pos
6
6
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+ from puzzle_solver.core.utils_visualizer import combined_function
7
8
 
8
9
 
9
10
  class Board:
10
11
  def __init__(self, board: np.array):
11
12
  assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
- assert board.shape[0] == board.shape[1], 'board must be square'
13
13
  assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
14
14
  self.board = board
15
- self.N = board.shape[0]
15
+ self.V, self.H = board.shape
16
16
  self.model = cp_model.CpModel()
17
17
  self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
-
19
18
  self.create_vars()
20
19
  self.add_all_constraints()
21
20
 
22
21
  def create_vars(self):
23
- for pos in get_all_pos(self.N):
22
+ for pos in get_all_pos(self.V, self.H):
24
23
  self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
25
24
 
26
25
  def add_all_constraints(self):
27
- for pos in get_all_pos(self.N):
26
+ for pos in get_all_pos(self.V, self.H):
28
27
  c = get_char(self.board, pos)
29
28
  if not str(c).isdecimal():
30
29
  continue
31
- neighbour_vars = [self.model_vars[p] for p in get_neighbors8(pos, self.N, include_self=True)]
32
- self.model.Add(lxp.sum(neighbour_vars) == int(c))
30
+ self.model.Add(lxp.Sum([self.model_vars[n] for n in get_neighbors8(pos, self.V, self.H, include_self=True)]) == int(c))
33
31
 
34
32
  def solve_and_print(self, verbose: bool = True):
35
33
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
36
- assignment: dict[Pos, int] = {}
37
- for pos, var in board.model_vars.items():
38
- assignment[pos] = solver.Value(var)
39
- return SingleSolution(assignment=assignment)
34
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
40
35
  def callback(single_res: SingleSolution):
41
36
  print("Solution found")
42
- res = np.full((self.N, self.N), ' ', dtype=object)
43
- for pos in get_all_pos(self.N):
44
- c = get_char(self.board, pos)
45
- c = 'B' if single_res.assignment[pos] == 1 else ' '
46
- set_char(res, pos, c)
47
- print(res)
37
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, center_char=lambda r, c: str(self.board[r, c]), text_on_shaded_cells=False))
48
38
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -1,8 +1,82 @@
1
- import numpy as np
2
1
  from ortools.sat.python import cp_model
3
2
 
4
- from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_row_pos, get_col_pos
3
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_row_pos, get_col_pos
5
4
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
5
+ from puzzle_solver.core.utils_visualizer import combined_function
6
+
7
+
8
+ def constrain_nonogram_sequence(model: cp_model.CpModel, clues: list[int], current_sequence: list[cp_model.IntVar], ns: str):
9
+ """
10
+ Constrain a binary sequence (current_sequence) to match the nonogram clues in clues.
11
+
12
+ clues: e.g., [3,1] means: a run of 3 ones, >=1 zero, then a run of 1 one.
13
+ current_sequence: list of IntVar in {0,1}.
14
+ extra_vars: dict for storing helper vars safely across multiple calls.
15
+
16
+ steps:
17
+ - Create start position s_i for each run i.
18
+ - Enforce order and >=1 separation between runs.
19
+ - Link each cell j to exactly one run interval (or none) via coverage booleans.
20
+ - Force sum of ones to equal sum(clues).
21
+ """
22
+ L = len(current_sequence)
23
+
24
+ # not needed but useful for debugging: any clue longer than the line ⇒ unsat.
25
+ if sum(clues) + len(clues) - 1 > L:
26
+ print(f"Infeasible: clue {clues} longer than line length {L} for {ns}")
27
+ model.Add(0 == 1)
28
+ return
29
+
30
+ result = {}
31
+ # Start variables for each run. This is the most critical variable for the problem.
32
+ starts = []
33
+ result[f"{ns}_starts"] = starts
34
+ for i in range(len(clues)):
35
+ s = model.NewIntVar(0, L, f"{ns}_s[{i}]")
36
+ starts.append(s)
37
+ # Enforce order and >=1 blank between consecutive runs.
38
+ for i in range(len(clues) - 1):
39
+ model.Add(starts[i + 1] >= starts[i] + clues[i] + 1)
40
+ # enforce that every run is fully contained in the board
41
+ for i in range(len(clues)):
42
+ model.Add(starts[i] + clues[i] <= L)
43
+
44
+ # For each cell j, create booleans cover[i][j] that indicate
45
+ # whether run i covers cell j: (starts[i] <= j) AND (j < starts[i] + clues[i])
46
+ cover = [[None] * L for _ in range(len(clues))]
47
+ list_b_le = [[None] * L for _ in range(len(clues))]
48
+ list_b_lt_end = [[None] * L for _ in range(len(clues))]
49
+ result[f"{ns}_cover"] = cover
50
+ result[f"{ns}_list_b_le"] = list_b_le
51
+ result[f"{ns}_list_b_lt_end"] = list_b_lt_end
52
+
53
+ for i, c in enumerate(clues):
54
+ s_i = starts[i]
55
+ for j in range(L):
56
+ # b_le: s_i <= j [is start[i] <= j]
57
+ b_le = model.NewBoolVar(f"{ns}_le[{i},{j}]")
58
+ model.Add(s_i <= j).OnlyEnforceIf(b_le)
59
+ model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
60
+
61
+ # b_lt_end: j < s_i + c ⇔ s_i + c - 1 >= j [is start[i] + clues[i] - 1 (aka end[i]) >= j]
62
+ b_lt_end = model.NewBoolVar(f"{ns}_lt_end[{i},{j}]")
63
+ end_expr = s_i + c - 1
64
+ model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
65
+ model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not()) # (s_i + c - 1) < j
66
+
67
+ b_cov = model.NewBoolVar(f"{ns}_cov[{i},{j}]")
68
+ # If covered ⇒ both comparisons true
69
+ model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
70
+ # If both comparisons true ⇒ covered
71
+ model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
72
+ cover[i][j] = b_cov
73
+ list_b_le[i][j] = b_le
74
+ list_b_lt_end[i][j] = b_lt_end
75
+
76
+ # Each cell j is 1 iff it is covered by exactly one run.
77
+ # (Because runs are separated by >=1 zero, these coverage intervals cannot overlap,
78
+ for j in range(L):
79
+ model.Add(sum(cover[i][j] for i in range(len(clues))) == current_sequence[j])
6
80
 
7
81
 
8
82
  class Board:
@@ -30,97 +104,18 @@ class Board:
30
104
  if ground_sequence == -1:
31
105
  continue
32
106
  current_sequence = [self.model_vars[pos] for pos in get_row_pos(i, self.H)]
33
- self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_side_{i}')
107
+ constrain_nonogram_sequence(self.model, ground_sequence, current_sequence, f'ngm_side_{i}')
34
108
  for i in range(self.H):
35
109
  ground_sequence = self.top[i]
36
110
  if ground_sequence == -1:
37
111
  continue
38
112
  current_sequence = [self.model_vars[pos] for pos in get_col_pos(i, self.V)]
39
- self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_top_{i}')
40
-
41
- def constrain_nonogram_sequence(self, clues: list[int], current_sequence: list[cp_model.IntVar], ns: str):
42
- """
43
- Constrain a binary sequence (current_sequence) to match the nonogram clues in clues.
44
-
45
- clues: e.g., [3,1] means: a run of 3 ones, >=1 zero, then a run of 1 one.
46
- current_sequence: list of IntVar in {0,1}.
47
- extra_vars: dict for storing helper vars safely across multiple calls.
48
-
49
- steps:
50
- - Create start position s_i for each run i.
51
- - Enforce order and >=1 separation between runs.
52
- - Link each cell j to exactly one run interval (or none) via coverage booleans.
53
- - Force sum of ones to equal sum(clues).
54
- """
55
- L = len(current_sequence)
56
-
57
- # not needed but useful for debugging: any clue longer than the line ⇒ unsat.
58
- if sum(clues) + len(clues) - 1 > L:
59
- print(f"Infeasible: clue {clues} longer than line length {L} for {ns}")
60
- self.model.Add(0 == 1)
61
- return
62
-
63
- # Start variables for each run. This is the most critical variable for the problem.
64
- starts = []
65
- self.extra_vars[f"{ns}_starts"] = starts
66
- for i in range(len(clues)):
67
- s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
68
- starts.append(s)
69
- # Enforce order and >=1 blank between consecutive runs.
70
- for i in range(len(clues) - 1):
71
- self.model.Add(starts[i + 1] >= starts[i] + clues[i] + 1)
72
- # enforce that every run is fully contained in the board
73
- for i in range(len(clues)):
74
- self.model.Add(starts[i] + clues[i] <= L)
75
-
76
- # For each cell j, create booleans cover[i][j] that indicate
77
- # whether run i covers cell j: (starts[i] <= j) AND (j < starts[i] + clues[i])
78
- cover = [[None] * L for _ in range(len(clues))]
79
- list_b_le = [[None] * L for _ in range(len(clues))]
80
- list_b_lt_end = [[None] * L for _ in range(len(clues))]
81
- self.extra_vars[f"{ns}_cover"] = cover
82
- self.extra_vars[f"{ns}_list_b_le"] = list_b_le
83
- self.extra_vars[f"{ns}_list_b_lt_end"] = list_b_lt_end
84
-
85
- for i, c in enumerate(clues):
86
- s_i = starts[i]
87
- for j in range(L):
88
- # b_le: s_i <= j [is start[i] <= j]
89
- b_le = self.model.NewBoolVar(f"{ns}_le[{i},{j}]")
90
- self.model.Add(s_i <= j).OnlyEnforceIf(b_le)
91
- self.model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
92
-
93
- # b_lt_end: j < s_i + c ⇔ s_i + c - 1 >= j [is start[i] + clues[i] - 1 (aka end[i]) >= j]
94
- b_lt_end = self.model.NewBoolVar(f"{ns}_lt_end[{i},{j}]")
95
- end_expr = s_i + c - 1
96
- self.model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
97
- self.model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not()) # (s_i + c - 1) < j
98
-
99
- b_cov = self.model.NewBoolVar(f"{ns}_cov[{i},{j}]")
100
- # If covered ⇒ both comparisons true
101
- self.model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
102
- # If both comparisons true ⇒ covered
103
- self.model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
104
- cover[i][j] = b_cov
105
- list_b_le[i][j] = b_le
106
- list_b_lt_end[i][j] = b_lt_end
107
-
108
- # Each cell j is 1 iff it is covered by exactly one run.
109
- # (Because runs are separated by >=1 zero, these coverage intervals cannot overlap,
110
- for j in range(L):
111
- self.model.Add(sum(cover[i][j] for i in range(len(clues))) == current_sequence[j])
113
+ constrain_nonogram_sequence(self.model, ground_sequence, current_sequence, f'ngm_top_{i}')
112
114
 
113
115
  def solve_and_print(self, verbose: bool = True):
114
116
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
115
- assignment: dict[Pos, int] = {}
116
- for pos, var in board.model_vars.items():
117
- assignment[pos] = solver.value(var)
118
- return SingleSolution(assignment=assignment)
117
+ return SingleSolution(assignment={pos: solver.value(var) for pos, var in board.model_vars.items()})
119
118
  def callback(single_res: SingleSolution):
120
119
  print("Solution found")
121
- res = np.full((self.V, self.H), ' ', dtype=object)
122
- for pos in get_all_pos(self.V, self.H):
123
- c = 'B' if single_res.assignment[pos] == 1 else ' '
124
- set_char(res, pos, c)
125
- print(res)
120
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
126
121
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,221 @@
1
+ from collections import defaultdict
2
+ from typing import Optional
3
+
4
+ import numpy as np
5
+ from ortools.sat.python import cp_model
6
+ from ortools.sat.python.cp_model import LinearExpr as lxp
7
+
8
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, set_char, get_row_pos, get_col_pos
9
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
10
+ from puzzle_solver.core.utils_visualizer import combined_function
11
+
12
+
13
+ def assert_input(lines: list[list[tuple[int, str]]]):
14
+ for line in lines:
15
+ for i,c in enumerate(line):
16
+ if c == -1:
17
+ continue
18
+ elif isinstance(c, str):
19
+ assert c[:-1].isdigit(), f'strings must begin with a digit, got {c}'
20
+ line[i] = (int(c[:-1]), c[-1])
21
+ elif isinstance(c, tuple):
22
+ assert len(c) == 2 and isinstance(c[0], int) and isinstance(c[1], str), f'tuples must be (int, str), got {c}'
23
+ else:
24
+ raise ValueError(f'invalid cell value: {c}')
25
+
26
+
27
+ class Board:
28
+ def __init__(self, top: list[list[tuple[int, str]]], side: list[list[tuple[int, str]]]):
29
+ assert_input(top)
30
+ assert_input(side)
31
+ self.top = top
32
+ self.side = side
33
+ self.V = len(side)
34
+ self.H = len(top)
35
+ self.unique_colors = list(set([i[1] for line in top for i in line if i != -1] + [i[1] for line in side for i in line if i != -1]))
36
+ self.model = cp_model.CpModel()
37
+ self.model_vars: dict[Pos, dict[str, cp_model.IntVar]] = defaultdict(dict)
38
+ self.extra_vars = {}
39
+
40
+ self.create_vars()
41
+ self.add_all_constraints()
42
+
43
+ def create_vars(self):
44
+ for pos in get_all_pos(self.V, self.H):
45
+ for color in self.unique_colors:
46
+ self.model_vars[pos][color] = self.model.NewBoolVar(f'{pos}:{color}')
47
+
48
+ def add_all_constraints(self):
49
+ for pos in get_all_pos(self.V, self.H):
50
+ self.model.Add(lxp.sum(list(self.model_vars[pos].values())) <= 1)
51
+ for i in range(self.V):
52
+ ground_sequence = self.side[i]
53
+ if tuple(ground_sequence) == (-1,):
54
+ continue
55
+ current_sequence = [self.model_vars[pos] for pos in get_row_pos(i, self.H)]
56
+ self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_side_{i}')
57
+ for i in range(self.H):
58
+ ground_sequence = self.top[i]
59
+ if tuple(ground_sequence) == (-1,):
60
+ continue
61
+ current_sequence = [self.model_vars[pos] for pos in get_col_pos(i, self.V)]
62
+ self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_top_{i}')
63
+
64
+ def constrain_nonogram_sequence(self, clues: list[tuple[int, str]], current_sequence: list[dict[str, cp_model.IntVar]], ns: str):
65
+ """
66
+ Constrain a colored sequence (current_sequence) to match the nonogram clues in clues.
67
+
68
+ clues: e.g., [(3, 'R'), (1, 'G')] means: a run of 3 red ones, then a run of 1 green one. If two clues are next to each other and have the same color, they must be separated by at least one blank.
69
+ current_sequence: list of dicts of IntVar in {0,1} for each color.
70
+
71
+ steps:
72
+ - Create start position s_i for each run i.
73
+ - Enforce order and >=1 separation between runs.
74
+ - Link each cell j to exactly one run interval (or none) via coverage booleans.
75
+ - Force sum of ones to equal sum(clues).
76
+ """
77
+ L = len(current_sequence)
78
+ R = len(clues)
79
+
80
+ # Early infeasibility check:
81
+ # Minimum required blanks equals number of adjacent pairs with same color.
82
+ same_color_separators = sum(1 for (len_i, col_i), (len_j, col_j) in zip(clues, clues[1:]) if col_i == col_j)
83
+ min_needed = sum(len_i for len_i, _ in clues) + same_color_separators
84
+ if min_needed > L:
85
+ print(f"Infeasible: clues {clues} need {min_needed} cells but line length is {L} for {ns}")
86
+ self.model.Add(0 == 1)
87
+ return
88
+
89
+ # Collect the color set present in clues and in the line vars
90
+ clue_colors = {c for _, c in clues}
91
+ seq_colors = set()
92
+ for j in range(L):
93
+ seq_colors.update(current_sequence[j].keys())
94
+ colors = sorted(clue_colors | seq_colors)
95
+
96
+ # Start vars per run
97
+ starts: list[cp_model.IntVar] = []
98
+ self.extra_vars[f"{ns}_starts"] = starts
99
+ for i in range(len(clues)):
100
+ # s_i in [0, L] but we will bound by containment constraint below
101
+ s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
102
+ starts.append(s)
103
+
104
+ # Ordering + separation:
105
+ # If same color: s[i+1] >= s[i] + len[i] + 1
106
+ # If different color: s[i+1] >= s[i] + len[i]
107
+ for i in range(R - 1):
108
+ len_i, col_i = clues[i]
109
+ _, col_next = clues[i + 1]
110
+ gap = 1 if col_i == col_next else 0
111
+ self.model.Add(starts[i + 1] >= starts[i] + len_i + gap)
112
+
113
+ # Containment: s[i] + len[i] <= L
114
+ for i, (run_len, _) in enumerate(clues):
115
+ self.model.Add(starts[i] + run_len <= L)
116
+
117
+ # Coverage booleans: cover[i][j] <=> (starts[i] <= j) AND (j < starts[i] + run_len)
118
+ cover = [[None] * L for _ in range(R)]
119
+ list_b_le = [[None] * L for _ in range(R)]
120
+ list_b_lt_end = [[None] * L for _ in range(R)]
121
+ self.extra_vars[f"{ns}_cover"] = cover
122
+ self.extra_vars[f"{ns}_list_b_le"] = list_b_le
123
+ self.extra_vars[f"{ns}_list_b_lt_end"] = list_b_lt_end
124
+
125
+ for i, (run_len, _) in enumerate(clues):
126
+ s_i = starts[i]
127
+ for j in range(L):
128
+ b_le = self.model.NewBoolVar(f"{ns}_le[{i},{j}]") # s_i <= j
129
+ self.model.Add(s_i <= j).OnlyEnforceIf(b_le)
130
+ self.model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
131
+
132
+ b_lt_end = self.model.NewBoolVar(f"{ns}_lt_end[{i},{j}]") # j < s_i + run_len <=> s_i + run_len - 1 >= j
133
+ end_expr = s_i + run_len - 1
134
+ self.model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
135
+ self.model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not())
136
+
137
+ b_cov = self.model.NewBoolVar(f"{ns}_cov[{i},{j}]")
138
+ self.model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
139
+ self.model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
140
+
141
+ cover[i][j] = b_cov
142
+ list_b_le[i][j] = b_le
143
+ list_b_lt_end[i][j] = b_lt_end
144
+
145
+ # Link coverage to per-cell, per-color variables.
146
+ # For each color k and cell j:
147
+ # sum_{i: color_i == k} cover[i][j] == current_sequence[j][k]
148
+ # Also tie the total cover at j to the sum over all colors at j:
149
+ # sum_i cover[i][j] == sum_k current_sequence[j][k]
150
+ # This enforces that at most one color is active per cell (since the LHS is in {0,1} due to non-overlap).
151
+ # If a color var is missing in current_sequence[j], assume it’s an implicit 0 by creating a fixed zero var.
152
+ # (Alternatively, require the caller to provide all colors per cell.)
153
+ zero_cache = {}
154
+ def get_zero(name: str):
155
+ if name not in zero_cache:
156
+ z = self.model.NewConstant(0)
157
+ zero_cache[name] = z
158
+ return zero_cache[name]
159
+
160
+ # Pre-index runs by color for efficiency
161
+ runs_by_color = {k: [] for k in colors}
162
+ for i, (_, k) in enumerate(clues):
163
+ runs_by_color[k].append(i)
164
+
165
+ for j in range(L):
166
+ # Total coverage at cell j
167
+ total_cov_j = sum(cover[i][j] for i in range(R)) if R > 0 else 0
168
+
169
+ # Sum of color vars at cell j
170
+ color_vars_j = []
171
+ for k in colors:
172
+ v = current_sequence[j].get(k, None)
173
+ if v is None:
174
+ v = get_zero(f"{ns}_zero_{k}")
175
+ color_vars_j.append(v)
176
+
177
+ # Per-color coverage equality
178
+ if runs_by_color[k]:
179
+ self.model.Add(sum(cover[i][j] for i in runs_by_color[k]) == v)
180
+ else:
181
+ # No runs of this color -> force cell color var to 0
182
+ self.model.Add(v == 0)
183
+
184
+ # Tie total coverage to sum of color vars (blank vs exactly-one color)
185
+ if R > 0:
186
+ self.model.Add(total_cov_j == sum(color_vars_j))
187
+ else:
188
+ # No runs at all: all cells must be blank across all colors
189
+ for v in color_vars_j:
190
+ self.model.Add(v == 0)
191
+
192
+ # Optional but strong propagation: per-color totals must match total clue lengths of that color
193
+ total_len_by_color = {k: 0 for k in colors}
194
+ for length, k in clues:
195
+ total_len_by_color[k] += length
196
+
197
+ for k in colors:
198
+ total_cells_k = sum(current_sequence[j].get(k, get_zero(f"{ns}_zero_{k}")) for j in range(L))
199
+ self.model.Add(total_cells_k == total_len_by_color[k])
200
+
201
+ def solve_and_print(self, verbose: bool = True, visualize_colors: Optional[dict[str, str]] = None):
202
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
203
+ return SingleSolution(assignment={pos: color for pos, d in board.model_vars.items() for color, var in d.items() if solver.value(var) == 1})
204
+ def callback(single_res: SingleSolution):
205
+ print("Solution found")
206
+ print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ' ')))
207
+ if visualize_colors is not None:
208
+ from matplotlib import pyplot as plt
209
+ from matplotlib.colors import ListedColormap
210
+ visualize_colors[' '] = 'black'
211
+ visualize_colors_keys = list(visualize_colors.keys())
212
+ char_to_int = {c: i for i, c in enumerate(visualize_colors_keys)}
213
+ nums = [[char_to_int[single_res.assignment.get(get_pos(x=c, y=r), ' ')] for c in range(self.H)] for r in range(self.V)]
214
+ plt.imshow(nums,
215
+ aspect='equal',
216
+ cmap=ListedColormap([visualize_colors[c] for c in visualize_colors_keys]),
217
+ extent=[0, self.H, self.V, 0])
218
+ plt.colorbar()
219
+ # plt.grid(True)
220
+ plt.show()
221
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)