multi-puzzle-solver 0.9.13__py3-none-any.whl → 0.9.15__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,237 @@
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
+ if v_count <= 2: # graph must have at least 3 nodes to possibly be disconnected
101
+ return {}
102
+ # =V model variables, one for each variable
103
+ is_root: dict[Pos, cp_model.IntVar] = {} # =V, defines the unique root
104
+ prefix_zero: dict[Pos, cp_model.IntVar] = {} # =V, used for picking the unique root
105
+ node_height: dict[Pos, cp_model.IntVar] = {} # =V, trickles down from the root
106
+ max_neighbor_height: dict[Pos, cp_model.IntVar] = {} # =V, the height of the tallest neighbor
107
+ prefix_name = "connected_component_"
108
+ # total = 4V [for N by M 2D grid total is 4NM]
109
+
110
+ keys_in_order = list(vs.keys()) # must enforce some ordering
111
+
112
+ for p in keys_in_order:
113
+ is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
114
+ node_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}node_height[{p}]")
115
+ max_neighbor_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}max_neighbor_height[{p}]")
116
+ # Unique root: the smallest index i with x[i] = 1
117
+ # prefix_zero[i] = AND_{k < i} (not x[k])
118
+ prev_p = None
119
+ for p in keys_in_order:
120
+ b = model.NewBoolVar(f"{prefix_name}prefix_zero[{p}]")
121
+ prefix_zero[p] = b
122
+ if prev_p is None: # No earlier cells -> True
123
+ model.Add(b == 1)
124
+ else:
125
+ # b <-> (prefix_zero[i-1] & ~x[i-1])
126
+ and_constraint(model, b, [prefix_zero[prev_p], vs[prev_p].Not()])
127
+ prev_p = p
128
+
129
+ # x[i] & prefix_zero[i] -> root[i]
130
+ for p in keys_in_order:
131
+ and_constraint(model, is_root[p], [vs[p], prefix_zero[p]])
132
+ # Exactly one root:
133
+ model.Add(sum(is_root.values()) <= 1)
134
+
135
+ # For each node i, consider only neighbors
136
+ for i, pi in enumerate(keys_in_order):
137
+ # ps is list of neighbor heights
138
+ ps = [node_height[pj] for j, pj in enumerate(keys_in_order) if i != j and is_neighbor(pi, pj)]
139
+ model.AddMaxEquality(max_neighbor_height[pi], ps)
140
+ # if a node is active and its not root, its height is the height of the tallest neighbor - 1
141
+ model.Add(node_height[pi] == max_neighbor_height[pi] - 1).OnlyEnforceIf([vs[pi], is_root[pi].Not()])
142
+ model.Add(node_height[pi] == v_count).OnlyEnforceIf(is_root[pi])
143
+ model.Add(node_height[pi] == 0).OnlyEnforceIf(vs[pi].Not())
144
+
145
+ # final check: all active nodes have height > 0
146
+ for p in keys_in_order:
147
+ model.Add(node_height[p] > 0).OnlyEnforceIf(vs[p])
148
+
149
+ all_new_vars: dict[str, cp_model.IntVar] = {}
150
+ for k, v in is_root.items():
151
+ all_new_vars[f"{prefix_name}is_root[{k}]"] = v
152
+ for k, v in prefix_zero.items():
153
+ all_new_vars[f"{prefix_name}prefix_zero[{k}]"] = v
154
+ for k, v in node_height.items():
155
+ all_new_vars[f"{prefix_name}node_height[{k}]"] = v
156
+ for k, v in max_neighbor_height.items():
157
+ all_new_vars[f"{prefix_name}max_neighbor_height[{k}]"] = v
158
+
159
+ return all_new_vars
160
+
161
+
162
+ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
163
+ """
164
+ Forces no loops in the given variables and any abstract function that defines adjacency.
165
+ Returns a dictionary of new variables that can be used to enforce the no component constraint.
166
+ """
167
+ if is_neighbor is None:
168
+ is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1
169
+
170
+ vs = vars_to_force
171
+ v_count = len(vs)
172
+ is_root: dict[Pos, cp_model.IntVar] = {}
173
+ block_root: dict[Pos, cp_model.IntVar] = {}
174
+ node_height: dict[Pos, cp_model.IntVar] = {}
175
+ tree_edge: dict[tuple[Pos, Pos], cp_model.IntVar] = {} # tree_edge[p, q] means p is parent of q
176
+ prefix_name = "no_loops_"
177
+
178
+ def parent_of(p: Pos) -> list[Pos]:
179
+ return [q for q in keys_in_order if (q, p) in tree_edge]
180
+ def children_of(p: Pos) -> list[Pos]:
181
+ return [q for q in keys_in_order if (p, q) in tree_edge]
182
+
183
+ keys_in_order = list(vs.keys()) # must enforce some ordering
184
+ node_to_idx: dict[Pos, int] = {p: i+1 for i, p in enumerate(keys_in_order)}
185
+ for p in keys_in_order:
186
+ # capacity = node_to_idx[p] + 1
187
+ # if p == Pos(0, 0):
188
+ # capacity = 10
189
+ # print(f'capacity for {p} = {capacity}')
190
+ node_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}node_height[{p}]")
191
+ block_root[p] = model.NewIntVar(0, node_to_idx[p], f"{prefix_name}block_root[{p}]")
192
+ is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
193
+ model.Add(is_root[p] == 0).OnlyEnforceIf([vs[p].Not()])
194
+ model.Add(node_height[p] == 0).OnlyEnforceIf([vs[p].Not()])
195
+ model.Add(node_height[p] == 1).OnlyEnforceIf([is_root[p]])
196
+ model.Add(block_root[p] == 0).OnlyEnforceIf([vs[p].Not()])
197
+ model.Add(block_root[p] == node_to_idx[p]).OnlyEnforceIf([is_root[p]])
198
+
199
+ for p in keys_in_order:
200
+ for q in keys_in_order:
201
+ if p == q:
202
+ continue
203
+ if is_neighbor(p, q):
204
+ tree_edge[(p, q)] = model.NewBoolVar(f"{prefix_name}tree_edge[{p} is parent of {q}]")
205
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[p].Not()])
206
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[q].Not()])
207
+ # a tree_edge[p, q] means p is parent of q thus h[q] = h[p] + 1
208
+ model.Add(node_height[q] == node_height[p] + 1).OnlyEnforceIf([tree_edge[(p, q)]])
209
+ model.Add(block_root[q] == block_root[p]).OnlyEnforceIf([tree_edge[(p, q)]])
210
+
211
+ for (p, q) in tree_edge:
212
+ if (q, p) in tree_edge:
213
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([tree_edge[(q, p)]])
214
+ model.Add(tree_edge[(p, q)] == 1).OnlyEnforceIf([tree_edge[(q, p)].Not(), vs[p], vs[q]])
215
+
216
+ for p in keys_in_order:
217
+ for p_child in children_of(p):
218
+ # i am root thus I point to all my children
219
+ model.Add(tree_edge[(p, p_child)] == 1).OnlyEnforceIf([is_root[p], vs[p_child]])
220
+ for p_parent in parent_of(p):
221
+ # i am root thus I have no parent
222
+ model.Add(tree_edge[(p_parent, p)] == 0).OnlyEnforceIf([is_root[p]])
223
+ # every active node has exactly 1 parent except root has none
224
+ model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of(p)] + [vs[p].Not(), is_root[p]])
225
+
226
+ # now each subgraph has directions where each non-root points to a single parent (and its value is parent+1).
227
+ # to break cycles, every non-root active node must be > all neighbors that arent children
228
+
229
+ all_new_vars: dict[str, cp_model.IntVar] = {}
230
+ for k, v in is_root.items():
231
+ all_new_vars[f"{prefix_name}is_root[{k}]"] = v
232
+ for k, v in tree_edge.items():
233
+ all_new_vars[f"{prefix_name}tree_edge[{k[0]} is parent of {k[1]}]"] = v
234
+ for k, v in node_height.items():
235
+ all_new_vars[f"{prefix_name}node_height[{k}]"] = v
236
+
237
+ 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