Skip to content

Commit fc812ab

Browse files
Ensure strict minimal puzzles with symmetry support
1 parent 24e6a50 commit fc812ab

File tree

3 files changed

+72
-32
lines changed

3 files changed

+72
-32
lines changed

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,19 @@ sudoku-dlx stats-file --in puzzles.txt --json stats.json --csv diff_hist.csv
7070
# }
7171

7272
# Advanced generator flags:
73+
# Minimal & symmetry (slower; strict guarantee)
7374
sudoku-dlx gen --seed 123 --givens 28 --minimal
74-
sudoku-dlx gen --seed 123 --givens 28 --symmetry rot180
75+
sudoku-dlx gen --seed 123 --givens 28 --minimal --symmetry rot180
7576
```
7677

78+
What this gives you
79+
80+
Strict minimality: after generation, removing any single clue breaks uniqueness.
81+
82+
Symmetry enforcement: rot180 removals are paired; mix keeps pairs adjacent but allows singles too.
83+
84+
CI-safe tests: fast settings, strong assertions.
85+
7786
## Library (typed API)
7887

7988
```python

src/sudoku_dlx/generate.py

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import random
4-
from typing import Optional
4+
from typing import Optional, Union
55

66
from .api import Grid, count_solutions, is_valid, solve
77

@@ -45,19 +45,23 @@ def _rot180(r: int, c: int) -> tuple[int, int]:
4545
return 8 - r, 8 - c
4646

4747

48-
def _removal_schedule(
49-
symmetry: Symmetry, rng: random.Random
50-
) -> list[tuple[int, int] | tuple[tuple[int, int], tuple[int, int]]]:
51-
"""Return a shuffled list of single cells or symmetric pairs to try removing."""
48+
PairOrCell = Union[tuple[int, int], tuple[tuple[int, int], tuple[int, int]]]
49+
50+
51+
def _removal_schedule(symmetry: Symmetry, rng: random.Random) -> list[PairOrCell]:
52+
"""
53+
Return a shuffled list of single cells or symmetric pairs to try
54+
removing, preserving adjacency for paired removals.
55+
"""
5256

5357
cells = [(r, c) for r in range(9) for c in range(9)]
5458
rng.shuffle(cells)
5559
if symmetry == "none":
56-
return cells # type: ignore[return-value]
60+
return cells # singles only
5761

5862
# rot180 or mix → group into pairs; center maps to itself
59-
seen: set[tuple[int, int]] = set()
60-
pairs: list[tuple[int, int] | tuple[tuple[int, int], tuple[int, int]]] = []
63+
seen: set[tuple[int,int]] = set()
64+
pairs: list[PairOrCell] = []
6165
for (r, c) in cells:
6266
if (r, c) in seen:
6367
continue
@@ -71,10 +75,10 @@ def _removal_schedule(
7175

7276
rng.shuffle(pairs)
7377
if symmetry == "rot180":
74-
return pairs # type: ignore[return-value]
78+
return pairs
7579

7680
# mix → flatten but keep pairs adjacent; mix of singles/pairs
77-
flat: list[tuple[int, int] | tuple[tuple[int, int], tuple[int, int]]] = []
81+
flat: list[PairOrCell] = []
7882
for item in pairs:
7983
flat.append(item)
8084
return flat
@@ -100,21 +104,47 @@ def _try_remove(p: Grid, r: int, c: int) -> bool:
100104
def _make_minimal(p: Grid) -> Grid:
101105
"""Enforce minimality: every clue is necessary for uniqueness."""
102106

107+
# Strict single-clue minimality:
108+
# keep removing clues as long as uniqueness still holds.
109+
# Order clues by a light heuristic: remove from denser rows/cols first.
110+
def clue_list(grid: Grid) -> list[tuple[int, int]]:
111+
clues: list[tuple[int,int]] = []
112+
for r in range(9):
113+
for c in range(9):
114+
if grid[r][c] != 0:
115+
clues.append((r,c))
116+
# heuristic: denser row/col first
117+
row_count = [sum(1 for x in grid[r] if x != 0) for r in range(9)]
118+
col_count = [sum(1 for r in range(9) if grid[r][c] != 0) for c in range(9)]
119+
clues.sort(key=lambda rc: -(row_count[rc[0]] + col_count[rc[1]]))
120+
return clues
121+
103122
changed = True
104123
while changed:
105124
changed = False
106-
for r in range(9):
107-
for c in range(9):
108-
if p[r][c] == 0:
109-
continue
110-
keep = p[r][c]
125+
for r, c in clue_list(p):
126+
keep = p[r][c]
127+
p[r][c] = 0
128+
if _uniqueness(p):
129+
# removal kept; continue loop to see if we can remove more
130+
changed = True
131+
else:
132+
p[r][c] = keep
133+
# Verify strict minimality: removing any single clue breaks uniqueness.
134+
for r in range(9):
135+
for c in range(9):
136+
if p[r][c] == 0:
137+
continue
138+
keep = p[r][c]
139+
p[r][c] = 0
140+
still_unique = _uniqueness(p)
141+
p[r][c] = keep
142+
if still_unique:
143+
# Extremely rare due to ordering/race; harden by removing it and re-running once.
111144
p[r][c] = 0
112-
if _uniqueness(p):
113-
changed = True
114-
# keep removed
115-
else:
116-
p[r][c] = keep
117-
return p
145+
# Re-run a short pass to clean up any others unlocked by this removal.
146+
return _make_minimal(p)
147+
return p # strict
118148

119149

120150
def generate(
@@ -144,11 +174,8 @@ def remaining_clues() -> int:
144174
if remaining_clues() <= target_givens:
145175
break
146176

147-
if (
148-
isinstance(item, tuple)
149-
and len(item) == 2
150-
and isinstance(item[0], tuple)
151-
):
177+
# If symmetry is rot180, paired removals must succeed together.
178+
if isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], tuple):
152179
# symmetric pair (rot180)
153180
(r1, c1), (r2, c2) = item # type: ignore[misc]
154181
keep1, keep2 = puzzle[r1][c1], puzzle[r2][c2]

tests/test_generate_minimal_symmetry.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
from sudoku_dlx import generate, is_valid, solve
1+
from sudoku_dlx import generate, is_valid, solve, count_solutions
22

33

44
def _filled(grid) -> int:
55
return sum(1 for r in range(9) for c in range(9) if grid[r][c] != 0)
66

77

8-
def _is_minimal(grid) -> bool:
9-
from sudoku_dlx import count_solutions
10-
8+
def _is_minimal_strict(grid) -> bool:
9+
# removing any single clue must break uniqueness
1110
for r in range(9):
1211
for c in range(9):
1312
if grid[r][c] == 0:
@@ -36,6 +35,11 @@ def test_gen_rot180_symmetry_unique_and_valid():
3635
def test_gen_minimal_flag_enforces_minimality():
3736
puzzle = generate(seed=11, target_givens=36, minimal=True, symmetry="none")
3837
assert is_valid(puzzle)
39-
assert _is_minimal(puzzle)
38+
assert _is_minimal_strict(puzzle)
4039
result = solve(puzzle)
4140
assert result is not None
41+
42+
43+
def test_minimal_puzzles_are_unique():
44+
puzzle = generate(seed=21, target_givens=35, minimal=True, symmetry="mix")
45+
assert count_solutions(puzzle, limit=2) == 1

0 commit comments

Comments
 (0)