changes + benchmarks

This commit is contained in:
2025-02-18 15:04:21 -05:00
parent ad28713775
commit 0d0b5786a2
7 changed files with 805 additions and 383 deletions

View File

@@ -1,383 +1,4 @@
use crate::{
agent::Agent,
board::{Board, Winner},
piece::Piece,
};
use indicatif::{ProgressIterator, ProgressStyle};
#[derive(Clone, Debug)]
struct Move {
/// `i` position of move
i: usize,
/// `j` position of move
j: usize,
/// [`Board`] state after move is made
board: Board,
/// Current winner of the match
winner: Winner,
/// Index of this move's parent
parent: Option<usize>,
/// Indices of this Move's Children
children: Vec<usize>,
/// Value of this move
value: i64,
color: Piece,
lazy_children: bool,
}
impl Move {
pub const fn coords(&self) -> (usize, usize) {
(self.i, self.j)
}
fn compute_self_value(&self, agent_color: Piece) -> i64 {
let mut self_value = self.board.net_score(agent_color) as i64;
if self.winner == Winner::Player(!agent_color) {
// if this board results in the opponent winning, MAJORLY negatively weigh this move
// NOTE! this branch isn't completely deleted because if so, the bot wouldn't make a move.
// We shouldn't prune branches because we still need to always react to the opponent's moves
self_value = i64::MIN;
} else if self.winner == Winner::Player(agent_color) {
// results in a win for the agent
self_value = i64::MAX;
}
// TODO! handle ties... what should they be valued as? maybe `i64::MAX / 2` or 0?
self_value
}
}
struct FutureMoves {
/// Arena containing all [`Move`]
arena: Vec<Move>,
/// Index of the [`Move`] tree's root node
current_root: Option<usize>,
/// Current generated depth of the Arena
current_depth: usize,
/// Target depth of children to generate
max_depth: usize,
/// Color w.r.t
agent_color: Piece,
}
impl FutureMoves {
pub const fn new(agent_color: Piece, max_depth: usize) -> Self {
Self {
arena: Vec::new(),
current_root: None,
current_depth: 0,
max_depth,
agent_color,
}
}
const LAZY_EXPIRE: usize = 6;
/// Generate children for all children of `nodes`
fn extend_layers(&mut self) {
let mut next_nodes: Vec<usize> = (0..self.arena.len())
// we want to select all nodes that don't have children, or are lazy (need to maybe be regenerated)
.filter(|&idx| self.arena[idx].children.is_empty() || self.arena[idx].lazy_children)
.filter(|&idx| self.is_connected_to_root(idx)) // put here so this will not extend needlessly before prunes
.collect();
for i in self.current_depth..=self.max_depth {
next_nodes = next_nodes
.into_iter()
.progress_with_style(
ProgressStyle::with_template(&format!(
"Generating children (depth: {}/{}): ({{pos}}/{{len}}) {{per_sec}}",
i, self.max_depth
))
.unwrap(),
)
.flat_map(|node_idx| {
self.generate_children(
node_idx,
if self.arena[node_idx].lazy_children
&& self.depth_of(node_idx) - 1 < Self::LAZY_EXPIRE
{
false
} else {
// this is a non-lazy_children child, should it be lazy?
i > Self::LAZY_EXPIRE
},
)
})
.flatten()
.collect();
}
self.current_depth = self.max_depth;
}
/// Determines if a [`Move`] at index `idx` is connected to `self.current_root`
/// Returns `false` if `self.current_root` is None
fn is_connected_to_root(&self, idx: usize) -> bool {
if let Some(root) = self.current_root {
let mut current = Some(idx);
while let Some(parent_idx) = current {
if parent_idx == root {
return true;
}
current = self.arena[parent_idx].parent;
}
}
false
}
/// Creates children for a parent (`parent`), returns an iterator it's children's indexes
fn generate_children(
&mut self,
parent_idx: usize,
lazy_children: bool,
) -> Option<impl Iterator<Item = usize>> {
// early-exit if a winner for the parent already exists
if self.arena[parent_idx].winner != Winner::None {
return None;
}
let new_color = !self.arena[parent_idx].color;
let parent_lazy = self.arena[parent_idx].lazy_children;
let mut new: Vec<Move> =
// use [`Board::all_positions`] here instead of [`Board::possible_moves`]
// because we use [`Board::what_if`] later and we want to reduce calls to [`Board::propegate_from_dry`]
Board::all_positions()
.flat_map(|(i, j)| self.arena[parent_idx].board.what_if(i, j, new_color).map(|x| (i, j, x)))
.map(|(i, j, new_board)| Move {
i,
j,
board: new_board,
winner: new_board.game_winner(!self.arena[parent_idx].color),
parent: Some(parent_idx),
children: Vec::new(),
value: 0,
color: new_color,
lazy_children,
}).collect();
// negative, because we want the max value to be at the first index
new.sort_by_key(|x| -x.compute_self_value(self.agent_color));
// keep the TOP_K children `self.agent_color`-color moves
const TOP_K_CHILDREN: usize = 1;
// we want to keep only the best move of the agent
if lazy_children && new_color == self.agent_color && new.len() > TOP_K_CHILDREN {
// TODO! Move this to `extend_layers` so we can prune based on recursive [`Move`] value
new.drain(TOP_K_CHILDREN..);
}
let start_idx = self.arena.len();
if parent_lazy && !lazy_children {
// this move's children are being regenerated after lazy child expiration, don't append first node
if new.len() > 1 {
self.arena.extend(new.drain(1..));
} else {
// nothing will be appended
// even though it was sorted the first time around
// there's still no more than one element (which is already in the arena)
return None;
}
} else {
self.arena.extend(new);
}
let new_indices = start_idx..self.arena.len();
self.arena[parent_idx].children.extend(new_indices.clone());
Some(new_indices)
}
/// Given an index from `self.arena`, what depth is it at? 1-indexed (ROOT IS AT INDEX 1)
fn depth_of(&self, node_idx: usize) -> usize {
let mut depth = 0;
let mut current = Some(node_idx);
while let Some(parent_idx) = current {
depth += 1;
current = self.arena[parent_idx].parent;
}
depth
}
fn compute_values(&mut self, indexes: impl Iterator<Item = usize>) {
// PERF! pre-organize all indexes based on what depth they're at
// previously, I did a lookup map based on if a node was visited, still resulted in a full
// O(n) iteration each depth
let mut by_depth: Vec<Vec<usize>> = (0..=self.max_depth + 2).map(|_| Vec::new()).collect();
for idx in indexes {
let depth = self.depth_of(idx);
// -1 because `depth_of` is one-indexed
by_depth[depth - 1].push(idx);
}
for (depth, nodes) in by_depth.into_iter().enumerate().rev() {
for idx in nodes {
let self_value =
self.arena[idx].compute_self_value(self.agent_color) / (depth + 1) as i64;
let children_value = self.arena[idx]
.children
.iter()
.map(|&child| self.arena[child].value)
.sum::<i64>()
.checked_div(self.arena[idx].children.len() as i64)
.unwrap_or(0);
self.arena[idx].value = self_value + children_value;
}
}
}
pub fn best_move(&self) -> Option<(usize, usize)> {
self.current_root
.and_then(|x| {
self.arena[x]
.children
.iter()
.max_by_key(|&&idx| self.arena[idx].value)
})
.inspect(|&&x| {
assert_eq!(
self.arena[x].color, self.agent_color,
"selected move color should be the same as the color of the agent"
);
})
.map(|&x| self.arena[x].coords())
}
/// Updates `FutureMoves` based on the current state of the board
/// The board is supposed to be after the opposing move
pub fn update(&mut self, board: &Board) {
let curr_board = self
.arena
.iter()
.enumerate()
.find(|(_, m)| {
&m.board == board && (m.parent == self.current_root) && self.current_root.is_some()
})
.map(|(idx, _)| idx);
if let Some(curr_board_idx) = curr_board {
self.update_root_idx(curr_board_idx);
} else {
println!("Generating root of FutureMoves");
self.arena.clear();
self.arena.push(Move {
i: 0,
j: 0,
board: *board,
winner: Winner::None,
parent: None,
children: Vec::new(),
value: 0,
color: !self.agent_color,
lazy_children: false,
});
self.update_root_idx(0);
}
}
/// Update the root based on the coordinate of the move
/// Returns a boolean, `true` if the operation was successful, false if not
#[must_use = "You must check if the root was properly set"]
pub fn update_root_coord(&mut self, i: usize, j: usize) -> bool {
self.arena
.iter()
.enumerate()
.filter(|(_, node)| {
node.parent == self.current_root
&& self.current_root.is_some()
&& node.i == i
&& node.j == j
})
.next()
.map(|x| x.0)
.inspect(|&root| self.update_root_idx(root))
.is_some()
}
fn update_root_idx(&mut self, idx: usize) {
self.current_root = Some(idx);
self.current_depth -= self.depth_of(idx) - 1;
self.refocus_tree();
self.extend_layers();
self.compute_values(0..self.arena.len());
}
/// Rebuilds the Arena based on `self.current_root`, prunes unrelated nodes
fn refocus_tree(&mut self) {
let Some(root) = self.current_root else {
return;
};
// make sure `root` doesn't reference another node
self.arena[root].parent = None;
let mut retain = vec![false; self.arena.len()];
// stack is going to be AT MAXIMUM, the size of the array,
// so lets just pre-allocate it
let mut stack: Vec<usize> = Vec::with_capacity(self.arena.len());
stack.push(root);
// traverse children of the current root
while let Some(idx) = stack.pop() {
retain[idx] = true;
stack.extend(self.arena[idx].children.iter());
}
let mut index_map = vec![None; self.arena.len()];
self.arena = retain
.into_iter()
.enumerate() // old_idx
.zip(self.arena.drain(..))
.flat_map(|((old_idx, keep), node)| keep.then_some((old_idx, node))) // filter out unrelated nodes
.enumerate() // new_idx
.map(|(new_idx, (old_idx, mut node))| {
index_map[old_idx] = Some(new_idx);
if let Some(parent) = node.parent.as_mut() {
if let Some(new_parent) = index_map[*parent] {
*parent = new_parent;
} else {
// make sure we don't have dangling parents
node.parent = None;
}
}
node.children.retain_mut(|c| {
if let Some(new_c) = index_map[*c] {
*c = new_c;
true
} else {
false
}
});
node
})
.collect();
self.current_root = index_map[root];
}
}
use crate::{agent::Agent, board::Board, future_moves::FutureMoves, piece::Piece};
pub struct ComplexAgent {
color: Piece,
@@ -386,10 +7,10 @@ pub struct ComplexAgent {
impl ComplexAgent {
pub const fn new(color: Piece) -> Self {
const MAX_DEPTH: usize = 10;
const MAX_DEPTH: usize = 15;
Self {
color,
future_moves: FutureMoves::new(color, MAX_DEPTH),
future_moves: FutureMoves::new(color, MAX_DEPTH, 4),
}
}
}
@@ -398,7 +19,7 @@ impl Agent for ComplexAgent {
fn next_move(&mut self, board: &Board) -> Option<(usize, usize)> {
self.future_moves.update(board);
println!("# of moves stored: {}", self.future_moves.arena.len());
println!("# of moves stored: {}", self.future_moves.len());
self.future_moves.best_move().inspect(|&(i, j)| {
if !self.future_moves.update_root_coord(i, j) {

381
src/future_moves.rs Normal file
View File

@@ -0,0 +1,381 @@
use indicatif::{ProgressIterator, ProgressStyle};
use crate::{
board::{Board, Winner},
piece::Piece,
};
#[derive(Clone, Debug)]
pub struct Move {
/// `i` position of move
i: usize,
/// `j` position of move
j: usize,
/// [`Board`] state after move is made
board: Board,
/// Current winner of the match
winner: Winner,
/// Index of this move's parent
parent: Option<usize>,
/// Indices of this Move's Children
children: Vec<usize>,
/// Value of this move
value: i64,
color: Piece,
lazy_children: bool,
}
impl Move {
pub const fn coords(&self) -> (usize, usize) {
(self.i, self.j)
}
fn compute_self_value(&self, agent_color: Piece) -> i64 {
let mut self_value = self.board.net_score(agent_color) as i64;
if self.winner == Winner::Player(!agent_color) {
// if this board results in the opponent winning, MAJORLY negatively weigh this move
// NOTE! this branch isn't completely deleted because if so, the bot wouldn't make a move.
// We shouldn't prune branches because we still need to always react to the opponent's moves
self_value = i64::MIN;
} else if self.winner == Winner::Player(agent_color) {
// results in a win for the agent
self_value = i64::MAX;
}
// TODO! handle ties... what should they be valued as? maybe `i64::MAX / 2` or 0?
self_value
}
}
pub struct FutureMoves {
/// Arena containing all [`Move`]
arena: Vec<Move>,
/// Index of the [`Move`] tree's root node
current_root: Option<usize>,
/// Current generated depth of the Arena
current_depth: usize,
/// Target depth of children to generate
max_depth: usize,
/// How many deep should the lazy children status expire?
lazy_expire: usize,
/// Color w.r.t
agent_color: Piece,
}
impl FutureMoves {
pub const fn new(agent_color: Piece, max_depth: usize, lazy_expire: usize) -> Self {
Self {
arena: Vec::new(),
current_root: None,
current_depth: 0,
max_depth,
agent_color,
lazy_expire,
}
}
pub fn len(&self) -> usize {
self.arena.len()
}
/// Generate children for all children of `nodes`
fn extend_layers(&mut self) {
let mut next_nodes: Vec<usize> = (0..self.arena.len())
// we want to select all nodes that don't have children, or are lazy (need to maybe be regenerated)
.filter(|&idx| self.arena[idx].children.is_empty() || self.arena[idx].lazy_children)
.filter(|&idx| self.is_connected_to_root(idx)) // put here so this will not extend needlessly before prunes
.collect();
for i in self.current_depth..=self.max_depth {
next_nodes = next_nodes
.into_iter()
.progress_with_style(
ProgressStyle::with_template(&format!(
"Generating children (depth: {}/{}): ({{pos}}/{{len}}) {{per_sec}}",
i, self.max_depth
))
.unwrap(),
)
.flat_map(|node_idx| {
self.generate_children(
node_idx,
if self.arena[node_idx].lazy_children {
self.depth_of(node_idx) - 1
} else {
i
} > self.lazy_expire,
)
})
.flatten()
.collect();
}
self.current_depth = self.max_depth;
}
/// Determines if a [`Move`] at index `idx` is connected to `self.current_root`
/// Returns `false` if `self.current_root` is None
fn is_connected_to_root(&self, idx: usize) -> bool {
if let Some(root) = self.current_root {
let mut current = Some(idx);
while let Some(parent_idx) = current {
if parent_idx == root {
return true;
}
current = self.arena[parent_idx].parent;
}
}
false
}
/// Creates children for a parent (`parent`), returns an iterator it's children's indexes
fn generate_children(
&mut self,
parent_idx: usize,
lazy_children: bool,
) -> Option<impl Iterator<Item = usize>> {
// early-exit if a winner for the parent already exists
if self.arena[parent_idx].winner != Winner::None {
return None;
}
let new_color = !self.arena[parent_idx].color;
let parent_lazy = self.arena[parent_idx].lazy_children;
let mut new: Vec<Move> =
// use [`Board::all_positions`] here instead of [`Board::possible_moves`]
// because we use [`Board::what_if`] later and we want to reduce calls to [`Board::propegate_from_dry`]
Board::all_positions()
.flat_map(|(i, j)| self.arena[parent_idx].board.what_if(i, j, new_color).map(|x| (i, j, x)))
.map(|(i, j, new_board)| Move {
i,
j,
board: new_board,
winner: new_board.game_winner(!self.arena[parent_idx].color),
parent: Some(parent_idx),
children: Vec::new(),
value: 0,
color: new_color,
lazy_children,
}).collect();
// negative, because we want the max value to be at the first index
new.sort_by_key(|x| -x.compute_self_value(self.agent_color));
// keep the TOP_K children `self.agent_color`-color moves
const TOP_K_CHILDREN: usize = 1;
// we want to keep only the best move of the agent
if lazy_children && new_color == self.agent_color && new.len() > TOP_K_CHILDREN {
new.drain(TOP_K_CHILDREN..);
}
let start_idx = self.arena.len();
if parent_lazy && !lazy_children {
self.arena[parent_idx].lazy_children = false;
// this move's children are being regenerated after lazy child expiration, don't append first node
if new.len() > 1 {
self.arena.extend(new.drain(1..));
} else {
// nothing will be appended
// even though it was sorted the first time around
// there's still no more than one element (which is already in the arena)
return None;
}
} else {
self.arena.extend(new);
}
let new_indices = start_idx..self.arena.len();
self.arena[parent_idx].children.extend(new_indices.clone());
Some(new_indices)
}
/// Given an index from `self.arena`, what depth is it at? 1-indexed (ROOT IS AT INDEX 1)
fn depth_of(&self, node_idx: usize) -> usize {
let mut depth = 0;
let mut current = Some(node_idx);
while let Some(parent_idx) = current {
depth += 1;
current = self.arena[parent_idx].parent;
}
depth
}
fn compute_values(&mut self, indexes: impl Iterator<Item = usize>) {
// PERF! pre-organize all indexes based on what depth they're at
// previously, I did a lookup map based on if a node was visited, still resulted in a full
// O(n) iteration each depth
let mut by_depth: Vec<Vec<usize>> = (0..=self.max_depth + 2).map(|_| Vec::new()).collect();
for idx in indexes {
let depth = self.depth_of(idx);
// -1 because `depth_of` is one-indexed
by_depth[depth - 1].push(idx);
}
for (depth, nodes) in by_depth.into_iter().enumerate().rev() {
for idx in nodes {
let self_value =
self.arena[idx].compute_self_value(self.agent_color) / (depth + 1) as i64;
let children_value = self.arena[idx]
.children
.iter()
.map(|&child| self.arena[child].value)
.sum::<i64>()
.checked_div(self.arena[idx].children.len() as i64)
.unwrap_or(0);
self.arena[idx].value = self_value + children_value;
}
}
}
pub fn best_move(&self) -> Option<(usize, usize)> {
self.current_root
.and_then(|x| {
self.arena[x]
.children
.iter()
.max_by_key(|&&idx| self.arena[idx].value)
})
.inspect(|&&x| {
assert_eq!(
self.arena[x].color, self.agent_color,
"selected move color should be the same as the color of the agent"
);
})
.map(|&x| self.arena[x].coords())
}
/// Updates `FutureMoves` based on the current state of the board
/// The board is supposed to be after the opposing move
pub fn update(&mut self, board: &Board) {
let curr_board = self
.arena
.iter()
.enumerate()
.find(|(_, m)| {
&m.board == board && (m.parent == self.current_root) && self.current_root.is_some()
})
.map(|(idx, _)| idx);
if let Some(curr_board_idx) = curr_board {
self.update_root_idx(curr_board_idx);
} else {
// println!("Generating root of FutureMoves");
self.arena.clear();
self.arena.push(Move {
i: 0,
j: 0,
board: *board,
winner: Winner::None,
parent: None,
children: Vec::new(),
value: 0,
color: !self.agent_color,
lazy_children: false,
});
self.update_root_idx(0);
}
}
/// Update the root based on the coordinate of the move
/// Returns a boolean, `true` if the operation was successful, false if not
#[must_use = "You must check if the root was properly set"]
pub fn update_root_coord(&mut self, i: usize, j: usize) -> bool {
self.arena
.iter()
.enumerate()
.find(|(_, node)| {
node.parent == self.current_root
&& self.current_root.is_some()
&& node.i == i
&& node.j == j
})
.map(|x| x.0)
.inspect(|&root| self.update_root_idx(root))
.is_some()
}
fn update_root_idx(&mut self, idx: usize) {
self.current_root = Some(idx);
self.current_depth -= self.depth_of(idx) - 1;
self.refocus_tree();
self.extend_layers();
self.compute_values(0..self.arena.len());
}
/// Rebuilds the Arena based on `self.current_root`, prunes unrelated nodes
fn refocus_tree(&mut self) {
let Some(root) = self.current_root else {
return;
};
// make sure `root` doesn't reference another node
self.arena[root].parent = None;
let mut retain = vec![false; self.arena.len()];
// stack is going to be AT MAXIMUM, the size of the array,
// so lets just pre-allocate it
let mut stack: Vec<usize> = Vec::with_capacity(self.arena.len());
stack.push(root);
// traverse children of the current root
while let Some(idx) = stack.pop() {
retain[idx] = true;
stack.extend(self.arena[idx].children.iter());
}
let mut index_map = vec![None; self.arena.len()];
self.arena = retain
.into_iter()
.enumerate() // old_idx
.zip(self.arena.drain(..))
.flat_map(|((old_idx, keep), node)| keep.then_some((old_idx, node))) // filter out unrelated nodes
.enumerate() // new_idx
.map(|(new_idx, (old_idx, mut node))| {
index_map[old_idx] = Some(new_idx);
if let Some(parent) = node.parent.as_mut() {
if let Some(new_parent) = index_map[*parent] {
*parent = new_parent;
} else {
// make sure we don't have dangling parents
node.parent = None;
}
}
node.children.retain_mut(|c| {
if let Some(new_c) = index_map[*c] {
*c = new_c;
true
} else {
false
}
});
node
})
.collect();
self.current_root = index_map[root];
}
}

8
src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
mod agent;
mod bitboard;
pub mod board;
mod complexagent;
pub mod future_moves;
mod game;
mod misc;
pub mod piece;

View File

@@ -5,6 +5,7 @@ mod agent;
mod bitboard;
mod board;
mod complexagent;
pub mod future_moves;
mod game;
mod misc;
mod piece;