Coverage for src / hodoku / core / solution_step.py: 100%

87 statements  

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

1from __future__ import annotations 

2 

3from dataclasses import dataclass, field 

4 

5from hodoku.core.types import SolutionType 

6 

7 

8def _cell_name(index: int) -> str: 

9 return f"r{index // 9 + 1}c{index % 9 + 1}" 

10 

11 

12@dataclass(frozen=True) 

13class Candidate: 

14 """A (cell, digit) pair — used in candidates_to_delete lists.""" 

15 index: int # 0-80 

16 value: int # 1-9 

17 

18 def __str__(self) -> str: 

19 return f"{_cell_name(self.index)}<>{self.value}" 

20 

21 

22@dataclass(frozen=True) 

23class Entity: 

24 """A house referenced in a hint description.""" 

25 type: int # Grid.ROW / Grid.COL / Grid.BOX / Grid.CELL 

26 number: int # 1-based 

27 

28 

29@dataclass 

30class SolutionStep: 

31 """One logical step: the technique applied plus enough data to 

32 describe and apply it.""" 

33 

34 type: SolutionType 

35 

36 # What to do to the grid 

37 indices: list[int] = field(default_factory=list) # cells to set 

38 values: list[int] = field(default_factory=list) # values for those cells 

39 candidates_to_delete: list[Candidate] = field(default_factory=list) 

40 

41 # House context (for display / explanation) 

42 entity: int = 0 

43 entity_number: int = 0 

44 entity2: int = 0 

45 entity2_number: int = 0 

46 base_entities: list[Entity] = field(default_factory=list) 

47 cover_entities: list[Entity] = field(default_factory=list) 

48 

49 # Fish-specific 

50 fins: list[Candidate] = field(default_factory=list) 

51 endo_fins: list[Candidate] = field(default_factory=list) 

52 is_siamese: bool = False 

53 

54 # Chain / coloring 

55 chains: list[list[int]] = field(default_factory=list) # each chain is list[int] (packed entries) 

56 color_candidates: dict[int, int] = field(default_factory=dict) 

57 

58 # ALS-specific 

59 alses: list[tuple[int, int]] = field(default_factory=list) # (CellSet bits, candidate mask) 

60 restricted_commons: list[tuple[int, int, int, int]] = field(default_factory=list) # (als1, als2, cand1, cand2) 

61 

62 # Progress scoring (filled in by SudokuSolver when requested) 

63 progress_score: int = -1 

64 progress_score_singles: int = -1 

65 progress_score_singles_only: int = -1 

66 

67 def add_index(self, index: int) -> None: 

68 self.indices.append(index) 

69 

70 def add_value(self, value: int) -> None: 

71 self.values.append(value) 

72 

73 def add_candidate_to_delete(self, index: int, value: int) -> None: 

74 self.candidates_to_delete.append(Candidate(index, value)) 

75 

76 def add_als(self, indices: int, candidates: int) -> None: 

77 self.alses.append((indices, candidates)) 

78 

79 def reset(self) -> None: 

80 """Clear all fields for reuse (mirrors Java's globalStep.reset()).""" 

81 self.indices.clear() 

82 self.values.clear() 

83 self.candidates_to_delete.clear() 

84 self.chains.clear() 

85 self.entity = 0 

86 self.entity_number = 0 

87 self.alses.clear() 

88 self.endo_fins.clear() 

89 self.fins.clear() 

90 self.color_candidates.clear() 

91 

92 def is_net(self) -> bool: 

93 """True if any chain contains a negative entry (net branch marker).""" 

94 for chain in self.chains: 

95 for entry in chain: 

96 if entry < 0: 

97 return True 

98 return False 

99 

100 def get_chain_length(self) -> int: 

101 """Total length across all chains (sum of chain list lengths).""" 

102 total = 0 

103 for chain in self.chains: 

104 total += len(chain) 

105 return total 

106 

107 def get_candidate_string(self) -> str: 

108 """Canonical string for dedup — sorted candidates_to_delete. 

109 

110 Includes step type name so that different step types (e.g. 

111 FORCING_CHAIN_CONTRADICTION vs FORCING_CHAIN_VERITY) that 

112 happen to target the same candidates are kept in separate 

113 dedup buckets — matching Java's getCandidateString(). 

114 

115 IMPORTANT: sorts candidates_to_delete IN PLACE, matching Java's 

116 Collections.sort(candidatesToDelete). Java's Candidate.compareTo 

117 sorts by value first, then by index. The in-place sort is 

118 required so that getIndexSumme() in compareTo() sees the same 

119 ordering as Java. 

120 """ 

121 self.candidates_to_delete.sort(key=lambda c: (c.value, c.index)) 

122 parts = [(c.index, c.value) for c in self.candidates_to_delete] 

123 elim_str = ",".join(f"{i}:{v}" for i, v in parts) 

124 return f"{elim_str} ({self.type.name})" 

125 

126 def get_single_candidate_string(self) -> str: 

127 """Canonical string for dedup — sorted indices/values (set-cell steps). 

128 

129 Includes step type name for the same reason as 

130 get_candidate_string() — matching Java's getSingleCandidateString(). 

131 """ 

132 parts = sorted(zip(self.indices, self.values)) 

133 cells_str = ",".join(f"{i}={v}" for i, v in parts) 

134 return f"{self.type.name}: {cells_str}" 

135 

136 def __str__(self) -> str: 

137 name = self.type.value 

138 if self.indices: 

139 cells = ", ".join( 

140 f"{_cell_name(i)}={v}" 

141 for i, v in zip(self.indices, self.values) 

142 ) 

143 return f"{name}: {cells}" 

144 if self.candidates_to_delete: 

145 cells = ", ".join(str(c) for c in self.candidates_to_delete) 

146 return f"{name}: {cells}" 

147 return name 

148 

149 def __repr__(self) -> str: 

150 return f"SolutionStep({self.type.name}, indices={self.indices}, values={self.values})"