diff --git a/README.md b/README.md index 2cef9df..836a4d7 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,12 @@ A Snake puzzle solver using mathematical programming. ## Overview -Snake is a logic puzzle where ... +Snake is a logic puzzle where you must create a single connected path on a grid according to these rules: + +- **Single connected path** - the snake forms one continuous line from start to end cell +- **No self-touching** - the snake body never touches itself, neither orthogonally nor diagonally +- **Row and column constraints** - each row/column must have a specific number of filled cells + - **Missing constraints variant** - supports puzzles where some row/column constraints are unknown (specified as `None`) This solver models the puzzle as a **Mixed Integer Programming (MIP)** problem to find solutions. @@ -70,16 +75,164 @@ pip install snake-mip-solver ## Requirements -- Python >=3.9 +- Python 3.9+ - Google OR-Tools - pytest (for testing) +## Example Puzzles + +### 6x6 Easy Puzzle + +This 6x6 puzzle demonstrates a straightforward Snake puzzle with clear constraints: + +| Puzzle | Solution | +|--------|----------| +| | | + +```python +def example_6x6_easy(): + """6x6 easy example""" + puzzle = SnakePuzzle( + row_sums=[1, 1, 1, 3, 2, 5], + col_sums=[4, 3, 1, 1, 1, 3], + start_cell=(0, 0), + end_cell=(3, 5) + ) + return puzzle +``` + +### 12x12 Evil Puzzle with Missing Constraints + +This 12x12 puzzle demonstrates advanced features: a large grid with missing row/column constraints (shown as `None`): + +| Puzzle | Solution | +|--------|----------| +| | | + +```python +def example_12x12_evil(): + """12x12 'Evil' puzzle from https://gridpuzzle.com/snake/evil-12""" + puzzle = SnakePuzzle( + row_sums=[11, 2, 7, 4, 4, None, None, None, 3, 2, None, 5], + col_sums=[9, 7, None, 2, 5, 6, None, None, 5, None, None, None], + start_cell=(2, 6), + end_cell=(7, 5) + ) + return puzzle +``` + ## Usage ```python from snake_mip_solver import SnakePuzzle, SnakeSolver +import time + +def solve_puzzle(puzzle, name): + """Solve a snake puzzle and display results""" + print(f"\n" + "="*60) + print(f"SOLVING {name.upper()}") + print("="*60) + + # Create and use the solver + solver = SnakeSolver(puzzle) + + print("Solver information:") + info = solver.get_solver_info() + for key, value in info.items(): + print(f" {key}: {value}") + + print("\nSolving...") + start_time = time.time() + solution = solver.solve(verbose=False) + solve_time = time.time() - start_time + + if solution: + print(f"\nSolution found in {solve_time:.3f} seconds!") + print(f"Solution has {len(solution)} filled cells") + print(f"Solution: {sorted(list(solution))}") + + # Display the board with solution + print("\nPuzzle with solution:") + print(puzzle.get_board_visualization(solution, show_indices=False)) + + # Validate solution + if puzzle.is_valid_solution(solution): + print("✅ Solution is valid!") + else: + print("❌ Solution validation failed!") + else: + print(f"\nNo solution found (took {solve_time:.3f} seconds)") + +# Load and solve example puzzles +puzzle_6x6 = example_6x6_easy() +solve_puzzle(puzzle_6x6, "6x6 Easy") + +puzzle_12x12 = example_12x12_evil() +solve_puzzle(puzzle_12x12, "12x12 Evil") +``` -# TODO: Make example either a direct copy of or very similar to main.py +### Output + +``` +============================================================ +SOLVING 6X6 EASY +============================================================ +Solver information: + solver_type: SCIP 9.2.2 [LP solver: SoPlex 7.1.3] + num_variables: 36 + num_constraints: 159 + puzzle_size: 6x6 + start_cell: (0, 0) + end_cell: (3, 5) + +Solving... + +Solution found in 0.002 seconds! +Solution has 13 filled cells +Solution: [(0, 0), (1, 0), (2, 0), (3, 0), (3, 1), (3, 5), (4, 1), (4, 5), (5, 1), (5, 2), (5, 3), (5, 4), (5, 5)] + +Puzzle with solution: + 4 3 1 1 1 3 +1 S _ _ _ _ _ +1 x _ _ _ _ _ +1 x _ _ _ _ _ +3 x x _ _ _ E +2 _ x _ _ _ x +5 _ x x x x x +✅ Solution is valid! + +============================================================ +SOLVING 12X12 EVIL +============================================================ +Solver information: + solver_type: SCIP 9.2.2 [LP solver: SoPlex 7.1.3] + num_variables: 144 + num_constraints: 665 + puzzle_size: 12x12 + start_cell: (2, 6) + end_cell: (7, 5) + +Solving... + +Solution found in 0.255 seconds! +Solution has 49 filled cells +Solution: [(0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8), (0, 9), (0, 10), (0, 11), (1, 1), (1, 11), (2, 0), (2, 1), (2, 6), (2, 8), (2, 9), (2, 10), (2, 11), (3, 0), (3, 5), (3, 6), (3, 8), (4, 0), (4, 1), (4, 5), (4, 8), (5, 1), (5, 5), (5, 6), (5, 7), (5, 8), (6, 0), (6, 1), (7, 0), (7, 5), (8, 0), (8, 4), (8, 5), (9, 0), (9, 4), (10, 0), (10, 4), (11, 0), (11, 1), (11, 2), (11, 3), (11, 4)] + +Puzzle with solution: + 9 7 ? 2 5 6 ? ? 5 ? ? ? +11 _ x x x x x x x x x x x + 2 _ x _ _ _ _ _ _ _ _ _ x + 7 x x _ _ _ _ S _ x x x x + 4 x _ _ _ _ x x _ x _ _ _ + 4 x x _ _ _ x _ _ x _ _ _ + ? _ x _ _ _ x x x x _ _ _ + ? x x _ _ _ _ _ _ _ _ _ _ + ? x _ _ _ _ E _ _ _ _ _ _ + 3 x _ _ _ x x _ _ _ _ _ _ + 2 x _ _ _ x _ _ _ _ _ _ _ + ? x _ _ _ x _ _ _ _ _ _ _ + 5 x x x x x _ _ _ _ _ _ _ +✅ Solution is valid! ``` ### Running the example @@ -103,8 +256,15 @@ pytest --cov=snake_mip_solver # Run with coverage The solver uses **Mixed Integer Programming (MIP)** to model the puzzle constraints. Google OR-Tools provides the optimization framework, with SCIP as the default solver. -See the complete formulation in **[Complete Mathematical Model Documentation](https://github.com/DenHvideDvaerg/snake-mip-solver/blob/main/model.md)** +The mathematical formulation includes six types of constraints: +1. **Start and End Cell Constraints** - fixing the path endpoints +2. **Row Sum Constraints** - ensuring correct number of cells per row +3. **Column Sum Constraints** - ensuring correct number of cells per column +4. **Snake Path Connectivity Constraints** - forming a single connected path +5. **Diagonal Non-Touching Constraints** - preventing diagonal self-contact +6. **No 2×2 Block Constraints** - preventing disconnected filled blocks +See the complete formulation in **[Complete Mathematical Model Documentation](https://github.com/DenHvideDvaerg/snake-mip-solver/blob/main/model.md)** ## License diff --git a/images/12x12_Evil.png b/images/12x12_Evil.png new file mode 100644 index 0000000..1b21bfd Binary files /dev/null and b/images/12x12_Evil.png differ diff --git a/images/12x12_Evil_solution.png b/images/12x12_Evil_solution.png new file mode 100644 index 0000000..d14ca14 Binary files /dev/null and b/images/12x12_Evil_solution.png differ diff --git a/images/6x6_Easy.png b/images/6x6_Easy.png new file mode 100644 index 0000000..0f97296 Binary files /dev/null and b/images/6x6_Easy.png differ diff --git a/images/6x6_Easy_solution.png b/images/6x6_Easy_solution.png new file mode 100644 index 0000000..08054f9 Binary files /dev/null and b/images/6x6_Easy_solution.png differ diff --git a/main.py b/main.py index 820c914..db14c64 100644 --- a/main.py +++ b/main.py @@ -13,17 +13,6 @@ def example_3x3(): return puzzle -def example_8x8(): - """8x8 example from https://puzzlegenius.org/snake/""" - puzzle = SnakePuzzle( - row_sums=[4, 2, 2, 3, 1, 3, 2, 6], - col_sums=[3, 2, 7, 2, 2, 4, 1, 2], - start_cell=(2, 5), - end_cell=(6, 7) - ) - return puzzle - - def example_diagonal_touching(): """Diagonal touching example (infeasible)""" puzzle = SnakePuzzle( @@ -44,6 +33,16 @@ def example_adjacent_touching(): ) return puzzle +def example_6x6_easy(): + """6x6 easy example""" + puzzle = SnakePuzzle( + row_sums=[1, 1, 1, 3, 2, 5], + col_sums=[4, 3, 1, 1, 1, 3], + start_cell=(0, 0), + end_cell=(3, 5) + ) + return puzzle + def example_12x12_evil(): """12x12 'Evil' puzzle from https://gridpuzzle.com/snake/evil-12""" puzzle = SnakePuzzle( @@ -80,7 +79,7 @@ def solve_puzzle(puzzle, name): # Display the board with solution print("\nPuzzle with solution:") - print(puzzle.get_board_visualization(solution, show_indices=True)) + print(puzzle.get_board_visualization(solution, show_indices=False)) # Validate solution if puzzle.is_valid_solution(solution): @@ -93,17 +92,8 @@ def solve_puzzle(puzzle, name): def main(): # Solve different puzzle examples - puzzle_3x3 = example_3x3() - solve_puzzle(puzzle_3x3, "3x3 Simple") - - puzzle_diag = example_diagonal_touching() - solve_puzzle(puzzle_diag, "5x5 Diagonal Touching") - - puzzle_adjacent_touching = example_adjacent_touching() - solve_puzzle(puzzle_adjacent_touching, "4x4 Adjacent Touching") - - puzzle_8x8 = example_8x8() - solve_puzzle(puzzle_8x8, "8x8") + puzzle_6x6 = example_6x6_easy() + solve_puzzle(puzzle_6x6, "6x6 Easy") puzzle_12x12 = example_12x12_evil() solve_puzzle(puzzle_12x12, "12x12 Evil") diff --git a/model.md b/model.md index 0c0e05e..237e515 100644 --- a/model.md +++ b/model.md @@ -5,24 +5,31 @@ This document provides a formal mathematical programming formulation of the Snak ## Problem Definition Given: -- Item 1 -- Item 2 -- Item 3 +- An **m × n** grid representing the puzzle board +- **Row sum requirements** R = {r₁, r₂, ..., rₘ} where rᵢ is the required number of snake cells in row i, or undefined if no requirement is given +- **Column sum requirements** C = {c₁, c₂, ..., cₙ} where cⱼ is the required number of snake cells in column j, or undefined if no requirement is given +- **Start cell** (s₁, s₂) where the snake path must begin +- **End cell** (e₁, e₂) where the snake path must terminate -**Objective:** Find ... +**Objective:** Find which cells to fill to create a valid snake path that satisfies all Snake puzzle rules: +- The snake forms a single connected path from start to end +- The snake body never touches itself, neither orthogonally nor diagonally +- Row and column sum requirements are satisfied ## Sets and Indices | Symbol | Definition | |--------|------------| -| **I** | Set of ... | -| **J** | Set of ... | +| **I** | Set of row indices: I = {0, 1, ..., m-1} | +| **J** | Set of column indices: J = {0, 1, ..., n-1} | +| **N(i,j)** | Set of orthogonally adjacent cells to (i,j): N(i,j) = {(i',j') : \|i-i'\| + \|j-j'\| = 1, (i',j') ∈ I×J} | +| **D(i,j)** | Set of diagonally adjacent cells to (i,j): D(i,j) = {(i',j') : \|i-i'\| = 1, \|j-j'\| = 1, (i',j') ∈ I×J} | ## Decision Variables | Variable | Domain | Definition | |----------|--------|------------| -| **x_{i,j}** | {0, 1} | 1 if XXX, 0 otherwise | +| **x_{i,j}** | {0, 1} | 1 if cell (i,j) is part of the snake path, 0 otherwise | ## Objective Function @@ -34,17 +41,114 @@ minimize 0 ## Constraints -### 1. Constraint type 1 +### 1. Start and End Cell Constraints -### 2. Constraint type 2 +The start and end cells must be part of the snake path: -### 3. Constraint type 3 +``` +x_{s₁,s₂} = 1 +x_{e₁,e₂} = 1 +``` + +### 2. Row Sum Constraints + +For each row i with a defined requirement rᵢ: + +``` +∑_{j∈J} x_{i,j} = rᵢ ∀i ∈ I : rᵢ is defined +``` + +### 3. Column Sum Constraints + +For each column j with a defined requirement cⱼ: + +``` +∑_{i∈I} x_{i,j} = cⱼ ∀j ∈ J : cⱼ is defined +``` + +### 4. Snake Path Connectivity Constraints + +The snake must form a single connected linear path from start to end with no self-touching: + +**Start and end cells must have exactly one neighbor:** +``` +∑_{(i',j') ∈ N(s₁,s₂)} x_{i',j'} = 1 +∑_{(i',j') ∈ N(e₁,e₂)} x_{i',j'} = 1 +``` + +**Other cells must have exactly 2 neighbors if activated, or no limit if not activated:** +``` +∑_{(i',j') ∈ N(i,j)} x_{i',j'} ≥ 2 · x_{i,j} ∀(i,j) ∈ I×J : (i,j) ≠ (s₁,s₂), (i,j) ≠ (e₁,e₂) +∑_{(i',j') ∈ N(i,j)} x_{i',j'} ≤ 4 - 2 · x_{i,j} ∀(i,j) ∈ I×J : (i,j) ≠ (s₁,s₂), (i,j) ≠ (e₁,e₂) +``` + +These two inequalities create conditional constraints. +- When x_{i,j} = 1 (cell is activated), they become "∑x_{i',j'} x_{i',j'} ≥ 2" and "∑x_{i',j'} x_{i',j'} ≤ 2", enforcing exactly 2 neighbors. +- When x_{i,j} = 0 (cell is not activated), they become "∑x_{i',j'} x_{i',j'} ≥ 0" and "∑x_{i',j'} x_{i',j'} ≤ 4", which impose no meaningful restriction. + +This ensures that only activated cells contribute to the snake path structure, while non-activated cells are free to have any number of activated neighbors. + +### 5. Diagonal Non-Touching Constraints + +The snake body cannot touch itself diagonally. Two diagonally adjacent cells can only both be part of the snake if there is an orthogonal connection between them (i.e., they are connected through the snake path, not just touching): + +``` +x_{i,j} + x_{i',j'} ≤ x_{i,j'} + x_{i',j} + 1 ∀(i,j) ∈ I×J, ∀(i',j') ∈ D(i,j) such that (i,j'), (i',j) ∈ I×J +``` + +This constraint allows diagonal adjacency only when there's an orthogonal path connection, preventing the snake from "touching itself" diagonally. The constraint requires that if two diagonally adjacent cells `(i,j)` and `(i',j')` are both filled, then at least one of the two orthogonal "bridge" cells `(i,j')` or `(i',j)` must also be filled. The domain restriction ensures these bridge cells are within the grid bounds. + +### 6. No 2×2 Block Constraints + +The snake cannot form solid 2×2 blocks. For every 2×2 sub-grid, at most 3 cells can be part of the snake: + +``` +x_{i,j} + x_{i,j+1} + x_{i+1,j} + x_{i+1,j+1} ≤ 3 ∀i ∈ {0,1,...,m-2}, ∀j ∈ {0,1,...,n-2} +``` + +This constraint is essential to prevent disconnected filled 2×2 blocks, which would otherwise satisfy both the snake path connectivity constraints (constraint 4) and diagonal non-touching constraints (constraint 5) but violate the fundamental snake rule that the path must be a single connected line. A filled 2×2 block has each cell with exactly 2 orthogonal neighbors (satisfying constraint 4) and all diagonal pairs connected through orthogonal bridges (satisfying constraint 5), but forms an illegal disconnected component that is not part of a linear snake path. + +#### Example: 12×12 'Evil' Puzzle + +The necessity of this constraint is demonstrated by the 12×12 'Evil' puzzle used as an example elsewhere. Without the 2×2 block constraint, the solver produces an invalid solution containing a disconnected 2×2 block: + +``` +**Invalid solution (without 2×2 constraint):** + 9 7 ? 2 5 6 ? ? 5 ? ? ? +11 _ x x x x x x x x x x x + 2 _ x _ _ _ _ _ _ _ _ _ x + 7 x x _ _ _ x S _ _ x x x + 4 x _ _ _ _ x _ _ x x _ _ + 4 x _ _ _ _ x _ x x _ _ _ + ? x _ _ _ _ x x x _ _ _ _ + ? x x _ _ _ _ _ _ _ _ _ _ + ? _ x _ _ x E _ _ _ _ _ _ + 3 x x _ _ x _ _ _ _ _ _ _ + 2 x _ _ _ x _ _ _ _ _ _ _ + ? x _ x x x _ _ _ x x _ _ ← 2x2 block + 5 x x x _ _ _ _ _ x x _ _ + +**Correct solution (with 2×2 constraint):** + 9 7 ? 2 5 6 ? ? 5 ? ? ? +11 _ x x x x x x x x x x x + 2 _ x _ _ _ _ _ _ _ _ _ x + 7 x x _ _ _ _ S _ x x x x + 4 x _ _ _ _ x x _ x _ _ _ + 4 x x _ _ _ x _ _ x _ _ _ + ? _ x _ _ _ x x x x _ _ _ + ? x x _ _ _ _ _ _ _ _ _ _ + ? x _ _ _ _ E _ _ _ _ _ _ + 3 x _ _ _ x x _ _ _ _ _ _ + 2 x _ _ _ x _ _ _ _ _ _ _ + ? x _ _ _ x _ _ _ _ _ _ _ + 5 x x x x x _ _ _ _ _ _ _ +``` ## Complete MIP Formulation **Variables:** ``` -x_{i,j} ∈ {0,1} ∀i ∈ I, ∀j ∈ J +x_{i,j} ∈ {0,1} ∀(i,j) ∈ I×J ``` **Objective:** @@ -54,5 +158,24 @@ minimize 0 **Subject to:** ``` +x_{s₁,s₂} = 1 +x_{e₁,e₂} = 1 + +∑_{j∈J} x_{i,j} = rᵢ ∀i ∈ I : rᵢ is defined + +∑_{i∈I} x_{i,j} = cⱼ ∀j ∈ J : cⱼ is defined + +∑_{(i',j') ∈ N(s₁,s₂)} x_{i',j'} = 1 + +∑_{(i',j') ∈ N(e₁,e₂)} x_{i',j'} = 1 + +∑_{(i',j') ∈ N(i,j)} x_{i',j'} ≥ 2 · x_{i,j} ∀(i,j) ∈ I×J \ {(s₁,s₂), (e₁,e₂)} + +∑_{(i',j') ∈ N(i,j)} x_{i',j'} ≤ 4 - 2 · x_{i,j} ∀(i,j) ∈ I×J \ {(s₁,s₂), (e₁,e₂)} + +x_{i,j} + x_{i',j'} ≤ x_{i,j'} + x_{i',j} + 1 ∀(i,j) ∈ I×J, ∀(i',j') ∈ D(i,j) : (i,j'), (i',j) ∈ I×J + +x_{i,j} + x_{i,j+1} + x_{i+1,j} + x_{i+1,j+1} ≤ 3 ∀i ∈ {0,...,m-2}, ∀j ∈ {0,...,n-2} +x_{i,j} ∈ {0,1} ∀(i,j) ∈ I×J ``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d44a710..b21760d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,10 @@ dependencies = [ "Bug Reports" = "https://github.com/DenHvideDvaerg/snake-mip-solver/issues" "Source" = "https://github.com/DenHvideDvaerg/snake-mip-solver" +[tool.setuptools.packages.find] +where = ["."] +include = ["snake_mip_solver*"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"]