multi-puzzle-solver 1.1.8__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.
Files changed (106) hide show
  1. multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
  2. multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
  3. multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +184 -0
  6. puzzle_solver/core/utils.py +298 -0
  7. puzzle_solver/core/utils_ortools.py +333 -0
  8. puzzle_solver/core/utils_visualizer.py +575 -0
  9. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  10. puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
  11. puzzle_solver/puzzles/area_51/area_51.py +159 -0
  12. puzzle_solver/puzzles/battleships/battleships.py +139 -0
  13. puzzle_solver/puzzles/binairo/binairo.py +98 -0
  14. puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
  15. puzzle_solver/puzzles/black_box/black_box.py +243 -0
  16. puzzle_solver/puzzles/branches/branches.py +64 -0
  17. puzzle_solver/puzzles/bridges/bridges.py +104 -0
  18. puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
  19. puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
  20. puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
  21. puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
  22. puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
  23. puzzle_solver/puzzles/clouds/clouds.py +81 -0
  24. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  25. puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
  26. puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
  27. puzzle_solver/puzzles/filling/filling.py +94 -0
  28. puzzle_solver/puzzles/flip/flip.py +64 -0
  29. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  30. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
  31. puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
  32. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
  33. puzzle_solver/puzzles/guess/guess.py +232 -0
  34. puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
  35. puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
  36. puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
  37. puzzle_solver/puzzles/inertia/inertia.py +121 -0
  38. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
  39. puzzle_solver/puzzles/inertia/tsp.py +400 -0
  40. puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
  41. puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
  42. puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
  43. puzzle_solver/puzzles/keen/keen.py +76 -0
  44. puzzle_solver/puzzles/kropki/kropki.py +94 -0
  45. puzzle_solver/puzzles/light_up/light_up.py +58 -0
  46. puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
  47. puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
  48. puzzle_solver/puzzles/lits/lits.py +138 -0
  49. puzzle_solver/puzzles/magnets/magnets.py +96 -0
  50. puzzle_solver/puzzles/map/map.py +56 -0
  51. puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
  52. puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
  53. puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
  54. puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
  55. puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
  56. puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
  57. puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
  58. puzzle_solver/puzzles/norinori/norinori.py +96 -0
  59. puzzle_solver/puzzles/number_path/number_path.py +76 -0
  60. puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
  61. puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
  62. puzzle_solver/puzzles/palisade/palisade.py +91 -0
  63. puzzle_solver/puzzles/pearl/pearl.py +107 -0
  64. puzzle_solver/puzzles/pipes/pipes.py +82 -0
  65. puzzle_solver/puzzles/range/range.py +59 -0
  66. puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
  67. puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
  68. puzzle_solver/puzzles/rooms/rooms.py +75 -0
  69. puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
  70. puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
  71. puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
  72. puzzle_solver/puzzles/signpost/signpost.py +93 -0
  73. puzzle_solver/puzzles/singles/singles.py +53 -0
  74. puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
  75. puzzle_solver/puzzles/slant/slant.py +111 -0
  76. puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
  77. puzzle_solver/puzzles/snail/snail.py +97 -0
  78. puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
  79. puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
  80. puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
  81. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
  82. puzzle_solver/puzzles/stitches/stitches.py +96 -0
  83. puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
  84. puzzle_solver/puzzles/suguru/suguru.py +55 -0
  85. puzzle_solver/puzzles/suko/suko.py +54 -0
  86. puzzle_solver/puzzles/tapa/tapa.py +97 -0
  87. puzzle_solver/puzzles/tatami/tatami.py +64 -0
  88. puzzle_solver/puzzles/tents/tents.py +80 -0
  89. puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
  90. puzzle_solver/puzzles/towers/towers.py +89 -0
  91. puzzle_solver/puzzles/tracks/tracks.py +88 -0
  92. puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
  93. puzzle_solver/puzzles/troix/dumplings.py +7 -0
  94. puzzle_solver/puzzles/troix/troix.py +75 -0
  95. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  96. puzzle_solver/puzzles/undead/undead.py +130 -0
  97. puzzle_solver/puzzles/unequal/unequal.py +128 -0
  98. puzzle_solver/puzzles/unruly/unruly.py +54 -0
  99. puzzle_solver/puzzles/vectors/vectors.py +94 -0
  100. puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
  101. puzzle_solver/puzzles/walls/walls.py +52 -0
  102. puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
  103. puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
  104. puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
  105. puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
  106. puzzle_solver/utils/visualizer.py +155 -0
@@ -0,0 +1,333 @@
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
+ if isinstance(self.assignment, list):
18
+ return json.dumps(self.assignment)
19
+ result = []
20
+ for pos, v in self.assignment.items():
21
+ result.append((pos.x, pos.y, v))
22
+ return json.dumps(result, sort_keys=True)
23
+
24
+
25
+ def and_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
26
+ model.AddBoolAnd(cs).OnlyEnforceIf(target) # target => (c1 ∧ ... ∧ cn)
27
+ model.AddBoolOr([target] + [c.Not() for c in cs]) # target ∨ ¬c1 ∨ ... ∨ ¬cn equivalent to (¬target => ¬(c1 ∧ ... ∧ cn))
28
+ # thus target <=> (c1 ∧ ... ∧ cn)
29
+
30
+
31
+ def or_constraint(model: cp_model.CpModel, target: cp_model.IntVar, cs: list[cp_model.IntVar]):
32
+ model.AddBoolOr(cs).OnlyEnforceIf(target) # target => (c1 ∨ ... ∨ cn)
33
+ model.AddBoolAnd([c.Not() for c in cs]).OnlyEnforceIf(target.Not()) # ¬target => ¬c1 ∧ ... ∧ ¬cn equivalent to (¬target => ¬(c1 ∨ ... ∨ cn))
34
+ # thus target <=> (c1 ∨ ... ∨ cn)
35
+
36
+
37
+ class AllSolutionsCollector(CpSolverSolutionCallback):
38
+ def __init__(self,
39
+ board: Any,
40
+ board_to_solution: Callable[Any, SingleSolution],
41
+ max_solutions: Optional[int] = None,
42
+ callback: Optional[Callable[SingleSolution, None]] = None
43
+ ):
44
+ super().__init__()
45
+ self.board = board
46
+ self.board_to_solution = board_to_solution
47
+ self.max_solutions = max_solutions
48
+ self.callback = callback
49
+ self.solutions = []
50
+ self.unique_solutions = set()
51
+
52
+ def on_solution_callback(self):
53
+ try:
54
+ result = self.board_to_solution(self.board, self)
55
+ result_json = result.get_hashable_solution()
56
+ if result_json in self.unique_solutions:
57
+ return
58
+ self.unique_solutions.add(result_json)
59
+ self.solutions.append(result)
60
+ if self.callback is not None:
61
+ self.callback(result)
62
+ if self.max_solutions is not None and len(self.solutions) >= self.max_solutions:
63
+ self.StopSearch()
64
+ except Exception as e:
65
+ print(e)
66
+ raise e
67
+
68
+ 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]:
69
+ try:
70
+ solver = cp_model.CpSolver()
71
+ solver.parameters.enumerate_all_solutions = True
72
+ collector = AllSolutionsCollector(board, board_to_solution, max_solutions=max_solutions, callback=callback)
73
+ tic = time.time()
74
+ solver.solve(board.model, collector)
75
+ if verbose:
76
+ print("Solutions found:", len(collector.solutions))
77
+ print("status:", solver.StatusName())
78
+ toc = time.time()
79
+ print(f"Time taken: {toc - tic:.2f} seconds")
80
+ return collector.solutions
81
+ except Exception as e:
82
+ print(e)
83
+ raise e
84
+
85
+
86
+ def generic_unique_projections(board: Any, vars_to_forbid: list[cp_model.IntVar], board_to_solution: Callable[Any, SingleSolution], max_solutions: Optional[int] = None, callback: Optional[Callable[[SingleSolution], None]] = None, verbose: bool = True):
87
+ tic = time.time()
88
+ solutions = []
89
+ solver = cp_model.CpSolver()
90
+ stopped_early = False
91
+ try:
92
+ while True:
93
+ solver.solve(board.model)
94
+ if solver.StatusName() not in ['OPTIMAL', 'FEASIBLE']:
95
+ break
96
+ solution = board_to_solution(board, solver)
97
+ solutions.append(solution)
98
+ if callback is not None:
99
+ callback(solution)
100
+ if max_solutions is not None and len(solutions) >= max_solutions:
101
+ stopped_early = True
102
+ break
103
+ board.model.AddForbiddenAssignments(vars_to_forbid, [[solver.Value(v) for v in vars_to_forbid]])
104
+ except Exception as e:
105
+ print(e)
106
+ raise e
107
+ if verbose:
108
+ print(f"Solutions found: {len(solutions)}{' (stopped early)' if stopped_early else ''}")
109
+ if len(solutions) == 0:
110
+ status = solver.StatusName()
111
+ elif len(solutions) > 0 and stopped_early:
112
+ status = 'FEASIBLE'
113
+ elif len(solutions) > 0 and not stopped_early:
114
+ status = 'OPTIMAL'
115
+ else:
116
+ raise AssertionError("impossible state")
117
+ print("status:", status)
118
+ toc = time.time()
119
+ print(f"Time taken: {toc - tic:.2f} seconds")
120
+ return solutions
121
+
122
+
123
+ def manhattan_distance(p1: Pos, p2: Pos) -> int:
124
+ return abs(p1.x - p2.x) + abs(p1.y - p2.y)
125
+
126
+
127
+ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
128
+ """
129
+ Forces a single connected component of the given variables and any abstract function that defines adjacency.
130
+ Returns a dictionary of new variables that can be used to enforce the connected component constraint.
131
+ Total new variables: =4V [for N by M 2D grid total is 4NM]
132
+ """
133
+ if is_neighbor is None:
134
+ is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1 # noqa: E731
135
+
136
+ vs = vars_to_force
137
+ v_count = len(vs)
138
+ if v_count <= 2: # graph must have at least 3 nodes to possibly be disconnected
139
+ return {}
140
+ # =V model variables, one for each variable
141
+ is_root: dict[Pos, cp_model.IntVar] = {} # =V, defines the unique
142
+ prefix_zero: dict[Pos, cp_model.IntVar] = {} # =V, used for picking the unique root
143
+ node_height: dict[Pos, cp_model.IntVar] = {} # =V, trickles down from the root
144
+ max_neighbor_height: dict[Pos, cp_model.IntVar] = {} # =V, the height of the tallest neighbor
145
+ prefix_name = "connected_component_"
146
+ # total = 4V [for N by M 2D grid total is 4NM]
147
+
148
+ keys_in_order = list(vs.keys()) # must enforce some ordering
149
+
150
+ for p in keys_in_order:
151
+ is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
152
+ node_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}node_height[{p}]")
153
+ max_neighbor_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}max_neighbor_height[{p}]")
154
+ # Unique root: the smallest index i with x[i] = 1
155
+ # prefix_zero[i] = AND_{k < i} (not x[k])
156
+ prev_p = None
157
+ for p in keys_in_order:
158
+ b = model.NewBoolVar(f"{prefix_name}prefix_zero[{p}]")
159
+ prefix_zero[p] = b
160
+ if prev_p is None: # No earlier cells -> True
161
+ model.Add(b == 1)
162
+ else:
163
+ # b <-> (prefix_zero[i-1] & ~x[i-1])
164
+ and_constraint(model, b, [prefix_zero[prev_p], vs[prev_p].Not()])
165
+ prev_p = p
166
+
167
+ # x[i] & prefix_zero[i] -> root[i]
168
+ for p in keys_in_order:
169
+ and_constraint(model, is_root[p], [vs[p], prefix_zero[p]])
170
+ # Exactly one root:
171
+ model.Add(sum(is_root.values()) <= 1)
172
+
173
+ # For each node i, consider only neighbors
174
+ for i, pi in enumerate(keys_in_order):
175
+ # ps is list of neighbor heights
176
+ ps = [node_height[pj] for j, pj in enumerate(keys_in_order) if i != j and is_neighbor(pi, pj)]
177
+ model.AddMaxEquality(max_neighbor_height[pi], ps)
178
+ # if a node is active and its not root, its height is the height of the tallest neighbor - 1
179
+ model.Add(node_height[pi] == max_neighbor_height[pi] - 1).OnlyEnforceIf([vs[pi], is_root[pi].Not()])
180
+ model.Add(node_height[pi] == v_count).OnlyEnforceIf(is_root[pi])
181
+ model.Add(node_height[pi] == 0).OnlyEnforceIf(vs[pi].Not())
182
+
183
+ # final check: all active nodes have height > 0
184
+ for p in keys_in_order:
185
+ model.Add(node_height[p] > 0).OnlyEnforceIf(vs[p])
186
+
187
+ all_new_vars = {
188
+ "is_root": is_root,
189
+ "prefix_zero": prefix_zero,
190
+ "node_height": node_height,
191
+ "max_neighbor_height": max_neighbor_height,
192
+ }
193
+ return all_new_vars
194
+
195
+ def force_connected_component_using_demand(model: cp_model.CpModel, nodes: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
196
+ """
197
+ Forces a single connected component of the given variables and any abstract function that defines adjacency using demand variables.
198
+ Warning: This method will not have a unique feasible assignment of variable, thus this function MUST be used in conjunction with the unique projection method.
199
+ """
200
+ if is_neighbor is None:
201
+ is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1 # noqa: E731
202
+
203
+ n = len(nodes)
204
+ if n <= 2: # graph must have at least 3 nodes to possibly be disconnected
205
+ return {}
206
+
207
+ neighs = {}
208
+ for ki in nodes.keys():
209
+ neighs[ki] = []
210
+ for kj in nodes.keys():
211
+ if is_neighbor(ki, kj):
212
+ neighs[ki].append(kj)
213
+ if sum(len(neighs[ki]) for ki in nodes.keys()) == 0: # no edges in the graph
214
+ model.Add(sum(nodes.values()) <= 1)
215
+ return
216
+
217
+ # Parent choice variables:
218
+ # parent[i][j] = 1 if node i chooses neighbor j as parent (if either i or j is inactive, parent[i][j] must be 0)
219
+ # is_root[i] = 1 if node i is the (unique) root attached to the dummy source (if i is inactive, is_root[i] must be 0)
220
+ parent = {}
221
+ is_root = {}
222
+ for ki in nodes.keys():
223
+ for kj in neighs[ki]:
224
+ pij = model.NewBoolVar(f"par_{ki}_from_{kj}")
225
+ parent[(ki, kj)] = pij
226
+ # gate on endpoint selections
227
+ model.Add(pij <= nodes[ki])
228
+ model.Add(pij <= nodes[kj])
229
+ ps = model.NewBoolVar(f"par_{ki}_from_src")
230
+ is_root[ki] = ps
231
+ model.Add(ps <= nodes[ki]) # only active node may be root
232
+ # Exactly one parent if selected, none if root or inactive
233
+ model.Add(sum(parent[(ki, kj)] for kj in neighs[ki]) + ps == nodes[ki]) # ∀i: (Σ_{j∈N(i)} p[i→j]) + r[i] == v[i]
234
+ model.Add(sum(is_root.values()) <= 1) # at most one root
235
+
236
+ # Depth variables to break cycles and form a tree
237
+ # depth in [0, n-1]; root has depth 0; other selected nodes have >=1; unselected -> 0
238
+ depth = {ki: model.NewIntVar(0, n - 1, f"depth_{ki}") for ki in nodes.keys()}
239
+ for ki in nodes.keys():
240
+ vi = nodes[ki]
241
+ model.Add(depth[ki] == 0).OnlyEnforceIf(vi.Not()) # inactive => depth 0
242
+ model.Add(depth[ki] == 0).OnlyEnforceIf(is_root[ki]) # if root => depth 0
243
+ model.Add(depth[ki] >= 1).OnlyEnforceIf([vi, is_root[ki].Not()]) # if active and not root => depth >= 1
244
+ for kj in neighs[ki]: # every parent's depth < child's depth
245
+ pij = parent[(ki, kj)]
246
+ model.Add(depth[ki] >= depth[kj] + 1).OnlyEnforceIf(pij)
247
+
248
+ return {
249
+ "parent": parent,
250
+ "is_root": is_root,
251
+ "depth": depth,
252
+ }
253
+
254
+ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.IntVar], is_neighbor: Callable[[Any, Any], bool] = None):
255
+ """
256
+ Forces no loops in the given variables and any abstract function that defines adjacency.
257
+ Returns a dictionary of new variables that can be used to enforce the no component constraint.
258
+ """
259
+ if is_neighbor is None:
260
+ is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1 # noqa: E731
261
+
262
+ vs = vars_to_force
263
+ v_count = len(vs)
264
+ is_root: dict[Pos, cp_model.IntVar] = {}
265
+ block_root: dict[Pos, cp_model.IntVar] = {}
266
+ node_height: dict[Pos, cp_model.IntVar] = {}
267
+ tree_edge: dict[tuple[Pos, Pos], cp_model.IntVar] = {} # tree_edge[p, q] means p is parent of q
268
+ prefix_name = "no_loops_"
269
+
270
+ parent_of = {p: [] for p in vs.keys()}
271
+ children_of = {p: [] for p in vs.keys()}
272
+ for p in vs.keys():
273
+ for q in vs.keys():
274
+ if p == q:
275
+ continue
276
+ if is_neighbor(p, q):
277
+ parent_of[q].append(p)
278
+ children_of[p].append(q)
279
+
280
+ keys_in_order = list(vs.keys()) # must enforce some ordering
281
+ node_to_idx: dict[Pos, int] = {p: i+1 for i, p in enumerate(keys_in_order)}
282
+ for p in keys_in_order:
283
+ # capacity = node_to_idx[p] + 1
284
+ # if p == Pos(0, 0):
285
+ # capacity = 10
286
+ # print(f'capacity for {p} = {capacity}')
287
+ node_height[p] = model.NewIntVar(0, v_count, f"{prefix_name}node_height[{p}]")
288
+ block_root[p] = model.NewIntVar(0, node_to_idx[p], f"{prefix_name}block_root[{p}]")
289
+ is_root[p] = model.NewBoolVar(f"{prefix_name}is_root[{p}]")
290
+ model.Add(is_root[p] == 0).OnlyEnforceIf([vs[p].Not()])
291
+ model.Add(node_height[p] == 0).OnlyEnforceIf([vs[p].Not()])
292
+ model.Add(node_height[p] == 1).OnlyEnforceIf([is_root[p]])
293
+ model.Add(block_root[p] == 0).OnlyEnforceIf([vs[p].Not()])
294
+ model.Add(block_root[p] == node_to_idx[p]).OnlyEnforceIf([is_root[p]])
295
+
296
+ for p in keys_in_order:
297
+ for q in children_of[p]:
298
+ tree_edge[(p, q)] = model.NewBoolVar(f"{prefix_name}tree_edge[{p} is parent of {q}]")
299
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[p].Not()])
300
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[q].Not()])
301
+ # a tree_edge[p, q] means p is parent of q thus h[q] = h[p] + 1
302
+ model.Add(node_height[q] == node_height[p] + 1).OnlyEnforceIf([tree_edge[(p, q)]])
303
+ model.Add(block_root[q] == block_root[p]).OnlyEnforceIf([tree_edge[(p, q)]])
304
+
305
+ for (p, q) in tree_edge:
306
+ if (q, p) in tree_edge:
307
+ model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([tree_edge[(q, p)]])
308
+ model.Add(tree_edge[(p, q)] == 1).OnlyEnforceIf([tree_edge[(q, p)].Not(), vs[p], vs[q]])
309
+
310
+ for p in keys_in_order:
311
+ for p_child in children_of[p]:
312
+ # i am root thus I point to all my children
313
+ model.Add(tree_edge[(p, p_child)] == 1).OnlyEnforceIf([is_root[p], vs[p_child]])
314
+ for p_parent in parent_of[p]:
315
+ # i am root thus I have no parent
316
+ model.Add(tree_edge[(p_parent, p)] == 0).OnlyEnforceIf([is_root[p]])
317
+ # every active node has exactly 1 parent except root has none
318
+ model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of[p]] + [vs[p].Not(), is_root[p]])
319
+
320
+ # now each subgraph has directions where each non-root points to a single parent (and its value is parent+1).
321
+ # to break cycles, every non-root active node must be > all neighbors that arent children
322
+
323
+ all_new_vars: dict[str, cp_model.IntVar] = {}
324
+ for k, v in is_root.items():
325
+ all_new_vars[f"{prefix_name}is_root[{k}]"] = v
326
+ for k, v in tree_edge.items():
327
+ all_new_vars[f"{prefix_name}tree_edge[{k[0]} is parent of {k[1]}]"] = v
328
+ for k, v in node_height.items():
329
+ all_new_vars[f"{prefix_name}node_height[{k}]"] = v
330
+ for k, v in block_root.items():
331
+ all_new_vars[f"{prefix_name}block_root[{k}]"] = v
332
+
333
+ return all_new_vars