Coverage for src / hodoku / core / solution_step.py: 100%
87 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
1from __future__ import annotations
3from dataclasses import dataclass, field
5from hodoku.core.types import SolutionType
8def _cell_name(index: int) -> str:
9 return f"r{index // 9 + 1}c{index % 9 + 1}"
12@dataclass(frozen=True)
13class Candidate:
14 """A (cell, digit) pair — used in candidates_to_delete lists."""
15 index: int # 0-80
16 value: int # 1-9
18 def __str__(self) -> str:
19 return f"{_cell_name(self.index)}<>{self.value}"
22@dataclass(frozen=True)
23class Entity:
24 """A house referenced in a hint description."""
25 type: int # Grid.ROW / Grid.COL / Grid.BOX / Grid.CELL
26 number: int # 1-based
29@dataclass
30class SolutionStep:
31 """One logical step: the technique applied plus enough data to
32 describe and apply it."""
34 type: SolutionType
36 # What to do to the grid
37 indices: list[int] = field(default_factory=list) # cells to set
38 values: list[int] = field(default_factory=list) # values for those cells
39 candidates_to_delete: list[Candidate] = field(default_factory=list)
41 # House context (for display / explanation)
42 entity: int = 0
43 entity_number: int = 0
44 entity2: int = 0
45 entity2_number: int = 0
46 base_entities: list[Entity] = field(default_factory=list)
47 cover_entities: list[Entity] = field(default_factory=list)
49 # Fish-specific
50 fins: list[Candidate] = field(default_factory=list)
51 endo_fins: list[Candidate] = field(default_factory=list)
52 is_siamese: bool = False
54 # Chain / coloring
55 chains: list[list[int]] = field(default_factory=list) # each chain is list[int] (packed entries)
56 color_candidates: dict[int, int] = field(default_factory=dict)
58 # ALS-specific
59 alses: list[tuple[int, int]] = field(default_factory=list) # (CellSet bits, candidate mask)
60 restricted_commons: list[tuple[int, int, int, int]] = field(default_factory=list) # (als1, als2, cand1, cand2)
62 # Progress scoring (filled in by SudokuSolver when requested)
63 progress_score: int = -1
64 progress_score_singles: int = -1
65 progress_score_singles_only: int = -1
67 def add_index(self, index: int) -> None:
68 self.indices.append(index)
70 def add_value(self, value: int) -> None:
71 self.values.append(value)
73 def add_candidate_to_delete(self, index: int, value: int) -> None:
74 self.candidates_to_delete.append(Candidate(index, value))
76 def add_als(self, indices: int, candidates: int) -> None:
77 self.alses.append((indices, candidates))
79 def reset(self) -> None:
80 """Clear all fields for reuse (mirrors Java's globalStep.reset())."""
81 self.indices.clear()
82 self.values.clear()
83 self.candidates_to_delete.clear()
84 self.chains.clear()
85 self.entity = 0
86 self.entity_number = 0
87 self.alses.clear()
88 self.endo_fins.clear()
89 self.fins.clear()
90 self.color_candidates.clear()
92 def is_net(self) -> bool:
93 """True if any chain contains a negative entry (net branch marker)."""
94 for chain in self.chains:
95 for entry in chain:
96 if entry < 0:
97 return True
98 return False
100 def get_chain_length(self) -> int:
101 """Total length across all chains (sum of chain list lengths)."""
102 total = 0
103 for chain in self.chains:
104 total += len(chain)
105 return total
107 def get_candidate_string(self) -> str:
108 """Canonical string for dedup — sorted candidates_to_delete.
110 Includes step type name so that different step types (e.g.
111 FORCING_CHAIN_CONTRADICTION vs FORCING_CHAIN_VERITY) that
112 happen to target the same candidates are kept in separate
113 dedup buckets — matching Java's getCandidateString().
115 IMPORTANT: sorts candidates_to_delete IN PLACE, matching Java's
116 Collections.sort(candidatesToDelete). Java's Candidate.compareTo
117 sorts by value first, then by index. The in-place sort is
118 required so that getIndexSumme() in compareTo() sees the same
119 ordering as Java.
120 """
121 self.candidates_to_delete.sort(key=lambda c: (c.value, c.index))
122 parts = [(c.index, c.value) for c in self.candidates_to_delete]
123 elim_str = ",".join(f"{i}:{v}" for i, v in parts)
124 return f"{elim_str} ({self.type.name})"
126 def get_single_candidate_string(self) -> str:
127 """Canonical string for dedup — sorted indices/values (set-cell steps).
129 Includes step type name for the same reason as
130 get_candidate_string() — matching Java's getSingleCandidateString().
131 """
132 parts = sorted(zip(self.indices, self.values))
133 cells_str = ",".join(f"{i}={v}" for i, v in parts)
134 return f"{self.type.name}: {cells_str}"
136 def __str__(self) -> str:
137 name = self.type.value
138 if self.indices:
139 cells = ", ".join(
140 f"{_cell_name(i)}={v}"
141 for i, v in zip(self.indices, self.values)
142 )
143 return f"{name}: {cells}"
144 if self.candidates_to_delete:
145 cells = ", ".join(str(c) for c in self.candidates_to_delete)
146 return f"{name}: {cells}"
147 return name
149 def __repr__(self) -> str:
150 return f"SolutionStep({self.type.name}, indices={self.indices}, values={self.values})"