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

1"""SolverConfig — unified configuration for the solver and find-all-steps paths. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import enum 

11from dataclasses import dataclass, field 

12from functools import cached_property 

13from typing import TYPE_CHECKING 

14 

15from hodoku.core.types import DifficultyType, SolutionType 

16 

17if TYPE_CHECKING: 

18 from hodoku.core.scoring import StepConfig 

19 

20 

21# --------------------------------------------------------------------------- 

22# FishType enum 

23# --------------------------------------------------------------------------- 

24 

25class FishType(enum.IntEnum): 

26 BASIC = 0 

27 BASIC_FRANKEN = 1 

28 BASIC_FRANKEN_MUTANT = 2 

29 

30 

31# --------------------------------------------------------------------------- 

32# Fish search configs 

33# --------------------------------------------------------------------------- 

34 

35ALL_CANDIDATES: int = 0x1FF # bits 0-8 set = digits 1-9 

36 

37 

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) 

41 

42 

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 

54 

55 

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 

65 

66 

67# --------------------------------------------------------------------------- 

68# StepSearchConfig 

69# --------------------------------------------------------------------------- 

70 

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}) 

82 

83 

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() 

103 

104 

105def _default_solve_search() -> StepSearchConfig: 

106 """Solve-path defaults — matches current hardcoded behaviour.""" 

107 return StepSearchConfig() 

108 

109 

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 ) 

119 

120 

121# --------------------------------------------------------------------------- 

122# SolverConfig 

123# --------------------------------------------------------------------------- 

124 

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) 

131 

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 

136 

137 if not self.step_overrides: 

138 from hodoku.core.scoring import SOLVER_STEPS 

139 return SOLVER_STEPS 

140 

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)) 

150 

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 

155 

156 if not self.step_overrides: 

157 return tuple(sorted(DEFAULT_STEPS, key=lambda s: s.index)) 

158 

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)) 

168 

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 

173 

174 if not self.step_overrides: 

175 return dict(_BASE) 

176 

177 from hodoku.core.scoring import DEFAULT_STEPS, StepConfig as SC 

178 

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 

187 

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 

202 

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 

212 

213 

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()