Skip to main content

ai_tictactoe/
search.rs

1use crate::mv::Move;
2use crate::position::{GameResult, Position};
3use crate::tablebase::Tablebase;
4use std::fmt;
5use std::str::FromStr;
6
7/// Exact game-theoretic result from the current side's perspective.
8#[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/// Exact solving method used by [`search`].
40#[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/// Complete exact-search result for a position.
97#[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
123/// Solves `position` exactly with the selected `algorithm`.
124///
125/// Every algorithm returns the same game-theoretic answer; they differ only
126/// in the amount of repeated work they avoid.
127pub 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
189/// Counts leaf positions reached at exactly `depth` plies or at game end.
190pub 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}