multi-puzzle-solver 0.9.13__py3-none-any.whl → 0.9.14__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.

@@ -0,0 +1,313 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional, Union
3
+ import json
4
+
5
+ import numpy as np
6
+ from ortools.sat.python import cp_model
7
+
8
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_row_pos, get_col_pos, in_bounds, get_opposite_direction, get_next_pos, Direction
9
+ from puzzle_solver.core.utils_ortools import generic_solve_all
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SingleSolution:
14
+ assignment: dict[Pos, Union[str, int]]
15
+ beam_assignments: dict[Pos, list[tuple[int, Pos, Direction]]]
16
+
17
+ def get_hashable_solution(self) -> str:
18
+ result = []
19
+ for pos, v in self.assignment.items():
20
+ result.append((pos.x, pos.y, v))
21
+ return json.dumps(result, sort_keys=True)
22
+
23
+
24
+ class Board:
25
+ def __init__(self, top: list, right: list, bottom: list, left: list, ball_count: Optional[tuple[int, int]] = None, max_travel_steps: Optional[int] = None):
26
+ assert len(top) == len(bottom), 'top and bottom must be the same length'
27
+ assert len(left) == len(right), 'left and right must be the same length'
28
+ self.K = len(top) + len(right) + len(bottom) + len(left) # side count
29
+ self.H = len(top)
30
+ self.V = len(left)
31
+ if max_travel_steps is None:
32
+ self.T = self.V * self.H # maximum travel steps for a beam that bounces an undefined number of times
33
+ else:
34
+ self.T = max_travel_steps
35
+ self.ball_count = ball_count
36
+
37
+ # top and bottom entry cells are at -1 and V
38
+ self.top_cells = set(get_row_pos(row_idx=-1, H=self.H))
39
+ self.bottom_cells = set(get_row_pos(row_idx=self.V, H=self.H))
40
+ # left and right entry cells are at -1 and H
41
+ self.left_cells = set(get_col_pos(col_idx=-1, V=self.V))
42
+ self.right_cells = set(get_col_pos(col_idx=self.H, V=self.V))
43
+ # self.top_cells = set([Pos(x=1, y=-1)])
44
+ # self.bottom_cells = set()
45
+ # self.left_cells = set()
46
+ # self.right_cells = set()
47
+ # print(self.top_cells)
48
+
49
+ self.top_values = top
50
+ self.right_values = right
51
+ self.bottom_values = bottom
52
+ self.left_values = left
53
+
54
+ self.model = cp_model.CpModel()
55
+ self.ball_states: dict[Pos, cp_model.IntVar] = {}
56
+ # (entry_pos, T, cell_pos, direction) -> True if the beam that entered from the board at "entry_pos" is present in "cell_pos" and is going in the direction "direction" at time T
57
+ self.beam_states: dict[tuple[Pos, int, Pos, Direction], cp_model.IntVar] = {}
58
+ # self.beam_states_ending_at: dict[Pos, cp_model.IntVar] = {}
59
+ self.beam_states_at_t: dict[int, dict[Pos, dict[tuple[Pos, Direction], cp_model.IntVar]]] = {}
60
+ self.create_vars()
61
+ print('Total number of variables:', len(self.ball_states), len(self.beam_states))
62
+ self.add_all_constraints()
63
+ print('Solving...')
64
+
65
+ def get_outside_border(self):
66
+ top_border = tuple(get_row_pos(row_idx=-1, H=self.H))
67
+ bottom_border = tuple(get_row_pos(row_idx=self.V, H=self.H))
68
+ left_border = tuple(get_col_pos(col_idx=-1, V=self.V))
69
+ right_border = tuple(get_col_pos(col_idx=self.H, V=self.V))
70
+ return (*top_border, *bottom_border, *left_border, *right_border)
71
+
72
+ def get_all_pos_extended(self):
73
+ return (*self.get_outside_border(), *get_all_pos(self.V, self.H))
74
+
75
+ def create_vars(self):
76
+ for pos in get_all_pos(self.V, self.H):
77
+ self.ball_states[pos] = self.model.NewBoolVar(f'ball_at:{pos}')
78
+ for pos in self.get_all_pos_extended(): # NxN board + 4 edges
79
+ if pos not in self.ball_states: # pos is not in the board -> its on the edge
80
+ self.ball_states[pos] = None # balls cant be on the edge
81
+
82
+ for entry_pos in (self.top_cells | self.right_cells | self.bottom_cells | self.left_cells):
83
+
84
+ for t in range(self.T):
85
+ self.beam_states[(entry_pos, t, 'HIT', 'HIT')] = self.model.NewBoolVar(f'beam:{entry_pos}:{t}:HIT:HIT')
86
+ for cell in self.get_all_pos_extended():
87
+ for direction in Direction:
88
+ self.beam_states[(entry_pos, t, cell, direction)] = self.model.NewBoolVar(f'beam:{entry_pos}:{t}:{cell}:{direction}')
89
+
90
+ for (entry_pos, t, cell, direction) in self.beam_states.keys():
91
+ if t not in self.beam_states_at_t:
92
+ self.beam_states_at_t[t] = {}
93
+ if entry_pos not in self.beam_states_at_t[t]:
94
+ self.beam_states_at_t[t][entry_pos] = {}
95
+ self.beam_states_at_t[t][entry_pos][(cell, direction)] = self.beam_states[(entry_pos, t, cell, direction)]
96
+
97
+ def add_all_constraints(self):
98
+ self.init_beams()
99
+ self.constrain_beam_movement()
100
+ self.constrain_final_beam_states()
101
+ if self.ball_count is not None:
102
+ s = sum([b for b in self.ball_states.values() if b is not None])
103
+ b_min, b_max = self.ball_count
104
+ self.model.Add(s >= b_min)
105
+ self.model.Add(s <= b_max)
106
+
107
+ def init_beams(self):
108
+ beam_ids = []
109
+ beam_ids.extend((beam_id, Direction.DOWN) for beam_id in self.top_cells)
110
+ beam_ids.extend((beam_id, Direction.LEFT) for beam_id in self.right_cells)
111
+ beam_ids.extend((beam_id, Direction.UP) for beam_id in self.bottom_cells)
112
+ beam_ids.extend((beam_id, Direction.RIGHT) for beam_id in self.left_cells)
113
+
114
+ for (beam_id, direction) in beam_ids:
115
+ # beam at t=0 is present at beam_id and facing direction
116
+ self.model.Add(self.beam_states[(beam_id, 0, beam_id, direction)] == 1)
117
+ for p in self.get_all_pos_extended():
118
+ # print(f'beam can be at {p}')
119
+ for direction in Direction:
120
+ if (p, direction) != (beam_id, direction):
121
+ self.model.Add(self.beam_states[(beam_id, 0, p, direction)] == 0)
122
+ # for p in self.get_all_pos_extended():
123
+ # print(f'beam can be at {p}')
124
+
125
+
126
+ def constrain_beam_movement(self):
127
+ for t in range(self.T):
128
+ for entry_pos in self.beam_states_at_t[t].keys():
129
+ next_state_dict = self.beam_states_at_t[t][entry_pos]
130
+ self.model.AddExactlyOne(list(next_state_dict.values()))
131
+ # print('add exactly one constraint for beam id', entry_pos, 'at time', t, next_state_dict.keys(), '\n')
132
+ if t == self.T - 1:
133
+ continue
134
+ for (cell, direction), prev_state in next_state_dict.items():
135
+ # print(f'for beam id {entry_pos}, time {t}\nif its at {cell} facing {direction}')
136
+ self.constrain_next_beam_state(entry_pos, t+1, cell, direction, prev_state)
137
+
138
+
139
+ def constrain_next_beam_state(self, entry_pos: Pos, t: int, cur_pos: Pos, direction: Direction, prev_state: cp_model.IntVar):
140
+ # print(f"constraining next beam state for {entry_pos}, {t}, {cur_pos}, {direction}")
141
+ if cur_pos == "HIT": # a beam that was "HIT" stays "HIT"
142
+ # print(f' HIT -> stays HIT')
143
+ self.model.Add(self.beam_states[(entry_pos, t, "HIT", "HIT")] == 1).OnlyEnforceIf(prev_state)
144
+ return
145
+ # if a beam is outside the board AND it is not facing the board -> it maintains its state
146
+ pos_ahead = get_next_pos(cur_pos, direction)
147
+ if not in_bounds(pos_ahead, self.V, self.H) and not in_bounds(cur_pos, self.V, self.H):
148
+ # print(f' OUTSIDE BOARD -> beam stays in the same state')
149
+ self.model.Add(self.beam_states[(entry_pos, t, cur_pos, direction)] == 1).OnlyEnforceIf(prev_state)
150
+ return
151
+
152
+ # look at the 3 balls ahead of the beam: thus 8 possible scenarios
153
+ # A beam with no balls ahead of it -> moves forward in the same direction (1 scenario)
154
+ # A beam that hits a ball head-on -> beam is "HIT" (4 scenarios)
155
+ # A beam with a ball in its front-left square and no ball ahead of it -> gets deflected 90 degrees to the right (1 scenario)
156
+ # A beam with a ball in its front-right square and no ball ahead of it -> gets similarly deflected to the left (1 scenario)
157
+ # A beam that would in its front-left AND front-right squares -> is reflected (1 scenarios)
158
+
159
+ direction_left = {
160
+ Direction.UP: Direction.LEFT,
161
+ Direction.LEFT: Direction.DOWN,
162
+ Direction.DOWN: Direction.RIGHT,
163
+ Direction.RIGHT: Direction.UP,
164
+ }[direction]
165
+ direction_right = {
166
+ Direction.UP: Direction.RIGHT,
167
+ Direction.RIGHT: Direction.DOWN,
168
+ Direction.DOWN: Direction.LEFT,
169
+ Direction.LEFT: Direction.UP,
170
+ }[direction]
171
+ reflected = get_opposite_direction(direction)
172
+ ball_left_pos = get_next_pos(pos_ahead, direction_left)
173
+ ball_right_pos = get_next_pos(pos_ahead, direction_right)
174
+ if in_bounds(pos_ahead, self.V, self.H):
175
+ ball_ahead = self.ball_states[pos_ahead]
176
+ ball_ahead_not = ball_ahead.Not()
177
+ else:
178
+ ball_ahead = False
179
+ ball_ahead_not = True
180
+ if in_bounds(ball_left_pos, self.V, self.H):
181
+ ball_left = self.ball_states[ball_left_pos]
182
+ ball_left_not = ball_left.Not()
183
+ else:
184
+ ball_left = False
185
+ ball_left_not = True
186
+ if in_bounds(ball_right_pos, self.V, self.H):
187
+ ball_right = self.ball_states[ball_right_pos]
188
+ ball_right_not = ball_right.Not()
189
+ else:
190
+ ball_right = False
191
+ ball_right_not = True
192
+
193
+ pos_left = get_next_pos(cur_pos, direction_left)
194
+ pos_right = get_next_pos(cur_pos, direction_right)
195
+ pos_reflected = get_next_pos(cur_pos, reflected)
196
+ if not in_bounds(pos_left, self.V, self.H):
197
+ pos_left = cur_pos
198
+ if not in_bounds(pos_right, self.V, self.H):
199
+ pos_right = cur_pos
200
+ if not in_bounds(pos_reflected, self.V, self.H):
201
+ pos_reflected = cur_pos
202
+
203
+ # debug_states = {
204
+ # 'if ball head': (entry_pos, t, "HIT", "HIT"),
205
+ # 'if ball in front-left': (entry_pos, t, get_next_pos(cur_pos, direction_right), direction_right),
206
+ # 'if ball in front-right': (entry_pos, t, get_next_pos(cur_pos, direction_left), direction_left),
207
+ # 'if ball in front-left and front-right': (entry_pos, t, get_next_pos(cur_pos, reflected), reflected),
208
+ # 'if no ball ahead': (entry_pos, t, pos_ahead, direction),
209
+ # }
210
+ # for k,v in debug_states.items():
211
+ # print(f' {k} -> {v}')
212
+
213
+ # ball head-on -> beam is "HIT"
214
+ self.model.Add(self.beam_states[(entry_pos, t, "HIT", "HIT")] == 1).OnlyEnforceIf([
215
+ ball_ahead,
216
+ prev_state,
217
+ ])
218
+ # ball in front-left -> beam is deflected right
219
+ self.model.Add(self.beam_states[(entry_pos, t, pos_right, direction_right)] == 1).OnlyEnforceIf([
220
+ ball_ahead_not,
221
+ ball_left,
222
+ ball_right_not,
223
+ prev_state,
224
+ ])
225
+ # ball in front-right -> beam is deflected left
226
+ self.model.Add(self.beam_states[(entry_pos, t, pos_left, direction_left)] == 1).OnlyEnforceIf([
227
+ ball_ahead_not,
228
+ ball_left_not,
229
+ ball_right,
230
+ prev_state,
231
+ ])
232
+ # ball in front-left and front-right -> beam is reflected
233
+ self.model.Add(self.beam_states[(entry_pos, t, pos_reflected, reflected)] == 1).OnlyEnforceIf([
234
+ ball_ahead_not,
235
+ ball_left,
236
+ ball_right,
237
+ prev_state,
238
+ ])
239
+ # no ball ahead -> beam moves forward in the same direction
240
+ self.model.Add(self.beam_states[(entry_pos, t, pos_ahead, direction)] == 1).OnlyEnforceIf([
241
+ ball_ahead_not,
242
+ ball_left_not,
243
+ ball_right_not,
244
+ prev_state,
245
+ ])
246
+
247
+ def constrain_final_beam_states(self):
248
+ all_values = []
249
+ all_values.extend([(Pos(x=c, y=-1), top_value) for c, top_value in enumerate(self.top_values)])
250
+ all_values.extend([(Pos(x=self.H, y=c), right_value) for c, right_value in enumerate(self.right_values)])
251
+ all_values.extend([(Pos(x=c, y=self.V), bottom_value) for c, bottom_value in enumerate(self.bottom_values)])
252
+ all_values.extend([(Pos(x=-1, y=c), left_value) for c, left_value in enumerate(self.left_values)])
253
+ digits = {}
254
+ hits = []
255
+ reflects = []
256
+ for pos, value in all_values:
257
+ value = str(value)
258
+ if value.isdecimal():
259
+ digits.setdefault(value, []).append(pos)
260
+ elif value == 'H':
261
+ hits.append(pos)
262
+ elif value == 'R':
263
+ reflects.append(pos)
264
+ else:
265
+ raise ValueError(f'Invalid value: {value}')
266
+ for digit, pos_list in digits.items():
267
+ assert len(pos_list) == 2, f'digit {digit} has {len(pos_list)} positions: {pos_list}'
268
+ p1, p2 = pos_list
269
+ self.model.AddExactlyOne([self.beam_states[(p1, self.T-1, p2, direction)] for direction in Direction])
270
+ self.model.AddExactlyOne([self.beam_states[(p2, self.T-1, p1, direction)] for direction in Direction])
271
+ for hit in hits:
272
+ self.model.AddExactlyOne([self.beam_states[(hit, self.T-1, 'HIT', 'HIT')]])
273
+ for reflect in reflects:
274
+ self.model.AddExactlyOne([self.beam_states[(reflect, self.T-1, reflect, direction)] for direction in Direction])
275
+
276
+
277
+ def solve_and_print(self, verbose: bool = True):
278
+ count = 0
279
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
280
+ nonlocal count
281
+ count += 1
282
+ # if count > 99:
283
+ # import sys
284
+ # sys.exit(0)
285
+ assignment = {}
286
+ for pos in get_all_pos(self.V, self.H):
287
+ assignment[pos] = solver.value(self.ball_states[pos])
288
+ beam_assignments = {}
289
+ # for (entry_pos, t, cell, direction), v in self.beam_states.items():
290
+ # if entry_pos not in beam_assignments:
291
+ # beam_assignments[entry_pos] = []
292
+ # if solver.value(v): # for every beam it can only be present in one state at a time
293
+ # beam_assignments[entry_pos].append((t, cell, direction))
294
+ # for k,v in beam_assignments.items():
295
+ # beam_assignments[k] = sorted(v, key=lambda x: x[0])
296
+ # print(k, beam_assignments[k], '\n')
297
+ # print(beam_assignments)
298
+ return SingleSolution(assignment=assignment, beam_assignments=beam_assignments)
299
+
300
+ def callback(single_res: SingleSolution):
301
+ print("Solution found")
302
+ res = np.full((self.V, self.H), ' ', dtype=object)
303
+ for pos in get_all_pos(self.V, self.H):
304
+ ball_state = 'O' if single_res.assignment[pos] else ' '
305
+ res[pos.y][pos.x] = ball_state
306
+ print(res)
307
+ r = generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
308
+ # print('non unique count:', count)
309
+
310
+
311
+
312
+
313
+
@@ -1,192 +1,117 @@
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, get_char, set_char, get_neighbors4, get_all_pos_to_idx_dict
5
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
6
-
7
-
8
- class Board:
9
- """
10
- Per cell p:
11
- val[p] ∈ {1..9} (respect givens)
12
- region[p] ∈ {0..N-1} # region id is the linear index of the region's root
13
- is_root[p] <=> (region[p] == idx[p])
14
- # NOTE: root is the minimum index among its members via region[p] ≤ idx[p]
15
-
16
- Per (p, k) where k is a root index (0..N-1):
17
- in_region[p,k] <=> (region[p] == k)
18
- dist[p,k] ∈ {0..INF}
19
- - If in_region[p,k] = 0 ⇒ dist[p,k] = INF
20
- - If p == pos_of(k) and is_root[pos_of(k)] = 1 ⇒ dist[p,k] = 0
21
- - If in_region[p,k] = 1 and p != pos_of(k) ⇒
22
- dist[p,k] = 1 + min_n masked_dist[n,k]
23
- where masked_dist[n,k] = dist[n,k] + 1 if in_region[n,k] else INF
24
-
25
- Edge (u,v):
26
- same-digit neighbors must be in same region.
27
-
28
- Region sizes:
29
- For each k: size[k] == #{p : region[p] == k}
30
- If is_root[pos_of(k)] → size[k] == val[pos_of(k)]
31
- Else size[k] == 0
32
- """
33
-
34
- def __init__(self, board: np.ndarray):
35
- assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
36
- self.board = board
37
- self.V, self.H = board.shape
38
- assert all((c == ' ') or (str(c).isdecimal() and 0 <= int(c) <= 9)
39
- for c in np.nditer(board)), "board must contain space or digits 0..9"
40
-
41
- self.N = self.V * self.H
42
- self.INF = self.N + 1 # a safe "infinity" upper bound for distances
43
-
44
- # Linear index maps (keyed by Pos; do NOT construct tuples)
45
- self.idx_of: dict[Pos, int] = get_all_pos_to_idx_dict(self.V, self.H)
46
- self.pos_of: list[Pos] = [None] * self.N
47
- for pos, idx in self.idx_of.items():
48
- self.pos_of[idx] = pos
49
-
50
- m = self.model = cp_model.CpModel()
51
-
52
- # Variables
53
- self.val = {} # val[p]
54
- self.region = {} # region[p]
55
- self.same_region = {} # same_region[(p,q)]
56
- self.is_root = {} # is_root[p]
57
- self.is_val = {} # is_val[(p,k)] (k=1..9)
58
- self.in_region = {} # in_region[(p,k)] (k = 0..N-1)
59
- self.dist = {} # dist[(p,k)] [0..INF]
60
-
61
- # Per-cell vars and givens
62
- for p in get_all_pos(self.V, self.H):
63
- idx = self.idx_of[p]
64
-
65
- v = m.NewIntVar(1, 9, f'val[{idx}]')
66
- ch = get_char(self.board, p)
67
- if str(ch).isdecimal():
68
- m.Add(v == int(ch))
69
- self.val[p] = v
70
-
71
- r = m.NewIntVar(0, self.N - 1, f'region[{idx}]')
72
- self.region[p] = r
73
-
74
- b = m.NewBoolVar(f'is_root[{idx}]')
75
- self.is_root[p] = b
76
- m.Add(r == idx).OnlyEnforceIf(b)
77
- m.Add(r != idx).OnlyEnforceIf(b.Not())
78
-
79
- # is_val indicators (for same-digit merge)
80
- for k in range(1, 10):
81
- bv = m.NewBoolVar(f'is_val[{idx}=={k}]')
82
- self.is_val[(p, k)] = bv
83
- m.Add(self.val[p] == k).OnlyEnforceIf(bv)
84
- m.Add(self.val[p] != k).OnlyEnforceIf(bv.Not())
85
-
86
- # Root = minimum index among members
87
- for p in get_all_pos(self.V, self.H):
88
- m.Add(self.region[p] <= self.idx_of[p])
89
-
90
- # Membership indicators in_region[p,k] <=> region[p] == k
91
- for k in range(self.N):
92
- for p in get_all_pos(self.V, self.H):
93
- bmem = m.NewBoolVar(f'in_region[{self.idx_of[p]}=={k}]')
94
- self.in_region[(p, k)] = bmem
95
- m.Add(self.region[p] == k).OnlyEnforceIf(bmem)
96
- m.Add(self.region[p] != k).OnlyEnforceIf(bmem.Not())
97
-
98
- # same-digit neighbors must be in the same region
99
- for u in get_all_pos(self.V, self.H):
100
- for v in get_neighbors4(u, self.V, self.H):
101
- if self.idx_of[v] < self.idx_of[u]:
102
- continue # undirected pair once
103
- # If val[u]==k and val[v]==k for any k in 1..9, then region[u]==region[v]
104
- # Implement as: for each k, (is_val[u,k] & is_val[v,k]) ⇒ (region[u]==region[v])
105
- for k in range(1, 10):
106
- m.Add(self.region[u] == self.region[v])\
107
- .OnlyEnforceIf([self.is_val[(u, k)], self.is_val[(v, k)]])
108
-
109
- for u in get_all_pos(self.V, self.H):
110
- for v in get_neighbors4(u, self.V, self.H):
111
- b = self.model.NewBoolVar(f'same_region[{self.idx_of[u]},{self.idx_of[v]}]')
112
- self.same_region[(u, v)] = b
113
- self.model.Add(self.region[u] == self.region[v]).OnlyEnforceIf(b)
114
- self.model.Add(self.region[u] != self.region[v]).OnlyEnforceIf(b.Not())
115
- self.model.Add(self.val[u] == self.val[v]).OnlyEnforceIf(b)
116
-
117
- # Distance variables dist[p,k] and masked AddMinEquality
118
- for k in range(self.N):
119
- root_pos = self.pos_of[k]
120
-
121
- for p in get_all_pos(self.V, self.H):
122
- dp = m.NewIntVar(0, self.INF, f'dist[{self.idx_of[p]},{k}]')
123
- self.dist[(p, k)] = dp
124
-
125
- # If p not in region k -> dist = INF
126
- m.Add(dp == self.INF).OnlyEnforceIf(self.in_region[(p, k)].Not())
127
-
128
- # Root distance: if k is active at its own position -> dist[root,k] = 0
129
- if p == root_pos:
130
- m.Add(dp == 0).OnlyEnforceIf(self.is_root[root_pos])
131
- # If root_pos isn't the root for k, membership is 0 and above rule sets INF.
132
-
133
- # For non-root members p of region k: dist[p,k] = 1 + min masked neighbor distances
134
- for p in get_all_pos(self.V, self.H):
135
- if p == root_pos:
136
- continue # handled above
137
-
138
- # Build masked neighbor candidates: INF if neighbor not in region k; else dist[n,k] + 1
139
- cand_vars = []
140
- for n in get_neighbors4(p, self.V, self.H):
141
- cn = m.NewIntVar(0, self.INF, f'canddist[{self.idx_of[p]},{k}->{self.idx_of[n]}]')
142
- cand_vars.append(cn)
143
-
144
- both_in_region_k = m.NewBoolVar(f'both_in_region_k[{self.idx_of[p]} in {k} and {self.idx_of[n]} in {k}]')
145
- and_constraint(m, both_in_region_k, [self.in_region[(p, k)], self.in_region[(n, k)]])
146
-
147
- # Reified equality:
148
- # in_region[n,k] => cn == dist[n,k] + 1
149
- m.Add(cn == self.dist[(n, k)] + 1).OnlyEnforceIf(both_in_region_k)
150
- # not in_region[n,k] => cn == INF
151
- m.Add(cn == self.INF).OnlyEnforceIf(both_in_region_k.Not())
152
-
153
- # Only enforce the min equation when p is actually in region k (and not the root position).
154
- # If p ∉ region k, dp is already INF via the earlier rule.
155
- if cand_vars:
156
- m.AddMinEquality(self.dist[(p, k)], cand_vars)
157
- for p in get_all_pos(self.V, self.H):
158
- # every cell must have 1 dist != INF (lets just do at least 1 dist != INF)
159
- not_infs = []
160
- for k in range(self.N):
161
- not_inf = m.NewBoolVar(f'not_inf[{self.idx_of[p]},{k}]')
162
- m.Add(self.dist[(p, k)] != self.INF).OnlyEnforceIf(not_inf)
163
- m.Add(self.dist[(p, k)] == self.INF).OnlyEnforceIf(not_inf.Not())
164
- not_infs.append(not_inf)
165
- m.AddBoolOr(not_infs)
166
-
167
- # Region sizes
168
- for k in range(self.N):
169
- root_pos = self.pos_of[k]
170
- members = [self.in_region[(p, k)] for p in get_all_pos(self.V, self.H)]
171
- size_k = m.NewIntVar(0, self.N, f'size[{k}]')
172
- m.Add(size_k == sum(members))
173
- # Active root -> size equals its value
174
- m.Add(size_k == self.val[root_pos]).OnlyEnforceIf(self.is_root[root_pos])
175
- # Inactive root id -> size 0
176
- m.Add(size_k == 0).OnlyEnforceIf(self.is_root[root_pos].Not())
177
-
178
- def solve_and_print(self, verbose: bool = True):
179
- def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
180
- assignment: dict[Pos, int] = {}
181
- for p in get_all_pos(board.V, board.H):
182
- assignment[p] = solver.Value(board.val[p])
183
- return SingleSolution(assignment=assignment)
184
- def callback(single_res: SingleSolution):
185
- print("Solution found")
186
- res = np.full((self.V, self.H), ' ', dtype=object)
187
- for pos in get_all_pos(self.V, self.H):
188
- c = get_char(self.board, pos)
189
- c = single_res.assignment[pos]
190
- set_char(res, pos, c)
191
- print(res)
192
- return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
1
+ from dataclasses import dataclass
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, polyominoes, in_bounds, get_next_pos, Direction
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
8
+
9
+
10
+ @dataclass
11
+ class ShapeOnBoard:
12
+ is_active: cp_model.IntVar
13
+ N: int
14
+ body: set[Pos]
15
+ disallow_same_shape: set[Pos]
16
+
17
+
18
+ class Board:
19
+ def __init__(self, board: np.ndarray, digits = (1, 2, 3, 4, 5, 6, 7, 8, 9)):
20
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
21
+ self.board = board
22
+ self.V, self.H = board.shape
23
+ assert all((c == ' ') or (str(c).isdecimal() and 0 <= int(c) <= 9) for c in np.nditer(board)), "board must contain space or digits 0..9"
24
+ self.digits = digits
25
+ self.polyominoes = {d: polyominoes(d) for d in self.digits}
26
+ # len_shapes = sum(len(shapes) for shapes in self.polyominoes.values())
27
+ # print(f'total number of shapes: {len_shapes}')
28
+
29
+ self.model = cp_model.CpModel()
30
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
31
+ self.digit_to_shapes = {d: [] for d in self.digits}
32
+ self.body_loc_to_shape = {(d,p): [] for d in self.digits for p in get_all_pos(self.V, self.H)}
33
+ self.forced_pos: dict[Pos, int] = {}
34
+
35
+ self.create_vars()
36
+ self.constrain_numbers_on_board()
37
+ self.init_polyominoes_on_board()
38
+ self.add_all_constraints()
39
+
40
+ def create_vars(self):
41
+ for pos in get_all_pos(self.V, self.H):
42
+ for d in self.digits:
43
+ self.model_vars[(d,pos)] = self.model.NewBoolVar(f'{d}:{pos}')
44
+
45
+ def constrain_numbers_on_board(self):
46
+ # force numbers already on the board
47
+ for pos in get_all_pos(self.V, self.H):
48
+ c = get_char(self.board, pos)
49
+ if c.isdecimal():
50
+ self.model.Add(self.model_vars[(int(c),pos)] == 1)
51
+ self.forced_pos[pos] = int(c)
52
+
53
+ def init_polyominoes_on_board(self):
54
+ # total_count = 0
55
+ for d in self.digits: # all digits
56
+ digit_count = 0
57
+ for pos in get_all_pos(self.V, self.H): # translate by shape
58
+ for shape in self.polyominoes[d]: # all shapes of d digits
59
+ body = {pos + p for p in shape}
60
+ if any(not in_bounds(p, self.V, self.H) for p in body):
61
+ continue
62
+ if any(p in self.forced_pos and self.forced_pos[p] != d for p in body): # part of this shape's body is already forced to a different digit, skip
63
+ continue
64
+ disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
65
+ disallow_same_shape = {p for p in disallow_same_shape if p not in body and in_bounds(p, self.V, self.H)}
66
+ shape_on_board = ShapeOnBoard(
67
+ is_active=self.model.NewBoolVar(f'd{d}:{digit_count}:{pos}:is_active'),
68
+ N=d,
69
+ body=body,
70
+ disallow_same_shape=disallow_same_shape,
71
+ )
72
+ self.digit_to_shapes[d].append(shape_on_board)
73
+ for p in body:
74
+ self.body_loc_to_shape[(d,p)].append(shape_on_board)
75
+ digit_count += 1
76
+ # total_count += 1
77
+ # if total_count % 1000 == 0:
78
+ # print(f'{total_count} shapes on board')
79
+ # print(f'total number of shapes on board: {total_count}')
80
+
81
+ def add_all_constraints(self):
82
+ for pos in get_all_pos(self.V, self.H):
83
+ # exactly one digit is active at every position
84
+ self.model.AddExactlyOne(self.model_vars[(d,pos)] for d in self.digits)
85
+ # exactly one shape is active at that position
86
+ self.model.AddExactlyOne(s.is_active for d in self.digits for s in self.body_loc_to_shape[(d,pos)])
87
+ # if a shape is active then all its body is active
88
+
89
+ for s_list in self.body_loc_to_shape.values():
90
+ for s in s_list:
91
+ for p in s.body:
92
+ self.model.Add(self.model_vars[(s.N,p)] == 1).OnlyEnforceIf(s.is_active)
93
+
94
+ # same shape cannot touch
95
+ for d, s_list in self.digit_to_shapes.items():
96
+ for s in s_list:
97
+ for disallow_pos in s.disallow_same_shape:
98
+ self.model.Add(self.model_vars[(d,disallow_pos)] == 0).OnlyEnforceIf(s.is_active)
99
+
100
+ def solve_and_print(self, verbose: bool = True):
101
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
102
+ assignment: dict[Pos, int] = {}
103
+ for pos in get_all_pos(self.V, self.H):
104
+ for d in self.digits:
105
+ if solver.Value(self.model_vars[(d,pos)]) == 1:
106
+ assignment[pos] = d
107
+ break
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(self.V, 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 if verbose else None, verbose=verbose)
@@ -342,7 +342,9 @@ def solve_optimal_walk(
342
342
  for attempt in range(attempts):
343
343
  cluster_orders = shuffled_cluster_orders()
344
344
  for meta in meta_list:
345
+ # print('solve once')
345
346
  rep_idxs, _, _, _ = solve_once(cluster_orders, meta)
347
+ # print('solve once done')
346
348
  if rep_idxs is None:
347
349
  continue
348
350
 
@@ -370,6 +372,7 @@ def solve_optimal_walk(
370
372
  new_nodes, new_cost = reps_to_nodes_and_cost(new_reps)
371
373
  if new_cost < cost:
372
374
  reps = new_reps
375
+ # print('2-opt improved cost from', cost, 'to', new_cost)
373
376
  nodes_seq, cost = new_nodes, new_cost
374
377
  improved = True
375
378
  break
@@ -383,7 +386,7 @@ def solve_optimal_walk(
383
386
 
384
387
  if best_nodes is None:
385
388
  raise RuntimeError("No solution found.")
386
-
389
+ # print('final check')
387
390
  # Final checks and edge list
388
391
  edge_walk: List[Tuple[Pos, Pos]] = [(best_nodes[i], best_nodes[i+1]) for i in range(len(best_nodes)-1)]
389
392
  assert all(e in edges for e in edge_walk), "Output contains an edge not in the input directed edges."