multi-puzzle-solver 0.9.10__py3-none-any.whl → 0.9.13__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-puzzle-solver
3
- Version: 0.9.10
3
+ Version: 0.9.13
4
4
  Summary: Efficient solvers for numerous popular and esoteric logic puzzles using CP-SAT
5
5
  Author: Ar-Kareem
6
6
  Project-URL: Homepage, https://github.com/Ar-Kareem/puzzle_solver
@@ -259,6 +259,11 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
259
259
  <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/star_battle_shapeless_solved.png" alt="Star Battle Shapeless" width="140">
260
260
  </a>
261
261
  </td>
262
+ <td align="center">
263
+ <a href="#lits-puzzle-type-33"><b>Lits</b><br><br>
264
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/lits_solved.png" alt="Lits" width="140">
265
+ </a>
266
+ </td>
262
267
  </tr>
263
268
  </table>
264
269
 
@@ -306,6 +311,7 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
306
311
  - [Kakurasu (Puzzle Type #30)](#kakurasu-puzzle-type-30)
307
312
  - [Star Battle (Puzzle Type #31)](#star-battle-puzzle-type-31)
308
313
  - [Star Battle Shapeless (Puzzle Type #32)](#star-battle-shapeless-puzzle-type-32)
314
+ - [Lits (Puzzle Type #33)](#lits-puzzle-type-33)
309
315
  - [Why SAT / CP-SAT?](#why-sat--cp-sat)
310
316
  - [Testing](#testing)
311
317
  - [Contributing](#contributing)
@@ -2687,6 +2693,95 @@ Time taken: 0.02 seconds
2687
2693
 
2688
2694
  ---
2689
2695
 
2696
+ ## Lits (Puzzle Type #33)
2697
+
2698
+ * [**Play online**](https://www.puzzle-lits.com/)
2699
+
2700
+ * [**Solver Code**][33]
2701
+
2702
+ <details>
2703
+ <summary><strong>Rules</strong></summary>
2704
+
2705
+ You have to place one tetromino in each region in such a way that:
2706
+ - 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
2707
+ - The shaded cells should form a single connected area.
2708
+ - 2x2 shaded areas are not allowed.
2709
+
2710
+ * Tetromino is a shape made of 4 connected cells. There are 5 types of tetrominoes, which are usually named L, I, T, S and O, based on their shape. The O tetromino is not used in this puzzle because it is a 2x2 shape, which is not allowed.
2711
+
2712
+ </details>
2713
+
2714
+ Note: The solver is capable of solving variations where the puzzle pieces the made up of more than 4 cells (e.g., pentominoes for 5 with `polyomino_degrees=5`, or hexominoes for 6 with `polyomino_degrees=6`, etc.). By default the degree is set to 4 thus only tetrominoes are used.
2715
+
2716
+ **Unsolved puzzle**
2717
+
2718
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/lits_unsolved.png" alt="Lits unsolved" width="500">
2719
+
2720
+ Code to utilize this package and solve the puzzle:
2721
+
2722
+ ```python
2723
+ board = np.array([
2724
+ ['00', '00', '00', '01', '01', '02', '02', '02', '03', '03', '03', '04', '04', '05', '06', '07', '07', '08', '08', '09'],
2725
+ ['00', '00', '00', '00', '01', '02', '03', '03', '03', '10', '04', '04', '05', '05', '06', '07', '08', '08', '09', '09'],
2726
+ ['11', '11', '11', '01', '01', '02', '02', '03', '10', '10', '04', '04', '05', '06', '06', '07', '07', '07', '09', '12'],
2727
+ ['11', '13', '13', '13', '01', '02', '03', '03', '03', '10', '04', '04', '06', '06', '06', '07', '12', '09', '09', '12'],
2728
+ ['11', '11', '11', '13', '14', '14', '03', '15', '15', '10', '04', '04', '06', '16', '16', '12', '12', '09', '12', '12'],
2729
+ ['17', '13', '13', '13', '14', '14', '03', '03', '15', '15', '04', '04', '16', '16', '16', '12', '12', '12', '12', '18'],
2730
+ ['17', '13', '19', '13', '20', '14', '03', '03', '15', '04', '04', '16', '16', '21', '21', '22', '23', '23', '23', '18'],
2731
+ ['17', '17', '19', '19', '20', '20', '03', '03', '24', '24', '24', '25', '25', '25', '21', '22', '23', '23', '18', '18'],
2732
+ ['17', '26', '19', '19', '20', '20', '20', '24', '24', '20', '20', '25', '25', '21', '21', '22', '22', '23', '23', '18'],
2733
+ ['26', '26', '26', '19', '19', '20', '20', '20', '20', '20', '25', '25', '21', '21', '21', '21', '21', '23', '27', '18'],
2734
+ ['28', '28', '28', '29', '29', '29', '29', '20', '20', '30', '30', '25', '31', '32', '32', '32', '21', '27', '27', '27'],
2735
+ ['28', '33', '28', '28', '28', '28', '29', '34', '34', '35', '30', '30', '31', '31', '31', '32', '32', '36', '36', '27'],
2736
+ ['28', '33', '33', '28', '28', '29', '29', '34', '34', '35', '35', '30', '31', '31', '31', '32', '36', '36', '27', '27'],
2737
+ ['28', '33', '37', '37', '28', '29', '34', '34', '35', '35', '38', '38', '39', '39', '40', '40', '40', '40', '27', '41'],
2738
+ ['28', '37', '37', '37', '42', '34', '34', '34', '43', '38', '38', '38', '39', '39', '44', '44', '40', '40', '27', '41'],
2739
+ ['37', '37', '42', '42', '42', '34', '34', '43', '43', '43', '38', '39', '39', '39', '44', '44', '27', '27', '27', '41'],
2740
+ ['45', '45', '45', '42', '46', '34', '34', '34', '34', '38', '38', '47', '47', '47', '44', '44', '44', '27', '27', '41'],
2741
+ ['48', '45', '45', '46', '46', '46', '46', '34', '49', '49', '49', '47', '44', '44', '44', '27', '44', '50', '27', '27'],
2742
+ ['48', '48', '45', '46', '46', '51', '46', '52', '52', '49', '49', '53', '44', '53', '44', '27', '50', '50', '50', '27'],
2743
+ ['48', '51', '51', '51', '51', '51', '52', '52', '52', '49', '53', '53', '53', '53', '44', '27', '27', '27', '27', '27']
2744
+ ])
2745
+ binst = solver.Board(board)
2746
+ solutions = binst.solve_then_constrain() # solve_then_constrain NOT solve_and_print (to use #1 instead of #2 in https://github.com/google/or-tools/discussions/3347, its faster in this case)
2747
+ ```
2748
+
2749
+ **Script Output**
2750
+
2751
+ ```python
2752
+ Solution found
2753
+ [
2754
+ ['X', 'X', 'X', ' ', ' ', 'X', 'X', 'X', ' ', ' ', ' ', ' ', ' ', 'X', ' ', 'X', ' ', 'X', 'X', ' '],
2755
+ [' ', 'X', ' ', ' ', 'X', 'X', ' ', ' ', ' ', 'X', ' ', ' ', 'X', 'X', ' ', 'X', 'X', 'X', ' ', ' '],
2756
+ ['X', 'X', 'X', 'X', 'X', ' ', ' ', 'X', 'X', 'X', ' ', ' ', 'X', ' ', ' ', 'X', ' ', ' ', 'X', ' '],
2757
+ ['X', ' ', ' ', ' ', 'X', ' ', 'X', 'X', ' ', 'X', 'X', ' ', 'X', 'X', 'X', 'X', ' ', 'X', 'X', ' '],
2758
+ [' ', ' ', ' ', 'X', 'X', 'X', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', ' ', ' ', ' ', 'X', ' ', ' '],
2759
+ ['X', ' ', 'X', 'X', ' ', 'X', ' ', ' ', 'X', 'X', 'X', ' ', 'X', 'X', ' ', 'X', 'X', 'X', 'X', ' '],
2760
+ ['X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', 'X', 'X', ' ', ' ', 'X', ' ', ' ', ' ', 'X'],
2761
+ ['X', 'X', ' ', 'X', ' ', 'X', ' ', ' ', 'X', 'X', ' ', 'X', ' ', ' ', ' ', 'X', ' ', 'X', ' ', 'X'],
2762
+ [' ', 'X', ' ', 'X', ' ', 'X', 'X', 'X', 'X', ' ', ' ', 'X', ' ', 'X', 'X', 'X', 'X', 'X', 'X', 'X'],
2763
+ ['X', 'X', 'X', 'X', 'X', 'X', ' ', ' ', ' ', ' ', 'X', 'X', 'X', 'X', ' ', ' ', ' ', 'X', ' ', 'X'],
2764
+ [' ', ' ', ' ', ' ', ' ', 'X', 'X', ' ', ' ', ' ', 'X', ' ', ' ', 'X', 'X', 'X', ' ', ' ', ' ', ' '],
2765
+ [' ', 'X', ' ', 'X', 'X', ' ', 'X', ' ', ' ', 'X', 'X', 'X', ' ', 'X', ' ', 'X', ' ', 'X', 'X', ' '],
2766
+ [' ', 'X', 'X', ' ', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', 'X', 'X', 'X', ' ', 'X', 'X', ' ', ' '],
2767
+ [' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', 'X', 'X', 'X', ' ', 'X', ' ', 'X', 'X', 'X', ' ', ' ', 'X'],
2768
+ [' ', 'X', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', 'X', ' ', ' ', 'X', ' ', ' ', 'X'],
2769
+ ['X', 'X', ' ', 'X', 'X', ' ', 'X', 'X', 'X', 'X', 'X', ' ', ' ', 'X', ' ', ' ', 'X', 'X', ' ', 'X'],
2770
+ [' ', 'X', 'X', 'X', ' ', ' ', 'X', ' ', ' ', ' ', 'X', 'X', 'X', 'X', 'X', ' ', ' ', 'X', 'X', 'X'],
2771
+ ['X', ' ', 'X', ' ', 'X', 'X', 'X', ' ', 'X', 'X', ' ', 'X', ' ', ' ', 'X', ' ', ' ', 'X', ' ', ' '],
2772
+ ['X', 'X', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', 'X', 'X', ' '],
2773
+ ['X', ' ', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', ' ', 'X', ' ', ' ', ' ', ' ', ' '],
2774
+ ]
2775
+ Solutions found: 1
2776
+ Time taken: 0.38 seconds
2777
+ ```
2778
+
2779
+ **Solved puzzle**
2780
+
2781
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/lits_solved.png" alt="Lits solved" width="500">
2782
+
2783
+ ---
2784
+
2690
2785
  ---
2691
2786
 
2692
2787
  ## Why SAT / CP-SAT?
@@ -2770,3 +2865,4 @@ Issues and PRs welcome!
2770
2865
  [30]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/kakurasu "puzzle_solver/src/puzzle_solver/puzzles/kakurasu at master · Ar-Kareem/puzzle_solver · GitHub"
2771
2866
  [31]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/star_battle "puzzle_solver/src/puzzle_solver/puzzles/star_battle at master · Ar-Kareem/puzzle_solver · GitHub"
2772
2867
  [32]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/star_battle_shapeless "puzzle_solver/src/puzzle_solver/puzzles/star_battle_shapeless at master · Ar-Kareem/puzzle_solver · GitHub"
2868
+ [33]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/lits "puzzle_solver/src/puzzle_solver/puzzles/lits at master · Ar-Kareem/puzzle_solver · GitHub"
@@ -1,8 +1,8 @@
1
- puzzle_solver/__init__.py,sha256=lETqp2Sgt2ua_j16Zp1WEE88el9-kz3Yah79K4--SEg,2370
2
- puzzle_solver/core/utils.py,sha256=3LlBDuie_G0uSlzibpQS2ULmEYSZmpJXh1kawj7rjkg,3396
3
- puzzle_solver/core/utils_ortools.py,sha256=qLTIzmITqmgGZvg8XpYAZ4c-lhD5sEDQfS8ECdQ_dkM,3005
1
+ puzzle_solver/__init__.py,sha256=3aQDyvMPWTlYKDo3j-v4x_lUcNbp7KyallMChQEeJY0,2429
2
+ puzzle_solver/core/utils.py,sha256=D7enPxJjnsTbGDqqtOtGaRaetwGs0nqrNtTnrqhMB-g,3408
3
+ puzzle_solver/core/utils_ortools.py,sha256=eoT9hSJe-c67A_hsu1jnMpyRgMrTtUs5n2j_m5Hk8Do,7362
4
4
  puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
5
- puzzle_solver/puzzles/battleships/battleships.py,sha256=J1Y-zG2TOmJePCwQnUF8uE9cXL5v8GDVEGv30bQls6k,7467
5
+ puzzle_solver/puzzles/battleships/battleships.py,sha256=6cYFHnOH7RYLfH5DFVz6SRPHY5GauB9YN4hPU7YNUHU,7444
6
6
  puzzle_solver/puzzles/bridges/bridges.py,sha256=15A9uV4xjoqPRo_9CTnoKeGRxS3z2aMF619T1n0dTOQ,5402
7
7
  puzzle_solver/puzzles/chess_range/chess_melee.py,sha256=D-_Oi8OyxsVe1j3dIKYwRlxgeb3NWLmDWGcv-oclY0c,195
8
8
  puzzle_solver/puzzles/chess_range/chess_range.py,sha256=uMQGTIwzGskHIhI-tPYjT9a3wHUBIkZ18eXjV9IpUE4,21071
@@ -17,11 +17,13 @@ puzzle_solver/puzzles/inertia/parse_map/parse_map.py,sha256=A9JQTNqamUdzlwqks0XQ
17
17
  puzzle_solver/puzzles/kakurasu/kakurasu.py,sha256=VNGMJnBHDi6WkghLObRLhUvkmrPaGphTTUDMC0TkQvQ,2064
18
18
  puzzle_solver/puzzles/keen/keen.py,sha256=tDb6C5S3Q0JAKPsdw-84WQ6PxRADELZHr_BK8FDH-NA,5039
19
19
  puzzle_solver/puzzles/light_up/light_up.py,sha256=iSA1rjZMFsnI0V0Nxivxox4qZkB7PvUrROSHXcoUXds,4541
20
+ puzzle_solver/puzzles/lits/lits.py,sha256=gYAcsuWucSer2JWs5eKOroiVmjfi-VzccmZvBIUIFks,12014
20
21
  puzzle_solver/puzzles/magnets/magnets.py,sha256=-Wl49JD_PKeq735zQVMQ3XSQX6gdHiY-7PKw-Sh16jw,6474
21
22
  puzzle_solver/puzzles/map/map.py,sha256=sxc57tapB8Tsgam-yoDitln1o-EB_SbIYvO6WEYy3us,2582
22
23
  puzzle_solver/puzzles/minesweeper/minesweeper.py,sha256=LiQVOGkWCsc1WtX8CdPgL_WwAcaeUFuoi5_eqH8U2Og,5876
23
24
  puzzle_solver/puzzles/mosaic/mosaic.py,sha256=QX_nVpVKQg8OfaUcqFk9tKqsDyVqvZc6-XWvfI3YcSw,2175
24
25
  puzzle_solver/puzzles/nonograms/nonograms.py,sha256=1jmDTOCnmivmBlwtMDyyk3TVqH5IjapzLn7zLQ4qubk,6056
26
+ puzzle_solver/puzzles/norinori/norinori.py,sha256=Z2c0iEn7a6S6gaaJlvNMNNbAQwpztNLB0LTH_XVgu74,12269
25
27
  puzzle_solver/puzzles/pearl/pearl.py,sha256=AP0whWwwZ-1zKingW14OwseYylNAr6NkXSrvdnPU6Rw,8566
26
28
  puzzle_solver/puzzles/range/range.py,sha256=g6ZuHuulYLpNFsqbnPoIB5KoGPllYppU10-Zzqfj5f8,6993
27
29
  puzzle_solver/puzzles/signpost/signpost.py,sha256=-0_S6ycwzwlUf9-ZhP127Rgo5gMBOHiTM6t08dLLDac,3869
@@ -29,7 +31,7 @@ puzzle_solver/puzzles/singles/singles.py,sha256=kwMENfqQ-OP3YIz5baY6LRcvYCsNfhIm
29
31
  puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
30
32
  puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
31
33
  puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
32
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=kbxTtJlfmE1IEA8Km3rALsfKXFl-0io1vd3d0Z-0OVo,8984
34
+ puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=VWHT-iYDaFsd37h9DE07EkeZ_dJMEfatXSByqC2vh04,8916
33
35
  puzzle_solver/puzzles/sudoku/sudoku.py,sha256=M_pry7XyKKzlfCF5rFi02lyOrj5GWZzXnDAxmD3NXvI,3588
34
36
  puzzle_solver/puzzles/tents/tents.py,sha256=iyVK2WXfIT5j_9qqlQg0WmwvixwXlZSsHGK3XA-KpII,6283
35
37
  puzzle_solver/puzzles/thermometers/thermometers.py,sha256=nsvJZkm7G8FALT27bpaB0lv5E_AWawqmvapQI8QcYXw,4015
@@ -37,8 +39,8 @@ puzzle_solver/puzzles/towers/towers.py,sha256=QvL0Pp-Z2ewCeq9ZkNrh8MShKOh-Y52sFB
37
39
  puzzle_solver/puzzles/tracks/tracks.py,sha256=0K1YZMHiRIMmFwoD_JxB2c_xB6GYV8spgNUCL-JwDJM,9073
38
40
  puzzle_solver/puzzles/undead/undead.py,sha256=IrCUfzQFBem658P5KKqldG7vd2TugTHehcwseCarerM,6604
39
41
  puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUyNhGSAu7cQ,3827
40
- puzzle_solver/utils/visualizer.py,sha256=2LmNoxEqb9PGWmBmHW6jh6OqFgNYguavhHB-9Dv5EUw,6113
41
- multi_puzzle_solver-0.9.10.dist-info/METADATA,sha256=i3hIJF1eUrTp9KHEO0d-e4KuIxZcrxRKSIzDZDRATCI,136364
42
- multi_puzzle_solver-0.9.10.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
43
- multi_puzzle_solver-0.9.10.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
44
- multi_puzzle_solver-0.9.10.dist-info/RECORD,,
42
+ puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
43
+ multi_puzzle_solver-0.9.13.dist-info/METADATA,sha256=x82w11xGJo2Rb4cGGQkmlbQNZ1i7MPGuhxCjOyaj61Y,143181
44
+ multi_puzzle_solver-0.9.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
45
+ multi_puzzle_solver-0.9.13.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
46
+ multi_puzzle_solver-0.9.13.dist-info/RECORD,,
puzzle_solver/__init__.py CHANGED
@@ -16,6 +16,7 @@ from puzzle_solver.puzzles.map import map as map_solver
16
16
  from puzzle_solver.puzzles.minesweeper import minesweeper as minesweeper_solver
17
17
  from puzzle_solver.puzzles.mosaic import mosaic as mosaic_solver
18
18
  from puzzle_solver.puzzles.nonograms import nonograms as nonograms_solver
19
+ from puzzle_solver.puzzles.lits import lits as lits_solver
19
20
  from puzzle_solver.puzzles.pearl import pearl as pearl_solver
20
21
  from puzzle_solver.puzzles.range import range as range_solver
21
22
  from puzzle_solver.puzzles.signpost import signpost as signpost_solver
@@ -33,4 +34,4 @@ from puzzle_solver.puzzles.unruly import unruly as unruly_solver
33
34
 
34
35
  from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
35
36
 
36
- __version__ = '0.9.10'
37
+ __version__ = '0.9.13'
@@ -21,7 +21,7 @@ class Direction8(Enum):
21
21
  DOWN_LEFT = 7
22
22
  DOWN_RIGHT = 8
23
23
 
24
- @dataclass(frozen=True)
24
+ @dataclass(frozen=True, order=True)
25
25
  class Pos:
26
26
  x: int
27
27
  y: int
@@ -79,4 +79,94 @@ def generic_solve_all(board: Any, board_to_solution: Callable[Any, SingleSolutio
79
79
  return collector.solutions
80
80
  except Exception as e:
81
81
  print(e)
82
- raise 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,4 +1,3 @@
1
- from enum import Enum
2
1
  from dataclasses import dataclass, field
3
2
  from typing import Optional
4
3
  import numpy as np
@@ -0,0 +1,255 @@
1
+ import json
2
+ import time
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Union
5
+
6
+ from ortools.sat.python import cp_model
7
+ import numpy as np
8
+
9
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, in_bounds, Direction, get_next_pos
10
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
11
+
12
+
13
+ # a shape on the 2d board is just a set of positions
14
+ Shape = frozenset[Pos]
15
+
16
+
17
+ def polyominoes(N):
18
+ """Generate all polyominoes of size N. Every rotation and reflection is considered different and included in the result.
19
+ Translation is not considered different and is removed from the result (otherwise the result would be infinite).
20
+
21
+ Below is the number of unique polyominoes of size N (not including rotations and reflections) and the lenth of the returned result (which includes all rotations and reflections)
22
+ N name #shapes #results
23
+ 1 monomino 1 1
24
+ 2 domino 1 2
25
+ 3 tromino 2 6
26
+ 4 tetromino 5 19
27
+ 5 pentomino 12 63
28
+ 6 hexomino 35 216
29
+ 7 heptomino 108 760
30
+ 8 octomino 369 2,725
31
+ 9 nonomino 1,285 9,910
32
+ 10 decomino 4,655 36,446
33
+ 11 undecomino 17,073 135,268
34
+ 12 dodecomino 63,600 505,861
35
+ Source: https://en.wikipedia.org/wiki/Polyomino
36
+
37
+ Args:
38
+ N (int): The size of the polyominoes to generate.
39
+
40
+ Returns:
41
+ set[(frozenset[Pos], int)]: A set of all polyominoes of size N (rotated and reflected up to D4 symmetry) along with a unique ID for each polyomino.
42
+ """
43
+ assert N >= 1, 'N cannot be less than 1'
44
+ # need a frozenset because regular sets are not hashable
45
+ shapes: set[Shape] = {frozenset({Pos(0, 0)})}
46
+ for i in range(1, N):
47
+ next_shapes: set[Shape] = set()
48
+ for s in shapes:
49
+ # frontier: all 4-neighbors of existing cells not already in the shape
50
+ frontier = {get_next_pos(pos, direction)
51
+ for pos in s
52
+ for direction in Direction
53
+ if get_next_pos(pos, direction) not in s}
54
+ for cell in frontier:
55
+ t = s | {cell}
56
+ # normalize by translation only: shift so min x,y is (0,0). This removes translational symmetries.
57
+ minx = min(pos.x for pos in t)
58
+ miny = min(pos.y for pos in t)
59
+ t0 = frozenset(Pos(x=pos.x - minx, y=pos.y - miny) for pos in t)
60
+ next_shapes.add(t0)
61
+ shapes = next_shapes
62
+ # shapes is now complete, now classify up to D4 symmetry (rotations/reflections), translations ignored
63
+ mats = (
64
+ ( 1, 0, 0, 1), # regular
65
+ (-1, 0, 0, 1), # reflect about x
66
+ ( 1, 0, 0,-1), # reflect about y
67
+ (-1, 0, 0,-1), # reflect about x and y
68
+ # trnaspose then all 4 above
69
+ ( 0, 1, 1, 0), ( 0, 1, -1, 0), ( 0,-1, 1, 0), ( 0,-1, -1, 0),
70
+ )
71
+ # compute canonical representative for each shape (lexicographically smallest normalized transform)
72
+ shape_to_canon: dict[Shape, tuple[Pos, ...]] = {}
73
+ for s in shapes:
74
+ reps: list[tuple[Pos, ...]] = []
75
+ for a, b, c, d in mats:
76
+ pts = {Pos(x=a*p.x + b*p.y, y=c*p.x + d*p.y) for p in s}
77
+ minx = min(p.x for p in pts)
78
+ miny = min(p.y for p in pts)
79
+ rep = tuple(sorted(Pos(x=p.x - minx, y=p.y - miny) for p in pts))
80
+ reps.append(rep)
81
+ canon = min(reps)
82
+ shape_to_canon[s] = canon
83
+
84
+ canon_set = set(shape_to_canon.values())
85
+ canon_to_id = {canon: i for i, canon in enumerate(sorted(canon_set))}
86
+ result = {(s, canon_to_id[shape_to_canon[s]]) for s in shapes}
87
+ return result
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class SingleSolution:
92
+ assignment: dict[Pos, Union[str, int]]
93
+ all_other_variables: dict
94
+
95
+ def get_hashable_solution(self) -> str:
96
+ result = []
97
+ for pos, v in self.assignment.items():
98
+ result.append((pos.x, pos.y, v))
99
+ return json.dumps(result, sort_keys=True)
100
+
101
+
102
+
103
+ @dataclass
104
+ class ShapeOnBoard:
105
+ is_active: cp_model.IntVar
106
+ shape: Shape
107
+ shape_id: int
108
+ body: set[Pos]
109
+ disallow_same_shape: set[Pos]
110
+
111
+
112
+ class Board:
113
+ def __init__(self, board: np.array, polyomino_degrees: int = 4):
114
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
115
+ self.V = board.shape[0]
116
+ self.H = board.shape[1]
117
+ assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
118
+ self.board = board
119
+ self.polyomino_degrees = polyomino_degrees
120
+ self.polyominoes = polyominoes(self.polyomino_degrees)
121
+
122
+ self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
123
+ self.blocks = {i: set() for i in self.block_numbers}
124
+ for cell in get_all_pos(self.V, self.H):
125
+ self.blocks[int(get_char(self.board, cell))].add(cell)
126
+
127
+ self.model = cp_model.CpModel()
128
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
129
+ self.connected_components: dict[Pos, cp_model.IntVar] = {}
130
+ self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
131
+
132
+ self.create_vars()
133
+ self.init_shapes_on_board()
134
+ self.add_all_constraints()
135
+
136
+ def create_vars(self):
137
+ for pos in get_all_pos(self.V, self.H):
138
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
139
+ # print('base vars:', len(self.model_vars))
140
+
141
+ def init_shapes_on_board(self):
142
+ for idx, (shape, shape_id) in enumerate(self.polyominoes):
143
+ for translate in get_all_pos(self.V, self.H): # body of shape is translated to be at pos
144
+ body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape}
145
+ if any(not in_bounds(p, self.V, self.H) for p in body):
146
+ continue
147
+ # shape must be fully contained in one block
148
+ if len(set(get_char(self.board, p) for p in body)) > 1:
149
+ continue
150
+ # 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
151
+ disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
152
+ disallow_same_shape -= body
153
+ self.shapes_on_board.append(ShapeOnBoard(
154
+ is_active=self.model.NewBoolVar(f'{idx}:{translate}:is_active'),
155
+ shape=shape,
156
+ shape_id=shape_id,
157
+ body=body,
158
+ disallow_same_shape=disallow_same_shape,
159
+ ))
160
+ # print('shapes on board:', len(self.shapes_on_board))
161
+
162
+ def add_all_constraints(self):
163
+ # RULES:
164
+ # 1- You have to place one tetromino in each region in such a way that:
165
+ # 2- 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
166
+ # 3- The shaded cells should form a single connected area.
167
+ # 4- 2x2 shaded areas are not allowed
168
+
169
+ # each cell must be part of a shape, every shape must be fully on the board. Core constraint, otherwise shapes on the board make no sense.
170
+ self.only_allow_shapes_on_board()
171
+
172
+ self.force_one_shape_per_block() # Rule #1
173
+ self.disallow_same_shape_touching() # Rule #2
174
+ self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
175
+ # print('force connected vars:', len(fc))
176
+ shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
177
+ self.disallow_shape(shape_2_by_2) # Rule #4
178
+
179
+
180
+ def only_allow_shapes_on_board(self):
181
+ for shape_on_board in self.shapes_on_board:
182
+ # if shape is active then all its body cells must be active
183
+ self.model.Add(sum(self.model_vars[p] for p in shape_on_board.body) == len(shape_on_board.body)).OnlyEnforceIf(shape_on_board.is_active)
184
+ # each cell must be part of a shape
185
+ for p in get_all_pos(self.V, self.H):
186
+ shapes_on_p = [s for s in self.shapes_on_board if p in s.body]
187
+ self.model.Add(sum(s.is_active for s in shapes_on_p) == 1).OnlyEnforceIf(self.model_vars[p])
188
+
189
+ def force_one_shape_per_block(self):
190
+ # You have to place exactly one tetromino in each region
191
+ for block_i in self.block_numbers:
192
+ shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
193
+ assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
194
+ # print(f'shapes on block {block_i} has {len(shapes_on_block)} shapes')
195
+ self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
196
+
197
+ def disallow_same_shape_touching(self):
198
+ # if shape is active then it must not touch any other shape of the same type
199
+ for shape_on_board in self.shapes_on_board:
200
+ similar_shapes = [s for s in self.shapes_on_board if s.shape_id == shape_on_board.shape_id]
201
+ for s in similar_shapes:
202
+ if shape_on_board.disallow_same_shape & s.body: # this shape disallows having s be on the board
203
+ self.model.Add(s.is_active == 0).OnlyEnforceIf(shape_on_board.is_active)
204
+
205
+ def disallow_shape(self, shape_to_disallow: Shape):
206
+ # for every position in the board, force sum of body < len(body)
207
+ for translate in get_all_pos(self.V, self.H):
208
+ cur_body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape_to_disallow}
209
+ if any(not in_bounds(p, self.V, self.H) for p in cur_body):
210
+ continue
211
+ self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
212
+
213
+
214
+
215
+
216
+ def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
217
+ if verbose_callback is None:
218
+ verbose_callback = verbose
219
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
220
+ assignment: dict[Pos, int] = {}
221
+ for pos, var in board.model_vars.items():
222
+ assignment[pos] = solver.Value(var)
223
+ all_other_variables = {
224
+ 'fc': {k: solver.Value(v) for k, v in board.fc.items()}
225
+ }
226
+ return SingleSolution(assignment=assignment, all_other_variables=all_other_variables)
227
+ def callback(single_res: SingleSolution):
228
+ print("Solution found")
229
+ res = np.full((self.V, self.H), ' ', dtype=str)
230
+ for pos, val in single_res.assignment.items():
231
+ c = 'X' if val == 1 else ' '
232
+ set_char(res, pos, c)
233
+ print('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
234
+ pass
235
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
236
+
237
+ def solve_then_constrain(self, verbose: bool = True):
238
+ tic = time.time()
239
+ all_solutions = []
240
+ while True:
241
+ solutions = self.solve_and_print(verbose=False, verbose_callback=verbose, max_solutions=1)
242
+ if len(solutions) == 0:
243
+ break
244
+ all_solutions.extend(solutions)
245
+ assignment = solutions[0].assignment
246
+ # constrain the board to not return the same solution again
247
+ lits = [self.model_vars[p].Not() if assignment[p] == 1 else self.model_vars[p] for p in assignment.keys()]
248
+ self.model.AddBoolOr(lits)
249
+ self.model.ClearHints()
250
+ for k, v in solutions[0].all_other_variables['fc'].items():
251
+ self.model.AddHint(self.fc[k], v)
252
+ print(f'Solutions found: {len(all_solutions)}')
253
+ toc = time.time()
254
+ print(f'Time taken: {toc - tic:.2f} seconds')
255
+ return all_solutions