Coverage for src / hodoku / solver / solver.py: 100%
66 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"""SudokuSolver — main solve loop with scoring and difficulty rating.
3Mirrors Java's SudokuSolver.solve(). Iterates SOLVER_STEPS in priority order,
4tries each enabled technique in turn, applies the first step found, and repeats
5until the puzzle is solved or no progress can be made.
6"""
8from __future__ import annotations
10from dataclasses import dataclass, field
11from typing import TYPE_CHECKING
13from hodoku.core.grid import Grid
14from hodoku.core.solution_step import SolutionStep
15from hodoku.core.types import DifficultyType, SolutionType
16from hodoku.solver.step_finder import SudokuStepFinder
18if TYPE_CHECKING:
19 from hodoku.config import SolverConfig
22@dataclass
23class SolveResult:
24 """Result of a solve() call."""
25 puzzle: str
26 steps: list[SolutionStep] = field(default_factory=list)
27 level: DifficultyType = DifficultyType.INCOMPLETE
28 score: int = 0
29 solved: bool = False
30 solution: str = "" # 81-char grid after all steps applied
33_PLACEMENT_TYPES: frozenset[SolutionType] = frozenset({
34 SolutionType.FULL_HOUSE,
35 SolutionType.NAKED_SINGLE,
36 SolutionType.HIDDEN_SINGLE,
37 SolutionType.BRUTE_FORCE,
38 # Forcing chains/nets can set values via contradiction/verity
39 SolutionType.FORCING_CHAIN_CONTRADICTION,
40 SolutionType.FORCING_CHAIN_VERITY,
41 SolutionType.FORCING_NET_CONTRADICTION,
42 SolutionType.FORCING_NET_VERITY,
43})
46def _apply_step(grid: Grid, step: SolutionStep) -> None:
47 """Apply a solution step to the grid.
49 For placement steps (singles), set_cell is called for each (index, value).
50 For elimination steps (LC, subsets, …), only candidates_to_delete is applied.
51 In both cases, step.indices on non-placement steps is pattern context only.
52 """
53 if step.type in _PLACEMENT_TYPES:
54 for idx, val in zip(step.indices, step.values):
55 grid.set_cell(idx, val)
56 for cand in step.candidates_to_delete:
57 grid.del_candidate(cand.index, cand.value)
60class SudokuSolver:
61 """Iterative solver that mirrors the HoDoKu solve loop."""
63 def __init__(self, config: SolverConfig | None = None) -> None:
64 if config is None:
65 from hodoku.config import DEFAULT_CONFIG
66 config = DEFAULT_CONFIG
67 self._config = config
69 def solve(self, puzzle: str) -> SolveResult:
70 """Solve *puzzle* and return a SolveResult with steps, level, and score."""
71 grid = Grid()
72 grid.set_sudoku(puzzle)
74 result = SolveResult(puzzle=grid.get_sudoku_string())
75 finder = SudokuStepFinder(grid, self._config.solve_search)
77 step_config = self._config.step_config
78 difficulty_max = self._config._difficulty_max_score
79 solver_steps = self._config.solver_steps
81 max_level = DifficultyType.INCOMPLETE
82 total_score = 0
84 while not grid.is_solved():
85 step = self._find_next_step(finder, solver_steps)
86 if step is None:
87 # No technique could make progress
88 break
89 _apply_step(grid, step)
90 result.steps.append(step)
92 cfg = step_config.get(step.type)
93 if cfg is not None:
94 total_score += cfg.base_score
95 if cfg.level.value > max_level.value:
96 max_level = cfg.level
98 result.solved = grid.is_solved()
99 result.score = total_score
100 if result.solved:
101 # Mirror Java's post-solve score-threshold bump:
102 # while (score > level.getMaxScore()) level = nextLevel
103 level = max_level
104 while difficulty_max.get(level, 2**31 - 1) < total_score:
105 level = DifficultyType(level.value + 1)
106 result.level = level
107 else:
108 result.level = DifficultyType.INCOMPLETE
109 result.solution = grid.get_sudoku_string()
110 return result
112 @staticmethod
113 def _find_next_step(finder: SudokuStepFinder, solver_steps) -> SolutionStep | None:
114 """Try each enabled technique in priority order; return first hit."""
115 for cfg in solver_steps:
116 step = finder.get_step(cfg.solution_type)
117 if step is not None:
118 return step
119 return None