//! Primary interface to working with the Blockfish engine. The [`Bot`] type controls an //! anytime algorithm that will provide a suggestion for the next move. It may be //! repeatedly polled by the `think` method in order to attempt to improve the suggestion. use alloc::collections::BinaryHeap; use alloc::vec::Vec; use mino::matrix::Mat; use mino::srs::{Piece, Queue}; mod node; use self::node::{Node, RawNodePtr}; pub(crate) use bumpalo::Bump as Arena; /// Encompasses an instance of the algorithm. pub struct Bot { algorithm: SegmentedAStar, // IMPORTANT: `arena` must occur after `algorithm` so that it is dropped last. arena: Arena, } impl Bot { /// Constructs a new bot from the given initial state (matrix and queue). // TODO: specify weights pub fn new(matrix: &Mat, queue: Queue<'_>) -> Self { let arena = bumpalo::Bump::new(); let root = Node::alloc_root(&arena, matrix, queue); let algorithm = SegmentedAStar::new(root); Self { algorithm, arena } } /// Perform a single "iteration" of work, which may end up improving the suggestion. /// What defines an iteration is vague, but similar versions of the engine should be /// deterministic, such that performing the same number of iterations gives the same /// resulting suggestion. pub fn think(&mut self) { self.algorithm.step(&self.arena); } /// Return the current best suggested placement. Returns `None` under two possible /// conditions: /// - `think` has not been called enough times to provide an initial suggestion. /// - there are no valid placements for the initial state pub fn suggest(&self) -> Option { self.algorithm.best().and_then(|node| node.root_placement()) } } // This implements an algorithm that is very similar to A* but has a slight // modification. Rather than one big open set, there are separate sets at each depth of // the search. After picking a node from one open set and expanding its children into the // successor set, we next pick a node from that successor set. This process continues // until a terminal node is reached. In order to select which open set to start picking // from next, we look globally at all the open sets and find the node with the best // rating; this part works similarly to as if there was only one open set. // // Only terminal nodes are compared in order to pick a suggestion. An interesting // consequence of this design is that on the first run of the algorithm we end up // performing a best-first-search, and the first terminal node found ends up being our // initial suggestion. This BFS terminates very quickly so it is nice from the perspective // of an anytime algorithm. // // The problem with directly applying A* for an anytime downstacking algorithm is that // simply looking for the best heuristic measurement (f) can lead you into a situation // where a node that only made 2 placements has a better score than all of the nodes with // 3+ placements, and thus it is considered the best. This is definitely not correct, // since that 2-placement node only leads to worse board states as you continue to place // pieces on the board. In downstacking you have to place all of your pieces, you can't // just stop after placing a few and arriving at a good board state! So before actually // considering a node to be a suggestion we have to make sure we run out all of the queue // first (i.e. its a terminal node), and only then should we check its rating. struct SegmentedAStar { open: Vec>, depth: usize, best: Option, } #[derive(Debug)] struct ShouldSelect; impl SegmentedAStar { fn new(root: &Node) -> Self { let mut open = Vec::with_capacity(root.queue().len()); open.push(BinaryHeap::new()); open[0].push(root.into()); Self { open, depth: 0, best: None, } } fn best(&self) -> Option<&Node> { self.best.map(|node| unsafe { node.as_node() }) } fn step(&mut self, arena: &Arena) { match self.expand(arena) { Ok(_) => {} Err(ShouldSelect) => self.select(), } } fn expand<'a>(&mut self, arena: &'a Arena) -> Result<&'a Node, ShouldSelect> { let open_set = self.open.get_mut(self.depth); let cand = open_set.map_or(None, |set| set.pop()).ok_or(ShouldSelect)?; let cand = unsafe { cand.0.as_node() }; if cand.is_terminal() { self.depth = self.open.len(); // makes expand() fail immediately self.backup(cand); return Err(ShouldSelect); } self.depth += 1; if self.open.len() <= self.depth { self.open.resize_with(self.depth + 1, BinaryHeap::new); } for suc in cand.expand(arena) { self.open[self.depth].push(suc.into()); } Ok(cand) } fn backup(&mut self, cand: &Node) { let rating = cand.rating(); if self.best().map_or(true, |n| rating < n.rating()) { tracing::debug!( "update suggestion ({}): {cand:?}", self.best.map_or("1st", |_| "new") ); self.best = Some(cand.into()); } } fn select(&mut self) { self.open .iter() .map(|set| set.peek().map(|node| unsafe { node.0.as_node() })) .enumerate() .filter(|(_, best)| best.is_some()) .min_by_key(|(_, best)| best.unwrap().rating()) .map(|(depth, _)| { self.depth = depth; }); } } // Wraps a `Node` pointer but implements `cmp::Ord` in order to compare by rating. #[derive(Copy, Clone)] struct AStarNode(RawNodePtr); impl From<&Node> for AStarNode { fn from(node: &Node) -> Self { Self(node.into()) } } impl core::cmp::Ord for AStarNode { fn cmp(&self, other: &Self) -> core::cmp::Ordering { let lhs = unsafe { self.0.as_node() }; let rhs = unsafe { other.0.as_node() }; // FIXME: add a deterministic tiebreaker lhs.rating().cmp(&rhs.rating()).reverse() } } impl core::cmp::PartialOrd for AStarNode { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl core::cmp::Eq for AStarNode {} impl core::cmp::PartialEq for AStarNode { fn eq(&self, other: &Self) -> bool { self.cmp(other).is_eq() } }