Coverage for src / hodoku / api.py: 97%

158 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-21 08:35 +0000

1"""Public API — the only import most users need.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import TYPE_CHECKING, Literal 

7 

8from hodoku.core.grid import ALL_UNITS, Grid 

9from hodoku.core.solution_step import SolutionStep 

10from hodoku.core.types import DifficultyType, SolutionCategory, SolutionType 

11from hodoku.generator.generator import SudokuGenerator 

12from hodoku.generator.pattern import GeneratorPattern 

13from hodoku.solver.solver import SudokuSolver 

14from hodoku.solver.step_finder import SudokuStepFinder 

15 

16if TYPE_CHECKING: 

17 from hodoku.config import SolverConfig 

18 

19_VALID_CELL_CHARS = frozenset("0123456789.") 

20 

21_FISH_CATEGORIES = frozenset({ 

22 SolutionCategory.BASIC_FISH, 

23 SolutionCategory.FINNED_BASIC_FISH, 

24 SolutionCategory.FRANKEN_FISH, 

25 SolutionCategory.FINNED_FRANKEN_FISH, 

26 SolutionCategory.MUTANT_FISH, 

27 SolutionCategory.FINNED_MUTANT_FISH, 

28}) 

29 

30# Category → FishType level required 

31_FISH_CAT_LEVEL = { 

32 SolutionCategory.BASIC_FISH: 0, 

33 SolutionCategory.FINNED_BASIC_FISH: 0, 

34 SolutionCategory.FRANKEN_FISH: 1, 

35 SolutionCategory.FINNED_FRANKEN_FISH: 1, 

36 SolutionCategory.MUTANT_FISH: 2, 

37 SolutionCategory.FINNED_MUTANT_FISH: 2, 

38} 

39 

40# Fish SolutionType → size (number of base sets). 

41_FISH_NAMES_BY_SIZE = [ 

42 "X_WING", "SWORDFISH", "JELLYFISH", "SQUIRMBAG", "WHALE", "LEVIATHAN", 

43] 

44_FISH_PREFIXES = [ 

45 "", "FINNED_", "SASHIMI_", 

46 "FRANKEN_", "FINNED_FRANKEN_", 

47 "MUTANT_", "FINNED_MUTANT_", 

48] 

49_FISH_TYPE_SIZE: dict[SolutionType, int] = {} 

50for _i, _name in enumerate(_FISH_NAMES_BY_SIZE): 

51 for _prefix in _FISH_PREFIXES: 

52 _st = getattr(SolutionType, _prefix + _name, None) 

53 if _st is not None: 

54 _FISH_TYPE_SIZE[_st] = _i + 2 

55 

56# Primary type → alias subtypes that share the same StepConfig. 

57# find_all must dispatch these separately to find all variants. 

58_FIND_ALL_ALIASES: dict[SolutionType, list[SolutionType]] = { 

59 SolutionType.SIMPLE_COLORS_TRAP: [SolutionType.SIMPLE_COLORS_WRAP], 

60 SolutionType.MULTI_COLORS_1: [SolutionType.MULTI_COLORS_2], 

61 SolutionType.CONTINUOUS_NICE_LOOP: [ 

62 SolutionType.DISCONTINUOUS_NICE_LOOP, SolutionType.AIC, 

63 ], 

64 SolutionType.GROUPED_CONTINUOUS_NICE_LOOP: [ 

65 SolutionType.GROUPED_DISCONTINUOUS_NICE_LOOP, SolutionType.GROUPED_AIC, 

66 ], 

67 SolutionType.FORCING_CHAIN_CONTRADICTION: [SolutionType.FORCING_CHAIN_VERITY], 

68 SolutionType.FORCING_NET_CONTRADICTION: [SolutionType.FORCING_NET_VERITY], 

69 SolutionType.KRAKEN_FISH_TYPE_1: [SolutionType.KRAKEN_FISH_TYPE_2], 

70 SolutionType.TWO_STRING_KITE: [SolutionType.DUAL_TWO_STRING_KITE], 

71 SolutionType.EMPTY_RECTANGLE: [SolutionType.DUAL_EMPTY_RECTANGLE], 

72} 

73 

74 

75def _validate_puzzle(puzzle: str) -> None: 

76 """Raise ValueError for malformed puzzle strings. 

77 

78 Accepts the same format as Grid.set_sudoku: 81 characters, each '0'-'9' 

79 or '.'. Also accepts the '+D' placed-cell prefix notation. Raises on 

80 invalid characters, wrong cell count, or duplicate digits in any house. 

81 """ 

82 cells: list[str] = [] 

83 i = 0 

84 while i < len(puzzle): 

85 ch = puzzle[i] 

86 if ch == "+": 

87 i += 1 

88 if i < len(puzzle): 

89 ch = puzzle[i] 

90 if ch not in _VALID_CELL_CHARS: 

91 raise ValueError(f"Invalid character after '+': {ch!r}") 

92 cells.append(ch) 

93 elif ch in _VALID_CELL_CHARS: 

94 cells.append(ch) 

95 else: 

96 raise ValueError(f"Invalid character in puzzle string: {ch!r}") 

97 i += 1 

98 

99 if len(cells) != 81: 

100 raise ValueError(f"Puzzle must have exactly 81 cells, got {len(cells)}") 

101 

102 # Check for duplicate digits within each house (row, col, box) 

103 for unit in ALL_UNITS: 

104 seen: dict[str, int] = {} 

105 for idx in unit: 

106 d = cells[idx] 

107 if d in ("0", "."): 

108 continue 

109 if d in seen: 

110 raise ValueError( 

111 f"Duplicate digit {d} in house (cells {seen[d]} and {idx})" 

112 ) 

113 seen[d] = idx 

114 

115 

116@dataclass 

117class SolveResult: 

118 solved: bool 

119 steps: list[SolutionStep] 

120 level: DifficultyType 

121 score: int 

122 solution: str # 81-char string 

123 

124 

125@dataclass 

126class RatingResult: 

127 level: DifficultyType 

128 score: int 

129 

130 

131class Solver: 

132 """Solves and analyses sudoku puzzles using human-style logic.""" 

133 

134 def __init__(self, config: SolverConfig | None = None) -> None: 

135 if config is None: 

136 from hodoku.config import DEFAULT_CONFIG 

137 config = DEFAULT_CONFIG 

138 self._config = config 

139 self._solver = SudokuSolver(config) 

140 

141 def solve(self, puzzle: str) -> SolveResult: 

142 """Solve a puzzle string, returning the full solution path.""" 

143 _validate_puzzle(puzzle) 

144 result = self._solver.solve(puzzle) 

145 return SolveResult( 

146 solved=result.solved, 

147 steps=result.steps, 

148 level=result.level, 

149 score=result.score, 

150 solution=result.solution, 

151 ) 

152 

153 def get_hint(self, puzzle: str) -> SolutionStep | None: 

154 """Return the next logical step, or None if already solved or stuck.""" 

155 _validate_puzzle(puzzle) 

156 grid = Grid() 

157 grid.set_sudoku(puzzle) 

158 if grid.is_solved(): 

159 return None 

160 finder = SudokuStepFinder(grid, self._config.solve_search) 

161 for cfg in self._config.solver_steps: 

162 step = finder.get_step(cfg.solution_type) 

163 if step is not None: 

164 return step 

165 return None 

166 

167 def rate(self, puzzle: str) -> RatingResult: 

168 """Return difficulty level and score without recording solution steps.""" 

169 _validate_puzzle(puzzle) 

170 result = self._solver.solve(puzzle) 

171 return RatingResult(level=result.level, score=result.score) 

172 

173 def find_all_steps(self, puzzle: str) -> list[SolutionStep]: 

174 """Return every applicable technique at the current grid state.""" 

175 _validate_puzzle(puzzle) 

176 grid = Grid() 

177 grid.set_sudoku(puzzle) 

178 return self._find_all_on_grid(grid) 

179 

180 def _find_all_on_grid(self, grid: Grid) -> list[SolutionStep]: 

181 """Return every applicable technique on a pre-built grid.""" 

182 find_cfg = self._config.find_all_search 

183 finder = SudokuStepFinder(grid, find_cfg) 

184 disabled = find_cfg.disabled_types 

185 fish_cfg = find_cfg.fish 

186 fish_type_level = fish_cfg.fish_type # max fish category level 

187 steps: list[SolutionStep] = [] 

188 for cfg in self._config.all_steps: 

189 sol_type = cfg.solution_type 

190 if sol_type in disabled: 

191 continue 

192 if cfg.category in _FISH_CATEGORIES: 

193 # Fish gated by search_fish + fish_type + max_size 

194 if not fish_cfg.search_fish: 

195 continue 

196 if _FISH_CAT_LEVEL[cfg.category] > fish_type_level: 

197 continue 

198 size = _FISH_TYPE_SIZE.get(sol_type, 99) 

199 if size < fish_cfg.min_size or size > fish_cfg.max_size: 

200 continue 

201 elif not cfg.all_steps_enabled: 

202 continue 

203 steps.extend(finder.find_all(sol_type)) 

204 for alias in _FIND_ALL_ALIASES.get(sol_type, ()): 

205 if alias not in disabled: 

206 steps.extend(finder.find_all(alias)) 

207 return steps 

208 

209 

210class Generator: 

211 """Generates valid sudoku puzzles at a requested difficulty level. 

212 

213 Mirrors Java's ``BackgroundGenerator``: repeatedly generates random 

214 symmetric puzzles and rates them until one matches the target difficulty. 

215 """ 

216 

217 MAX_TRIES = 20_000 

218 

219 def __init__(self, config: SolverConfig | None = None) -> None: 

220 if config is None: 

221 from hodoku.config import DEFAULT_CONFIG 

222 config = DEFAULT_CONFIG 

223 self._config = config 

224 self._generator = SudokuGenerator() 

225 self._solver = SudokuSolver(config) 

226 

227 def generate( 

228 self, 

229 difficulty: DifficultyType = DifficultyType.MEDIUM, 

230 symmetric: bool = True, 

231 pattern: list[int] | GeneratorPattern | None = None, 

232 max_tries: int | None = None, 

233 ) -> str: 

234 """Generate an 81-character puzzle string at the requested difficulty. 

235 

236 Raises ``RuntimeError`` if no puzzle matching the difficulty is found 

237 within *max_tries* attempts (default ``MAX_TRIES``). 

238 """ 

239 if max_tries is None: 

240 max_tries = self.MAX_TRIES 

241 

242 bool_pattern: list[bool] | None = None 

243 if isinstance(pattern, GeneratorPattern): 

244 bool_pattern = pattern.pattern 

245 elif pattern is not None: 

246 bool_pattern = [bool(p) for p in pattern] 

247 

248 for _ in range(max_tries): 

249 puzzle = self._generator.generate_sudoku( 

250 symmetric=symmetric, pattern=bool_pattern, 

251 ) 

252 if puzzle is None: 

253 # Pattern-based generation failed entirely 

254 raise RuntimeError( 

255 "Could not generate a puzzle with the given pattern" 

256 ) 

257 

258 # Rate the puzzle 

259 result = self._solver.solve(puzzle) 

260 if not result.solved or result.level != difficulty: 

261 continue 

262 

263 # Reject if score is below the previous level's max_score 

264 # (mirrors Java's rejectTooLowScore logic) 

265 if difficulty.value > DifficultyType.EASY.value: 

266 prev_level = DifficultyType(difficulty.value - 1) 

267 if result.score < self._config._difficulty_max_score[prev_level]: 

268 continue 

269 

270 return puzzle 

271 

272 raise RuntimeError( 

273 f"Could not generate a {difficulty.name} puzzle in " 

274 f"{max_tries} attempts" 

275 ) 

276 

277 def validate(self, puzzle: str) -> Literal["valid", "invalid", "multiple"]: 

278 """Check whether a puzzle has exactly one solution. 

279 

280 Returns ``"valid"`` (unique), ``"invalid"`` (no solution), 

281 or ``"multiple"`` (more than one solution). 

282 """ 

283 _validate_puzzle(puzzle) 

284 grid = Grid() 

285 grid.set_sudoku(puzzle) 

286 count = self._generator.get_number_of_solutions(grid) 

287 if count == 0: 

288 return "invalid" 

289 elif count == 1: 

290 return "valid" 

291 else: 

292 return "multiple"