Skip to content

Commit 0bfb247

Browse files
Add analysis API and CLI check command
1 parent 39d2bab commit 0bfb247

File tree

5 files changed

+131
-2
lines changed

5 files changed

+131
-2
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ sudoku-dlx dedupe --in puzzles.txt --out unique.txt
5757
# Generate a unique puzzle (deterministic with seed)
5858
sudoku-dlx gen --seed 123 --givens 30 # ~target clue count (approx)
5959
sudoku-dlx gen --seed 123 --givens 30 --pretty
60+
# Analyze (valid/solvable/unique/difficulty/stats/canonical)
61+
sudoku-dlx check --grid "<81chars>"
62+
sudoku-dlx check --grid "<81chars>" --json > report.json
6063
# Advanced generator flags:
6164
sudoku-dlx gen --seed 123 --givens 28 --minimal
6265
sudoku-dlx gen --seed 123 --givens 28 --symmetry rot180

src/sudoku_dlx/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
count_solutions,
88
from_string,
99
is_valid,
10+
analyze,
1011
solve,
1112
to_string,
1213
)
@@ -34,6 +35,7 @@
3435
"to_string",
3536
"is_valid",
3637
"solve",
38+
"analyze",
3739
"count_solutions",
3840
"rate",
3941
"canonical_form",

src/sudoku_dlx/api.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from dataclasses import dataclass
44
from time import perf_counter
5-
from typing import List, Optional
5+
from typing import List, Optional, Dict, Any
66

77
Grid = List[List[int]]
88

9+
ANALYZE_VERSION = "1"
10+
911

1012
@dataclass
1113
class Stats:
@@ -88,6 +90,50 @@ def count_solutions(grid: Grid, limit: int = 2) -> int:
8890
engine = DLXEngine()
8991
return engine.count(rows, limit=limit)
9092

93+
94+
def analyze(grid: Grid) -> Dict[str, Any]:
95+
"""
96+
Return a compact analysis dict for a Sudoku grid. Keys:
97+
- version: schema version string
98+
- valid: bool (no row/col/box duplicates among givens)
99+
- givens: int
100+
- solvable: bool
101+
- unique: bool (exactly one solution determined via limit=2)
102+
- difficulty: float in [0,10] (heuristic)
103+
- canonical: str (81-char canonical form)
104+
- solution: str | None (81-char solution if solvable)
105+
- stats: {ms, nodes, backtracks} (0s if unsolvable)
106+
"""
107+
from .rating import rate
108+
from .canonical import canonical_form
109+
110+
givens = sum(1 for r in range(9) for c in range(9) if grid[r][c] != 0)
111+
valid = is_valid(grid)
112+
uniq = False
113+
solv: Optional[SolveResult] = None
114+
if valid:
115+
# Uniqueness and solvability
116+
uniq = count_solutions(grid, limit=2) == 1
117+
solv = solve(grid)
118+
solution = None
119+
ms = nodes = backs = 0
120+
if solv is not None:
121+
solution = to_string(solv.grid)
122+
ms = int(round(solv.stats.ms))
123+
nodes = int(solv.stats.nodes)
124+
backs = int(solv.stats.backtracks)
125+
return {
126+
"version": ANALYZE_VERSION,
127+
"valid": valid,
128+
"givens": givens,
129+
"solvable": solv is not None,
130+
"unique": uniq,
131+
"difficulty": float(rate(grid)),
132+
"canonical": canonical_form(grid),
133+
"solution": solution,
134+
"stats": {"ms": ms, "nodes": nodes, "backtracks": backs},
135+
}
136+
91137
__all__ = [
92138
"Grid",
93139
"Stats",
@@ -97,4 +143,5 @@ def count_solutions(grid: Grid, limit: int = 2) -> int:
97143
"is_valid",
98144
"solve",
99145
"count_solutions",
146+
"analyze",
100147
]

src/sudoku_dlx/cli.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import argparse
44
import csv
5+
import json
56
import pathlib
67
import random
78
import sys
89
from typing import Optional
910

10-
from .api import from_string, is_valid, solve, to_string
11+
from .api import analyze, from_string, is_valid, solve, to_string
1112
from .canonical import canonical_form
1213
from .generate import generate
1314
from .rating import rate
@@ -27,6 +28,23 @@ def _print_grid(grid) -> None:
2728
print(" ".join(str(x) for x in row))
2829

2930

31+
def _print_analysis(data: dict) -> None:
32+
def yes(b: bool) -> str:
33+
return "yes" if b else "no"
34+
35+
print("== sudoku-dlx check ==")
36+
print(f"valid: {yes(data['valid'])} givens: {data['givens']}")
37+
print(f"solvable: {yes(data['solvable'])} unique: {yes(data['unique'])}")
38+
print(f"difficulty:{data['difficulty']:.1f}")
39+
stats = data["stats"]
40+
print(
41+
f"stats: {stats['ms']} ms · nodes {stats['nodes']} · backtracks {stats['backtracks']}"
42+
)
43+
print(f"canonical: {data['canonical']}")
44+
if data["solution"]:
45+
print(f"solution: {data['solution']}")
46+
47+
3048
def cmd_solve(ns: argparse.Namespace) -> int:
3149
grid = from_string(_read_grid_arg(ns))
3250
if not is_valid(grid):
@@ -54,6 +72,16 @@ def cmd_rate(ns: argparse.Namespace) -> int:
5472
return 0
5573

5674

75+
def cmd_check(ns: argparse.Namespace) -> int:
76+
grid = from_string(_read_grid_arg(ns))
77+
data = analyze(grid)
78+
if ns.json:
79+
print(json.dumps(data, separators=(",", ":"), sort_keys=True))
80+
else:
81+
_print_analysis(data)
82+
return 0
83+
84+
5785
def cmd_gen(ns: argparse.Namespace) -> int:
5886
grid = generate(
5987
seed=ns.seed,
@@ -165,6 +193,14 @@ def main(argv: Optional[list[str]] = None) -> int:
165193
rate_parser.add_argument("--file", help="path to a file with 9 lines of 9 chars")
166194
rate_parser.set_defaults(func=cmd_rate)
167195

196+
check_parser = sub.add_parser(
197+
"check", help="validate/score a puzzle and show stats/canonical form"
198+
)
199+
check_parser.add_argument("--grid", help="81-char string; 0/./- for blanks")
200+
check_parser.add_argument("--file", help="path to a file with 9 lines of 9 chars")
201+
check_parser.add_argument("--json", action="store_true", help="output JSON")
202+
check_parser.set_defaults(func=cmd_check)
203+
168204
canon_parser = sub.add_parser(
169205
"canon",
170206
help=(

tests/test_check.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from textwrap import dedent
2+
import json
3+
from sudoku_dlx import analyze, from_string
4+
from sudoku_dlx import cli
5+
6+
PUZ = dedent(
7+
"""
8+
53..7....
9+
6..195...
10+
.98....6.
11+
8...6...3
12+
4..8.3..1
13+
7...2...6
14+
.6....28.
15+
...419..5
16+
....8..79
17+
"""
18+
).strip()
19+
20+
def test_analyze_api_keys():
21+
g = from_string(PUZ)
22+
data = analyze(g)
23+
for k in ["version","valid","givens","solvable","unique","difficulty","canonical","solution","stats"]:
24+
assert k in data
25+
st = data["stats"]
26+
for k in ["ms","nodes","backtracks"]:
27+
assert k in st
28+
29+
def test_cli_check_pretty_and_json(capsys):
30+
rc = cli.main(["check", "--grid", PUZ])
31+
assert rc == 0
32+
out = capsys.readouterr().out
33+
assert "valid:" in out and "difficulty:" in out and "canonical:" in out
34+
35+
rc = cli.main(["check", "--grid", PUZ, "--json"])
36+
assert rc == 0
37+
out = capsys.readouterr().out.strip()
38+
data = json.loads(out)
39+
assert data["valid"] is True
40+
assert "canonical" in data and len(data["canonical"]) == 81
41+
assert isinstance(data["difficulty"], float)

0 commit comments

Comments
 (0)