multi-puzzle-solver 0.9.24__py3-none-any.whl → 0.9.25__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.24
3
+ Version: 0.9.25
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
@@ -326,6 +326,11 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
326
326
  <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/rectangles_solved.png" alt="Rectangles" width="140">
327
327
  </a>
328
328
  </td>
329
+ <td align="center">
330
+ <a href="#palisade-puzzle-type-43"><b>Palisade</b><br><br>
331
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/palisade_solved.png" alt="Palisade" width="140">
332
+ </a>
333
+ </td>
329
334
  </tr>
330
335
  </table>
331
336
 
@@ -383,6 +388,7 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
383
388
  - [Yin-Yang (Puzzle Type #40)](#yin-yang-puzzle-type-40)
384
389
  - [Binairo (Puzzle Type #41)](#binairo-puzzle-type-41)
385
390
  - [Rectangles (Puzzle Type #42)](#rectangles-puzzle-type-42)
391
+ - [Palisade (Puzzle Type #43)](#palisade-puzzle-type-43)
386
392
  - [Why SAT / CP-SAT?](#why-sat--cp-sat)
387
393
  - [Testing](#testing)
388
394
  - [Contributing](#contributing)
@@ -3317,9 +3323,13 @@ Applying the solution to the puzzle visually:
3317
3323
 
3318
3324
  ## Slitherlink (Puzzle Type #39)
3319
3325
 
3320
- Also known as Fences and Loop the Loop
3326
+ Also known as Fences, Loop the Loop, and Loopy
3321
3327
 
3322
- * [**Play online**](https://www.puzzle-loop.com)
3328
+ * [**Play online 1**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/loopy.html)
3329
+
3330
+ * [**Play online 2**](https://www.puzzle-loop.com)
3331
+
3332
+ * [**Instructions**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/doc/loopy.html#loopy)
3323
3333
 
3324
3334
  * [**Solver Code**][39]
3325
3335
 
@@ -3720,6 +3730,95 @@ Applying the solution to the puzzle visually:
3720
3730
 
3721
3731
  ---
3722
3732
 
3733
+
3734
+ ## Palisade (Puzzle Type #43)
3735
+
3736
+ * [**Play online**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/palisade.html)
3737
+
3738
+ * [**Instructions**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/doc/palisade.html#palisade)
3739
+
3740
+ * [**Solver Code**][42]
3741
+
3742
+ <details>
3743
+ <summary><strong>Rules</strong></summary>
3744
+
3745
+ You're given a grid of N squares and a region size M, some of which contain numbers. Your goal is to subdivide the grid into (N/M) contiguous regions, where every region is of size M, such that each square containing a number is adjacent to exactly that many edges (including those between the inside and the outside of the grid).
3746
+
3747
+ </details>
3748
+
3749
+ **Unsolved puzzle**
3750
+
3751
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/palisade_unsolved.png" alt="Palisade unsolved" width="500">
3752
+
3753
+ Code to utilize this package and solve the puzzle:
3754
+
3755
+ (Note: it takes a few seconds for the model to be built if the region size is larger than 8 and around 10 seconds for a region size of 10)
3756
+
3757
+ ```python
3758
+ import numpy as np
3759
+ from puzzle_solver import palisade_solver as solver
3760
+ board = np.array([
3761
+ ['2', ' ', ' ', ' ', ' ', '3', ' ', ' ', '1', '1', '3', ' ', ' ', ' ', ' '],
3762
+ ['3', '2', '1', ' ', '2', '3', ' ', ' ', ' ', ' ', ' ', '2', ' ', '0', ' '],
3763
+ [' ', ' ', ' ', '1', '1', ' ', ' ', '1', ' ', ' ', ' ', '1', ' ', ' ', ' '],
3764
+ [' ', '3', '2', ' ', ' ', ' ', ' ', '2', '3', ' ', ' ', ' ', '1', ' ', ' '],
3765
+ [' ', '0', '1', ' ', '2', ' ', ' ', '0', ' ', ' ', ' ', '1', ' ', '3', '2'],
3766
+ ['1', '0', ' ', ' ', ' ', '2', '2', ' ', '2', ' ', '3', ' ', '0', '2', ' '],
3767
+ [' ', ' ', ' ', ' ', ' ', '3', ' ', ' ', ' ', '2', ' ', ' ', ' ', ' ', ' '],
3768
+ [' ', '1', ' ', ' ', ' ', '3', '1', ' ', '1', ' ', ' ', ' ', ' ', '1', ' '],
3769
+ [' ', ' ', ' ', '0', ' ', ' ', '0', ' ', ' ', '1', '2', ' ', ' ', ' ', '3'],
3770
+ [' ', ' ', ' ', ' ', ' ', ' ', '1', ' ', ' ', '2', ' ', ' ', '1', '2', '1'],
3771
+ [' ', ' ', ' ', ' ', '1', ' ', '2', '3', '1', ' ', ' ', ' ', '2', ' ', '1'],
3772
+ ['2', ' ', '1', ' ', '2', '2', '1', ' ', ' ', '2', ' ', ' ', ' ', ' ', ' '],
3773
+ ])
3774
+ binst = solver.Board(board, region_size=10)
3775
+ solutions = binst.solve_and_print()
3776
+ ```
3777
+
3778
+ **Script Output**
3779
+
3780
+ ```python
3781
+ Solution found
3782
+ 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1
3783
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4
3784
+ ┌───────────────────┬───────────────────────┬───────────────┐
3785
+ 0│ 2 · · · · │ 3 · · 1 1 3 │ · · · · │
3786
+ │ ┌───────────┐ ├───────┬───┐ ┌───┴───┐ │
3787
+ 1│ 3 │ 2 1 · │ 2 │ 3 · │ · │ · · │ · 2 │ · 0 · │
3788
+ ├───┘ │ └───┐ │ └───┐ └───┐ └───┐ │
3789
+ 2│ · · · 1 │ 1 · │ · │ 1 · │ · · │ 1 · │ · · │
3790
+ │ ┌───┐ │ ┌───┘ │ ┌───┴───────┘ └───┐ │
3791
+ 3│ · │ 3 │ 2 · │ · │ · · │ 2 │ 3 · · · 1 · │ · │
3792
+ ├───┘ └───────┼───┘ ┌───┘ └───┬───────────────┬───┴───┤
3793
+ 4│ · 0 1 · │ 2 · │ · 0 · │ · · 1 · │ 3 2 │
3794
+ │ ┌───┘ │ │ ┌───┐ └───┐ │
3795
+ 5│ 1 0 · │ · · 2 │ 2 · 2 │ · │ 3 │ · 0 2 │ · │
3796
+ │ ┌───┴───────────┼───┬───────┴───┤ ├───┐ │ │
3797
+ 6│ · · │ · · · 3 │ · │ · · 2 │ · │ · │ · · │ · │
3798
+ ├───────┘ ┌───────────┤ └───┐ │ │ └───────┘ │
3799
+ 7│ · 1 · │ · · 3 │ 1 · │ 1 · │ · │ · · 1 · │
3800
+ │ ┌───┘ ┌───┘ │ │ └───────┐ ┌───┤
3801
+ 8│ · · │ · 0 · │ · 0 · │ · 1 │ 2 · · │ · │ 3 │
3802
+ │ ┌───┘ ┌───┤ ├───┐ └───┐ ├───┘ │
3803
+ 9│ · │ · · · │ · │ · 1 · │ · │ 2 · │ · 1 │ 2 1 │
3804
+ ├───┤ ┌───────┘ ├───────┐ │ └───┐ │ │ │
3805
+ 10│ · │ · │ · · 1 │ · 2 │ 3 │ 1 · │ · │ · 2 │ · 1 │
3806
+ │ └───┘ │ └───┘ ├───┴───────┘ │
3807
+ 11│ 2 · 1 · 2 │ 2 1 · · 2 │ · · · · · │
3808
+ └───────────────────┴───────────────────┴───────────────────┘
3809
+ Solutions found: 1
3810
+ status: OPTIMAL
3811
+ Time taken: 11.94 seconds
3812
+ ```
3813
+
3814
+ **Solved puzzle**
3815
+
3816
+ Applying the solution to the puzzle visually:
3817
+
3818
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/palisade_solved.png" alt="Palisade solved" width="500">
3819
+
3820
+ ---
3821
+
3723
3822
  ---
3724
3823
 
3725
3824
  ## Why SAT / CP-SAT?
@@ -3813,3 +3912,4 @@ Issues and PRs welcome!
3813
3912
  [40]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/yin_yang "puzzle_solver/src/puzzle_solver/puzzles/yin_yang at master · Ar-Kareem/puzzle_solver · GitHub"
3814
3913
  [41]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/binairo "puzzle_solver/src/puzzle_solver/puzzles/binairo at master · Ar-Kareem/puzzle_solver · GitHub"
3815
3914
  [42]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/rectangles "puzzle_solver/src/puzzle_solver/puzzles/rectangles at master · Ar-Kareem/puzzle_solver · GitHub"
3915
+ [43]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/palisade "puzzle_solver/src/puzzle_solver/puzzles/palisade at master · Ar-Kareem/puzzle_solver · GitHub"
@@ -1,6 +1,6 @@
1
- puzzle_solver/__init__.py,sha256=y6x9mMvglHDRKhkRXIudfXFgJ0KGBPo3jqi1YT_B6wc,3071
2
- puzzle_solver/core/utils.py,sha256=BH4b-GZLfYWWZ4QPt1UcuwSX3ntE3bInJtwHd7RnVf4,13459
3
- puzzle_solver/core/utils_ortools.py,sha256=2xEL9cMEKmNhRD9lhr2nGdZ3Lbmc9cnHY8xv6iLhUr0,10542
1
+ puzzle_solver/__init__.py,sha256=DsQVO-Eo1odFFFvQB1IpbJw-Yr2MTtON6zMnLUi05P8,3203
2
+ puzzle_solver/core/utils.py,sha256=7Wo8_LHLEv8bY5-HsuCuLIjttZMMW09DoL1CcFDiu1Q,14046
3
+ puzzle_solver/core/utils_ortools.py,sha256=_i8cixHOB5XGqqcr-493bOiZgYJidnvxQMEfj--Trns,10278
4
4
  puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
5
5
  puzzle_solver/puzzles/battleships/battleships.py,sha256=RuYCrs4j0vUjlU139NRYYP-uNPAgO0V7hAzbsHrRwD8,7446
6
6
  puzzle_solver/puzzles/binairo/binairo.py,sha256=sRtflnlGrN8xQ64beRZBGr74R8KptzxYDdFgXuW27pM,4595
@@ -12,6 +12,7 @@ puzzle_solver/puzzles/chess_range/chess_solo.py,sha256=U3v766UsZHx_dC3gxqU90VbjA
12
12
  puzzle_solver/puzzles/chess_sequence/chess_sequence.py,sha256=6ap3Wouf2PxHV4P56B9ol1QT98Ym6VHaxorQZWl6LnY,13692
13
13
  puzzle_solver/puzzles/dominosa/dominosa.py,sha256=Nmb7pn8U27QJwGy9F3wo8ylqo2_U51OAo3GN2soaNpc,7195
14
14
  puzzle_solver/puzzles/filling/filling.py,sha256=vrOIil285_r3IQ0F4c9mUBWMRVlPH4vowog_z1tCGdI,5567
15
+ puzzle_solver/puzzles/flip/flip.py,sha256=4rQ-JsC_f33YKDM7aueKVlcVdDwzeBkTJL51K-Vy0gA,2223
15
16
  puzzle_solver/puzzles/galaxies/galaxies.py,sha256=p10lpmW0FjtneFCMEjG1FSiEpQuvD8zZG9FG8zYGoes,5582
16
17
  puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=v5TCrdREeOB69s9_QFgPHKA7flG69Im1HVzIdxH0qQc,9355
17
18
  puzzle_solver/puzzles/guess/guess.py,sha256=sH-NlYhxM3DNbhk4eGde09kgM0KaDvSbLrpHQiwcFGo,10791
@@ -21,13 +22,14 @@ puzzle_solver/puzzles/inertia/parse_map/parse_map.py,sha256=A9JQTNqamUdzlwqks0XQ
21
22
  puzzle_solver/puzzles/kakurasu/kakurasu.py,sha256=VNGMJnBHDi6WkghLObRLhUvkmrPaGphTTUDMC0TkQvQ,2064
22
23
  puzzle_solver/puzzles/keen/keen.py,sha256=tDb6C5S3Q0JAKPsdw-84WQ6PxRADELZHr_BK8FDH-NA,5039
23
24
  puzzle_solver/puzzles/light_up/light_up.py,sha256=iSA1rjZMFsnI0V0Nxivxox4qZkB7PvUrROSHXcoUXds,4541
24
- puzzle_solver/puzzles/lits/lits.py,sha256=6Yp9EqhQpuWz_rc_rXtu9dG_pUrmBAhWj1Q9IyAfxPk,7891
25
+ puzzle_solver/puzzles/lits/lits.py,sha256=3fPIkhAIUz8JokcfaE_ZM3b0AFEnf5xPzGJ2qnm8SWY,7099
25
26
  puzzle_solver/puzzles/magnets/magnets.py,sha256=-Wl49JD_PKeq735zQVMQ3XSQX6gdHiY-7PKw-Sh16jw,6474
26
27
  puzzle_solver/puzzles/map/map.py,sha256=sxc57tapB8Tsgam-yoDitln1o-EB_SbIYvO6WEYy3us,2582
27
28
  puzzle_solver/puzzles/minesweeper/minesweeper.py,sha256=LiQVOGkWCsc1WtX8CdPgL_WwAcaeUFuoi5_eqH8U2Og,5876
28
29
  puzzle_solver/puzzles/mosaic/mosaic.py,sha256=QX_nVpVKQg8OfaUcqFk9tKqsDyVqvZc6-XWvfI3YcSw,2175
29
30
  puzzle_solver/puzzles/nonograms/nonograms.py,sha256=1jmDTOCnmivmBlwtMDyyk3TVqH5IjapzLn7zLQ4qubk,6056
30
31
  puzzle_solver/puzzles/norinori/norinori.py,sha256=uC8vXAw35xsTmpmTeKqYW7tbcssms9LCcXFBONtV2Ng,4743
32
+ puzzle_solver/puzzles/palisade/palisade.py,sha256=ZFvBnBVbR0iIcQ5Vm3PtHPjdSDvrO5OUbM91YoTKpHI,4962
31
33
  puzzle_solver/puzzles/pearl/pearl.py,sha256=OhzpMYpxqvR3GCd5NH4ETT0NO4X753kRi6p5omYLChM,6798
32
34
  puzzle_solver/puzzles/range/range.py,sha256=rruvD5ZSaOgvQuX6uGV_Dkr82nSiWZ5kDz03_j7Tt24,4425
33
35
  puzzle_solver/puzzles/rectangles/rectangles.py,sha256=V7p6GSCwYrFfILDWiLLUbX08WlnPbQKdhQm8bMa2Mgw,7060
@@ -35,7 +37,7 @@ puzzle_solver/puzzles/signpost/signpost.py,sha256=-0_S6ycwzwlUf9-ZhP127Rgo5gMBOH
35
37
  puzzle_solver/puzzles/singles/singles.py,sha256=3wACiUa1Vmh2ce6szQ2hPjyBuH7aHiQ888p4R2jFkW4,3342
36
38
  puzzle_solver/puzzles/slant/slant.py,sha256=xF-N4PuXYfx638NP1f1mi6YncIZB4mLtXtdS79XyPbg,6122
37
39
  puzzle_solver/puzzles/slant/parse_map/parse_map.py,sha256=dxnALSDXe9wU0uSD0QEXnzoh1q801mj1ePTNLtG0n60,4796
38
- puzzle_solver/puzzles/slitherlink/slitherlink.py,sha256=_P5IyDKs8gP9aubCW5QStOv4TGf0Hkq7ybyjkxw5n_U,6856
40
+ puzzle_solver/puzzles/slitherlink/slitherlink.py,sha256=N3jv1Z-yYFlQDinii-DZfuJvLUsn9fT0h5Kyruxjn94,7017
39
41
  puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
40
42
  puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
41
43
  puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
@@ -51,7 +53,7 @@ puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUy
51
53
  puzzle_solver/puzzles/yin_yang/yin_yang.py,sha256=WrRdNhmKhIARdGOt_36gpRxRzrfLGv3wl7igBpPFM64,5259
52
54
  puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py,sha256=drjfoHqmFf6U-ZQUwrBbfGINRxDQpgbvy4U3D9QyMhM,6617
53
55
  puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
54
- multi_puzzle_solver-0.9.24.dist-info/METADATA,sha256=mcSsOot7iHAhhc2fangljbrIpGvDsuwc80_ifFRXJ7s,202273
55
- multi_puzzle_solver-0.9.24.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
56
- multi_puzzle_solver-0.9.24.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
57
- multi_puzzle_solver-0.9.24.dist-info/RECORD,,
56
+ multi_puzzle_solver-0.9.25.dist-info/METADATA,sha256=DcVaQpmwyhYN0y0XxOcvomrpoerVjgYdylE8VUFml04,208538
57
+ multi_puzzle_solver-0.9.25.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
58
+ multi_puzzle_solver-0.9.25.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
59
+ multi_puzzle_solver-0.9.25.dist-info/RECORD,,
puzzle_solver/__init__.py CHANGED
@@ -8,6 +8,7 @@ from puzzle_solver.puzzles.chess_range import chess_solo as chess_solo_solver
8
8
  from puzzle_solver.puzzles.chess_range import chess_melee as chess_melee_solver
9
9
  from puzzle_solver.puzzles.dominosa import dominosa as dominosa_solver
10
10
  from puzzle_solver.puzzles.filling import filling as filling_solver
11
+ # from puzzle_solver.puzzles.flip import flip as flip_solver
11
12
  from puzzle_solver.puzzles.galaxies import galaxies as galaxies_solver
12
13
  from puzzle_solver.puzzles.guess import guess as guess_solver
13
14
  from puzzle_solver.puzzles.inertia import inertia as inertia_solver
@@ -20,6 +21,7 @@ from puzzle_solver.puzzles.minesweeper import minesweeper as minesweeper_solver
20
21
  from puzzle_solver.puzzles.mosaic import mosaic as mosaic_solver
21
22
  from puzzle_solver.puzzles.nonograms import nonograms as nonograms_solver
22
23
  from puzzle_solver.puzzles.norinori import norinori as norinori_solver
24
+ from puzzle_solver.puzzles.palisade import palisade as palisade_solver
23
25
  from puzzle_solver.puzzles.lits import lits as lits_solver
24
26
  from puzzle_solver.puzzles.pearl import pearl as pearl_solver
25
27
  from puzzle_solver.puzzles.range import range as range_solver
@@ -43,4 +45,4 @@ from puzzle_solver.puzzles.yin_yang import yin_yang as yin_yang_solver
43
45
 
44
46
  from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
45
47
 
46
- __version__ = '0.9.24'
48
+ __version__ = '0.9.25'
@@ -133,7 +133,7 @@ def get_deltas(direction: Union[Direction, Direction8]) -> Tuple[int, int]:
133
133
  raise ValueError(f'invalid direction: {direction}')
134
134
 
135
135
 
136
- def polyominoes(N):
136
+ def polyominoes(N) -> set[Shape]:
137
137
  """Generate all polyominoes of size N. Every rotation and reflection is considered different and included in the result.
138
138
  Translation is not considered different and is removed from the result (otherwise the result would be infinite).
139
139
 
@@ -165,7 +165,7 @@ def polyominoes(N):
165
165
  shapes: set[FastShape] = {frozenset({(0, 0)})}
166
166
  for i in range(1, N):
167
167
  next_shapes: set[FastShape] = set()
168
- directions = ((1,0),(-1,0),(0,1)) if i > 1 else (((1,0),(0,1))) # cannot take left on first step, if confused read: https://louridas.github.io/rwa/assignments/polyominoes/
168
+ directions = ((1,0),(-1,0),(0,1),(0,-1)) if i > 1 else (((1,0),(0,1))) # cannot take left on first step, if confused read: https://louridas.github.io/rwa/assignments/polyominoes/
169
169
  for s in shapes:
170
170
  # frontier of a single shape: all 4-neighbors of existing cells not already in the shape
171
171
  frontier = set()
@@ -239,125 +239,145 @@ def render_grid(cell_flags: np.ndarray,
239
239
  center_char: np.ndarray of shape (N, N) with the center of the cells, or a string to use for all cells, or None to not show centers.
240
240
  scale_x: horizontal stretch factor (>=1). Try 2 or 3 for squarer cells.
241
241
  """
242
- if cell_flags is not None:
243
- N = cell_flags.shape[0]
244
- H = np.zeros((N+1, N), dtype=bool)
245
- V = np.zeros((N, N+1), dtype=bool)
246
- for r in range(N):
247
- for c in range(N):
248
- s = cell_flags[r, c]
249
- if 'U' in s: H[r, c] = True # edge between (r,c) and (r, c+1) above the cell
250
- if 'D' in s: H[r+1, c] = True # edge below the cell
251
- if 'L' in s: V[r, c] = True # edge left of the cell
252
- if 'R' in s: V[r, c+1] = True # edge right of the cell
253
- assert H is not None and V is not None, 'H and V must be provided'
242
+ assert cell_flags is not None and cell_flags.ndim == 2
243
+ R, C = cell_flags.shape
244
+
245
+ # Edge presence arrays (note the rectangular shapes)
246
+ H = np.zeros((R+1, C), dtype=bool) # horizontal edges between rows
247
+ V = np.zeros((R, C+1), dtype=bool) # vertical edges between cols
248
+ for r in range(R):
249
+ for c in range(C):
250
+ s = cell_flags[r, c]
251
+ if 'U' in s: H[r, c] = True
252
+ if 'D' in s: H[r+1, c] = True
253
+ if 'L' in s: V[r, c] = True
254
+ if 'R' in s: V[r, c+1] = True
255
+
254
256
  # Bitmask for corner connections
255
- U, R, D, L = 1, 2, 4, 8
257
+ U, Rb, D, Lb = 1, 2, 4, 8
256
258
  JUNCTION = {
257
259
  0: ' ',
258
260
  U: '│', D: '│', U|D: '│',
259
- L: '─', R: '─', L|R: '─',
260
- U|R: '└', R|D: '┌', D|L: '┐', L|U: '┘',
261
- U|D|L: '┤', U|D|R: '├', L|R|U: '┴', L|R|D: '┬',
262
- U|R|D|L: '┼',
261
+ Lb: '─', Rb: '─', Lb|Rb: '─',
262
+ U|Rb: '└', Rb|D: '┌', D|Lb: '┐', Lb|U: '┘',
263
+ U|D|Lb: '┤', U|D|Rb: '├', Lb|Rb|U: '┴', Lb|Rb|D: '┬',
264
+ U|Rb|D|Lb: '┼',
263
265
  }
264
266
 
265
267
  assert scale_x >= 1
266
- N = V.shape[0]
267
- assert H.shape == (N+1, N) and V.shape == (N, N+1)
268
+ assert H.shape == (R+1, C) and V.shape == (R, C+1)
268
269
 
269
- rows = 2*N + 1
270
- cols = 2*N*scale_x + 1 # stretched width
270
+ rows = 2*R + 1
271
+ cols = 2*C*scale_x + 1
271
272
  canvas = [[' ']*cols for _ in range(rows)]
272
273
 
273
- def x_corner(c): # x of corner column c
274
+ def x_corner(c): # x of corner column c (0..C)
274
275
  return (2*c) * scale_x
275
- def x_between(c,k): # kth in-between column (1..scale_x) between c and c+1 corners
276
+ def x_between(c,k): # kth in-between col (1..2*scale_x-1) between corners c and c+1
276
277
  return (2*c) * scale_x + k
277
278
 
278
279
  # horizontal edges: fill the stretched band between corners with '─'
279
- for r in range(N+1):
280
+ for r in range(R+1):
280
281
  rr = 2*r
281
- for c in range(N):
282
+ for c in range(C):
282
283
  if H[r, c]:
283
- # previously: for k in range(1, scale_x*2, 2):
284
- for k in range(1, scale_x*2): # 1..(2*scale_x-1), no gaps
284
+ for k in range(1, scale_x*2): # 1..(2*scale_x-1)
285
285
  canvas[rr][x_between(c, k)] = '─'
286
286
 
287
- # vertical edges: draw at the corner columns (no horizontal stretching needed)
288
- for r in range(N):
287
+ # vertical edges: at the corner columns
288
+ for r in range(R):
289
289
  rr = 2*r + 1
290
- for c in range(N+1):
290
+ for c in range(C+1):
291
291
  if V[r, c]:
292
292
  canvas[rr][x_corner(c)] = '│'
293
293
 
294
- # junctions at corners
295
- for r in range(N+1):
294
+ # junctions at every corner grid point
295
+ for r in range(R+1):
296
296
  rr = 2*r
297
- for c in range(N+1):
297
+ for c in range(C+1):
298
298
  m = 0
299
299
  if r > 0 and V[r-1, c]: m |= U
300
- if c < N and H[r, c]: m |= R
301
- if r < N and V[r, c]: m |= D
302
- if c > 0 and H[r, c-1]: m |= L
300
+ if c < C and H[r, c]: m |= Rb
301
+ if r < R and V[r, c]: m |= D
302
+ if c > 0 and H[r, c-1]: m |= Lb
303
303
  canvas[rr][x_corner(c)] = JUNCTION[m]
304
304
 
305
- # centers
306
- # ── Centers (now safe for multi-character strings) ──────────────────────
307
- # We render center text within the interior span (between corner columns),
308
- # centered if it fits; otherwise we truncate to the span width.
305
+ # centers (safe for multi-character strings)
309
306
  def put_center_text(rr: int, c: int, text: str):
310
- # interior span (exclusive of the corner columns)
311
307
  left = x_corner(c) + 1
312
308
  right = x_corner(c+1) - 1
313
309
  if right < left:
314
- return # no interior space (shouldn’t happen when scale_x>=1)
310
+ return
315
311
  span_width = right - left + 1
316
-
317
312
  s = str(text)
318
313
  if len(s) > span_width:
319
- s = s[:span_width] # hard truncate if it doesn't fit
320
- # center within the span
314
+ s = s[:span_width] # truncate to protect borders
321
315
  start = left + (span_width - len(s)) // 2
322
316
  for i, ch in enumerate(s):
323
317
  canvas[rr][start + i] = ch
324
318
 
325
319
  if center_char is not None:
326
- for r in range(N):
320
+ for r in range(R):
327
321
  rr = 2*r + 1
328
- for c in range(N):
322
+ for c in range(C):
329
323
  val = center_char if isinstance(center_char, str) else center_char[r, c]
330
324
  put_center_text(rr, c, '' if val is None else str(val))
331
325
 
332
- # turn canvas rows into strings
326
+ # rows -> strings
333
327
  art_rows = [''.join(row) for row in canvas]
334
-
335
328
  if not show_axes:
336
329
  return '\n'.join(art_rows)
337
330
 
338
- # ── Axes ────────────────────────────────────────────────────────────────
339
- gut = max(2, len(str(N-1))) # left gutter width
331
+ # Axes labels: row indices on the left, column indices on top (handle C, not R)
332
+ gut = max(2, len(str(R-1))) # gutter width based on row index width
340
333
  gutter = ' ' * gut
341
334
  top_tens = list(gutter + ' ' * cols)
342
335
  top_ones = list(gutter + ' ' * cols)
343
336
 
344
- for c in range(N):
337
+ for c in range(C):
345
338
  xc_center = x_corner(c) + scale_x
346
- if N >= 10:
339
+ if C >= 10:
347
340
  top_tens[gut + xc_center] = str((c // 10) % 10)
348
341
  top_ones[gut + xc_center] = str(c % 10)
349
342
 
350
- # tiny corner labels
351
343
  if gut >= 2:
352
344
  top_tens[gut-2:gut] = list(' ')
353
345
  top_ones[gut-2:gut] = list(' ')
354
346
 
355
347
  labeled = []
356
348
  for r, line in enumerate(art_rows):
357
- if r % 2 == 1: # cell-center row
349
+ if r % 2 == 1: # cell-center row
358
350
  label = str(r//2).rjust(gut)
359
351
  else:
360
352
  label = ' ' * gut
361
353
  labeled.append(label + line)
362
354
 
363
355
  return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
356
+
357
+ def id_board_to_wall_board(id_board: np.array, border_is_wall = True) -> np.array:
358
+ """In many instances, we have a 2d array where cell values are arbitrary ids
359
+ and we want to convert it to a 2d array where cell values are walls "U", "D", "L", "R" to represent the edges that separate me from my neighbors that have different ids.
360
+ Args:
361
+ id_board: np.array of shape (N, N) with arbitrary ids.
362
+ border_is_wall: if True, the edges of the board are considered to be walls.
363
+ Returns:
364
+ np.array of shape (N, N) with walls "U", "D", "L", "R".
365
+ """
366
+ res = np.full((id_board.shape[0], id_board.shape[1]), '', dtype=object)
367
+ V, H = id_board.shape
368
+ def append_char(pos: Pos, s: str):
369
+ set_char(res, pos, get_char(res, pos) + s)
370
+ def handle_pos_direction(pos: Pos, direction: Direction, s: str):
371
+ pos2 = get_next_pos(pos, direction)
372
+ if in_bounds(pos2, V, H):
373
+ if get_char(id_board, pos2) != get_char(id_board, pos):
374
+ append_char(pos, s)
375
+ else:
376
+ if border_is_wall:
377
+ append_char(pos, s)
378
+ for pos in get_all_pos(V, H):
379
+ handle_pos_direction(pos, Direction.LEFT, 'L')
380
+ handle_pos_direction(pos, Direction.RIGHT, 'R')
381
+ handle_pos_direction(pos, Direction.UP, 'U')
382
+ handle_pos_direction(pos, Direction.DOWN, 'D')
383
+ return res
@@ -146,16 +146,12 @@ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any,
146
146
  for p in keys_in_order:
147
147
  model.Add(node_height[p] > 0).OnlyEnforceIf(vs[p])
148
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
-
149
+ all_new_vars = {
150
+ "is_root": is_root,
151
+ "prefix_zero": prefix_zero,
152
+ "node_height": node_height,
153
+ "max_neighbor_height": max_neighbor_height,
154
+ }
159
155
  return all_new_vars
160
156
 
161
157
 
@@ -0,0 +1,48 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+ from ortools.sat.python.cp_model import LinearExpr as lxp
4
+
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_neighbors8
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert board.shape[0] == board.shape[1], 'board must be square'
13
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
14
+ self.board = board
15
+ self.N = board.shape[0]
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
+
19
+ self.create_vars()
20
+ self.add_all_constraints()
21
+
22
+ def create_vars(self):
23
+ for pos in get_all_pos(self.N):
24
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
25
+
26
+ def add_all_constraints(self):
27
+ for pos in get_all_pos(self.N):
28
+ c = get_char(self.board, pos)
29
+ if not str(c).isdecimal():
30
+ continue
31
+ neighbour_vars = [self.model_vars[p] for p in get_neighbors8(pos, self.N, include_self=True)]
32
+ self.model.Add(lxp.sum(neighbour_vars) == int(c))
33
+
34
+ def solve_and_print(self, verbose: bool = True):
35
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
36
+ assignment: dict[Pos, int] = {}
37
+ for pos, var in board.model_vars.items():
38
+ assignment[pos] = solver.Value(var)
39
+ return SingleSolution(assignment=assignment)
40
+ def callback(single_res: SingleSolution):
41
+ print("Solution found")
42
+ res = np.full((self.N, self.N), ' ', dtype=object)
43
+ for pos in get_all_pos(self.N):
44
+ c = get_char(self.board, pos)
45
+ c = 'B' if single_res.assignment[pos] == 1 else ' '
46
+ set_char(res, pos, c)
47
+ print(res)
48
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -1,7 +1,5 @@
1
- import json
2
- import time
3
1
  from dataclasses import dataclass
4
- from typing import Optional, Union
2
+ from typing import Optional
5
3
 
6
4
  from ortools.sat.python import cp_model
7
5
  import numpy as np
@@ -14,19 +12,6 @@ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution,
14
12
  Shape = frozenset[Pos]
15
13
 
16
14
 
17
- @dataclass(frozen=True)
18
- class SingleSolution:
19
- assignment: dict[Pos, Union[str, int]]
20
- all_other_variables: dict
21
-
22
- def get_hashable_solution(self) -> str:
23
- result = []
24
- for pos, v in self.assignment.items():
25
- result.append((pos.x, pos.y, v))
26
- return json.dumps(result, sort_keys=True)
27
-
28
-
29
-
30
15
  @dataclass
31
16
  class ShapeOnBoard:
32
17
  is_active: cp_model.IntVar
@@ -63,7 +48,6 @@ class Board:
63
48
  def create_vars(self):
64
49
  for pos in get_all_pos(self.V, self.H):
65
50
  self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
66
- # print('base vars:', len(self.model_vars))
67
51
 
68
52
  def init_shapes_on_board(self):
69
53
  for idx, (shape, shape_id) in enumerate(self.polyominoes):
@@ -84,7 +68,6 @@ class Board:
84
68
  body=body,
85
69
  disallow_same_shape=disallow_same_shape,
86
70
  ))
87
- # print('shapes on board:', len(self.shapes_on_board))
88
71
 
89
72
  def add_all_constraints(self):
90
73
  # RULES:
@@ -99,11 +82,9 @@ class Board:
99
82
  self.force_one_shape_per_block() # Rule #1
100
83
  self.disallow_same_shape_touching() # Rule #2
101
84
  self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
102
- # print('force connected vars:', len(fc))
103
85
  shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
104
86
  self.disallow_shape(shape_2_by_2) # Rule #4
105
87
 
106
-
107
88
  def only_allow_shapes_on_board(self):
108
89
  for shape_on_board in self.shapes_on_board:
109
90
  # if shape is active then all its body cells must be active
@@ -118,7 +99,6 @@ class Board:
118
99
  for block_i in self.block_numbers:
119
100
  shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
120
101
  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'
121
- # print(f'shapes on block {block_i} has {len(shapes_on_block)} shapes')
122
102
  self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
123
103
 
124
104
  def disallow_same_shape_touching(self):
@@ -138,8 +118,6 @@ class Board:
138
118
  self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
139
119
 
140
120
 
141
-
142
-
143
121
  def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
144
122
  if verbose_callback is None:
145
123
  verbose_callback = verbose
@@ -147,10 +125,7 @@ class Board:
147
125
  assignment: dict[Pos, int] = {}
148
126
  for pos, var in board.model_vars.items():
149
127
  assignment[pos] = solver.Value(var)
150
- all_other_variables = {
151
- 'fc': {k: solver.Value(v) for k, v in board.fc.items()}
152
- }
153
- return SingleSolution(assignment=assignment, all_other_variables=all_other_variables)
128
+ return SingleSolution(assignment=assignment)
154
129
  def callback(single_res: SingleSolution):
155
130
  print("Solution found")
156
131
  res = np.full((self.V, self.H), ' ', dtype=str)
@@ -158,5 +133,4 @@ class Board:
158
133
  c = 'X' if val == 1 else ' '
159
134
  set_char(res, pos, c)
160
135
  print('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
161
- pass
162
136
  return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
@@ -0,0 +1,104 @@
1
+ from dataclasses import dataclass
2
+ from collections import defaultdict
3
+
4
+ import numpy as np
5
+ from ortools.sat.python import cp_model
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, id_board_to_wall_board, render_grid, set_char, in_bounds, get_next_pos, Direction, polyominoes
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+
10
+
11
+
12
+ # a shape on the 2d board is just a set of positions
13
+ Shape = frozenset[Pos]
14
+
15
+ @dataclass(frozen=True)
16
+ class ShapeOnBoard:
17
+ is_active: cp_model.IntVar
18
+ shape: Shape
19
+ shape_id: int
20
+ body: set[Pos]
21
+
22
+
23
+ def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
24
+ # give a shape and a board, return all valid translations of the shape that are fully contained in the board AND consistent with the clues on the board
25
+ shape_list = list(shape)
26
+ shape_borders = [] # will contain the number of borders for each pos in the shape; this has to be consistent with the clues on the board
27
+ for pos in shape_list:
28
+ v = 0
29
+ for direction in Direction:
30
+ next_pos = get_next_pos(pos, direction)
31
+ if not in_bounds(next_pos, board.shape[0], board.shape[1]) or next_pos not in shape:
32
+ v += 1
33
+ shape_borders.append(v)
34
+ shape_list = [(p.x, p.y) for p in shape_list]
35
+ # min x/y is always 0
36
+ max_x = max(p[0] for p in shape_list)
37
+ max_y = max(p[1] for p in shape_list)
38
+
39
+ for dy in range(0, board.shape[0] - max_y):
40
+ for dx in range(0, board.shape[1] - max_x):
41
+ body = tuple((p[0] + dx, p[1] + dy) for p in shape_list)
42
+ for i, p in enumerate(body):
43
+ c = board[p[1], p[0]]
44
+ if c != ' ' and c != str(shape_borders[i]): # there is a clue and it doesn't match my translated shape, skip
45
+ break
46
+ else:
47
+ yield tuple(get_pos(x=p[0], y=p[1]) for p in body)
48
+
49
+
50
+
51
+ class Board:
52
+ def __init__(self, board: np.array, region_size: int):
53
+ assert region_size >= 1 and isinstance(region_size, int), 'region_size must be an integer greater than or equal to 1'
54
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
55
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
56
+ self.board = board
57
+ self.V, self.H = board.shape
58
+ self.region_size = region_size
59
+ self.region_count = (self.V * self.H) // self.region_size
60
+ assert self.region_count * self.region_size == self.V * self.H, f'region_size must be a factor of the board size, got {self.region_size} and {self.region_count}'
61
+
62
+ self.polyominoes = polyominoes(self.region_size)
63
+
64
+ self.model = cp_model.CpModel()
65
+ self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
66
+ self.pos_to_shapes: dict[Pos, set[ShapeOnBoard]] = defaultdict(set)
67
+ self.create_vars()
68
+ self.add_all_constraints()
69
+
70
+ def create_vars(self):
71
+ for shape in self.polyominoes:
72
+ for body in get_valid_translations(shape, self.board):
73
+ uid = len(self.shapes_on_board)
74
+ shape_on_board = ShapeOnBoard(
75
+ is_active=self.model.NewBoolVar(f'{uid}:is_active'),
76
+ shape=shape,
77
+ shape_id=uid,
78
+ body=body,
79
+ )
80
+ self.shapes_on_board.append(shape_on_board)
81
+ for pos in body:
82
+ self.pos_to_shapes[pos].add(shape_on_board)
83
+
84
+ def add_all_constraints(self):
85
+ for pos in get_all_pos(self.V, self.H): # each position has exactly one shape active
86
+ self.model.AddExactlyOne(shape.is_active for shape in self.pos_to_shapes[pos])
87
+
88
+ def solve_and_print(self, verbose: bool = True):
89
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
90
+ assignment: dict[Pos, int] = {}
91
+ for shape in board.shapes_on_board:
92
+ if solver.Value(shape.is_active) == 1:
93
+ for pos in shape.body:
94
+ assignment[pos] = shape.shape_id
95
+ return SingleSolution(assignment=assignment)
96
+ def callback(single_res: SingleSolution):
97
+ print("Solution found")
98
+ id_board = np.full((self.V, self.H), ' ', dtype=object)
99
+ for pos in get_all_pos(self.V, self.H):
100
+ region_idx = single_res.assignment[pos]
101
+ set_char(id_board, pos, region_idx)
102
+ board = np.where(self.board == ' ', '·', self.board)
103
+ print(render_grid(id_board_to_wall_board(id_board), center_char=board))
104
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -20,6 +20,10 @@ class Board:
20
20
  self.cell_borders_to_corners: dict[CellBorder, set[Corner]] = defaultdict(set) # for every cell border, a set of all corners it is connected to
21
21
  self.corners_to_cell_borders: dict[Corner, set[CellBorder]] = defaultdict(set) # opposite direction
22
22
 
23
+ # 2N^2 + 2N edges
24
+ # 4*edges (fully connected component)
25
+ # model variables = edges (on/off) + 4*edges (fully connected component)
26
+ # = 9N^2 + 9N
23
27
  self.model = cp_model.CpModel()
24
28
  self.model_vars: dict[CellBorder, cp_model.IntVar] = {} # one entry for every unique variable in the model
25
29
  self.cell_borders: dict[CellBorder, cp_model.IntVar] = {} # for every position and direction, one entry for that edge (thus the same edge variables are used in opposite directions of neighboring cells)
@@ -87,10 +91,11 @@ class Board:
87
91
  if not val.isdecimal():
88
92
  continue
89
93
  self.model.Add(sum(variables) == int(val))
94
+
95
+ corner_sum_domain = cp_model.Domain.FromValues([0, 2]) # sum of edges touching a corner is 0 or 2
90
96
  for corner in self.corner_vars: # a corder always has 0 or 2 active edges
91
- g = self.model.NewBoolVar(f'corner_gate_{corner}')
92
- self.model.Add(sum(self.corner_vars[corner]) == 0).OnlyEnforceIf(g.Not())
93
- self.model.Add(sum(self.corner_vars[corner]) == 2).OnlyEnforceIf(g)
97
+ self.model.AddLinearExpressionInDomain(sum(self.corner_vars[corner]), corner_sum_domain)
98
+
94
99
  # single connected component
95
100
  def is_neighbor(cb1: CellBorder, cb2: CellBorder) -> bool:
96
101
  cb1_corners = self.cell_borders_to_corners[cb1]