1use crate::mv::Move;
2use crate::position::{GameResult, Position};
3use crate::tablebase::Tablebase;
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Outcome {
10 Loss = -1,
11 Draw = 0,
12 Win = 1,
13}
14
15impl Outcome {
16 pub const fn negate(self) -> Self {
17 match self {
18 Self::Loss => Self::Win,
19 Self::Draw => Self::Draw,
20 Self::Win => Self::Loss,
21 }
22 }
23
24 pub const fn as_i8(self) -> i8 {
25 self as i8
26 }
27}
28
29impl fmt::Display for Outcome {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 Self::Loss => write!(f, "loss"),
33 Self::Draw => write!(f, "draw"),
34 Self::Win => write!(f, "win"),
35 }
36 }
37}
38
39#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
41pub enum Algorithm {
42 Plain,
43 Memo,
44 Symmetry,
45 #[default]
46 Tablebase,
47}
48
49impl Algorithm {
50 pub const ALL: [Self; 4] = [Self::Plain, Self::Memo, Self::Symmetry, Self::Tablebase];
51
52 pub const fn name(self) -> &'static str {
53 match self {
54 Self::Plain => "plain",
55 Self::Memo => "memo",
56 Self::Symmetry => "symmetry",
57 Self::Tablebase => "tablebase",
58 }
59 }
60}
61
62impl fmt::Display for Algorithm {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(self.name())
65 }
66}
67
68impl FromStr for Algorithm {
69 type Err = &'static str;
70
71 fn from_str(value: &str) -> Result<Self, Self::Err> {
72 match value.to_ascii_lowercase().as_str() {
73 "plain" | "negamax" => Ok(Self::Plain),
74 "memo" | "memoized" => Ok(Self::Memo),
75 "symmetry" | "canonical" => Ok(Self::Symmetry),
76 "tablebase" | "perfect" => Ok(Self::Tablebase),
77 _ => Err("algorithm must be plain, memo, symmetry, or tablebase"),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
83pub struct SearchStats {
84 pub nodes: u64,
85 pub cache_hits: u64,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Candidate {
90 pub mv: Move,
91 pub outcome: Outcome,
92 pub distance: u8,
93 pub nodes: u64,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct SearchReport {
99 pub algorithm: Algorithm,
100 pub best_move: Option<Move>,
101 pub outcome: Outcome,
102 pub distance: u8,
103 pub stats: SearchStats,
104 pub candidates: Vec<Candidate>,
105 pub pv: Vec<Move>,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub(crate) struct Solved {
110 pub outcome: Outcome,
111 pub distance: u8,
112}
113
114impl Solved {
115 fn from_child(child: Self) -> Self {
116 Self {
117 outcome: child.outcome.negate(),
118 distance: child.distance + 1,
119 }
120 }
121}
122
123pub fn search(position: Position, algorithm: Algorithm, tablebase: &Tablebase) -> SearchReport {
128 if position.result() != GameResult::Ongoing {
129 return SearchReport {
130 algorithm,
131 best_move: None,
132 outcome: terminal_value(position),
133 distance: 0,
134 stats: SearchStats {
135 nodes: 1,
136 cache_hits: 0,
137 },
138 candidates: Vec::new(),
139 pv: Vec::new(),
140 };
141 }
142
143 let mut stats = SearchStats::default();
144 let mut cache = vec![None; Position::KEY_SPACE];
145 let mut candidates = Vec::new();
146 let mut best: Option<(Move, Solved)> = None;
147
148 for mv in position.legal_moves() {
149 let before = stats.nodes;
150 let mut child = position;
151 child.make_move(mv).expect("legal move must apply");
152 let solved = match algorithm {
153 Algorithm::Plain => Solved::from_child(negamax_plain(&mut child, &mut stats)),
154 Algorithm::Memo => {
155 Solved::from_child(negamax_cached(&mut child, &mut cache, false, &mut stats))
156 }
157 Algorithm::Symmetry => {
158 Solved::from_child(negamax_cached(&mut child, &mut cache, true, &mut stats))
159 }
160 Algorithm::Tablebase => {
161 stats.nodes += 1;
162 Solved::from_child(tablebase.value(child))
163 }
164 };
165 candidates.push(Candidate {
166 mv,
167 outcome: solved.outcome,
168 distance: solved.distance,
169 nodes: stats.nodes - before,
170 });
171 if best.is_none_or(|(_, incumbent)| is_better(solved, incumbent)) {
172 best = Some((mv, solved));
173 }
174 }
175
176 let (best_move, solved) = best.expect("ongoing position has a legal move");
177 let pv = extract_pv(position, algorithm, tablebase);
178 SearchReport {
179 algorithm,
180 best_move: Some(best_move),
181 outcome: solved.outcome,
182 distance: solved.distance,
183 stats,
184 candidates,
185 pv,
186 }
187}
188
189pub fn perft(position: &mut Position, depth: u8) -> u64 {
191 if depth == 0 || position.result() != GameResult::Ongoing {
192 return 1;
193 }
194 let moves: Vec<_> = position.legal_moves().collect();
195 moves
196 .into_iter()
197 .map(|mv| {
198 position.make_move(mv).expect("generated move is legal");
199 let nodes = perft(position, depth - 1);
200 position.unmake_move(mv);
201 nodes
202 })
203 .sum()
204}
205
206fn negamax_plain(position: &mut Position, stats: &mut SearchStats) -> Solved {
207 stats.nodes += 1;
208 if position.result() != GameResult::Ongoing {
209 return Solved {
210 outcome: terminal_value(*position),
211 distance: 0,
212 };
213 }
214
215 let moves: Vec<_> = position.legal_moves().collect();
216 let mut best: Option<Solved> = None;
217 for mv in moves {
218 position.make_move(mv).expect("generated move is legal");
219 let candidate = Solved::from_child(negamax_plain(position, stats));
220 position.unmake_move(mv);
221 if best.is_none_or(|incumbent| is_better(candidate, incumbent)) {
222 best = Some(candidate);
223 }
224 }
225 best.expect("ongoing position has a legal move")
226}
227
228fn negamax_cached(
229 position: &mut Position,
230 cache: &mut [Option<Solved>],
231 use_symmetry: bool,
232 stats: &mut SearchStats,
233) -> Solved {
234 stats.nodes += 1;
235 let key = if use_symmetry {
236 position.canonical_key()
237 } else {
238 position.key()
239 };
240 if let Some(solved) = cache[key] {
241 stats.cache_hits += 1;
242 return solved;
243 }
244 if position.result() != GameResult::Ongoing {
245 let solved = Solved {
246 outcome: terminal_value(*position),
247 distance: 0,
248 };
249 cache[key] = Some(solved);
250 return solved;
251 }
252
253 let moves: Vec<_> = position.legal_moves().collect();
254 let mut best: Option<Solved> = None;
255 for mv in moves {
256 position.make_move(mv).expect("generated move is legal");
257 let candidate = Solved::from_child(negamax_cached(position, cache, use_symmetry, stats));
258 position.unmake_move(mv);
259 if best.is_none_or(|incumbent| is_better(candidate, incumbent)) {
260 best = Some(candidate);
261 }
262 }
263 let solved = best.expect("ongoing position has a legal move");
264 cache[key] = Some(solved);
265 solved
266}
267
268fn terminal_value(position: Position) -> Outcome {
269 match position.result() {
270 GameResult::Draw => Outcome::Draw,
271 GameResult::Win(winner) if winner == position.side_to_move() => Outcome::Win,
272 GameResult::Win(_) => Outcome::Loss,
273 GameResult::Ongoing => unreachable!("terminal_value called on an ongoing position"),
274 }
275}
276
277fn is_better(candidate: Solved, incumbent: Solved) -> bool {
278 match candidate.outcome.as_i8().cmp(&incumbent.outcome.as_i8()) {
279 std::cmp::Ordering::Greater => true,
280 std::cmp::Ordering::Less => false,
281 std::cmp::Ordering::Equal => match candidate.outcome {
282 Outcome::Win => candidate.distance < incumbent.distance,
283 Outcome::Draw => candidate.distance < incumbent.distance,
284 Outcome::Loss => candidate.distance > incumbent.distance,
285 },
286 }
287}
288
289fn extract_pv(mut position: Position, algorithm: Algorithm, tablebase: &Tablebase) -> Vec<Move> {
290 let mut pv = Vec::new();
291 while position.result() == GameResult::Ongoing {
292 let mut best: Option<(Move, Solved)> = None;
293 for mv in position.legal_moves() {
294 let mut child = position;
295 child.make_move(mv).expect("legal move must apply");
296 let child_value = match algorithm {
297 Algorithm::Tablebase => tablebase.value(child),
298 _ => {
299 let mut ignored = SearchStats::default();
300 let mut cache = vec![None; Position::KEY_SPACE];
301 match algorithm {
302 Algorithm::Plain => negamax_plain(&mut child, &mut ignored),
303 Algorithm::Memo => {
304 negamax_cached(&mut child, &mut cache, false, &mut ignored)
305 }
306 Algorithm::Symmetry => {
307 negamax_cached(&mut child, &mut cache, true, &mut ignored)
308 }
309 Algorithm::Tablebase => unreachable!(),
310 }
311 }
312 };
313 let solved = Solved::from_child(child_value);
314 if best.is_none_or(|(_, incumbent)| is_better(solved, incumbent)) {
315 best = Some((mv, solved));
316 }
317 }
318 let Some((mv, _)) = best else {
319 break;
320 };
321 position.make_move(mv).expect("PV move must apply");
322 pv.push(mv);
323 }
324 pv
325}