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.
- multi_puzzle_solver-0.1.0.dist-info/METADATA +1897 -0
- multi_puzzle_solver-0.1.0.dist-info/RECORD +31 -0
- multi_puzzle_solver-0.1.0.dist-info/WHEEL +5 -0
- multi_puzzle_solver-0.1.0.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +26 -0
- puzzle_solver/core/utils.py +127 -0
- puzzle_solver/core/utils_ortools.py +78 -0
- puzzle_solver/puzzles/bridges/bridges.py +106 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +136 -0
- puzzle_solver/puzzles/filling/filling.py +192 -0
- puzzle_solver/puzzles/guess/guess.py +231 -0
- puzzle_solver/puzzles/inertia/inertia.py +122 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +204 -0
- puzzle_solver/puzzles/inertia/tsp.py +398 -0
- puzzle_solver/puzzles/keen/keen.py +99 -0
- puzzle_solver/puzzles/light_up/light_up.py +95 -0
- puzzle_solver/puzzles/magnets/magnets.py +117 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +110 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +48 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +126 -0
- puzzle_solver/puzzles/pearl/pearl.py +151 -0
- puzzle_solver/puzzles/range/range.py +154 -0
- puzzle_solver/puzzles/signpost/signpost.py +95 -0
- puzzle_solver/puzzles/singles/singles.py +116 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +90 -0
- puzzle_solver/puzzles/tents/tents.py +110 -0
- puzzle_solver/puzzles/towers/towers.py +139 -0
- puzzle_solver/puzzles/tracks/tracks.py +170 -0
- puzzle_solver/puzzles/undead/undead.py +168 -0
- 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)
|