11from __future__ import annotations
22
33import random
4- from typing import Optional
4+ from typing import Optional , Union
55
66from .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:
100104def _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
120150def 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 ]
0 commit comments