Coverage for src / hodoku / config.py: 100%
128 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"""SolverConfig — unified configuration for the solver and find-all-steps paths.
3Replaces scattered module-level constants with a single config object that can
4be passed to Solver(). Default values reproduce the current hardcoded behaviour
5exactly, so ``SolverConfig()`` is a drop-in replacement.
6"""
8from __future__ import annotations
10import enum
11from dataclasses import dataclass, field
12from functools import cached_property
13from typing import TYPE_CHECKING
15from hodoku.core.types import DifficultyType, SolutionType
17if TYPE_CHECKING:
18 from hodoku.core.scoring import StepConfig
21# ---------------------------------------------------------------------------
22# FishType enum
23# ---------------------------------------------------------------------------
25class FishType(enum.IntEnum):
26 BASIC = 0
27 BASIC_FRANKEN = 1
28 BASIC_FRANKEN_MUTANT = 2
31# ---------------------------------------------------------------------------
32# Fish search configs
33# ---------------------------------------------------------------------------
35ALL_CANDIDATES: int = 0x1FF # bits 0-8 set = digits 1-9
38def make_candidates(digits: list[int]) -> int:
39 """Build a 9-bit candidate mask from a list of digits 1-9."""
40 return sum(1 << (d - 1) for d in digits)
43@dataclass(frozen=True)
44class FishSearchConfig:
45 search_fish: bool = True
46 fish_type: FishType = FishType.BASIC_FRANKEN
47 min_size: int = 2
48 max_size: int = 4
49 max_fins: int = 5
50 max_endo_fins: int = 2
51 check_templates: bool = True
52 only_one_per_elimination: bool = True
53 candidates: int = ALL_CANDIDATES
56@dataclass(frozen=True)
57class KrakenFishSearchConfig:
58 search_kraken_fish: bool = False
59 fish_type: FishType = FishType.BASIC_FRANKEN
60 min_size: int = 2
61 max_size: int = 4
62 max_fins: int = 2
63 max_endo_fins: int = 0
64 candidates: int = ALL_CANDIDATES
67# ---------------------------------------------------------------------------
68# StepSearchConfig
69# ---------------------------------------------------------------------------
71# Default disabled types for find-all (last-resort techniques unchecked in HoDoKu UI)
72_FIND_ALL_DISABLED: frozenset[SolutionType] = frozenset({
73 SolutionType.KRAKEN_FISH_TYPE_1,
74 SolutionType.KRAKEN_FISH_TYPE_2,
75 SolutionType.FORCING_CHAIN_CONTRADICTION,
76 SolutionType.FORCING_CHAIN_VERITY,
77 SolutionType.FORCING_NET_CONTRADICTION,
78 SolutionType.FORCING_NET_VERITY,
79 SolutionType.TEMPLATE_SET,
80 SolutionType.TEMPLATE_DEL,
81})
84@dataclass(frozen=True)
85class StepSearchConfig:
86 fish: FishSearchConfig = field(default_factory=FishSearchConfig)
87 kraken_fish: KrakenFishSearchConfig = field(default_factory=KrakenFishSearchConfig)
88 chain_max_length: int = 20
89 nice_loop_max_length: int = 10
90 chain_restrict_length: bool = True
91 tabling_entry_size: int = 1000
92 tabling_net_depth: int = 4
93 tabling_only_one_chain_per_elimination: bool = True
94 tabling_allow_als_in_chains: bool = False
95 als_allow_overlap: bool = False
96 als_only_one_per_elimination: bool = True
97 als_chain_length: int = 6
98 als_chain_forward_only: bool = True
99 allow_ers_with_two_candidates: bool = False
100 allow_duals_and_siamese: bool = False
101 allow_missing_candidates_in_urs: bool = False
102 disabled_types: frozenset[SolutionType] = frozenset()
105def _default_solve_search() -> StepSearchConfig:
106 """Solve-path defaults — matches current hardcoded behaviour."""
107 return StepSearchConfig()
110def _default_find_all_search() -> StepSearchConfig:
111 """Find-all-steps defaults — more permissive than solve."""
112 return StepSearchConfig(
113 tabling_allow_als_in_chains=True,
114 tabling_only_one_chain_per_elimination=False,
115 als_allow_overlap=True,
116 als_only_one_per_elimination=False,
117 disabled_types=_FIND_ALL_DISABLED,
118 )
121# ---------------------------------------------------------------------------
122# SolverConfig
123# ---------------------------------------------------------------------------
125@dataclass(frozen=True)
126class SolverConfig:
127 solve_search: StepSearchConfig = field(default_factory=_default_solve_search)
128 find_all_search: StepSearchConfig = field(default_factory=_default_find_all_search)
129 step_overrides: dict[SolutionType, dict] = field(default_factory=dict)
130 difficulty_thresholds: dict[DifficultyType, int] = field(default_factory=dict)
132 @cached_property
133 def solver_steps(self) -> tuple[StepConfig, ...]:
134 """Enabled steps sorted by index — mirrors module-level SOLVER_STEPS."""
135 from hodoku.core.scoring import DEFAULT_STEPS, StepConfig as SC
137 if not self.step_overrides:
138 from hodoku.core.scoring import SOLVER_STEPS
139 return SOLVER_STEPS
141 steps: list[SC] = []
142 for s in DEFAULT_STEPS:
143 overrides = self.step_overrides.get(s.solution_type)
144 if overrides:
145 vals = {f: getattr(s, f) for f in s.__dataclass_fields__}
146 vals.update(overrides)
147 s = SC(**vals)
148 steps.append(s)
149 return tuple(sorted((s for s in steps if s.enabled), key=lambda s: s.index))
151 @cached_property
152 def all_steps(self) -> tuple[StepConfig, ...]:
153 """All steps sorted by index (for find-all iteration)."""
154 from hodoku.core.scoring import DEFAULT_STEPS, StepConfig as SC
156 if not self.step_overrides:
157 return tuple(sorted(DEFAULT_STEPS, key=lambda s: s.index))
159 steps: list[SC] = []
160 for s in DEFAULT_STEPS:
161 overrides = self.step_overrides.get(s.solution_type)
162 if overrides:
163 vals = {f: getattr(s, f) for f in s.__dataclass_fields__}
164 vals.update(overrides)
165 s = SC(**vals)
166 steps.append(s)
167 return tuple(sorted(steps, key=lambda s: s.index))
169 @cached_property
170 def step_config(self) -> dict[SolutionType, StepConfig]:
171 """Fast lookup by SolutionType, including aliases."""
172 from hodoku.core.scoring import STEP_CONFIG as _BASE
174 if not self.step_overrides:
175 return dict(_BASE)
177 from hodoku.core.scoring import DEFAULT_STEPS, StepConfig as SC
179 result: dict[SolutionType, SC] = {}
180 for s in DEFAULT_STEPS:
181 overrides = self.step_overrides.get(s.solution_type)
182 if overrides:
183 vals = {f: getattr(s, f) for f in s.__dataclass_fields__}
184 vals.update(overrides)
185 s = SC(**vals)
186 result[s.solution_type] = s
188 # Aliases (same as scoring.py)
189 _S = SolutionType
190 result[_S.SIMPLE_COLORS_WRAP] = result[_S.SIMPLE_COLORS_TRAP]
191 result[_S.MULTI_COLORS_2] = result[_S.MULTI_COLORS_1]
192 result[_S.DISCONTINUOUS_NICE_LOOP] = result[_S.CONTINUOUS_NICE_LOOP]
193 result[_S.AIC] = result[_S.CONTINUOUS_NICE_LOOP]
194 result[_S.GROUPED_DISCONTINUOUS_NICE_LOOP] = result[_S.GROUPED_CONTINUOUS_NICE_LOOP]
195 result[_S.GROUPED_AIC] = result[_S.GROUPED_CONTINUOUS_NICE_LOOP]
196 result[_S.FORCING_CHAIN_VERITY] = result[_S.FORCING_CHAIN_CONTRADICTION]
197 result[_S.FORCING_NET_VERITY] = result[_S.FORCING_NET_CONTRADICTION]
198 result[_S.KRAKEN_FISH_TYPE_2] = result[_S.KRAKEN_FISH_TYPE_1]
199 result[_S.DUAL_TWO_STRING_KITE] = result[_S.TWO_STRING_KITE]
200 result[_S.DUAL_EMPTY_RECTANGLE] = result[_S.EMPTY_RECTANGLE]
201 return result
203 @cached_property
204 def _difficulty_max_score(self) -> dict[DifficultyType, int]:
205 """Difficulty thresholds with overrides applied."""
206 from hodoku.core.scoring import DIFFICULTY_MAX_SCORE
207 if not self.difficulty_thresholds:
208 return dict(DIFFICULTY_MAX_SCORE)
209 result = dict(DIFFICULTY_MAX_SCORE)
210 result.update(self.difficulty_thresholds)
211 return result
214# Module-level default instance — shared across Solver(), SudokuSolver(),
215# Generator() when no config is provided. Frozen + cached_property means
216# derived tables (solver_steps, step_config) are computed once.
217DEFAULT_CONFIG = SolverConfig()