Coverage for src / hodoku / solver / wings.py: 97%

255 statements  

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

1"""Wing pattern solver: W-Wing, XY-Wing, XYZ-Wing. 

2 

3Mirrors Java's WingSolver. 

4""" 

5 

6from __future__ import annotations 

7 

8from hodoku.core.grid import BUDDIES, Grid 

9from hodoku.core.solution_step import Candidate, SolutionStep 

10from hodoku.core.types import SolutionType 

11 

12 

13class WingSolver: 

14 """W-Wing, XY-Wing, XYZ-Wing.""" 

15 

16 def __init__(self, grid: Grid) -> None: 

17 self.grid = grid 

18 

19 def get_step(self, sol_type: SolutionType) -> SolutionStep | None: 

20 if sol_type == SolutionType.W_WING: 

21 return self._find_w_wing() 

22 if sol_type == SolutionType.XY_WING: 

23 return self._find_xy_wing() 

24 if sol_type == SolutionType.XYZ_WING: 

25 return self._find_xyz_wing() 

26 return None 

27 

28 def find_all(self, sol_type: SolutionType) -> list[SolutionStep]: 

29 if sol_type == SolutionType.W_WING: 

30 return self._find_w_wing_all() 

31 if sol_type == SolutionType.XY_WING: 

32 return self._find_wing_all(xyz=False) 

33 if sol_type == SolutionType.XYZ_WING: 

34 return self._find_wing_all(xyz=True) 

35 return [] 

36 

37 def _find_wing_all(self, xyz: bool) -> list[SolutionStep]: 

38 grid = self.grid 

39 bi_cells: list[int] = [] 

40 tri_cells: list[int] = [] 

41 for i in range(81): 

42 if grid.values[i] != 0: 

43 continue 

44 n = bin(grid.candidates[i]).count("1") 

45 if n == 2: 

46 bi_cells.append(i) 

47 elif xyz and n == 3: 

48 tri_cells.append(i) 

49 

50 pivot_list = tri_cells if xyz else bi_cells 

51 results: list[SolutionStep] = [] 

52 seen_elims: set[tuple] = set() 

53 

54 for pi, pivot in enumerate(pivot_list): 

55 pivot_mask = grid.candidates[pivot] 

56 for ji, j_cell in enumerate(bi_cells): 

57 if xyz and j_cell == pivot: 

58 continue 

59 if not xyz and ji <= pi: 

60 continue 

61 j_mask = grid.candidates[j_cell] 

62 if bin(pivot_mask | j_mask).count("1") != 3: 

63 continue 

64 k_start = ji + 1 

65 for k_cell in bi_cells[k_start:]: 

66 if k_cell == pivot: 

67 continue 

68 k_mask = grid.candidates[k_cell] 

69 if bin(pivot_mask | j_mask | k_mask).count("1") != 3: 

70 continue 

71 if pivot_mask == j_mask or j_mask == k_mask or k_mask == pivot_mask: 

72 continue 

73 

74 candidates_for_pivot = [(pivot, j_cell, k_cell)] 

75 if not xyz: 

76 candidates_for_pivot += [ 

77 (j_cell, pivot, k_cell), 

78 (k_cell, j_cell, pivot), 

79 ] 

80 

81 for idx1, idx2, idx3 in candidates_for_pivot: 

82 step = self._check_wing(idx1, idx2, idx3, xyz) 

83 if step: 

84 key = tuple(sorted( 

85 (c.index, c.value) for c in step.candidates_to_delete 

86 )) 

87 if key not in seen_elims: 

88 seen_elims.add(key) 

89 results.append(step) 

90 return results 

91 

92 def _find_w_wing_all(self) -> list[SolutionStep]: 

93 grid = self.grid 

94 bi_cells: list[int] = [] 

95 for i in range(81): 

96 if grid.values[i] == 0 and bin(grid.candidates[i]).count("1") == 2: 

97 bi_cells.append(i) 

98 

99 results: list[SolutionStep] = [] 

100 seen_elims: set[tuple] = set() 

101 

102 for ii, i in enumerate(bi_cells): 

103 cell_i = grid.candidates[i] 

104 bits = [b + 1 for b in range(9) if cell_i >> b & 1] 

105 cand_a, cand_b = bits[0], bits[1] 

106 peers_a = grid.candidate_sets[cand_a] & BUDDIES[i] 

107 peers_b = grid.candidate_sets[cand_b] & BUDDIES[i] 

108 

109 for j in bi_cells[ii + 1:]: 

110 if grid.candidates[j] != cell_i: 

111 continue 

112 

113 elim_a = peers_a & BUDDIES[j] 

114 if elim_a: 

115 step = self._check_w_link(cand_a, cand_b, i, j, elim_a) 

116 if step: 

117 key = tuple(sorted( 

118 (c.index, c.value) for c in step.candidates_to_delete 

119 )) 

120 if key not in seen_elims: 

121 seen_elims.add(key) 

122 results.append(step) 

123 

124 elim_b = peers_b & BUDDIES[j] 

125 if elim_b: 

126 step = self._check_w_link(cand_b, cand_a, i, j, elim_b) 

127 if step: 

128 key = tuple(sorted( 

129 (c.index, c.value) for c in step.candidates_to_delete 

130 )) 

131 if key not in seen_elims: 

132 seen_elims.add(key) 

133 results.append(step) 

134 

135 return results 

136 

137 # ------------------------------------------------------------------ 

138 # XY-Wing / XYZ-Wing shared logic 

139 # ------------------------------------------------------------------ 

140 

141 def _find_xy_wing(self) -> SolutionStep | None: 

142 return self._find_wing(xyz=False) 

143 

144 def _find_xyz_wing(self) -> SolutionStep | None: 

145 return self._find_wing(xyz=True) 

146 

147 def _find_wing(self, xyz: bool) -> SolutionStep | None: 

148 """Search for XY-Wing (xyz=False) or XYZ-Wing (xyz=True). 

149 

150 Logic mirrors WingSolver.getWing(): 

151 - Collect bivalue cells (candidate count == 2) and optionally trivalue. 

152 - For XY-Wing: try each bivalue cell as pivot, two others as pincers. 

153 - For XYZ-Wing: try each trivalue cell as pivot, two bivalue pincers. 

154 - All three cells together must span exactly 3 distinct candidates. 

155 - Pivot must see both pincers. 

156 - Pincers share exactly one candidate z. 

157 - Eliminate z from cells that see both pincers (and pivot for XYZ). 

158 """ 

159 grid = self.grid 

160 bi_cells: list[int] = [] 

161 tri_cells: list[int] = [] 

162 for i in range(81): 

163 if grid.values[i] != 0: 

164 continue 

165 n = bin(grid.candidates[i]).count("1") 

166 if n == 2: 

167 bi_cells.append(i) 

168 elif xyz and n == 3: 

169 tri_cells.append(i) 

170 

171 pivot_list = tri_cells if xyz else bi_cells 

172 

173 for pi, pivot in enumerate(pivot_list): 

174 pivot_mask = grid.candidates[pivot] 

175 for ji, j_cell in enumerate(bi_cells): 

176 if xyz and j_cell == pivot: 

177 continue 

178 if not xyz and ji <= pi: 

179 continue # avoid duplicate (pivot, j) pairs for XY 

180 j_mask = grid.candidates[j_cell] 

181 if bin(pivot_mask | j_mask).count("1") != 3: 

182 continue # can't form a 3-candidate wing 

183 k_start = ji + 1 

184 for k_cell in bi_cells[k_start:]: 

185 if k_cell == pivot: 

186 continue 

187 k_mask = grid.candidates[k_cell] 

188 if bin(pivot_mask | j_mask | k_mask).count("1") != 3: 

189 continue 

190 # No two cells may have identical candidate sets 

191 if pivot_mask == j_mask or j_mask == k_mask or k_mask == pivot_mask: 

192 continue 

193 

194 # For XY-Wing, try all 3 rotations (each cell as pivot) 

195 candidates_for_pivot = [(pivot, j_cell, k_cell)] 

196 if not xyz: 

197 candidates_for_pivot += [ 

198 (j_cell, pivot, k_cell), 

199 (k_cell, j_cell, pivot), 

200 ] 

201 

202 for idx1, idx2, idx3 in candidates_for_pivot: 

203 step = self._check_wing(idx1, idx2, idx3, xyz) 

204 if step: 

205 return step 

206 return None 

207 

208 def _check_wing( 

209 self, pivot: int, pincer1: int, pincer2: int, xyz: bool 

210 ) -> SolutionStep | None: 

211 """Check if (pivot, pincer1, pincer2) form a valid wing and return step.""" 

212 grid = self.grid 

213 # Pivot must see both pincers 

214 if not (BUDDIES[pivot] >> pincer1 & 1) or not (BUDDIES[pivot] >> pincer2 & 1): 

215 return None 

216 

217 p1_mask = grid.candidates[pincer1] 

218 p2_mask = grid.candidates[pincer2] 

219 shared = p1_mask & p2_mask 

220 if bin(shared).count("1") != 1: 

221 return None # pincers must share exactly one candidate z 

222 

223 cand_z = shared.bit_length() # digit (1-9) 

224 

225 # Cells to eliminate from: see both pincers (and pivot for XYZ) 

226 elim_set = grid.candidate_sets[cand_z] & BUDDIES[pincer1] & BUDDIES[pincer2] 

227 if xyz: 

228 elim_set &= BUDDIES[pivot] 

229 # Remove the wing cells themselves 

230 elim_set &= ~((1 << pivot) | (1 << pincer1) | (1 << pincer2)) 

231 if not elim_set: 

232 return None 

233 

234 sol_type = SolutionType.XYZ_WING if xyz else SolutionType.XY_WING 

235 step = SolutionStep(sol_type) 

236 

237 pivot_mask = grid.candidates[pivot] 

238 # Values stored: the three candidates of the pivot cell 

239 for bit in range(1, 10): 

240 if pivot_mask >> (bit - 1) & 1: 

241 step.add_value(bit) 

242 if not xyz: 

243 # XY-Wing: add Z digit as third value (HoDoKu convention) 

244 step.add_value(cand_z) 

245 

246 step.add_index(pivot) 

247 step.add_index(pincer1) 

248 step.add_index(pincer2) 

249 

250 # Fins: pincers carry z; pivot also carries z for XYZ 

251 if xyz: 

252 step.fins.append(Candidate(pivot, cand_z)) 

253 step.fins.append(Candidate(pincer1, cand_z)) 

254 step.fins.append(Candidate(pincer2, cand_z)) 

255 

256 tmp = elim_set 

257 while tmp: 

258 lsb = tmp & -tmp 

259 step.add_candidate_to_delete(lsb.bit_length() - 1, cand_z) 

260 tmp ^= lsb 

261 return step 

262 

263 # ------------------------------------------------------------------ 

264 # W-Wing 

265 # ------------------------------------------------------------------ 

266 

267 def _find_w_wing(self) -> SolutionStep | None: 

268 """Find the first W-Wing elimination. 

269 

270 Two bivalue cells with identical candidates {a, b}. 

271 A strong link on b connects them (one end sees cell1, the other sees cell2). 

272 Eliminate a from all cells that see both bivalue cells. 

273 """ 

274 grid = self.grid 

275 bi_cells: list[int] = [] 

276 for i in range(81): 

277 if grid.values[i] == 0 and bin(grid.candidates[i]).count("1") == 2: 

278 bi_cells.append(i) 

279 

280 for ii, i in enumerate(bi_cells): 

281 cell_i = grid.candidates[i] 

282 # Extract the two digits from the bitmask 

283 bits = [b + 1 for b in range(9) if cell_i >> b & 1] 

284 cand_a, cand_b = bits[0], bits[1] 

285 

286 # Pre-compute peers of i that carry cand_a / cand_b 

287 peers_a = grid.candidate_sets[cand_a] & BUDDIES[i] 

288 peers_b = grid.candidate_sets[cand_b] & BUDDIES[i] 

289 

290 for j in bi_cells[ii + 1:]: 

291 if grid.candidates[j] != cell_i: 

292 continue # must have identical candidate sets 

293 

294 # Potential eliminations of cand_a: cells seeing both i and j 

295 elim_a = peers_a & BUDDIES[j] 

296 if elim_a: 

297 step = self._check_w_link(cand_a, cand_b, i, j, elim_a) 

298 if step: 

299 return step 

300 

301 # Potential eliminations of cand_b: cells seeing both i and j 

302 elim_b = peers_b & BUDDIES[j] 

303 if elim_b: 

304 step = self._check_w_link(cand_b, cand_a, i, j, elim_b) 

305 if step: 

306 return step 

307 

308 return None 

309 

310 def _check_w_link( 

311 self, 

312 cand_elim: int, 

313 cand_link: int, 

314 idx1: int, 

315 idx2: int, 

316 elim_set: int, 

317 ) -> SolutionStep | None: 

318 """Find a strong link on cand_link that bridges idx1 and idx2. 

319 

320 The strong link must have one end seeing idx1 and the other seeing idx2. 

321 A single end cannot see BOTH idx1 and idx2 (forbidden by HoDoKu). 

322 """ 

323 grid = self.grid 

324 # Scan all 27 constraints for a strong link on cand_link 

325 for constr in range(27): 

326 if grid.free[constr][cand_link] != 2: 

327 continue 

328 # Find the two cells in this unit with cand_link 

329 link_cells: list[int] = [] 

330 for cell in self._unit_cells(constr): 

331 if cell != idx1 and cell != idx2 and grid.candidate_sets[cand_link] >> cell & 1: 

332 link_cells.append(cell) 

333 if len(link_cells) == 2: 

334 break 

335 if len(link_cells) != 2: 

336 continue 

337 

338 w1, w2 = link_cells 

339 # One must see idx1, the other idx2; neither may see BOTH 

340 w1_sees1 = bool(BUDDIES[w1] >> idx1 & 1) 

341 w1_sees2 = bool(BUDDIES[w1] >> idx2 & 1) 

342 w2_sees1 = bool(BUDDIES[w2] >> idx1 & 1) 

343 w2_sees2 = bool(BUDDIES[w2] >> idx2 & 1) 

344 

345 if w1_sees1 and w1_sees2: 

346 continue # forbidden 

347 if w2_sees1 and w2_sees2: 

348 continue # forbidden 

349 

350 if w1_sees1 and w2_sees2: 

351 pass # w1→idx1, w2→idx2 

352 elif w1_sees2 and w2_sees1: 

353 w1, w2 = w2, w1 # swap so w1→idx1 

354 else: 

355 continue # no valid bridge 

356 

357 # Valid W-Wing! 

358 step = SolutionStep(SolutionType.W_WING) 

359 step.add_value(cand_elim) 

360 step.add_value(cand_link) 

361 step.add_index(idx1) 

362 step.add_index(idx2) 

363 step.fins.append(Candidate(idx1, cand_link)) 

364 step.fins.append(Candidate(idx2, cand_link)) 

365 step.fins.append(Candidate(w1, cand_link)) 

366 step.fins.append(Candidate(w2, cand_link)) 

367 

368 tmp = elim_set 

369 while tmp: 

370 lsb = tmp & -tmp 

371 step.add_candidate_to_delete(lsb.bit_length() - 1, cand_elim) 

372 tmp ^= lsb 

373 return step 

374 

375 return None 

376 

377 # ------------------------------------------------------------------ 

378 # Helper 

379 # ------------------------------------------------------------------ 

380 

381 def _unit_cells(self, constr: int) -> list[int]: 

382 """Return all cell indices in constraint unit constr (0-26).""" 

383 from hodoku.core.grid import ALL_UNITS # avoid circular at module level 

384 return list(ALL_UNITS[constr])