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
« 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."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import TYPE_CHECKING, Literal
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
16if TYPE_CHECKING:
17 from hodoku.config import SolverConfig
19_VALID_CELL_CHARS = frozenset("0123456789.")
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})
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}
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
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}
75def _validate_puzzle(puzzle: str) -> None:
76 """Raise ValueError for malformed puzzle strings.
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
99 if len(cells) != 81:
100 raise ValueError(f"Puzzle must have exactly 81 cells, got {len(cells)}")
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
116@dataclass
117class SolveResult:
118 solved: bool
119 steps: list[SolutionStep]
120 level: DifficultyType
121 score: int
122 solution: str # 81-char string
125@dataclass
126class RatingResult:
127 level: DifficultyType
128 score: int
131class Solver:
132 """Solves and analyses sudoku puzzles using human-style logic."""
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)
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 )
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
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)
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)
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
210class Generator:
211 """Generates valid sudoku puzzles at a requested difficulty level.
213 Mirrors Java's ``BackgroundGenerator``: repeatedly generates random
214 symmetric puzzles and rates them until one matches the target difficulty.
215 """
217 MAX_TRIES = 20_000
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)
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.
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
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]
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 )
258 # Rate the puzzle
259 result = self._solver.solve(puzzle)
260 if not result.solved or result.level != difficulty:
261 continue
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
270 return puzzle
272 raise RuntimeError(
273 f"Could not generate a {difficulty.name} puzzle in "
274 f"{max_tries} attempts"
275 )
277 def validate(self, puzzle: str) -> Literal["valid", "invalid", "multiple"]:
278 """Check whether a puzzle has exactly one solution.
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"