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.

@@ -1,172 +1,235 @@
1
- import time
2
- import json
3
- from dataclasses import dataclass
4
- from typing import Optional, Callable, Any, Union
5
-
6
- from ortools.sat.python import cp_model
7
- from ortools.sat.python.cp_model import CpSolverSolutionCallback
8
-
9
- from puzzle_solver.core.utils import Pos
10
-
11
-
12
- @dataclass(frozen=True)
13
- class SingleSolution:
14
- assignment: dict[Pos, Union[str, int]]
15
-
16
- def get_hashable_solution(self) -> str:
17
- result = []
18
- for pos, v in self.assignment.items():
19
- result.append((pos.x, pos.y, v))
20
- return json.dumps(result, sort_keys=True)
21
-
22
-
23
- def and_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
24
- for c in cs:
25
- model.Add(target <= c)
26
- model.Add(target >= sum(cs) - len(cs) + 1)
27
-
28
-
29
- def or_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
30
- for c in cs:
31
- model.Add(target >= c)
32
- model.Add(target <= sum(cs))
33
-
34
-
35
-
36
- class AllSolutionsCollector(CpSolverSolutionCallback):
37
- def __init__(self,
38
- board: Any,
39
- board_to_solution: Callable[Any, SingleSolution],
40
- max_solutions: Optional[int] = None,
41
- callback: Optional[Callable[SingleSolution, None]] = None
42
- ):
43
- super().__init__()
44
- self.board = board
45
- self.board_to_solution = board_to_solution
46
- self.max_solutions = max_solutions
47
- self.callback = callback
48
- self.solutions = []
49
- self.unique_solutions = set()
50
-
51
- def on_solution_callback(self):
52
- try:
53
- result = self.board_to_solution(self.board, self)
54
- result_json = result.get_hashable_solution()
55
- if result_json in self.unique_solutions:
56
- return
57
- self.unique_solutions.add(result_json)
58
- self.solutions.append(result)
59
- if self.callback is not None:
60
- self.callback(result)
61
- if self.max_solutions is not None and len(self.solutions) >= self.max_solutions:
62
- self.StopSearch()
63
- except Exception as e:
64
- print(e)
65
- raise e
66
-
67
- def generic_solve_all(board: Any, board_to_solution: Callable[Any, SingleSolution], max_solutions: Optional[int] = None, callback: Optional[Callable[[SingleSolution], None]] = None, verbose: bool = True) -> list[SingleSolution]:
68
- try:
69
- solver = cp_model.CpSolver()
70
- solver.parameters.enumerate_all_solutions = True
71
- collector = AllSolutionsCollector(board, board_to_solution, max_solutions=max_solutions, callback=callback)
72
- tic = time.time()
73
- solver.solve(board.model, collector)
74
- if verbose:
75
- print("Solutions found:", len(collector.solutions))
76
- print("status:", solver.StatusName())
77
- toc = time.time()
78
- print(f"Time taken: {toc - tic:.2f} seconds")
79
- return collector.solutions
80
- except Exception as e:
81
- print(e)
82
- raise e
83
-
84
-
85
- def manhattan_distance(p1: Pos, p2: Pos) -> int:
86
- return abs(p1.x - p2.x) + abs(p1.y - p2.y)
87
-
88
-
89
- def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
90
- """
91
- Forces a single connected component of the given variables and any abstract function that defines adjacency.
92
- Returns a dictionary of new variables that can be used to enforce the connected component constraint.
93
- Total new variables: =(3+N)V where N is average number of neighbors, ~7*N*M for N by M 2D grid
94
- WARNING: Will make solutions not unique (because the choice of parent is not unique)
95
- """
96
- if is_neighbor is None:
97
- is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1
98
-
99
- vs = vars_to_force
100
- v_count = len(vs)
101
- # =V model variables, one for each variable
102
- is_root: dict[Pos, cp_model.IntVar] = {} # =V
103
- prefix_zero: dict[Pos, cp_model.IntVar] = {} # =V
104
- node_mtz: dict[Pos, cp_model.IntVar] = {} # =V
105
- # =NV model variables where N is average number of neighbors (with double counting)
106
- # for a N by M 2D grid exactly = 4MN-2M-2N [the correction term (-2M-2N) is because the borders have less neighbors]
107
- parent: dict[tuple[int, int], cp_model.IntVar] = {} # =NV
108
- prefix_name = "connected_component_"
109
- # total = (3+N)V [for N by M 2D grid total is (7MN-2M-2N) or simply ~7*N*M]
110
-
111
- # must enforce some ordering
112
- key_to_idx: dict[Pos, int] = {p: i for i, p in enumerate(vs.keys())}
113
- idx_to_key: dict[int, Pos] = {i: p for p, i in key_to_idx.items()}
114
- keys_in_order = [idx_to_key[i] for i in range(len(key_to_idx))]
115
-
116
- for p in keys_in_order:
117
- is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
118
- node_mtz[p] = model.NewIntVar(0, v_count - 1, f"{prefix_name}node_mtz[{p}]")
119
- # Unique root: the smallest index i with x[i] = 1
120
- # prefix_zero[i] = AND_{k < i} (not x[k])
121
- prev_p = None
122
- for p in keys_in_order:
123
- b = model.NewBoolVar(f"{prefix_name}prefix_zero[{p}]")
124
- prefix_zero[p] = b
125
- if prev_p is None: # No earlier cells -> True
126
- model.Add(b == 1)
127
- else:
128
- # b <-> (prefix_zero[i-1] & ~x[i-1])
129
- and_constraint(model, b, [prefix_zero[prev_p], vs[prev_p].Not()])
130
- prev_p = p
131
-
132
- # x[i] & prefix_zero[i] -> root[i]
133
- for p in keys_in_order:
134
- and_constraint(model, is_root[p], [vs[p], prefix_zero[p]])
135
- # Exactly one root:
136
- model.Add(sum(is_root.values()) == 1)
137
-
138
- # For each node i, consider only neighbors
139
- for i, pi in enumerate(keys_in_order):
140
- cand = sorted([pj for j, pj in enumerate(keys_in_order) if i != j and is_neighbor(pi, pj)])
141
- # if a node is active and its not root, it must have 1 parent [the first true candidate], otherwise no parent
142
- ps = []
143
- for j, pj in enumerate(cand):
144
- parent_ij = model.NewBoolVar(f"{prefix_name}parent[{pi},{pj}]")
145
- parent[(pi,pj)] = parent_ij
146
- am_i_root = is_root[pi]
147
- am_i_active = vs[pi]
148
- is_neighbor_active = vs[pj]
149
- model.AddImplication(parent_ij, am_i_root.Not())
150
- model.AddImplication(parent_ij, am_i_active)
151
- model.AddImplication(parent_ij, is_neighbor_active)
152
- ps.append(parent_ij)
153
- # if 1 then sum(parents) = 1, if 0 then sum(parents) = 0; thus sum(parents) = var_minus_root
154
- var_minus_root = vs[pi] - is_root[pi]
155
- model.Add(sum(ps) == var_minus_root)
156
- # MTZ constraint to force single connected component
157
- model.Add(node_mtz[pi] == 0).OnlyEnforceIf(is_root[pi])
158
- model.Add(node_mtz[pi] == 0).OnlyEnforceIf(vs[pi].Not())
159
- for pj in cand:
160
- model.Add(node_mtz[pi] == node_mtz[pj] + 1).OnlyEnforceIf(parent[(pi,pj)])
161
-
162
- all_new_vars: dict[str, cp_model.IntVar] = {}
163
- for k, v in is_root.items():
164
- all_new_vars[f"{prefix_name}is_root[{k}]"] = v
165
- for k, v in prefix_zero.items():
166
- all_new_vars[f"{prefix_name}prefix_zero[{k}]"] = v
167
- for (p1, p2), v in parent.items():
168
- all_new_vars[f"{prefix_name}parent[{p1},{p2}]"] = v
169
- for k, v in node_mtz.items():
170
- all_new_vars[f"{prefix_name}node_mtz[{k}]"] = v
171
-
172
- return all_new_vars
1
+ import time
2
+ import json
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Callable, Any, Union
5
+
6
+ from ortools.sat.python import cp_model
7
+ from ortools.sat.python.cp_model import CpSolverSolutionCallback
8
+
9
+ from puzzle_solver.core.utils import Pos
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SingleSolution:
14
+ assignment: dict[Pos, Union[str, int]]
15
+
16
+ def get_hashable_solution(self) -> str:
17
+ result = []
18
+ for pos, v in self.assignment.items():
19
+ result.append((pos.x, pos.y, v))
20
+ return json.dumps(result, sort_keys=True)
21
+
22
+
23
+ def and_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
24
+ for c in cs:
25
+ model.Add(target <= c)
26
+ model.Add(target >= sum(cs) - len(cs) + 1)
27
+
28
+
29
+ def or_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
30
+ for c in cs:
31
+ model.Add(target >= c)
32
+ model.Add(target <= sum(cs))
33
+
34
+
35
+
36
+ class AllSolutionsCollector(CpSolverSolutionCallback):
37
+ def __init__(self,
38
+ board: Any,
39
+ board_to_solution: Callable[Any, SingleSolution],
40
+ max_solutions: Optional[int] = None,
41
+ callback: Optional[Callable[SingleSolution, None]] = None
42
+ ):
43
+ super().__init__()
44
+ self.board = board
45
+ self.board_to_solution = board_to_solution
46
+ self.max_solutions = max_solutions
47
+ self.callback = callback
48
+ self.solutions = []
49
+ self.unique_solutions = set()
50
+
51
+ def on_solution_callback(self):
52
+ try:
53
+ result = self.board_to_solution(self.board, self)
54
+ result_json = result.get_hashable_solution()
55
+ if result_json in self.unique_solutions:
56
+ return
57
+ self.unique_solutions.add(result_json)
58
+ self.solutions.append(result)
59
+ if self.callback is not None:
60
+ self.callback(result)
61
+ if self.max_solutions is not None and len(self.solutions) >= self.max_solutions:
62
+ self.StopSearch()
63
+ except Exception as e:
64
+ print(e)
65
+ raise e
66
+
67
+ def generic_solve_all(board: Any, board_to_solution: Callable[Any, SingleSolution], max_solutions: Optional[int] = None, callback: Optional[Callable[[SingleSolution], None]] = None, verbose: bool = True) -> list[SingleSolution]:
68
+ try:
69
+ solver = cp_model.CpSolver()
70
+ solver.parameters.enumerate_all_solutions = True
71
+ collector = AllSolutionsCollector(board, board_to_solution, max_solutions=max_solutions, callback=callback)
72
+ tic = time.time()
73
+ solver.solve(board.model, collector)
74
+ if verbose:
75
+ print("Solutions found:", len(collector.solutions))
76
+ print("status:", solver.StatusName())
77
+ toc = time.time()
78
+ print(f"Time taken: {toc - tic:.2f} seconds")
79
+ return collector.solutions
80
+ except Exception as e:
81
+ print(e)
82
+ raise e
83
+
84
+
85
+ def manhattan_distance(p1: Pos, p2: Pos) -> int:
86
+ return abs(p1.x - p2.x) + abs(p1.y - p2.y)
87
+
88
+
89
+ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
90
+ """
91
+ Forces a single connected component of the given variables and any abstract function that defines adjacency.
92
+ Returns a dictionary of new variables that can be used to enforce the connected component constraint.
93
+ Total new variables: =4V [for N by M 2D grid total is 4NM]
94
+ """
95
+ if is_neighbor is None:
96
+ is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1
97
+
98
+ vs = vars_to_force
99
+ v_count = len(vs)
100
+ # =V model variables, one for each variable
101
+ is_root: dict[Pos, cp_model.IntVar] = {} # =V, defines the unique root
102
+ prefix_zero: dict[Pos, cp_model.IntVar] = {} # =V, used for picking the unique root
103
+ node_height: dict[Pos, cp_model.IntVar] = {} # =V, trickles down from the root
104
+ max_neighbor_height: dict[Pos, cp_model.IntVar] = {} # =V, the height of the tallest neighbor
105
+ prefix_name = "connected_component_"
106
+ # total = 4V [for N by M 2D grid total is 4NM]
107
+
108
+ keys_in_order = list(vs.keys()) # must enforce some ordering
109
+
110
+ for p in keys_in_order:
111
+ is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
112
+ node_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}node_height[{p}]")
113
+ max_neighbor_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}max_neighbor_height[{p}]")
114
+ # Unique root: the smallest index i with x[i] = 1
115
+ # prefix_zero[i] = AND_{k < i} (not x[k])
116
+ prev_p = None
117
+ for p in keys_in_order:
118
+ b = model.NewBoolVar(f"{prefix_name}prefix_zero[{p}]")
119
+ prefix_zero[p] = b
120
+ if prev_p is None: # No earlier cells -> True
121
+ model.Add(b == 1)
122
+ else:
123
+ # b <-> (prefix_zero[i-1] & ~x[i-1])
124
+ and_constraint(model, b, [prefix_zero[prev_p], vs[prev_p].Not()])
125
+ prev_p = p
126
+
127
+ # x[i] & prefix_zero[i] -> root[i]
128
+ for p in keys_in_order:
129
+ and_constraint(model, is_root[p], [vs[p], prefix_zero[p]])
130
+ # Exactly one root:
131
+ model.Add(sum(is_root.values()) == 1)
132
+
133
+ # For each node i, consider only neighbors
134
+ for i, pi in enumerate(keys_in_order):
135
+ # ps is list of neighbor heights
136
+ ps = [node_height[pj] for j, pj in enumerate(keys_in_order) if i != j and is_neighbor(pi, pj)]
137
+ model.AddMaxEquality(max_neighbor_height[pi], ps)
138
+ # if a node is active and its not root, its height is the height of the tallest neighbor - 1
139
+ model.Add(node_height[pi] == max_neighbor_height[pi] - 1).OnlyEnforceIf([vs[pi], is_root[pi].Not()])
140
+ model.Add(node_height[pi] == v_count).OnlyEnforceIf(is_root[pi])
141
+ model.Add(node_height[pi] == 0).OnlyEnforceIf(vs[pi].Not())
142
+
143
+ # final check: all active nodes have height > 0
144
+ for p in keys_in_order:
145
+ model.Add(node_height[p] > 0).OnlyEnforceIf(vs[p])
146
+
147
+ all_new_vars: dict[str, cp_model.IntVar] = {}
148
+ for k, v in is_root.items():
149
+ all_new_vars[f"{prefix_name}is_root[{k}]"] = v
150
+ for k, v in prefix_zero.items():
151
+ all_new_vars[f"{prefix_name}prefix_zero[{k}]"] = v
152
+ for k, v in node_height.items():
153
+ all_new_vars[f"{prefix_name}node_height[{k}]"] = v
154
+ for k, v in max_neighbor_height.items():
155
+ all_new_vars[f"{prefix_name}max_neighbor_height[{k}]"] = v
156
+
157
+ return all_new_vars
158
+
159
+
160
+ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
161
+ """
162
+ Forces no loops in the given variables and any abstract function that defines adjacency.
163
+ Returns a dictionary of new variables that can be used to enforce the no component constraint.
164
+ """
165
+ if is_neighbor is None:
166
+ is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1
167
+
168
+ vs = vars_to_force
169
+ v_count = len(vs)
170
+ is_root: dict[Pos, cp_model.IntVar] = {}
171
+ block_root: dict[Pos, cp_model.IntVar] = {}
172
+ node_height: dict[Pos, cp_model.IntVar] = {}
173
+ tree_edge: dict[tuple[Pos, Pos], cp_model.IntVar] = {} # tree_edge[p, q] means p is parent of q
174
+ prefix_name = "no_loops_"
175
+
176
+ def parent_of(p: Pos) -> list[Pos]:
177
+ return [q for q in keys_in_order if (q, p) in tree_edge]
178
+ def children_of(p: Pos) -> list[Pos]:
179
+ return [q for q in keys_in_order if (p, q) in tree_edge]
180
+
181
+ keys_in_order = list(vs.keys()) # must enforce some ordering
182
+ node_to_idx: dict[Pos, int] = {p: i+1 for i, p in enumerate(keys_in_order)}
183
+ for p in keys_in_order:
184
+ # capacity = node_to_idx[p] + 1
185
+ # if p == Pos(0, 0):
186
+ # capacity = 10
187
+ # print(f'capacity for {p} = {capacity}')
188
+ node_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}node_height[{p}]")
189
+ block_root[p] = model.NewIntVar(0, node_to_idx[p], f"{prefix_name}block_root[{p}]")
190
+ is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
191
+ model.Add(is_root[p] == 0).OnlyEnforceIf([vs[p].Not()])
192
+ model.Add(node_height[p] == 0).OnlyEnforceIf([vs[p].Not()])
193
+ model.Add(node_height[p] == 1).OnlyEnforceIf([is_root[p]])
194
+ model.Add(block_root[p] == 0).OnlyEnforceIf([vs[p].Not()])
195
+ model.Add(block_root[p] == node_to_idx[p]).OnlyEnforceIf([is_root[p]])
196
+
197
+ for p in keys_in_order:
198
+ for q in keys_in_order:
199
+ if p == q:
200
+ continue
201
+ if is_neighbor(p, q):
202
+ tree_edge[(p, q)] = model.NewBoolVar(f"{prefix_name}tree_edge[{p} is parent of {q}]")
203
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[p].Not()])
204
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[q].Not()])
205
+ # a tree_edge[p, q] means p is parent of q thus h[q] = h[p] + 1
206
+ model.Add(node_height[q] == node_height[p] + 1).OnlyEnforceIf([tree_edge[(p, q)]])
207
+ model.Add(block_root[q] == block_root[p]).OnlyEnforceIf([tree_edge[(p, q)]])
208
+
209
+ for (p, q) in tree_edge:
210
+ if (q, p) in tree_edge:
211
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([tree_edge[(q, p)]])
212
+ model.Add(tree_edge[(p, q)] == 1).OnlyEnforceIf([tree_edge[(q, p)].Not(), vs[p], vs[q]])
213
+
214
+ for p in keys_in_order:
215
+ for p_child in children_of(p):
216
+ # i am root thus I point to all my children
217
+ model.Add(tree_edge[(p, p_child)] == 1).OnlyEnforceIf([is_root[p], vs[p_child]])
218
+ for p_parent in parent_of(p):
219
+ # i am root thus I have no parent
220
+ model.Add(tree_edge[(p_parent, p)] == 0).OnlyEnforceIf([is_root[p]])
221
+ # every active node has exactly 1 parent except root has none
222
+ model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of(p)] + [vs[p].Not(), is_root[p]])
223
+
224
+ # now each subgraph has directions where each non-root points to a single parent (and its value is parent+1).
225
+ # to break cycles, every non-root active node must be > all neighbors that arent children
226
+
227
+ all_new_vars: dict[str, cp_model.IntVar] = {}
228
+ for k, v in is_root.items():
229
+ all_new_vars[f"{prefix_name}is_root[{k}]"] = v
230
+ for k, v in tree_edge.items():
231
+ all_new_vars[f"{prefix_name}tree_edge[{k[0]} is parent of {k[1]}]"] = v
232
+ for k, v in node_height.items():
233
+ all_new_vars[f"{prefix_name}node_height[{k}]"] = v
234
+
235
+ return all_new_vars
@@ -1,5 +1,6 @@
1
1
  from dataclasses import dataclass, field
2
2
  from typing import Optional
3
+
3
4
  import numpy as np
4
5
  from ortools.sat.python import cp_model
5
6