multi-puzzle-solver 1.1.8__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.
Files changed (106) hide show
  1. multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
  2. multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
  3. multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +184 -0
  6. puzzle_solver/core/utils.py +298 -0
  7. puzzle_solver/core/utils_ortools.py +333 -0
  8. puzzle_solver/core/utils_visualizer.py +575 -0
  9. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  10. puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
  11. puzzle_solver/puzzles/area_51/area_51.py +159 -0
  12. puzzle_solver/puzzles/battleships/battleships.py +139 -0
  13. puzzle_solver/puzzles/binairo/binairo.py +98 -0
  14. puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
  15. puzzle_solver/puzzles/black_box/black_box.py +243 -0
  16. puzzle_solver/puzzles/branches/branches.py +64 -0
  17. puzzle_solver/puzzles/bridges/bridges.py +104 -0
  18. puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
  19. puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
  20. puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
  21. puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
  22. puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
  23. puzzle_solver/puzzles/clouds/clouds.py +81 -0
  24. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  25. puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
  26. puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
  27. puzzle_solver/puzzles/filling/filling.py +94 -0
  28. puzzle_solver/puzzles/flip/flip.py +64 -0
  29. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  30. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
  31. puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
  32. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
  33. puzzle_solver/puzzles/guess/guess.py +232 -0
  34. puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
  35. puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
  36. puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
  37. puzzle_solver/puzzles/inertia/inertia.py +121 -0
  38. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
  39. puzzle_solver/puzzles/inertia/tsp.py +400 -0
  40. puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
  41. puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
  42. puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
  43. puzzle_solver/puzzles/keen/keen.py +76 -0
  44. puzzle_solver/puzzles/kropki/kropki.py +94 -0
  45. puzzle_solver/puzzles/light_up/light_up.py +58 -0
  46. puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
  47. puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
  48. puzzle_solver/puzzles/lits/lits.py +138 -0
  49. puzzle_solver/puzzles/magnets/magnets.py +96 -0
  50. puzzle_solver/puzzles/map/map.py +56 -0
  51. puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
  52. puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
  53. puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
  54. puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
  55. puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
  56. puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
  57. puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
  58. puzzle_solver/puzzles/norinori/norinori.py +96 -0
  59. puzzle_solver/puzzles/number_path/number_path.py +76 -0
  60. puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
  61. puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
  62. puzzle_solver/puzzles/palisade/palisade.py +91 -0
  63. puzzle_solver/puzzles/pearl/pearl.py +107 -0
  64. puzzle_solver/puzzles/pipes/pipes.py +82 -0
  65. puzzle_solver/puzzles/range/range.py +59 -0
  66. puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
  67. puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
  68. puzzle_solver/puzzles/rooms/rooms.py +75 -0
  69. puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
  70. puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
  71. puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
  72. puzzle_solver/puzzles/signpost/signpost.py +93 -0
  73. puzzle_solver/puzzles/singles/singles.py +53 -0
  74. puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
  75. puzzle_solver/puzzles/slant/slant.py +111 -0
  76. puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
  77. puzzle_solver/puzzles/snail/snail.py +97 -0
  78. puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
  79. puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
  80. puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
  81. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
  82. puzzle_solver/puzzles/stitches/stitches.py +96 -0
  83. puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
  84. puzzle_solver/puzzles/suguru/suguru.py +55 -0
  85. puzzle_solver/puzzles/suko/suko.py +54 -0
  86. puzzle_solver/puzzles/tapa/tapa.py +97 -0
  87. puzzle_solver/puzzles/tatami/tatami.py +64 -0
  88. puzzle_solver/puzzles/tents/tents.py +80 -0
  89. puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
  90. puzzle_solver/puzzles/towers/towers.py +89 -0
  91. puzzle_solver/puzzles/tracks/tracks.py +88 -0
  92. puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
  93. puzzle_solver/puzzles/troix/dumplings.py +7 -0
  94. puzzle_solver/puzzles/troix/troix.py +75 -0
  95. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  96. puzzle_solver/puzzles/undead/undead.py +130 -0
  97. puzzle_solver/puzzles/unequal/unequal.py +128 -0
  98. puzzle_solver/puzzles/unruly/unruly.py +54 -0
  99. puzzle_solver/puzzles/vectors/vectors.py +94 -0
  100. puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
  101. puzzle_solver/puzzles/walls/walls.py +52 -0
  102. puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
  103. puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
  104. puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
  105. puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
  106. puzzle_solver/utils/visualizer.py +155 -0
@@ -0,0 +1,71 @@
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_char, get_pos, get_row_pos, get_col_pos, Direction8, get_ray
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
+ class Board:
14
+ def __init__(self, board: np.array, id_to_count: Optional[dict[int, int]] = None):
15
+ """
16
+ board is a 2d array of location ids
17
+ id_to_count is a dict of int to int, where the key is the id of the location and the value is the count of the queens on that location
18
+ """
19
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
20
+ assert all(str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only digits'
21
+ assert id_to_count is None or (isinstance(id_to_count, dict) and all(isinstance(k, int) and isinstance(v, int) for k, v in id_to_count.items())), 'id_to_count must be a dict of int to int'
22
+ self.board = board
23
+ self.V, self.H = board.shape
24
+ self.location_ids = set([int(c.item()) for c in np.nditer(board)])
25
+ self.location_ids_to_pos: dict[int, set[Pos]] = defaultdict(set)
26
+ for pos in get_all_pos(self.V, self.H):
27
+ self.location_ids_to_pos[int(get_char(self.board, pos))].add(pos)
28
+ self.id_to_count = id_to_count
29
+ if self.id_to_count is None:
30
+ self.id_to_count = {id_: 1 for id_ in self.location_ids}
31
+
32
+ self.model = cp_model.CpModel()
33
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
34
+ self.create_vars()
35
+ self.add_all_constraints()
36
+
37
+ def create_vars(self):
38
+ for pos in get_all_pos(self.V, self.H):
39
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
40
+
41
+ def add_all_constraints(self):
42
+ # every row has at most one queen
43
+ for row in range(self.V):
44
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in get_row_pos(row, self.H)]) <= 1)
45
+ # every column has at most one queen
46
+ for col in range(self.H):
47
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in get_col_pos(col, self.V)]) <= 1)
48
+ # every diagonal has at most one queen
49
+ for pos in get_col_pos(0, self.V): # down-right diagonal on left border
50
+ ray = get_ray(pos, Direction8.DOWN_RIGHT, self.V, self.H, include_self=True)
51
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in ray]) <= 1)
52
+ for pos in get_row_pos(0, self.H): # down-right diagonal on top border
53
+ ray = get_ray(pos, Direction8.DOWN_RIGHT, self.V, self.H, include_self=True)
54
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in ray]) <= 1)
55
+ for pos in get_row_pos(0, self.H): # down-left diagonal on top
56
+ ray = get_ray(pos, Direction8.DOWN_LEFT, self.V, self.H, include_self=True)
57
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in ray]) <= 1)
58
+ for pos in get_col_pos(self.H - 1, self.V): # down-left diagonal on right border
59
+ ray = get_ray(pos, Direction8.DOWN_LEFT, self.V, self.H, include_self=True)
60
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in ray]) <= 1)
61
+ # every id has at most count queens
62
+ for id_ in self.location_ids:
63
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in self.location_ids_to_pos[id_]]) == self.id_to_count[id_])
64
+
65
+ def solve_and_print(self, verbose: bool = True):
66
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
67
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
68
+ def callback(single_res: SingleSolution):
69
+ print("Solution found")
70
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1))
71
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,121 @@
1
+ from ortools.sat.python import cp_model
2
+
3
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_row_pos, get_col_pos
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])
80
+
81
+
82
+ class Board:
83
+ def __init__(self, top: list[list[int]], side: list[list[int]]):
84
+ assert all(isinstance(i, int) for line in top for i in line), 'top must be a list of lists of integers'
85
+ assert all(isinstance(i, int) for line in side for i in line), 'side must be a list of lists of integers'
86
+ self.top = top
87
+ self.side = side
88
+ self.V = len(side)
89
+ self.H = len(top)
90
+ self.model = cp_model.CpModel()
91
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
92
+ self.extra_vars = {}
93
+
94
+ self.create_vars()
95
+ self.add_all_constraints()
96
+
97
+ def create_vars(self):
98
+ for pos in get_all_pos(self.V, self.H):
99
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
100
+
101
+ def add_all_constraints(self):
102
+ for i in range(self.V):
103
+ ground_sequence = self.side[i]
104
+ if ground_sequence == -1:
105
+ continue
106
+ current_sequence = [self.model_vars[pos] for pos in get_row_pos(i, self.H)]
107
+ constrain_nonogram_sequence(self.model, ground_sequence, current_sequence, f'ngm_side_{i}')
108
+ for i in range(self.H):
109
+ ground_sequence = self.top[i]
110
+ if ground_sequence == -1:
111
+ continue
112
+ current_sequence = [self.model_vars[pos] for pos in get_col_pos(i, self.V)]
113
+ constrain_nonogram_sequence(self.model, ground_sequence, current_sequence, f'ngm_top_{i}')
114
+
115
+ def solve_and_print(self, verbose: bool = True):
116
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
117
+ return SingleSolution(assignment={pos: solver.value(var) for pos, var in board.model_vars.items()})
118
+ def callback(single_res: SingleSolution):
119
+ print("Solution found")
120
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
121
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,220 @@
1
+ from collections import defaultdict
2
+ from typing import Optional
3
+
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_pos, get_row_pos, get_col_pos
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
+
11
+
12
+ def assert_input(lines: list[list[tuple[int, str]]]):
13
+ for line in lines:
14
+ for i,c in enumerate(line):
15
+ if c == -1:
16
+ continue
17
+ elif isinstance(c, str):
18
+ assert c[:-1].isdigit(), f'strings must begin with a digit, got {c}'
19
+ line[i] = (int(c[:-1]), c[-1])
20
+ elif isinstance(c, tuple):
21
+ assert len(c) == 2 and isinstance(c[0], int) and isinstance(c[1], str), f'tuples must be (int, str), got {c}'
22
+ else:
23
+ raise ValueError(f'invalid cell value: {c}')
24
+
25
+
26
+ class Board:
27
+ def __init__(self, top: list[list[tuple[int, str]]], side: list[list[tuple[int, str]]]):
28
+ assert_input(top)
29
+ assert_input(side)
30
+ self.top = top
31
+ self.side = side
32
+ self.V = len(side)
33
+ self.H = len(top)
34
+ 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]))
35
+ self.model = cp_model.CpModel()
36
+ self.model_vars: dict[Pos, dict[str, cp_model.IntVar]] = defaultdict(dict)
37
+ self.extra_vars = {}
38
+
39
+ self.create_vars()
40
+ self.add_all_constraints()
41
+
42
+ def create_vars(self):
43
+ for pos in get_all_pos(self.V, self.H):
44
+ for color in self.unique_colors:
45
+ self.model_vars[pos][color] = self.model.NewBoolVar(f'{pos}:{color}')
46
+
47
+ def add_all_constraints(self):
48
+ for pos in get_all_pos(self.V, self.H):
49
+ self.model.Add(lxp.sum(list(self.model_vars[pos].values())) <= 1)
50
+ for i in range(self.V):
51
+ ground_sequence = self.side[i]
52
+ if tuple(ground_sequence) == (-1,):
53
+ continue
54
+ current_sequence = [self.model_vars[pos] for pos in get_row_pos(i, self.H)]
55
+ self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_side_{i}')
56
+ for i in range(self.H):
57
+ ground_sequence = self.top[i]
58
+ if tuple(ground_sequence) == (-1,):
59
+ continue
60
+ current_sequence = [self.model_vars[pos] for pos in get_col_pos(i, self.V)]
61
+ self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_top_{i}')
62
+
63
+ def constrain_nonogram_sequence(self, clues: list[tuple[int, str]], current_sequence: list[dict[str, cp_model.IntVar]], ns: str):
64
+ """
65
+ Constrain a colored sequence (current_sequence) to match the nonogram clues in clues.
66
+
67
+ 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.
68
+ current_sequence: list of dicts of IntVar in {0,1} for each color.
69
+
70
+ steps:
71
+ - Create start position s_i for each run i.
72
+ - Enforce order and >=1 separation between runs.
73
+ - Link each cell j to exactly one run interval (or none) via coverage booleans.
74
+ - Force sum of ones to equal sum(clues).
75
+ """
76
+ L = len(current_sequence)
77
+ R = len(clues)
78
+
79
+ # Early infeasibility check:
80
+ # Minimum required blanks equals number of adjacent pairs with same color.
81
+ same_color_separators = sum(1 for (len_i, col_i), (len_j, col_j) in zip(clues, clues[1:]) if col_i == col_j)
82
+ min_needed = sum(len_i for len_i, _ in clues) + same_color_separators
83
+ if min_needed > L:
84
+ print(f"Infeasible: clues {clues} need {min_needed} cells but line length is {L} for {ns}")
85
+ self.model.Add(0 == 1)
86
+ return
87
+
88
+ # Collect the color set present in clues and in the line vars
89
+ clue_colors = {c for _, c in clues}
90
+ seq_colors = set()
91
+ for j in range(L):
92
+ seq_colors.update(current_sequence[j].keys())
93
+ colors = sorted(clue_colors | seq_colors)
94
+
95
+ # Start vars per run
96
+ starts: list[cp_model.IntVar] = []
97
+ self.extra_vars[f"{ns}_starts"] = starts
98
+ for i in range(len(clues)):
99
+ # s_i in [0, L] but we will bound by containment constraint below
100
+ s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
101
+ starts.append(s)
102
+
103
+ # Ordering + separation:
104
+ # If same color: s[i+1] >= s[i] + len[i] + 1
105
+ # If different color: s[i+1] >= s[i] + len[i]
106
+ for i in range(R - 1):
107
+ len_i, col_i = clues[i]
108
+ _, col_next = clues[i + 1]
109
+ gap = 1 if col_i == col_next else 0
110
+ self.model.Add(starts[i + 1] >= starts[i] + len_i + gap)
111
+
112
+ # Containment: s[i] + len[i] <= L
113
+ for i, (run_len, _) in enumerate(clues):
114
+ self.model.Add(starts[i] + run_len <= L)
115
+
116
+ # Coverage booleans: cover[i][j] <=> (starts[i] <= j) AND (j < starts[i] + run_len)
117
+ cover = [[None] * L for _ in range(R)]
118
+ list_b_le = [[None] * L for _ in range(R)]
119
+ list_b_lt_end = [[None] * L for _ in range(R)]
120
+ self.extra_vars[f"{ns}_cover"] = cover
121
+ self.extra_vars[f"{ns}_list_b_le"] = list_b_le
122
+ self.extra_vars[f"{ns}_list_b_lt_end"] = list_b_lt_end
123
+
124
+ for i, (run_len, _) in enumerate(clues):
125
+ s_i = starts[i]
126
+ for j in range(L):
127
+ b_le = self.model.NewBoolVar(f"{ns}_le[{i},{j}]") # s_i <= j
128
+ self.model.Add(s_i <= j).OnlyEnforceIf(b_le)
129
+ self.model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
130
+
131
+ b_lt_end = self.model.NewBoolVar(f"{ns}_lt_end[{i},{j}]") # j < s_i + run_len <=> s_i + run_len - 1 >= j
132
+ end_expr = s_i + run_len - 1
133
+ self.model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
134
+ self.model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not())
135
+
136
+ b_cov = self.model.NewBoolVar(f"{ns}_cov[{i},{j}]")
137
+ self.model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
138
+ self.model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
139
+
140
+ cover[i][j] = b_cov
141
+ list_b_le[i][j] = b_le
142
+ list_b_lt_end[i][j] = b_lt_end
143
+
144
+ # Link coverage to per-cell, per-color variables.
145
+ # For each color k and cell j:
146
+ # sum_{i: color_i == k} cover[i][j] == current_sequence[j][k]
147
+ # Also tie the total cover at j to the sum over all colors at j:
148
+ # sum_i cover[i][j] == sum_k current_sequence[j][k]
149
+ # This enforces that at most one color is active per cell (since the LHS is in {0,1} due to non-overlap).
150
+ # If a color var is missing in current_sequence[j], assume it’s an implicit 0 by creating a fixed zero var.
151
+ # (Alternatively, require the caller to provide all colors per cell.)
152
+ zero_cache = {}
153
+ def get_zero(name: str):
154
+ if name not in zero_cache:
155
+ z = self.model.NewConstant(0)
156
+ zero_cache[name] = z
157
+ return zero_cache[name]
158
+
159
+ # Pre-index runs by color for efficiency
160
+ runs_by_color = {k: [] for k in colors}
161
+ for i, (_, k) in enumerate(clues):
162
+ runs_by_color[k].append(i)
163
+
164
+ for j in range(L):
165
+ # Total coverage at cell j
166
+ total_cov_j = sum(cover[i][j] for i in range(R)) if R > 0 else 0
167
+
168
+ # Sum of color vars at cell j
169
+ color_vars_j = []
170
+ for k in colors:
171
+ v = current_sequence[j].get(k, None)
172
+ if v is None:
173
+ v = get_zero(f"{ns}_zero_{k}")
174
+ color_vars_j.append(v)
175
+
176
+ # Per-color coverage equality
177
+ if runs_by_color[k]:
178
+ self.model.Add(sum(cover[i][j] for i in runs_by_color[k]) == v)
179
+ else:
180
+ # No runs of this color -> force cell color var to 0
181
+ self.model.Add(v == 0)
182
+
183
+ # Tie total coverage to sum of color vars (blank vs exactly-one color)
184
+ if R > 0:
185
+ self.model.Add(total_cov_j == sum(color_vars_j))
186
+ else:
187
+ # No runs at all: all cells must be blank across all colors
188
+ for v in color_vars_j:
189
+ self.model.Add(v == 0)
190
+
191
+ # Optional but strong propagation: per-color totals must match total clue lengths of that color
192
+ total_len_by_color = {k: 0 for k in colors}
193
+ for length, k in clues:
194
+ total_len_by_color[k] += length
195
+
196
+ for k in colors:
197
+ total_cells_k = sum(current_sequence[j].get(k, get_zero(f"{ns}_zero_{k}")) for j in range(L))
198
+ self.model.Add(total_cells_k == total_len_by_color[k])
199
+
200
+ def solve_and_print(self, verbose: bool = True, visualize_colors: Optional[dict[str, str]] = None):
201
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
202
+ return SingleSolution(assignment={pos: color for pos, d in board.model_vars.items() for color, var in d.items() if solver.value(var) == 1})
203
+ def callback(single_res: SingleSolution):
204
+ print("Solution found")
205
+ print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ' ')))
206
+ if visualize_colors is not None:
207
+ from matplotlib import pyplot as plt
208
+ from matplotlib.colors import ListedColormap
209
+ visualize_colors[' '] = 'black'
210
+ visualize_colors_keys = list(visualize_colors.keys())
211
+ char_to_int = {c: i for i, c in enumerate(visualize_colors_keys)}
212
+ 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)]
213
+ plt.imshow(nums,
214
+ aspect='equal',
215
+ cmap=ListedColormap([visualize_colors[c] for c in visualize_colors_keys]),
216
+ extent=[0, self.H, self.V, 0])
217
+ plt.colorbar()
218
+ # plt.grid(True)
219
+ plt.show()
220
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,96 @@
1
+ from dataclasses import dataclass
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, in_bounds, get_next_pos, Direction
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
9
+
10
+
11
+ @dataclass
12
+ class ShapeOnBoard:
13
+ is_active: cp_model.IntVar
14
+ orientation: str
15
+ body: set[Pos]
16
+ disallow: set[Pos]
17
+
18
+
19
+ class Board:
20
+ def __init__(self, board: np.ndarray):
21
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
22
+ self.board = board
23
+ self.V, self.H = board.shape
24
+ assert all((c == ' ') or str(c).isdecimal() for c in np.nditer(board)), "board must contain space or digits"
25
+ self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
26
+ self.blocks = {i: [pos for pos in get_all_pos(self.V, self.H) if int(get_char(self.board, pos)) == i] for i in self.block_numbers}
27
+
28
+ self.model = cp_model.CpModel()
29
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
30
+ self.shapes_on_board: list[ShapeOnBoard] = []
31
+
32
+ self.create_vars()
33
+ self.init_shapes_on_board()
34
+ self.add_all_constraints()
35
+
36
+ def create_vars(self):
37
+ for pos in get_all_pos(self.V, self.H):
38
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
39
+
40
+ def init_shapes_on_board(self):
41
+ for pos in get_all_pos(self.V, self.H):
42
+ shape = self.get_shape(pos, 'horizontal')
43
+ if shape is not None:
44
+ self.shapes_on_board.append(shape)
45
+ shape = self.get_shape(pos, 'vertical')
46
+ if shape is not None:
47
+ self.shapes_on_board.append(shape)
48
+
49
+ def add_all_constraints(self):
50
+ # if a piece is active then all its body is active and the disallow is inactive
51
+ for shape in self.shapes_on_board:
52
+ for pos in shape.body:
53
+ self.model.Add(self.model_vars[pos] == 1).OnlyEnforceIf(shape.is_active)
54
+ for pos in shape.disallow:
55
+ self.model.Add(self.model_vars[pos] == 0).OnlyEnforceIf(shape.is_active)
56
+ # if a spot is active then exactly one piece (with a body there) is active
57
+ for pos in get_all_pos(self.V, self.H):
58
+ pieces_on_pos = [shape for shape in self.shapes_on_board if pos in shape.body]
59
+ # if pos is on then exactly one shape is active. if pos is off then 0 shapes are active.
60
+ self.model.Add(sum(shape.is_active for shape in pieces_on_pos) == self.model_vars[pos])
61
+ # every region must have exactly 2 spots active.
62
+ for block in self.blocks.values():
63
+ self.model.Add(sum(self.model_vars[pos] for pos in block) == 2)
64
+
65
+ def get_shape(self, pos: Pos, orientation: str) -> Shape:
66
+ assert orientation in ['horizontal', 'vertical'], 'orientation must be horizontal or vertical'
67
+ if orientation == 'horizontal':
68
+ body = {pos, get_next_pos(pos, Direction.RIGHT)}
69
+ else:
70
+ body = {pos, get_next_pos(pos, Direction.DOWN)}
71
+ if any(not in_bounds(p, self.V, self.H) for p in body):
72
+ return None
73
+ disallow = set(get_next_pos(p, direction) for p in body for direction in Direction)
74
+ disallow = {p for p in disallow if p not in body and in_bounds(p, self.V, self.H)}
75
+ shape_on_board = ShapeOnBoard(
76
+ is_active=self.model.NewBoolVar(f'horizontal:{pos}'),
77
+ orientation='horizontal',
78
+ body=body,
79
+ disallow=disallow,
80
+ )
81
+ return shape_on_board
82
+
83
+
84
+ def solve_and_print(self, verbose: bool = True):
85
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
86
+ assignment: dict[Pos, int] = {}
87
+ for pos in get_all_pos(self.V, self.H):
88
+ if solver.Value(self.model_vars[pos]) == 1:
89
+ assignment[pos] = get_char(self.board, pos)
90
+ return SingleSolution(assignment=assignment)
91
+ def callback(single_res: SingleSolution):
92
+ print("Solution found")
93
+ print(combined_function(self.V, self.H,
94
+ cell_flags=id_board_to_wall_fn(self.board),
95
+ is_shaded=lambda r, c: Pos(x=c, y=r) in single_res.assignment))
96
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,76 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+ from ortools.sat.python.cp_model import LinearExpr as lxp
4
+
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_next_pos, get_pos, in_bounds, Direction8, get_opposite_direction
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
7
+ from puzzle_solver.core.utils_visualizer import combined_function
8
+
9
+
10
+ class Board:
11
+ def __init__(self, board: np.array, start: Pos, end: Pos):
12
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
13
+ assert all(str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
14
+ self.board = board
15
+ self.V, self.H = board.shape
16
+ assert in_bounds(start, self.V, self.H) and in_bounds(end, self.V, self.H), 'start and end must be within the board'
17
+ self.numbers = sorted(set(int(c.item()) for c in np.nditer(board)))
18
+ self.numbers_to_next: dict[int, int] = {}
19
+ for i in range(len(self.numbers) - 1):
20
+ self.numbers_to_next[self.numbers[i]] = self.numbers[i + 1]
21
+ self.numbers_to_next[self.numbers[-1]] = self.numbers[0]
22
+ self.start = start
23
+ self.end = end
24
+
25
+ self.model = cp_model.CpModel()
26
+ self.from_to: dict[tuple[Pos, Direction8], cp_model.IntVar] = {}
27
+ self.to_from: dict[tuple[Pos, Direction8], cp_model.IntVar] = {}
28
+ self.create_vars()
29
+ self.add_all_constraints()
30
+
31
+ def create_vars(self):
32
+ for pos in get_all_pos(self.V, self.H):
33
+ if pos == self.end: # end shouldn't point
34
+ continue
35
+ target = self.numbers_to_next[int(get_char(self.board, pos))]
36
+ for direction in Direction8:
37
+ next_pos = get_next_pos(pos, direction)
38
+ if not in_bounds(next_pos, self.V, self.H):
39
+ continue
40
+ if next_pos == self.start: # nothing should point at the start
41
+ continue
42
+ if int(get_char(self.board, next_pos)) != target:
43
+ continue
44
+ self.from_to[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
45
+ self.to_from[(next_pos, get_opposite_direction(direction))] = self.from_to[(pos, direction)]
46
+
47
+ def add_all_constraints(self):
48
+ for pos in get_all_pos(self.V, self.H):
49
+ if pos == self.end: # end shouldn't point
50
+ continue
51
+ all_dirs = [self.from_to[(pos, direction)] for direction in Direction8 if (pos, direction) in self.from_to]
52
+ assert len(all_dirs) > 0, f'no directions found for pos {pos}'
53
+ self.model.Add(lxp.Sum(all_dirs) == 1)
54
+ for pos in get_all_pos(self.V, self.H):
55
+ if pos == self.start: # start shouldn't be pointed at
56
+ continue
57
+ all_dirs = [self.to_from[(pos, direction)] for direction in Direction8 if (pos, direction) in self.to_from]
58
+ assert len(all_dirs) > 0, f'no directions found for pos {pos}'
59
+ self.model.Add(lxp.Sum(all_dirs) == 1)
60
+
61
+ def is_neighbor(pd1: tuple[Pos, Direction8], pd2: tuple[Pos, Direction8]) -> bool:
62
+ p1, d1 = pd1
63
+ p2, d2 = pd2
64
+ if get_next_pos(p1, d1) == p2 or p1 == p2 or get_next_pos(p2, d2) == p1:
65
+ return True
66
+ return False
67
+ force_connected_component(self.model, self.from_to, is_neighbor=is_neighbor)
68
+
69
+ def solve_and_print(self, verbose: bool = True):
70
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
71
+ return SingleSolution(assignment={pos: direction.name for (pos, direction), var in board.from_to.items() if solver.Value(var) == 1})
72
+ def callback(single_res: SingleSolution):
73
+ print("Solution found")
74
+ arrows = {Direction8.UP.name: '↑', Direction8.DOWN.name: '↓', Direction8.LEFT.name: '←', Direction8.RIGHT.name: '→', Direction8.UP_LEFT.name: '↖', Direction8.UP_RIGHT.name: '↗', Direction8.DOWN_LEFT.name: '↙', Direction8.DOWN_RIGHT.name: '↘', ' ': ' '}
75
+ print(combined_function(self.V, self.H, center_char=lambda r, c: arrows[single_res.assignment.get(get_pos(x=c, y=r), ' ')]))
76
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)