multi-puzzle-solver 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of multi-puzzle-solver might be problematic. Click here for more details.

Files changed (31) hide show
  1. multi_puzzle_solver-0.1.0.dist-info/METADATA +1897 -0
  2. multi_puzzle_solver-0.1.0.dist-info/RECORD +31 -0
  3. multi_puzzle_solver-0.1.0.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-0.1.0.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +26 -0
  6. puzzle_solver/core/utils.py +127 -0
  7. puzzle_solver/core/utils_ortools.py +78 -0
  8. puzzle_solver/puzzles/bridges/bridges.py +106 -0
  9. puzzle_solver/puzzles/dominosa/dominosa.py +136 -0
  10. puzzle_solver/puzzles/filling/filling.py +192 -0
  11. puzzle_solver/puzzles/guess/guess.py +231 -0
  12. puzzle_solver/puzzles/inertia/inertia.py +122 -0
  13. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +204 -0
  14. puzzle_solver/puzzles/inertia/tsp.py +398 -0
  15. puzzle_solver/puzzles/keen/keen.py +99 -0
  16. puzzle_solver/puzzles/light_up/light_up.py +95 -0
  17. puzzle_solver/puzzles/magnets/magnets.py +117 -0
  18. puzzle_solver/puzzles/map/map.py +56 -0
  19. puzzle_solver/puzzles/minesweeper/minesweeper.py +110 -0
  20. puzzle_solver/puzzles/mosaic/mosaic.py +48 -0
  21. puzzle_solver/puzzles/nonograms/nonograms.py +126 -0
  22. puzzle_solver/puzzles/pearl/pearl.py +151 -0
  23. puzzle_solver/puzzles/range/range.py +154 -0
  24. puzzle_solver/puzzles/signpost/signpost.py +95 -0
  25. puzzle_solver/puzzles/singles/singles.py +116 -0
  26. puzzle_solver/puzzles/sudoku/sudoku.py +90 -0
  27. puzzle_solver/puzzles/tents/tents.py +110 -0
  28. puzzle_solver/puzzles/towers/towers.py +139 -0
  29. puzzle_solver/puzzles/tracks/tracks.py +170 -0
  30. puzzle_solver/puzzles/undead/undead.py +168 -0
  31. puzzle_solver/puzzles/unruly/unruly.py +86 -0
@@ -0,0 +1,99 @@
1
+ from typing import Optional
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_row_pos, get_col_pos
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+
9
+
10
+ def add_opcode_constraint(model: cp_model.CpModel, vlist: list[cp_model.IntVar], opcode: str, result: int):
11
+ assert opcode in ['+', '-', '*', '/'], "Invalid opcode"
12
+ if opcode in ['-', '/']:
13
+ assert len(vlist) == 2, f"Opcode '{opcode}' requires exactly 2 variables"
14
+
15
+ if opcode == '+':
16
+ model.Add(sum(vlist) == result)
17
+ elif opcode == '*':
18
+ model.AddMultiplicationEquality(result, vlist)
19
+
20
+ elif opcode == '-':
21
+ # either vlist[0] - vlist[1] == result OR vlist[1] - vlist[0] == result
22
+ b = model.NewBoolVar('sub_dir')
23
+ model.Add(vlist[0] - vlist[1] == result).OnlyEnforceIf(b)
24
+ model.Add(vlist[1] - vlist[0] == result).OnlyEnforceIf(b.Not())
25
+ elif opcode == '/':
26
+ # either v0 / v1 == result or v1 / v0 == result
27
+ b = model.NewBoolVar('div_dir')
28
+ # Ensure no division by zero
29
+ model.Add(vlist[0] != 0)
30
+ model.Add(vlist[1] != 0)
31
+ # case 1: v0 / v1 == result → v0 == v1 * result
32
+ model.Add(vlist[0] == vlist[1] * result).OnlyEnforceIf(b)
33
+ # case 2: v1 / v0 == result → v1 == v0 * result
34
+ model.Add(vlist[1] == vlist[0] * result).OnlyEnforceIf(b.Not())
35
+
36
+ class Board:
37
+ def __init__(self, board: np.ndarray, block_results: dict[str, tuple[str, int]], clues: Optional[np.ndarray] = None):
38
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
39
+ assert board.shape[0] == board.shape[1], 'board must be square'
40
+ assert clues is None or clues.ndim == 2 and clues.shape[0] == board.shape[0] and clues.shape[1] == board.shape[1], f'clues must be 2d, got {clues.ndim}'
41
+ assert all((c.item().startswith('d') and c.item()[1:].isdecimal()) for c in np.nditer(board)), "board must contain 'd' prefixed digits"
42
+ block_names = set(c.item() for c in np.nditer(board))
43
+ assert set(block_results.keys()).issubset(block_names), f'block results must contain all block names, {block_names - set(block_results.keys())}'
44
+ self.board = board
45
+ self.N = board.shape[0]
46
+ self.block_results = {block: (op, result) for block, (op, result) in block_results.items()}
47
+ self.clues = clues
48
+ self.model = cp_model.CpModel()
49
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
50
+ self.create_vars()
51
+ self.add_all_constraints()
52
+
53
+ def get_block_pos(self, block: str):
54
+ return [p for p in get_all_pos(self.N) if get_char(self.board, p) == block]
55
+
56
+ def create_vars(self):
57
+ for pos in get_all_pos(self.N):
58
+ self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
59
+ if self.clues is not None:
60
+ for pos in get_all_pos(self.N):
61
+ c = get_char(self.clues, pos)
62
+ if int(c) >= 1:
63
+ self.model.Add(self.model_vars[pos] == int(c))
64
+
65
+ def add_all_constraints(self):
66
+ self.unique_digits()
67
+ self.constrain_block_results()
68
+
69
+ def unique_digits(self):
70
+ # Each row contains only one occurrence of each digit
71
+ for row in range(self.N):
72
+ row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.N)]
73
+ self.model.AddAllDifferent(row_vars)
74
+ # Each column contains only one occurrence of each digit
75
+ for col in range(self.N):
76
+ col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.N)]
77
+ self.model.AddAllDifferent(col_vars)
78
+
79
+ def constrain_block_results(self):
80
+ # The digits in each block can be combined to form the number stated in the clue, using the arithmetic operation given in the clue. That is:
81
+ for block, (op, result) in self.block_results.items():
82
+ block_vars = [self.model_vars[p] for p in self.get_block_pos(block)]
83
+ add_opcode_constraint(self.model, block_vars, op, result)
84
+
85
+ def solve_and_print(self):
86
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
87
+ assignment: dict[Pos, int] = {}
88
+ for p in get_all_pos(board.N):
89
+ assignment[p] = solver.Value(board.model_vars[p])
90
+ return SingleSolution(assignment=assignment)
91
+ def callback(single_res: SingleSolution):
92
+ print("Solution found")
93
+ res = np.full((self.N, self.N), ' ', dtype=object)
94
+ for pos in get_all_pos(self.N):
95
+ c = get_char(self.board, pos)
96
+ c = single_res.assignment[pos]
97
+ set_char(res, pos, c)
98
+ print(res)
99
+ return generic_solve_all(self, board_to_solution, callback=callback, max_solutions=10)
@@ -0,0 +1,95 @@
1
+ from enum import Enum
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+ from ortools.sat.python.cp_model import LinearExpr as lxp
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, in_bounds, get_next_pos, get_neighbors4, Direction
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+
10
+
11
+ class State(Enum):
12
+ BLACK = ('BLACK', 'B')
13
+ SHINE = ('SHINE', 'S')
14
+ LIGHT = ('LIGHT', 'L')
15
+
16
+
17
+ def laser_out(board: np.array, init_pos: Pos) -> list[Pos]:
18
+ 'laser out in all 4 directions until we hit a wall or out of bounds'
19
+ N = board.shape[0]
20
+ result = []
21
+ for direction in Direction:
22
+ cur_pos = init_pos
23
+ while True:
24
+ cur_pos = get_next_pos(cur_pos, direction)
25
+ if not in_bounds(cur_pos, N) or get_char(board, cur_pos) != '*':
26
+ break
27
+ result.append(cur_pos)
28
+ return result
29
+
30
+
31
+ class Board:
32
+ def __init__(self, board: np.array):
33
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
34
+ assert board.shape[0] == board.shape[1], 'board must be square'
35
+ assert all((c in ['*', 'W']) or str(c).isdecimal() for c in np.nditer(board)), 'board must contain only * or W or numbers'
36
+ self.board = board
37
+ self.N = board.shape[0]
38
+ self.star_positions: set[Pos] = {pos for pos in get_all_pos(self.N) if get_char(self.board, pos) == '*'}
39
+ self.number_position: set[Pos] = {pos for pos in get_all_pos(self.N) if str(get_char(self.board, pos)).isdecimal()}
40
+ self.model = cp_model.CpModel()
41
+ self.model_vars: dict[tuple[Pos, State], cp_model.IntVar] = {}
42
+
43
+ self.create_vars()
44
+ self.add_all_constraints()
45
+
46
+ def create_vars(self):
47
+ for pos in self.star_positions:
48
+ var_list = []
49
+ for state in State:
50
+ v = self.model.NewBoolVar(f'{pos}:{state.value[0]}')
51
+ self.model_vars[(pos, state)] = v
52
+ var_list.append(v)
53
+ self.model.AddExactlyOne(var_list)
54
+
55
+ def add_all_constraints(self):
56
+ # goal: no black squares
57
+ for pos in self.star_positions:
58
+ self.model.Add(self.model_vars[(pos, State.BLACK)] == 0)
59
+ # number of lights touching a decimal is = decimal
60
+ for pos in self.number_position:
61
+ ground = int(get_char(self.board, pos))
62
+ neighbour_list = get_neighbors4(pos, self.N, self.N)
63
+ neighbour_list = [p for p in neighbour_list if p in self.star_positions]
64
+ neighbour_light_count = lxp.Sum([self.model_vars[(p, State.LIGHT)] for p in neighbour_list])
65
+ self.model.Add(neighbour_light_count == ground)
66
+ # if a square is a light then everything it touches shines
67
+ for pos in self.star_positions:
68
+ orthoginals = laser_out(self.board, pos)
69
+ for ortho in orthoginals:
70
+ self.model.Add(self.model_vars[(ortho, State.SHINE)] == 1).OnlyEnforceIf([self.model_vars[(pos, State.LIGHT)]])
71
+ # a square is black if all of it's laser_out is not light AND itself isnot a light
72
+ for pos in self.star_positions:
73
+ orthoginals = laser_out(self.board, pos)
74
+ i_am_not_light = [self.model_vars[(pos, State.LIGHT)].Not()]
75
+ no_light_in_laser = [self.model_vars[(p, State.LIGHT)].Not() for p in orthoginals]
76
+ self.model.Add(self.model_vars[(pos, State.BLACK)] == 1).OnlyEnforceIf(i_am_not_light + no_light_in_laser)
77
+
78
+ def solve_and_print(self):
79
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
80
+ assignment: dict[Pos, str] = {}
81
+ for (pos, state), var in board.model_vars.items():
82
+ if solver.BooleanValue(var):
83
+ assignment[pos] = state.value[1]
84
+ return SingleSolution(assignment=assignment)
85
+ def callback(single_res: SingleSolution):
86
+ print("Solution found")
87
+ res = np.full((self.N, self.N), ' ', dtype=object)
88
+ for pos in get_all_pos(self.N):
89
+ c = get_char(self.board, pos)
90
+ if c == '*':
91
+ c = single_res.assignment[pos]
92
+ c = 'L' if c == 'L' else ' '
93
+ set_char(res, pos, c)
94
+ print(res)
95
+ return generic_solve_all(self, board_to_solution, callback=callback)
@@ -0,0 +1,117 @@
1
+ from enum import Enum
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+ from ortools.sat.python.cp_model import LinearExpr as lxp
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, in_bounds, get_next_pos, Direction, get_row_pos, get_col_pos
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+
10
+
11
+ class State(Enum):
12
+ BLANK = ('BLANK', ' ')
13
+ POSITIVE = ('POSITIVE', '+')
14
+ NEGATIVE = ('NEGATIVE', '-')
15
+
16
+
17
+ class Board:
18
+ def __init__(self, board: np.array, sides: dict[str, np.array]):
19
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
20
+ assert len(sides) == 4, '4 sides must be provided'
21
+ assert all(s.ndim == 1 for s in sides.values()), 'all sides must be 1d'
22
+ assert set(sides.keys()) == set(['pos_v', 'neg_v', 'pos_h', 'neg_h'])
23
+ assert sides['pos_h'].shape[0] == board.shape[0], 'pos_h dim must equal vertical board size'
24
+ assert sides['neg_h'].shape[0] == board.shape[0], 'neg_h dim must equal vertical board size'
25
+ assert sides['pos_v'].shape[0] == board.shape[1], 'pos_v dim must equal horizontal board size'
26
+ assert sides['neg_v'].shape[0] == board.shape[1], 'neg_v dim must equal horizontal board size'
27
+ self.board = board
28
+ self.sides = sides
29
+ self.V = board.shape[0]
30
+ self.H = board.shape[1]
31
+ self.model = cp_model.CpModel()
32
+ self.pairs: set[tuple[Pos, Pos]] = set()
33
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
34
+
35
+ self.create_vars()
36
+ self.add_all_constraints()
37
+
38
+ def create_vars(self):
39
+ # init vars
40
+ for pos in get_all_pos(V=self.V, H=self.H):
41
+ var_list = []
42
+ for state in State:
43
+ v = self.model.NewBoolVar(f'{pos}:{state.value[0]}')
44
+ self.model_vars[(pos, state)] = v
45
+ var_list.append(v)
46
+ self.model.AddExactlyOne(var_list)
47
+ # init pairs. traverse from top left and V indicates vertical domino (2x1) while H is horizontal (1x2)
48
+ seen_pos = set()
49
+ for pos in get_all_pos(V=self.V, H=self.H):
50
+ if pos in seen_pos:
51
+ continue
52
+ seen_pos.add(pos)
53
+ c = get_char(self.board, pos)
54
+ direction = {'V': Direction.DOWN, 'H': Direction.RIGHT}[c]
55
+ other_pos = get_next_pos(pos, direction)
56
+ seen_pos.add(other_pos)
57
+ self.pairs.add((pos, other_pos))
58
+ assert len(self.pairs)*2 == self.V*self.H
59
+
60
+ def add_all_constraints(self):
61
+ # pairs must be matching
62
+ for pair in self.pairs:
63
+ a, b = pair
64
+ self.model.add(self.model_vars[(a, State.BLANK)] == self.model_vars[(b, State.BLANK)])
65
+ self.model.add(self.model_vars[(a, State.POSITIVE)] == self.model_vars[(b, State.NEGATIVE)])
66
+ self.model.add(self.model_vars[(a, State.NEGATIVE)] == self.model_vars[(b, State.POSITIVE)])
67
+ # no orthoginal matching poles
68
+ for pos in get_all_pos(V=self.V, H=self.H):
69
+ right_pos = get_next_pos(pos, Direction.RIGHT)
70
+ down_pos = get_next_pos(pos, Direction.DOWN)
71
+ if in_bounds(right_pos, H=self.H, V=self.V):
72
+ self.model.add(self.model_vars[(pos, State.POSITIVE)] == 0).OnlyEnforceIf(self.model_vars[(right_pos, State.POSITIVE)])
73
+ self.model.add(self.model_vars[(right_pos, State.POSITIVE)] == 0).OnlyEnforceIf(self.model_vars[(pos, State.POSITIVE)])
74
+ self.model.add(self.model_vars[(pos, State.NEGATIVE)] == 0).OnlyEnforceIf(self.model_vars[(right_pos, State.NEGATIVE)])
75
+ self.model.add(self.model_vars[(right_pos, State.NEGATIVE)] == 0).OnlyEnforceIf(self.model_vars[(pos, State.NEGATIVE)])
76
+ if in_bounds(down_pos, H=self.H, V=self.V):
77
+ self.model.add(self.model_vars[(pos, State.POSITIVE)] == 0).OnlyEnforceIf(self.model_vars[(down_pos, State.POSITIVE)])
78
+ self.model.add(self.model_vars[(down_pos, State.POSITIVE)] == 0).OnlyEnforceIf(self.model_vars[(pos, State.POSITIVE)])
79
+ self.model.add(self.model_vars[(pos, State.NEGATIVE)] == 0).OnlyEnforceIf(self.model_vars[(down_pos, State.NEGATIVE)])
80
+ self.model.add(self.model_vars[(down_pos, State.NEGATIVE)] == 0).OnlyEnforceIf(self.model_vars[(pos, State.NEGATIVE)])
81
+
82
+ # sides counts must equal actual count
83
+ for row_i in range(self.V):
84
+ sum_pos = lxp.sum([self.model_vars[(pos, State.POSITIVE)] for pos in get_row_pos(row_i, self.H)])
85
+ sum_neg = lxp.sum([self.model_vars[(pos, State.NEGATIVE)] for pos in get_row_pos(row_i, self.H)])
86
+ ground_pos = self.sides['pos_h'][row_i]
87
+ ground_neg = self.sides['neg_h'][row_i]
88
+ if ground_pos != -1:
89
+ self.model.Add(sum_pos == ground_pos)
90
+ if ground_neg != -1:
91
+ self.model.Add(sum_neg == ground_neg)
92
+ for col_i in range(self.H):
93
+ sum_pos = lxp.sum([self.model_vars[(pos, State.POSITIVE)] for pos in get_col_pos(col_i, self.V)])
94
+ sum_neg = lxp.sum([self.model_vars[(pos, State.NEGATIVE)] for pos in get_col_pos(col_i, self.V)])
95
+ ground_pos = self.sides['pos_v'][col_i]
96
+ ground_neg = self.sides['neg_v'][col_i]
97
+ if ground_pos != -1:
98
+ self.model.Add(sum_pos == ground_pos)
99
+ if ground_neg != -1:
100
+ self.model.Add(sum_neg == ground_neg)
101
+
102
+ def solve_and_print(self):
103
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
104
+ assignment: dict[Pos, str] = {}
105
+ for (pos, state), var in board.model_vars.items():
106
+ if solver.BooleanValue(var):
107
+ assignment[pos] = state.value[1]
108
+ return SingleSolution(assignment=assignment)
109
+ def callback(single_res: SingleSolution):
110
+ print("Solution found")
111
+ res = np.full((self.V, self.H), ' ', dtype=object)
112
+ for pos in get_all_pos(V=self.V, H=self.H):
113
+ c = get_char(self.board, pos)
114
+ c = single_res.assignment[pos]
115
+ set_char(res, pos, c)
116
+ print(res)
117
+ return generic_solve_all(self, board_to_solution, callback=callback)
@@ -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):
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)
@@ -0,0 +1,110 @@
1
+ import time
2
+ from typing import Union
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: int):
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
+ self.model.Add(lxp.Sum(list(self.model_vars.values())) == self.mine_count)
32
+ for pos in get_all_pos(self.V, self.H):
33
+ c = get_char(self.board, pos)
34
+ if c in ['F', ' ']:
35
+ continue
36
+ if c == 'S': # safe position but neighbours are unknown
37
+ self.model.Add(self.model_vars[pos] == 0)
38
+ continue
39
+ if c == 'M': # mine position but neighbours are unknown
40
+ self.model.Add(self.model_vars[pos] == 1)
41
+ continue
42
+ # clue indicates safe position AND neighbours are known
43
+ c = int(c)
44
+ self.model.Add(lxp.Sum([self.model_vars[n] for n in get_neighbors8(pos, self.V, self.H, include_self=False)]) == c)
45
+ self.model.Add(self.model_vars[pos] == 0)
46
+
47
+
48
+ def _is_feasible(board: np.array, pos: Pos = None, value: str = None, mine_count: int = None) -> bool:
49
+ """Returns True if the board is feasible after setting the value at the position"""
50
+ board = board.copy()
51
+ if pos is not None and value is not None:
52
+ set_char(board, pos, str(value))
53
+ board = Board(board, mine_count=mine_count)
54
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
55
+ return SingleSolution(assignment={pos: solver.value(var) for pos, var in board.model_vars.items()})
56
+ return len(generic_solve_all(board, board_to_solution, max_solutions=1, verbose=False)) >= 1
57
+
58
+ def _is_safe(board: np.array, pos: Pos, mine_count: int) -> Union[bool, None]:
59
+ """Returns a True if the position is safe, False if it is a mine, otherwise None"""
60
+ safe_feasible = _is_feasible(board, pos, 'S', mine_count=mine_count)
61
+ mine_feasible = _is_feasible(board, pos, 'M', mine_count=mine_count)
62
+ if safe_feasible and mine_feasible:
63
+ return None
64
+ if safe_feasible:
65
+ return True
66
+ if mine_feasible:
67
+ return False
68
+ raise ValueError(f"Position {pos} has both safe and mine infeasible")
69
+
70
+ def give_next_guess(board: np.array, mine_count: int):
71
+ tic = time.time()
72
+ is_feasible = _is_feasible(board, mine_count=mine_count)
73
+ if not is_feasible:
74
+ raise ValueError("Board is not feasible")
75
+ V = board.shape[0]
76
+ H = board.shape[1]
77
+ check_positions = set() # any position that is unknown and has a neighbour with a clue or flag
78
+ flag_positions = set()
79
+ for pos in get_all_pos(V, H):
80
+ neighbours8 = get_neighbors8(pos, V, H, include_self=False)
81
+ if get_char(board, pos) not in [' ', 'F']:
82
+ continue
83
+ if get_char(board, pos) == 'F' or any(get_char(board, n) != ' ' for n in neighbours8):
84
+ check_positions.add(pos)
85
+ if get_char(board, pos) == 'F':
86
+ flag_positions.add(pos)
87
+ pos_dict = {pos: _is_safe(board, pos, mine_count) for pos in check_positions}
88
+ safe_positions = {pos for pos, is_safe in pos_dict.items() if is_safe is True}
89
+ mine_positions = {pos for pos, is_safe in pos_dict.items() if is_safe is False}
90
+ new_garuneed_mine_positions = mine_positions - flag_positions
91
+ wrong_flag_positions = flag_positions - mine_positions
92
+ if len(safe_positions) > 0:
93
+ print(f"Found {len(safe_positions)} new guaranteed safe positions")
94
+ print(safe_positions)
95
+ print('-'*10)
96
+ if len(mine_positions) == 0:
97
+ print("No guaranteed mine positions")
98
+ print('-'*10)
99
+ if len(new_garuneed_mine_positions) > 0:
100
+ print(f"Found {len(new_garuneed_mine_positions)} new guaranteed mine positions")
101
+ print(new_garuneed_mine_positions)
102
+ print('-'*10)
103
+ if len(wrong_flag_positions) > 0:
104
+ print(f"WARNING | "*4 + "WARNING")
105
+ print(f"Found {len(wrong_flag_positions)} wrong flag positions")
106
+ print(wrong_flag_positions)
107
+ print('-'*10)
108
+ toc = time.time()
109
+ print(f"Time taken: {toc - tic:.2f} seconds")
110
+ return safe_positions, new_garuneed_mine_positions, wrong_flag_positions
@@ -0,0 +1,48 @@
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, set_char, get_char, get_neighbors8
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ 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
+ assert all((c.item() == '*') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only * or digits'
14
+ self.board = board
15
+ self.N = board.shape[0]
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
+
19
+ self.create_vars()
20
+ self.add_all_constraints()
21
+
22
+ def create_vars(self):
23
+ for pos in get_all_pos(self.N):
24
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
25
+
26
+ def add_all_constraints(self):
27
+ for pos in get_all_pos(self.N):
28
+ c = get_char(self.board, pos)
29
+ if not str(c).isdecimal():
30
+ 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))
33
+
34
+ def solve_and_print(self):
35
+ 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)
40
+ def callback(single_res: SingleSolution):
41
+ 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)
48
+ return generic_solve_all(self, board_to_solution, callback=callback)
@@ -0,0 +1,126 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_row_pos, get_col_pos
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+
7
+
8
+ class Board:
9
+ def __init__(self, top: list[list[int]], side: list[list[int]]):
10
+ assert all(isinstance(i, int) for l in top for i in l), 'top must be a list of lists of integers'
11
+ assert all(isinstance(i, int) for l in side for i in l), 'side must be a list of lists of integers'
12
+ self.top = top
13
+ self.side = side
14
+ self.V = len(side)
15
+ self.H = len(top)
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
+ self.extra_vars = {}
19
+
20
+ self.create_vars()
21
+ self.add_all_constraints()
22
+
23
+ def create_vars(self):
24
+ for pos in get_all_pos(self.V, self.H):
25
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
26
+
27
+ def add_all_constraints(self):
28
+ for i in range(self.V):
29
+ ground_sequence = self.side[i]
30
+ if ground_sequence == -1:
31
+ continue
32
+ 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}')
34
+ for i in range(self.H):
35
+ ground_sequence = self.top[i]
36
+ if ground_sequence == -1:
37
+ continue
38
+ 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, c in enumerate(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])
112
+
113
+ def solve_and_print(self):
114
+ 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)
119
+ def callback(single_res: SingleSolution):
120
+ 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)
126
+ return generic_solve_all(self, board_to_solution, callback=callback)