Skip to content

Commit 988549d

Browse files
Add human solving explain feature
1 parent a3591bb commit 988549d

File tree

6 files changed

+266
-0
lines changed

6 files changed

+266
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ sudoku-dlx gen --seed 123 --givens 30 --pretty
7979
sudoku-dlx check --grid "<81chars>"
8080
sudoku-dlx check --grid "<81chars>" --json > report.json
8181

82+
# Explain (human-style)
83+
sudoku-dlx explain --grid "<81chars>" --json
84+
# Strategies: naked single, hidden single (row/col/box), locked candidates (pointing).
85+
# Deterministic steps for reproducible tutorials.
86+
8287
# Dataset stats
8388
sudoku-dlx stats-file --in puzzles.txt --json stats.json --csv diff_hist.csv
8489
# prints a compact JSON summary to stdout and writes optional files using v2 difficulty:

src/sudoku_dlx/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
to_string,
1313
build_reveal_trace,
1414
)
15+
from .explain import explain
1516
from .canonical import canonical_form
1617
from .generate import generate
1718
from .rating import rate
@@ -39,6 +40,7 @@
3940
"solve",
4041
"analyze",
4142
"count_solutions",
43+
"explain",
4244
"rate",
4345
"canonical_form",
4446
"generate",

src/sudoku_dlx/cli.py

Lines changed: 23 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 .explain import explain
78
from .canonical import canonical_form
89
from .generate import generate
910
from .rating import rate
@@ -103,6 +104,19 @@ def cmd_check(ns: argparse.Namespace) -> int:
103104
return 0
104105

105106

107+
def cmd_explain(ns: argparse.Namespace) -> int:
108+
grid = from_string(_read_grid_arg(ns))
109+
data = explain(grid, max_steps=ns.max_steps)
110+
if ns.json:
111+
print(json.dumps(data, separators=(",", ":"), sort_keys=True))
112+
else:
113+
print("== sudoku-dlx explain ==")
114+
for i, step in enumerate(data["steps"], 1):
115+
print(f"{i:03d}. {step['strategy']}: {step}")
116+
print(f"solved: {data['solved']} steps: {len(data['steps'])}")
117+
return 0
118+
119+
106120
def cmd_gen(ns: argparse.Namespace) -> int:
107121
grid = generate(
108122
seed=ns.seed,
@@ -350,6 +364,15 @@ def main(argv: Optional[list[str]] = None) -> int:
350364
check_parser.add_argument("--json", action="store_true", help="output JSON")
351365
check_parser.set_defaults(func=cmd_check)
352366

367+
explain_parser = sub.add_parser(
368+
"explain", help="human-style steps (naked/hidden single, locked candidates)"
369+
)
370+
explain_parser.add_argument("--grid", help="81-char string; 0/./- for blanks")
371+
explain_parser.add_argument("--file", help="path to a file with 9 lines of 9 chars")
372+
explain_parser.add_argument("--json", action="store_true")
373+
explain_parser.add_argument("--max-steps", type=int, default=200)
374+
explain_parser.set_defaults(func=cmd_explain)
375+
353376
canon_parser = sub.add_parser(
354377
"canon",
355378
help=(

src/sudoku_dlx/explain.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
from typing import List, Dict, Any, Optional
3+
from .api import Grid, to_string, solve
4+
from .strategies import step_once
5+
6+
def explain(grid: Grid, max_steps: int = 200) -> Dict[str, Any]:
7+
"""
8+
Try to solve using human strategies (naked/hidden singles, locked candidates).
9+
Returns:
10+
{
11+
"version": "explain-1",
12+
"steps": [ {move...}, ... ],
13+
"progress": "<81-char after applying steps>",
14+
"solved": bool,
15+
"solution": "<81-char>" | None
16+
}
17+
Deterministic order and moves.
18+
"""
19+
g = [row[:] for row in grid]
20+
steps: List[Dict[str, Any]] = []
21+
for _ in range(max_steps):
22+
m = step_once(g)
23+
if not m:
24+
break
25+
steps.append(m)
26+
# If we ever complete, stop early
27+
if all(g[r][c] != 0 for r in range(9) for c in range(9)):
28+
break
29+
progress = to_string(g)
30+
# For convenience include full solution if solvable
31+
solved_out: Optional[str] = None
32+
sres = solve([row[:] for row in grid])
33+
if sres is not None:
34+
solved_out = to_string(sres.grid)
35+
return {
36+
"version": "explain-1",
37+
"steps": steps,
38+
"progress": progress,
39+
"solved": progress.find(".") == -1,
40+
"solution": solved_out,
41+
}
42+
43+
__all__ = ["explain"]

src/sudoku_dlx/strategies.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
"""
3+
Lightweight human strategies:
4+
- candidates() snapshot
5+
- naked_single
6+
- hidden_single (row/col/box)
7+
- locked candidates (pointing)
8+
Deterministic scan order for reproducible explanations.
9+
"""
10+
from typing import List, Dict, Set, Tuple, Optional
11+
12+
Grid = List[List[int]]
13+
Cand = List[List[Set[int]]]
14+
15+
def _box_id(r: int, c: int) -> int:
16+
return (r // 3) * 3 + (c // 3)
17+
18+
def _unit_cells_row(r: int) -> List[Tuple[int,int]]:
19+
return [(r, c) for c in range(9)]
20+
21+
def _unit_cells_col(c: int) -> List[Tuple[int,int]]:
22+
return [(r, c) for r in range(9)]
23+
24+
def _unit_cells_box(b: int) -> List[Tuple[int,int]]:
25+
br, bc = (b // 3) * 3, (b % 3) * 3
26+
return [(br + dr, bc + dc) for dr in range(3) for dc in range(3)]
27+
28+
def candidates(grid: Grid) -> Cand:
29+
"""Compute candidate sets for each empty cell (deterministic)."""
30+
rows = [set() for _ in range(9)]
31+
cols = [set() for _ in range(9)]
32+
boxes = [set() for _ in range(9)]
33+
for r in range(9):
34+
for c in range(9):
35+
v = grid[r][c]
36+
if v:
37+
rows[r].add(v); cols[c].add(v); boxes[_box_id(r,c)].add(v)
38+
out: Cand = [[set() for _ in range(9)] for _ in range(9)]
39+
allv = set(range(1,10))
40+
for r in range(9):
41+
for c in range(9):
42+
if grid[r][c] == 0:
43+
out[r][c] = allv - rows[r] - cols[c] - boxes[_box_id(r,c)]
44+
return out
45+
46+
# ----- Moves ------------------------------------------------------------------------------------
47+
48+
def apply_naked_single(grid: Grid, cand: Cand) -> Optional[Dict]:
49+
"""If any cell has exactly one candidate, place it."""
50+
for r in range(9):
51+
for c in range(9):
52+
if grid[r][c] == 0 and len(cand[r][c]) == 1:
53+
v = next(iter(cand[r][c]))
54+
grid[r][c] = v
55+
return {"type": "place", "strategy": "naked_single", "r": r, "c": c, "v": v}
56+
return None
57+
58+
def _hidden_single_in_unit(grid: Grid, cand: Cand, cells: List[Tuple[int,int]], unit_kind: str, unit_idx: int) -> Optional[Dict]:
59+
# map digit -> cells where it can go
60+
places: Dict[int, List[Tuple[int,int]]] = {d: [] for d in range(1,10)}
61+
for (r,c) in cells:
62+
if grid[r][c] != 0:
63+
continue
64+
for d in cand[r][c]:
65+
places[d].append((r,c))
66+
for d in range(1,10):
67+
locs = places[d]
68+
if len(locs) == 1:
69+
r, c = locs[0]
70+
grid[r][c] = d
71+
return {"type": "place", "strategy": "hidden_single", "unit": unit_kind, "unit_index": unit_idx, "r": r, "c": c, "v": d}
72+
return None
73+
74+
def apply_hidden_single(grid: Grid, cand: Cand) -> Optional[Dict]:
75+
# rows
76+
for r in range(9):
77+
move = _hidden_single_in_unit(grid, cand, _unit_cells_row(r), "row", r)
78+
if move: return move
79+
# cols
80+
for c in range(9):
81+
move = _hidden_single_in_unit(grid, cand, _unit_cells_col(c), "col", c)
82+
if move: return move
83+
# boxes
84+
for b in range(9):
85+
move = _hidden_single_in_unit(grid, cand, _unit_cells_box(b), "box", b)
86+
if move: return move
87+
return None
88+
89+
def apply_locked_candidates_pointing(grid: Grid, cand: Cand) -> Optional[Dict]:
90+
"""
91+
Pointing: in a 3x3 box, if all candidates for digit d lie in the same row (or same col),
92+
eliminate d from that row (or col) outside the box.
93+
Produces one elimination at a time for deterministic playback.
94+
"""
95+
# For each box and digit, gather positions
96+
for b in range(9):
97+
cells = _unit_cells_box(b)
98+
for d in range(1,10):
99+
locs = [(r,c) for (r,c) in cells if grid[r][c] == 0 and d in cand[r][c]]
100+
if len(locs) < 2:
101+
continue
102+
rows = {r for (r,_) in locs}
103+
cols = {c for (_,c) in locs}
104+
if len(rows) == 1:
105+
r = next(iter(rows))
106+
# eliminate from row r, columns not in this box
107+
box_cols = {c for (_,c) in cells}
108+
for c in range(9):
109+
if (r,c) not in locs and c not in box_cols and grid[r][c] == 0 and d in cand[r][c]:
110+
cand[r][c].remove(d)
111+
return {"type": "eliminate", "strategy": "locked_pointing_row", "box": b, "r": r, "c": c, "v": d}
112+
if len(cols) == 1:
113+
c = next(iter(cols))
114+
box_rows = {r for (r,_) in cells}
115+
for r in range(9):
116+
if (r,c) not in locs and r not in box_rows and grid[r][c] == 0 and d in cand[r][c]:
117+
cand[r][c].remove(d)
118+
return {"type": "eliminate", "strategy": "locked_pointing_col", "box": b, "r": r, "c": c, "v": d}
119+
return None
120+
121+
def step_once(grid: Grid) -> Optional[Dict]:
122+
"""
123+
Apply exactly one logical step (prioritized order). Returns a move dict or None.
124+
Priority: naked_single > hidden_single > locked_pointing (elimination)
125+
"""
126+
cand = candidates(grid)
127+
m = apply_naked_single(grid, cand)
128+
if m: return m
129+
m = apply_hidden_single(grid, cand)
130+
if m: return m
131+
m = apply_locked_candidates_pointing(grid, cand)
132+
return m
133+
134+
__all__ = [
135+
"candidates",
136+
"apply_naked_single",
137+
"apply_hidden_single",
138+
"apply_locked_candidates_pointing",
139+
"step_once",
140+
]

tests/test_explain.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from textwrap import dedent
2+
import json
3+
from sudoku_dlx import from_string, explain, solve
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+
21+
def _apply_steps(grid, steps):
22+
g = [row[:] for row in grid]
23+
for st in steps:
24+
if st["type"] == "place":
25+
g[st["r"]][st["c"]] = st["v"]
26+
# eliminations already reflected by our stepper; ignored in reconstruction
27+
return g
28+
29+
30+
def test_explain_api_makes_progress_and_is_deterministic():
31+
g = from_string(PUZ)
32+
out1 = explain(g, max_steps=200)
33+
out2 = explain(g, max_steps=200)
34+
assert out1["steps"] == out2["steps"]
35+
# steps should not be empty for this classic puzzle
36+
assert len(out1["steps"]) > 0
37+
# applying placements should move towards solution
38+
g2 = _apply_steps(g, out1["steps"])
39+
res = solve(g)
40+
res2 = solve(g2)
41+
assert res is not None and res2 is not None
42+
# filled clues after steps should be >= initial
43+
filled0 = sum(1 for r in range(9) for c in range(9) if g[r][c] != 0)
44+
filled1 = sum(1 for r in range(9) for c in range(9) if g2[r][c] != 0)
45+
assert filled1 >= filled0
46+
47+
48+
def test_cli_explain_json(capsys):
49+
rc = cli.main(["explain", "--grid", PUZ, "--json", "--max-steps", "120"])
50+
assert rc == 0
51+
data = json.loads(capsys.readouterr().out.strip())
52+
assert "steps" in data and isinstance(data["steps"], list)
53+
assert "progress" in data and len(data["progress"]) == 81

0 commit comments

Comments
 (0)