Source code for dedoku.techniques.naked
"""Naked candidate techniques: Single, Pair, Triple, and Quad.
A *naked* subset is a group of ``n`` unsolved cells inside one house whose
candidates, taken together, span exactly ``n`` digits. Those digits must go
into those cells, so they can be eliminated from every other cell of the
house. With ``n = 1`` the cell simply receives its only candidate.
"""
from __future__ import annotations
from itertools import combinations
from typing import TYPE_CHECKING
from .base import Elimination, Placement, Step, Technique
if TYPE_CHECKING:
from ..grid import Grid
__all__ = ["NakedSingle", "NakedPair", "NakedTriple", "NakedQuad"]
[docs]
class NakedSingle(Technique):
"""Place the value of any cell left with a single candidate."""
name = "Naked Single"
[docs]
def apply(self, grid: Grid) -> Step | None:
"""Find the first cell with exactly one candidate and solve it.
:param grid: The board to inspect and mutate.
:type grid: Grid
:returns: The placement performed, or ``None`` if every unsolved
cell still has two or more candidates.
:rtype: Step | None
"""
for cell in grid.cells:
if cell.is_solved or len(cell.candidates) != 1:
continue
digit = next(iter(cell.candidates))
cell.set_value(digit)
return Step(
technique=self.name,
description=f"{cell.label} has {digit} as its only candidate",
placements=(Placement(cell.row_index, cell.column_index, digit),),
)
return None
class _NakedSubset(Technique):
"""Shared implementation for naked pairs, triples, and quads.
:cvar size: Number of cells (and digits) 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 naked 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
naked subset of this size exists.
:rtype: Step | None
"""
for unit in grid.units:
unsolved = unit.unsolved_cells()
if len(unsolved) <= self.size:
continue
pool = [
cell for cell in unsolved
if 2 <= len(cell.candidates) <= self.size
]
for combo in combinations(pool, self.size):
digits: frozenset[int] = frozenset().union(
*(cell.candidates for cell in combo)
)
if len(digits) != self.size:
continue
eliminations: list[Elimination] = []
members = set(combo)
for cell in unsolved:
if cell in members:
continue
for digit in sorted(digits & cell.candidates):
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(digits))
cell_text = ", ".join(cell.label for cell in combo)
return Step(
technique=self.name,
description=(
f"naked {self.subset_word} {{{digit_text}}} in "
f"{cell_text} locks these digits inside "
f"{unit.name}"
),
eliminations=tuple(eliminations),
)
return None
[docs]
class NakedPair(_NakedSubset):
"""Two cells of a house sharing the same two candidates."""
name = "Naked Pair"
size = 2
subset_word = "pair"
[docs]
class NakedTriple(_NakedSubset):
"""Three cells of a house whose candidates span three digits."""
name = "Naked Triple"
size = 3
subset_word = "triple"
[docs]
class NakedQuad(_NakedSubset):
"""Four cells of a house whose candidates span four digits."""
name = "Naked Quad"
size = 4
subset_word = "quad"