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,138 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ from ortools.sat.python import cp_model
5
+ import numpy as np
6
+
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
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
9
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
10
+
11
+
12
+ # a shape on the 2d board is just a set of positions
13
+ Shape = frozenset[Pos]
14
+
15
+
16
+ @dataclass
17
+ class ShapeOnBoard:
18
+ is_active: cp_model.IntVar
19
+ shape: Shape
20
+ shape_id: int
21
+ body: set[Pos]
22
+ disallow_same_shape: set[Pos]
23
+
24
+
25
+ class Board:
26
+ def __init__(self, board: np.array, polyomino_degrees: int = 4):
27
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
28
+ self.V = board.shape[0]
29
+ self.H = board.shape[1]
30
+ assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
31
+ self.board = board
32
+ self.polyomino_degrees = polyomino_degrees
33
+ self.polyominoes = polyominoes_with_shape_id(self.polyomino_degrees)
34
+
35
+ self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
36
+ self.blocks = {i: set() for i in self.block_numbers}
37
+ for cell in get_all_pos(self.V, self.H):
38
+ self.blocks[int(get_char(self.board, cell))].add(cell)
39
+
40
+ self.model = cp_model.CpModel()
41
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
42
+ self.connected_components: dict[Pos, cp_model.IntVar] = {}
43
+ self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
44
+
45
+ self.create_vars()
46
+ self.init_shapes_on_board()
47
+ self.add_all_constraints()
48
+
49
+ def create_vars(self):
50
+ for pos in get_all_pos(self.V, self.H):
51
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
52
+
53
+ def init_shapes_on_board(self):
54
+ for idx, (shape, shape_id) in enumerate(self.polyominoes):
55
+ for translate in get_all_pos(self.V, self.H): # body of shape is translated to be at pos
56
+ body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape}
57
+ if any(not in_bounds(p, self.V, self.H) for p in body):
58
+ continue
59
+ # shape must be fully contained in one block
60
+ if len(set(get_char(self.board, p) for p in body)) > 1:
61
+ continue
62
+ # 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
63
+ disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
64
+ disallow_same_shape -= body
65
+ self.shapes_on_board.append(ShapeOnBoard(
66
+ is_active=self.model.NewBoolVar(f'{idx}:{translate}:is_active'),
67
+ shape=shape,
68
+ shape_id=shape_id,
69
+ body=body,
70
+ disallow_same_shape=disallow_same_shape,
71
+ ))
72
+
73
+ def add_all_constraints(self):
74
+ # RULES:
75
+ # 1- You have to place one tetromino in each region in such a way that:
76
+ # 2- 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
77
+ # 3- The shaded cells should form a single connected area.
78
+ # 4- 2x2 shaded areas are not allowed
79
+
80
+ # each cell must be part of a shape, every shape must be fully on the board. Core constraint, otherwise shapes on the board make no sense.
81
+ self.only_allow_shapes_on_board()
82
+
83
+ self.force_one_shape_per_block() # Rule #1
84
+ self.disallow_same_shape_touching() # Rule #2
85
+ self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
86
+ shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
87
+ self.disallow_shape(shape_2_by_2) # Rule #4
88
+
89
+ def only_allow_shapes_on_board(self):
90
+ for shape_on_board in self.shapes_on_board:
91
+ # if shape is active then all its body cells must be active
92
+ self.model.Add(sum(self.model_vars[p] for p in shape_on_board.body) == len(shape_on_board.body)).OnlyEnforceIf(shape_on_board.is_active)
93
+ # each cell must be part of a shape
94
+ for p in get_all_pos(self.V, self.H):
95
+ shapes_on_p = [s for s in self.shapes_on_board if p in s.body]
96
+ self.model.Add(sum(s.is_active for s in shapes_on_p) == 1).OnlyEnforceIf(self.model_vars[p])
97
+
98
+ def force_one_shape_per_block(self):
99
+ # You have to place exactly one tetromino in each region
100
+ for block_i in self.block_numbers:
101
+ shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
102
+ assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
103
+ self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
104
+
105
+ def disallow_same_shape_touching(self):
106
+ # if shape is active then it must not touch any other shape of the same type
107
+ for shape_on_board in self.shapes_on_board:
108
+ similar_shapes = [s for s in self.shapes_on_board if s.shape_id == shape_on_board.shape_id]
109
+ for s in similar_shapes:
110
+ if shape_on_board.disallow_same_shape & s.body: # this shape disallows having s be on the board
111
+ self.model.Add(s.is_active == 0).OnlyEnforceIf(shape_on_board.is_active)
112
+
113
+ def disallow_shape(self, shape_to_disallow: Shape):
114
+ # for every position in the board, force sum of body < len(body)
115
+ for translate in get_all_pos(self.V, self.H):
116
+ cur_body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape_to_disallow}
117
+ if any(not in_bounds(p, self.V, self.H) for p in cur_body):
118
+ continue
119
+ self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
120
+
121
+
122
+ def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
123
+ if verbose_callback is None:
124
+ verbose_callback = verbose
125
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
126
+ assignment: dict[Pos, int] = {}
127
+ for pos, var in board.model_vars.items():
128
+ assignment[pos] = solver.Value(var)
129
+ return SingleSolution(assignment=assignment)
130
+ def callback(single_res: SingleSolution):
131
+ print("Solution found")
132
+ res = np.full((self.V, self.H), ' ', dtype=object)
133
+ for pos, val in single_res.assignment.items():
134
+ set_char(res, pos, '▒▒▒' if val == 1 else ' ')
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]))
138
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
@@ -0,0 +1,96 @@
1
+ from dataclasses import dataclass
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, get_pos, get_next_pos, Direction, 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, id_board_to_wall_fn
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class MagnetOnBoard:
14
+ uid: str
15
+ is_active: cp_model.IntVar
16
+ str_rep: tuple[tuple[Pos, str], ...]
17
+
18
+
19
+ class Board:
20
+ def __init__(self, board: np.array, top_pos: np.array, top_neg: np.array, side_pos: np.array, side_neg: np.array):
21
+ assert (len(side_pos), len(top_pos)) == board.shape, 'side_pos and top_pos must be the same shape as the board'
22
+ assert (len(side_neg), len(top_neg)) == board.shape, 'side_neg and top_neg must be the same shape as the board'
23
+ self.board = board
24
+ self.top_pos, self.top_neg = top_pos, top_neg
25
+ self.side_pos, self.side_neg = side_pos, side_neg
26
+
27
+ self.V, self.H = board.shape
28
+ self.model = cp_model.CpModel()
29
+ self.magnets: set[MagnetOnBoard] = set()
30
+ self.pos_vars: dict[tuple[Pos, str], MagnetOnBoard] = {}
31
+ self.create_vars()
32
+ self.add_all_constraints()
33
+
34
+ def create_vars(self):
35
+ for col_i in range(self.H): # vertical magnets
36
+ row_i = 0
37
+ while row_i < self.V - 1:
38
+ pos_1 = get_pos(x=col_i, y=row_i)
39
+ if get_char(self.board, pos_1) != 'V':
40
+ row_i += 1
41
+ continue
42
+ pos_2 = get_next_pos(pos_1, Direction.DOWN)
43
+ self.add_magnet(pos_1, pos_2)
44
+ row_i += 2 # skip next cell since it's already covered by this magnet
45
+ for row_i in range(self.V): # horizontal magnets
46
+ col_i = 0
47
+ while col_i < self.H - 1:
48
+ pos_1 = get_pos(x=col_i, y=row_i)
49
+ if get_char(self.board, pos_1) != 'H':
50
+ col_i += 1
51
+ continue
52
+ pos_2 = get_next_pos(pos_1, Direction.RIGHT)
53
+ self.add_magnet(pos_1, pos_2)
54
+ col_i += 2 # skip next cell since it's already covered by this magnet
55
+
56
+ def add_magnet(self, pos1: Pos, pos2: Pos):
57
+ for v1, v2 in [('+', '-'), ('-', '+'), ('x', 'x')]:
58
+ magnet = MagnetOnBoard(uid=f'{pos1}:{pos2}:{v1}{v2}', is_active=self.model.NewBoolVar(f'{pos1}:{pos2}:{v1}{v2}'), str_rep=((pos1, v1), (pos2, v2)))
59
+ self.pos_vars[(pos1, v1)] = magnet
60
+ self.pos_vars[(pos2, v2)] = magnet
61
+ self.magnets.add(magnet)
62
+
63
+ def add_all_constraints(self):
64
+ for pos in get_all_pos(self.V, self.H): # each position has exactly one magnet
65
+ self.model.AddExactlyOne([self.pos_vars[(pos, v)].is_active for v in ['+', '-', 'x']])
66
+ for pos in get_all_pos(self.V, self.H): # orthogonal positions can't both be + or -
67
+ for v in ['+', '-']:
68
+ magnet = self.pos_vars.get((pos, v))
69
+ if magnet is None:
70
+ continue
71
+ for direction in [Direction.DOWN, Direction.RIGHT]:
72
+ next_magnet = self.pos_vars.get((get_next_pos(pos, direction), v))
73
+ if next_magnet is None:
74
+ continue
75
+ self.model.AddBoolOr([magnet.is_active.Not(), next_magnet.is_active.Not()]) # ~magnet ∨ ~next_magnet
76
+ for row in range(self.V): # force side counts
77
+ if self.side_pos[row] != -1:
78
+ self.model.Add(lxp.Sum([self.pos_vars[(pos, '+')].is_active for pos in get_row_pos(row, self.H) if (pos, '+') in self.pos_vars]) == self.side_pos[row])
79
+ if self.side_neg[row] != -1:
80
+ self.model.Add(lxp.Sum([self.pos_vars[(pos, '-')].is_active for pos in get_row_pos(row, self.H) if (pos, '-') in self.pos_vars]) == self.side_neg[row])
81
+ for col in range(self.H): # force top counts
82
+ if self.top_pos[col] != -1:
83
+ self.model.Add(lxp.Sum([self.pos_vars[(pos, '+')].is_active for pos in get_col_pos(col, self.V) if (pos, '+') in self.pos_vars]) == self.top_pos[col])
84
+ if self.top_neg[col] != -1:
85
+ self.model.Add(lxp.Sum([self.pos_vars[(pos, '-')].is_active for pos in get_col_pos(col, self.V) if (pos, '-') in self.pos_vars]) == self.top_neg[col])
86
+
87
+ def solve_and_print(self, verbose: bool = True):
88
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
89
+ return SingleSolution(assignment={pos: (magnet.uid, s) for magnet in board.magnets for (pos, s) in magnet.str_rep if solver.BooleanValue(magnet.is_active)})
90
+ def callback(single_res: SingleSolution):
91
+ print("Solution found")
92
+ print(combined_function(V=self.V, H=self.H,
93
+ center_char=lambda r, c: single_res.assignment[get_pos(x=c, y=r)][1],
94
+ cell_flags=id_board_to_wall_fn(np.array([[single_res.assignment.get(get_pos(x=c, y=r), (None, ' '))[0] for c in range(self.H)] for r in range(self.V)])),
95
+ ))
96
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,56 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SingleSolution:
11
+ assignment: dict[int, int]
12
+
13
+ def get_hashable_solution(self) -> str:
14
+ return json.dumps(self.assignment, sort_keys=True)
15
+
16
+
17
+ class Board:
18
+ def __init__(self, regions: dict[int, set[int]], fixed_colors: dict[int, str]):
19
+ self.regions = regions
20
+ self.fixed_colors = fixed_colors
21
+ self.N = len(regions)
22
+ assert max(max(region) for region in regions.values() if region) == self.N - 1, 'region indices must be 0..N-1'
23
+ assert set(fixed_colors.keys()).issubset(set(range(self.N))), 'fixed colors must be a subset of region indices'
24
+ assert all(color in ['Y', 'R', 'G', 'B'] for color in fixed_colors.values()), 'fixed colors must be Y, R, G, or B'
25
+ self.color_to_int = {c: i for i, c in enumerate(set(fixed_colors.values()))}
26
+ self.int_to_color = {i: c for c, i in self.color_to_int.items()}
27
+
28
+ self.model = cp_model.CpModel()
29
+ self.model_vars: dict[int, cp_model.IntVar] = {}
30
+
31
+ self.create_vars()
32
+ self.add_all_constraints()
33
+
34
+ def create_vars(self):
35
+ for region_idx in self.regions.keys():
36
+ self.model_vars[region_idx] = self.model.NewIntVar(0, 3, f'{region_idx}')
37
+
38
+ def add_all_constraints(self):
39
+ # fix given colors
40
+ for region_idx, color in self.fixed_colors.items():
41
+ self.model.Add(self.model_vars[region_idx] == self.color_to_int[color])
42
+ # neighboring regions must have different colors
43
+ for region_idx, region_connections in self.regions.items():
44
+ for other_region_idx in region_connections: # neighboring regions must have different colors
45
+ self.model.Add(self.model_vars[region_idx] != self.model_vars[other_region_idx])
46
+
47
+ def solve_and_print(self, verbose: bool = True):
48
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
49
+ assignment: dict[int, int] = {}
50
+ for region_idx, var in board.model_vars.items():
51
+ assignment[region_idx] = solver.Value(var)
52
+ return SingleSolution(assignment=assignment)
53
+ def callback(single_res: SingleSolution):
54
+ print("Solution found")
55
+ print({k: self.int_to_color[v] for k, v in single_res.assignment.items()})
56
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,119 @@
1
+ from dataclasses import dataclass
2
+ import numpy as np
3
+ from ortools.sat.python import cp_model
4
+ from ortools.util.python import sorted_interval_list
5
+
6
+ from puzzle_solver.core.utils import Direction, Pos, get_char, get_next_pos, get_row_pos, get_col_pos, in_bounds, set_char
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import combined_function
9
+
10
+
11
+ @dataclass
12
+ class var_with_bounds:
13
+ var: cp_model.IntVar
14
+ min_value: int
15
+ max_value: int
16
+
17
+
18
+ def _div_bounds(a_min: int, a_max: int, b_min: int, b_max: int) -> tuple[int, int]:
19
+ assert not (b_min == 0 and b_max == 0), "Denominator interval cannot be [0, 0]."
20
+ denoms = [b_min, b_max]
21
+ if 0 in denoms:
22
+ denoms.remove(0)
23
+ if b_min <= -1:
24
+ denoms += [-1]
25
+ if b_max >= 1:
26
+ denoms += [1]
27
+ candidates = [a_min // d for d in denoms] + [a_max // d for d in denoms]
28
+ return min(candidates), max(candidates)
29
+
30
+
31
+ class Board:
32
+ def __init__(self, board: np.array, digits: list[int]):
33
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
34
+ self.board = board
35
+ self.V, self.H = board.shape
36
+ assert self.V >= 3 and self.V % 2 == 1, f'board must have at least 3 rows and an odd number of rows. Got {self.V} rows.'
37
+ assert self.H >= 3 and self.H % 2 == 1, f'board must have at least 3 columns and an odd number of columns. Got {self.H} columns.'
38
+ self.digits = digits
39
+ self.domain_values = sorted_interval_list.Domain.FromValues(self.digits)
40
+ self.domain_values_no_zero = sorted_interval_list.Domain.FromValues([d for d in self.digits if d != 0])
41
+
42
+ self.model = cp_model.CpModel()
43
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
44
+ self.create_vars()
45
+ assert len(self.model_vars) == len(self.digits), f'len(model_vars) != len(digits), {len(self.model_vars)} != {len(self.digits)}'
46
+ self.model.AddAllDifferent(list(self.model_vars.values()))
47
+
48
+ def create_vars(self):
49
+ for row in range(0, self.V-2, 2):
50
+ line_pos = [pos for pos in get_row_pos(row, self.H)]
51
+ self.parse_line(line_pos)
52
+ for col in range(0, self.H-2, 2):
53
+ line_pos = [pos for pos in get_col_pos(col, self.V)]
54
+ self.parse_line(line_pos)
55
+
56
+ def parse_line(self, line_pos: list[Pos]) -> list[int]:
57
+ last_num = get_char(self.board, line_pos[-1])
58
+ equal_sign = get_char(self.board, line_pos[-2])
59
+ assert equal_sign == '=', f'last element of line must be =, got {equal_sign}'
60
+ line_pos = line_pos[:-2]
61
+ operators = [get_char(self.board, pos) for pos in line_pos[1::2]]
62
+ assert all(c.strip() in ['+', '-', '*', '/'] for c in operators), f'even indices of line must be operators, got {operators}'
63
+ digits_pos = line_pos[::2]
64
+ running_var = self.get_var(digits_pos[0], fixed=get_char(self.board, digits_pos[0]))
65
+ for pos, operator in zip(digits_pos[1:], operators):
66
+ running_var = self.apply_operator(operator, running_var, self.get_var(pos, fixed=get_char(self.board, pos)))
67
+ self.model.Add(running_var.var == int(last_num))
68
+ return running_var
69
+
70
+ def get_var(self, pos: Pos, fixed: str) -> var_with_bounds:
71
+ if pos not in self.model_vars:
72
+ domain = self.domain_values_no_zero if self.might_be_denominator(pos) else self.domain_values
73
+ self.model_vars[pos] = self.model.NewIntVarFromDomain(domain, f'{pos}')
74
+ if fixed.strip():
75
+ self.model.Add(self.model_vars[pos] == int(fixed))
76
+ return var_with_bounds(var=self.model_vars[pos], min_value=min(self.digits), max_value=max(self.digits))
77
+
78
+ def might_be_denominator(self, pos: Pos) -> bool:
79
+ "Important since if the variable might be a denominator and the domain includes 0 then ortools immediately sets the model as INVALID"
80
+ above_pos = get_next_pos(pos, Direction.UP)
81
+ left_pos = get_next_pos(pos, Direction.LEFT)
82
+ above_operator = get_char(self.board, above_pos) if in_bounds(above_pos, self.V, self.H) else None
83
+ left_operator = get_char(self.board, left_pos) if in_bounds(left_pos, self.V, self.H) else None
84
+ return above_operator == '/' or left_operator == '/'
85
+
86
+ def apply_operator(self, operator: str, a: var_with_bounds, b: var_with_bounds) -> var_with_bounds:
87
+ assert operator in ['+', '-', '*', '/'], f'invalid operator: {operator}'
88
+ if operator == "+":
89
+ lo = a.min_value + b.min_value
90
+ hi = a.max_value + b.max_value
91
+ res = self.model.NewIntVar(lo, hi, "sum")
92
+ self.model.Add(res == a.var + b.var)
93
+ elif operator == "-":
94
+ lo = a.min_value - b.max_value
95
+ hi = a.max_value - b.min_value
96
+ res = self.model.NewIntVar(lo, hi, "diff")
97
+ self.model.Add(res == a.var - b.var)
98
+ elif operator == "*":
99
+ cands = [a.min_value*b.min_value, a.min_value*b.max_value, a.max_value*b.min_value, a.max_value*b.max_value]
100
+ lo, hi = min(cands), max(cands)
101
+ res = self.model.NewIntVar(lo, hi, "prod")
102
+ self.model.AddMultiplicationEquality(res, [a.var, b.var])
103
+ elif operator == "/":
104
+ self.model.Add(b.var != 0)
105
+ lo, hi = _div_bounds(a.min_value, a.max_value, b.min_value, b.max_value)
106
+ res = self.model.NewIntVar(lo, hi, "quot")
107
+ self.model.AddDivisionEquality(res, a.var, b.var)
108
+ return var_with_bounds(res, lo, hi)
109
+
110
+ def solve_and_print(self, verbose: bool = True):
111
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
112
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
113
+ def callback(single_res: SingleSolution):
114
+ print("Solution found")
115
+ output_board = self.board.copy()
116
+ for pos, var in single_res.assignment.items():
117
+ set_char(output_board, pos, str(var))
118
+ print(combined_function(self.V, self.H, center_char=lambda r, c: str(output_board[r, c])))
119
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,93 @@
1
+ from typing import Optional
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+ from ortools.util.python import sorted_interval_list
6
+
7
+ from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, get_char, Direction, get_col_pos, get_next_pos, get_pos, get_row_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 add_opcode_constraint(model: cp_model.CpModel, vlist: list[cp_model.IntVar], opcode: str, result: int):
13
+ assert opcode in ['+', '-', '*', '/'], "Invalid opcode"
14
+ assert opcode not in ['-', '/'] or len(vlist) == 2, f"Opcode '{opcode}' requires exactly 2 variables"
15
+ if opcode == '+':
16
+ model.Add(sum(vlist) == result)
17
+ elif opcode == '*':
18
+ model.AddMultiplicationEquality(result, vlist)
19
+ elif opcode == '-':
20
+ # either vlist[0] - vlist[1] == result OR vlist[1] - vlist[0] == result
21
+ b = model.NewBoolVar('sub_gate')
22
+ model.Add(vlist[0] - vlist[1] == result).OnlyEnforceIf(b)
23
+ model.Add(vlist[1] - vlist[0] == result).OnlyEnforceIf(b.Not())
24
+ elif opcode == '/':
25
+ # either v0 / v1 == result or v1 / v0 == result
26
+ b = model.NewBoolVar('div_gate')
27
+ # Ensure no division by zero
28
+ model.Add(vlist[0] != 0)
29
+ model.Add(vlist[1] != 0)
30
+ # case 1: v0 / v1 == result → v0 == v1 * result
31
+ model.Add(vlist[0] == vlist[1] * result).OnlyEnforceIf(b)
32
+ # case 2: v1 / v0 == result → v1 == v0 * result
33
+ model.Add(vlist[1] == vlist[0] * result).OnlyEnforceIf(b.Not())
34
+
35
+
36
+ class Board:
37
+ def __init__(self, circle_board: np.array, board: Optional[np.array] = None):
38
+ assert circle_board.ndim == 2, f'circle_board must be 2d, got {circle_board.ndim}'
39
+ self.circle_board = circle_board
40
+ self.board = board
41
+ self.V, self.H = circle_board.shape
42
+ self.V += 1
43
+ self.H += 1
44
+ assert board is None or board.shape == (self.V, self.H), f'board must be {(self.V, self.H)}, got {board.shape}'
45
+ self.N = max(self.V, self.H)
46
+
47
+ self.model = cp_model.CpModel()
48
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
49
+ self.create_vars()
50
+ self.add_all_constraints()
51
+
52
+ def create_vars(self):
53
+ for pos in get_all_pos(self.V, self.H):
54
+ self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
55
+
56
+ def add_all_constraints(self):
57
+ for pos in get_all_pos(self.V-1, self.H-1): # enforce circles
58
+ tl = self.model_vars[pos]
59
+ tr = self.model_vars[get_next_pos(pos, Direction.RIGHT)]
60
+ bl = self.model_vars[get_next_pos(pos, Direction.DOWN)]
61
+ br = self.model_vars[get_next_pos(pos, Direction8.DOWN_RIGHT)]
62
+ c = get_char(self.circle_board, pos).strip()
63
+ if c == 'E': # all are even
64
+ domain = sorted_interval_list.Domain.FromValues(list(range(2, self.N+1, 2)))
65
+ for v in [tl, tr, bl, br]:
66
+ self.model.AddLinearExpressionInDomain(v, domain)
67
+ elif c == 'O': # all are odd
68
+ domain = sorted_interval_list.Domain.FromValues(list(range(1, self.N+1, 2)))
69
+ for v in [tl, tr, bl, br]:
70
+ self.model.AddLinearExpressionInDomain(v, domain)
71
+ elif c:
72
+ result, opcode = c[:-1], c[-1]
73
+ opcode = opcode.replace('x', '*')
74
+ add_opcode_constraint(self.model, [tl, br], opcode, int(result))
75
+ add_opcode_constraint(self.model, [tr, bl], opcode, int(result))
76
+ for row in range(self.V): # every row is unique
77
+ self.model.AddAllDifferent([self.model_vars[p] for p in get_row_pos(row, self.H)])
78
+ for col in range(self.H): # every column is unique
79
+ self.model.AddAllDifferent([self.model_vars[p] for p in get_col_pos(col, self.V)])
80
+ if self.board is not None:
81
+ for pos in get_all_pos(self.V, self.H):
82
+ c = get_char(self.board, pos).strip()
83
+ if c:
84
+ self.model.Add(self.model_vars[pos] == int(c))
85
+
86
+
87
+ def solve_and_print(self, verbose: bool = True):
88
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
89
+ return SingleSolution(assignment={pos: solver.Value(board.model_vars[pos]) for pos in get_all_pos(board.V, board.H)})
90
+ def callback(single_res: SingleSolution):
91
+ print("Solution found")
92
+ print(combined_function(self.V, self.H, center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]).strip()))
93
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,123 @@
1
+ import time
2
+ from typing import Union, 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, set_char, get_neighbors8
9
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
10
+
11
+
12
+ class Board:
13
+ def __init__(self, board: np.array, mine_count: Optional[int] = None):
14
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
15
+ assert all(isinstance(i.item(), str) and (str(i.item()) in [' ', 'F', 'S', 'M', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) for i in np.nditer(board)), 'board must be either F, S, M, 0-9 or space'
16
+ self.board = board
17
+ self.V = board.shape[0]
18
+ self.H = board.shape[1]
19
+ self.mine_count = mine_count
20
+ self.model = cp_model.CpModel()
21
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
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.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
29
+
30
+ def add_all_constraints(self):
31
+ if self.mine_count is not None:
32
+ self.model.Add(lxp.Sum(list(self.model_vars.values())) == self.mine_count)
33
+ for pos in get_all_pos(self.V, self.H):
34
+ c = get_char(self.board, pos)
35
+ if c in ['F', ' ']:
36
+ continue
37
+ if c == 'S': # safe position but neighbors are unknown
38
+ self.model.Add(self.model_vars[pos] == 0)
39
+ continue
40
+ if c == 'M': # mine position but neighbors are unknown
41
+ self.model.Add(self.model_vars[pos] == 1)
42
+ continue
43
+ # clue indicates safe position AND neighbors are known
44
+ c = int(c)
45
+ self.model.Add(lxp.Sum([self.model_vars[n] for n in get_neighbors8(pos, self.V, self.H, include_self=False)]) == c)
46
+ self.model.Add(self.model_vars[pos] == 0)
47
+
48
+
49
+ def _is_feasible(board: np.array, pos: Pos = None, value: str = None, mine_count: int = None) -> bool:
50
+ """Returns True if the board is feasible after setting the value at the position"""
51
+ board = board.copy()
52
+ if pos is not None and value is not None:
53
+ set_char(board, pos, str(value))
54
+ board = Board(board, mine_count=mine_count)
55
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
56
+ return SingleSolution(assignment={pos: solver.value(var) for pos, var in board.model_vars.items()})
57
+ return len(generic_solve_all(board, board_to_solution, max_solutions=1, verbose=False)) >= 1
58
+
59
+ def _is_safe(board: np.array, pos: Pos, mine_count: Optional[int] = None) -> Union[bool, None]:
60
+ """Returns a True if the position is safe, False if it is a mine, otherwise None"""
61
+ safe_feasible = _is_feasible(board, pos, 'S', mine_count=mine_count)
62
+ mine_feasible = _is_feasible(board, pos, 'M', mine_count=mine_count)
63
+ if safe_feasible and mine_feasible:
64
+ return None
65
+ if safe_feasible:
66
+ return True
67
+ if mine_feasible:
68
+ return False
69
+ raise ValueError(f"Position {pos} has both safe and mine infeasible")
70
+
71
+ def give_next_guess(board: np.array, mine_count: Optional[int] = None, verbose: bool = True):
72
+ tic = time.time()
73
+ is_feasible = _is_feasible(board, mine_count=mine_count)
74
+ if not is_feasible:
75
+ raise ValueError("Board is not feasible")
76
+ V = board.shape[0]
77
+ H = board.shape[1]
78
+ check_positions = set() # any position that is unknown and has a neighbor with a clue or flag
79
+ flag_positions = set()
80
+ for pos in get_all_pos(V, H):
81
+ neighbors8 = get_neighbors8(pos, V, H, include_self=False)
82
+ if get_char(board, pos) not in [' ', 'F']:
83
+ continue
84
+ if get_char(board, pos) == 'F' or any(get_char(board, n) != ' ' for n in neighbors8):
85
+ check_positions.add(pos)
86
+ if get_char(board, pos) == 'F':
87
+ flag_positions.add(pos)
88
+ pos_dict = {pos: _is_safe(board, pos, mine_count) for pos in check_positions}
89
+ safe_positions = {pos for pos, is_safe in pos_dict.items() if is_safe is True}
90
+ mine_positions = {pos for pos, is_safe in pos_dict.items() if is_safe is False}
91
+ new_garuneed_mine_positions = mine_positions - flag_positions
92
+ wrong_flag_positions = flag_positions - mine_positions
93
+ if verbose:
94
+ if len(safe_positions) > 0:
95
+ print(f"Found {len(safe_positions)} new guaranteed safe positions")
96
+ print(safe_positions)
97
+ print('#'*10)
98
+ if len(mine_positions) == 0:
99
+ print("No guaranteed mine positions")
100
+ print('#'*10)
101
+ if len(new_garuneed_mine_positions) > 0:
102
+ print(f"Found {len(new_garuneed_mine_positions)} new guaranteed mine positions")
103
+ print(new_garuneed_mine_positions)
104
+ print('#'*10)
105
+ if len(wrong_flag_positions) > 0:
106
+ print("WARNING | "*4 + "WARNING")
107
+ print(f"Found {len(wrong_flag_positions)} wrong flag positions")
108
+ print(wrong_flag_positions)
109
+ print('#'*10)
110
+ toc = time.time()
111
+ print(f"Time taken: {toc - tic:.2f} seconds")
112
+ return safe_positions, new_garuneed_mine_positions, wrong_flag_positions
113
+
114
+ def print_board(board: np.array, safe_positions: set[Pos], new_garuneed_mine_positions: set[Pos], wrong_flag_positions: set[Pos]):
115
+ res = np.full((board.shape[0], board.shape[1]), ' ', dtype=object)
116
+ for pos in get_all_pos(board.shape[0], board.shape[1]):
117
+ if pos in safe_positions:
118
+ set_char(res, pos, 'S')
119
+ elif pos in new_garuneed_mine_positions:
120
+ set_char(res, pos, 'M')
121
+ elif get_char(board, pos) == 'F' and pos not in wrong_flag_positions:
122
+ set_char(res, pos, 'F')
123
+ print(res)
@@ -0,0 +1,38 @@
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_neighbors8, get_pos
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+ from puzzle_solver.core.utils_visualizer import combined_function
8
+
9
+
10
+ class Board:
11
+ def __init__(self, board: np.array):
12
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
13
+ assert all((c.item() == ' ') or 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
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
+ self.create_vars()
19
+ self.add_all_constraints()
20
+
21
+ def create_vars(self):
22
+ for pos in get_all_pos(self.V, self.H):
23
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
24
+
25
+ def add_all_constraints(self):
26
+ for pos in get_all_pos(self.V, self.H):
27
+ c = get_char(self.board, pos)
28
+ if not str(c).isdecimal():
29
+ continue
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))
31
+
32
+ def solve_and_print(self, verbose: bool = True):
33
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
34
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
35
+ def callback(single_res: SingleSolution):
36
+ print("Solution found")
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))
38
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)