Source code for dedoku.techniques.als
"""Almost Locked Set techniques: the ALS-XZ rule.
An *almost locked set* (ALS) is a group of ``n`` unsolved cells inside one
house whose candidates span exactly ``n + 1`` digits — one candidate away
from a naked subset. A single bivalue cell is the smallest ALS.
**ALS-XZ rule.** Take two disjoint ALSs *A* and *B* sharing digits ``x``
and ``z``, where ``x`` is a *restricted common*: every ``x``-cell of *A*
sees every ``x``-cell of *B*, so ``x`` can be placed in at most one of the
two sets. If an outside cell holding ``z`` saw every ``z``-cell of both
sets, ``z`` would vanish from *A* and *B*, locking both and forcing ``x``
into each of them — impossible. Such cells therefore lose ``z``.
This single rule subsumes many named patterns (WXYZ-Wing, Sue-de-Coq and
parts of Death Blossom arise as special cases of small ALS pairs).
"""
from __future__ import annotations
from itertools import combinations
from typing import TYPE_CHECKING
from .base import Elimination, Step, Technique
if TYPE_CHECKING:
from ..grid import Grid
__all__ = ["AlsXz"]
_MAX_ALS_SIZE = 4
"""int: Largest ALS enumerated; bigger sets are rare and costly."""
[docs]
class AlsXz(Technique):
"""Eliminate through a pair of ALSs joined by a restricted common."""
name = "ALS-XZ"
[docs]
def apply(self, grid: Grid) -> Step | None:
"""Find the first productive ALS-XZ elimination.
:param grid: The board to inspect and mutate.
:type grid: Grid
:returns: The eliminations performed, or ``None`` if no productive
ALS pair exists.
:rtype: Step | None
"""
sets = self._collect(grid)
for index, (cells_a, cands_a) in enumerate(sets):
for cells_b, cands_b in sets[index + 1:]:
if cells_a & cells_b:
continue
common = cands_a & cands_b
if len(common) < 2:
continue
step = self._match(grid, cells_a, cells_b, common)
if step is not None:
return step
return None
@staticmethod
def _collect(grid: Grid) -> list[tuple[frozenset, frozenset]]:
"""Enumerate every ALS of the board up to the size cap.
:param grid: The board being searched.
:type grid: Grid
:returns: Deduplicated ``(cells, candidates)`` pairs, in a
deterministic order.
:rtype: list[tuple[frozenset[Cell], frozenset[int]]]
"""
seen: set[frozenset] = set()
sets: list[tuple[frozenset, frozenset]] = []
for unit in grid.units:
unsolved = unit.unsolved_cells()
for size in range(1, min(_MAX_ALS_SIZE, len(unsolved)) + 1):
for combo in combinations(unsolved, size):
cands = frozenset().union(
*(cell.candidates for cell in combo)
)
if len(cands) != size + 1:
continue
cells = frozenset(combo)
if cells in seen:
continue
seen.add(cells)
sets.append((cells, cands))
return sets
def _match(
self,
grid: Grid,
cells_a: frozenset,
cells_b: frozenset,
common: frozenset,
) -> Step | None:
"""Try every restricted common ``x`` and elimination digit ``z``.
:param grid: The board to inspect and mutate.
:type grid: Grid
:param cells_a: The cells of the first ALS.
:type cells_a: frozenset[Cell]
:param cells_b: The cells of the second ALS.
:type cells_b: frozenset[Cell]
:param common: The digits shared by both ALSs.
:type common: frozenset[int]
:returns: The eliminations performed, or ``None``.
:rtype: Step | None
"""
members = cells_a | cells_b
for x in sorted(common):
holders_a = [cell for cell in cells_a if x in cell.candidates]
holders_b = [cell for cell in cells_b if x in cell.candidates]
if not all(
first.sees(second)
for first in holders_a
for second in holders_b
):
continue
for z in sorted(common - {x}):
z_cells = [
cell for cell in members if z in cell.candidates
]
# Cells seeing every z-cell are exactly the common peers.
witnesses = set(z_cells[0].peers)
for z_cell in z_cells[1:]:
witnesses.intersection_update(z_cell.peers)
eliminations: list[Elimination] = []
for cell in sorted(
witnesses, key=lambda c: c.position
):
if cell in members or z not in cell.candidates:
continue
cell.remove_candidate(z)
eliminations.append(
Elimination(cell.row_index, cell.column_index, z)
)
if eliminations:
label = lambda cells: ", ".join( # noqa: E731
cell.label
for cell in sorted(cells, key=lambda c: c.position)
)
return Step(
technique=self.name,
description=(
f"ALS-XZ with sets ({label(cells_a)}) and "
f"({label(cells_b)}), restricted common {x}: "
f"digit {z} is removed from every cell seeing "
f"all its homes in both sets"
),
eliminations=tuple(eliminations),
)
return None