Source code for dedoku.techniques.hidden

"""Hidden candidate techniques: Single, Pair, Triple, and Quad.

A *hidden* subset is a group of ``n`` digits whose candidate homes inside
one house are confined to the same ``n`` cells. Those cells must receive
those digits, so every other candidate can be stripped from them. With
``n = 1`` the digit has a single home and is placed directly.
"""

from __future__ import annotations

from itertools import combinations
from typing import TYPE_CHECKING

from ..exceptions import ContradictionError
from .base import Elimination, Placement, Step, Technique

if TYPE_CHECKING:
    from ..grid import Grid

__all__ = ["HiddenSingle", "HiddenPair", "HiddenTriple", "HiddenQuad"]


[docs] class HiddenSingle(Technique): """Place any digit that has a single possible home inside a house.""" name = "Hidden Single"
[docs] def apply(self, grid: Grid) -> Step | None: """Find the first digit confined to one cell of a house and place it. :param grid: The board to inspect and mutate. :type grid: Grid :returns: The placement performed, or ``None`` if every missing digit still has two or more homes in each house. :rtype: Step | None :raises ContradictionError: If a missing digit has no home left inside a house, which proves the board inconsistent. """ for unit in grid.units: for digit in sorted(unit.missing_values()): homes = unit.cells_with_candidate(digit) if not homes: raise ContradictionError( f"digit {digit} has no available cell in {unit.name}" ) if len(homes) == 1: cell = homes[0] cell.set_value(digit) return Step( technique=self.name, description=( f"in {unit.name}, digit {digit} fits only " f"in {cell.label}" ), placements=( Placement(cell.row_index, cell.column_index, digit), ), ) return None
class _HiddenSubset(Technique): """Shared implementation for hidden pairs, triples, and quads. :cvar size: Number of digits (and cells) forming the subset. :cvar subset_word: Human-readable subset name used in descriptions. """ size: int = 0 subset_word: str = "" def apply(self, grid: Grid) -> Step | None: """Find the first hidden subset that eliminates at least one candidate. :param grid: The board to inspect and mutate. :type grid: Grid :returns: The eliminations performed, or ``None`` if no productive hidden subset of this size exists. :rtype: Step | None """ for unit in grid.units: missing = sorted(unit.missing_values()) if len(missing) <= self.size: continue unsolved = unit.unsolved_cells() for digits in combinations(missing, self.size): digit_set = frozenset(digits) homes = [ cell for cell in unsolved if cell.candidates & digit_set ] if len(homes) != self.size: continue if not all( any(digit in cell.candidates for cell in homes) for digit in digit_set ): continue eliminations: list[Elimination] = [] for cell in homes: for digit in sorted(cell.candidates - digit_set): cell.remove_candidate(digit) eliminations.append( Elimination(cell.row_index, cell.column_index, digit) ) if eliminations: digit_text = ", ".join(str(d) for d in sorted(digit_set)) cell_text = ", ".join(cell.label for cell in homes) return Step( technique=self.name, description=( f"hidden {self.subset_word} {{{digit_text}}} in " f"{unit.name} confines these digits to " f"{cell_text}" ), eliminations=tuple(eliminations), ) return None
[docs] class HiddenPair(_HiddenSubset): """Two digits confined to the same two cells of a house.""" name = "Hidden Pair" size = 2 subset_word = "pair"
[docs] class HiddenTriple(_HiddenSubset): """Three digits confined to the same three cells of a house.""" name = "Hidden Triple" size = 3 subset_word = "triple"
[docs] class HiddenQuad(_HiddenSubset): """Four digits confined to the same four cells of a house.""" name = "Hidden Quad" size = 4 subset_word = "quad"