Coverage for src / hodoku / solver / fish.py: 99%
539 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"""Fish solver: basic, finned/sashimi, Franken, and Mutant variants.
3Mirrors Java's FishSolver. Basic and finned/sashimi were already implemented;
4Franken and Mutant extend the unit pool to include boxes as base/cover sets.
6Fish category classification (mirrors Java's createFishStep logic):
7 BASIC — base = rows only, cover = cols only (or vice versa)
8 FRANKEN — base ⊆ {rows, blocks}, cover ⊆ {cols, blocks} (or vice versa)
9 MUTANT — any other combination (rows and cols appear on the same side)
11Unit pools for initForCandidat (fishType, lines=True):
12 BASIC: base = rows, cover = cols
13 FRANKEN: base = rows + blocks, cover = cols + blocks
14 MUTANT: base = all 27 units, cover = all 27 units (one orientation)
16Endo fins: cells that appear in two or more base units (possible when blocks
17overlap rows/cols). At most MAX_ENDO_FINS=2 endo fins are allowed.
18Endo fins that are not covered appear in the regular fins mask and are
19handled identically to external fins when computing eliminations.
20"""
22from __future__ import annotations
24import os
25from itertools import combinations
26from typing import TYPE_CHECKING
28from hodoku.core.grid import (
29 ALL_UNIT_MASKS,
30 BUDDIES,
31 COL_MASKS,
32 Grid,
33 LINE_MASKS,
34)
36if TYPE_CHECKING:
37 from hodoku.config import StepSearchConfig
39# All 81 cells mask
40_ALL_CELLS = (1 << 81) - 1
42# ---- C accelerator for cover search (optional, huge speedup) ----
44try:
45 if os.environ.get("HODOKU_NO_ACCEL"):
46 raise ImportError("disabled by HODOKU_NO_ACCEL")
47 from hodoku.solver import _fish_accel as _accel
48 _accel.set_buddies(list(BUDDIES))
49except ImportError:
50 _accel = None
53def _fin_buddies(fin_mask: int) -> int:
54 """Return the intersection of buddies of all fin cells.
56 A cell is in the result iff it can see every fin cell simultaneously.
57 """
58 common = _ALL_CELLS
59 tmp = fin_mask
60 while tmp:
61 lsb = tmp & -tmp
62 common &= BUDDIES[lsb.bit_length() - 1]
63 tmp ^= lsb
64 return common
67from hodoku.core.solution_step import Entity, SolutionStep # noqa: E402
68from hodoku.core.types import SolutionType # noqa: E402
70# Entity type constants (mirror Java's Sudoku2.LINE / COL / BLOCK)
71_LINE = 0
72_COL = 1
74# Size for each basic fish type
75_FISH_SIZE = {
76 SolutionType.X_WING: 2,
77 SolutionType.SWORDFISH: 3,
78 SolutionType.JELLYFISH: 4,
79}
81# Finned and Sashimi types with their size
82_FINNED_SIZE = {
83 SolutionType.FINNED_X_WING: 2,
84 SolutionType.FINNED_SWORDFISH: 3,
85 SolutionType.FINNED_JELLYFISH: 4,
86}
88_SASHIMI_SIZE = {
89 SolutionType.SASHIMI_X_WING: 2,
90 SolutionType.SASHIMI_SWORDFISH: 3,
91 SolutionType.SASHIMI_JELLYFISH: 4,
92}
94# Map finned → sashimi counterpart (same size, different classification)
95_FINNED_TO_SASHIMI = {
96 SolutionType.FINNED_X_WING: SolutionType.SASHIMI_X_WING,
97 SolutionType.FINNED_SWORDFISH: SolutionType.SASHIMI_SWORDFISH,
98 SolutionType.FINNED_JELLYFISH: SolutionType.SASHIMI_JELLYFISH,
99}
101# ---------------------------------------------------------------------------
102# Franken / Mutant fish
103# ---------------------------------------------------------------------------
105# Fish category constants (mirror Java FishSolver BASIC/FRANKEN/MUTANT)
106_CAT_BASIC = 0
107_CAT_FRANKEN = 1
108_CAT_MUTANT = 2
110# Unit type bit-flags (for category classification)
111_BIT_LINE = 1
112_BIT_COL = 2
113_BIT_BLOCK = 4
115# HoDoKu defaults for fin/endo-fin limits
116_MAX_FINS = 5
117_MAX_ENDO_FINS = 2
119# Entity type constants for boxes (Java's Sudoku2.BLOCK = 0)
120_BLOCK = 2
122# Entity type constants (reuse existing _LINE=0, _COL=1, new _BLOCK_ENT=2)
123_BLOCK_ENT = 2
125# SolutionType → (category, size, with_fins)
126_GENERALIZED_INFO: dict[SolutionType, tuple[int, int, bool]] = {
127 # Franken (basic / no fins)
128 SolutionType.FRANKEN_X_WING: (_CAT_FRANKEN, 2, False),
129 SolutionType.FRANKEN_SWORDFISH: (_CAT_FRANKEN, 3, False),
130 SolutionType.FRANKEN_JELLYFISH: (_CAT_FRANKEN, 4, False),
131 # Franken (finned)
132 SolutionType.FINNED_FRANKEN_X_WING: (_CAT_FRANKEN, 2, True),
133 SolutionType.FINNED_FRANKEN_SWORDFISH: (_CAT_FRANKEN, 3, True),
134 SolutionType.FINNED_FRANKEN_JELLYFISH: (_CAT_FRANKEN, 4, True),
135 # Mutant (basic / no fins)
136 SolutionType.MUTANT_X_WING: (_CAT_MUTANT, 2, False),
137 SolutionType.MUTANT_SWORDFISH: (_CAT_MUTANT, 3, False),
138 SolutionType.MUTANT_JELLYFISH: (_CAT_MUTANT, 4, False),
139 # Mutant (finned)
140 SolutionType.FINNED_MUTANT_X_WING: (_CAT_MUTANT, 2, True),
141 SolutionType.FINNED_MUTANT_SWORDFISH: (_CAT_MUTANT, 3, True),
142 SolutionType.FINNED_MUTANT_JELLYFISH: (_CAT_MUTANT, 4, True),
143 SolutionType.FINNED_MUTANT_SQUIRMBAG: (_CAT_MUTANT, 5, True),
144 SolutionType.FINNED_MUTANT_WHALE: (_CAT_MUTANT, 6, True),
145 SolutionType.FINNED_MUTANT_LEVIATHAN: (_CAT_MUTANT, 7, True),
146}
148# (category, size, with_fins) → basic SolutionType (for step annotation when no fins)
149_CAT_BASIC_TYPES: dict[tuple[int, int], SolutionType] = {
150 (_CAT_FRANKEN, 2): SolutionType.FRANKEN_X_WING,
151 (_CAT_FRANKEN, 3): SolutionType.FRANKEN_SWORDFISH,
152 (_CAT_FRANKEN, 4): SolutionType.FRANKEN_JELLYFISH,
153 (_CAT_MUTANT, 2): SolutionType.MUTANT_X_WING,
154 (_CAT_MUTANT, 3): SolutionType.MUTANT_SWORDFISH,
155 (_CAT_MUTANT, 4): SolutionType.MUTANT_JELLYFISH,
156}
158# (category, size) → finned SolutionType
159_CAT_FINNED_TYPES: dict[tuple[int, int], SolutionType] = {
160 (_CAT_FRANKEN, 2): SolutionType.FINNED_FRANKEN_X_WING,
161 (_CAT_FRANKEN, 3): SolutionType.FINNED_FRANKEN_SWORDFISH,
162 (_CAT_FRANKEN, 4): SolutionType.FINNED_FRANKEN_JELLYFISH,
163 (_CAT_MUTANT, 2): SolutionType.FINNED_MUTANT_X_WING,
164 (_CAT_MUTANT, 3): SolutionType.FINNED_MUTANT_SWORDFISH,
165 (_CAT_MUTANT, 4): SolutionType.FINNED_MUTANT_JELLYFISH,
166 (_CAT_MUTANT, 5): SolutionType.FINNED_MUTANT_SQUIRMBAG,
167 (_CAT_MUTANT, 6): SolutionType.FINNED_MUTANT_WHALE,
168 (_CAT_MUTANT, 7): SolutionType.FINNED_MUTANT_LEVIATHAN,
169}
172def _classify_fish(base_type_bits: int, cover_type_bits: int) -> int:
173 """Classify a fish as BASIC/FRANKEN/MUTANT based on unit type bits.
175 Mirrors Java FishSolver.createFishStep type-determination logic.
176 LINE_MASK=1, COL_MASK=2, BLOCK_MASK=4 in Java.
177 """
178 if ((base_type_bits == _BIT_LINE and cover_type_bits == _BIT_COL) or
179 (base_type_bits == _BIT_COL and cover_type_bits == _BIT_LINE)):
180 return _CAT_BASIC
181 # Franken: base ⊆ {LINE,BLOCK} and cover ⊆ {COL,BLOCK}, or vice versa
182 if (((base_type_bits & ~(_BIT_LINE | _BIT_BLOCK)) == 0 and
183 (cover_type_bits & ~(_BIT_COL | _BIT_BLOCK)) == 0) or # noqa: E127
184 ((base_type_bits & ~(_BIT_COL | _BIT_BLOCK)) == 0 and
185 (cover_type_bits & ~(_BIT_LINE | _BIT_BLOCK)) == 0)):
186 return _CAT_FRANKEN
187 return _CAT_MUTANT
190def _unit_type_bit(unit_idx: int) -> int:
191 """Return the type bit for a unit index (0-8=row, 9-17=col, 18-26=block)."""
192 if unit_idx < 9:
193 return _BIT_LINE
194 if unit_idx < 18:
195 return _BIT_COL
196 return _BIT_BLOCK
199def _build_unit_pools(
200 cand_set: int,
201 fish_cat: int,
202 lines: bool,
203 n: int,
204 with_fins: bool,
205) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]:
206 """Build (base_pool, cover_pool) for a generalized fish search.
208 Each pool entry is (unit_cands, unit_index) where unit_index is 0-26
209 (0-8=rows, 9-17=cols, 18-26=blocks).
211 Mirrors Java FishSolver.initForCandidat().
212 """
213 base_pool: list[tuple[int, int]] = []
214 cover_pool: list[tuple[int, int]] = []
216 for i in range(27):
217 if fish_cat == _CAT_BASIC and i >= 18:
218 continue
219 unit_cands = cand_set & ALL_UNIT_MASKS[i]
220 if not unit_cands:
221 continue
223 if i < 9: # row
224 if lines or fish_cat == _CAT_MUTANT:
225 # row → base (with size filter), and cover if MUTANT
226 if with_fins or unit_cands.bit_count() <= n:
227 base_pool.append((unit_cands, i))
228 if fish_cat == _CAT_MUTANT:
229 cover_pool.append((unit_cands, i))
230 else:
231 # row → cover only (non-MUTANT, lines=False)
232 cover_pool.append((unit_cands, i))
233 elif i < 18: # col
234 if lines or fish_cat == _CAT_MUTANT:
235 # col → cover, and base if MUTANT
236 cover_pool.append((unit_cands, i))
237 if fish_cat == _CAT_MUTANT:
238 if with_fins or unit_cands.bit_count() <= n:
239 base_pool.append((unit_cands, i))
240 else:
241 # col → base (with size filter), and cover if MUTANT
242 if with_fins or unit_cands.bit_count() <= n:
243 base_pool.append((unit_cands, i))
244 if fish_cat == _CAT_MUTANT:
245 cover_pool.append((unit_cands, i))
246 else: # block (i >= 18)
247 if fish_cat != _CAT_BASIC:
248 cover_pool.append((unit_cands, i))
249 if with_fins or unit_cands.bit_count() <= n:
250 base_pool.append((unit_cands, i))
252 return base_pool, cover_pool
255def _apply_siamese(steps: list[SolutionStep]) -> None:
256 """Append siamese combinations to *steps* (modifies in place).
258 Two finned/sashimi fish are siamese when they share the same base
259 entities (same candidate, same rows/cols) but differ in their fin
260 configuration and eliminations. HoDoKu merges them into a single
261 step with the combined cover + fin + elimination sets.
262 """
263 n = len(steps)
264 for i in range(n - 1):
265 s1 = steps[i]
266 if not s1.base_entities:
267 continue
268 # Entity is a frozen dataclass with fields .type and .number
269 base_key1 = tuple((e.type, e.number) for e in s1.base_entities)
270 cand1 = s1.values[0] if s1.values else None
271 elim1 = frozenset((c.index, c.value) for c in s1.candidates_to_delete)
272 for j in range(i + 1, n):
273 s2 = steps[j]
274 if not s2.base_entities:
275 continue
276 if (s2.values[0] if s2.values else None) != cand1:
277 continue
278 base_key2 = tuple((e.type, e.number) for e in s2.base_entities)
279 if base_key1 != base_key2:
280 continue
281 elim2 = frozenset((c.index, c.value) for c in s2.candidates_to_delete)
282 if elim1 == elim2:
283 continue
284 # Build siamese step: clone s1, add s2's covers/fins/elims
285 # Entity and Candidate are frozen dataclasses — safe to share refs
286 siam = SolutionStep(s1.type)
287 siam.add_value(cand1)
288 for idx in s1.indices:
289 siam.add_index(idx)
290 siam.base_entities.extend(s1.base_entities)
291 siam.cover_entities.extend(s1.cover_entities)
292 siam.fins.extend(s1.fins)
293 for c in s1.candidates_to_delete:
294 siam.add_candidate_to_delete(c.index, c.value)
295 # Add s2's additional covers, fins, elims
296 siam.cover_entities.extend(s2.cover_entities)
297 siam.fins.extend(s2.fins)
298 for c in s2.candidates_to_delete:
299 if (c.index, c.value) not in elim1:
300 siam.add_candidate_to_delete(c.index, c.value)
301 steps.append(siam)
304class FishSolver:
305 """X-Wing, Swordfish, Jellyfish — basic, finned/sashimi, Franken, Mutant."""
307 def __init__(self, grid: Grid, search_config: StepSearchConfig | None = None) -> None:
308 self.grid = grid
309 if search_config is not None:
310 self._max_fins = search_config.fish.max_fins
311 self._max_endo_fins = search_config.fish.max_endo_fins
312 self._allow_duals_and_siamese = search_config.allow_duals_and_siamese
313 else:
314 # Backward-compatible defaults (pre-config behavior)
315 self._max_fins = _MAX_FINS
316 self._max_endo_fins = _MAX_ENDO_FINS
317 self._allow_duals_and_siamese = True
319 def get_step(self, sol_type: SolutionType) -> SolutionStep | None:
320 if sol_type in _FISH_SIZE:
321 return self._find_basic_fish(sol_type)
322 if sol_type in _FINNED_SIZE:
323 return self._find_finned_fish(sol_type, sashimi=False)
324 if sol_type in _SASHIMI_SIZE:
325 return self._find_finned_fish(sol_type, sashimi=True)
326 if sol_type in _GENERALIZED_INFO:
327 steps = self._find_generalized_fish_all(sol_type)
328 # NOTE: Java calls Collections.sort(steps) here using
329 # SolutionStep.compareTo(). We rely on iteration order matching
330 # instead. If a parity divergence traces to generalized fish
331 # step selection, add a sort here (see getCandidateString() sort
332 # side-effect documented in chains.py _elim_sort_key).
333 return steps[0] if steps else None
334 return None
336 def find_all(self, sol_type: SolutionType, *, for_candidate: int = -1) -> list[SolutionStep]:
337 if sol_type in _FISH_SIZE:
338 return self._find_basic_fish_all(sol_type)
339 if sol_type in _FINNED_SIZE:
340 n = _FINNED_SIZE[sol_type]
341 steps = self._find_finned_fish_all(sol_type, sashimi=False)
342 if n >= 3:
343 # HoDoKu finds finned+sashimi together for siamese combinations
344 sashimi_type = _FINNED_TO_SASHIMI[sol_type]
345 steps = steps + self._find_finned_fish_all(sashimi_type, sashimi=True)
346 if self._allow_duals_and_siamese:
347 _apply_siamese(steps)
348 return steps
349 if sol_type in _SASHIMI_SIZE:
350 n = _SASHIMI_SIZE[sol_type]
351 sashimi_steps = self._find_finned_fish_all(sol_type, sashimi=True)
352 if n >= 3:
353 finned_type = {v: k for k, v in _FINNED_TO_SASHIMI.items()}[sol_type]
354 steps = self._find_finned_fish_all(finned_type, sashimi=False) + sashimi_steps
355 if self._allow_duals_and_siamese:
356 _apply_siamese(steps)
357 return steps
358 return sashimi_steps
359 if sol_type in _GENERALIZED_INFO:
360 steps = self._find_generalized_fish_all(sol_type, for_candidate=for_candidate)
361 if self._allow_duals_and_siamese:
362 _apply_siamese(steps)
363 return steps
364 return []
366 def _find_basic_fish_all(self, sol_type: SolutionType) -> list[SolutionStep]:
367 """Return ALL basic fish of the given size."""
368 n = _FISH_SIZE[sol_type]
369 grid = self.grid
370 results: list[SolutionStep] = []
371 seen_elims: set[tuple] = set()
373 for cand in range(1, 10):
374 cand_set = grid.candidate_sets[cand]
375 if not cand_set:
376 continue
378 for row_mode in (True, False):
379 unit_masks = LINE_MASKS if row_mode else COL_MASKS
380 cover_masks = COL_MASKS if row_mode else LINE_MASKS
382 eligible: list[int] = []
383 for u in range(9):
384 cnt = (cand_set & unit_masks[u]).bit_count()
385 if 2 <= cnt <= n:
386 eligible.append(u)
388 if len(eligible) < n:
389 continue
391 for combo in combinations(eligible, n):
392 base_mask = 0
393 for u in combo:
394 base_mask |= cand_set & unit_masks[u]
396 cover_indices: list[int] = []
397 for ci in range(9):
398 if base_mask & cover_masks[ci]:
399 cover_indices.append(ci)
401 if len(cover_indices) != n:
402 continue
404 cover_mask = 0
405 for ci in cover_indices:
406 cover_mask |= cover_masks[ci]
408 elim_mask = cand_set & cover_mask & ~base_mask
409 if not elim_mask:
410 continue
412 elim_key = (cand, elim_mask)
413 if elim_key in seen_elims:
414 continue
415 seen_elims.add(elim_key)
417 results.append(self._make_step(
418 sol_type, cand, combo, cover_indices,
419 base_mask, elim_mask, row_mode
420 ))
422 return results
424 def _find_finned_fish_all(
425 self, sol_type: SolutionType, sashimi: bool
426 ) -> list[SolutionStep]:
427 """Return ALL finned/sashimi fish of the given size."""
428 n = _SASHIMI_SIZE[sol_type] if sashimi else _FINNED_SIZE[sol_type]
429 finned_type = sol_type if not sashimi else {
430 v: k for k, v in _FINNED_TO_SASHIMI.items()
431 }[sol_type]
432 grid = self.grid
433 results: list[SolutionStep] = []
434 seen_elims: set[tuple] = set()
436 for cand in range(1, 10):
437 cand_set = grid.candidate_sets[cand]
438 if not cand_set:
439 continue
441 for row_mode in (True, False):
442 unit_masks = LINE_MASKS if row_mode else COL_MASKS
443 cover_masks = COL_MASKS if row_mode else LINE_MASKS
445 eligible: list[int] = []
446 for u in range(9):
447 if cand_set & unit_masks[u]:
448 eligible.append(u)
450 if len(eligible) < n:
451 continue
453 for combo in combinations(eligible, n):
454 base_mask = 0
455 for u in combo:
456 base_mask |= cand_set & unit_masks[u]
458 cover_indices: list[int] = []
459 for ci in range(9):
460 if base_mask & cover_masks[ci]:
461 cover_indices.append(ci)
463 if len(cover_indices) < n or len(cover_indices) > n + 3:
464 continue
466 for cover_combo in combinations(cover_indices, n):
467 cover_mask = 0
468 for ci in cover_combo:
469 cover_mask |= cover_masks[ci]
471 fin_mask = base_mask & ~cover_mask
472 if not fin_mask:
473 continue
474 if fin_mask.bit_count() > 5:
475 continue
477 fin_common = _fin_buddies(fin_mask)
478 elim_mask = cand_set & cover_mask & fin_common & ~base_mask
479 if not elim_mask:
480 continue
482 is_sashimi = False
483 for u in combo:
484 body = cand_set & unit_masks[u] & cover_mask
485 if body.bit_count() <= 1:
486 is_sashimi = True
487 break
489 if is_sashimi != sashimi:
490 continue
492 actual_type = (
493 _FINNED_TO_SASHIMI[finned_type]
494 if is_sashimi else finned_type
495 )
497 elim_key = (cand, row_mode, elim_mask, fin_mask)
498 if elim_key in seen_elims:
499 continue
500 seen_elims.add(elim_key)
502 results.append(self._make_step(
503 actual_type, cand, combo, list(cover_combo),
504 base_mask, elim_mask, row_mode,
505 fin_mask=fin_mask,
506 ))
508 return results
510 # ------------------------------------------------------------------
511 # Core algorithm
512 # ------------------------------------------------------------------
514 def _find_basic_fish(self, sol_type: SolutionType) -> SolutionStep | None:
515 """Search for a basic fish of the given size.
517 Row-mode: base = rows, cover = cols.
518 Col-mode: base = cols, cover = rows.
519 For each candidate digit, try all C(9, n) combinations of base units
520 where the digit appears 2..n times. If those n base units span exactly
521 n cover units, any candidates in the cover units outside the base can
522 be eliminated.
523 """
524 n = _FISH_SIZE[sol_type]
525 grid = self.grid
527 for cand in range(1, 10):
528 cand_set = grid.candidate_sets[cand]
529 if not cand_set:
530 continue
532 for row_mode in (True, False):
533 unit_masks = LINE_MASKS if row_mode else COL_MASKS
534 cover_masks = COL_MASKS if row_mode else LINE_MASKS
536 # Collect candidate base units (those with 2..n occurrences)
537 eligible: list[int] = []
538 for u in range(9):
539 cnt = (cand_set & unit_masks[u]).bit_count()
540 if 2 <= cnt <= n:
541 eligible.append(u)
543 if len(eligible) < n:
544 continue
546 for combo in combinations(eligible, n):
547 # Union of candidate cells across these n base units
548 base_mask = 0
549 for u in combo:
550 base_mask |= cand_set & unit_masks[u]
552 # Which perpendicular units do these cells span?
553 cover_indices: list[int] = []
554 for ci in range(9):
555 if base_mask & cover_masks[ci]:
556 cover_indices.append(ci)
558 if len(cover_indices) != n:
559 continue # not a fish
561 # Elimination: candidates in cover units but not in base
562 cover_mask = 0
563 for ci in cover_indices:
564 cover_mask |= cover_masks[ci]
566 elim_mask = cand_set & cover_mask & ~base_mask
567 if not elim_mask:
568 continue
570 return self._make_step(
571 sol_type, cand, combo, cover_indices,
572 base_mask, elim_mask, row_mode
573 )
575 return None
577 def _find_finned_fish(
578 self, sol_type: SolutionType, sashimi: bool
579 ) -> SolutionStep | None:
580 """Search for a finned or sashimi fish of the given size.
582 A finned fish is like a basic fish but some base cells fall outside
583 the n cover units — these are 'fins'. All fins must share one block.
584 Eliminations: candidates in the cover units that are also in the fin
585 block, but not in any base unit.
587 Sashimi: same pattern, but at least one base unit has all its cells
588 as fins (it contributes nothing to the body).
589 """
590 n = _SASHIMI_SIZE[sol_type] if sashimi else _FINNED_SIZE[sol_type]
591 # The canonical type for step construction is the FINNED variant;
592 # we decide finned vs sashimi per found fish.
593 finned_type = sol_type if not sashimi else {
594 v: k for k, v in _FINNED_TO_SASHIMI.items()
595 }[sol_type]
596 grid = self.grid
598 for cand in range(1, 10):
599 cand_set = grid.candidate_sets[cand]
600 if not cand_set:
601 continue
603 for row_mode in (True, False):
604 unit_masks = LINE_MASKS if row_mode else COL_MASKS
605 cover_masks = COL_MASKS if row_mode else LINE_MASKS
607 # Eligible base units: any unit with ≥1 occurrence
608 # (fins can make a unit appear to have too many cells)
609 eligible: list[int] = []
610 for u in range(9):
611 if cand_set & unit_masks[u]:
612 eligible.append(u)
614 if len(eligible) < n:
615 continue
617 for combo in combinations(eligible, n):
618 base_mask = 0
619 for u in combo:
620 base_mask |= cand_set & unit_masks[u]
622 # All cover units hit by the base cells
623 cover_indices: list[int] = []
624 for ci in range(9):
625 if base_mask & cover_masks[ci]:
626 cover_indices.append(ci)
628 # Need at least n covers; skip if far too many (performance)
629 if len(cover_indices) < n or len(cover_indices) > n + 3:
630 continue
632 # Try every n-subset of cover_indices as the main covers
633 for cover_combo in combinations(cover_indices, n):
634 cover_mask = 0
635 for ci in cover_combo:
636 cover_mask |= cover_masks[ci]
638 fin_mask = base_mask & ~cover_mask
639 if not fin_mask:
640 continue # basic fish, handled elsewhere
641 if fin_mask.bit_count() > 5:
642 continue
644 # Eliminations: cover candidates that see ALL fin cells
645 fin_common = _fin_buddies(fin_mask)
646 elim_mask = cand_set & cover_mask & fin_common & ~base_mask
647 if not elim_mask:
648 continue
650 # Sashimi: any base unit has ≤1 body cell (HoDoKu definition)
651 is_sashimi = False
652 for u in combo:
653 body = cand_set & unit_masks[u] & cover_mask
654 if body.bit_count() <= 1:
655 is_sashimi = True
656 break
658 if is_sashimi != sashimi:
659 continue
661 actual_type = (
662 _FINNED_TO_SASHIMI[finned_type]
663 if is_sashimi else finned_type
664 )
665 return self._make_step(
666 actual_type, cand, combo, list(cover_combo),
667 base_mask, elim_mask, row_mode,
668 fin_mask=fin_mask,
669 )
671 return None
673 # ------------------------------------------------------------------
674 # Generalized fish (Franken / Mutant)
675 # ------------------------------------------------------------------
677 def _find_generalized_fish_all(
678 self, sol_type: SolutionType, *, for_candidate: int = -1
679 ) -> list[SolutionStep]:
680 """Find all Franken or Mutant fish of the requested type.
682 Mirrors Java FishSolver.getFishes() with FRANKEN or MUTANT fish_type.
683 When for_candidate is 1-9, only that digit is searched (much faster).
684 """
685 fish_cat, n, with_fins = _GENERALIZED_INFO[sol_type]
686 grid = self.grid
687 results: list[SolutionStep] = []
688 seen_elims: set[tuple] = set()
690 # MUTANT: only one orientation (lines=True) since all units go to both
691 # pools regardless of orientation.
692 # FRANKEN: two orientations to cover both row-base and col-base setups.
693 orientations = [True] if fish_cat == _CAT_MUTANT else [True, False]
695 for cand in range(1, 10):
696 if for_candidate != -1 and cand != for_candidate:
697 continue
698 cand_set = grid.candidate_sets[cand]
699 if not cand_set:
700 continue
702 for lines in orientations:
703 base_pool, cover_pool = _build_unit_pools(
704 cand_set, fish_cat, lines, n, with_fins
705 )
706 if len(base_pool) < n:
707 continue
709 for base_combo in combinations(range(len(base_pool)), n):
710 # Accumulate base candidates and track endo fins
711 base_cand = 0
712 endo_fins = 0
713 skip = False
714 for bi in base_combo:
715 overlap = base_cand & base_pool[bi][0]
716 if overlap:
717 if not with_fins:
718 # Basic fish: no endo fins allowed
719 skip = True
720 break
721 if (endo_fins | overlap).bit_count() > self._max_endo_fins:
722 skip = True
723 break
724 endo_fins |= overlap
725 base_cand |= base_pool[bi][0]
726 if skip:
727 continue
729 # Unit indices used as base (to exclude from cover)
730 base_ids = frozenset(base_pool[bi][1] for bi in base_combo)
732 # Eligible cover units: not a base unit, intersects base_cand
733 cover_eligible = [
734 (mask, idx) for mask, idx in cover_pool
735 if idx not in base_ids and (mask & base_cand)
736 ]
737 if len(cover_eligible) < n:
738 continue
740 # Precompute base classification bits (same for all cover combos)
741 base_bits = 0
742 for bi in base_combo:
743 base_bits |= _unit_type_bit(base_pool[bi][1])
745 num_cover = len(cover_eligible)
747 if _accel is not None:
748 # ---- C-accelerated cover search ----
749 ce_masks = [cover_eligible[i][0] for i in range(num_cover)]
750 for indices, cover_cand, fins, elim in _accel.find_covers(
751 ce_masks, n, base_cand, cand_set,
752 1 if with_fins else 0, self._max_fins, endo_fins,
753 ):
754 cover_bits = 0
755 for k in range(n):
756 cover_bits |= _unit_type_bit(cover_eligible[indices[k]][1])
757 actual_cat = _classify_fish(base_bits, cover_bits)
758 if actual_cat != fish_cat:
759 continue
760 elim_key = (cand, elim)
761 if elim_key in seen_elims:
762 continue
763 seen_elims.add(elim_key)
764 results.append(self._make_general_step(
765 sol_type, cand,
766 [base_pool[bi] for bi in base_combo],
767 [cover_eligible[indices[k]] for k in range(n)],
768 base_cand, elim, fins, endo_fins,
769 ))
770 else:
771 # ---- Pure Python fallback (DFS with pruning) ----
772 ce_masks = [cover_eligible[i][0] for i in range(num_cover)]
773 _cc = [0] * (n + 1)
774 _cn = [0] * (n + 1)
775 _ni = [0] * (n + 1)
776 _pi = [0] * (n + 1)
777 level = 1
778 _ni[1] = 0
779 while True:
780 while _ni[level] > num_cover - (n - level + 1):
781 level -= 1
782 if level == 0:
783 break
784 if level == 0:
785 break
786 ci = _ni[level]
787 _ni[level] = ci + 1
788 _pi[level] = ci
789 mask = ce_masks[ci]
790 prev_cand = _cc[level - 1]
791 new_cand = prev_cand | mask
792 overlap = prev_cand & mask
793 new_cannibal = _cn[level - 1]
794 if overlap:
795 new_cannibal |= overlap
796 if level < n:
797 if with_fins and not (base_cand & ~new_cand):
798 continue
799 _cc[level] = new_cand
800 _cn[level] = new_cannibal
801 level += 1
802 _ni[level] = ci + 1
803 else:
804 fins = base_cand & ~new_cand
805 # Java includes endo fins in the fin set for
806 # both the finnless/finned decision and the
807 # fin_buddies elimination check.
808 all_fins = fins | endo_fins
809 if not with_fins:
810 if all_fins:
811 continue
812 elim = cand_set & new_cand & ~base_cand
813 if new_cannibal:
814 elim |= cand_set & new_cannibal
815 if not elim:
816 continue
817 else:
818 fin_count = all_fins.bit_count()
819 if fin_count == 0:
820 continue
821 if fin_count > self._max_fins:
822 continue
823 fin_buddies = _fin_buddies(all_fins)
824 elim = cand_set & new_cand & fin_buddies & ~base_cand
825 if new_cannibal:
826 elim |= cand_set & new_cannibal & fin_buddies
827 if not elim:
828 continue
829 cover_bits = 0
830 for lv in range(1, n + 1):
831 cover_bits |= _unit_type_bit(cover_eligible[_pi[lv]][1])
832 actual_cat = _classify_fish(base_bits, cover_bits)
833 if actual_cat != fish_cat:
834 continue
835 elim_key = (cand, elim)
836 if elim_key in seen_elims:
837 continue
838 seen_elims.add(elim_key)
839 results.append(self._make_general_step(
840 sol_type, cand,
841 [base_pool[bi] for bi in base_combo],
842 [cover_eligible[_pi[lv]] for lv in range(1, n + 1)],
843 base_cand, elim, fins, endo_fins,
844 ))
846 return results
848 def _make_general_step(
849 self,
850 sol_type: SolutionType,
851 cand: int,
852 base_units: list[tuple[int, int]], # (unit_cands, unit_idx)
853 cover_units: list[tuple[int, int]],
854 base_cand: int,
855 elim: int,
856 fins: int,
857 endo_fins: int,
858 ) -> SolutionStep:
859 """Build a SolutionStep for a Franken/Mutant fish."""
860 step = SolutionStep(sol_type)
861 step.add_value(cand)
863 # Body cells: base candidates minus external fins
864 body = base_cand & ~fins
865 tmp = body
866 while tmp:
867 lsb = tmp & -tmp
868 step.add_index(lsb.bit_length() - 1)
869 tmp ^= lsb
871 # External fins (fins excluding endo fins)
872 from hodoku.core.solution_step import Candidate
873 ext_fins = fins & ~endo_fins
874 tmp = ext_fins
875 while tmp:
876 lsb = tmp & -tmp
877 step.fins.append(Candidate(lsb.bit_length() - 1, cand))
878 tmp ^= lsb
880 # Endo fins
881 tmp = endo_fins
882 while tmp:
883 lsb = tmp & -tmp
884 step.endo_fins.append(Candidate(lsb.bit_length() - 1, cand))
885 tmp ^= lsb
887 # Base entities
888 for _mask, uid in base_units:
889 if uid < 9:
890 step.base_entities.append(Entity(_LINE, uid + 1))
891 elif uid < 18:
892 step.base_entities.append(Entity(_COL, uid - 9 + 1))
893 else:
894 step.base_entities.append(Entity(_BLOCK_ENT, uid - 18 + 1))
896 # Cover entities
897 for _mask, uid in cover_units:
898 if uid < 9:
899 step.cover_entities.append(Entity(_LINE, uid + 1))
900 elif uid < 18:
901 step.cover_entities.append(Entity(_COL, uid - 9 + 1))
902 else:
903 step.cover_entities.append(Entity(_BLOCK_ENT, uid - 18 + 1))
905 # Eliminations
906 tmp = elim
907 while tmp:
908 lsb = tmp & -tmp
909 step.add_candidate_to_delete(lsb.bit_length() - 1, cand)
910 tmp ^= lsb
912 return step
914 # ------------------------------------------------------------------
915 # Step construction
916 # ------------------------------------------------------------------
918 def _make_step(
919 self,
920 sol_type: SolutionType,
921 cand: int,
922 base_units: tuple[int, ...],
923 cover_units: list[int],
924 base_mask: int,
925 elim_mask: int,
926 row_mode: bool,
927 fin_mask: int = 0,
928 ) -> SolutionStep:
929 step = SolutionStep(sol_type)
930 step.add_value(cand)
932 # Indices: body cells (base minus fins) carry the fish pattern;
933 # fin cells are recorded in step.fins
934 body_mask = base_mask & ~fin_mask
935 tmp = body_mask
936 while tmp:
937 lsb = tmp & -tmp
938 step.add_index(lsb.bit_length() - 1)
939 tmp ^= lsb
941 # Fin cells
942 from hodoku.core.solution_step import Candidate
943 tmp = fin_mask
944 while tmp:
945 lsb = tmp & -tmp
946 step.fins.append(Candidate(lsb.bit_length() - 1, cand))
947 tmp ^= lsb
949 # Base entities (rows or cols)
950 base_type = _LINE if row_mode else _COL
951 cover_type = _COL if row_mode else _LINE
952 for u in base_units:
953 step.base_entities.append(Entity(base_type, u + 1)) # 1-based
954 for ci in cover_units:
955 step.cover_entities.append(Entity(cover_type, ci + 1))
957 # Eliminations
958 tmp = elim_mask
959 while tmp:
960 lsb = tmp & -tmp
961 step.add_candidate_to_delete(lsb.bit_length() - 1, cand)
962 tmp ^= lsb
964 return step