├── .coveragerc ├── .github └── workflows │ └── check.yml ├── .gitignore ├── LICENSE ├── README.md ├── bench ├── generator.py └── pzv_problem.py ├── cspuz ├── __init__.py ├── analyzer.py ├── array.py ├── backend │ ├── __init__.py │ ├── _subproc.py │ ├── backend.py │ ├── sugar_like.py │ └── z3.py ├── configuration.py ├── constraints.py ├── expr.py ├── generator │ ├── __init__.py │ ├── builder.py │ ├── core.py │ ├── deterministic_random.py │ ├── segmentation.py │ └── srandom.py ├── graph.py ├── grid_frame.py ├── problem_serializer.py ├── puzzle │ ├── __init__.py │ ├── akari.py │ ├── aquarium.py │ ├── building.py │ ├── castle_wall.py │ ├── compass.py │ ├── creek.py │ ├── doppelblock.py │ ├── fillomino.py │ ├── firefly.py │ ├── fivecells.py │ ├── geradeweg.py │ ├── gokigen.py │ ├── heyawake.py │ ├── lits.py │ ├── magnets.py │ ├── masyu.py │ ├── nanro.py │ ├── norinori.py │ ├── nurikabe.py │ ├── nurimaze.py │ ├── nurimisaki.py │ ├── putteria.py │ ├── shakashaka.py │ ├── simpleloop.py │ ├── slalom.py │ ├── slitherlink.py │ ├── star_battle.py │ ├── sudoku.py │ ├── util.py │ ├── view.py │ ├── yajilin.py │ └── yinyang.py └── solver.py ├── docs ├── Makefile ├── ja │ ├── index.md │ ├── sudoku.png │ ├── tutorial1.md │ ├── tutorial2.md │ └── tutorial3.md ├── make.bat └── source │ ├── conf.py │ ├── cspuz.rst │ ├── graph.rst │ ├── index.rst │ └── solver.rst ├── pyproject.toml ├── pytest.ini ├── python_check.sh ├── setup.cfg ├── sugar_extension ├── CspuzSugarInterface.java ├── compile.sh └── sugar_ext.sh └── tests ├── __init__.py ├── puzzle ├── test_compass.py └── test_star_battle.py ├── test_array.py ├── test_backend.py ├── test_constraints.py ├── test_expr.py ├── test_graph.py ├── test_serializer.py └── util.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: not covered 4 | @overload 5 | 6 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Run checks 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: {} 7 | schedule: 8 | - cron: "0 15 * * FRI" # weekly test: every Saturday 00:00 JST 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 19 | exclude: 20 | - python-version: ${{ github.event_name != 'schedule' && '3.10' }} 21 | - python-version: ${{ github.event_name != 'schedule' && '3.11' }} 22 | - python-version: ${{ github.event_name != 'schedule' && '3.12' }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Setup Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install 30 | run: pip install .[check] 31 | - name: Run checks 32 | run: | 33 | black --check cspuz tests 34 | flake8 cspuz tests 35 | mypy cspuz tests 36 | test-ubuntu: 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 41 | exclude: 42 | - python-version: ${{ github.event_name != 'schedule' && '3.10' }} 43 | - python-version: ${{ github.event_name != 'schedule' && '3.11' }} 44 | - python-version: ${{ github.event_name != 'schedule' && '3.12' }} 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: Update Rust 48 | run: rustup update 49 | - name: Setup Python ${{ matrix.python-version }} 50 | uses: actions/setup-python@v5 51 | with: 52 | python-version: ${{ matrix.python-version }} 53 | - name: Build cspuz_core 54 | run: | 55 | cd ${{ runner.temp }} 56 | git clone --recursive https://github.com/semiexp/cspuz_core.git 57 | cd cspuz_core 58 | pip install . 59 | - name: Install cspuz 60 | run: pip install .[test] 61 | - name: Run tests 62 | run: pytest 63 | test-windows: 64 | runs-on: windows-latest 65 | steps: 66 | - uses: actions/checkout@v4 67 | - name: Update Rust 68 | run: rustup update 69 | - name: Setup Python 3.13 70 | uses: actions/setup-python@v5 71 | with: 72 | python-version: 3.13 73 | - name: Build cspuz_core 74 | run: | 75 | cd ${{ runner.temp }} 76 | git clone --recursive https://github.com/semiexp/cspuz_core.git 77 | cd cspuz_core 78 | pip install . 79 | - name: Install cspuz 80 | run: pip install .[test] 81 | - name: Run tests 82 | run: pytest 83 | test-macos: 84 | runs-on: macos-latest 85 | steps: 86 | - uses: actions/checkout@v4 87 | - name: Update Rust 88 | run: rustup update 89 | - name: Setup Python 3.13 90 | uses: actions/setup-python@v5 91 | with: 92 | python-version: 3.13 93 | - name: Build cspuz_core 94 | run: | 95 | cd ${{ runner.temp }} 96 | git clone --recursive https://github.com/semiexp/cspuz_core.git 97 | cd cspuz_core 98 | pip install . 99 | - name: Install cspuz 100 | run: pip install .[test] 101 | - name: Run tests 102 | run: pytest 103 | test-all-backends: 104 | strategy: 105 | matrix: 106 | os: [ubuntu-latest] 107 | python-version: ["3.9", "3.13"] 108 | if: github.event_name == 'schedule' 109 | runs-on: ${{ matrix.os }} 110 | steps: 111 | - uses: actions/checkout@v4 112 | - name: Update Rust 113 | run: rustup update 114 | - name: Setup Python ${{ matrix.python-version }} 115 | uses: actions/setup-python@v5 116 | with: 117 | python-version: ${{ matrix.python-version }} 118 | - name: Build csugar 119 | run: | 120 | cd ${{ runner.temp }} 121 | git clone https://github.com/semiexp/csugar.git 122 | cd csugar 123 | mkdir build 124 | cd build 125 | cmake -DCMAKE_BUILD_TYPE=Release .. && make 126 | cd .. 127 | pip install . 128 | - name: Build cspuz_core 129 | run: | 130 | cd ${{ runner.temp }} 131 | git clone --recursive https://github.com/semiexp/cspuz_core.git 132 | cd cspuz_core 133 | pip install . 134 | - name: Install cspuz 135 | run: pip install .[test] 136 | - name: Run tests 137 | run: pytest -m "" # run all tests 138 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | *.egg-info/ 3 | **/build/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 semiexp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | 24 | License 25 | 26 | Sugar (A SAT-based CSP Solver) version 2 27 | 28 | Copyright (c) 2008-2012 29 | by Naoyuki Tamura (tamura @ kobe-u.ac.jp), 30 | Tomoya Tanjo, and 31 | Mutsunori Banbara (banbara @ kobe-u.ac.jp) 32 | All rights reserved. 33 | 34 | Redistribution and use in source and binary forms, with or without 35 | modification, are permitted provided that the following conditions are 36 | met: 37 | 38 | * Redistributions of source code must retain the above copyright 39 | notice, this list of conditions and the following disclaimer. 40 | * Redistributions in binary form must reproduce the above copyright 41 | notice, this list of conditions and the following disclaimer in the 42 | documentation and/or other materials provided with the 43 | distribution. 44 | * Neither the name of the Kobe University nor the names of its 45 | contributors may be used to endorse or promote products derived 46 | from this software without specific prior written permission. 47 | 48 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 49 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 50 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 51 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 52 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 53 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 54 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 55 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 56 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 57 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 58 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cspuz: A library for making puzzle solvers based on CSP reduction 2 | 3 | cspuz is a Python library for making puzzle solvers based on CSP reduction. 4 | It offers: 5 | - Intuitive interfaces to use efficient CSP solvers from Python, and 6 | - A collection of functionalities which makes writing puzzle solvers easier. 7 | 8 | ## Requirements 9 | 10 | cspuz requires a CSP solver corresponding to the backend specified in the program. 11 | Currently, three backends are supported: 12 | 13 | - [cspuz_core](https://github.com/semiexp/cspuz_core) 14 | - [csugar](https://github.com/semiexp/csugar) 15 | - [z3](https://pypi.org/project/z3-solver/) 16 | - [Sugar](http://bach.istc.kobe-u.ac.jp/sugar/) 17 | - sugar-ext, which aims to reduce the overhead of invocation of `sugar` script of Sugar. 18 | 19 | By default, cspuz automatically decided the backend based on its availability. 20 | You can explicitly set the default backend by setting the `$CSPUZ_DEFAULT_BACKEND` environment variable. 21 | 22 | ## Setup 23 | 24 | Before installing cspuz, you need to setup a backend CSP solver. 25 | 26 | ### cspuz_core backend (highly recommended) 27 | 28 | `cspuz_core` is a CSP solver supporting several features which accelerate solving logic puzzles. 29 | Among the supported backends, `cspuz_core` offers the best performance. 30 | 31 | To install `cspuz_core`, follow the instruction in the [repository](https://github.com/semiexp/cspuz_core). 32 | 33 | ### z3 backend (easy) 34 | 35 | Installing z3 will be as easy as just running 36 | 37 | ``` 38 | pip install z3-solver 39 | ``` 40 | 41 | in your terminal. 42 | 43 | ### Sugar backend 44 | 45 | To use Sugar backend, you first need to install Sugar (which can be downloaded from [Sugar's website](http://bach.istc.kobe-u.ac.jp/sugar/)). 46 | Then, you need to specify the path of `sugar` script (provided in the Sugar archive) by `$CSPUZ_BACKEND_PATH` environment variable. 47 | 48 | ### sugar-ext backend 49 | 50 | sugar-ext backend also depends on Sugar. 51 | Therefore, as in the case of Sugar backend, you need to install Sugar first. 52 | After installing Sugar, you need to specify the path of Sugar JAR file by `$SUGAR_JAR` environment variable, then compile `CspuzSugarInterface` by running `sugar_extension/compile.sh` (JDK required). 53 | Then, you need to specify the path of `sugar_extension/sugar_ext.sh` script (rather than `sugar`) by `$CSPUZ_BACKEND_PATH` environment variable. 54 | Please note that `$SUGAR_JAR` is also required for running `sugar_ext.sh`. 55 | 56 | ### csugar backend 57 | 58 | [csugar](https://github.com/semiexp/csugar) is a reimplementation of Sugar CSP solver in C++. 59 | Although it is not fully verified, it offers several performance advangates: 60 | 61 | - It can use MiniSat incrementally. This contributes to improve the performance of finding non-refutable assignments (`Solver.solve` in cspuz). 62 | - Moreover, it supports graph connectivity as a *native constraint*. Thus, it can handle constraints such as `cspuz.graph.active_vertices_connected`, `cspuz.graph.division_connected` and `cspuz.graph.active_edges_single_cycle`. To utilize this feature from cspuz, you need to set `$CSPUZ_USE_GRAPH_PRIMITIVE` environment variable to `1`. 63 | 64 | `csugar` binary which will be produced by building csugar is designed to run in the same way as `sugar_ext.sh`. 65 | Therefore, you can set the `$CSPUZ_DEFAULT_BACKEND` to `sugar_extended` in order to use csugar. 66 | 67 | ### Installing cspuz 68 | 69 | First clone this repository to whichever directory you like, and run `pip install .` in the directory in which you cloned it. 70 | -------------------------------------------------------------------------------- /bench/generator.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | from cspuz.generator.srandom import use_deterministic_prng 4 | import cspuz.puzzle.masyu as masyu 5 | import cspuz.puzzle.nurimisaki as nurimisaki 6 | import cspuz.puzzle.slitherlink as slitherlink 7 | import cspuz.puzzle.sudoku as sudoku 8 | 9 | 10 | def run_generator_bench(bench_name, generator, serializer, num_problems, expected): 11 | use_deterministic_prng(True, seed=0) 12 | start = time.time() 13 | for i in range(num_problems): 14 | while True: 15 | generated = generator() 16 | if generated is not None: 17 | break 18 | serialized = serializer(generated) 19 | if expected is not None: 20 | assert expected[i] == serialized 21 | else: 22 | print(serialized) 23 | elapsed = time.time() - start 24 | 25 | print(f"{bench_name}: {elapsed}") 26 | 27 | 28 | def bench_masyu(): 29 | expected = [ 30 | "https://puzz.link/p?masyu/10/10/2051020b00010300i0i50020002696020i", 31 | "https://puzz.link/p?masyu/10/10/00266000600220603230i09000c00169i0", 32 | "https://puzz.link/p?masyu/10/10/0023i30090366000080i1326000021160i", 33 | "https://puzz.link/p?masyu/10/10/063o03000003oil032813601000ia9i030", 34 | "https://puzz.link/p?masyu/10/10/210030390600409i0182i00200010i0i20", 35 | "https://puzz.link/p?masyu/10/10/29602f009230300003369i20000i0306i0", 36 | "https://puzz.link/p?masyu/10/10/0063399k90i020i060k000029i0106300i", 37 | "https://puzz.link/p?masyu/10/10/31020ii0902100000006020391i1900i20", 38 | "https://puzz.link/p?masyu/10/10/1030602002130303i1203000000f3i0b00", 39 | "https://puzz.link/p?masyu/10/10/j0000i030i060007k6c000600i00006b00", 40 | ] 41 | run_generator_bench( 42 | "masyu", 43 | lambda: masyu.generate_masyu(height=10, width=10, symmetry=False, verbose=False), 44 | masyu.serialize_masyu, 45 | 10, 46 | expected, 47 | ) 48 | 49 | 50 | def bench_slitherlink(): 51 | # TODO: add URL serializer and use it 52 | def serializer(problem): 53 | res = [] 54 | for row in problem: 55 | for x in row: 56 | if x == -1: 57 | res.append(".") 58 | else: 59 | res.append(str(x)) 60 | return "".join(res) 61 | 62 | expected = [ 63 | "....2....1.0..2.23..32.3.122....1.....201..3.......0....32.01.2.3..1........1.2.3..0..2..2...3.12..0", # noqa: E501 64 | "3...221.3.3.2....1......32.3.120..2...0..1.3....32...01..1..1.3....1.10.1..02....3....3.3.1...32....", # noqa: E501 65 | "...1.0..2..3.22...110.10.10...12.......0...3.....201.2.11.22.1.1.33..3......1.0.1...31.1..3.0.32.0..", # noqa: E501 66 | "2...3..3..3.3...311.2..3...2.......3.0.101...3..31..12.....30.1......3.3.....202...3..3......3.13..1", # noqa: E501 67 | "...23.0...2...03....0.22.30..........1........1.31.12.1.1...0..10.12.12.2.1.3..0.....0....3.0..2.0..", # noqa: E501 68 | "....3...3.2.0..0.22.0.1.....02.22..3....2.30...1.12..2..113..1.13..3.....1.........3.231.210.......2", # noqa: E501 69 | "...0.1...23...1..2.2..0...10..22..2.33....3.02....2.2.....020....2223...2...3.20..22.2.....022.1..1.", # noqa: E501 70 | "3.2....0...110113..0..1.1.....1.....1.23..2...0...323....3.0...3.1.23110.1...1....33...1.0.1...2.2..", # noqa: E501 71 | "...0.3.01.1.11..2...31....2.3...2..0.1.23....1.....3..22..33.1..12.1...12...3..3....3.3...11...2..0.", # noqa: E501 72 | ".2....3333.1.3...1..3.022....31....03.....1.11...1221..0..01...1...2..0.1..1.0.1...223.......3.2..0.", # noqa: E501 73 | ] 74 | 75 | run_generator_bench( 76 | "slitherlink", 77 | lambda: slitherlink.generate_slitherlink( 78 | height=10, width=10, symmetry=False, verbose=False 79 | ), 80 | serializer, 81 | 10, 82 | expected, 83 | ) 84 | 85 | 86 | def bench_nurimisaki(): 87 | expected = [ 88 | "https://puzz.link/p?nurimisaki/10/10/g.i.w.j.i.h.k.h.g.y.h.i.i.h.g.h.n.k", 89 | "https://puzz.link/p?nurimisaki/10/10/h.g.g.w.g.q.h.g.h.m.h.n.m.h.q.h.g.k", 90 | "https://puzz.link/p?nurimisaki/10/10/h.n.j.m.j.zh.h.j.n.m.m.l.g.k", 91 | "https://puzz.link/p?nurimisaki/10/10/w.i.j.h.h.h.m.l.j.r.g.i.j.h.h.s.", 92 | "https://puzz.link/p?nurimisaki/10/10/h.l.g.j.k.g.j.g.g.h.w.h.n.g.g.g.h.g.z.g", 93 | "https://puzz.link/p?nurimisaki/10/10/i.k.j.l.k.g.h.r.g.j.i.i.m.u.h.i.n", 94 | "https://puzz.link/p?nurimisaki/10/10/g.n.l.l.j.h.i.r.l.j.q.g.l.k.l.j", 95 | "https://puzz.link/p?nurimisaki/10/10/s.g.k.s.g.g.p.g.i.p.m.h.g.s.h.h", 96 | "https://puzz.link/p?nurimisaki/10/10/j.r.n.h.i.i.h.g.z.g.g.g.g.p.j.p.g", 97 | "https://puzz.link/p?nurimisaki/10/10/g.g.p.g.h.q.h.n.k.l.r.j.n.n.m", 98 | ] 99 | run_generator_bench( 100 | "nurimisaki", 101 | lambda: nurimisaki.generate_nurimisaki(10, 10, verbose=False), 102 | nurimisaki.serialize_nurimisaki, 103 | 10, 104 | expected, 105 | ) 106 | 107 | 108 | def bench_sudoku(): 109 | expected = [ 110 | "https://puzz.link/p?sudoku/9/9/l768g3g2g7o4h1j2h43h4g5h92h6j7h5o5g9g1g824l", 111 | "https://puzz.link/p?sudoku/9/9/i1k7j659h6j7j13j8g5g7g8g4g6j42j2j7h372j1k5i", 112 | "https://puzz.link/p?sudoku/9/9/i1g9k9j61j2h3i6h3g47j9j78g5h9i3h8j51j4k3g7i", 113 | "https://puzz.link/p?sudoku/9/9/h72j6g1g93q4h4i7g53j6j98g4i2h3q54g1g2j87h", 114 | "https://puzz.link/p?sudoku/9/9/g3m69i4l5g27h71m9g81g54g2m95h34g8l9i51m6g", 115 | "https://puzz.link/p?sudoku/9/9/2h4i8k15h6g7j5g1g2h3l4i2l6h9g1g2j6g6h98k4i7h5", 116 | "https://puzz.link/p?sudoku/9/9/4p8g2g9h21h6i8i4i5h53i47h8i9i7i3h46h5g4g7p5", 117 | "https://puzz.link/p?sudoku/9/9/6g8h4h9m83i6j5k2g7h5h7h4h1g4k5j9i84m1h5h2g6", 118 | "https://puzz.link/p?sudoku/9/9/7l3g16g9i2j65j3g6l4h8g9h7l1g6j25j8i3g65g7l2", 119 | "https://puzz.link/p?sudoku/9/9/n5k37g8g6g7m3h7g4i59168i9g5h2m5g6g9g82k2n", 120 | ] 121 | run_generator_bench( 122 | "sudoku", 123 | lambda: sudoku.generate_sudoku(3, symmetry=True, max_clue=24, verbose=False), 124 | sudoku.serialize_sudoku, 125 | 10, 126 | expected, 127 | ) 128 | 129 | 130 | ALL_BENCHES = [ 131 | (bench_masyu, "masyu"), 132 | (bench_slitherlink, "slitherlink"), 133 | (bench_nurimisaki, "nurimisaki"), 134 | (bench_sudoku, "sudoku"), 135 | ] 136 | 137 | 138 | def main(): 139 | flt = None 140 | if len(sys.argv) >= 2: 141 | flt = sys.argv[1].split(",") 142 | for bench, name in ALL_BENCHES: 143 | if flt is None or name in flt: 144 | bench() 145 | 146 | 147 | if __name__ == "__main__": 148 | main() 149 | -------------------------------------------------------------------------------- /bench/pzv_problem.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import time 4 | 5 | from cspuz import problem_serializer 6 | from cspuz.puzzle import heyawake, lits, masyu, nurikabe, nurimisaki, slitherlink, yajilin 7 | from cspuz.generator import default_uniqueness_checker 8 | 9 | 10 | def solve_nurikabe(url): 11 | problem = nurikabe.deserialize_nurikabe(url) 12 | height = len(problem) 13 | width = len(problem[0]) 14 | is_sat, ans = nurikabe.solve_nurikabe(height, width, problem) 15 | return is_sat and default_uniqueness_checker(ans) 16 | 17 | 18 | def solve_masyu(url): 19 | problem = masyu.deserialize_masyu(url) 20 | height = len(problem) 21 | width = len(problem[0]) 22 | is_sat, ans = masyu.solve_masyu(height, width, problem) 23 | return is_sat and default_uniqueness_checker(ans) 24 | 25 | 26 | def solve_slitherlink(url): 27 | problem = slitherlink.deserialize_slitherlink(url) 28 | if problem is None: 29 | return None 30 | height = len(problem) 31 | width = len(problem[0]) 32 | is_sat, ans = slitherlink.solve_slitherlink(height, width, problem) 33 | return is_sat and default_uniqueness_checker(ans) 34 | 35 | 36 | def solve_heyawake(url): 37 | problem = heyawake.deserialize_heyawake(url) 38 | if problem is None: 39 | return None 40 | height, width, (rooms, clues) = problem 41 | for clue in clues: 42 | if clue > 15: 43 | # TODO: problem with large clue numbers are too difficult to solve 44 | return None 45 | is_sat, ans = heyawake.solve_heyawake(height, width, rooms, clues) 46 | return is_sat and default_uniqueness_checker(ans) 47 | 48 | 49 | def solve_lits(url): 50 | problem = lits.deserialize_lits(url) 51 | if problem is None: 52 | return None 53 | height, width, rooms = problem 54 | is_sat, ans = lits.solve_lits(height, width, rooms) 55 | return is_sat and default_uniqueness_checker(ans) 56 | 57 | 58 | def solve_nurimisaki(url): 59 | problem = nurimisaki.deserialize_nurimisaki(url) 60 | height = len(problem) 61 | width = len(problem[0]) 62 | is_sat, ans = nurimisaki.solve_nurimisaki(height, width, problem) 63 | return is_sat and default_uniqueness_checker(ans) 64 | 65 | 66 | def solve_yajilin(url): 67 | problem = yajilin.deserialize_yajilin(url) 68 | height = len(problem) 69 | width = len(problem[0]) 70 | is_sat, grid_frame, is_black = yajilin.solve_yajilin(height, width, problem) 71 | return is_sat and default_uniqueness_checker(grid_frame, is_black) 72 | 73 | 74 | PUZZLE_KIND_ALIAS = { 75 | "mashu": "masyu", 76 | } 77 | 78 | 79 | def solve_problem(url, height_lim=None, width_lim=None): 80 | info = problem_serializer.get_puzzle_info_from_url(url) 81 | if info is None: 82 | return None 83 | kind, height, width = info 84 | 85 | if height_lim is not None and height > height_lim: 86 | return None 87 | if width_lim is not None and width > width_lim: 88 | return None 89 | 90 | if kind in PUZZLE_KIND_ALIAS: 91 | kind = PUZZLE_KIND_ALIAS[kind] 92 | 93 | if kind == "nurikabe": 94 | return solve_nurikabe(url) 95 | elif kind == "masyu": 96 | return solve_masyu(url) 97 | elif kind == "slither": 98 | return solve_slitherlink(url) 99 | elif kind == "heyawake": 100 | return solve_heyawake(url) 101 | elif kind == "lits": 102 | return solve_lits(url) 103 | elif kind == "nurimisaki": 104 | return solve_nurimisaki(url) 105 | elif kind == "yajilin": 106 | return solve_yajilin(url) 107 | else: 108 | return None 109 | 110 | 111 | def main(): 112 | parser = argparse.ArgumentParser() 113 | parser.add_argument("--hmax", type=int) 114 | parser.add_argument("--wmax", type=int) 115 | args = parser.parse_args() 116 | 117 | hmax = args.hmax 118 | wmax = args.wmax 119 | 120 | idx = 0 121 | while True: 122 | idx += 1 123 | url = sys.stdin.readline().strip() 124 | if url == "": 125 | break 126 | 127 | start = time.time() 128 | res = solve_problem(url, height_lim=hmax, width_lim=wmax) 129 | elapsed = time.time() - start 130 | 131 | if res is None: 132 | continue 133 | elif res is False: 134 | print(f"{idx}\tnot solved", flush=True) 135 | else: 136 | print(f"{idx}\t{elapsed}", flush=True) 137 | 138 | 139 | if __name__ == "__main__": 140 | main() 141 | -------------------------------------------------------------------------------- /cspuz/__init__.py: -------------------------------------------------------------------------------- 1 | from .solver import Solver 2 | from .constraints import alldifferent, count_true, cond, fold_and, fold_or 3 | from .configuration import config 4 | from .grid_frame import BoolGridFrame 5 | 6 | __all__ = [ 7 | "Solver", 8 | "alldifferent", 9 | "count_true", 10 | "cond", 11 | "fold_and", 12 | "fold_or", 13 | "config", 14 | "BoolGridFrame", 15 | ] 16 | -------------------------------------------------------------------------------- /cspuz/analyzer.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import Pool 2 | from typing import Any, List, Optional, Tuple, Union 3 | 4 | from .solver import Solver, _get_backend 5 | from .array import BoolArray2D, IntArray2D 6 | from .expr import BoolExpr, BoolVar, IntVar 7 | from .constraints import flatten_iterator 8 | 9 | 10 | class Analyzer(Solver): 11 | answer_key_name: List[Optional[str]] 12 | axiom_constraints: List[int] 13 | optional_constraints: List[Tuple[str, List[int]]] 14 | 15 | def __init__(self): 16 | super(Analyzer, self).__init__() 17 | self.answer_key_name = [] 18 | self.axiom_constraints = [] 19 | self.optional_constraints = [] 20 | 21 | def bool_var(self) -> BoolVar: 22 | self.answer_key_name.append(None) 23 | return super(Analyzer, self).bool_var() 24 | 25 | def int_var(self, lo, hi) -> IntVar: 26 | self.answer_key_name.append(None) 27 | return super(Analyzer, self).int_var(lo, hi) 28 | 29 | def add_answer_key(self, *variable: Any, name: Optional[str] = None): 30 | if len(variable) == 0: 31 | return 32 | elif len(variable) == 1: 33 | var = variable[0] 34 | if isinstance(var, (BoolVar, IntVar)): 35 | self.answer_key_name[var.id] = name 36 | super(Analyzer, self).add_answer_key(var) 37 | return 38 | 39 | if isinstance(var, (BoolArray2D, IntArray2D)): 40 | height, width = var.shape 41 | for y in range(height): 42 | for x in range(width): 43 | if name is None: 44 | new_name = None 45 | else: 46 | new_name = name + "." + str(y) + "." + str(x) 47 | self.add_answer_key(var[y, x], name=new_name) 48 | else: 49 | for i, elem in enumerate(var): 50 | if name is None: 51 | new_name = None 52 | else: 53 | new_name = name + "." + str(i) 54 | self.add_answer_key(elem, name=new_name) 55 | else: 56 | for i, elem in enumerate(variable): 57 | if name is None: 58 | new_name = None 59 | else: 60 | new_name = name + "." + str(i) 61 | self.add_answer_key(elem, name=new_name) 62 | 63 | def ensure(self, *constraint: Any, name: Optional[str] = None): 64 | flat_constraints = [] 65 | for x in flatten_iterator(constraint): 66 | if isinstance(x, (BoolExpr, bool)): 67 | flat_constraints.append(x) 68 | else: 69 | raise TypeError("each element in 'constraint' must be BoolExpr-like") 70 | new_ids = list(range(len(self.constraints), len(self.constraints) + len(flat_constraints))) 71 | if name is None: 72 | self.axiom_constraints += new_ids 73 | else: 74 | self.optional_constraints.append((name, new_ids)) 75 | self.constraints += flat_constraints 76 | 77 | def _test_unlearnt_fact(self, i, unlearnt_facts, learnt_facts, backend): 78 | backend_type = _get_backend(backend) 79 | is_active_constraint = [True for _ in range(len(self.optional_constraints))] 80 | is_active_fact = [True for _ in range(len(learnt_facts))] 81 | 82 | def check(): 83 | csp_solver = backend_type(self.variables) 84 | csp_solver.add_constraint([self.constraints[j] for j in self.axiom_constraints]) 85 | for k in range(len(self.optional_constraints)): 86 | if is_active_constraint[k]: 87 | _, cs = self.optional_constraints[k] 88 | csp_solver.add_constraint([self.constraints[j] for j in cs]) 89 | for k in range(len(learnt_facts)): 90 | if is_active_fact[k]: 91 | vi, val = learnt_facts[k] 92 | csp_solver.add_constraint(self.variables[vi] == val) 93 | vi, val = unlearnt_facts[i] 94 | csp_solver.add_constraint(self.variables[vi] != val) 95 | return not csp_solver.solve() 96 | 97 | for j in range(len(is_active_constraint)): 98 | is_active_constraint[j] = False 99 | if not check(): 100 | is_active_constraint[j] = True 101 | 102 | for j in range(len(is_active_fact)): 103 | is_active_fact[j] = False 104 | if not check(): 105 | is_active_fact[j] = True 106 | 107 | active_constraint_ids = [ 108 | i for i in range(len(is_active_constraint)) if is_active_constraint[i] 109 | ] 110 | active_fact_ids = [i for i in range(len(is_active_fact)) if is_active_fact[i]] 111 | score = len(active_constraint_ids) + len(active_fact_ids) 112 | return score, active_constraint_ids, active_fact_ids 113 | 114 | def analyze(self, n_workers: int = 0, backend: Union[None, str, type] = None): 115 | backend_type = _get_backend(backend) 116 | csp_solver = backend_type(self.variables) 117 | csp_solver.add_constraint(self.constraints) 118 | 119 | if not csp_solver.solve_irrefutably(self.is_answer_key): 120 | return None 121 | 122 | unlearnt_facts = [] 123 | for i, v in enumerate(self.variables): 124 | if self.is_answer_key[i] and self.variables[i].sol is not None: 125 | unlearnt_facts.append((i, self.variables[i].sol)) 126 | learnt_facts: List[Tuple[int, Union[BoolVar, IntVar]]] = [] 127 | 128 | res = [] 129 | while len(unlearnt_facts) > 0: 130 | if n_workers >= 0: 131 | with Pool(None if n_workers == 0 else n_workers) as pool: 132 | args = [ 133 | (i, unlearnt_facts, learnt_facts, backend) 134 | for i in range(len(unlearnt_facts)) 135 | ] 136 | cand_all = pool.starmap(self._test_unlearnt_fact, args) 137 | else: 138 | cand_all = [ 139 | self._test_unlearnt_fact(i, unlearnt_facts, learnt_facts, backend) 140 | for i in range(len(unlearnt_facts)) 141 | ] 142 | 143 | best_cand = min(cand_all) 144 | 145 | _, active_constraint_ids, active_fact_ids = best_cand 146 | csp_solver = backend_type(self.variables) 147 | csp_solver.add_constraint([self.constraints[i] for i in self.axiom_constraints]) 148 | for k in active_constraint_ids: 149 | _, cs = self.optional_constraints[k] 150 | csp_solver.add_constraint([self.constraints[j] for j in cs]) 151 | for k in active_fact_ids: 152 | vi, val = learnt_facts[k] 153 | csp_solver.add_constraint(self.variables[vi] == val) 154 | 155 | assert csp_solver.solve_irrefutably(self.is_answer_key) 156 | 157 | new_learnt_facts = [] 158 | new_unlearnt_facts = [] 159 | for vi, val in unlearnt_facts: 160 | if self.variables[vi].sol is not None: 161 | assert self.variables[vi].sol is val 162 | new_learnt_facts.append((vi, val)) 163 | else: 164 | new_unlearnt_facts.append((vi, val)) 165 | 166 | res.append( 167 | ( 168 | [(self.answer_key_name[i], val) for i, val in new_learnt_facts], 169 | [self.optional_constraints[i][0] for i in best_cand[1]], 170 | [self.answer_key_name[learnt_facts[i][0]] for i in best_cand[2]], 171 | ) 172 | ) 173 | print( 174 | ( 175 | [(self.answer_key_name[i], val) for i, val in new_learnt_facts], 176 | [self.optional_constraints[i][0] for i in best_cand[1]], 177 | [self.answer_key_name[learnt_facts[i][0]] for i in best_cand[2]], 178 | ) 179 | ) 180 | learnt_facts += new_learnt_facts 181 | unlearnt_facts = new_unlearnt_facts 182 | 183 | return res 184 | -------------------------------------------------------------------------------- /cspuz/backend/__init__.py: -------------------------------------------------------------------------------- 1 | from . import backend, sugar_like, z3 2 | 3 | __all__ = ["backend", "sugar_like", "z3"] 4 | -------------------------------------------------------------------------------- /cspuz/backend/_subproc.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | import subprocess 3 | import signal 4 | 5 | try: 6 | import psutil # type: ignore 7 | 8 | _PSUTIL_AVAILABLE = True 9 | except ImportError: 10 | _PSUTIL_AVAILABLE = False 11 | 12 | 13 | def run_subprocess(args, input, timeout=None): 14 | if timeout and not _PSUTIL_AVAILABLE: 15 | warnings.warn("psutil not found; timeout is ignored") 16 | if timeout and _PSUTIL_AVAILABLE: 17 | proc = subprocess.Popen( 18 | args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE 19 | ) 20 | try: 21 | out, _ = proc.communicate(input.encode("ascii"), timeout=timeout) 22 | out = out.decode("utf-8") 23 | except subprocess.TimeoutExpired: 24 | parent = psutil.Process(proc.pid) 25 | children = parent.children(recursive=True) 26 | children.append(parent) 27 | for p in children: 28 | p.send_signal(signal.SIGTERM) 29 | raise 30 | return out 31 | else: 32 | res = subprocess.run(args, input=input.encode("ascii"), stdout=subprocess.PIPE) 33 | out = res.stdout.decode("utf-8") 34 | return out 35 | -------------------------------------------------------------------------------- /cspuz/backend/backend.py: -------------------------------------------------------------------------------- 1 | class Backend: 2 | def solve(self): 3 | raise NotImplementedError 4 | 5 | def solve_irrefutably(self, is_answer_key): 6 | raise NotImplementedError 7 | -------------------------------------------------------------------------------- /cspuz/backend/sugar_like.py: -------------------------------------------------------------------------------- 1 | """ 2 | CSP backend using the Sugar CSP solver (http://bach.istc.kobe-u.ac.jp/sugar/). 3 | """ 4 | 5 | from ..configuration import config 6 | from ..expr import Op, Expr, BoolVar, IntVar 7 | 8 | from .backend import Backend 9 | from ._subproc import run_subprocess 10 | 11 | OP_TO_OPNAME = { 12 | Op.NEG: "-", 13 | Op.ADD: "+", 14 | Op.SUB: "-", 15 | Op.EQ: "=", 16 | Op.NE: "!=", 17 | Op.LE: "<=", 18 | Op.LT: "<", 19 | Op.GE: ">=", 20 | Op.GT: ">", 21 | Op.NOT: "!", 22 | Op.AND: "&&", 23 | Op.OR: "||", 24 | Op.IFF: "iff", 25 | Op.XOR: "xor", 26 | Op.IMP: "=>", 27 | Op.IF: "if", 28 | Op.ALLDIFF: "alldifferent", 29 | Op.GRAPH_ACTIVE_VERTICES_CONNECTED: "graph-active-vertices-connected", 30 | Op.GRAPH_DIVISION: "graph-division", 31 | } 32 | 33 | 34 | def _convert_variable(v): 35 | if isinstance(v, BoolVar): 36 | return "(bool b{})".format(v.id) 37 | elif isinstance(v, IntVar): 38 | return "(int i{} {} {})".format(v.id, v.lo, v.hi) 39 | else: 40 | raise TypeError() 41 | 42 | 43 | def _convert_expr(e): 44 | if e is None: 45 | return "*" 46 | if isinstance(e, bool): 47 | return "true" if e else "false" 48 | if isinstance(e, int): 49 | return str(e) 50 | if not isinstance(e, Expr): 51 | raise TypeError() 52 | 53 | if isinstance(e, BoolVar): 54 | return "b{}".format(e.id) 55 | elif isinstance(e, IntVar): 56 | return "i{}".format(e.id) 57 | elif e.op == Op.BOOL_CONSTANT: 58 | return "true" if e.operands[0] else "false" 59 | elif e.op == Op.INT_CONSTANT: 60 | return str(e.operands[0]) 61 | else: 62 | return "({} {})".format(OP_TO_OPNAME[e.op], " ".join(map(_convert_expr, e.operands))) 63 | 64 | 65 | class SugarLikeBackend(Backend): 66 | def __init__(self, variables): 67 | self.variables = variables 68 | max_var_id = -1 69 | for v in self.variables: 70 | if isinstance(v, (BoolVar, IntVar)): 71 | max_var_id = max(max_var_id, v.id) 72 | else: 73 | raise TypeError() 74 | self.max_var_id = max_var_id 75 | self.converted_variables = list(map(_convert_variable, self.variables)) 76 | self.converted_constraints = [] 77 | 78 | def add_constraint(self, constraint): 79 | if isinstance(constraint, list): 80 | self.converted_constraints += map(_convert_expr, constraint) 81 | else: 82 | self.converted_constraints.append(_convert_expr(constraint)) 83 | 84 | def solve(self): 85 | csp_description = "\n".join(self.converted_variables + self.converted_constraints) 86 | out = self._call_solver(csp_description).split("\n") 87 | if "UNSATISFIABLE" in out[0]: 88 | for v in self.variables: 89 | v.sol = None 90 | return False 91 | 92 | assignment = [None] * (self.max_var_id + 1) 93 | for line in out[1:]: 94 | if len(line) <= 2: 95 | break 96 | var, val = line[2:].strip().split("\t") 97 | if val == "true": 98 | converted_val = True 99 | elif val == "false": 100 | converted_val = False 101 | else: 102 | converted_val = int(val) 103 | assignment[int(var[1:])] = converted_val 104 | for v in self.variables: 105 | v.sol = assignment[v.id] 106 | return True 107 | 108 | def solve_irrefutably(self, is_answer_key): 109 | answer_keys = [] 110 | for i in range(len(self.variables)): 111 | if is_answer_key[i]: 112 | if isinstance(self.variables[i], BoolVar): 113 | answer_keys.append("b{}".format(self.variables[i].id)) 114 | elif isinstance(self.variables[i], IntVar): 115 | answer_keys.append("i{}".format(self.variables[i].id)) 116 | else: 117 | raise TypeError() 118 | answer_keys_desc = "#" + " ".join(answer_keys) 119 | csp_description = "\n".join( 120 | self.converted_variables + self.converted_constraints + [answer_keys_desc] 121 | ) 122 | out = self._call_solver(csp_description).split("\n") 123 | for v in self.variables: 124 | v.sol = None 125 | 126 | if "unsat" in out[0]: 127 | return False 128 | 129 | assignment = [None] * (self.max_var_id + 1) 130 | for line in out[1:]: 131 | if len(line) <= 2: 132 | break 133 | var, val = line.split(" ") 134 | if val == "true": 135 | converted_val = True 136 | elif val == "false": 137 | converted_val = False 138 | else: 139 | converted_val = int(val) 140 | assignment[int(var[1:])] = converted_val 141 | for v in self.variables: 142 | v.sol = assignment[v.id] 143 | return True 144 | 145 | def _call_solver(self, csp_description: str) -> str: 146 | raise NotImplementedError 147 | 148 | 149 | class SugarBackend(SugarLikeBackend): 150 | def solve_irrefutably(self, is_answer_key): 151 | raise NotImplementedError 152 | 153 | def _call_solver(self, csp_description: str) -> str: 154 | sugar_path = config.backend_path or "sugar" 155 | out = run_subprocess( 156 | [sugar_path, "/dev/stdin"], csp_description, timeout=config.solver_timeout 157 | ) 158 | return out 159 | 160 | 161 | class SugarExtendedBackend(SugarLikeBackend): 162 | def _call_solver(self, csp_description: str) -> str: 163 | sugar_path = config.backend_path or "sugar" 164 | out = run_subprocess( 165 | [sugar_path, "/dev/stdin"], csp_description, timeout=config.solver_timeout 166 | ) 167 | return out 168 | 169 | 170 | class CSugarBackend(SugarLikeBackend): 171 | def _call_solver(self, csp_description: str) -> str: 172 | import pycsugar # type: ignore 173 | 174 | return pycsugar.solver(csp_description) 175 | 176 | 177 | class EnigmaCSPBackend(SugarLikeBackend): 178 | def _call_solver(self, csp_description: str) -> str: 179 | import enigma_csp # type: ignore 180 | 181 | return enigma_csp.solver(csp_description) 182 | 183 | 184 | class CspuzCoreBackend(SugarLikeBackend): 185 | def _call_solver(self, csp_description: str) -> str: 186 | import cspuz_core # type: ignore 187 | 188 | return cspuz_core.solver(csp_description) 189 | -------------------------------------------------------------------------------- /cspuz/backend/z3.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | from .backend import Backend 4 | from ..expr import Op, Expr, BoolVar, IntVar 5 | 6 | z3 = None 7 | 8 | 9 | def _convert_expr(e, variables_dict): 10 | if isinstance(e, (bool, int)): 11 | return e 12 | if not isinstance(e, Expr): 13 | raise TypeError() 14 | if isinstance(e, (BoolVar, IntVar)): 15 | return variables_dict[e.id] 16 | else: 17 | operands = list(map(lambda x: _convert_expr(x, variables_dict), e.operands)) 18 | if e.op == Op.NEG: 19 | return -operands[0] 20 | elif e.op == Op.ADD: 21 | ret = operands[0] 22 | for i in range(1, len(operands)): 23 | ret = ret + operands[i] 24 | return ret 25 | elif e.op == Op.SUB: 26 | ret = operands[0] 27 | for i in range(1, len(operands)): 28 | ret = ret - operands[i] 29 | return ret 30 | elif e.op == Op.EQ: 31 | return operands[0] == operands[1] 32 | elif e.op == Op.NE: 33 | return operands[0] != operands[1] 34 | elif e.op == Op.LE: 35 | return operands[0] <= operands[1] 36 | elif e.op == Op.LT: 37 | return operands[0] < operands[1] 38 | elif e.op == Op.GE: 39 | return operands[0] >= operands[1] 40 | elif e.op == Op.GT: 41 | return operands[0] > operands[1] 42 | elif e.op == Op.NOT: 43 | return z3.Not(operands[0]) 44 | elif e.op == Op.AND: 45 | return z3.And(operands) 46 | elif e.op == Op.OR: 47 | return z3.Or(operands) 48 | elif e.op == Op.XOR: 49 | return z3.Xor(operands[0], operands[1]) 50 | elif e.op == Op.IFF: 51 | return operands[0] == operands[1] 52 | elif e.op == Op.IMP: 53 | return z3.Or(z3.Not(operands[0]), operands[1]) 54 | elif e.op == Op.IF: 55 | return z3.If(operands[0], operands[1], operands[2]) 56 | elif e.op == Op.ALLDIFF: 57 | return z3.Distinct(operands) 58 | 59 | 60 | class Z3Backend(Backend): 61 | def __init__(self, variables): 62 | global z3 63 | if z3 is None: 64 | z3 = importlib.import_module("z3") 65 | 66 | self.variables = variables 67 | self.variables_dict = dict() 68 | id_last = 0 69 | for v in variables: 70 | if isinstance(v, BoolVar): 71 | self.variables_dict[v.id] = z3.Bool("b" + str(id_last)) 72 | elif isinstance(v, IntVar): 73 | self.variables_dict[v.id] = z3.Int("i" + str(id_last)) 74 | id_last += 1 75 | self.converted_constraints = [] 76 | 77 | def add_constraint(self, constraint): 78 | if isinstance(constraint, list): 79 | self.converted_constraints += map( 80 | lambda e: _convert_expr(e, self.variables_dict), constraint 81 | ) 82 | else: 83 | self.converted_constraints.append(_convert_expr(constraint, self.variables_dict)) 84 | 85 | def solve(self): 86 | solver = z3.Solver() 87 | for var in self.variables: 88 | if isinstance(var, IntVar): 89 | var_z3 = self.variables_dict[var.id] 90 | solver.add(var.lo <= var_z3, var_z3 <= var.hi) 91 | solver.add(self.converted_constraints) 92 | 93 | if solver.check() == z3.unsat: 94 | return False 95 | 96 | model = solver.model() 97 | for var in self.variables: 98 | var_z3 = self.variables_dict[var.id] 99 | if isinstance(var, BoolVar): 100 | var.sol = z3.is_true(model[var_z3]) 101 | elif isinstance(var, IntVar): 102 | var.sol = model[var_z3].as_long() 103 | return True 104 | -------------------------------------------------------------------------------- /cspuz/configuration.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, TypeVar, Union 3 | 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def _get_default(infer_from_env: bool, env_key: str, default: T) -> Union[str, T]: 9 | if infer_from_env: 10 | return os.environ.get(env_key, default) 11 | else: 12 | return default 13 | 14 | 15 | def _strtobool(s: str) -> bool: 16 | s = s.lower() 17 | if s in ("true", "1"): 18 | return True 19 | elif s in ("false", "0"): 20 | return False 21 | else: 22 | raise ValueError(f"Invalid value for boolean: {s}") 23 | 24 | 25 | def _detect_backend() -> str: 26 | try: 27 | import cspuz_core # type: ignore # noqa 28 | 29 | return "cspuz_core" 30 | except ImportError: 31 | pass 32 | 33 | try: 34 | import enigma_csp # type: ignore # noqa 35 | 36 | return "enigma_csp" 37 | except ImportError: 38 | pass 39 | 40 | try: 41 | import pycsugar # type: ignore # noqa 42 | 43 | return "csugar" 44 | except ImportError: 45 | pass 46 | 47 | try: 48 | import z3 # type: ignore # noqa 49 | 50 | return "z3" 51 | except ImportError: 52 | pass 53 | 54 | return "sugar" 55 | 56 | 57 | class Config(object): 58 | """ 59 | Class for maintaining the solver configurations. 60 | 61 | Currently, there are 5 different backends supported: 62 | 63 | - `sugar` 64 | Sugar CSP solver (https://cspsat.gitlab.io/sugar/). 65 | - `sugar_extended` 66 | Sugar CSP solver with an optimization for `solve_irrefutably` feature. 67 | - `z3` 68 | z3 SMT solver (https://pypi.org/project/z3-solver/). 69 | Prerequisite: `import z3` succeeds. 70 | - `csugar` 71 | csugar CSP solver (https://github.com/semiexp/csugar) with Python 72 | interface. 73 | Prerequisite: `import pycsugar` succeeds. 74 | - `cspuz_core` 75 | cspuz_core CSP solver (https://github.com/semiexp/cspuz_core) with Python 76 | interface. 77 | Prerequisite: `import cspuz_core` succeeds. 78 | - `auto` 79 | Automatically decide the backend based on availability of the libraries. 80 | The priority is as follows: 81 | - `cspuz_core` 82 | - `enigma_csp` 83 | - `csugar` 84 | - `z3` 85 | - `sugar` 86 | 87 | For backward compatibility, `enigma_csp` (the former name of `cspuz_core`) 88 | is also supported. 89 | 90 | `default_backend` is the name of a backend (listed above) which is used 91 | by default in `Solver`. 92 | 93 | `backend_path` specifies the path to the executable of the backend solver 94 | (e.g. `sugar` script, `sugar_ext.sh`, or a binary of Sugar-compatible 95 | CSP solver like csugar) for `sugar` and `sugar_extended` backends. 96 | 97 | `use_graph_primitive` controls whether native graph constraints are used. 98 | This feature is supported by csugar and cspuz_core CSP solver and is 99 | enabled by default for `csugar` and `cspuz_core` backends. 100 | You can use this for `sugar` and `sugar_extended` backends to work with 101 | csugar or cspuz_core CLI, but cspuz does not check whether the backend 102 | actually supports native graph constraints. 103 | Note that `use_graph_primitive` affects how graph constraints are 104 | translated on the invocation of graph constraints methods (like 105 | `active_vertices_connected`). Therefore, it is strongly recommended to set 106 | `default_backend` correctly, rather than specifying the backend on calling 107 | `Solver.solve` or `Solver.solve_irrefutably`. 108 | """ 109 | 110 | default_backend: str 111 | backend_path: Optional[str] 112 | csugar_binding: Optional[str] 113 | use_graph_primitive: bool 114 | use_graph_division_primitive: bool 115 | solver_timeout: Optional[float] 116 | 117 | def __init__(self, infer_from_env: bool = True) -> None: 118 | default_backend = _get_default(infer_from_env, "CSPUZ_DEFAULT_BACKEND", "auto") 119 | 120 | if default_backend == "auto": 121 | self.default_backend = _detect_backend() 122 | else: 123 | self.default_backend = default_backend 124 | 125 | self.backend_path = _get_default(infer_from_env, "CSPUZ_BACKEND_PATH", None) 126 | if self.default_backend in ("csugar", "enigma_csp", "cspuz_core"): 127 | graph_primitive_default = "True" 128 | else: 129 | graph_primitive_default = "False" 130 | if self.default_backend in ("enigma_csp", "cspuz_core"): 131 | graph_division_primitive_default = "True" 132 | else: 133 | graph_division_primitive_default = "False" 134 | self.use_graph_primitive = _strtobool( 135 | _get_default(infer_from_env, "CSPUZ_USE_GRAPH_PRIMITIVE", graph_primitive_default) 136 | ) 137 | self.use_graph_division_primitive = _strtobool( 138 | _get_default( 139 | infer_from_env, 140 | "CSPUZ_USE_GRAPH_DIVISION_PRIMITIVE", 141 | graph_division_primitive_default, 142 | ) 143 | ) 144 | self.solver_timeout = None 145 | 146 | 147 | config = Config() 148 | -------------------------------------------------------------------------------- /cspuz/constraints.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Union, overload 2 | 3 | from .array import BoolArray1D, BoolArray2D, IntArray1D, IntArray2D, _elementwise 4 | from .expr import BoolExpr, BoolExprLike, IntExpr, IntExprLike, Op 5 | 6 | 7 | def flatten_iterator(*args: Any) -> Any: 8 | for arg in args: 9 | if hasattr(arg, "__iter__"): 10 | for xs in arg: 11 | for x in flatten_iterator(xs): 12 | yield x 13 | else: 14 | yield arg 15 | 16 | 17 | def alldifferent(*args: Any) -> BoolExpr: 18 | operands: List[Union[IntExpr, int]] = [] 19 | 20 | for x in flatten_iterator(*args): 21 | if isinstance(x, int): 22 | operands.append(x) 23 | elif isinstance(x, IntExpr): 24 | operands.append(x) 25 | else: 26 | raise TypeError() 27 | 28 | return BoolExpr(Op.ALLDIFF, operands) 29 | 30 | 31 | def count_true(*args: Any) -> IntExpr: 32 | operands: List[Union[IntExpr, int]] = [] 33 | constant = 0 34 | 35 | for x in flatten_iterator(*args): 36 | if isinstance(x, bool): 37 | if x is True: 38 | constant += 1 39 | elif isinstance(x, BoolExpr): 40 | operands.append(x.cond(1, 0)) 41 | else: 42 | raise TypeError() 43 | 44 | if constant > 0: 45 | operands.append(constant) 46 | if len(operands) == 0: 47 | return IntExpr(Op.INT_CONSTANT, [0]) 48 | 49 | return IntExpr(Op.ADD, operands) # type: ignore 50 | 51 | 52 | def fold_or(*args: Any) -> BoolExpr: 53 | operands: List[BoolExpr] = [] 54 | 55 | for x in flatten_iterator(*args): 56 | if isinstance(x, bool): 57 | if x is True: 58 | return BoolExpr(Op.BOOL_CONSTANT, [True]) 59 | elif isinstance(x, BoolExpr): 60 | operands.append(x) 61 | else: 62 | raise TypeError() 63 | 64 | if len(operands) == 0: 65 | return BoolExpr(Op.BOOL_CONSTANT, [False]) 66 | return BoolExpr(Op.OR, operands) # type: ignore 67 | 68 | 69 | def fold_and(*args: Any) -> BoolExpr: 70 | operands: List[BoolExpr] = [] 71 | 72 | for x in flatten_iterator(*args): 73 | if isinstance(x, bool): 74 | if x is False: 75 | return BoolExpr(Op.BOOL_CONSTANT, [False]) 76 | elif isinstance(x, BoolExpr): 77 | operands.append(x) 78 | else: 79 | raise TypeError() 80 | 81 | if len(operands) == 0: 82 | return BoolExpr(Op.BOOL_CONSTANT, [True]) 83 | return BoolExpr(Op.AND, operands) # type: ignore 84 | 85 | 86 | @overload 87 | def cond(c: BoolExprLike, t: IntExprLike, f: IntExprLike) -> IntExpr: ... 88 | 89 | 90 | @overload 91 | def cond(c: BoolExprLike, t: IntExprLike, f: IntArray1D) -> IntArray1D: ... 92 | 93 | 94 | @overload 95 | def cond(c: BoolExprLike, t: IntArray2D, f: IntArray2D) -> IntArray2D: ... 96 | 97 | 98 | @overload 99 | def cond(c: BoolExprLike, t: IntArray1D, f: Union[IntExprLike, IntArray1D]) -> IntArray1D: ... 100 | 101 | 102 | @overload 103 | def cond(c: BoolExprLike, t: IntArray2D, f: Union[IntExprLike, IntArray2D]) -> IntArray2D: ... 104 | 105 | 106 | @overload 107 | def cond( 108 | c: BoolArray1D, t: Union[IntExprLike, IntArray1D], f: Union[IntExprLike, IntArray1D] 109 | ) -> IntExpr: ... 110 | 111 | 112 | @overload 113 | def cond( 114 | c: BoolArray2D, t: Union[IntExprLike, IntArray2D], f: Union[IntExprLike, IntArray2D] 115 | ) -> IntExpr: ... 116 | 117 | 118 | def cond( 119 | c: Union[BoolExprLike, BoolArray1D, BoolArray2D], 120 | t: Union[IntExprLike, IntArray1D, IntArray2D], 121 | f: Union[IntExprLike, IntArray1D, IntArray2D], 122 | ) -> Union[IntExpr, IntArray1D, IntArray2D]: 123 | if isinstance(c, (BoolArray1D, BoolArray2D)): 124 | shape = c.shape 125 | elif isinstance(t, (IntArray1D, IntArray2D)): 126 | shape = t.shape 127 | elif isinstance(f, (IntArray1D, IntArray2D)): 128 | shape = f.shape 129 | else: 130 | return IntExpr(Op.IF, [c, t, f]) 131 | 132 | return _elementwise(Op.IF, shape, [c, t, f]) # type: ignore 133 | 134 | 135 | @overload 136 | def then(x: BoolExprLike, y: BoolExprLike) -> BoolExpr: ... 137 | 138 | 139 | @overload 140 | def then(x: BoolExprLike, y: BoolArray1D) -> BoolArray1D: ... 141 | 142 | 143 | @overload 144 | def then(x: BoolExprLike, y: BoolArray2D) -> BoolArray2D: ... 145 | 146 | 147 | @overload 148 | def then(x: BoolArray1D, y: Union[BoolExprLike, BoolArray1D]) -> BoolArray1D: ... 149 | 150 | 151 | @overload 152 | def then(x: BoolArray2D, y: Union[BoolExprLike, BoolArray2D]) -> BoolArray2D: ... 153 | 154 | 155 | def then( 156 | x: Union[BoolExprLike, BoolArray1D, BoolArray2D], 157 | y: Union[BoolExprLike, BoolArray1D, BoolArray2D], 158 | ) -> Union[BoolExpr, BoolArray1D, BoolArray2D]: 159 | if isinstance(x, (BoolArray1D, BoolArray2D)): 160 | shape = x.shape 161 | elif isinstance(y, (BoolArray1D, BoolArray2D)): 162 | shape = y.shape 163 | else: 164 | return BoolExpr(Op.IMP, [x, y]) 165 | 166 | return _elementwise(Op.IMP, shape, [x, y]) # type: ignore 167 | -------------------------------------------------------------------------------- /cspuz/generator/__init__.py: -------------------------------------------------------------------------------- 1 | from cspuz.generator.core import ( 2 | default_score_calculator, 3 | default_uniqueness_checker, 4 | count_non_default_values, 5 | generate_problem, 6 | ) 7 | from cspuz.generator.builder import Builder, Choice, ArrayBuilder2D, build_neighbor_generator 8 | from cspuz.generator.segmentation import SegmentationBuilder2D 9 | 10 | __all__ = [ 11 | "default_score_calculator", 12 | "default_uniqueness_checker", 13 | "count_non_default_values", 14 | "generate_problem", 15 | "Builder", 16 | "Choice", 17 | "ArrayBuilder2D", 18 | "build_neighbor_generator", 19 | "SegmentationBuilder2D", 20 | ] 21 | -------------------------------------------------------------------------------- /cspuz/generator/builder.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Any, Callable, Generic, Iterator, Iterable, Optional, TypeVar 3 | 4 | import cspuz.generator.srandom as srandom 5 | 6 | 7 | def build_neighbor_generator(pattern: Any) -> tuple[Any, Callable[[Any], Iterator[Any]]]: 8 | variables = [] 9 | 10 | def enumerate_variables(pat: Any, pos: list[Any]) -> Any: 11 | if isinstance(pat, Builder): 12 | variables.append((pos, pat)) 13 | return pat.initial() 14 | elif isinstance(pat, (list, tuple)): 15 | ret = [] 16 | for i in range(len(pat)): 17 | ret.append(enumerate_variables(pat[i], pos + [i])) 18 | if isinstance(pat, list): 19 | return ret 20 | else: 21 | return tuple(ret) 22 | else: 23 | return pat 24 | 25 | initial = enumerate_variables(pattern, []) 26 | 27 | def get(pat: Any, pos: list[Any]) -> Any: 28 | if len(pos) == 0: 29 | return pat 30 | return get(pat[pos[0]], pos[1:]) 31 | 32 | def with_update(problem: Any, pat: Any, pos: list[Any], v: Any) -> Any: 33 | if len(pos) == 0: 34 | assert isinstance(pat, Builder) 35 | return pat.copy_with_update(problem, v) 36 | if isinstance(pat, (list, tuple)): 37 | ret = [ 38 | with_update(problem[i], pat[i], pos[1:], v) if i == pos[0] else problem[i] 39 | for i in range(len(pat)) 40 | ] 41 | if isinstance(pat, tuple): 42 | return tuple(ret) 43 | else: 44 | return ret 45 | 46 | def generator(problem: Any) -> Iterator[Any]: 47 | global _use_deterministic_prng 48 | cands = [] 49 | for pos, choice in variables: 50 | subproblem = get(problem, pos) 51 | subpattern = get(pattern, pos) 52 | for v in subpattern.candidates(subproblem): 53 | cands.append((pos, v)) 54 | srandom.shuffle(cands) 55 | for pos, val in cands: 56 | next_problem = with_update(problem, pattern, pos, val) 57 | yield next_problem 58 | 59 | return initial, generator 60 | 61 | 62 | T = TypeVar("T") 63 | U = TypeVar("U") 64 | 65 | 66 | class Builder(Generic[T, U]): 67 | def initial(self) -> T: 68 | raise NotImplementedError 69 | 70 | def candidates(self, current: T) -> Iterable[U]: 71 | raise NotImplementedError 72 | 73 | def copy_with_update(self, previous: T, update: U) -> T: 74 | raise NotImplementedError 75 | 76 | 77 | class Choice(Generic[T], Builder[T, T]): 78 | def __init__(self, choice: Iterable[T], default: T) -> None: 79 | self.choice = list(choice) 80 | self.default = default 81 | 82 | def initial(self) -> T: 83 | return self.default 84 | 85 | def candidates(self, current: T) -> Iterable[T]: 86 | return [c for c in self.choice if c != current] 87 | 88 | def copy_with_update(self, previous: T, update: T) -> T: 89 | return update 90 | 91 | 92 | class ArrayBuilder2D(Generic[T], Builder[list[list[T]], list[tuple[int, int, T]]]): 93 | def __init__( 94 | self, 95 | height: int, 96 | width: int, 97 | choice: Iterable[T], 98 | default: T, 99 | disallow_adjacent: bool = False, 100 | symmetry: bool = False, 101 | initial: Optional[list[list[T]]] = None, 102 | use_move: bool = False, 103 | ) -> None: 104 | self.height = height 105 | self.width = width 106 | self.choice = list(choice) 107 | self.default = default 108 | self.non_default = [c for c in self.choice if c != self.default] 109 | if disallow_adjacent is True: 110 | self.disallow_adjacent = [(-1, 0), (1, 0), (0, -1), (0, 1)] 111 | elif disallow_adjacent is False: 112 | self.disallow_adjacent = [] 113 | else: 114 | self.disallow_adjacent = disallow_adjacent 115 | self.symmetry = symmetry 116 | self.initial_problem = initial 117 | self.use_move = use_move 118 | 119 | def initial(self) -> list[list[T]]: 120 | if self.initial_problem is not None: 121 | return self.initial_problem 122 | return [[self.default for _ in range(self.width)] for _ in range(self.height)] 123 | 124 | def candidates(self, current: list[list[T]]) -> Iterable[list[tuple[int, int, T]]]: 125 | global _use_deterministic_prng 126 | ret = [] 127 | if self.use_move: 128 | if self.symmetry: 129 | for y1 in range(self.height): 130 | for x1 in range(self.width): 131 | for _ in range(10): 132 | y2 = srandom.randint(0, self.height - 1) 133 | x2 = srandom.randint(0, self.width - 1) 134 | if (y1, x1) == (y2, x2): 135 | continue 136 | 137 | y1b = self.height - 1 - y1 138 | x1b = self.width - 1 - x1 139 | y2b = self.height - 1 - y2 140 | x2b = self.width - 1 - x2 141 | if (y1, x1) == (y1b, x1b): 142 | continue 143 | if (y1, x1) == (y2b, x2b): 144 | continue 145 | 146 | if current[y1][x1] != current[y2][x2]: 147 | ret.append( 148 | [ 149 | (y1, x1, current[y2][x2]), 150 | (y2, x2, current[y1][x1]), 151 | (y1b, x1b, current[y2b][x2b]), 152 | (y2b, x2b, current[y1b][x1b]), 153 | ] 154 | ) 155 | else: 156 | for y in range(self.height): 157 | for x in range(self.width): 158 | for _ in range(10): 159 | y2 = srandom.randint(0, self.height - 1) 160 | x2 = srandom.randint(0, self.width - 1) 161 | if (y, x) == (y2, x2): 162 | continue 163 | 164 | if current[y][x] != current[y2][x2]: 165 | ret.append( 166 | [ 167 | (y, x, current[y2][x2]), 168 | (y2, x2, current[y][x]), 169 | ] 170 | ) 171 | 172 | for y in range(self.height): 173 | for x in range(self.width): 174 | default_only = False 175 | for dy, dx in self.disallow_adjacent: 176 | y2 = y + dy 177 | x2 = x + dx 178 | if ( 179 | 0 <= y2 < self.height 180 | and 0 <= x2 < self.width 181 | and current[y2][x2] != self.default 182 | ): 183 | default_only = True 184 | if self.symmetry: 185 | y2 = self.height - 1 - y 186 | x2 = self.width - 1 - x 187 | if (y2 - y, x2 - x) in self.disallow_adjacent: 188 | default_only = True 189 | if current[y][x] != self.default: 190 | ret.append([(y, x, self.default), (y2, x2, self.default)]) 191 | if not default_only: 192 | if current[y][x] == self.default: 193 | for v in self.non_default: 194 | v2 = srandom.choice(self.non_default) 195 | if current[y][x] != v or current[y2][x2] != v2: 196 | ret.append([(y, x, v), (y2, x2, v2)]) 197 | else: 198 | for v in self.non_default: 199 | if v != current[y][x]: 200 | ret.append([(y, x, v)]) 201 | else: 202 | for v in self.choice: 203 | if default_only and v != self.default: 204 | continue 205 | if v != current[y][x]: 206 | ret.append([(y, x, v)]) 207 | return ret 208 | 209 | def copy_with_update( 210 | self, previous: list[list[T]], update: list[tuple[int, int, T]] 211 | ) -> list[list[T]]: 212 | ret = copy.deepcopy(previous) 213 | for y, x, v in update: 214 | ret[y][x] = v 215 | return ret 216 | -------------------------------------------------------------------------------- /cspuz/generator/core.py: -------------------------------------------------------------------------------- 1 | import math 2 | import sys 3 | from typing import Any, Callable, Optional, TypeVar 4 | from collections.abc import Iterator 5 | 6 | from ..array import Array1D, Array2D 7 | from cspuz.expr import BoolExpr, IntExpr 8 | from cspuz.grid_frame import BoolGridFrame 9 | from cspuz.generator.builder import build_neighbor_generator 10 | import cspuz.generator.srandom as srandom 11 | 12 | 13 | def default_score_calculator(*args: Any) -> float: 14 | score = 0.0 15 | for arg in args: 16 | if isinstance(arg, (BoolExpr, IntExpr)) and arg.is_variable(): 17 | if arg.sol is not None: 18 | score += 1 19 | elif isinstance(arg, (Array1D, Array2D, BoolGridFrame)): 20 | for a in arg: 21 | if a.sol is not None: 22 | score += 1 23 | elif isinstance(arg, list): 24 | for a in arg: 25 | score += default_score_calculator(a) 26 | return score 27 | 28 | 29 | def default_uniqueness_checker(*args: Any) -> bool: 30 | for arg in args: 31 | if isinstance(arg, (BoolExpr, IntExpr)) and arg.is_variable(): 32 | if arg.sol is None: 33 | return False 34 | elif isinstance(arg, (Array1D, Array2D, BoolGridFrame)): 35 | for a in arg: 36 | if a.sol is None: 37 | return False 38 | elif isinstance(arg, list): 39 | for a in arg: 40 | if not default_uniqueness_checker(a): 41 | return False 42 | return True 43 | 44 | 45 | def count_non_default_values(problem: Any, default: Any, weight: float = 1.0) -> float: 46 | if isinstance(problem, (list, tuple)): 47 | ret = 0.0 48 | for v in problem: 49 | ret += count_non_default_values(v, default, weight) 50 | return ret 51 | else: 52 | if problem != default: 53 | return weight 54 | else: 55 | return 0.0 56 | 57 | 58 | Problem = TypeVar("Problem") 59 | 60 | 61 | def generate_problem( 62 | solver: Callable[[Problem], tuple[Any, ...]], 63 | initial_problem: Optional[Problem] = None, 64 | neighbor_generator: Optional[Callable[[Problem], Iterator[Problem]]] = None, 65 | builder_pattern: Any = None, 66 | score: Optional[Callable[..., float]] = None, 67 | clue_penalty: Optional[Callable[[Problem], float]] = None, 68 | uniqueness: Optional[Callable[..., bool]] = None, 69 | pretest: Optional[Callable[[Problem], bool]] = None, 70 | initial_temperature: float = 5.0, 71 | temperature_decay: float = 0.995, 72 | max_steps: Optional[int] = None, 73 | solve_initial_problem: bool = False, 74 | verbose: bool = False, 75 | ) -> Optional[Problem]: 76 | global _use_deterministic_prng 77 | 78 | if builder_pattern is not None: 79 | if initial_problem is not None or neighbor_generator is not None: 80 | raise ValueError( 81 | "initial_problem and neighbor_generator must not be " 82 | "specified if builder_pattern is specified" 83 | ) 84 | initial_problem, neighbor_generator = build_neighbor_generator(builder_pattern) 85 | else: 86 | if initial_problem is None or neighbor_generator is None: 87 | raise ValueError( 88 | "initial_problem and neighbor_generator must be specified " 89 | "if builder_pattern is not specified" 90 | ) 91 | if score is None: 92 | score = default_score_calculator 93 | if uniqueness is None: 94 | uniqueness = default_uniqueness_checker 95 | 96 | problem = initial_problem 97 | current_score = None 98 | temperature = initial_temperature 99 | 100 | if max_steps is None: 101 | max_steps = 1000 102 | 103 | if solve_initial_problem: 104 | is_sat, *answer = solver(problem) 105 | if not is_sat: 106 | return None 107 | score_base = score(*answer) 108 | if clue_penalty is None: 109 | score_penalty = 0.0 110 | else: 111 | score_penalty = clue_penalty(problem) 112 | current_score = score_base - score_penalty 113 | 114 | for _step in range(max_steps): 115 | for next_problem in neighbor_generator(problem): 116 | if pretest is not None and not pretest(next_problem): 117 | continue 118 | 119 | is_sat, *answer = solver(next_problem) 120 | if not is_sat: 121 | continue 122 | 123 | if uniqueness(*answer): 124 | if verbose: 125 | print("generated", file=sys.stderr) 126 | return next_problem 127 | 128 | next_score_base = score(*answer) 129 | if clue_penalty is None: 130 | next_score_penalty = 0.0 131 | else: 132 | next_score_penalty = clue_penalty(next_problem) 133 | next_score = next_score_base - next_score_penalty 134 | 135 | update = ( 136 | current_score is None 137 | or current_score <= next_score 138 | or srandom.random() < math.exp((next_score - current_score) / temperature) 139 | ) 140 | if update: 141 | if verbose: 142 | print( 143 | "score: {} -> {} (base: {}, penalty: {})".format( 144 | current_score, next_score, next_score_base, next_score_penalty 145 | ), 146 | file=sys.stderr, 147 | ) 148 | problem = next_problem 149 | current_score = next_score 150 | break 151 | temperature *= temperature_decay 152 | if verbose: 153 | print("failed", file=sys.stderr) 154 | return None 155 | -------------------------------------------------------------------------------- /cspuz/generator/deterministic_random.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Sequence 2 | 3 | 4 | class XorShift: 5 | """An XorShift pseudo-random number generator (PRNG) with period 2^128-1. 6 | 7 | Although Python supplies an easy-to-use :obj:`random` module, it does not 8 | necessarily ensure the reproducibility across different versions of Python. 9 | If reproducibility is the top priority in your application (e.g. bench- 10 | marks), you may consider using this PRNG. 11 | 12 | Reference: 13 | Marsaglia, G. (2003). Xorshift RNGs. Journal of Statistical Software, 14 | 8(14), 1-6. https://doi.org/10.18637/jss.v008.i14 15 | """ 16 | 17 | def __init__(self, seed: int) -> None: 18 | """Initialize an XorShift PRNG with a seed. 19 | 20 | Args: 21 | seed (:obj:`int`): Seed for initialization. 22 | Note that only the lowest 32 bits in :obj:`seed` are used to seed 23 | this PRNG. 24 | """ 25 | self._x = 123456789 26 | self._y = 362436069 27 | self._z = 521288629 28 | self._w = 88675123 ^ (seed & 0xFFFFFFFF) 29 | 30 | def next(self) -> int: 31 | """Return a random integer, modifying the internal states. 32 | 33 | Returns: 34 | int: A random integer in range [0, 2^31-1]. 35 | """ 36 | t = (self._x ^ (self._x << 11)) & 0xFFFFFFFF 37 | self._x = self._y 38 | self._y = self._z 39 | self._z = self._w 40 | self._w = (self._w ^ (self._w >> 19)) ^ (t ^ (t >> 8)) 41 | return self._w 42 | 43 | 44 | _XORSHIFT_DOMAIN_SIZE = 1 << 32 45 | _rng = XorShift(0) 46 | 47 | 48 | def seed(s: int) -> None: 49 | """Initialize the global PRNG with the given seed :obj:`s`. 50 | 51 | Args: 52 | s (int): Seed for initialization. See :obj:`XorShift::__init__` for 53 | details. 54 | """ 55 | global _rng 56 | _rng = XorShift(s) 57 | 58 | 59 | def randint(a: int, b: int) -> int: 60 | """Return an uniform random integer in range [:obj:`a`, :obj:`b`], 61 | inclusive. 62 | 63 | Args: 64 | a (int): The lower bound. 65 | b (int): The upper bound. 66 | 67 | Raises: 68 | ValueError: If the domain specified by :obj:`a` and :obj:`b` is 69 | invalid, i.e., :obj:`a` > :obj:`b` or :obj:`b` >= :obj:`a` + 2^32. 70 | 71 | Returns: 72 | int: A random integer. 73 | """ 74 | global _rng 75 | 76 | if a > b: 77 | raise ValueError("`b` must be at least `a`") 78 | 79 | w = b - a + 1 80 | if w > _XORSHIFT_DOMAIN_SIZE: 81 | raise ValueError(f"domain size is too large: {w}") 82 | 83 | limit = _XORSHIFT_DOMAIN_SIZE - _XORSHIFT_DOMAIN_SIZE % w 84 | while True: 85 | x = _rng.next() 86 | if x < limit: 87 | return x % w 88 | 89 | 90 | def choice(cand: Sequence[Any]) -> Any: 91 | """Pick an element in :obj:`cand` uniformly at random. 92 | 93 | Args: 94 | cand (:obj:`Sequence[Any]`): Candidates for choice. 95 | 96 | Raises: 97 | ValueError: If `cand` contains no element. 98 | 99 | Returns: 100 | :obj:`Any`: The picked element. 101 | """ 102 | if len(cand) == 0: 103 | raise ValueError("`cand` is empty") 104 | 105 | idx = randint(0, len(cand) - 1) 106 | return cand[idx] 107 | 108 | 109 | def shuffle(seq: List[Any]) -> None: 110 | """Shuffle :obj:`seq` uniformly at random. :obj:`seq` is modified. 111 | 112 | Args: 113 | seq (:obj:`List[Any]`): Sequence to be shuffled. 114 | """ 115 | for i in range(1, len(seq)): 116 | j = randint(0, i) 117 | if i != j: 118 | seq[i], seq[j] = seq[j], seq[i] 119 | 120 | 121 | def random() -> float: 122 | """Return a uniform random float number in [0, 1). 123 | 124 | Returns: 125 | :obj:`float`: A random number. 126 | """ 127 | 128 | global _rng 129 | return float(_rng.next()) / _XORSHIFT_DOMAIN_SIZE 130 | -------------------------------------------------------------------------------- /cspuz/generator/srandom.py: -------------------------------------------------------------------------------- 1 | import random as pyrandom 2 | from typing import Any, List, Optional, Sequence 3 | 4 | import cspuz.generator.deterministic_random as drandom 5 | 6 | 7 | _use_deterministic_prng = False 8 | 9 | 10 | def use_deterministic_prng(enabled: bool, seed: Optional[int] = None) -> None: 11 | global _use_deterministic_prng 12 | _use_deterministic_prng = enabled 13 | if enabled: 14 | if seed is None: 15 | seed = 0 16 | drandom.seed(seed) 17 | 18 | 19 | def is_use_deterministic_prng() -> bool: 20 | return _use_deterministic_prng 21 | 22 | 23 | def randint(a: int, b: int) -> int: 24 | global _use_deterministic_prng 25 | if _use_deterministic_prng: 26 | return drandom.randint(a, b) 27 | else: 28 | return pyrandom.randint(a, b) 29 | 30 | 31 | def choice(cand: Sequence[Any]) -> Any: 32 | global _use_deterministic_prng 33 | if _use_deterministic_prng: 34 | return drandom.choice(cand) 35 | else: 36 | return pyrandom.choice(cand) 37 | 38 | 39 | def shuffle(seq: List[Any]) -> None: 40 | global _use_deterministic_prng 41 | if _use_deterministic_prng: 42 | drandom.shuffle(seq) 43 | else: 44 | pyrandom.shuffle(seq) 45 | 46 | 47 | def random() -> float: 48 | global _use_deterministic_prng 49 | if _use_deterministic_prng: 50 | return drandom.random() 51 | else: 52 | return pyrandom.random() 53 | -------------------------------------------------------------------------------- /cspuz/grid_frame.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import Iterator, Optional, Tuple, Union 3 | 4 | from .array import BoolArray1D, BoolArray2D 5 | from .expr import BoolExpr 6 | from .solver import Solver 7 | 8 | 9 | class BoolGridFrame: 10 | """ 11 | Frame of `height` * `width` grid, each of whose edges is associated with 12 | a bool variable. 13 | """ 14 | 15 | def __init__( 16 | self, 17 | solver: Solver, 18 | height: int, 19 | width: int, 20 | horizontal: Optional[BoolArray2D] = None, 21 | vertical: Optional[BoolArray2D] = None, 22 | ): 23 | self.solver = solver 24 | self.height = height 25 | self.width = width 26 | 27 | if horizontal is None: 28 | self.horizontal = solver.bool_array((height + 1, width)) 29 | else: 30 | self.horizontal = horizontal 31 | 32 | if vertical is None: 33 | self.vertical = solver.bool_array((height, width + 1)) 34 | else: 35 | self.vertical = vertical 36 | 37 | def __getitem__(self, item: Tuple[int, int]) -> BoolExpr: 38 | y, x = item 39 | if not (0 <= y <= self.height * 2 and 0 <= x <= self.width * 2): 40 | raise IndexError("index out of range") 41 | if y % 2 == 0 and x % 2 == 1: 42 | return self.horizontal[y // 2, x // 2] 43 | elif y % 2 == 1 and x % 2 == 0: 44 | return self.vertical[y // 2, x // 2] 45 | else: 46 | raise IndexError("index does not specify a loop edge") 47 | 48 | def all_edges(self) -> BoolArray1D: 49 | return BoolArray1D(list(itertools.chain(self.horizontal, self.vertical))) 50 | 51 | def __iter__(self) -> Iterator[BoolExpr]: 52 | return itertools.chain(self.horizontal, self.vertical) 53 | 54 | def cell_neighbors( 55 | self, y: Union[int, Tuple[int, int]], x: Optional[int] = None 56 | ) -> BoolArray1D: 57 | if x is None: 58 | if isinstance(y, int): 59 | raise TypeError("two integers must be provided to 'cell_neighbors'") 60 | y2, x2 = y 61 | else: 62 | if x is None or isinstance(y, tuple): 63 | raise TypeError("two integers must be provided to 'cell_neighbors'") 64 | y2 = y 65 | x2 = x 66 | if not (0 <= y2 < self.height and 0 <= x2 < self.width): 67 | raise IndexError("index out of range") 68 | return BoolArray1D( 69 | [ 70 | self.horizontal[y2, x2], 71 | self.horizontal[y2 + 1, x2], 72 | self.vertical[y2, x2], 73 | self.vertical[y2, x2 + 1], 74 | ] 75 | ) 76 | 77 | def vertex_neighbors( 78 | self, y: Union[int, Tuple[int, int]], x: Optional[int] = None 79 | ) -> BoolArray1D: 80 | if x is None: 81 | if isinstance(y, int): 82 | raise TypeError("two integers must be provided to 'cell_neighbors'") 83 | y2, x2 = y 84 | else: 85 | if x is None or isinstance(y, tuple): 86 | raise TypeError("two integers must be provided to 'cell_neighbors'") 87 | y2 = y 88 | x2 = x 89 | if not (0 <= y2 <= self.height and 0 <= x2 <= self.width): 90 | raise IndexError("index out of range") 91 | 92 | res = [] 93 | if y2 > 0: 94 | res.append(self.vertical[y2 - 1, x2]) 95 | if y2 < self.height: 96 | res.append(self.vertical[y2, x2]) 97 | if x2 > 0: 98 | res.append(self.horizontal[y2, x2 - 1]) 99 | if x2 < self.width: 100 | res.append(self.horizontal[y2, x2]) 101 | return BoolArray1D(res) 102 | 103 | def dual(self) -> "BoolInnerGridFrame": 104 | return BoolInnerGridFrame( 105 | solver=self.solver, 106 | height=self.height + 1, 107 | width=self.width + 1, 108 | horizontal=self.vertical, 109 | vertical=self.horizontal, 110 | ) 111 | 112 | def single_loop(self) -> BoolArray2D: 113 | from . import graph 114 | 115 | return graph.active_edges_single_cycle(self.solver, self) 116 | 117 | 118 | class BoolInnerGridFrame: 119 | def __init__( 120 | self, 121 | solver: Solver, 122 | height: int, 123 | width: int, 124 | horizontal: Optional[BoolArray2D] = None, 125 | vertical: Optional[BoolArray2D] = None, 126 | ) -> None: 127 | self.solver = solver 128 | self.height = height 129 | self.width = width 130 | 131 | if horizontal is None: 132 | self.horizontal = solver.bool_array((height - 1, width)) 133 | else: 134 | self.horizontal = horizontal 135 | 136 | if vertical is None: 137 | self.vertical = solver.bool_array((height, width - 1)) 138 | else: 139 | self.vertical = vertical 140 | 141 | def dual(self) -> BoolGridFrame: 142 | return BoolGridFrame( 143 | solver=self.solver, 144 | height=self.height - 1, 145 | width=self.width - 1, 146 | horizontal=self.vertical, 147 | vertical=self.horizontal, 148 | ) 149 | 150 | def __iter__(self) -> Iterator[BoolExpr]: 151 | return iter(self.dual()) 152 | -------------------------------------------------------------------------------- /cspuz/puzzle/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semiexp/cspuz/401b3a329c73a062dc0b1255720ad34ca061a43c/cspuz/puzzle/__init__.py -------------------------------------------------------------------------------- /cspuz/puzzle/akari.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cspuz import Solver 4 | from cspuz.constraints import fold_or, count_true 5 | from cspuz.puzzle import util 6 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 7 | 8 | 9 | def solve_akari(height, width, problem): 10 | solver = Solver() 11 | has_light = solver.bool_array((height, width)) 12 | solver.add_answer_key(has_light) 13 | 14 | for y in range(height): 15 | for x in range(width): 16 | if problem[y][x] >= -1: 17 | continue 18 | if y == 0 or problem[y - 1][x] >= -1: 19 | group = [] 20 | for y2 in range(y, height): 21 | if problem[y2][x] < -1: 22 | group.append((y2, x)) 23 | else: 24 | break 25 | solver.ensure(count_true([has_light[p] for p in group]) <= 1) 26 | if x == 0 or problem[y][x - 1] >= -1: 27 | group = [] 28 | for x2 in range(x, width): 29 | if problem[y][x2] < -1: 30 | group.append((y, x2)) 31 | else: 32 | break 33 | solver.ensure(count_true([has_light[p] for p in group]) <= 1) 34 | 35 | for y in range(height): 36 | for x in range(width): 37 | if problem[y][x] < -1: 38 | sight = [(y, x)] 39 | for y2 in range(y - 1, -1, -1): 40 | if problem[y2][x] < -1: 41 | sight.append((y2, x)) 42 | else: 43 | break 44 | for y2 in range(y + 1, height, 1): 45 | if problem[y2][x] < -1: 46 | sight.append((y2, x)) 47 | else: 48 | break 49 | for x2 in range(x - 1, -1, -1): 50 | if problem[y][x2] < -1: 51 | sight.append((y, x2)) 52 | else: 53 | break 54 | for x2 in range(x + 1, width, 1): 55 | if problem[y][x2] < -1: 56 | sight.append((y, x2)) 57 | else: 58 | break 59 | solver.ensure(fold_or([has_light[p] for p in sight])) 60 | else: 61 | solver.ensure(~has_light[y, x]) 62 | if problem[y][x] >= 0: 63 | neighbors = [] 64 | if y > 0 and problem[y - 1][x] < -1: 65 | neighbors.append((y - 1, x)) 66 | if y < height - 1 and problem[y + 1][x] < -1: 67 | neighbors.append((y + 1, x)) 68 | if x > 0 and problem[y][x - 1] < -1: 69 | neighbors.append((y, x - 1)) 70 | if x < width - 1 and problem[y][x + 1] < -1: 71 | neighbors.append((y, x + 1)) 72 | solver.ensure(count_true([has_light[p] for p in neighbors]) == problem[y][x]) 73 | 74 | is_sat = solver.solve() 75 | return is_sat, has_light 76 | 77 | 78 | def compute_score(ans): 79 | score = 0 80 | for v in ans: 81 | if v.sol is not None: 82 | score += 1 83 | return score 84 | 85 | 86 | def generate_akari(height, width, no_easy=False, verbose=False): 87 | def pretest(problem): 88 | visited = [[False for _ in range(width)] for _ in range(height)] 89 | 90 | def visit(y, x): 91 | if not ( 92 | 0 <= y < height and 0 <= x < width and problem[y][x] == -2 and not visited[y][x] 93 | ): 94 | return 95 | visited[y][x] = True 96 | visit(y - 1, x) 97 | visit(y + 1, x) 98 | visit(y, x - 1) 99 | visit(y, x + 1) 100 | 101 | n_component = 0 102 | for y in range(height): 103 | for x in range(width): 104 | if problem[y][x] == -2 and not visited[y][x]: 105 | n_component += 1 106 | visit(y, x) 107 | if n_component != 1: 108 | return False 109 | if not no_easy: 110 | return True 111 | for y in range(height): 112 | for x in range(width): 113 | if problem[y][x] >= 0: 114 | n_adj = ( 115 | (1 if y > 0 and problem[y - 1][x] == -2 else 0) 116 | + (1 if x > 0 and problem[y][x - 1] == -2 else 0) 117 | + (1 if y < height - 1 and problem[y + 1][x] == -2 else 0) 118 | + (1 if x < width - 1 and problem[y][x + 1] == -2 else 0) 119 | ) 120 | if problem[y][x] >= n_adj - 1: 121 | return False 122 | return True 123 | 124 | pattern = [-2, -1, 1, 2] if no_easy else [-2, -1, 0, 1, 2, 3, 4] 125 | generated = generate_problem( 126 | lambda problem: solve_akari(height, width, problem), 127 | builder_pattern=ArrayBuilder2D(height, width, pattern, default=-2, symmetry=True), 128 | clue_penalty=lambda problem: count_non_default_values(problem, default=-2, weight=5), 129 | pretest=pretest, 130 | verbose=verbose, 131 | ) 132 | return generated 133 | 134 | 135 | def _main(): 136 | if len(sys.argv) == 1: 137 | # generated example 138 | # https://twitter.com/semiexp/status/1225770511080144896 139 | height = 10 140 | width = 10 141 | # fmt: off 142 | problem = [ 143 | [-2, -2, 2, -2, -2, -2, -2, -2, -2, -2], # noqa: E201, E241 144 | [-2, -2, -2, -2, -2, -2, -2, -2, 2, -2], # noqa: E201, E241 145 | [-2, -2, -2, -2, -2, -2, -2, -1, -2, -2], # noqa: E201, E241 146 | [-1, -2, -2, -2, 3, -2, -2, -2, -2, -2], # noqa: E201, E241 147 | [-2, -2, -2, -2, -2, -1, -2, -2, -2, -1], # noqa: E201, E241 148 | [ 2, -2, -2, -2, 2, -2, -2, -2, -2, -2], # noqa: E201, E241 149 | [-2, -2, -2, -2, -2, 3, -2, -2, -2, -1], # noqa: E201, E241 150 | [-2, -2, -1, -2, -2, -2, -2, -2, -2, -2], # noqa: E201, E241 151 | [-2, 2, -2, -2, -2, -2, -2, -2, -2, -2], # noqa: E201, E241 152 | [-2, -2, -2, -2, -2, -2, -2, -1, -2, -2], # noqa: E201, E241 153 | ] 154 | # fmt: on 155 | is_sat, has_light = solve_akari(height, width, problem) 156 | print("has answer:", is_sat) 157 | if is_sat: 158 | print(util.stringify_array(has_light, {True: "O", False: ".", None: "?"})) 159 | else: 160 | height, width = map(int, sys.argv[1:]) 161 | while True: 162 | problem = generate_akari(height, width, verbose=True) 163 | if problem is not None: 164 | print( 165 | util.stringify_array( 166 | problem, {-2: ".", -1: "#", 0: "0", 1: "1", 2: "2", 3: "3", 4: "4"} 167 | ) 168 | ) 169 | print("", flush=True) 170 | 171 | 172 | if __name__ == "__main__": 173 | _main() 174 | -------------------------------------------------------------------------------- /cspuz/puzzle/aquarium.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cspuz import Solver 4 | from cspuz.puzzle import util 5 | from cspuz.constraints import count_true 6 | from cspuz.generator import ( 7 | generate_problem, 8 | Choice, 9 | SegmentationBuilder2D, 10 | count_non_default_values, 11 | ) 12 | 13 | 14 | def solve_aquarium(height, width, blocks, clue_row, clue_col): 15 | solver = Solver() 16 | is_water = solver.bool_array((height, width)) 17 | solver.add_answer_key(is_water) 18 | 19 | for y in range(height): 20 | if clue_row[y] >= 0: 21 | solver.ensure(count_true(is_water[y, :]) == clue_row[y]) 22 | for x in range(width): 23 | if clue_col[x] >= 0: 24 | solver.ensure(count_true(is_water[:, x]) == clue_col[x]) 25 | block_id = [[-1 for _ in range(width)] for _ in range(width)] 26 | for i, block in enumerate(blocks): 27 | for y, x in block: 28 | block_id[y][x] = i 29 | for y in range(height): 30 | for x in range(width): 31 | if x < width - 1 and block_id[y][x] == block_id[y][x + 1]: 32 | solver.ensure(is_water[y, x] == is_water[y, x + 1]) 33 | if y < height - 1 and block_id[y][x] == block_id[y + 1][x]: 34 | solver.ensure(is_water[y, x].then(is_water[y + 1, x])) 35 | is_sat = solver.solve() 36 | return is_sat, is_water 37 | 38 | 39 | def generate_aquarium(height, width, verbose=False): 40 | builder_pattern = ( 41 | SegmentationBuilder2D( 42 | height, width, min_block_size=1, max_block_size=3, allow_unmet_constraints_first=False 43 | ), 44 | [Choice([-1] + list(range(3, width - 2)), default=-1) for _ in range(height)], 45 | [Choice([-1] + list(range(3, height - 2)), default=-1) for _ in range(width)], 46 | ) 47 | generated = generate_problem( 48 | lambda problem: solve_aquarium(height, width, *problem), 49 | builder_pattern=builder_pattern, 50 | clue_penalty=lambda problem: count_non_default_values(problem[1], default=-1, weight=4) 51 | + count_non_default_values(problem[2], default=-1, weight=4), 52 | verbose=verbose, 53 | ) 54 | return generated 55 | 56 | 57 | def problem_to_url(height, width, blocks, clue_row, clue_col): 58 | blocks_str = util.encode_grid_segmentation( 59 | height, width, util.blocks_to_block_id(height, width, blocks) 60 | ) 61 | clues_str = util.encode_array(clue_col + clue_row, empty=-1) 62 | return "https://puzz.link/p?aquarium/{}/{}/{}/{}".format(width, height, blocks_str, clues_str) 63 | 64 | 65 | def _main(): 66 | if len(sys.argv) == 1: 67 | pass 68 | else: 69 | height, width = map(int, sys.argv[1:]) 70 | while True: 71 | gen = generate_aquarium(height, width, verbose=False) 72 | if gen is not None: 73 | print(gen) 74 | print(problem_to_url(height, width, *gen)) 75 | 76 | 77 | if __name__ == "__main__": 78 | _main() 79 | -------------------------------------------------------------------------------- /cspuz/puzzle/building.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cspuz import Solver 4 | from cspuz.constraints import alldifferent, fold_and 5 | from cspuz.puzzle import util 6 | from cspuz.generator import ( 7 | Choice, 8 | generate_problem, 9 | build_neighbor_generator, 10 | count_non_default_values, 11 | ) 12 | 13 | 14 | def solve_building(n, up, dw, lf, rg): 15 | solver = Solver() 16 | answer = solver.int_array((n, n), 1, n) 17 | solver.add_answer_key(answer) 18 | 19 | for i in range(n): 20 | solver.ensure(alldifferent(answer[i, :])) 21 | solver.ensure(alldifferent(answer[:, i])) 22 | 23 | def num_visible_buildings(cells): 24 | cells = list(cells) 25 | res = 1 26 | for i in range(1, len(cells)): 27 | res += fold_and([cells[j] < cells[i] for j in range(i)]).cond(1, 0) 28 | return res 29 | 30 | for i in range(n): 31 | if up[i] >= 1: 32 | solver.ensure(num_visible_buildings(answer[:, i]) == up[i]) 33 | if dw[i] >= 1: 34 | solver.ensure(num_visible_buildings(reversed(list(answer[:, i]))) == dw[i]) 35 | if lf[i] >= 1: 36 | solver.ensure(num_visible_buildings(answer[i, :]) == lf[i]) 37 | if rg[i] >= 1: 38 | solver.ensure(num_visible_buildings(reversed(list(answer[i, :]))) == rg[i]) 39 | 40 | is_sat = solver.solve() 41 | return is_sat, answer 42 | 43 | 44 | def generate_building(size, verbose=False): 45 | initial, neighbor = build_neighbor_generator( 46 | [[Choice(range(0, size + 1), default=0) for _ in range(size)] for _ in range(4)] 47 | ) 48 | generated = generate_problem( 49 | lambda problem: solve_building(size, *problem), 50 | initial, 51 | neighbor, 52 | clue_penalty=lambda problem: count_non_default_values(problem, default=0, weight=3.0), 53 | verbose=verbose, 54 | ) 55 | if generated is not None: 56 | return generated 57 | 58 | 59 | def _main(): 60 | if len(sys.argv) == 1: 61 | # generated example 62 | # https://twitter.com/semiexp/status/1223911674941296641 63 | n = 6 64 | up = [0, 0, 0, 2, 0, 3] 65 | dw = [0, 6, 3, 3, 2, 0] 66 | lf = [2, 0, 0, 3, 3, 3] 67 | rg = [0, 6, 3, 0, 2, 0] 68 | is_sat, answer = solve_building(n, up, dw, lf, rg) 69 | if is_sat: 70 | print(util.stringify_array(answer, str)) 71 | else: 72 | n = int(sys.argv[1]) 73 | while True: 74 | problem = generate_building(n, verbose=True) 75 | if problem is not None: 76 | print(problem) 77 | 78 | 79 | if __name__ == "__main__": 80 | _main() 81 | -------------------------------------------------------------------------------- /cspuz/puzzle/creek.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import cspuz 5 | from cspuz import Solver, graph 6 | from cspuz.constraints import count_true 7 | from cspuz.puzzle import util 8 | from cspuz.generator import generate_problem, count_non_default_values, Choice 9 | 10 | 11 | def solve_creek(height, width, problem): 12 | solver = Solver() 13 | is_white = solver.bool_array((height, width)) 14 | solver.add_answer_key(is_white) 15 | graph.active_vertices_connected(solver, is_white) 16 | for y in range(0, height + 1): 17 | for x in range(0, width + 1): 18 | if problem[y][x] >= 0: 19 | solver.ensure( 20 | count_true( 21 | ~is_white[ 22 | max(y - 1, 0) : min(y + 1, height), max(x - 1, 0) : min(x + 1, width) 23 | ] 24 | ) 25 | == problem[y][x] 26 | ) 27 | is_sat = solver.solve() 28 | return is_sat, is_white 29 | 30 | 31 | def generate_creek(height, width, no_easy=False, verbose=False): 32 | pattern = [] 33 | for y in range(height + 1): 34 | row = [] 35 | for x in range(width + 1): 36 | nmax = (1 if y in (0, height) else 2) * (1 if x in (0, width) else 2) 37 | row.append( 38 | Choice( 39 | [-1] + list(range(1 if no_easy else 0, nmax if no_easy else nmax + 1)), 40 | default=-1, 41 | ) 42 | ) 43 | pattern.append(row) 44 | 45 | def pretest(problem): 46 | if not no_easy: 47 | return True 48 | for y in range(height + 1): 49 | for x in range(width + 1): 50 | if y < height and (problem[y][x], problem[y + 1][x]) in ((1, 3), (3, 1)): 51 | return False 52 | if x < width and (problem[y][x], problem[y][x + 1]) in ((1, 3), (3, 1)): 53 | return False 54 | return True 55 | 56 | generated = generate_problem( 57 | lambda problem: solve_creek(height, width, problem), 58 | builder_pattern=pattern, 59 | clue_penalty=lambda problem: count_non_default_values(problem, default=-1, weight=3), 60 | pretest=pretest, 61 | verbose=verbose, 62 | ) 63 | return generated 64 | 65 | 66 | def _main(): 67 | if len(sys.argv) == 1: 68 | pass 69 | else: 70 | height, width = map(int, sys.argv[1:]) 71 | cspuz.config.solver_timeout = 1200.0 72 | while True: 73 | try: 74 | problem = generate_creek(height, width, no_easy=True, verbose=True) 75 | if problem is not None: 76 | print( 77 | util.stringify_array(problem, lambda x: "." if x == -1 else str(x)), 78 | flush=True, 79 | ) 80 | print(flush=True) 81 | except subprocess.TimeoutExpired: 82 | print("timeout", file=sys.stderr) 83 | 84 | 85 | if __name__ == "__main__": 86 | _main() 87 | -------------------------------------------------------------------------------- /cspuz/puzzle/doppelblock.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cspuz import Solver 4 | from cspuz.constraints import fold_or, count_true 5 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 6 | from cspuz.puzzle import util 7 | 8 | 9 | def solve_doppelblock(n, clue_row, clue_column): 10 | solver = Solver() 11 | answer = solver.int_array((n, n), 0, n - 2) 12 | solver.add_answer_key(answer) 13 | 14 | def sequence_constraint(cells, v): 15 | s = 0 16 | for i in range(n): 17 | s += (fold_or(cells[:i] == 0) & fold_or(cells[i + 1 :] == 0)).cond(cells[i], 0) 18 | return s == v 19 | 20 | def occurrence_constraint(cells): 21 | solver.ensure(count_true(cells == 0) == 2) 22 | for i in range(1, n - 1): 23 | solver.ensure(count_true(cells == i) == 1) 24 | 25 | for i in range(n): 26 | occurrence_constraint(answer[i, :]) 27 | occurrence_constraint(answer[:, i]) 28 | if clue_row[i] >= 0: 29 | solver.ensure(sequence_constraint(answer[i, :], clue_row[i])) 30 | if clue_column[i] >= 0: 31 | solver.ensure(sequence_constraint(answer[:, i], clue_column[i])) 32 | 33 | is_sat = solver.solve() 34 | return is_sat, answer 35 | 36 | 37 | def generate_doppelblock(n, verbose=False): 38 | max_sum = (n - 2) * (n - 1) // 2 39 | generated = generate_problem( 40 | lambda problem: solve_doppelblock(n, problem[0], problem[1]), 41 | builder_pattern=ArrayBuilder2D(2, n, [-1] + list(range(0, max_sum + 1)), default=-1), 42 | clue_penalty=lambda problem: count_non_default_values(problem, default=-1, weight=10), 43 | verbose=verbose, 44 | ) 45 | return generated 46 | 47 | 48 | def _main(): 49 | if len(sys.argv) == 1: 50 | # https://puzsq.jp/main/puzzle_play.php?pid=10025 51 | n = 5 52 | row = [5, -1, 5, -1, -1] 53 | column = [3, -1, -1, 1, -1] 54 | is_sat, answer = solve_doppelblock(n, row, column) 55 | if is_sat: 56 | print(util.stringify_array(answer, str)) 57 | else: 58 | n = int(sys.argv[1]) 59 | while True: 60 | problem = generate_doppelblock(n) 61 | if problem is not None: 62 | print(problem, flush=True) 63 | 64 | 65 | if __name__ == "__main__": 66 | _main() 67 | -------------------------------------------------------------------------------- /cspuz/puzzle/fillomino.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import cspuz 5 | from cspuz import Solver, graph 6 | from cspuz.puzzle import util 7 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 8 | 9 | 10 | def solve_fillomino(height, width, problem, checkered=False): 11 | solver = Solver() 12 | size = solver.int_array((height, width), 1, height * width) 13 | solver.add_answer_key(size) 14 | border = graph.BoolInnerGridFrame(solver, height, width) 15 | graph.division_connected_variable_groups_with_borders( 16 | solver, group_size=size, is_border=border 17 | ) 18 | solver.ensure(border.vertical == (size[:, :-1] != size[:, 1:])) 19 | solver.ensure(border.horizontal == (size[:-1, :] != size[1:, :])) 20 | for y in range(height): 21 | for x in range(width): 22 | if problem[y][x] >= 1: 23 | solver.ensure(size[y, x] == problem[y][x]) 24 | if checkered: 25 | color = solver.bool_array((height, width)) 26 | solver.ensure(border.vertical == (color[:, :-1] != color[:, 1:])) 27 | solver.ensure(border.horizontal == (color[:-1, :] != color[1:, :])) 28 | is_sat = solver.solve() 29 | return is_sat, size 30 | 31 | 32 | def generate_fillomino( 33 | height, width, checkered=False, disallow_adjacent=False, symmetry=False, verbose=False 34 | ): 35 | generated = generate_problem( 36 | lambda problem: solve_fillomino(height, width, problem, checkered=checkered), 37 | builder_pattern=ArrayBuilder2D( 38 | height, 39 | width, 40 | range(0, 9), 41 | default=0, 42 | disallow_adjacent=disallow_adjacent, 43 | symmetry=symmetry, 44 | ), 45 | clue_penalty=lambda problem: count_non_default_values(problem, default=0, weight=5), 46 | verbose=verbose, 47 | ) 48 | return generated 49 | 50 | 51 | def _main(): 52 | if len(sys.argv) == 1: 53 | # https://twitter.com/semiexp/status/1227192389120356353 54 | height = 8 55 | width = 8 56 | problem = [ 57 | [0, 0, 0, 5, 4, 0, 0, 0], 58 | [0, 0, 0, 4, 1, 3, 0, 0], 59 | [1, 0, 0, 0, 0, 0, 0, 4], 60 | [6, 0, 4, 0, 0, 0, 0, 7], 61 | [0, 5, 0, 0, 0, 0, 0, 0], 62 | [0, 0, 0, 0, 0, 0, 0, 2], 63 | [1, 0, 0, 0, 4, 0, 0, 7], 64 | [7, 0, 0, 6, 2, 0, 7, 0], 65 | ] 66 | # """ 67 | is_sat, ans = solve_fillomino(height, width, problem) 68 | print("has answer:", is_sat) 69 | if is_sat: 70 | print(util.stringify_array(ans, str)) 71 | else: 72 | cspuz.config.solver_timeout = 1200.0 73 | height, width = map(int, sys.argv[1:]) 74 | while True: 75 | try: 76 | problem = generate_fillomino( 77 | height, width, disallow_adjacent=True, symmetry=True, verbose=True 78 | ) 79 | if problem is not None: 80 | print( 81 | util.stringify_array(problem, lambda x: "." if x == 0 else str(x)), 82 | flush=True, 83 | ) 84 | print(flush=True) 85 | except subprocess.TimeoutExpired: 86 | print("timeout", file=sys.stderr) 87 | 88 | 89 | if __name__ == "__main__": 90 | _main() 91 | -------------------------------------------------------------------------------- /cspuz/puzzle/firefly.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import cspuz 5 | from cspuz import Solver 6 | from cspuz.array import BoolArray1D 7 | from cspuz.grid_frame import BoolGridFrame 8 | from cspuz.constraints import count_true 9 | from cspuz.puzzle import util 10 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 11 | 12 | 13 | def solve_firefly(height, width, problem): 14 | solver = Solver() 15 | 16 | has_line = BoolGridFrame(solver, height - 1, width - 1) 17 | solver.add_answer_key(has_line) 18 | 19 | line_ul = BoolGridFrame(solver, height - 1, width - 1) 20 | line_dr = BoolGridFrame(solver, height - 1, width - 1) 21 | solver.ensure( 22 | BoolArray1D(list(has_line)) == (BoolArray1D(list(line_ul)) | BoolArray1D(list(line_dr))) 23 | ) 24 | solver.ensure(~(BoolArray1D(list(line_ul)) & BoolArray1D(list(line_dr)))) 25 | 26 | # unicyclic (= connected) 27 | ignored_edge = BoolGridFrame(solver, height - 1, width - 1) 28 | solver.ensure(count_true(ignored_edge) == 1) 29 | rank = solver.int_array((height, width), 0, height * width - 1) 30 | solver.ensure((line_ul.horizontal & ~ignored_edge.horizontal).then(rank[:, :-1] < rank[:, 1:])) 31 | solver.ensure((line_ul.vertical & ~ignored_edge.vertical).then(rank[:-1, :] < rank[1:, :])) 32 | solver.ensure((line_dr.horizontal & ~ignored_edge.horizontal).then(rank[:, :-1] > rank[:, 1:])) 33 | solver.ensure((line_dr.vertical & ~ignored_edge.vertical).then(rank[:-1, :] > rank[1:, :])) 34 | 35 | max_n_turn = 0 36 | for y in range(height): 37 | for x in range(width): 38 | if problem[y][x][0] != "." and problem[y][x][1] != "?": 39 | max_n_turn = max(max_n_turn, int(problem[y][x][1:])) 40 | n_turn_unknown = max_n_turn + 1 41 | n_turn_horizontal = solver.int_array((height, width - 1), 0, max_n_turn + 1) 42 | n_turn_vertical = solver.int_array((height - 1, width), 0, max_n_turn + 1) 43 | 44 | for y in range(height): 45 | for x in range(width): 46 | # u, d, l, r 47 | adj = [] # (line_in, line_out, # turn) 48 | 49 | if y > 0: 50 | adj.append( 51 | ( 52 | line_dr.vertical[y - 1, x], 53 | line_ul.vertical[y - 1, x], 54 | n_turn_vertical[y - 1, x], 55 | ) 56 | ) 57 | else: 58 | adj.append(None) 59 | if y < height - 1: 60 | adj.append((line_ul.vertical[y, x], line_dr.vertical[y, x], n_turn_vertical[y, x])) 61 | else: 62 | adj.append(None) 63 | if x > 0: 64 | adj.append( 65 | ( 66 | line_dr.horizontal[y, x - 1], 67 | line_ul.horizontal[y, x - 1], 68 | n_turn_horizontal[y, x - 1], 69 | ) 70 | ) 71 | else: 72 | adj.append(None) 73 | if x < width - 1: 74 | adj.append( 75 | (line_ul.horizontal[y, x], line_dr.horizontal[y, x], n_turn_horizontal[y, x]) 76 | ) 77 | else: 78 | adj.append(None) 79 | 80 | if problem[y][x][0] != ".": 81 | if problem[y][x][0] == "^": 82 | out_idx = 0 83 | elif problem[y][x][0] == "v": 84 | out_idx = 1 85 | elif problem[y][x][0] == "<": 86 | out_idx = 2 87 | elif problem[y][x][0] == ">": 88 | out_idx = 3 89 | else: 90 | raise ValueError("invalid direction: {}".format(problem[y][x][0])) 91 | if adj[out_idx] is None: 92 | solver.ensure(False) 93 | break 94 | solver.ensure(adj[out_idx][1]) 95 | if problem[y][x][1] != "?": 96 | solver.ensure(adj[out_idx][2] == int(problem[y][x][1:])) 97 | else: 98 | solver.ensure(adj[out_idx][2] == n_turn_unknown) 99 | for i in range(4): 100 | if adj[i] is not None and i != out_idx: 101 | solver.ensure(~adj[i][1]) 102 | solver.ensure( 103 | adj[i][0].then((adj[i][2] == 0) | (adj[i][2] == n_turn_unknown)) 104 | ) 105 | else: 106 | adj_present = list(filter(lambda x: x is not None, adj)) 107 | solver.ensure(count_true(map(lambda x: x[0], adj_present)) <= 1) 108 | solver.ensure( 109 | count_true(map(lambda x: x[0], adj_present)) 110 | == count_true(map(lambda x: x[1], adj_present)) 111 | ) 112 | 113 | for i in range(4): 114 | for j in range(4): 115 | if adj[i] is not None and adj[j] is not None and i != j: 116 | if (i // 2) == (j // 2): # straight 117 | solver.ensure((adj[i][0] & adj[j][1]).then(adj[i][2] == adj[j][2])) 118 | else: 119 | solver.ensure( 120 | (adj[i][0] & adj[j][1]).then( 121 | ( 122 | (adj[i][2] == n_turn_unknown) 123 | & (adj[j][2] == n_turn_unknown) 124 | ) 125 | | (adj[i][2] == adj[j][2] + 1) 126 | ) 127 | ) 128 | is_sat = solver.solve() 129 | return is_sat, has_line 130 | 131 | 132 | def generate_firefly(height, width, min_clue=0, max_clue=5, verbose=False): 133 | cand = [".."] 134 | for d in ["^", "v", "<", ">"]: 135 | cand.append(d + "?") 136 | for i in range(min_clue, max_clue + 1): 137 | cand.append(d + str(i)) 138 | generated = generate_problem( 139 | lambda problem: solve_firefly(height, width, problem), 140 | builder_pattern=ArrayBuilder2D(height, width, cand, default=".."), 141 | clue_penalty=lambda problem: count_non_default_values(problem, default="..", weight=10), 142 | verbose=verbose, 143 | ) 144 | return generated 145 | 146 | 147 | def _main(): 148 | if len(sys.argv) == 1: 149 | # http://pzv.jp/p.html?firefly/6/6/a2.k4.b27a45g22i 150 | height = 6 151 | width = 6 152 | problem = [ 153 | ["..", "v?", "..", "..", "..", ".."], 154 | ["..", "..", "..", "..", "..", ".."], 155 | ["..", ">?", "..", "..", "v7", ".."], 156 | [">5", "..", "..", "..", "..", ".."], 157 | ["..", "..", "v2", "..", "..", ".."], 158 | ["..", "..", "..", "..", "..", ".."], 159 | ] 160 | is_sat, is_line = solve_firefly(height, width, problem) 161 | print("has answer:", is_sat) 162 | if is_sat: 163 | print(util.stringify_grid_frame(is_line)) 164 | else: 165 | cspuz.config.solver_timeout = 600.0 166 | height, width = map(int, sys.argv[1:]) 167 | while True: 168 | try: 169 | problem = generate_firefly(height, width, min_clue=2, max_clue=7, verbose=True) 170 | if problem is not None: 171 | print(util.stringify_array(problem)) 172 | print(flush=True) 173 | except subprocess.TimeoutExpired: 174 | print("timeout", file=sys.stderr) 175 | 176 | 177 | if __name__ == "__main__": 178 | _main() 179 | -------------------------------------------------------------------------------- /cspuz/puzzle/fivecells.py: -------------------------------------------------------------------------------- 1 | import random 2 | import math 3 | import sys 4 | 5 | from cspuz import Solver, graph 6 | from cspuz.constraints import count_true 7 | from cspuz.puzzle import util 8 | 9 | 10 | def solve_fivecells(height, width, problem): 11 | vertex_id = [[-1 for _ in range(width)] for _ in range(height)] 12 | id_last = 0 13 | for y in range(height): 14 | for x in range(width): 15 | if problem[y][x] >= -1: 16 | vertex_id[y][x] = id_last 17 | id_last += 1 18 | g = graph.Graph(id_last) 19 | for y in range(height): 20 | for x in range(width): 21 | if problem[y][x] >= -1: 22 | if y < height - 1 and problem[y + 1][x] >= -1: 23 | g.add_edge(vertex_id[y][x], vertex_id[y + 1][x]) 24 | if x < width - 1 and problem[y][x + 1] >= -1: 25 | g.add_edge(vertex_id[y][x], vertex_id[y][x + 1]) 26 | solver = Solver() 27 | group_id = graph.division_connected_variable_groups(solver, graph=g, group_size=5) 28 | is_invalid = False 29 | for y in range(height): 30 | for x in range(width): 31 | if problem[y][x] >= 0: 32 | borders = [] 33 | if y > 0 and problem[y - 1][x] >= -1: 34 | borders.append(group_id[vertex_id[y][x]] != group_id[vertex_id[y - 1][x]]) 35 | if y < height - 1 and problem[y + 1][x] >= -1: 36 | borders.append(group_id[vertex_id[y][x]] != group_id[vertex_id[y + 1][x]]) 37 | if x > 0 and problem[y][x - 1] >= -1: 38 | borders.append(group_id[vertex_id[y][x]] != group_id[vertex_id[y][x - 1]]) 39 | if x < width - 1 and problem[y][x + 1] >= -1: 40 | borders.append(group_id[vertex_id[y][x]] != group_id[vertex_id[y][x + 1]]) 41 | always_border = 4 - len(borders) 42 | solver.ensure(count_true(borders) == problem[y][x] - always_border) 43 | if problem[y][x] - always_border < 0: 44 | is_invalid = True 45 | 46 | is_border = solver.bool_array(len(g)) 47 | for i, (u, v) in enumerate(g): 48 | solver.ensure(is_border[i] == (group_id[u] != group_id[v])) 49 | solver.add_answer_key(is_border) 50 | 51 | if is_invalid: 52 | is_sat = False 53 | else: 54 | is_sat = solver.solve() 55 | return is_sat, is_border 56 | 57 | 58 | def compute_score(ans): 59 | score = 0 60 | for v in ans: 61 | if v.sol is not None: 62 | score += 1 63 | return score 64 | 65 | 66 | def generate_fivecells(height, width, verbose=False): 67 | problem = [[-1 for _ in range(width)] for _ in range(height)] 68 | score = 0 69 | temperature = 5.0 70 | fully_solved_score = height * (width - 1) + (height - 1) * width 71 | 72 | for step in range(height * width * 10): 73 | cand = [] 74 | for y in range(height): 75 | for x in range(width): 76 | low = 0 77 | if y == 0 or y == height - 1: 78 | low += 1 79 | if x == 0 or x == width - 1: 80 | low += 1 81 | for n in range(-1, 4): 82 | if n != -1 and n < low: 83 | continue 84 | if n == low: # avoid easy problems 85 | continue 86 | if problem[y][x] != n: 87 | cand.append((y, x, n)) 88 | random.shuffle(cand) 89 | 90 | for y, x, n in cand: 91 | n_prev = problem[y][x] 92 | problem[y][x] = n 93 | 94 | sat, answer = solve_fivecells(height, width, problem) 95 | if not sat: 96 | score_next = -1 97 | update = False 98 | else: 99 | raw_score = compute_score(answer) 100 | if raw_score == fully_solved_score: 101 | return problem 102 | clue_score = 0 103 | for y2 in range(height): 104 | for x2 in range(width): 105 | if problem[y2][x2] >= 0: 106 | clue_score += 8 107 | score_next = raw_score - clue_score 108 | update = score < score_next or random.random() < math.exp( 109 | (score_next - score) / temperature 110 | ) 111 | 112 | if update: 113 | if verbose: 114 | print("update: {} -> {}".format(score, score_next), file=sys.stderr) 115 | score = score_next 116 | break 117 | else: 118 | problem[y][x] = n_prev 119 | 120 | temperature *= 0.995 121 | if verbose: 122 | print("failed", file=sys.stderr) 123 | return None 124 | 125 | 126 | def _main(): 127 | if len(sys.argv) == 1: 128 | # generated example: http://pzv.jp/p.html?fivecells/5/5/a23i21b3g3 129 | height = 5 130 | width = 5 131 | # fmt: off 132 | problem = [ 133 | [-1, 2, 3, -1, -1], # noqa: E201, E241 134 | [-1, -1, -1, -1, -1], # noqa: E201, E241 135 | [-1, -1, 2, 1, -1], # noqa: E201, E241 136 | [-1, 3, -1, -1, -1], # noqa: E201, E241 137 | [-1, -1, -1, -1, 3], # noqa: E201, E241 138 | ] 139 | # fmt: on 140 | is_sat, is_border = solve_fivecells(height, width, problem) 141 | print("has answer:", is_sat) 142 | for i, x in enumerate(is_border): 143 | print(i, x.sol) 144 | else: 145 | height, width = map(int, sys.argv[1:]) 146 | while True: 147 | problem = generate_fivecells(height, width, verbose=True) 148 | if problem is not None: 149 | print( 150 | util.stringify_array( 151 | problem, {-2: "#", -1: ".", 0: "0", 1: "1", 2: "2", 3: "3"} 152 | ) 153 | + "\n", 154 | flush=True, 155 | ) 156 | 157 | 158 | if __name__ == "__main__": 159 | _main() 160 | -------------------------------------------------------------------------------- /cspuz/puzzle/geradeweg.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import cspuz 5 | from cspuz import Solver, graph 6 | from cspuz.grid_frame import BoolGridFrame 7 | from cspuz.constraints import fold_or 8 | from cspuz.puzzle import util 9 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 10 | 11 | 12 | def solve_geradeweg(height, width, problem): 13 | def line_length(edges): 14 | edges = list(edges) 15 | if len(edges) == 0: 16 | return 0 17 | ret = edges[-1].cond(1, 0) 18 | for i in range(len(edges) - 2, -1, -1): 19 | ret = edges[i].cond(1 + ret, 0) 20 | return ret 21 | 22 | solver = Solver() 23 | grid_frame = BoolGridFrame(solver, height - 1, width - 1) 24 | solver.add_answer_key(grid_frame) 25 | is_passed = graph.active_edges_single_cycle(solver, grid_frame) 26 | 27 | for y in range(height): 28 | for x in range(width): 29 | if problem[y][x] >= 1: 30 | solver.ensure(is_passed[y, x]) 31 | solver.ensure( 32 | fold_or( 33 | ([grid_frame.horizontal[y, x - 1]] if x > 0 else []) 34 | + ([grid_frame.horizontal[y, x]] if x < width - 1 else []) 35 | ).then( 36 | line_length(reversed(list(grid_frame.horizontal[y, :x]))) 37 | + line_length(grid_frame.horizontal[y, x:]) 38 | == problem[y][x] 39 | ) 40 | ) 41 | solver.ensure( 42 | fold_or( 43 | ([grid_frame.vertical[y - 1, x]] if y > 0 else []) 44 | + ([grid_frame.vertical[y, x]] if y < height - 1 else []) 45 | ).then( 46 | line_length(reversed(list(grid_frame.vertical[:y, x]))) 47 | + line_length(grid_frame.vertical[y:, x]) 48 | == problem[y][x] 49 | ) 50 | ) 51 | 52 | is_sat = solver.solve() 53 | return is_sat, grid_frame 54 | 55 | 56 | def generate_geradeweg(height, width, symmetry=False, verbose=False): 57 | generated = generate_problem( 58 | lambda problem: solve_geradeweg(height, width, problem), 59 | builder_pattern=ArrayBuilder2D(height, width, range(0, 6), default=0, symmetry=symmetry), 60 | clue_penalty=lambda problem: count_non_default_values(problem, default=0, weight=10), 61 | verbose=verbose, 62 | ) 63 | return generated 64 | 65 | 66 | def _main(): 67 | if len(sys.argv) == 1: 68 | # https://puzsq.sakura.ne.jp/main/puzzle_play.php?pid=8864 69 | height = 10 70 | width = 10 71 | problem = [ 72 | [5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 73 | [0, 0, 0, 0, 0, 0, 0, 1, 0, 0], 74 | [0, 0, 0, 5, 0, 0, 0, 0, 0, 0], 75 | [0, 0, 0, 0, 0, 2, 0, 0, 0, 3], 76 | [0, 0, 0, 0, 0, 0, 0, 0, 4, 0], 77 | [0, 2, 0, 0, 0, 0, 0, 0, 0, 0], 78 | [2, 0, 0, 0, 4, 0, 0, 0, 0, 0], 79 | [0, 0, 0, 0, 0, 0, 4, 0, 0, 0], 80 | [0, 0, 2, 0, 0, 0, 0, 0, 0, 0], 81 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 5], 82 | ] 83 | is_sat, is_line = solve_geradeweg(height, width, problem) 84 | print("has answer:", is_sat) 85 | if is_sat: 86 | print(util.stringify_grid_frame(is_line)) 87 | else: 88 | cspuz.config.solver_timeout = 600.0 89 | height, width = map(int, sys.argv[1:]) 90 | while True: 91 | try: 92 | problem = generate_geradeweg(height, width, symmetry=False, verbose=True) 93 | if problem is not None: 94 | print(util.stringify_array(problem, lambda x: "." if x == 0 else str(x))) 95 | print(flush=True) 96 | except subprocess.TimeoutExpired: 97 | print("timeout", file=sys.stderr) 98 | 99 | 100 | if __name__ == "__main__": 101 | _main() 102 | -------------------------------------------------------------------------------- /cspuz/puzzle/gokigen.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | from cspuz import Solver, graph 5 | from cspuz.puzzle import util 6 | from cspuz.constraints import count_true 7 | from cspuz.generator import generate_problem, count_non_default_values, Choice 8 | 9 | 10 | def solve_gokigen(height, width, problem): 11 | solver = Solver() 12 | edge_type = solver.bool_array((height, width)) # false: /, true: \ 13 | solver.add_answer_key(edge_type) 14 | 15 | g = graph.Graph((height + 1) * (width + 1)) 16 | edge_list = [] 17 | for y in range(height): 18 | for x in range(width): 19 | g.add_edge(y * (width + 1) + x, (y + 1) * (width + 1) + (x + 1)) 20 | edge_list.append(edge_type[y, x]) 21 | g.add_edge(y * (width + 1) + (x + 1), (y + 1) * (width + 1) + x) 22 | edge_list.append(~edge_type[y, x]) 23 | graph.active_edges_acyclic(solver, edge_list, g) 24 | 25 | for y in range(height + 1): 26 | for x in range(width + 1): 27 | if problem[y][x] >= 0: 28 | related = [] 29 | if 0 < y and 0 < x: 30 | related.append(edge_type[y - 1, x - 1]) 31 | if 0 < y and x < width: 32 | related.append(~edge_type[y - 1, x]) 33 | if y < height and 0 < x: 34 | related.append(~edge_type[y, x - 1]) 35 | if y < height and x < width: 36 | related.append(edge_type[y, x]) 37 | solver.ensure(count_true(related) == problem[y][x]) 38 | 39 | is_sat = solver.solve() 40 | return is_sat, edge_type 41 | 42 | 43 | def generate_gokigen(height, width, no_easy=False, no_adjacent=False, verbose=False): 44 | pattern = [] 45 | for y in range(height + 1): 46 | row = [] 47 | for x in range(width + 1): 48 | lim = (1 if y in (0, height) else 2) * (1 if x in (0, width) else 2) 49 | row.append( 50 | Choice( 51 | [-1] + list(range(1 if no_easy else 0, lim if no_easy else (lim + 1))), 52 | default=-1, 53 | ) 54 | ) 55 | pattern.append(row) 56 | 57 | def pretest(problem): 58 | for y in range(height + 1): 59 | for x in range(width + 1): 60 | if no_adjacent: 61 | if y < height: 62 | if problem[y][x] != -1 and problem[y + 1][x] != -1: 63 | return False 64 | if x < width: 65 | if problem[y][x] != -1 and problem[y][x + 1] != -1: 66 | return False 67 | if no_easy: 68 | if y < height: 69 | if problem[y][x] in (1, 3) and problem[y + 1][x] in (1, 3): 70 | return False 71 | if x < width: 72 | if problem[y][x] in (1, 3) and problem[y][x + 1] in (1, 3): 73 | return False 74 | if y < height - 1: 75 | if ( 76 | problem[y][x] != -1 77 | and problem[y + 1][x] != -1 78 | and problem[y + 2][x] != -1 79 | ): 80 | return False 81 | if x < width - 1: 82 | if ( 83 | problem[y][x] != -1 84 | and problem[y][x + 1] != -1 85 | and problem[y][x + 2] != -1 86 | ): 87 | return False 88 | return True 89 | 90 | generated = generate_problem( 91 | lambda problem: solve_gokigen(height, width, problem), 92 | builder_pattern=pattern, 93 | clue_penalty=lambda problem: count_non_default_values(problem, default=-1, weight=2), 94 | pretest=pretest, 95 | verbose=verbose, 96 | ) 97 | return generated 98 | 99 | 100 | def _main(): 101 | if len(sys.argv) == 1: 102 | # https://puzsq.sakura.ne.jp/main/puzzle_play.php?pid=7862 103 | height = 7 104 | width = 7 105 | # fmt: off 106 | problem = [ 107 | [-1, -1, -1, -1, -1, -1, -1, -1], # noqa: E201, E241 108 | [-1, 3, -1, 2, 3, -1, 3, -1], # noqa: E201, E241 109 | [-1, -1, 1, -1, -1, 1, -1, -1], # noqa: E201, E241 110 | [-1, -1, -1, -1, 3, 2, -1, -1], # noqa: E201, E241 111 | [-1, 3, -1, 3, 2, -1, 3, -1], # noqa: E201, E241 112 | [-1, -1, 1, -1, -1, 1, -1, -1], # noqa: E201, E241 113 | [-1, 3, -1, -1, 3, -1, 3, -1], # noqa: E201, E241 114 | [-1, -1, -1, -1, -1, -1, -1, -1], # noqa: E201, E241 115 | ] 116 | # fmt: on 117 | is_sat, ans = solve_gokigen(height, width, problem) 118 | print("has answer:", is_sat) 119 | if is_sat: 120 | print(util.stringify_array(ans, {None: ".", True: "\\", False: "/"})) 121 | else: 122 | parser = argparse.ArgumentParser(add_help=False) 123 | parser.add_argument("-h", "--height", type=int, required=True) 124 | parser.add_argument("-w", "--width", type=int, required=True) 125 | parser.add_argument("--no-easy", action="store_true") 126 | parser.add_argument("--no-adjacent", action="store_true") 127 | parser.add_argument("-v", "--verbose", action="store_true") 128 | args = parser.parse_args() 129 | height = args.height 130 | width = args.width 131 | no_easy = args.no_easy 132 | no_adjacent = args.no_adjacent 133 | verbose = args.verbose 134 | while True: 135 | problem = generate_gokigen( 136 | height, width, no_easy=no_easy, no_adjacent=no_adjacent, verbose=verbose 137 | ) 138 | if problem is not None: 139 | print( 140 | util.stringify_array(problem, lambda x: "." if x == -1 else str(x)), flush=True 141 | ) 142 | print(flush=True) 143 | 144 | 145 | if __name__ == "__main__": 146 | _main() 147 | -------------------------------------------------------------------------------- /cspuz/puzzle/masyu.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import cspuz 5 | from cspuz import Solver, graph 6 | from cspuz.grid_frame import BoolGridFrame 7 | from cspuz.puzzle import util 8 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 9 | from cspuz.problem_serializer import ( 10 | Grid, 11 | MultiDigit, 12 | serialize_problem_as_url, 13 | deserialize_problem_as_url, 14 | ) 15 | 16 | 17 | def solve_masyu(height, width, problem): 18 | solver = Solver() 19 | grid_frame = BoolGridFrame(solver, height - 1, width - 1) 20 | solver.add_answer_key(grid_frame) 21 | graph.active_edges_single_cycle(solver, grid_frame) 22 | 23 | def get_edge(y, x, neg=False): 24 | if 0 <= y <= 2 * (height - 1) and 0 <= x <= 2 * (width - 1): 25 | if y % 2 == 0: 26 | r = grid_frame.horizontal[y // 2][x // 2] 27 | else: 28 | r = grid_frame.vertical[y // 2][x // 2] 29 | if neg: 30 | return ~r 31 | else: 32 | return r 33 | else: 34 | return neg 35 | 36 | for y in range(height): 37 | for x in range(width): 38 | if problem[y][x] == 1: 39 | solver.ensure( 40 | ( 41 | get_edge(y * 2, x * 2 - 1) 42 | & get_edge(y * 2, x * 2 + 1) 43 | & (get_edge(y * 2, x * 2 - 3, True) | get_edge(y * 2, x * 2 + 3, True)) 44 | ) 45 | | ( 46 | get_edge(y * 2 - 1, x * 2) 47 | & get_edge(y * 2 + 1, x * 2) 48 | & (get_edge(y * 2 - 3, x * 2, True) | get_edge(y * 2 + 3, x * 2, True)) 49 | ) 50 | ) 51 | elif problem[y][x] == 2: 52 | dirs = [ 53 | get_edge(y * 2, x * 2 - 1) & get_edge(y * 2, x * 2 - 3), 54 | get_edge(y * 2 - 1, x * 2) & get_edge(y * 2 - 3, x * 2), 55 | get_edge(y * 2, x * 2 + 1) & get_edge(y * 2, x * 2 + 3), 56 | get_edge(y * 2 + 1, x * 2) & get_edge(y * 2 + 3, x * 2), 57 | ] 58 | solver.ensure((dirs[0] | dirs[2]) & (dirs[1] | dirs[3])) 59 | 60 | is_sat = solver.solve() 61 | return is_sat, grid_frame 62 | 63 | 64 | def generate_masyu(height, width, symmetry=False, verbose=False): 65 | generated = generate_problem( 66 | lambda problem: solve_masyu(height, width, problem), 67 | builder_pattern=ArrayBuilder2D(height, width, [0, 1, 2], default=0, symmetry=symmetry), 68 | clue_penalty=lambda problem: count_non_default_values(problem, default=0, weight=10), 69 | verbose=verbose, 70 | ) 71 | return generated 72 | 73 | 74 | MASYU_COMBINATOR = Grid(MultiDigit(base=3, digits=3)) 75 | 76 | 77 | def serialize_masyu(problem): 78 | height = len(problem) 79 | width = len(problem[0]) 80 | return serialize_problem_as_url(MASYU_COMBINATOR, "masyu", height, width, problem) 81 | 82 | 83 | def deserialize_masyu(url): 84 | return deserialize_problem_as_url(MASYU_COMBINATOR, url, allowed_puzzles=["masyu", "mashu"]) 85 | 86 | 87 | def _main(): 88 | if len(sys.argv) == 1: 89 | # https://puzsq.jp/main/puzzle_play.php?pid=9833 90 | height = 10 91 | width = 10 92 | problem = [ 93 | [0, 0, 0, 0, 2, 0, 0, 0, 0, 0], 94 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 1], 95 | [0, 2, 0, 0, 0, 0, 0, 0, 2, 0], 96 | [1, 0, 2, 0, 0, 1, 0, 1, 0, 0], 97 | [0, 0, 0, 0, 0, 0, 2, 0, 0, 0], 98 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 99 | [0, 0, 0, 1, 0, 1, 0, 1, 0, 0], 100 | [0, 0, 0, 2, 0, 0, 0, 0, 0, 0], 101 | [0, 2, 0, 0, 0, 0, 0, 1, 0, 0], 102 | [0, 0, 0, 0, 1, 0, 0, 1, 0, 0], 103 | ] 104 | is_sat, is_line = solve_masyu(height, width, problem) 105 | print("has answer:", is_sat) 106 | if is_sat: 107 | print(util.stringify_grid_frame(is_line)) 108 | else: 109 | cspuz.config.solver_timeout = 600.0 110 | height, width = map(int, sys.argv[1:]) 111 | while True: 112 | try: 113 | problem = generate_masyu(height, width, symmetry=False, verbose=False) 114 | if problem is not None: 115 | print(util.stringify_array(problem, {0: ".", 1: "O", 2: "#"})) 116 | print(flush=True) 117 | except subprocess.TimeoutExpired: 118 | print("timeout", file=sys.stderr) 119 | 120 | 121 | if __name__ == "__main__": 122 | _main() 123 | -------------------------------------------------------------------------------- /cspuz/puzzle/nurikabe.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import subprocess 4 | 5 | import cspuz 6 | from cspuz import Solver, graph, count_true 7 | from cspuz.puzzle import util 8 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 9 | from cspuz.problem_serializer import ( 10 | Grid, 11 | OneOf, 12 | Spaces, 13 | HexInt, 14 | Dict, 15 | serialize_problem_as_url, 16 | deserialize_problem_as_url, 17 | ) 18 | 19 | 20 | def solve_nurikabe(height, width, problem, unknown_low=None): 21 | solver = Solver() 22 | clues = [] 23 | for y in range(height): 24 | for x in range(width): 25 | if problem[y][x] >= 1 or problem[y][x] == -1: 26 | clues.append((y, x, problem[y][x])) 27 | division = solver.int_array((height, width), 0, len(clues)) 28 | 29 | roots = [None] + list(map(lambda x: (x[0], x[1]), clues)) 30 | graph.division_connected(solver, division, len(clues) + 1, roots=roots) 31 | is_white = solver.bool_array((height, width)) 32 | solver.ensure(is_white == (division != 0)) 33 | solver.add_answer_key(is_white) 34 | 35 | solver.ensure(is_white.conv2d(2, 1, "and").then(division[:-1, :] == division[1:, :])) 36 | solver.ensure(is_white.conv2d(1, 2, "and").then(division[:, :-1] == division[:, 1:])) 37 | solver.ensure(is_white.conv2d(2, 2, "or")) 38 | for i, (y, x, n) in enumerate(clues): 39 | if n > 0: 40 | solver.ensure(count_true(division == (i + 1)) == n) 41 | elif n == -1 and unknown_low is not None: 42 | solver.ensure(count_true(division == (i + 1)) >= unknown_low) 43 | 44 | is_sat = solver.solve() 45 | 46 | return is_sat, is_white 47 | 48 | 49 | def resolve_unknown(height, width, problem, unknown_low=None): 50 | is_sat, sol = solve_nurikabe(height, width, problem, unknown_low=unknown_low) 51 | 52 | visited = [[False for _ in range(width)] for _ in range(height)] 53 | 54 | def visit(y, x): 55 | if not (0 <= y < height and 0 <= x < width) or visited[y][x] or not sol[y, x].sol: 56 | return 0 57 | visited[y][x] = True 58 | ret = 1 + visit(y - 1, x) + visit(y, x - 1) + visit(y + 1, x) + visit(y, x + 1) 59 | return ret 60 | 61 | ret = [] 62 | for y in range(height): 63 | row = [] 64 | for x in range(width): 65 | if problem[y][x] == -1: 66 | row.append(visit(y, x)) 67 | else: 68 | row.append(problem[y][x]) 69 | ret.append(row) 70 | return ret 71 | 72 | 73 | def generate_nurikabe(height, width, min_clue=None, max_clue=10, verbose=False): 74 | disallow_adjacent = [] 75 | for dy in range(-2, 3): 76 | for dx in range(-2, 3): 77 | if (dy, dx) != (0, 0): 78 | disallow_adjacent.append((dy, dx)) 79 | generated = generate_problem( 80 | lambda problem: solve_nurikabe(height, width, problem, unknown_low=min_clue), 81 | builder_pattern=ArrayBuilder2D( 82 | height, 83 | width, 84 | [-1, 0] + list(range(min_clue or 1, max_clue)), 85 | default=0, 86 | disallow_adjacent=True, 87 | symmetry=False, 88 | ), 89 | clue_penalty=lambda problem: count_non_default_values(problem, default=0, weight=5), 90 | verbose=verbose, 91 | ) 92 | if generated is None: 93 | return None 94 | else: 95 | return resolve_unknown(height, width, generated, unknown_low=min_clue) 96 | 97 | 98 | NURIKABE_COMBINATOR = Grid(OneOf(Dict([-1], ["."]), Spaces(0, "g"), HexInt())) 99 | 100 | 101 | def serialize_nurikabe(problem): 102 | height = len(problem) 103 | width = len(problem[0]) 104 | return serialize_problem_as_url(NURIKABE_COMBINATOR, "nurikabe", height, width, problem) 105 | 106 | 107 | def deserialize_nurikabe(url): 108 | return deserialize_problem_as_url(NURIKABE_COMBINATOR, url, allowed_puzzles="nurikabe") 109 | 110 | 111 | def main(): 112 | if len(sys.argv) == 1: 113 | # https://twitter.com/semiexp/status/1222541993638678530 114 | height = 10 115 | width = 10 116 | problem = [ 117 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 118 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 119 | [0, 0, 0, 0, 7, 0, 0, 0, 0, 0], 120 | [0, 0, 0, 7, 0, 0, 0, 0, 9, 0], 121 | [0, 0, 0, 0, 0, 0, 0, 7, 0, 0], 122 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 123 | [0, 0, 7, 0, 0, 0, 7, 0, 0, 0], 124 | [0, 0, 0, 0, 0, 7, 0, 0, 0, 0], 125 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 126 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 127 | ] 128 | is_sat, is_white = solve_nurikabe(height, width, problem) 129 | print("has answer:", is_sat) 130 | if is_sat: 131 | print(util.stringify_array(is_white, {None: "?", True: ".", False: "#"})) 132 | else: 133 | parser = argparse.ArgumentParser(add_help=False) 134 | parser.add_argument("-h", "--height", type=int) 135 | parser.add_argument("-w", "--width", type=int) 136 | parser.add_argument("--min-clue", type=int, default=1) 137 | parser.add_argument("--max-clue", type=int, default=10) 138 | parser.add_argument("-v", "--verbose", action="store_true") 139 | args = parser.parse_args() 140 | 141 | height = args.height 142 | width = args.width 143 | min_clue = args.min_clue 144 | max_clue = args.max_clue 145 | verbose = args.verbose 146 | cspuz.config.solver_timeout = 1800.0 147 | while True: 148 | try: 149 | problem = generate_nurikabe( 150 | height, width, min_clue=min_clue, max_clue=max_clue, verbose=verbose 151 | ) 152 | if problem is not None: 153 | print( 154 | util.stringify_array( 155 | problem, lambda x: "." if x == 0 else ("?" if x == -1 else str(x)) 156 | ), 157 | flush=True, 158 | ) 159 | print(flush=True) 160 | except subprocess.TimeoutExpired: 161 | print("timeout", file=sys.stderr) 162 | 163 | 164 | if __name__ == "__main__": 165 | main() 166 | -------------------------------------------------------------------------------- /cspuz/puzzle/nurimisaki.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cspuz import Solver, graph 4 | from cspuz.constraints import count_true, fold_and, fold_or 5 | from cspuz.puzzle import util 6 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 7 | from cspuz.problem_serializer import ( 8 | Grid, 9 | OneOf, 10 | Spaces, 11 | HexInt, 12 | Dict, 13 | serialize_problem_as_url, 14 | deserialize_problem_as_url, 15 | ) 16 | 17 | 18 | def solve_nurimisaki(height, width, problem): 19 | solver = Solver() 20 | is_white = solver.bool_array((height, width)) 21 | solver.add_answer_key(is_white) 22 | 23 | graph.active_vertices_connected(solver, is_white) 24 | 25 | solver.ensure(is_white[:-1, :-1] | is_white[1:, :-1] | is_white[:-1, 1:] | is_white[1:, 1:]) 26 | solver.ensure(~(is_white[:-1, :-1] & is_white[1:, :-1] & is_white[:-1, 1:] & is_white[1:, 1:])) 27 | 28 | for y in range(height): 29 | for x in range(width): 30 | if problem[y][x] == -1: 31 | solver.ensure(is_white[y, x].then(count_true(is_white.four_neighbors(y, x)) != 1)) 32 | else: 33 | solver.ensure(is_white[y, x]) 34 | solver.ensure(count_true(is_white.four_neighbors(y, x)) == 1) 35 | if problem[y][x] != 0: 36 | n = problem[y][x] 37 | cand = [] 38 | if y == n - 1: 39 | cand.append(fold_and(is_white[(y - n + 1) : y, x])) 40 | elif y > n - 1: 41 | cand.append(fold_and(is_white[(y - n + 1) : y, x], ~is_white[y - n, x])) 42 | if y == height - n: 43 | cand.append(fold_and(is_white[(y + 1) : (y + n), x])) 44 | elif y < height - n: 45 | cand.append(fold_and(is_white[(y + 1) : (y + n), x], ~is_white[y + n, x])) 46 | if x == n - 1: 47 | cand.append(fold_and(is_white[y, (x - n + 1) : x])) 48 | elif x > n - 1: 49 | cand.append(fold_and(is_white[y, (x - n + 1) : x], ~is_white[y, x - n])) 50 | if x == width - n: 51 | cand.append(fold_and(is_white[y, (x + 1) : (x + n)])) 52 | elif x < width - n: 53 | cand.append(fold_and(is_white[y, (x + 1) : (x + n)], ~is_white[y, x + n])) 54 | solver.ensure(fold_or(cand)) 55 | 56 | is_sat = solver.solve() 57 | return is_sat, is_white 58 | 59 | 60 | def generate_nurimisaki(height, width, verbose=False): 61 | generated = generate_problem( 62 | lambda problem: solve_nurimisaki(height, width, problem), 63 | builder_pattern=ArrayBuilder2D(height, width, [-1, 0], default=-1), 64 | clue_penalty=lambda problem: count_non_default_values(problem, default=-1, weight=7), 65 | verbose=verbose, 66 | ) 67 | return generated 68 | 69 | 70 | NURIMISAKI_COMBINATOR = Grid(OneOf(Dict([0], ["."]), Spaces(-1, "g"), HexInt())) 71 | 72 | 73 | def serialize_nurimisaki(problem): 74 | height = len(problem) 75 | width = len(problem[0]) 76 | return serialize_problem_as_url(NURIMISAKI_COMBINATOR, "nurimisaki", height, width, problem) 77 | 78 | 79 | def deserialize_nurimisaki(url): 80 | return deserialize_problem_as_url(NURIMISAKI_COMBINATOR, url, allowed_puzzles="nurimisaki") 81 | 82 | 83 | def _main(): 84 | if len(sys.argv) == 1: 85 | # https://twitter.com/semiexp/status/1168898897424633856 86 | height = 10 87 | width = 10 88 | # fmt: off 89 | problem = [ 90 | [-1, -1, -1, -1, 3, -1, -1, -1, -1, -1], # noqa: E201, E241 91 | [-1, 3, -1, -1, -1, -1, -1, -1, -1, -1], # noqa: E201, E241 92 | [-1, -1, -1, -1, -1, -1, -1, -1, 2, -1], # noqa: E201, E241 93 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1], # noqa: E201, E241 94 | [-1, -1, -1, 2, -1, -1, -1, -1, -1, -1], # noqa: E201, E241 95 | [-1, -1, -1, -1, 0, -1, 2, -1, -1, -1], # noqa: E201, E241 96 | [-1, 2, -1, -1, -1, -1, -1, -1, -1, -1], # noqa: E201, E241 97 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, 2], # noqa: E201, E241 98 | [-1, -1, -1, -1, -1, 2, -1, -1, -1, -1], # noqa: E201, E241 99 | [-1, -1, -1, -1, 3, -1, -1, -1, -1, -1], # noqa: E201, E241 100 | ] 101 | # fmt: on 102 | is_sat, ans = solve_nurimisaki(height, width, problem) 103 | print("has answer:", is_sat) 104 | if is_sat: 105 | print(util.stringify_array(ans, {None: "?", True: ".", False: "#"})) 106 | else: 107 | height, width = map(int, sys.argv[1:]) 108 | while True: 109 | problem = generate_nurimisaki(height, width, verbose=True) 110 | if problem is not None: 111 | print(util.stringify_array(problem, {-1: ".", 0: "O"}), flush=True) 112 | print(flush=True) 113 | 114 | 115 | if __name__ == "__main__": 116 | _main() 117 | -------------------------------------------------------------------------------- /cspuz/puzzle/putteria.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cspuz import Solver 4 | from cspuz.constraints import count_true 5 | from cspuz.puzzle import util 6 | from cspuz.generator import generate_problem, SegmentationBuilder2D 7 | 8 | 9 | def solve_putteria(height, width, blocks): 10 | solver = Solver() 11 | has_number = solver.bool_array((height, width)) 12 | solver.add_answer_key(has_number) 13 | 14 | solver.ensure((~has_number[:, :-1]) | (~has_number[:, 1:])) 15 | solver.ensure((~has_number[:-1, :]) | (~has_number[1:, :])) 16 | for block in blocks: 17 | solver.ensure(count_true(has_number[block]) == 1) 18 | 19 | block_size = [[0 for _ in range(width)] for _ in range(height)] 20 | for block in blocks: 21 | for y, x in block: 22 | block_size[y][x] = len(block) 23 | for y in range(height): 24 | for x1 in range(width): 25 | for x2 in range(x1 + 1, width): 26 | if block_size[y][x1] == block_size[y][x2]: 27 | solver.ensure(~(has_number[y, x1] & has_number[y, x2])) 28 | for x in range(width): 29 | for y1 in range(height): 30 | for y2 in range(y1 + 1, height): 31 | if block_size[y1][x] == block_size[y2][x]: 32 | solver.ensure(~(has_number[y1, x] & has_number[y2, x])) 33 | 34 | is_sat = solver.solve() 35 | return is_sat, has_number 36 | 37 | 38 | def generate_putteria( 39 | height, width, min_blocks=13, max_blocks=22, max_block_size=8, verbose=False 40 | ): 41 | generated = generate_problem( 42 | lambda problem: solve_putteria(height, width, problem), 43 | builder_pattern=SegmentationBuilder2D( 44 | height, 45 | width, 46 | min_num_blocks=min_blocks, 47 | max_num_blocks=max_blocks, 48 | min_block_size=3, 49 | max_block_size=max_block_size, 50 | allow_unmet_constraints_first=True, 51 | ), 52 | verbose=verbose, 53 | ) 54 | return generated 55 | 56 | 57 | def _main(): 58 | if len(sys.argv) == 1: 59 | pass 60 | else: 61 | height, width, min_blocks, max_blocks, max_block_size = map(int, sys.argv[1:]) 62 | while True: 63 | gen = generate_putteria( 64 | height, 65 | width, 66 | min_blocks=min_blocks, 67 | max_blocks=max_blocks, 68 | max_block_size=max_block_size, 69 | verbose=True, 70 | ) 71 | print(gen, file=sys.stderr) 72 | if gen is not None: 73 | block_id = [[-1 for _ in range(width)] for _ in range(height)] 74 | for i, block in enumerate(gen): 75 | for y, x in block: 76 | block_id[y][x] = i 77 | url = util.encode_grid_segmentation(height, width, block_id) 78 | print(url, flush=True) 79 | 80 | 81 | if __name__ == "__main__": 82 | _main() 83 | -------------------------------------------------------------------------------- /cspuz/puzzle/shakashaka.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cspuz import Solver 4 | from cspuz.constraints import count_true 5 | from cspuz.puzzle import util 6 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 7 | 8 | 9 | def solve_shakashaka(height, width, problem): 10 | # 1 2 3 4 11 | # +-+ + + +-+ 12 | # |/ |\ /| \| 13 | # + +-+ +-+ + 14 | solver = Solver() 15 | answer = solver.int_array((height, width), 0, 4) 16 | solver.add_answer_key(answer) 17 | 18 | for y in range(height): 19 | for x in range(width): 20 | if problem[y][x] is not None: 21 | solver.ensure(answer[y, x] == 0) 22 | if problem[y][x] >= 0: 23 | solver.ensure(count_true(answer.four_neighbors(y, x) != 0) == problem[y][x]) 24 | for y in range(height + 1): 25 | for x in range(width + 1): 26 | diagonals = [] 27 | is_empty = [] 28 | is_white_angle = [] 29 | if y > 0 and x > 0: 30 | diagonals.append(answer[y - 1, x - 1] == 4) 31 | diagonals.append(answer[y - 1, x - 1] == 2) 32 | is_empty.append( 33 | answer[y - 1, x - 1] == 0 if problem[y - 1][x - 1] is None else False 34 | ) 35 | if problem[y - 1][x - 1] is None: 36 | is_white_angle.append( 37 | (answer[y - 1, x - 1] == 0) | (answer[y - 1, x - 1] == 1) 38 | ) 39 | else: 40 | diagonals += [False, False] 41 | is_empty.append(False) 42 | if y < height and x > 0: 43 | diagonals.append(answer[y, x - 1] == 1) 44 | diagonals.append(answer[y, x - 1] == 3) 45 | is_empty.append(answer[y, x - 1] == 0 if problem[y][x - 1] is None else False) 46 | if problem[y][x - 1] is None: 47 | is_white_angle.append((answer[y, x - 1] == 0) | (answer[y, x - 1] == 2)) 48 | else: 49 | diagonals += [False, False] 50 | is_empty.append(False) 51 | if y < height and x < width: 52 | diagonals.append(answer[y, x] == 2) 53 | diagonals.append(answer[y, x] == 4) 54 | is_empty.append(answer[y, x] == 0 if problem[y][x] is None else False) 55 | if problem[y][x] is None: 56 | is_white_angle.append((answer[y, x] == 0) | (answer[y, x] == 3)) 57 | else: 58 | diagonals += [False, False] 59 | is_empty.append(False) 60 | if y > 0 and x < width: 61 | diagonals.append(answer[y - 1, x] == 3) 62 | diagonals.append(answer[y - 1, x] == 1) 63 | is_empty.append(answer[y - 1, x] == 0 if problem[y - 1][x] is None else False) 64 | if problem[y - 1][x] is None: 65 | is_white_angle.append((answer[y - 1, x] == 0) | (answer[y - 1, x] == 4)) 66 | else: 67 | diagonals += [False, False] 68 | is_empty.append(False) 69 | for i in range(8): 70 | if diagonals[i] is False: 71 | continue 72 | if i % 2 == 0: 73 | solver.ensure( 74 | diagonals[i].then( 75 | diagonals[(i + 3) % 8] 76 | | (is_empty[(i + 3) % 8 // 2] & diagonals[(i + 5) % 8]) 77 | ) 78 | ) 79 | else: 80 | solver.ensure( 81 | diagonals[i].then( 82 | diagonals[(i + 5) % 8] 83 | | (is_empty[(i + 5) % 8 // 2] & diagonals[(i + 3) % 8]) 84 | ) 85 | ) 86 | solver.ensure(count_true(is_white_angle) != 3) 87 | is_sat = solver.solve() 88 | return is_sat, answer 89 | 90 | 91 | def generate_shakashaka(height, width, verbose=False): 92 | generated = generate_problem( 93 | lambda problem: solve_shakashaka(height, width, problem), 94 | builder_pattern=ArrayBuilder2D( 95 | height, width, [None, -1, 0, 1, 2, 3, 4], default=None, disallow_adjacent=True 96 | ), 97 | clue_penalty=lambda problem: count_non_default_values(problem, default=None, weight=6), 98 | verbose=verbose, 99 | ) 100 | return generated 101 | 102 | 103 | def _main(): 104 | if len(sys.argv) == 1: 105 | # generated example 106 | # https://twitter.com/semiexp/status/1223794016593956864 107 | height, width = 10, 10 108 | problem = [[None for _ in range(width)] for _ in range(height)] 109 | problem[1][2] = 3 110 | problem[2][7] = 2 111 | problem[2][9] = 0 112 | problem[3][0] = 1 113 | problem[3][3] = 3 114 | problem[4][6] = 3 115 | problem[5][0] = 2 116 | problem[5][3] = 2 117 | problem[6][8] = 2 118 | problem[9][3] = 2 119 | problem[9][7] = 0 120 | 121 | is_sat, ans = solve_shakashaka(height, width, problem) 122 | print("has answer:", is_sat) 123 | if is_sat: 124 | print(util.stringify_array(ans, str)) 125 | else: 126 | height, width = map(int, sys.argv[1:]) 127 | while True: 128 | problem = generate_shakashaka(height, width, verbose=True) 129 | if problem is not None: 130 | print( 131 | util.stringify_array( 132 | problem, {None: ".", -1: "#", 0: "0", 1: "1", 2: "2", 3: "3", 4: "4"} 133 | ) 134 | ) 135 | 136 | 137 | if __name__ == "__main__": 138 | _main() 139 | -------------------------------------------------------------------------------- /cspuz/puzzle/simpleloop.py: -------------------------------------------------------------------------------- 1 | import random 2 | import sys 3 | import subprocess 4 | 5 | import cspuz 6 | from cspuz import Solver, BoolGridFrame, graph 7 | from cspuz.puzzle import util 8 | from cspuz.generator import generate_problem, ArrayBuilder2D, count_non_default_values 9 | 10 | 11 | def solve_simpleloop(height, width, blocked, pivot): 12 | solver = Solver() 13 | grid_frame = BoolGridFrame(solver, height - 1, width - 1) 14 | solver.add_answer_key(grid_frame) 15 | is_passed = graph.active_edges_single_cycle(solver, grid_frame) 16 | 17 | for y in range(height): 18 | for x in range(width): 19 | if (y, x) != pivot: 20 | solver.ensure(is_passed[y, x] == (blocked[y][x] == 0)) 21 | 22 | py, px = pivot 23 | n_pass = 0 24 | for y in range(height): 25 | for x in range(width): 26 | if (y, x) != pivot and blocked[y][x] == 0: 27 | n_pass += 1 28 | solver.ensure(is_passed[py, px] == (n_pass % 2 == 1)) 29 | is_sat = solver.solve() 30 | return is_sat, grid_frame 31 | 32 | 33 | def generate_simpleloop(height, width, verbose): 34 | pivot = (random.randint(0, height - 1), random.randint(0, width - 1)) 35 | 36 | def pretest(problem): 37 | parity = [0, 0] 38 | for y in range(height): 39 | for x in range(width): 40 | if problem[y][x] == 1: 41 | continue 42 | a = (y + x) % 2 * 2 - 1 43 | if (y, x) != pivot: 44 | parity[0] += a 45 | parity[1] += a 46 | return parity[0] == 0 or parity[1] == 0 47 | 48 | generated = generate_problem( 49 | lambda problem: solve_simpleloop(height, width, problem, pivot), 50 | builder_pattern=ArrayBuilder2D(height, width, [0, 1], default=0, disallow_adjacent=True), 51 | clue_penalty=lambda problem: count_non_default_values(problem, default=0, weight=10), 52 | pretest=pretest, 53 | verbose=verbose, 54 | ) 55 | if generated is None: 56 | return None 57 | num_pass = 0 58 | for y in range(height): 59 | for x in range(width): 60 | if (y, x) != pivot and generated[y][x] == 0: 61 | num_pass += 1 62 | y, x = pivot 63 | generated[y][x] = 1 - num_pass % 2 64 | return generated 65 | 66 | 67 | def _main(): 68 | if len(sys.argv) == 1: 69 | pass 70 | else: 71 | cspuz.config.solver_timeout = 1200.0 72 | height, width = map(int, sys.argv[1:]) 73 | while True: 74 | try: 75 | problem = generate_simpleloop(height, width, verbose=True) 76 | if problem is not None: 77 | print(util.stringify_array(problem, {0: ".", 1: "#"}), flush=True) 78 | print("", flush=True) 79 | except subprocess.TimeoutExpired: 80 | print("timeout", file=sys.stderr) 81 | 82 | 83 | if __name__ == "__main__": 84 | _main() 85 | -------------------------------------------------------------------------------- /cspuz/puzzle/slitherlink.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import cspuz 5 | from cspuz import Solver, graph 6 | from cspuz.grid_frame import BoolGridFrame 7 | from cspuz.constraints import count_true 8 | from cspuz.puzzle import util 9 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 10 | from cspuz.problem_serializer import ( 11 | Grid, 12 | OneOf, 13 | Spaces, 14 | IntSpaces, 15 | serialize_problem_as_url, 16 | deserialize_problem_as_url, 17 | ) 18 | 19 | 20 | def solve_slitherlink(height, width, problem): 21 | solver = Solver() 22 | grid_frame = BoolGridFrame(solver, height, width) 23 | solver.add_answer_key(grid_frame) 24 | graph.active_edges_single_cycle(solver, grid_frame) 25 | for y in range(height): 26 | for x in range(width): 27 | if problem[y][x] >= 0: 28 | solver.ensure(count_true(grid_frame.cell_neighbors(y, x)) == problem[y][x]) 29 | is_sat = solver.solve() 30 | return is_sat, grid_frame 31 | 32 | 33 | def generate_slitherlink(height, width, symmetry=False, verbose=False, disallow_adjacent=False): 34 | def no_neighboring_zero(problem): 35 | for y in range(height): 36 | for x in range(width): 37 | if problem[y][x] != 0: 38 | continue 39 | for dy in range(-1, 2): 40 | for dx in range(-1, 2): 41 | y2 = y + dy 42 | x2 = x + dx 43 | if ( 44 | (dy, dx) != (0, 0) 45 | and 0 <= y2 < height 46 | and 0 <= x2 < width 47 | and problem[y2][x2] == 0 48 | ): 49 | return False 50 | return True 51 | 52 | generated = generate_problem( 53 | lambda problem: solve_slitherlink(height, width, problem), 54 | builder_pattern=ArrayBuilder2D( 55 | height, 56 | width, 57 | range(-1, 4), 58 | default=-1, 59 | symmetry=symmetry, 60 | disallow_adjacent=disallow_adjacent, 61 | ), 62 | clue_penalty=lambda problem: count_non_default_values(problem, default=-1, weight=5), 63 | pretest=no_neighboring_zero, 64 | verbose=verbose, 65 | ) 66 | return generated 67 | 68 | 69 | SLITHERLINK_COMBINATOR = Grid(OneOf(Spaces(-1, "g"), IntSpaces(-1, max_int=4, max_num_spaces=2))) 70 | 71 | 72 | def serialize_slitherlink(problem): 73 | height = len(problem) 74 | width = len(problem[0]) 75 | return serialize_problem_as_url(SLITHERLINK_COMBINATOR, "slither", height, width, problem) 76 | 77 | 78 | def deserialize_slitherlink(url): 79 | return deserialize_problem_as_url(SLITHERLINK_COMBINATOR, url, allowed_puzzles="slither") 80 | 81 | 82 | def _main(): 83 | if len(sys.argv) == 1: 84 | # original example: http://pzv.jp/p.html?slither/4/4/dgdh2c7b 85 | height = 4 86 | width = 4 87 | # fmt: off 88 | problem = [ 89 | [ 3, -1, -1, -1], # noqa: E201, E241 90 | [ 3, -1, -1, -1], # noqa: E201, E241 91 | [-1, 2, 2, -1], # noqa: E201, E241 92 | [-1, 2, -1, 1], # noqa: E201, E241 93 | ] 94 | # fmt: on 95 | is_sat, is_line = solve_slitherlink(height, width, problem) 96 | print("has answer:", is_sat) 97 | if is_sat: 98 | print(util.stringify_grid_frame(is_line)) 99 | else: 100 | cspuz.config.solver_timeout = 1800.0 101 | height, width = map(int, sys.argv[1:]) 102 | while True: 103 | try: 104 | problem = generate_slitherlink(height, width, symmetry=True, verbose=True) 105 | if problem is not None: 106 | print(util.stringify_array(problem, {-1: ".", 0: "0", 1: "1", 2: "2", 3: "3"})) 107 | print(flush=True) 108 | except subprocess.TimeoutExpired: 109 | print("timeout", file=sys.stderr) 110 | 111 | 112 | if __name__ == "__main__": 113 | _main() 114 | -------------------------------------------------------------------------------- /cspuz/puzzle/star_battle.py: -------------------------------------------------------------------------------- 1 | import random 2 | import math 3 | import sys 4 | 5 | try: 6 | import numpy as np # type: ignore 7 | except ImportError: 8 | pass 9 | 10 | from cspuz import Solver 11 | from cspuz.constraints import count_true 12 | from cspuz.puzzle import util 13 | 14 | 15 | def solve_star_battle(n, blocks, k): 16 | solver = Solver() 17 | has_star = solver.bool_array((n, n)) 18 | solver.add_answer_key(has_star) 19 | for i in range(n): 20 | solver.ensure(sum(has_star[i, :].cond(1, 0)) == k) 21 | solver.ensure(sum(has_star[:, i].cond(1, 0)) == k) 22 | solver.ensure(~(has_star[:-1, :] & has_star[1:, :])) 23 | solver.ensure(~(has_star[:, :-1] & has_star[:, 1:])) 24 | solver.ensure(~(has_star[:-1, :-1] & has_star[1:, 1:])) 25 | solver.ensure(~(has_star[:-1, 1:] & has_star[1:, :-1])) 26 | for i in range(n): 27 | cells = [] 28 | for y in range(n): 29 | for x in range(n): 30 | if blocks[y][x] == i: 31 | cells.append(has_star[y, x]) 32 | solver.ensure(count_true(cells) == k) 33 | 34 | is_sat = solver.solve() 35 | return is_sat, has_star 36 | 37 | 38 | def _initial_blocks(n): 39 | seeds = set() 40 | while len(seeds) < n: 41 | seeds.add((random.randint(0, n - 1), random.randint(0, n - 1))) 42 | blocks = [[-1 for _ in range(n)] for _ in range(n)] 43 | seeds = list(seeds) 44 | for i, (y, x) in enumerate(seeds): 45 | blocks[y][x] = i 46 | 47 | dirs = [(-1, 0), (0, -1), (1, 0), (0, 1)] 48 | 49 | for i in range(n * (n - 1)): 50 | cand = [] 51 | sz = [0] * n 52 | for y in range(n): 53 | for x in range(n): 54 | if blocks[y][x] != -1: 55 | sz[blocks[y][x]] += 1 56 | continue 57 | for dy, dx in dirs: 58 | y2 = y + dy 59 | x2 = x + dx 60 | if 0 <= y2 < n and 0 <= x2 < n and blocks[y2][x2] != -1: 61 | cand.append((y, x, blocks[y2][x2])) 62 | w = [sz[g] ** -4 for _, _, g in cand] 63 | p = np.array(w) / sum(w) 64 | i = np.random.choice(np.arange(len(cand)), p=p) 65 | y, x, g = cand[i] 66 | blocks[y][x] = g 67 | 68 | return blocks 69 | 70 | 71 | def _is_connected(n, blocks, g, excluded): 72 | visited = [[False for _ in range(n)] for _ in range(n)] 73 | 74 | def visit(y, x): 75 | if ( 76 | not (0 <= y < n and 0 <= x < n) 77 | or (y, x) == excluded 78 | or visited[y][x] 79 | or blocks[y][x] != g 80 | ): 81 | return 82 | visited[y][x] = True 83 | visit(y - 1, x) 84 | visit(y + 1, x) 85 | visit(y, x - 1) 86 | visit(y, x + 1) 87 | 88 | grp = 0 89 | for y in range(n): 90 | for x in range(n): 91 | if blocks[y][x] == g and not visited[y][x] and (y, x) != excluded: 92 | grp += 1 93 | visit(y, x) 94 | 95 | return grp == 1 96 | 97 | 98 | def _compute_score(has_star): 99 | ret = 0 100 | for v in has_star: 101 | if v.sol is not None: 102 | ret += 1 103 | return ret 104 | 105 | 106 | def generate_star_battle(n, k, verbose=False): 107 | while True: 108 | blocks = _initial_blocks(n) 109 | is_sat, has_star = solve_star_battle(n, blocks, k) 110 | if is_sat: 111 | score = _compute_score(has_star) 112 | break 113 | 114 | temperature = 5.0 115 | fully_solved_score = n * n 116 | for step in range(n * n * 10): 117 | cand = [] 118 | for y in range(n): 119 | for x in range(n): 120 | if not _is_connected(n, blocks, blocks[y][x], (y, x)): 121 | continue 122 | g2 = set() 123 | if y > 0: 124 | g2.add(blocks[y - 1][x]) 125 | if y < n - 1: 126 | g2.add(blocks[y + 1][x]) 127 | if x > 0: 128 | g2.add(blocks[y][x - 1]) 129 | if x < n - 1: 130 | g2.add(blocks[y][x + 1]) 131 | for g in g2: 132 | if g != blocks[y][x]: 133 | cand.append((y, x, g)) 134 | random.shuffle(cand) 135 | 136 | for y, x, g in cand: 137 | g_prev = blocks[y][x] 138 | blocks[y][x] = g 139 | 140 | sat, has_star = solve_star_battle(n, blocks, k) 141 | if not sat: 142 | score_next = -1 143 | update = False 144 | else: 145 | score_next = _compute_score(has_star) 146 | if score_next == fully_solved_score: 147 | return blocks 148 | update = score < score_next or random.random() < math.exp( 149 | (score_next - score) / temperature 150 | ) 151 | 152 | if update: 153 | if verbose: 154 | print("update: {} -> {}".format(score, score_next), file=sys.stderr) 155 | score = score_next 156 | break 157 | else: 158 | blocks[y][x] = g_prev 159 | 160 | temperature *= 0.995 161 | if verbose: 162 | print("failed", file=sys.stderr) 163 | return None 164 | 165 | 166 | def problem_to_pzv_url(n, k, blocks): 167 | return "http://pzv.jp/p.html?starbattle/{}/{}/{}/{}".format( 168 | n, n, k, util.encode_grid_segmentation(n, n, blocks) 169 | ) 170 | 171 | 172 | def _main(): 173 | if len(sys.argv) == 1: 174 | # generated example: http://pzv.jp/p.html?starbattle/6/6/1/2u9gn9c9jpmk 175 | is_sat, has_star = solve_star_battle( 176 | 6, 177 | [ 178 | [0, 0, 0, 0, 1, 1], 179 | [0, 2, 3, 0, 1, 1], 180 | [2, 2, 3, 3, 3, 1], 181 | [2, 1, 1, 1, 1, 1], 182 | [2, 4, 4, 1, 4, 5], 183 | [2, 2, 4, 4, 4, 5], 184 | ], 185 | 1, 186 | ) 187 | print("has answer:", is_sat) 188 | if is_sat: 189 | print(util.stringify_array(has_star, {None: "?", True: "*", False: "."})) 190 | else: 191 | n, k = map(int, sys.argv[1:]) 192 | while True: 193 | problem = generate_star_battle(n, k, verbose=True) 194 | if problem is not None: 195 | print(problem_to_pzv_url(n, k, problem), flush=True) 196 | 197 | 198 | if __name__ == "__main__": 199 | _main() 200 | -------------------------------------------------------------------------------- /cspuz/puzzle/sudoku.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | from cspuz import Solver 5 | from cspuz.constraints import alldifferent 6 | from cspuz.puzzle import util 7 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 8 | from cspuz.problem_serializer import ( 9 | Grid, 10 | OneOf, 11 | HexInt, 12 | Spaces, 13 | serialize_problem_as_url, 14 | deserialize_problem_as_url, 15 | ) 16 | 17 | 18 | def solve_sudoku(problem, n=3): 19 | size = n * n 20 | solver = Solver() 21 | answer = solver.int_array((size, size), 1, size) 22 | solver.add_answer_key(answer) 23 | for i in range(size): 24 | solver.ensure(alldifferent(answer[i, :])) 25 | solver.ensure(alldifferent(answer[:, i])) 26 | for y in range(n): 27 | for x in range(n): 28 | solver.ensure(alldifferent(answer[y * n : (y + 1) * n, x * n : (x + 1) * n])) 29 | for y in range(size): 30 | for x in range(size): 31 | if problem[y][x] >= 1: 32 | solver.ensure(answer[y, x] == problem[y][x]) 33 | is_sat = solver.solve() 34 | 35 | return is_sat, answer 36 | 37 | 38 | def generate_sudoku(n, max_clue=None, symmetry=False, verbose=False): 39 | size = n * n 40 | 41 | def pretest(problem): 42 | if max_clue is None: 43 | return True 44 | else: 45 | return count_non_default_values(problem, default=0, weight=1) <= max_clue 46 | 47 | generated = generate_problem( 48 | lambda problem: solve_sudoku(problem, n=n), 49 | builder_pattern=ArrayBuilder2D( 50 | size, size, range(0, size + 1), default=0, symmetry=symmetry 51 | ), 52 | pretest=pretest, 53 | clue_penalty=lambda problem: count_non_default_values(problem, default=0, weight=5), 54 | verbose=verbose, 55 | ) 56 | return generated 57 | 58 | 59 | SUDOKU_COMBINATOR = Grid(OneOf(Spaces(0, "g"), HexInt())) 60 | 61 | 62 | def serialize_sudoku(problem): 63 | height = len(problem) 64 | width = len(problem[0]) 65 | return serialize_problem_as_url(SUDOKU_COMBINATOR, "sudoku", height, width, problem) 66 | 67 | 68 | def deserialize_sudoku(url): 69 | return deserialize_problem_as_url(SUDOKU_COMBINATOR, url, allowed_puzzles="sudoku") 70 | 71 | 72 | def _main(): 73 | if len(sys.argv) == 1: 74 | # https://commons.wikimedia.org/wiki/File:Sudoku-by-L2G-20050714.svg 75 | problem = [ 76 | [5, 3, 0, 0, 7, 0, 0, 0, 0], 77 | [6, 0, 0, 1, 9, 5, 0, 0, 0], 78 | [0, 9, 8, 0, 0, 0, 0, 6, 0], 79 | [8, 0, 0, 0, 6, 0, 0, 0, 3], 80 | [4, 0, 0, 8, 0, 3, 0, 0, 1], 81 | [7, 0, 0, 0, 2, 0, 0, 0, 6], 82 | [0, 6, 0, 0, 0, 0, 2, 8, 0], 83 | [0, 0, 0, 4, 1, 9, 0, 0, 5], 84 | [0, 0, 0, 0, 8, 0, 0, 7, 9], 85 | ] 86 | is_sat, answer = solve_sudoku(problem) 87 | if is_sat: 88 | print( 89 | util.stringify_array( 90 | answer, dict([(None, "?")] + [(i, str(i)) for i in range(1, 10)]) 91 | ) 92 | ) 93 | else: 94 | n = int(sys.argv[1]) 95 | if len(sys.argv) >= 3: 96 | max_clue = int(sys.argv[2]) 97 | else: 98 | max_clue = None 99 | while True: 100 | try: 101 | problem = generate_sudoku(n, max_clue=max_clue, symmetry=True, verbose=True) 102 | if problem is not None: 103 | print( 104 | util.stringify_array(problem, lambda x: "." if x == 0 else str(x)), 105 | flush=True, 106 | ) 107 | print(flush=True) 108 | except subprocess.TimeoutExpired: 109 | print("timeout", file=sys.stderr) 110 | 111 | 112 | if __name__ == "__main__": 113 | _main() 114 | -------------------------------------------------------------------------------- /cspuz/puzzle/util.py: -------------------------------------------------------------------------------- 1 | from ..array import Array1D, Array2D 2 | 3 | 4 | def stringify_array(array, symbol_map=None): 5 | if isinstance(array, (Array1D, Array2D)): 6 | height, _ = array.shape 7 | else: 8 | height = len(array) 9 | rows = [] 10 | 11 | for y in range(height): 12 | if isinstance(symbol_map, dict): 13 | row = [symbol_map[v.sol if hasattr(v, "sol") else v] for v in array[y]] 14 | elif symbol_map is not None: 15 | row = [symbol_map(v.sol if hasattr(v, "sol") else v) for v in array[y]] 16 | else: 17 | row = [v.sol if hasattr(v, "sol") else v for v in array[y]] 18 | rows.append(" ".join(row)) 19 | 20 | return "\n".join(rows) 21 | 22 | 23 | _VERTICAL_EDGE = {None: " ", True: "|", False: "x"} 24 | 25 | _HORIZONTAL_EDGE = {None: " ", True: "-", False: "x"} 26 | 27 | 28 | def stringify_grid_frame(grid_frame): 29 | res = [] 30 | for y in range(2 * grid_frame.height + 1): 31 | for x in range(2 * grid_frame.width + 1): 32 | if y % 2 == 0 and x % 2 == 0: 33 | res.append("+") 34 | elif y % 2 == 1 and x % 2 == 0: 35 | res.append(_VERTICAL_EDGE[grid_frame[y, x].sol]) 36 | elif y % 2 == 0 and x % 2 == 1: 37 | res.append(_HORIZONTAL_EDGE[grid_frame[y, x].sol]) 38 | else: 39 | res.append(" ") 40 | res.append("\n") 41 | return "".join(res) 42 | 43 | 44 | _BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz" 45 | 46 | 47 | def _encode_int_or_str(v): 48 | if isinstance(v, str): 49 | return v 50 | else: 51 | if v <= 15: 52 | prefix = "" 53 | elif v <= 255: 54 | prefix = "-" 55 | elif v <= 4095: 56 | prefix = "+" 57 | else: 58 | raise ValueError("too large value: {}".format(v)) 59 | return prefix + hex(v)[2:] 60 | 61 | 62 | def map2d(func, it): 63 | return list(map(lambda x: list(map(func, x)), it)) 64 | 65 | 66 | def encode_array(array, single_empty_marker="g", empty=None, dim=None): 67 | """ 68 | Encode a 1-D or 2-D array into a serialized string in the pzpr format. 69 | :param array: a 1-D or 2-D array to be serialized. 70 | Each element of `array` should be one of: 71 | - `empty` representing the empty cell, 72 | - a str to be serialized as-is, 73 | - an int to be serialized in base-16, or 74 | - a `list` or 'tuple' of `str`s or `int`s. 75 | :param single_empty_marker: the number (in base-36) representing 76 | single empty cell in the serialization. 77 | :param empty: the value to represent empty cells. 78 | :param dim: the number of dimensions (1 or 2) of `array`. If `None` 79 | is specified, it is automatically inferred. 80 | :return: a str representing the serialization of `array`. 81 | """ 82 | single_empty_index = _BASE36.index(single_empty_marker) 83 | 84 | if dim is None: 85 | is_two_dim = True 86 | for v in array: 87 | if not isinstance(v, list): 88 | is_two_dim = False 89 | if is_two_dim: 90 | dim = 2 91 | else: 92 | dim = 1 93 | else: 94 | if dim not in (1, 2): 95 | raise ValueError("invalid value of dim") 96 | if dim == 2: 97 | # flatten 98 | array = sum(array, []) 99 | res = [] 100 | contiguous_empty_cells = 0 101 | for v in array: 102 | if v == empty: 103 | contiguous_empty_cells += 1 104 | if contiguous_empty_cells - 1 + single_empty_index >= 36: 105 | res.append(_BASE36[-1]) 106 | contiguous_empty_cells = 1 107 | else: 108 | if contiguous_empty_cells > 0: 109 | res.append(_BASE36[contiguous_empty_cells - 1 + single_empty_index]) 110 | contiguous_empty_cells = 0 111 | if isinstance(v, (str, int)): 112 | res.append(_encode_int_or_str(v)) 113 | elif isinstance(v, (list, tuple)): 114 | for w in v: 115 | res.append(_encode_int_or_str(w)) 116 | else: 117 | raise TypeError("unsupported type for serialization") 118 | if contiguous_empty_cells > 0: 119 | res.append(_BASE36[contiguous_empty_cells - 1 + single_empty_index]) 120 | return "".join(res) 121 | 122 | 123 | def encode_grid_segmentation(height, width, block_id): 124 | def convert_binary_seq(s): 125 | ret = "" 126 | for i in range((len(s) + 4) // 5): 127 | v = 0 128 | for j in range(5): 129 | if i * 5 + j < len(s) and s[i * 5 + j] == 1: 130 | v += 2 ** (4 - j) 131 | ret += _BASE36[v] 132 | return ret 133 | 134 | s = [] 135 | for y in range(height): 136 | for x in range(width - 1): 137 | s.append(1 if block_id[y][x] != block_id[y][x + 1] else 0) 138 | ret = convert_binary_seq(s) 139 | s = [] 140 | for y in range(height - 1): 141 | for x in range(width): 142 | s.append(1 if block_id[y][x] != block_id[y + 1][x] else 0) 143 | ret += convert_binary_seq(s) 144 | return ret 145 | 146 | 147 | def blocks_to_block_id(height, width, blocks): 148 | ret = [[-1 for _ in range(width)] for _ in range(height)] 149 | for i, block in enumerate(blocks): 150 | for y, x in block: 151 | ret[y][x] = i 152 | return ret 153 | -------------------------------------------------------------------------------- /cspuz/puzzle/view.py: -------------------------------------------------------------------------------- 1 | import random 2 | import math 3 | import sys 4 | 5 | from cspuz import Solver, graph 6 | from cspuz.puzzle import util 7 | 8 | 9 | def solve_view(height, width, problem): 10 | solver = Solver() 11 | has_number = solver.bool_array((height, width)) 12 | graph.active_vertices_connected(solver, has_number) 13 | nums = solver.int_array((height, width), 0, height + width) 14 | solver.add_answer_key(nums) 15 | solver.add_answer_key(has_number) 16 | 17 | to_up = solver.int_array((height, width), 0, height - 1) 18 | solver.ensure(to_up[0, :] == 0) 19 | solver.ensure(to_up[1:, :] == has_number[:-1, :].cond(0, to_up[:-1, :] + 1)) 20 | 21 | to_down = solver.int_array((height, width), 0, height - 1) 22 | solver.ensure(to_down[-1, :] == 0) 23 | solver.ensure(to_down[:-1, :] == has_number[1:, :].cond(0, to_down[1:, :] + 1)) 24 | 25 | to_left = solver.int_array((height, width), 0, width - 1) 26 | solver.ensure(to_left[:, 0] == 0) 27 | solver.ensure(to_left[:, 1:] == has_number[:, :-1].cond(0, to_left[:, :-1] + 1)) 28 | 29 | to_right = solver.int_array((height, width), 0, width - 1) 30 | solver.ensure(to_right[:, -1] == 0) 31 | solver.ensure(to_right[:, :-1] == has_number[:, 1:].cond(0, to_right[:, 1:] + 1)) 32 | 33 | solver.ensure(has_number.then(nums == to_up + to_left + to_down + to_right)) 34 | solver.ensure((has_number[:-1, :] & has_number[1:, :]).then(nums[:-1, :] != nums[1:, :])) 35 | solver.ensure((has_number[:, :-1] & has_number[:, 1:]).then(nums[:, :-1] != nums[:, 1:])) 36 | solver.ensure((~has_number).then(nums == 0)) 37 | for y in range(height): 38 | for x in range(width): 39 | if problem[y][x] >= 0: 40 | solver.ensure(nums[y, x] == problem[y][x]) 41 | solver.ensure(has_number[y, x]) 42 | 43 | is_sat = solver.solve() 44 | 45 | return is_sat, nums, has_number 46 | 47 | 48 | def compute_score(nums): 49 | score = 0 50 | for v in nums: 51 | if v.sol is not None: 52 | score += 1 53 | return score 54 | 55 | 56 | def generate_view(height, width, verbose=False): 57 | problem = [[-1 for _ in range(width)] for _ in range(height)] 58 | score = 0 59 | temperature = 5.0 60 | fully_solved_score = height * width 61 | 62 | for step in range(height * width * 10): 63 | cand = [] 64 | for y in range(height): 65 | for x in range(width): 66 | for n in range(-1, max(height, width) + 2): 67 | if problem[y][x] != n: 68 | cand.append((y, x, n)) 69 | random.shuffle(cand) 70 | 71 | for y, x, n in cand: 72 | n_prev = problem[y][x] 73 | problem[y][x] = n 74 | 75 | sat, nums, has_number = solve_view(height, width, problem) 76 | if not sat: 77 | score_next = -1 78 | update = False 79 | else: 80 | raw_score = compute_score(nums) 81 | if raw_score == fully_solved_score: 82 | return problem 83 | clue_score = 0 84 | for y2 in range(height): 85 | for x2 in range(width): 86 | if problem[y2][x2] >= 0: 87 | clue_score += 3 88 | score_next = raw_score - clue_score 89 | update = score < score_next or random.random() < math.exp( 90 | (score_next - score) / temperature 91 | ) 92 | 93 | if update: 94 | if verbose: 95 | print("update: {} -> {}".format(score, score_next), file=sys.stderr) 96 | score = score_next 97 | break 98 | else: 99 | problem[y][x] = n_prev 100 | 101 | temperature *= 0.995 102 | if verbose: 103 | print("failed", file=sys.stderr) 104 | return None 105 | 106 | 107 | def _main(): 108 | problem = generate_view(5, 5, True) 109 | print(util.stringify_array(problem, str)) 110 | # https://twitter.com/semiexp/status/1210955179270393856 111 | # fmt: off 112 | is_sat, nums, has_number = solve_view( 113 | 8, 114 | 8, 115 | [ 116 | [-1, 4, -1, -1, 2, -1, -1, -1], # noqa: E201, E241 117 | [-1, -1, 2, -1, -1, -1, -1, -1], # noqa: E201, E241 118 | [-1, -1, -1, -1, -1, -1, -1, 2], # noqa: E201, E241 119 | [-1, -1, -1, -1, 2, -1, -1, -1], # noqa: E201, E241 120 | [-1, -1, -1, -1, -1, 2, -1, -1], # noqa: E201, E241 121 | [-1, -1, 1, -1, -1, 0, -1, -1], # noqa: E201, E241 122 | [-1, 2, -1, -1, -1, -1, -1, -1], # noqa: E201, E241 123 | [-1, -1, -1, 9, -1, -1, -1, 2], # noqa: E201, E241 124 | ], 125 | ) 126 | # fmt: on 127 | print("has_answer:", is_sat) 128 | if is_sat: 129 | ans = [] 130 | for y in range(8): 131 | row = [] 132 | for x in range(8): 133 | if has_number[y, x].sol is not None: 134 | if has_number[y, x].sol: 135 | if nums[y, x].sol is not None: 136 | row.append(str(nums[y, x].sol)) 137 | else: 138 | row.append("#") 139 | else: 140 | row.append(".") 141 | else: 142 | row.append("?") 143 | ans.append(row) 144 | print(util.stringify_array(ans)) 145 | 146 | 147 | if __name__ == "__main__": 148 | _main() 149 | -------------------------------------------------------------------------------- /cspuz/puzzle/yajilin.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | from cspuz import Solver, graph 5 | from cspuz.constraints import count_true 6 | from cspuz.grid_frame import BoolGridFrame 7 | from cspuz.puzzle import util 8 | from cspuz.generator import generate_problem, count_non_default_values, Choice 9 | from cspuz.problem_serializer import ( 10 | Combinator, 11 | Grid, 12 | OneOf, 13 | Spaces, 14 | deserialize_problem_as_url, 15 | serialize_problem_as_url, 16 | ) 17 | 18 | 19 | def solve_yajilin(height, width, problem): 20 | solver = Solver() 21 | grid_frame = BoolGridFrame(solver, height - 1, width - 1) 22 | is_passed = graph.active_edges_single_cycle(solver, grid_frame) 23 | black_cell = solver.bool_array((height, width)) 24 | graph.active_vertices_not_adjacent(solver, black_cell) 25 | solver.add_answer_key(grid_frame) 26 | solver.add_answer_key(black_cell) 27 | 28 | for y in range(height): 29 | for x in range(width): 30 | if problem[y][x] != "..": 31 | # clue 32 | solver.ensure(~is_passed[y, x]) 33 | solver.ensure(~black_cell[y, x]) 34 | 35 | if problem[y][x] == "??": 36 | continue 37 | if problem[y][x][0] == "^": 38 | solver.ensure(count_true(black_cell[0:y, x]) == int(problem[y][x][1:])) 39 | elif problem[y][x][0] == "v": 40 | solver.ensure( 41 | count_true(black_cell[(y + 1) : height, x]) == int(problem[y][x][1:]) 42 | ) 43 | elif problem[y][x][0] == "<": 44 | solver.ensure(count_true(black_cell[y, 0:x]) == int(problem[y][x][1:])) 45 | elif problem[y][x][0] == ">": 46 | solver.ensure( 47 | count_true(black_cell[y, (x + 1) : width]) == int(problem[y][x][1:]) 48 | ) 49 | else: 50 | solver.ensure(is_passed[y, x] != black_cell[y, x]) 51 | 52 | is_sat = solver.solve() 53 | return is_sat, grid_frame, black_cell 54 | 55 | 56 | def generate_yajilin(height, width, no_zero=False, no_max_clue=False, verbose=False): 57 | choices = [] 58 | for y in range(height): 59 | row = [] 60 | for x in range(width): 61 | c = [".."] 62 | for i in range(1 if no_zero else 0, (y + 3) // 2 - (1 if no_max_clue else 0)): 63 | c.append("^{}".format(i)) 64 | for i in range(1 if no_zero else 0, (x + 3) // 2 - (1 if no_max_clue else 0)): 65 | c.append("<{}".format(i)) 66 | for i in range(1 if no_zero else 0, (height - y + 2) // 2 - (1 if no_max_clue else 0)): 67 | c.append("v{}".format(i)) 68 | for i in range(1 if no_zero else 0, (width - x + 2) // 2 - (1 if no_max_clue else 0)): 69 | c.append(">{}".format(i)) 70 | row.append(Choice(c, "..")) 71 | choices.append(row) 72 | generated = generate_problem( 73 | lambda problem: solve_yajilin(height, width, problem), 74 | builder_pattern=choices, 75 | clue_penalty=lambda problem: count_non_default_values(problem, default="..", weight=20), 76 | verbose=verbose, 77 | ) 78 | return generated 79 | 80 | 81 | class YajilinClue(Combinator): 82 | def __init__(self): 83 | super().__init__() 84 | 85 | def serialize(self, env, data, idx): 86 | if idx >= len(data): 87 | return None 88 | if data[idx] == "..": 89 | return None 90 | value = data[idx] 91 | DIR_MAP = {"^": 1, "v": 2, "<": 3, ">": 4} 92 | dir = DIR_MAP[value[0]] 93 | n = int(value[1:]) 94 | return 1, f"{dir}{hex(n)[2:]}" 95 | 96 | def deserialize(self, env, data, idx): 97 | if idx + 1 >= len(data): 98 | return None 99 | dir = data[idx] 100 | if dir == "0": 101 | return 2, ["??"] 102 | if dir not in "1234": 103 | return None 104 | DIR_MAP = {1: "^", 2: "v", 3: "<", 4: ">"} 105 | n = data[idx + 1] 106 | if n == ".": 107 | return 2, ["??"] 108 | return 2, [f"{DIR_MAP[int(dir)]}{int(n, 16)}"] 109 | 110 | 111 | YAJILIN_COMBINATOR = Grid(OneOf(YajilinClue(), Spaces("..", "a"))) 112 | 113 | 114 | def serialize_yajilin(problem): 115 | height = len(problem) 116 | width = len(problem[0]) 117 | return serialize_problem_as_url(YAJILIN_COMBINATOR, "yajilin", height, width, problem) 118 | 119 | 120 | def deserialize_yajilin(url): 121 | return deserialize_problem_as_url(YAJILIN_COMBINATOR, url, allowed_puzzles="yajilin") 122 | 123 | 124 | def _main(): 125 | if len(sys.argv) == 1: 126 | # https://twitter.com/semiexp/status/1206956338556764161 127 | height = 10 128 | width = 10 129 | problem = [ 130 | ["..", "..", "..", "..", "..", "..", "..", "..", "..", ".."], 131 | ["..", "..", "..", "..", "..", "..", "..", "..", "..", ".."], 132 | ["..", "..", "v0", "..", "..", ">2", "..", "..", "..", ".."], 133 | ["..", "..", "..", "..", "..", "..", "..", "..", "..", ".."], 134 | ["..", "..", "..", "..", "..", "..", "..", "..", "..", ".."], 135 | ["..", "..", "..", "..", "..", "..", "..", "..", "^1", ".."], 136 | ["..", "..", "..", "..", "..", "..", "..", "..", "..", ".."], 137 | ["..", "..", "^0", "..", "^3", "..", "..", ">1", "..", ".."], 138 | ["..", "..", "..", "..", "..", "..", "..", "..", "..", ".."], 139 | ["..", "..", "..", "..", "..", "..", "..", ">0", "..", ".."], 140 | ] 141 | is_sat, is_line, black_cell = solve_yajilin(height, width, problem) 142 | print("has answer:", is_sat) 143 | if is_sat: 144 | print(util.stringify_grid_frame(is_line)) 145 | else: 146 | parser = argparse.ArgumentParser(add_help=False) 147 | parser.add_argument("-h", "--height", type=int, required=True) 148 | parser.add_argument("-w", "--width", type=int, required=True) 149 | parser.add_argument("--no-zero", action="store_true") 150 | parser.add_argument("--no-max-clue", action="store_true") 151 | parser.add_argument("-v", "--verbose", action="store_true") 152 | args = parser.parse_args() 153 | 154 | height = args.height 155 | width = args.width 156 | no_zero = args.no_zero 157 | no_max_clue = args.no_max_clue 158 | verbose = args.verbose 159 | while True: 160 | problem = generate_yajilin( 161 | height, width, no_zero=no_zero, no_max_clue=no_max_clue, verbose=verbose 162 | ) 163 | if problem is not None: 164 | print(util.stringify_array(problem, str), flush=True) 165 | print(flush=True) 166 | 167 | 168 | if __name__ == "__main__": 169 | _main() 170 | -------------------------------------------------------------------------------- /cspuz/puzzle/yinyang.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import cspuz 5 | from cspuz import Solver, graph 6 | from cspuz.constraints import count_true 7 | from cspuz.puzzle import util 8 | from cspuz.generator import generate_problem, count_non_default_values, ArrayBuilder2D 9 | 10 | 11 | def solve_yinyang(height, width, problem): 12 | solver = Solver() 13 | is_black = solver.bool_array((height, width)) 14 | solver.add_answer_key(is_black) 15 | 16 | graph.active_vertices_connected(solver, is_black) 17 | graph.active_vertices_connected(solver, ~is_black) 18 | solver.ensure(is_black[:-1, :-1] | is_black[:-1, 1:] | is_black[1:, :-1] | is_black[1:, 1:]) 19 | solver.ensure(~(is_black[:-1, :-1] & is_black[:-1, 1:] & is_black[1:, :-1] & is_black[1:, 1:])) 20 | 21 | # auxiliary constraint 22 | solver.ensure( 23 | ~(is_black[:-1, :-1] & is_black[1:, 1:] & ~is_black[1:, :-1] & ~is_black[:-1, 1:]) 24 | ) 25 | solver.ensure( 26 | ~(~is_black[:-1, :-1] & ~is_black[1:, 1:] & is_black[1:, :-1] & is_black[:-1, 1:]) 27 | ) 28 | 29 | circ = [] 30 | for y in range(height): 31 | circ.append(is_black[y, 0]) 32 | for x in range(1, width): 33 | circ.append(is_black[-1, x]) 34 | for y in reversed(range(0, height - 1)): 35 | circ.append(is_black[y, -1]) 36 | for x in reversed(range(1, width - 1)): 37 | circ.append(is_black[0, x]) 38 | circ_switching = [] 39 | for i in range(len(circ)): 40 | circ_switching.append(circ[i] != circ[(i + 1) % len(circ)]) 41 | solver.ensure(count_true(circ_switching) <= 2) 42 | 43 | for y in range(height): 44 | for x in range(width): 45 | if problem[y][x] == 1: 46 | solver.ensure(~is_black[y, x]) 47 | elif problem[y][x] == 2: 48 | solver.ensure(is_black[y, x]) 49 | 50 | is_sat = solver.solve() 51 | return is_sat, is_black 52 | 53 | 54 | def generate_yinyang( 55 | height, width, disallow_adjacent=False, no_clue_on_circumference=False, verbose=False 56 | ): 57 | def pretest(problem): 58 | for y in range(height): 59 | if problem[y][0] != 0 or problem[y][-1] != 0: 60 | return False 61 | for x in range(width): 62 | if problem[0][x] != 0 or problem[-1][x] != 0: 63 | return False 64 | return True 65 | 66 | generated = generate_problem( 67 | lambda problem: solve_yinyang(height, width, problem), 68 | builder_pattern=ArrayBuilder2D( 69 | height, width, range(0, 3), default=0, disallow_adjacent=disallow_adjacent 70 | ), 71 | clue_penalty=lambda problem: count_non_default_values(problem, default=0, weight=5), 72 | pretest=pretest if no_clue_on_circumference else None, 73 | verbose=verbose, 74 | ) 75 | return generated 76 | 77 | 78 | def _main(): 79 | if len(sys.argv) == 1: 80 | # generated example: http://pzv.jp/p.html?yinyang/6/6/0j40j0060220 81 | height = 6 82 | width = 6 83 | problem = [ 84 | [0, 0, 0, 2, 0, 1], 85 | [0, 1, 1, 0, 0, 0], 86 | [2, 0, 1, 0, 0, 0], 87 | [0, 0, 0, 0, 2, 0], 88 | [0, 0, 0, 0, 0, 2], 89 | [0, 0, 2, 0, 0, 0], 90 | ] 91 | is_sat, is_black = solve_yinyang(height, width, problem) 92 | print("has answer:", is_sat) 93 | if is_sat: 94 | print(util.stringify_array(is_black, {None: "?", True: "#", False: "o"})) 95 | else: 96 | cspuz.config.solver_timeout = 1200.0 97 | height, width = map(int, sys.argv[1:]) 98 | while True: 99 | try: 100 | problem = generate_yinyang(height, width, disallow_adjacent=False, verbose=True) 101 | if problem is not None: 102 | print(util.stringify_array(problem, {0: ".", 1: "o", 2: "#"}), flush=True) 103 | print(flush=True) 104 | except subprocess.TimeoutExpired: 105 | print("timeout", file=sys.stderr) 106 | 107 | 108 | if __name__ == "__main__": 109 | _main() 110 | -------------------------------------------------------------------------------- /cspuz/solver.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import warnings 3 | from typing import Any, List, Tuple, Union, cast, overload 4 | 5 | from . import backend 6 | from .array import BoolArray1D, BoolArray2D, IntArray1D, IntArray2D 7 | from .configuration import config 8 | from .expr import BoolExpr, BoolExprLike, BoolVar, IntVar, Op 9 | from .constraints import flatten_iterator 10 | 11 | 12 | def _get_backend_by_name(backend_name: str) -> type: 13 | if backend_name == "sugar": 14 | return backend.sugar_like.SugarBackend 15 | elif backend_name == "sugar_extended": 16 | return backend.sugar_like.SugarExtendedBackend 17 | elif backend_name == "z3": 18 | return backend.z3.Z3Backend 19 | elif backend_name == "csugar": 20 | return backend.sugar_like.CSugarBackend 21 | elif backend_name == "enigma_csp": 22 | return backend.sugar_like.EnigmaCSPBackend 23 | elif backend_name == "cspuz_core": 24 | return backend.sugar_like.CspuzCoreBackend 25 | else: 26 | raise ValueError("invalid backend {}".format(backend_name)) 27 | 28 | 29 | def _get_default_backend() -> type: 30 | backend_name = config.default_backend 31 | return _get_backend_by_name(backend_name) 32 | 33 | 34 | def _get_backend(backend: Union[None, str, type]) -> type: 35 | if backend is None: 36 | return _get_default_backend() 37 | elif isinstance(backend, str): 38 | return _get_backend_by_name(backend) 39 | else: 40 | return backend 41 | 42 | 43 | class Solver(object): 44 | variables: List[Union[BoolVar, IntVar]] 45 | is_answer_key: List[bool] 46 | constraints: List[BoolExprLike] 47 | 48 | def __init__(self) -> None: 49 | self.variables = [] 50 | self.is_answer_key = [] 51 | self.constraints = [] 52 | 53 | def bool_var(self) -> BoolVar: 54 | v = BoolVar(len(self.variables)) 55 | self.variables.append(v) 56 | self.is_answer_key.append(False) 57 | return v 58 | 59 | def int_var(self, lo: int, hi: int) -> IntVar: 60 | v = IntVar(len(self.variables), lo, hi) 61 | self.variables.append(v) 62 | self.is_answer_key.append(False) 63 | return v 64 | 65 | @overload 66 | def bool_array(self, shape: Union[int, Tuple[int]]) -> BoolArray1D: ... 67 | 68 | @overload 69 | def bool_array(self, shape: Tuple[int, int]) -> BoolArray2D: ... 70 | 71 | def bool_array( 72 | self, shape: Union[int, Tuple[int], Tuple[int, int]] 73 | ) -> Union[BoolArray1D, BoolArray2D]: 74 | if isinstance(shape, int): 75 | shape = (shape,) 76 | size = functools.reduce(lambda x, y: x * y, shape, 1) 77 | vars = [self.bool_var() for _ in range(size)] 78 | 79 | if len(shape) == 1: 80 | return BoolArray1D(vars) 81 | else: 82 | return BoolArray2D(vars, cast(Tuple[int, int], shape)) 83 | 84 | @overload 85 | def int_array(self, shape: Union[int, Tuple[int]], lo: int, hi: int) -> IntArray1D: ... 86 | 87 | @overload 88 | def int_array(self, shape: Tuple[int, int], lo: int, hi: int) -> IntArray2D: ... 89 | 90 | def int_array( 91 | self, shape: Union[int, Tuple[int], Tuple[int, int]], lo: int, hi: int 92 | ) -> Union[IntArray1D, IntArray2D]: 93 | if lo > hi: 94 | raise ValueError("'hi' must be at least 'lo'") 95 | 96 | if isinstance(shape, int): 97 | shape = (shape,) 98 | size = functools.reduce(lambda x, y: x * y, shape, 1) 99 | vars = [self.int_var(lo, hi) for _ in range(size)] 100 | 101 | if len(shape) == 1: 102 | return IntArray1D(vars) 103 | else: 104 | return IntArray2D(vars, cast(Tuple[int, int], shape)) 105 | 106 | def ensure(self, *constraint: Any) -> None: 107 | for x in flatten_iterator(*constraint): 108 | if isinstance(x, (BoolExpr, bool)): 109 | self.constraints.append(x) 110 | else: 111 | raise TypeError("each element in 'constraint' must be BoolExpr-like") 112 | 113 | def add_answer_key(self, *variable: Any) -> None: 114 | for x in flatten_iterator(*variable): 115 | if isinstance(x, (BoolVar, IntVar)): 116 | if self.is_answer_key[x.id]: 117 | raise ValueError(f"variable #{x.id} is already an answer key") 118 | self.is_answer_key[x.id] = True 119 | else: 120 | raise TypeError("each element in 'variable' must be BoolVar or IntVar") 121 | 122 | def find_answer(self, backend: Union[None, str, type] = None) -> bool: 123 | backend_type = _get_backend(backend) 124 | csp_solver = backend_type(self.variables) # type: ignore 125 | csp_solver.add_constraint(self.constraints) 126 | return csp_solver.solve() 127 | 128 | def solve(self, backend: Union[None, str, type] = None) -> bool: 129 | if not any(self.is_answer_key): 130 | warnings.warn("no answer key is given") 131 | backend_type = _get_backend(backend) 132 | csp_solver = backend_type(self.variables) # type: ignore 133 | csp_solver.add_constraint(self.constraints) 134 | 135 | try: 136 | return csp_solver.solve_irrefutably(self.is_answer_key) 137 | except NotImplementedError: 138 | pass 139 | 140 | if not csp_solver.solve(): 141 | # inconsistent problem 142 | return False 143 | 144 | n_var = len(self.variables) 145 | answer: List[Union[None, bool, int]] = [None] * n_var 146 | for i in range(n_var): 147 | if self.is_answer_key[i]: 148 | answer[i] = self.variables[i].sol 149 | 150 | while True: 151 | difference_cond = [] 152 | for i in range(n_var): 153 | a = answer[i] 154 | if self.is_answer_key[i] and a is not None: 155 | difference_cond.append(self.variables[i] != a) 156 | csp_solver.add_constraint(BoolExpr(Op.OR, difference_cond)) 157 | if not csp_solver.solve(): 158 | break 159 | 160 | for i in range(n_var): 161 | if ( 162 | self.is_answer_key[i] 163 | and answer[i] is not None 164 | and answer[i] != self.variables[i].sol 165 | ): 166 | answer[i] = None 167 | 168 | for i in range(n_var): 169 | if self.is_answer_key[i]: 170 | self.variables[i].sol = answer[i] 171 | return True 172 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/ja/index.md: -------------------------------------------------------------------------------- 1 | # puzrs リファレンス (日本語版) 2 | 3 | - チュートリアル 4 | 1. [インストールおよび基本的な使い方](tutorial1.md) 5 | 2. [Array の使い方](tutorial2.md) 6 | 3. [graph モジュール](tutorial3.md) 7 | -------------------------------------------------------------------------------- /docs/ja/sudoku.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semiexp/cspuz/401b3a329c73a062dc0b1255720ad34ca061a43c/docs/ja/sudoku.png -------------------------------------------------------------------------------- /docs/ja/tutorial1.md: -------------------------------------------------------------------------------- 1 | # チュートリアル(1): インストールおよび基本的な使い方 2 | 3 | cspuz は,CSP ソルバーに基づいたパズルソルバーを作成するための Python ライブラリです. 4 | cspuz を利用すると,次のようなことが可能になります: 5 | 6 | - 直感的なインタフェースを用い,CSP ソルバーを Python から直接利用する 7 | - 多くのパズルのソルバーを簡単に記述する 8 | 9 | cspuz それ自体は CSP ソルバーを含みません.代わりに,外部の CSP ソルバーへのインタフェースを提供します. 10 | cspuz では CSP ソルバー [Sugar](http://bach.istc.kobe-u.ac.jp/sugar/) を利用することができます. 11 | 12 | ## cspuz のインストール 13 | 14 | 先述のように,cspuz は CSP ソルバー [Sugar](http://bach.istc.kobe-u.ac.jp/sugar/) を呼び出して利用します.そのため,cspuz をインストールするためには,予め Sugar をインストールしておく必要があります.Sugar のインストール方法については,Sugar のホームページを参照してください. 15 | 16 | Sugar をインストールしたら,次に cspuz をインストールします. 17 | 18 | 1. `https://github.com/semiexp/cspuz` を clone します. 19 | 2. clone したディレクトリで `pip install .` を実行します. 20 | 21 | Tips: cspuz は Python2 では恐らく動作しません.`python` コマンドで Python2 が起動する場合は,デフォルトの Python が Python3 系列になるようにしておくか,`python`, `pip` をそれぞれ `python3`, `pip3` に読み替えるなどしてください. 22 | 23 | これで cspuz 自体のインストールは完了です. 24 | ただし,実際に cspuz を Python から使用する際には,Sugar のパスを指定しておく必要があります. 25 | そのため,環境変数 `$CSPUZ_BACKEND_PATH` に `sugar` スクリプト(Sugar に含まれているはずです)のパスを指定してください. 26 | 27 | ### `sugar-ext` バックエンドの利用 28 | 29 | (TODO: 英語版の説明 https://github.com/semiexp/cspuz/blob/master/README.md を見てください) 30 | 31 | ## cspuz で CSP を解く 32 | 33 | cspuz で CSP を解く手順は次のようになります: 34 | 35 | - クラス `cspuz.Solver` のインスタンス (solver) を生成する 36 | - CSP の変数定義を行う 37 | - 定義した変数に対する制約を solver に与える 38 | - `solver.find_answer()` や `solver.solve()` を用いて CSP を解く 39 | 40 | これらの手順を,実際の Python コードを示しつつ説明していきます. 41 | 以下,Python の対話環境(`python` コマンドを引数なしで呼び出すと起動します)を前提とします. 42 | 43 | 初めに,`cspuz` を import します: 44 | ``` 45 | >>> import cspuz 46 | ``` 47 | 48 | solver の生成は次のようにすればよいです: 49 | ``` 50 | >>> solver = cspuz.Solver() 51 | ``` 52 | 53 | CSP の変数定義はこの `solver` を介して行います. 54 | ``` 55 | >>> x = solver.bool_var() 56 | >>> y = solver.int_var(0, 5) 57 | >>> z = solver.int_var(3, 4) 58 | ``` 59 | 60 | `bool_var()` は真偽値を表す変数を新しく生成します. 61 | `int_var(lo, hi)` は `lo` 以上 `hi` 以下の整数を表す変数を新しく生成します. 62 | これらの関数は呼び出すたびに異なる変数を生成します. 63 | 64 | なお,同時に複数の solver を宣言することもできますが,どの変数がどの solver に対応するかのチェックは行われないため注意してください(混同すると誤動作します). 65 | 66 | 次に,変数に対する制約を solver に与えます. 67 | 簡単に言うと,制約を Python の式みたいに書き,それを引数に `solver.ensure()` を呼び出せばよいです.詳細は後述します. 68 | 69 | ``` 70 | >>> solver.ensure(y + y == z) 71 | >>> solver.ensure(x == (y > z)) 72 | ``` 73 | 74 | 最後に CSP を解きます. 75 | 与えた制約すべてを満たす変数の割当が存在するかを判定するには,`solver.find_answer()` を呼び出します. 76 | 77 | ``` 78 | >>> solver.find_answer() 79 | True 80 | ``` 81 | `True` が返ってきました.これは割当が存在することを表します. 82 | 実際の割当は,各変数のインスタンス変数 `sol` を参照することで確認できます: 83 | ``` 84 | >>> x.sol 85 | False 86 | >>> y.sol 87 | 2 88 | >>> z.sol 89 | 4 90 | ``` 91 | これは,得られた割当において `x = False, y = 2, z = 4` であったことを表します.実際,これが最初に与えた制約を満たすことはすぐに確かめられます. 92 | 93 | ## `solver.solve()` を用いて問題を「解ける限り」解く 94 | 95 | 前節では `solver.find_answer()` を使って CSP の解を 1 つ求めました. 96 | ところで,実際にパズルソルバーを作成する際には,「解ける限り」解く,すなわち「絶対にその値を割り当てないといけない,という値だけ確定させる」ことが重要になることがあります. 97 | そのために `solver.solve()` を使うことができます. 98 | 99 | ここでは,例として前節と違う問題を用います. 100 | 101 | ``` 102 | >>> solver = cspuz.Solver() 103 | >>> x = solver.int_var(1, 3) 104 | >>> y = solver.int_var(1, 3) 105 | >>> z = solver.int_var(1, 3) 106 | >>> solver.ensure(cspuz.alldifferent(x, y, z)) 107 | >>> solver.ensure(x + y == 3) 108 | ``` 109 | 110 | 3 つの変数 `x`, `y`, `z` があり,いずれも 1 以上 3 以下の整数で,互いに異なっています.また,`x + y = 3` です. 111 | これを満たす割当は `(x, y, z) = (1, 2, 3), (3, 2, 1)` の 2 つがあります.いずれにしても `z = 3` が確定しますが,`x`, `y` の値は確定しません. 112 | 113 | `solver.solve()` を使うためには,呼び出す前に「確定させるべき変数」を `solver.add_answer_key()` により solver に登録する必要があります. 114 | 登録されなかった変数は確定させる必要はないとみなされ,仮に確定させられるとしても試行が行われません. 115 | (実際にパズルソルバーを書く際には,答えには直接関係はないが CSP として記述するために必要な変数が現れることが多々あり,そのような変数に対しても確定を試みるのはコストが大きいため,このような仕様になっています.) 116 | 117 | ``` 118 | >>> solver.add_answer_key([x, y, z]) 119 | ``` 120 | (現状,単一の変数か,変数の iterable のみ与えることができます.`solver.add_answer_key(x, y, z)` と書くことはできません) 121 | 122 | この上で `solver.solve()` を呼び出してみましょう: 123 | ``` 124 | >>> solver.solve() 125 | True 126 | ``` 127 | この `True` は `find_answer()` のときと同様,制約をすべて満たす変数の割当が存在することを表します. 128 | 実際の割当結果も確認してみましょう: 129 | ``` 130 | >>> print(x.sol, y.sol, z.sol) 131 | None None 3 132 | ``` 133 | `x.sol` および `y.sol` が `None` となりました.これは,値を確定させることができなかったことを表します. 134 | 一方,`z.sol` は `3` となっており,値が 3 に確定することを表します. 135 | 先程確認したように,この問題においては `x`, `y` の値は確定せず,`z` の値のみが 3 に確定するため,正しい結果が得られました. 136 | 137 | ## cspuz での制約の記述 138 | 139 | 制約を書くために使える演算子には次のものがあります. 140 | 141 | - 加算 `+`, 減算 `-`(`-` は単項演算子としても使えます) 142 | - 比較演算 `==`, `!=`, `<`, `>`, `<=`, `>=`(`==`, `!=` は真偽値に対しても使えます) 143 | - 論理積 (and) `&`, 論理和 (not) `|`, 否定 (not) `~` 144 | 145 | 演算子のオペランドの片方が通常の Python の `bool` や `int` の値であってもかまいません.例えば `a + 2 < b` と書くことができます. 146 | 147 | 論理演算子 `&`, `|`, `~` は通常 Python で用いる `and`, `or`, `not` とは異なっていることに注意してください. 148 | また,これらの演算子の優先順位は `<` などより高いため,例えば次のようには書けません: 149 | ``` 150 | a < b | b < a + 3 151 | ``` 152 | 代わりに次のように書くべきです: 153 | ``` 154 | (a < b) | (b < a + 3) 155 | ``` 156 | 157 | また,次のようなものもあります: 158 | - 「`x` ならば `y`」を意味する `x.then(y)` 159 | - 論理値 `b` に対して,`x if b else y` に対応する `b.cond(x, y)` 160 | - 引数に与えた変数がすべて異なることを意味する `cspuz.alldifferent` 161 | - 引数に与えた変数すべてに対する and / or を意味する `cspuz.fold_and` および `cspuz.fold_or` 162 | - Python 組み込みの `any` や `all` は使わないでください. 163 | - 引数に与えた変数のうち `true` のものを数える `cspuz.count_true` 164 | -------------------------------------------------------------------------------- /docs/ja/tutorial2.md: -------------------------------------------------------------------------------- 1 | # チュートリアル(2): Array の使い方 2 | 3 | cspuz には,CSP 変数の 1 次元ないし 2 次元の配列を表すための `Array` 型が定義されています. 4 | これを用いると,パズル盤面全体に対する制約を簡潔に書けることがあります. 5 | また,次の章でグラフコンポーネントを紹介しますが,これは Array を用いるとより効果的に用いることができます. 6 | 7 | ## Array の宣言 8 | 9 | 真偽値変数の Array は `solver.bool_array(shape)` で作成することができます. 10 | `shape` は Array の形状を表す `int` もしくはタプルです. 11 | 12 | ``` 13 | >>> solver = cspuz.Solver() 14 | >>> a = solver.bool_array(5) 15 | >>> b = solver.bool_array((3, 4)) 16 | ``` 17 | 18 | `a` は長さ 5 の 1 次元配列,`b` は 3*4 の 2 次元配列になります. 19 | 20 | 整数値変数の Array は `solver.int_array(shape, low, high)` で作成することができます. 21 | `shape` は Array の形状を表す `int` もしくはタプル,`low`, `high` はそれぞれ各変数の最小値,最大値です. 22 | 23 | ``` 24 | >>> solver = cspuz.Solver() 25 | >>> a = solver.int_array(5, 0, 2) 26 | >>> b = solver.int_array((3, 4), -2, 1) 27 | ``` 28 | 29 | `a` は `0` 以上 `2` 以下の値をとる変数 5 つの 1 次元配列,`b` は `-2` 以上 `1` 以下の値をとる変数の 3*4 の 2 次元配列になります. 30 | 31 | Array の要素へのアクセスは,通常の list などと同様に `[]` で可能です.なお,2 次元 Array に対しては `x[1][2]` のようにしなくても `x[1, 2]` のようにもアクセス可能です: 32 | ``` 33 | >>> solver = cspuz.Solver() 34 | >>> a = solver.int_array(5, 0, 2) 35 | >>> b = solver.int_array((3, 4), -2, 1) 36 | >>> solver.ensure(a[1] == b[0, 2]) 37 | ``` 38 | 39 | ## numpy をご存知の方へ 40 | 41 | cspuz の Array の演算子に対する挙動は `numpy.array` とほとんど同じです.次の「Array の演算」「スライス」の節は読み飛ばしていただいてかまいません. 42 | ただし,以下の差異に注意してください: 43 | 44 | - Array 同士での broadcast はエラーとなる.例えば,`solver.bool_array((5, 5)) & solver.bool_array((1, 5))` などはできない 45 | - Array の要素への代入はできない. 46 | - Array に対する `iter` は,Array の次元によらず,各要素を順に返す. 47 | 48 | ## Array の演算 49 | 50 | 前章で述べた演算子は,同じ形の Array 同士に対しても適用することができます. 51 | この場合,各要素に対してその演算子を適用した値を要素に持つ,同じ形の Array が返されます. 52 | 53 | ``` 54 | >>> solver = cspuz.Solver() 55 | >>> a = solver.bool_array(5) 56 | >>> b = ~a # [~a[0], ~a[1], ~a[2], ~a[3], ~a[4]] 57 | ``` 58 | 59 | さらに,単一の値と Array に対しても演算子を適用することができます. 60 | この場合,その単一の値と Array の各要素に対してその演算子を適用した値を要素に持つ,同じ形の Array が返されます. 61 | 62 | ``` 63 | >>> solver = cspuz.Solver() 64 | >>> a = solver.int_var(1, 3) 65 | >>> b = solver.int_array((2, 2), 3, 5) 66 | >>> solver.ensure(a == b) # a == b[0, 0] and a == b[0, 1] and a == b[1, 0] and a == b[1, 1] 67 | ``` 68 | 69 | ちなみに,この例のように,Array を `solver.ensure` に直接与えることも可能です.この場合,Array の要素すべてが充足される (true になる) ことを制約として要請します. 70 | 他にも,前章で出てきた `fold_true`, `alldifferent` などもすべて Array を直接引数にとれるようになっています. 71 | 72 | ## スライス 73 | 74 | `[]` で Array にアクセスする際に,単一の位置を `int` で与える代わりに,スライスを与えることができます. 75 | 76 | ``` 77 | >>> solver = cspuz.Solver() 78 | >>> a = solver.int_array((3, 4), 0, 5) 79 | ``` 80 | 81 | 原則として `x:y` は `x` 以上 `y` 未満のインデクスを指します.`x` を省略すると `0`,`y` を省略すると対応する軸のサイズとなります: 82 | ``` 83 | >>> a[1:3, 0:2] # [[a[1, 0], a[1, 1]], [a[2, 0], a[2, 1]]] 84 | >>> a[1:, :2] # [[a[1, 0], a[1, 1]], [a[2, 0], a[2, 1]]] 85 | ``` 86 | 87 | ただし,負の値を与えた場合は,その値を対応する軸のサイズに加えた値となります: 88 | ``` 89 | >>> a[:-1, 0:1] # [[a[0, 0], a[1, 0], a[2, 0]]] 90 | ``` 91 | 92 | スライスの代わりに単一の位置を与えると,その軸をその位置で固定し,より次元の低い Array が返されます: 93 | ``` 94 | >>> a[2, :] # [a[2, 0], a[2, 1], a[2, 2], a[2, 3]] 95 | >>> a[2:3, :] # [[a[2, 0], a[2, 1], a[2, 2], a[2, 3]]] 96 | >>> a[2, :] == a[2:3, :] # Array の次元の不一致でエラー 97 | ``` 98 | 99 | ## 実際にパズル問題を解かせてみる 100 | 101 | この章で紹介した Array を用いて,実際にパズルを自動解答させてみましょう. 102 | ここでは数独の自動解答を例に示します. 103 | 104 | 例題として,次の問題を解かせることを考えます: 105 | 106 | http://pzv.jp/p.html?sudoku/9/9/j6h53k32g1g139l8i673h4k1h624i8l289g8g75k45h8j 107 | 108 | ![](sudoku.png) 109 | 110 | (ちなみに,この問題はこのチュートリアルのために自動生成させたものです.現在の puzrs では,数独の自動生成も極めて簡潔に書くことができます.詳細は後の章で説明します) 111 | 112 | ここからは記述量が長くなるので,Python のインタラクティブ環境ではなく,ソースファイルに順次書いていくことを前提にコード例を示していきます. 113 | 114 | まず,Solver を用意します. 115 | 116 | ``` 117 | import cspuz 118 | 119 | solver = cspuz.Solver() 120 | ``` 121 | 122 | 数独では 9×9 のマス目の各マスに 1 以上 9 以下の整数を入れるため,9×9 の整数変数配列を用意します. 123 | また,これらの変数は解答盤面において「確定しているべき値」であるため,`add_answer_key` を用いて solver に登録します. 124 | 125 | ``` 126 | answer = solver.int_array((9, 9), 1, 9) 127 | solver.add_answer_key(answer) 128 | ``` 129 | 130 | 問題盤面における表出数字の制約を与えます. 131 | 132 | ``` 133 | problem = [ 134 | [0, 0, 0, 0, 6, 0, 0, 5, 3], 135 | [0, 0, 0, 0, 0, 3, 2, 0, 1], 136 | [0, 1, 3, 9, 0, 0, 0, 0, 0], 137 | [0, 8, 0, 0, 0, 6, 7, 3, 0], 138 | [0, 4, 0, 0, 0, 0, 0, 1, 0], 139 | [0, 6, 2, 4, 0, 0, 0, 8, 0], 140 | [0, 0, 0, 0, 0, 2, 8, 9, 0], 141 | [8, 0, 7, 5, 0, 0, 0, 0, 0], 142 | [4, 5, 0, 0, 8, 0, 0, 0, 0], 143 | ] 144 | for y in range(9): 145 | for x in range(9): 146 | if problem[y][x] != 0: 147 | solver.ensure(answer[y, x] == problem[y][x]) 148 | ``` 149 | 150 | 各行,列において,すべての数字が異なるという制約を与えます. 151 | 152 | ``` 153 | for y in range(9): 154 | solver.ensure(cspuz.alldifferent(answer[y, :])) 155 | for x in range(9): 156 | solver.ensure(cspuz.alldifferent(answer[:, x])) 157 | ``` 158 | 159 | ここで Array のスライス記法を用いています.`answer` は 9×9 の Array なので,`answer[y, :]` は `answer[y, 0:9]` と等価です. 160 | 161 | 最後に,各ブロックにおいて,すべての数字が異なるという制約を与えます. 162 | 163 | ``` 164 | for gy in range(3): 165 | for gx in range(3): 166 | solver.ensure(cspuz.alldifferent(answer[gy*3:(gy+1)*3, gx*3:(gx+1)*3])) 167 | ``` 168 | 169 | これでルールはすべて記述できているでしょうか? 170 | [数独のルール](https://www.nikoli.co.jp/ja/iphone/sd_tutorial/) を見ると,各行(列,ブロック)に対して,1 から 9 の数字のそれぞれが現れるという制約を加えないといけないようにも見えます.しかし,これは「盤面のサイズが 9×9」「各行,列,ブロックの中の数字はすべて異なる」という今まで書いた制約から従うため,改めて書かなくても問題はありません.(ただし,一般には,あえて冗長な制約を記述したほうが速く解が得られるということもしばしばあります) 171 | 172 | 173 | ここまで来たら,solver に問題を解かせることができます. 174 | ``` 175 | solver.solve() 176 | ``` 177 | 178 | 得られた解は,変数に対して `var.sol` を見ることで確認できるのでした. 179 | ``` 180 | for y in range(9): 181 | for x in range(9): 182 | print(answer[y, x].sol, end=' ') 183 | print() 184 | ``` 185 | 186 | ここで示したコードを実行すると,次のように解が得られることが確かめられます: 187 | 188 | ``` 189 | 2 7 8 1 6 4 9 5 3 190 | 5 9 4 8 7 3 2 6 1 191 | 6 1 3 9 2 5 4 7 8 192 | 9 8 1 2 5 6 7 3 4 193 | 7 4 5 3 9 8 6 1 2 194 | 3 6 2 4 1 7 5 8 9 195 | 1 3 6 7 4 2 8 9 5 196 | 8 2 7 5 3 9 1 4 6 197 | 4 5 9 6 8 1 3 2 7 198 | ``` 199 | -------------------------------------------------------------------------------- /docs/ja/tutorial3.md: -------------------------------------------------------------------------------- 1 | # チュートリアル(3): graph モジュール 2 | 3 | ペンシルパズルでは,「白マスが一つながりになる」「盤面に単一のループができる」といったルールがしばしば登場します. 4 | このような条件を毎回 CSP で 1 から記述するのは面倒ですが,cspuz の `graph` モジュールを用いると,こういった制約を簡単に記述することができます. 5 | 6 | ## graph とは? 7 | 8 | `graph` は「白マスが一つながり」といった条件を記述できるモジュールですが,より一般の「グラフ」に対して同様の条件を記述することもできます. 9 | (詳細はソースコードを読んでください) 10 | 以下,その理論的背景について記述します.(読み飛ばして構いません) 11 | 12 | --- 13 | 14 | グラフ (graph) は,「棒グラフ」や「折れ線グラフ」などのグラフではなく,数学の [グラフ理論](https://ja.wikipedia.org/wiki/%E3%82%B0%E3%83%A9%E3%83%95%E7%90%86%E8%AB%96) の用語で,**頂点** と,2 つの頂点を結ぶ **辺** たちからなる構造を言います. 15 | 16 | ペンシルパズルにおける「白マスは一つながりになる」といった条件は,次のようにするとグラフ理論的に抽象化することができます: 17 | 18 | - 盤面の各マスを頂点,隣り合っているマス(に対応する頂点)同士が辺で結ばれたグラフを考える 19 | - このグラフにおいて,白マスに対応する頂点のみからなる誘導部分グラフが連結 20 | 21 | 「白マスが一つながり」という制約は,内部的にはこのような制約として解釈されて CSP に変換されています.正確には,前章の `Array` を与えたときには,自然なグラフ構造を自動で用いるようになっています.ここで,直接グラフ構造を与えて制約を記述することも可能になっています.こうすることで,例えば三角形のマス目上での連結条件なども記述できるようになっています. 22 | 23 | ## 盤面全体で連結 24 | 25 | 「白マスが一つながり」といった条件を記述するには,`graph.active_vertices_connected` を用いることができます. 26 | 27 | ``` 28 | graph.active_vertices_connected(solver, is_active, acyclic=False) 29 | ``` 30 | 31 | (注:前節で述べたように,`graph` モジュールの関数は一般のグラフを与えて利用することも可能ですが,ここではそのような利用法については割愛するため,省略した引数が存在します) 32 | 33 | - `solver` は,制約を加える対象の `Solver` オブジェクトです. 34 | - `is_active` は論理値型の 2 次元 `Array` です.これを自然に 2 次元グリッドとして解釈し,値が true のマスが一つながりになっていることを制約として加えます. 35 | - `acyclic` が `True` であるならば,一つながりであるだけでなく,「輪っかができない」(値が true のマスからそのマス自身まで,true のマスだけを通って,同じマスを 2 度通らないようにたどることができない)ことも制約とします. 36 | 37 | ### 使用例 38 | 39 | [クリークソルバー](../../cspuz/puzzle/creek.py) で,白マスが連結という制約を記述するために `graph.active_vertices_connected` を使用しています. 40 | 41 | ## 連黒分断禁 42 | 43 | 「黒マスが隣接しない」という条件を書く関数も存在します: 44 | 45 | ``` 46 | graph.active_vertices_not_adjacent(solver, is_active) 47 | ``` 48 | 49 | - `solver` は,制約を加える対象の `Solver` オブジェクトです. 50 | - `is_active` は論理値型の 2 次元 `Array` です.値が true であるマスを黒マスに見立てて,黒マスが隣接しないという条件を制約として加えます. 51 | 52 | ちなみに,この制約は,Array の要素同士の演算を使うと 2 行で書くこともできます: 53 | ``` 54 | solver.ensure(~(is_active[1:, :] & is_active[:-1, :])) 55 | solver.ensure(~(is_active[:, 1:] & is_active[:, :-1])) 56 | ``` 57 | 58 | これと先程の `active_vertices_connected` と組み合わせて,いわゆる「連黒分断禁」制約を書くことができます. 59 | 60 | 一方,連黒分断禁については直接書くための関数も存在します: 61 | 62 | ``` 63 | graph.active_vertices_not_adjacent_and_not_segmenting(solver, is_active) 64 | ``` 65 | 66 | - `solver` は,制約を加える対象の `Solver` オブジェクトです. 67 | - `is_active` は論理値型の 2 次元 `Array` です.値が true であるマスを黒マス,false であるマスを白マスに見立てて,連黒分断禁条件(すなわち,黒マスが隣接しない,かつ白マスが黒マスで分断されない,という条件)を制約として加えます. 68 | 69 | なお,これは `active_vertices_connected` を用いる方法とは CSP への変換方法が違うので,完全に等価ではないですが,同様のものと思ってかまいません. 70 | 71 | ### 使用例 72 | 73 | [へやわけソルバー](../../cspuz/puzzle/heyawake.py) を見てください. 74 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | sys.path.insert(0, str(Path("..", "..").resolve())) 5 | 6 | # Configuration file for the Sphinx documentation builder. 7 | # 8 | # For the full list of built-in configuration values, see the documentation: 9 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 10 | 11 | # -- Project information ----------------------------------------------------- 12 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 13 | 14 | project = 'cspuz' 15 | copyright = '2025, semiexp' 16 | author = 'semiexp' 17 | 18 | # -- General configuration --------------------------------------------------- 19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 20 | 21 | extensions = [ 22 | "sphinx.ext.autodoc", 23 | "sphinx.ext.autosummary", 24 | "sphinx.ext.napoleon", 25 | ] 26 | 27 | templates_path = ['_templates'] 28 | exclude_patterns = [] 29 | 30 | 31 | 32 | # -- Options for HTML output ------------------------------------------------- 33 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 34 | 35 | html_theme = 'sphinx_rtd_theme' 36 | html_static_path = ['_static'] 37 | -------------------------------------------------------------------------------- /docs/source/cspuz.rst: -------------------------------------------------------------------------------- 1 | cspuz 2 | ===== 3 | 4 | .. automodule:: cspuz 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/source/graph.rst: -------------------------------------------------------------------------------- 1 | cspuz.graph 2 | =========== 3 | 4 | .. automodule:: cspuz.graph 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | cspuz documentation 2 | =================== 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :caption: Contents: 7 | 8 | cspuz 9 | graph 10 | solver 11 | -------------------------------------------------------------------------------- /docs/source/solver.rst: -------------------------------------------------------------------------------- 1 | cspuz.solver 2 | ============ 3 | 4 | .. automodule:: cspuz.solver 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "cspuz" 7 | version = "0.1.0" 8 | 9 | [project.optional-dependencies] 10 | check = ["black", "flake8", "mypy", "pytest"] 11 | test = ["pytest", "z3-solver"] 12 | 13 | [tool.setuptools] 14 | packages = ["cspuz", "cspuz.backend", "cspuz.generator", "cspuz.puzzle"] 15 | 16 | [tool.black] 17 | line-length = 99 18 | 19 | [[tool.mypy.overrides]] 20 | module = [ 21 | "cspuz.graph", 22 | "cspuz.array", 23 | "cspuz.solver", 24 | "cspuz.expr", 25 | "cspuz.constraints", 26 | "cspuz.configuration", 27 | "cspuz.grid_frame", 28 | "cspuz.problem_serializer", 29 | "cspuz.generator.*", 30 | "tests.*", 31 | ] 32 | disallow_untyped_defs = true 33 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -m "not all_backends" 3 | -------------------------------------------------------------------------------- /python_check.sh: -------------------------------------------------------------------------------- 1 | CHECK_DIR="cspuz tests bench" 2 | 3 | # yapf -d -r $CHECK_DIR 4 | # flake8 $CHECK_DIR --ignore="H" 5 | 6 | echo "Running black..." 7 | black --line-length 99 $CHECK_DIR 8 | 9 | echo "Running flake8..." 10 | flake8 --max-line-length 99 --ignore H,E203,W503,W504 $CHECK_DIR 11 | 12 | echo "Running mypy..." 13 | mypy $CHECK_DIR 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 99 3 | ignore = E203,W503,W504,E704,F824 4 | -------------------------------------------------------------------------------- /sugar_extension/compile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd `dirname $0` 3 | javac CspuzSugarInterface.java -classpath ${SUGAR_JAR} 4 | -------------------------------------------------------------------------------- /sugar_extension/sugar_ext.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd `dirname $0` 3 | java -cp ".:${SUGAR_JAR}" CspuzSugarInterface 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semiexp/cspuz/401b3a329c73a062dc0b1255720ad34ca061a43c/tests/__init__.py -------------------------------------------------------------------------------- /tests/puzzle/test_compass.py: -------------------------------------------------------------------------------- 1 | from cspuz.puzzle import compass 2 | 3 | 4 | def test_serialization() -> None: 5 | height = 5 6 | width = 4 7 | problem = [(1, 1, 1, 2, -1, 3), (2, 3, -1, 6, -1, -1), (3, 1, 4, -1, -1, 5)] 8 | expected = "https://puzz.link/p?compass/4/5/k1.23k..6.g4..5l" 9 | assert compass.to_puzz_link_url(height, width, problem) == expected 10 | -------------------------------------------------------------------------------- /tests/puzzle/test_star_battle.py: -------------------------------------------------------------------------------- 1 | from cspuz.puzzle import star_battle 2 | 3 | 4 | def test_serialization() -> None: 5 | n = 6 6 | k = 1 7 | block_id = [ 8 | [0, 0, 0, 0, 1, 1], 9 | [0, 2, 3, 0, 1, 1], 10 | [2, 2, 3, 3, 3, 1], 11 | [2, 1, 1, 1, 1, 1], 12 | [2, 4, 4, 1, 4, 5], 13 | [2, 2, 4, 4, 4, 5], 14 | ] 15 | expected = "http://pzv.jp/p.html?starbattle/6/6/1/2u9gn9c9jpmk" 16 | assert star_battle.problem_to_pzv_url(n, k, block_id) == expected 17 | -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import cspuz 4 | 5 | 6 | # TODO: test sugar, sugar_extended, csugar and enigma_csp 7 | @pytest.fixture( 8 | autouse=True, 9 | params=[ 10 | pytest.param("z3"), 11 | pytest.param("cspuz_core"), 12 | pytest.param("csugar", marks=pytest.mark.all_backends), 13 | ], 14 | ) 15 | def default_backend(request: pytest.FixtureRequest) -> None: 16 | cspuz.config.default_backend = request.param 17 | 18 | 19 | @pytest.fixture 20 | def solver() -> cspuz.Solver: 21 | return cspuz.Solver() 22 | 23 | 24 | def test_find_answer(solver: cspuz.Solver) -> None: 25 | x = solver.bool_var() 26 | y = solver.bool_var() 27 | 28 | solver.ensure(x.then(y)) 29 | solver.ensure((~x).then(y)) 30 | 31 | assert solver.find_answer() 32 | 33 | 34 | def test_solve(solver: cspuz.Solver) -> None: 35 | x = solver.bool_var() 36 | y = solver.bool_var() 37 | 38 | solver.ensure(x.then(y)) 39 | solver.ensure((~x).then(y)) 40 | solver.add_answer_key(x, y) 41 | 42 | assert solver.solve() 43 | assert x.sol is None 44 | assert y.sol is True 45 | -------------------------------------------------------------------------------- /tests/test_constraints.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import cspuz 4 | from cspuz.expr import Expr, Op 5 | 6 | from tests.util import check_equality_expr 7 | 8 | 9 | @pytest.fixture 10 | def solver() -> cspuz.Solver: 11 | return cspuz.Solver() 12 | 13 | 14 | def test_alldifferent(solver: cspuz.Solver) -> None: 15 | a = solver.int_array((2, 2), -5, 5) 16 | b = solver.int_var(-10, 10) 17 | c = solver.int_var(-10, 10) 18 | d = solver.int_var(-10, 10) 19 | e = 2 20 | 21 | actual = cspuz.alldifferent(a, [b, c], d, e) 22 | assert check_equality_expr( 23 | actual, Expr(Op.ALLDIFF, [a[0, 0], a[0, 1], a[1, 0], a[1, 1], b, c, d, e]) 24 | ) 25 | 26 | 27 | def test_fold_or(solver: cspuz.Solver) -> None: 28 | a = solver.bool_array((2, 2)) 29 | b = solver.bool_var() 30 | c = solver.bool_var() 31 | d = solver.bool_var() 32 | 33 | actual = cspuz.fold_or([[a, b], c], d) 34 | assert check_equality_expr(actual, Expr(Op.OR, [a[0, 0], a[0, 1], a[1, 0], a[1, 1], b, c, d])) 35 | 36 | 37 | def test_fold_or_empty() -> None: 38 | actual = cspuz.fold_or() 39 | assert check_equality_expr(actual, Expr(Op.BOOL_CONSTANT, [False])) 40 | 41 | 42 | def test_fold_or_with_true_constant(solver: cspuz.Solver) -> None: 43 | a = solver.bool_var() 44 | b = solver.bool_var() 45 | c = solver.bool_var() 46 | actual = cspuz.fold_or(a, True, [b, c]) 47 | assert check_equality_expr(actual, Expr(Op.BOOL_CONSTANT, [True])) 48 | 49 | 50 | def test_fold_and(solver: cspuz.Solver) -> None: 51 | a = solver.bool_array((2, 2)) 52 | b = solver.bool_var() 53 | c = solver.bool_var() 54 | d = solver.bool_var() 55 | 56 | actual = cspuz.fold_and([[a, b], c], d) 57 | assert check_equality_expr(actual, Expr(Op.AND, [a[0, 0], a[0, 1], a[1, 0], a[1, 1], b, c, d])) 58 | 59 | 60 | def test_fold_and_empty() -> None: 61 | actual = cspuz.fold_and() 62 | assert check_equality_expr(actual, Expr(Op.BOOL_CONSTANT, [True])) 63 | 64 | 65 | def test_fold_and_with_false_constant(solver: cspuz.Solver) -> None: 66 | a = solver.bool_var() 67 | b = solver.bool_var() 68 | c = solver.bool_var() 69 | actual = cspuz.fold_and(a, False, [b, c]) 70 | assert check_equality_expr(actual, Expr(Op.BOOL_CONSTANT, [False])) 71 | 72 | 73 | def test_count_true(solver: cspuz.Solver) -> None: 74 | a = solver.bool_array((2, 2)) 75 | b = solver.bool_var() 76 | actual = cspuz.count_true(False, [a, True], b) 77 | vars = [a[0, 0], a[0, 1], a[1, 0], a[1, 1], b] 78 | exprs = list(map(lambda x: Expr(Op.IF, [x, 1, 0]), vars)) + [1] 79 | assert check_equality_expr(actual, Expr(Op.ADD, exprs)) 80 | 81 | 82 | def test_count_true_empty() -> None: 83 | actual = cspuz.count_true() 84 | assert check_equality_expr(actual, Expr(Op.INT_CONSTANT, [0])) 85 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | from cspuz.expr import Expr, ExprLike, BoolVar, IntVar 2 | 3 | 4 | def check_equality_expr(left: ExprLike, right: ExprLike) -> bool: 5 | if not isinstance(left, Expr) or not isinstance(right, Expr): 6 | if isinstance(left, Expr) or isinstance(right, Expr): 7 | return False 8 | return left == right 9 | 10 | if left.op != right.op: 11 | return False 12 | if len(left.operands) != len(right.operands): 13 | return False 14 | if isinstance(left, (BoolVar, IntVar)): 15 | if not isinstance(right, (BoolVar, IntVar)): 16 | return False 17 | return left.id == right.id 18 | for i in range(len(left.operands)): 19 | if not check_equality_expr(left.operands[i], right.operands[i]): 20 | return False 21 | return True 22 | --------------------------------------------------------------------------------