Skip to content

Commit 1255cf5

Browse files
Add swordfish, simple coloring, SAT cross-check, and demo notebook
1 parent 350e051 commit 1255cf5

File tree

9 files changed

+467
-3
lines changed

9 files changed

+467
-3
lines changed

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ sudoku-dlx explain --grid "<81chars>" --json
8787
# - pairs: naked pair, hidden pair (row/col/box)
8888
# - triples: naked triple, hidden triple (row/col/box)
8989
# - fish: X-Wing (rows & columns)
90+
# - advanced: Swordfish (rows & columns), Simple Coloring (Rule 2)
9091
# Deterministic steps for reproducible tutorials.
9192

9293
# Dataset stats
@@ -104,8 +105,20 @@ sudoku-dlx gen --seed 123 --givens 28 --minimal --symmetry rot180
104105

105106
# Trace & Visualize
106107
sudoku-dlx solve --grid "<81chars>" --trace out.json
107-
# Then open web/visualizer.html in your browser and load out.json
108-
# (works on GitHub Pages)
108+
# Open web/visualizer.html and load out.json
109+
```
110+
111+
## Cross-check with SAT (optional)
112+
Install the optional extra:
113+
114+
```bash
115+
pip install -e ".[sat]"
116+
```
117+
118+
Then verify solutions using an independent SAT solver:
119+
120+
```bash
121+
sudoku-dlx solve --grid "<81chars>" --crosscheck sat
109122
```
110123
111124
What this gives you

notebooks/demo.ipynb

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Sudoku DLX — Demo Notebook\n",
8+
"\n",
9+
"This notebook walks through:\n",
10+
"1. Generate & analyze a puzzle\n",
11+
"2. Solve & capture a trace\n",
12+
"3. Review human-style explanation steps\n",
13+
"4. (Optional) SAT cross-check\n",
14+
"\n",
15+
"> Set up locally with `pip install -e \".[dev]\"` and optionally `pip install -e \".[sat]\"`."
16+
]
17+
},
18+
{
19+
"cell_type": "code",
20+
"execution_count": null,
21+
"metadata": {},
22+
"outputs": [],
23+
"source": [
24+
"from textwrap import dedent\n",
25+
"\n",
26+
"from sudoku_dlx import analyze, generate, solve, to_string, explain, build_reveal_trace\n",
27+
"from sudoku_dlx.crosscheck import sat_solve\n",
28+
"\n",
29+
"puzzle = generate(seed=123, target_givens=30, minimal=False, symmetry=\"mix\")\n",
30+
"print(\"Puzzle:\")\n",
31+
"for row in puzzle:\n",
32+
" print(\" \\".join(str(x) if x else \".\" for x in row))\n",
33+
"print(\"\nAnalysis:\")\n",
34+
"print(analyze(puzzle))"
35+
]
36+
},
37+
{
38+
"cell_type": "code",
39+
"execution_count": null,
40+
"metadata": {},
41+
"outputs": [],
42+
"source": [
43+
"result = solve([row[:] for row in puzzle])\n",
44+
"assert result is not None\n",
45+
"print(\"Solved in\", f\"{result.stats.ms:.2f} ms\", \"nodes:\", result.stats.nodes)\n",
46+
"print(\"Solution (81):\", to_string(result.grid))\n",
47+
"trace = build_reveal_trace(puzzle, result.grid, result.stats)\n",
48+
"print(\"Trace keys:\", list(trace.keys()))"
49+
]
50+
},
51+
{
52+
"cell_type": "markdown",
53+
"metadata": {},
54+
"source": [
55+
"### Human-style steps"
56+
]
57+
},
58+
{
59+
"cell_type": "code",
60+
"execution_count": null,
61+
"metadata": {},
62+
"outputs": [],
63+
"source": [
64+
"explanation = explain(puzzle, max_steps=200)\n",
65+
"print(\"Steps:\", len(explanation['steps']))\n",
66+
"print(\"Progress:\", explanation['progress'])\n",
67+
"print(\"Example step:\", explanation['steps'][0] if explanation['steps'] else None)"
68+
]
69+
},
70+
{
71+
"cell_type": "markdown",
72+
"metadata": {},
73+
"source": [
74+
"### Optional: SAT cross-check\n",
75+
"If `python-sat` is installed, the SAT solution should match DLX."
76+
]
77+
},
78+
{
79+
"cell_type": "code",
80+
"execution_count": null,
81+
"metadata": {},
82+
"outputs": [],
83+
"source": [
84+
"sat_grid = None\n",
85+
"try:\n",
86+
" sat_grid = sat_solve(puzzle)\n",
87+
"except Exception as exc:\n",
88+
" print(\"SAT unavailable:\", exc)\n",
89+
"if sat_grid is not None:\n",
90+
" print(\"SAT solved:\", to_string(sat_grid))\n",
91+
" print(\"Match:\", to_string(sat_grid) == to_string(result.grid))\n",
92+
"else:\n",
93+
" print(\"SAT solver not installed. Run pip install -e '.[sat]'\")"
94+
]
95+
}
96+
],
97+
"metadata": {
98+
"kernelspec": {
99+
"display_name": "Python 3",
100+
"language": "python",
101+
"name": "python3"
102+
},
103+
"language_info": {
104+
"name": "python",
105+
"pygments_lexer": "ipython3"
106+
}
107+
},
108+
"nbformat": 4,
109+
"nbformat_minor": 5
110+
}

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ sudoku-dlx = "sudoku_dlx.cli:main"
2828

2929
[project.optional-dependencies]
3030
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "black>=24.8", "pre-commit>=3.7", "hypothesis>=6.113"]
31+
# optional solver cross-checkers
32+
sat = ["python-sat>=0.1.8"]
3133

3234
[project.urls]
3335
Homepage = "https://github.com/SaridakisStamatisChristos/sudoku_dlx"

src/sudoku_dlx/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .canonical import canonical_form
1717
from .generate import generate
1818
from .rating import rate
19+
from .crosscheck import sat_solve
1920
from .solver import (
2021
SOLVER,
2122
generate_minimal,
@@ -44,6 +45,7 @@
4445
"rate",
4546
"canonical_form",
4647
"generate",
48+
"sat_solve",
4749
# Legacy exports
4850
"SOLVER",
4951
"generate_minimal",

src/sudoku_dlx/cli.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Optional
55

66
from .api import analyze, build_reveal_trace, from_string, is_valid, solve, to_string
7+
from .crosscheck import sat_solve
78
from .explain import explain
89
from .canonical import canonical_form
910
from .generate import generate
@@ -64,6 +65,7 @@ def yes(b: bool) -> str:
6465

6566
def cmd_solve(ns: argparse.Namespace) -> int:
6667
grid = from_string(_read_grid_arg(ns))
68+
grid_for_crosscheck = [row[:] for row in grid]
6769
if not is_valid(grid):
6870
print("Invalid puzzle (duplicate in row/col/box).", file=sys.stderr)
6971
return 2
@@ -80,6 +82,22 @@ def cmd_solve(ns: argparse.Namespace) -> int:
8082
outp = pathlib.Path(ns.trace)
8183
outp.parent.mkdir(parents=True, exist_ok=True)
8284
outp.write_text(json.dumps(trace, indent=2, sort_keys=True), encoding="utf-8")
85+
if ns.crosscheck:
86+
if ns.crosscheck == "sat":
87+
sat_grid = sat_solve(grid_for_crosscheck)
88+
if sat_grid is None:
89+
print(
90+
"# crosscheck: SAT solver unavailable (install optional extra: pip install -e '.[sat]')",
91+
file=sys.stderr,
92+
)
93+
else:
94+
ok = to_string(sat_grid) == to_string(result.grid)
95+
status = "OK" if ok else "MISMATCH"
96+
print(f"# crosscheck[sat]: {status}", file=sys.stderr)
97+
if not ok:
98+
print(f"# SAT solution: {to_string(sat_grid)}", file=sys.stderr)
99+
else:
100+
print(f"# crosscheck: unknown method {ns.crosscheck!r}", file=sys.stderr)
83101
if ns.stats:
84102
print(
85103
f"# solved in {result.stats.ms:.2f} ms · nodes {result.stats.nodes} · backtracks {result.stats.backtracks}",
@@ -349,6 +367,7 @@ def main(argv: Optional[list[str]] = None) -> int:
349367
solve_parser.add_argument("--pretty", action="store_true", help="print 9x9 grid format")
350368
solve_parser.add_argument("--stats", action="store_true", help="print timing & node stats to stderr")
351369
solve_parser.add_argument("--trace", help="write a solution-reveal trace JSON to this path")
370+
solve_parser.add_argument("--crosscheck", choices=["sat"], help="verify solution with external solver")
352371
solve_parser.set_defaults(func=cmd_solve)
353372

354373
rate_parser = sub.add_parser("rate", help="estimate difficulty in [0,10]")

src/sudoku_dlx/crosscheck.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
"""SAT cross-check utilities using python-sat (optional extra)."""
4+
5+
from typing import List, Optional
6+
7+
Grid = List[List[int]]
8+
9+
10+
def _var(r: int, c: int, d: int) -> int:
11+
"""Map (row, col, digit) triples to CNF variables in [1, 729]."""
12+
13+
return r * 81 + c * 9 + d + 1
14+
15+
16+
def _encode_cnf(grid: Grid) -> list[list[int]]:
17+
cnf: list[list[int]] = []
18+
# 1) Each cell has at least one digit
19+
for r in range(9):
20+
for c in range(9):
21+
cnf.append([_var(r, c, d) for d in range(9)])
22+
# 2) Each cell has at most one digit
23+
for r in range(9):
24+
for c in range(9):
25+
for d1 in range(9):
26+
for d2 in range(d1 + 1, 9):
27+
cnf.append([-_var(r, c, d1), -_var(r, c, d2)])
28+
# 3) Rows: each digit appears exactly once
29+
for r in range(9):
30+
for d in range(9):
31+
cnf.append([_var(r, c, d) for c in range(9)])
32+
for c1 in range(9):
33+
for c2 in range(c1 + 1, 9):
34+
cnf.append([-_var(r, c1, d), -_var(r, c2, d)])
35+
# 4) Columns: each digit appears exactly once
36+
for c in range(9):
37+
for d in range(9):
38+
cnf.append([_var(r, c, d) for r in range(9)])
39+
for r1 in range(9):
40+
for r2 in range(r1 + 1, 9):
41+
cnf.append([-_var(r1, c, d), -_var(r2, c, d)])
42+
# 5) Boxes: each digit appears exactly once
43+
for br in range(0, 9, 3):
44+
for bc in range(0, 9, 3):
45+
cells = [(r, c) for r in range(br, br + 3) for c in range(bc, bc + 3)]
46+
for d in range(9):
47+
cnf.append([_var(r, c, d) for (r, c) in cells])
48+
for i in range(9):
49+
for j in range(i + 1, 9):
50+
r1, c1 = cells[i]
51+
r2, c2 = cells[j]
52+
cnf.append([-_var(r1, c1, d), -_var(r2, c2, d)])
53+
# 6) Givens
54+
for r in range(9):
55+
for c in range(9):
56+
v = grid[r][c]
57+
if v:
58+
cnf.append([_var(r, c, v - 1)])
59+
return cnf
60+
61+
62+
def sat_solve(grid: Grid) -> Optional[Grid]:
63+
"""Solve a Sudoku grid via SAT; returns the solved grid or ``None`` if unavailable."""
64+
65+
try:
66+
from pysat.solvers import Minisat22 # type: ignore import-not-found
67+
except Exception:
68+
return None
69+
70+
cnf = _encode_cnf(grid)
71+
with Minisat22(bootstrap_with=cnf) as solver:
72+
if not solver.solve():
73+
return None
74+
model = solver.get_model()
75+
76+
solved: Grid = [[0] * 9 for _ in range(9)]
77+
for r in range(9):
78+
for c in range(9):
79+
for d in range(9):
80+
if _var(r, c, d) in model:
81+
solved[r][c] = d + 1
82+
break
83+
return solved
84+
85+
86+
__all__ = ["sat_solve"]

0 commit comments

Comments
 (0)