diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/board.rs | 211 | ||||
| -rw-r--r-- | src/claude-player.rs | 2 | ||||
| -rw-r--r-- | src/game.rs | 2 | ||||
| -rw-r--r-- | src/main.rs | 59 | ||||
| -rw-r--r-- | src/network.rs | 425 | ||||
| -rw-r--r-- | src/neural-player.rs | 68 | ||||
| -rw-r--r-- | src/player.rs | 27 | ||||
| -rw-r--r-- | src/train.rs | 353 |
8 files changed, 1124 insertions, 23 deletions
diff --git a/src/board.rs b/src/board.rs index 1c21c3e..087ac0e 100644 --- a/src/board.rs +++ b/src/board.rs @@ -218,6 +218,94 @@ fn pos_of(idx: usize) -> Pos ((idx % 5) as u8, (idx / 5) as u8) } +/// The board's 8 symmetries (rotations and reflections) as a point map: bit 2 of `sym` swaps x and +/// y, then bit 0 mirrors x and bit 1 mirrors y. They all keep the board lines, since x + y keeps +/// its parity, so a transformed position is just as legal and just as good for either side. +pub const SYMMETRIES: usize = 8; + +pub const fn transform_pos((x, y): Pos, sym: usize) -> Pos +{ + let (x, y) = if sym & 4 != 0 { (y, x) } else { (x, y) }; + let x = if sym & 1 != 0 { 4 - x } else { x }; + let y = if sym & 2 != 0 { 4 - y } else { y }; + (x, y) +} + +/// For each symmetry, where each bit index goes. +const SYMMETRY_MAPS: [[u8; 25]; SYMMETRIES] = { + let mut maps = [[0; 25]; SYMMETRIES]; + let mut sym = 0; + while sym < SYMMETRIES + { + let mut idx = 0; + while idx < 25 + { + let (x, y) = transform_pos(((idx % 5) as u8, (idx / 5) as u8), sym); + maps[sym][idx] = x + y * 5; + idx += 1; + } + sym += 1; + } + maps +}; + +/// For each symmetry and row, where each of the 32 possible sets of points in that row go, so a +/// whole mask transforms in 5 lookups. The search does this for every cache probe. +const ROW_MAPS: [[[u32; 32]; 5]; SYMMETRIES] = { + let mut maps = [[[0; 32]; 5]; SYMMETRIES]; + let mut sym = 0; + while sym < SYMMETRIES + { + let mut row = 0; + while row < 5 + { + let mut bits = 0; + while bits < 32 + { + let mut x = 0; + while x < 5 + { + if bits & 1 << x != 0 + { + maps[sym][row][bits] |= 1 << SYMMETRY_MAPS[sym][x + row * 5]; + } + x += 1; + } + bits += 1; + } + row += 1; + } + sym += 1; + } + maps +}; + +#[inline(always)] +fn transform_mask(mask: u32, sym: usize) -> u32 +{ + let rows = &ROW_MAPS[sym]; + rows[0][(mask & 31) as usize] | rows[1][(mask >> 5 & 31) as usize] | rows[2][(mask >> 10 & 31) as usize] + | rows[3][(mask >> 15 & 31) as usize] | rows[4][(mask >> 20 & 31) as usize] +} + +/// The symmetry that undoes `sym`. The mirrors undo themselves, but with the swap of x and y +/// first, undoing them means swapping which axis is mirrored. +pub const fn inverse_symmetry(sym: usize) -> usize +{ + if sym & 4 == 0 { sym } else { 4 | (sym & 1) << 1 | (sym >> 1 & 1) } +} + +/// `mv` as played on the board transformed by `sym`. +pub fn transform_move(mv: Move, sym: usize) -> Move +{ + match mv + { + Move::PlaceSheep { pos } => Move::PlaceSheep { pos: transform_pos(pos, sym) }, + Move::MoveSheep { from, to } => Move::MoveSheep { from: transform_pos(from, sym), to: transform_pos(to, sym) }, + Move::MoveTiger { from, to } => Move::MoveTiger { from: transform_pos(from, sym), to: transform_pos(to, sym) }, + } +} + impl GameBoard { /// No pieces on the board and none in hand. @@ -266,6 +354,37 @@ impl GameBoard /// The whole board, sheep in hand included, as one number: different boards give different keys. pub fn key(&self) -> u64 { (self.sheep_mask as u64) << 32 | self.tiger_mask as u64 } pub fn sheep_count(&self) -> u32 { (self.sheep_mask & BOARD_MASK).count_ones() } + /// The points holding a tiger, as bits `x + y*5`. + pub fn tiger_points(&self) -> u32 { self.tiger_mask } + /// The points holding a sheep, as bits `x + y*5`. + pub fn sheep_points(&self) -> u32 { self.sheep_mask & BOARD_MASK } + + /// The board under symmetry `sym` (see `transform_pos`), with the same sheep in hand. + pub fn transformed(&self, sym: usize) -> GameBoard + { + GameBoard + { + tiger_mask: transform_mask(self.tiger_mask, sym), + sheep_mask: transform_mask(self.sheep_points(), sym) | (self.sheep_mask & !BOARD_MASK), + } + } + + /// One board standing for all 8 symmetric ones (the one whose `key` is smallest), and the + /// symmetry that takes `self` to it. Symmetric boards are equally good, so the search caches + /// them under one entry. + pub fn canonical(&self) -> (GameBoard, usize) + { + let mut best = (*self, 0); + for sym in 1..SYMMETRIES + { + let t = self.transformed(sym); + if t.key() < best.0.key() + { + best = (t, sym); + } + } + best + } /// Empty board points. fn empty(&self) -> u32 @@ -1246,4 +1365,96 @@ mod tests assert!(board.check_win(Turn::Sheep)); assert!(!board.check_win(Turn::Tiger)); } + + // --- symmetries --- + + #[test] + fn symmetries_are_distinct_bijections() + { + for sym in 0..SYMMETRIES + { + let mut seen = 0u32; + for idx in 0..25 + { + seen |= 1 << SYMMETRY_MAPS[sym][idx]; + } + assert_eq!(seen, BOARD_MASK, "symmetry {sym} is not a bijection"); + for other in 0..sym + { + assert_ne!(SYMMETRY_MAPS[sym], SYMMETRY_MAPS[other], "symmetries {sym} and {other}"); + } + } + assert_eq!(transform_pos((1, 0), 0), (1, 0)); + assert_eq!(GameBoard::default().transformed(5), GameBoard::default()); + } + + #[test] + fn inverse_symmetry_undoes_it() + { + for sym in 0..SYMMETRIES + { + for idx in 0..25 + { + let pos = pos_of(idx); + assert_eq!(transform_pos(transform_pos(pos, sym), inverse_symmetry(sym)), pos, "symmetry {sym}"); + } + } + } + + #[test] + fn canonical_is_shared_by_symmetric_boards() + { + let mut seed: u64 = 0x94D049BB133111EB; + let mut next = || { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; seed }; + let mut board = GameBoard::default(); + let mut turn = Turn::Sheep; + for _ in 0..60 + { + if board.check_win(turn) { break; } + let (canon, sym) = board.canonical(); + assert_eq!(board.transformed(sym), canon); + for s in 0..SYMMETRIES + { + let t = board.transformed(s); + assert_eq!(t.canonical().0, canon, "symmetry {s} of {board:?}"); + // The fast transform agrees with moving each point + let slow = |mask: u32| indices(mask).fold(0, |m, idx| m | 1 << SYMMETRY_MAPS[s][idx]); + assert_eq!((t.tiger_points(), t.sheep_points()), (slow(board.tiger_points()), slow(board.sheep_points()))); + } + let moves = board.get_moves(turn); + board.make_move_unchecked(moves[(next() % moves.len() as u64) as usize]); + turn = turn.next_turn(); + } + } + + #[test] + fn transformed_boards_have_transformed_moves() + { + let mut seed: u64 = 0xD1B54A32D192ED03; + let mut next = || { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; seed }; + let mut board = GameBoard::default(); + let mut turn = Turn::Sheep; + for _ in 0..80 + { + if board.check_win(turn) { break; } + for sym in 0..SYMMETRIES + { + let t = board.transformed(sym); + assert_eq!(t.sheep_left(), board.sheep_left()); + assert_eq!(t.sheep_count(), board.sheep_count()); + for side in [Turn::Tiger, Turn::Sheep] + { + let mut expected: Vec<Move> = board.get_moves(side).into_iter().map(|m| transform_move(m, sym)).collect(); + expected.sort(); + let mut moves = t.get_moves(side); + moves.sort(); + assert_eq!(moves, expected, "symmetry {sym}, {side:?} on {board:?}"); + assert_eq!(t.check_win(side), board.check_win(side)); + } + } + let moves = board.get_moves(turn); + board.make_move_unchecked(moves[(next() % moves.len() as u64) as usize]); + turn = turn.next_turn(); + } + } } diff --git a/src/claude-player.rs b/src/claude-player.rs index 5cbd794..c25c5e1 100644 --- a/src/claude-player.rs +++ b/src/claude-player.rs @@ -39,7 +39,7 @@ impl MinimaxPlayer for ClaudePlayer /// - jumps the tigers could make right now (sheep left hanging); /// - tigers boxed in with no move at all, since trapping every tiger wins for the sheep; /// - tiger mobility in general, since the sheep win by squeezing it to zero. - fn evaluate(&mut self, board: &GameBoard) -> f32 + fn evaluate(&mut self, board: &GameBoard, _turn: Turn) -> f32 { let dead = TOTAL_SHEEP - board.sheep_count() - board.sheep_left(); diff --git a/src/game.rs b/src/game.rs index 11a77fd..5aef809 100644 --- a/src/game.rs +++ b/src/game.rs @@ -87,7 +87,7 @@ impl GameHandler /// Play in the terminal with `p1` as the sheep and `p2` as the tigers, until someone wins and /// the user quits. Returns the winner, or None if the user quit before the game was won. - pub fn play_game<T1: Player, T2: Player>(&mut self, p1: &mut T1, p2: &mut T2) -> io::Result<Option<Turn>> + pub fn play_game(&mut self, p1: &mut dyn Player, p2: &mut dyn Player) -> io::Result<Option<Turn>> { let mut terminal = ratatui::init(); let result = execute!(io::stdout(), EnableMouseCapture) diff --git a/src/main.rs b/src/main.rs index 16467b0..8f6968d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ -use std::time::Duration; +use std::{path::Path, time::Duration}; -use crate::{claude_player::ClaudePlayer, game::GameHandler, local_player::LocalPlayer}; +use crate::{board::Turn, claude_player::ClaudePlayer, game::GameHandler, local_player::LocalPlayer, network::Network, neural_player::NeuralPlayer, player::Player}; mod board; mod player; @@ -12,20 +12,59 @@ mod local_player; #[path = "claude-player.rs"] mod claude_player; mod bench; +mod network; +#[path = "neural-player.rs"] +mod neural_player; +mod train; fn main() -> std::io::Result<()> { - // `bench [depth]` times the computer player's search without the TUI let args: Vec<String> = std::env::args().collect(); - if args.get(1).is_some_and(|a| a == "bench") + let arg = |i: usize| args.get(i).map(String::as_str); + let number = |i: usize, default: usize| arg(i).map_or(default, |n| n.parse().expect("expected a number")); + match arg(1) { - let depth = args.get(2).map_or(6, |d| d.parse().expect("depth should be a number")); - bench::run(depth); - return Ok(()); + // `bench [depth]` times the computer player's search without the TUI + Some("bench") => + { + bench::run(number(2, 6)); + return Ok(()); + } + // `train [generations] [games per generation] [out dir]` trains a network by self-play + Some("train") => + { + let defaults = train::Config::default(); + let cfg = train::Config + { + generations: number(2, defaults.generations), + games_per_generation: number(3, defaults.games_per_generation), + out_dir: arg(4).map_or(defaults.out_dir.clone(), Into::into), + ..defaults + }; + return train::run(&cfg); + } + // `neural <weights> [sheep|tiger]`: you play the given side against a trained network + Some("neural") => + { + let net = Network::load(Path::new(arg(2).expect("usage: neural <weights file> [sheep|tiger]")))?; + let side = match arg(3) { Some("tiger") => Turn::Tiger, _ => Turn::Sheep }; + let mut computer = NeuralPlayer::new(net, Duration::from_secs(2), 20); + let mut human = LocalPlayer::default(); + let (sheep, tiger): (&mut dyn Player, &mut dyn Player) = match side + { + Turn::Sheep => (&mut human, &mut computer), + Turn::Tiger => (&mut computer, &mut human), + }; + return play(sheep, tiger); + } + _ => (), } + play(&mut ClaudePlayer::new(Duration::from_secs(2)), &mut ClaudePlayer::new(Duration::from_secs(2))) +} + +fn play(sheep: &mut dyn Player, tiger: &mut dyn Player) -> std::io::Result<()> +{ let mut game = GameHandler::default(); - let winner = game.play_game(&mut ClaudePlayer::new(Duration::from_secs(2)), - &mut ClaudePlayer::new(Duration::from_secs(2)))?; - if let Some(winner) = winner + if let Some(winner) = game.play_game(sheep, tiger)? { println!("{winner:?} wins!"); } diff --git a/src/network.rs b/src/network.rs new file mode 100644 index 0000000..5836798 --- /dev/null +++ b/src/network.rs @@ -0,0 +1,425 @@ +use std::{fs, io, path::Path}; + +use crate::board::{GameBoard, Turn}; + +/// Inputs: a bit per point for tigers (0..25) and for sheep (25..50), the sheep in hand over 20 +/// (`HAND`) and 1 if tigers are to move (`TIGER_TO_MOVE`). Nothing else, so the network has to +/// work out for itself what makes a position good. +pub const INPUTS: usize = 52; +const HAND: usize = 50; +const TIGER_TO_MOVE: usize = 51; +pub const HIDDEN1: usize = 64; +pub const HIDDEN2: usize = 32; + +// All the weights live in one flat Vec, at these offsets, so the optimiser, the gradient and +// save/load can treat them as a single list of numbers. +/// Layer 1 weights, input-major: input i's weight to hidden unit j is at `W1 + i*HIDDEN1 + j`, +/// so an input's weights are contiguous and adding up the set bits' columns is cheap. +const W1: usize = 0; +const B1: usize = W1 + INPUTS * HIDDEN1; +/// Layer 2 weights, unit-major: hidden1 unit j's weight to hidden2 unit k is at `W2 + k*HIDDEN1 + j`. +const W2: usize = B1 + HIDDEN1; +const B2: usize = W2 + HIDDEN1 * HIDDEN2; +const W3: usize = B2 + HIDDEN2; +const B3: usize = W3 + HIDDEN2; +pub const PARAMS: usize = B3 + 1; + +const MAGIC: &[u8; 4] = b"TSNN"; +const VERSION: u32 = 1; + +/// A small, fast pseudo-random generator (xorshift64*), good enough for weight init and exploration. +#[derive(Clone, Debug)] +pub struct Rng(u64); + +impl Rng +{ + pub fn new(seed: u64) -> Self + { + // Zero is a fixed point of xorshift, and nearby seeds should still diverge quickly + Self(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1) + } + + pub fn next_u64(&mut self) -> u64 + { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545F4914F6CDD1D) + } + + /// Uniform in `0.0..1.0`. + pub fn next_f32(&mut self) -> f32 + { + (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32 + } + + /// Uniform in `0..n`. + pub fn below(&mut self, n: usize) -> usize + { + (((self.next_u64() >> 32) * n as u64) >> 32) as usize + } + + /// Standard normal, by Box-Muller. + pub fn normal(&mut self) -> f32 + { + let u = 1.0 - self.next_f32(); + let v = self.next_f32(); + (-2.0 * u.ln()).sqrt() * (std::f32::consts::TAU * v).cos() + } +} + +/// A position and the score the network should learn to give it (+1 tigers win, -1 sheep win). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Sample +{ + pub board: GameBoard, + pub turn: Turn, + pub target: f32, +} + +/// Values of every layer for one position, kept by `forward_full` for backprop. +struct Activations +{ + h1: [f32; HIDDEN1], + h2: [f32; HIDDEN2], + out: f32, +} + +/// A position scorer: inputs → 64 ReLU → 32 ReLU → tanh, positive favouring tigers. +#[derive(Clone, Debug, PartialEq)] +pub struct Network +{ + params: Vec<f32>, +} + +/// Call `f` with the index of each input that is 1, and return the value of the `HAND` input. +#[inline(always)] +fn for_active(board: &GameBoard, turn: Turn, mut f: impl FnMut(usize)) -> f32 +{ + let mut tigers = board.tiger_points(); + while tigers != 0 + { + f(tigers.trailing_zeros() as usize); + tigers &= tigers - 1; + } + let mut sheep = board.sheep_points(); + while sheep != 0 + { + f(25 + sheep.trailing_zeros() as usize); + sheep &= sheep - 1; + } + if turn == Turn::Tiger + { + f(TIGER_TO_MOVE); + } + board.sheep_left() as f32 / 20.0 +} + +impl Network +{ + /// Random weights (He initialisation), so a new network plays roughly at random. + pub fn random(seed: u64) -> Self + { + let mut rng = Rng::new(seed); + let mut params = vec![0.0; PARAMS]; + let mut init = |range: std::ops::Range<usize>, fan_in: usize, params: &mut [f32]| + { + let scale = (2.0 / fan_in as f32).sqrt(); + for p in &mut params[range] + { + *p = rng.normal() * scale; + } + }; + // About a third of the inputs are set at once, so scale layer 1 by that rather than all 52 + init(W1..B1, 20, &mut params); + init(W2..B2, HIDDEN1, &mut params); + init(W3..B3, HIDDEN2, &mut params); + Self { params } + } + + fn forward_full(&self, board: &GameBoard, turn: Turn) -> Activations + { + let p = &self.params; + let mut h1 = [0.0; HIDDEN1]; + h1.copy_from_slice(&p[B1..B1 + HIDDEN1]); + let hand = for_active(board, turn, |i| + { + for (h, w) in h1.iter_mut().zip(&p[W1 + i * HIDDEN1..W1 + (i + 1) * HIDDEN1]) + { + *h += w; + } + }); + for (h, w) in h1.iter_mut().zip(&p[W1 + HAND * HIDDEN1..W1 + (HAND + 1) * HIDDEN1]) + { + *h = (*h + hand * w).max(0.0); + } + let mut h2 = [0.0; HIDDEN2]; + for (k, h) in h2.iter_mut().enumerate() + { + let w = &p[W2 + k * HIDDEN1..W2 + (k + 1) * HIDDEN1]; + let sum: f32 = w.iter().zip(&h1).map(|(w, x)| w * x).sum(); + *h = (p[B2 + k] + sum).max(0.0); + } + let sum: f32 = p[W3..B3].iter().zip(&h2).map(|(w, x)| w * x).sum(); + Activations { h1, h2, out: (p[B3] + sum).tanh() } + } + + /// The network's score for `board` with `turn` to move, in -1..1 (positive favours tigers). + pub fn forward(&self, board: &GameBoard, turn: Turn) -> f32 + { + self.forward_full(board, turn).out + } + + /// Add the gradient of the squared error `(out - target)² / 2` for one sample to `grad`, + /// returning that error. + fn add_gradient(&self, s: &Sample, grad: &mut [f32]) -> f32 + { + let p = &self.params; + let a = self.forward_full(&s.board, s.turn); + let err = a.out - s.target; + // Back through tanh + let d_out = err * (1.0 - a.out * a.out); + grad[B3] += d_out; + let mut d_h2 = [0.0; HIDDEN2]; + for k in 0..HIDDEN2 + { + grad[W3 + k] += d_out * a.h2[k]; + // Back through ReLU: no gradient where the unit was off + d_h2[k] = if a.h2[k] > 0.0 { d_out * p[W3 + k] } else { 0.0 }; + } + let mut d_h1 = [0.0; HIDDEN1]; + for k in 0..HIDDEN2 + { + if d_h2[k] == 0.0 + { + continue; + } + grad[B2 + k] += d_h2[k]; + let row = W2 + k * HIDDEN1; + for j in 0..HIDDEN1 + { + grad[row + j] += d_h2[k] * a.h1[j]; + d_h1[j] += d_h2[k] * p[row + j]; + } + } + for j in 0..HIDDEN1 + { + if a.h1[j] <= 0.0 + { + d_h1[j] = 0.0; + } + grad[B1 + j] += d_h1[j]; + } + let hand = for_active(&s.board, s.turn, |i| + { + for (g, d) in grad[W1 + i * HIDDEN1..W1 + (i + 1) * HIDDEN1].iter_mut().zip(&d_h1) + { + *g += d; + } + }); + for (g, d) in grad[W1 + HAND * HIDDEN1..W1 + (HAND + 1) * HIDDEN1].iter_mut().zip(&d_h1) + { + *g += hand * d; + } + err * err / 2.0 + } + + /// One optimiser step on the mean error over `batch`; returns that mean error. + pub fn train_batch(&mut self, batch: &[Sample], adam: &mut Adam) -> f32 + { + let mut grad = vec![0.0; PARAMS]; + let loss: f32 = batch.iter().map(|s| self.add_gradient(s, &mut grad)).sum(); + let n = batch.len().max(1) as f32; + for g in &mut grad + { + *g /= n; + } + adam.step(&mut self.params, &grad); + loss / n + } + + /// The mean error over `samples`, without training. + pub fn loss(&self, samples: &[Sample]) -> f32 + { + let total: f32 = samples.iter().map(|s| { let e = self.forward(&s.board, s.turn) - s.target; e * e / 2.0 }).sum(); + total / samples.len().max(1) as f32 + } + + /// Saves as "TSNN", then the version and layer sizes as little-endian u32s, then every weight as a little-endian f32. + pub fn save(&self, path: &Path) -> io::Result<()> + { + let mut bytes = Vec::with_capacity(20 + 4 * PARAMS); + bytes.extend_from_slice(MAGIC); + for n in [VERSION, INPUTS as u32, HIDDEN1 as u32, HIDDEN2 as u32] + { + bytes.extend_from_slice(&n.to_le_bytes()); + } + for p in &self.params + { + bytes.extend_from_slice(&p.to_le_bytes()); + } + fs::write(path, bytes) + } + + pub fn load(path: &Path) -> io::Result<Self> + { + let bytes = fs::read(path)?; + let bad = |msg: &str| io::Error::new(io::ErrorKind::InvalidData, format!("{}: {msg}", path.display())); + if bytes.len() != 20 + 4 * PARAMS || &bytes[..4] != MAGIC + { + return Err(bad("not a network file of the expected size")); + } + let word = |i: usize| u32::from_le_bytes(bytes[4 + 4 * i..8 + 4 * i].try_into().unwrap()); + if [word(0), word(1), word(2), word(3)] != [VERSION, INPUTS as u32, HIDDEN1 as u32, HIDDEN2 as u32] + { + return Err(bad("network has a different version or layer sizes")); + } + let params = bytes[20..].chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(); + Ok(Self { params }) + } +} + +/// The Adam optimiser's state: running averages of each weight's gradient and squared gradient. +pub struct Adam +{ + pub learning_rate: f32, + m: Vec<f32>, + v: Vec<f32>, + steps: i32, +} + +impl Adam +{ + const BETA1: f32 = 0.9; + const BETA2: f32 = 0.999; + const EPSILON: f32 = 1e-8; + + pub fn new(learning_rate: f32) -> Self + { + Self { learning_rate, m: vec![0.0; PARAMS], v: vec![0.0; PARAMS], steps: 0 } + } + + fn step(&mut self, params: &mut [f32], grad: &[f32]) + { + self.steps += 1; + // Corrects the averages' bias towards their zero start + let c1 = 1.0 - Self::BETA1.powi(self.steps); + let c2 = 1.0 - Self::BETA2.powi(self.steps); + for i in 0..params.len() + { + self.m[i] = Self::BETA1 * self.m[i] + (1.0 - Self::BETA1) * grad[i]; + self.v[i] = Self::BETA2 * self.v[i] + (1.0 - Self::BETA2) * grad[i] * grad[i]; + params[i] -= self.learning_rate * (self.m[i] / c1) / ((self.v[i] / c2).sqrt() + Self::EPSILON); + } + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + /// Positions from a pseudo-random game, with made-up targets. + fn samples(n: usize, seed: u64) -> Vec<Sample> + { + let mut rng = Rng::new(seed); + let mut board = GameBoard::default(); + let mut turn = Turn::Sheep; + let mut out = Vec::new(); + while out.len() < n + { + if board.check_win(turn) + { + board = GameBoard::default(); + turn = Turn::Sheep; + } + out.push(Sample { board, turn, target: rng.next_f32() * 1.6 - 0.8 }); + let moves = board.get_moves(turn); + board.make_move_unchecked(moves[rng.below(moves.len())]); + turn = turn.next_turn(); + } + out + } + + #[test] + fn gradient_matches_finite_differences() + { + let mut net = Network::random(7); + let s = samples(30, 3)[29]; + let mut grad = vec![0.0; PARAMS]; + net.add_gradient(&s, &mut grad); + let loss = |net: &Network| { let e = net.forward(&s.board, s.turn) as f64 - s.target as f64; e * e / 2.0 }; + let mut rng = Rng::new(11); + // The input weights of unset inputs have no gradient, so check some of each layer's, + // plus every weight that really has one + let mut checked = 0; + for _ in 0..4000 + { + let i = rng.below(PARAMS); + let eps = 1e-2; + let old = net.params[i]; + net.params[i] = old + eps; + let up = loss(&net); + net.params[i] = old - eps; + let down = loss(&net); + net.params[i] = old; + let numeric = (up - down) / (2.0 * eps as f64); + let tolerance = 1e-3 + 2e-2 * numeric.abs().max(grad[i].abs() as f64); + // A ReLU switching on or off inside ±eps makes the numeric estimate meaningless; such + // kinks are rare, so skip a weight only where the two halves disagree wildly + let left = (loss(&net) - down) / eps as f64; + let right = (up - loss(&net)) / eps as f64; + if (left - right).abs() > 0.1 * left.abs().max(right.abs()) + 1e-4 + { + continue; + } + assert!((numeric - grad[i] as f64).abs() < tolerance, "weight {i}: numeric {numeric}, backprop {}", grad[i]); + checked += 1; + } + assert!(checked > 3000); + } + + #[test] + fn training_can_fit_a_few_samples() + { + let mut net = Network::random(1); + let data = samples(10, 5); + let mut adam = Adam::new(3e-3); + for _ in 0..2000 + { + net.train_batch(&data, &mut adam); + } + assert!(net.loss(&data) < 1e-3, "loss {}", net.loss(&data)); + } + + #[test] + fn save_and_load_round_trip() + { + let net = Network::random(9); + let path = std::env::temp_dir().join(format!("tsnn-test-{}.bin", std::process::id())); + net.save(&path).unwrap(); + let loaded = Network::load(&path); + std::fs::write(&path, b"nope").unwrap(); + let broken = Network::load(&path); + std::fs::remove_file(&path).unwrap(); + assert_eq!(loaded.unwrap(), net); + assert_eq!(broken.unwrap_err().kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn forward_matches_a_plain_matrix_multiply() + { + let net = Network::random(4); + for s in samples(50, 8) + { + let mut x = [0.0f32; INPUTS]; + let hand = for_active(&s.board, s.turn, |i| x[i] = 1.0); + x[HAND] = hand; + let p = &net.params; + let h1: Vec<f32> = (0..HIDDEN1).map(|j| (p[B1 + j] + (0..INPUTS).map(|i| x[i] * p[W1 + i * HIDDEN1 + j]).sum::<f32>()).max(0.0)).collect(); + let h2: Vec<f32> = (0..HIDDEN2).map(|k| (p[B2 + k] + (0..HIDDEN1).map(|j| h1[j] * p[W2 + k * HIDDEN1 + j]).sum::<f32>()).max(0.0)).collect(); + let out = (p[B3] + (0..HIDDEN2).map(|k| h2[k] * p[W3 + k]).sum::<f32>()).tanh(); + assert!((net.forward(&s.board, s.turn) - out).abs() < 1e-5); + } + } +} diff --git a/src/neural-player.rs b/src/neural-player.rs new file mode 100644 index 0000000..3a9c4a0 --- /dev/null +++ b/src/neural-player.rs @@ -0,0 +1,68 @@ +use std::time::Duration; + +use crate::{board::{GameBoard, Turn}, network::Network, player::{Killers, MinimaxPlayer, ScoreCache, SearchReport, TranspositionTable}}; + +/// A computer player that searches like `ClaudePlayer` but scores positions with a neural network +/// trained by self-play (see `train.rs`), so it has no human idea of what a good position is. +pub struct NeuralPlayer +{ + pub net: Network, + pub time_limit: Duration, + /// The most plies to search, counting the move itself; normally `time_limit` stops the search first. + pub max_depth: usize, + cache: TranspositionTable, + killers: Killers, + last_search: Option<SearchReport>, +} + +impl NeuralPlayer +{ + /// A player with a `1 << table_bits`-slot transposition table. + pub fn new(net: Network, time_limit: Duration, table_bits: u32) -> Self + { + Self { net, time_limit, max_depth: 64, cache: TranspositionTable::new(table_bits), killers: Killers::default(), last_search: None } + } +} + +impl MinimaxPlayer for NeuralPlayer +{ + /// The network's output, in -1..1; forced wins are still ±`WIN` from the search itself. + fn evaluate(&mut self, board: &GameBoard, turn: Turn) -> f32 + { + self.net.forward(board, turn) + } + + fn name(&self) -> String { "Neural".to_string() } + + fn set_last_search(&mut self, report: Option<SearchReport>) { self.last_search = report; } + + fn last_search(&self) -> Option<SearchReport> { self.last_search } + + fn time_limit(&self) -> Duration { self.time_limit } + + fn depth(&self) -> usize { self.max_depth } + + fn cache(&mut self) -> Option<&mut dyn ScoreCache> { Some(&mut self.cache) } + + fn killers(&mut self) -> Option<&mut Killers> { Some(&mut self.killers) } +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::player::Player; + + #[test] + fn pick_move_reports_its_search() + { + let mut player = NeuralPlayer::new(Network::random(2), Duration::from_secs(3600), 12); + player.max_depth = 3; + let board = GameBoard::default(); + let m = player.pick_move(board, Turn::Sheep).unwrap(); + assert_eq!(board.check_move(m), Ok(())); + let report = player.last_search.expect("a report"); + assert_eq!((report.mv, report.depth), (m, 3)); + assert_eq!(report.score, player.minimax(board, Turn::Sheep, 3, f32::NEG_INFINITY, f32::INFINITY, None).unwrap()); + } +} diff --git a/src/player.rs b/src/player.rs index 7a19219..0c58a58 100644 --- a/src/player.rs +++ b/src/player.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, time::{Duration, Instant}}; use ratatui::{buffer::Buffer, crossterm::event::KeyEvent, layout::Rect, widgets::{Paragraph, Widget, Wrap}}; -use crate::board::{is_jump, GameBoard, Move, Pos, Turn}; +use crate::board::{inverse_symmetry, is_jump, transform_move, GameBoard, Move, Pos, Turn}; /// Input the game coordinator passes on to the player whose turn it is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -89,7 +89,7 @@ pub struct CacheEntry } /// Score cache for a `MinimaxPlayer`. No defaults, so an implementor must supply all of it. -/// Entries are keyed by the side to move and the remaining search depth as well as the board, +/// `minimax` looks boards up by `GameBoard::canonical`, so symmetric boards share an entry. Entries are keyed by the side to move and the remaining search depth as well as the board, /// since the same board can be reached with either side to move, or at different depths. pub trait ScoreCache { @@ -318,7 +318,11 @@ pub trait MinimaxPlayer /// Returns None if `deadline` passed before the search finished. fn minimax(&mut self, board: GameBoard, turn: Turn, depth: usize, mut alpha: ScoreType, mut beta: ScoreType, deadline: Option<Instant>) -> Option<ScoreType> { - let known = self.cache().and_then(|c| { count!(cache_probes); c.lookup(&board, turn, depth) }); + // Symmetric boards are equally good, so they share an entry, under the canonical board. + // Entries' best moves are stored as played on that board, and turned back for this one. + let (canon, sym) = if self.cache().is_some() { board.canonical() } else { (board, 0) }; + let back = |m: Move| transform_move(m, inverse_symmetry(sym)); + let known = self.cache().and_then(|c| { count!(cache_probes); c.lookup(&canon, turn, depth) }); let mut hint = None; if let Some(k) = known { @@ -335,16 +339,17 @@ pub trait MinimaxPlayer } alpha = alpha.max(k.lower); beta = beta.min(k.upper); - hint = k.best; + hint = k.best.map(back); } // Otherwise try the best move from the previous, one-ply-shallower iteration first if hint.is_none() && depth > 0 { - hint = self.cache().and_then(|c| { count!(cache_probes); c.lookup(&board, turn, depth - 1) }).and_then(|e| e.best); + hint = self.cache().and_then(|c| { count!(cache_probes); c.lookup(&canon, turn, depth - 1) }).and_then(|e| e.best).map(back); } let (score, best) = self.minimax_uncached(board, turn, depth, alpha, beta, hint, deadline)?; if let Some(c) = self.cache() { + let best = best.map(|m| transform_move(m, sym)); // On a fail-low every move was only bounded, so none is known to be best let mut entry = if score <= alpha { @@ -364,7 +369,7 @@ pub trait MinimaxPlayer entry.upper = entry.upper.min(k.upper); entry.best = entry.best.or(k.best); } - c.insert(&board, turn, depth, entry); + c.insert(&canon, turn, depth, entry); } Some(score) } @@ -387,7 +392,7 @@ pub trait MinimaxPlayer if depth == 0 { count!(leaves); - return Some((self.evaluate(&board), None)); + return Some((self.evaluate(&board, turn), None)); } if deadline.is_some_and(|d| Instant::now() >= d) { @@ -471,8 +476,8 @@ pub trait MinimaxPlayer None } - /// More positive for tiger winning, less positive for sheep winning - fn evaluate(&mut self, board: &GameBoard) -> ScoreType; + /// More positive for tiger winning, less positive for sheep winning, with `turn` to move next + fn evaluate(&mut self, board: &GameBoard, turn: Turn) -> ScoreType; /// The most plies `pick_move` searches, counting its own move. Normally the time limit stops it first. fn depth(&self) -> usize { MAX_DEPTH } @@ -616,7 +621,7 @@ mod tests impl MinimaxPlayer for TestPlayer { - fn evaluate(&mut self, board: &GameBoard) -> ScoreType + fn evaluate(&mut self, board: &GameBoard, _turn: Turn) -> ScoreType { let dead = 20 - board.sheep_count() - board.sheep_left(); 100.0 * dead as f32 + board.get_moves(Turn::Tiger).len() as f32 @@ -651,7 +656,7 @@ mod tests } if depth == 0 { - return player.evaluate(&board); + return player.evaluate(&board, turn); } let scores = board.get_moves(turn).into_iter() .map(|m| reference(player, board.after_move_unchecked(m), turn.next_turn(), depth - 1)); diff --git a/src/train.rs b/src/train.rs new file mode 100644 index 0000000..1ee40c0 --- /dev/null +++ b/src/train.rs @@ -0,0 +1,353 @@ +use std::{collections::VecDeque, fs, path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}, time::{Duration, Instant}}; + +use crate::{board::{GameBoard, Move, Turn, SYMMETRIES}, claude_player::ClaudePlayer, network::{Adam, Network, Rng, Sample}, neural_player::NeuralPlayer, player::{MinimaxPlayer, Player}}; + +/// Settings for `run`. +#[derive(Clone, Debug)] +pub struct Config +{ + pub generations: usize, + pub games_per_generation: usize, + pub out_dir: PathBuf, + /// Search depth for every move in self-play and matches. The depth cap, not a time limit, + /// ends each search, so runs are repeatable. + pub depth: usize, + /// A game this long is called a draw, since nothing in the rules ends a game that goes round in circles. + pub ply_cap: usize, + /// Self-play picks moves at random for this many plies, so games start in different places. + pub random_plies: usize, + /// After that, the chance of a random move instead of the searched one. + pub epsilon: f32, + /// TD(λ): 1 trains every position on its game's result, smaller trusts later positions' search scores more. + pub lambda: f32, + /// Train on the samples of this many of the latest generations. + pub buffer_generations: usize, + pub epochs: usize, + pub batch_size: usize, + pub learning_rate: f32, + /// Games against `ClaudePlayer`, and against the previous generation, after each generation (half as each side). + pub match_games: usize, +} + +impl Default for Config +{ + fn default() -> Self + { + Self + { + generations: 50, + games_per_generation: 500, + out_dir: PathBuf::from("weights"), + depth: 3, + ply_cap: 200, + random_plies: 10, + epsilon: 0.05, + lambda: 0.7, + buffer_generations: 5, + epochs: 2, + batch_size: 256, + learning_rate: 1e-3, + match_games: 100, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome +{ + Tiger, + Sheep, + Draw, +} + +impl Outcome +{ + /// +1 for a tiger win, -1 for a sheep win, matching the sign of the scores. + fn value(self) -> f32 + { + match self { Outcome::Tiger => 1.0, Outcome::Sheep => -1.0, Outcome::Draw => 0.0 } + } +} + +/// A position from a game, with the searching player's score for it (clamped to -1..1). +#[derive(Clone, Copy, Debug)] +struct Ply +{ + board: GameBoard, + turn: Turn, + score: f32, + /// A random move was played here instead of the searched one. + random: bool, +} + +/// Plays one game from the start. `choose` gives the move and score for a position; for the +/// first `random_plies` plies, and with chance `epsilon` after, a random move is played instead +/// (the position keeps `choose`'s score, and is marked `random` unless the two moves are the same). +fn play(mut choose: impl FnMut(GameBoard, Turn) -> (Move, f32), ply_cap: usize, random_plies: usize, epsilon: f32, rng: &mut Rng) + -> (Outcome, Vec<Ply>) +{ + let mut board = GameBoard::default(); + let mut turn = Turn::Sheep; + let mut plies = Vec::new(); + while plies.len() < ply_cap + { + if board.check_win(turn) + { + // The side that just moved has won + let winner = match turn { Turn::Sheep => Outcome::Tiger, Turn::Tiger => Outcome::Sheep }; + return (winner, plies); + } + let (searched, score) = choose(board, turn); + let mut m = searched; + if plies.len() < random_plies || rng.next_f32() < epsilon + { + let moves = board.get_moves(turn); + m = moves[rng.below(moves.len())]; + } + plies.push(Ply { board, turn, score: score.clamp(-1.0, 1.0), random: m != searched }); + board.make_move_unchecked(m); + turn = turn.next_turn(); + } + (Outcome::Draw, plies) +} + +/// TD(λ) targets, worked backwards: the last position's target is the result, and each earlier +/// one's is `(1 - λ)` times the next position's score plus `λ` times the next position's target. +/// +/// A position where a random move was played is instead labelled with its own search score, which +/// also cuts the chain there, so earlier positions don't learn from the random move's result. +/// Without the cut, the sheep's random moves (which usually leave a sheep to be jumped, where a +/// tiger's random move costs little) taught the net that tigers nearly always win. +fn td_targets(plies: &[Ply], outcome: Outcome, lambda: f32) -> Vec<f32> +{ + let mut targets = vec![0.0; plies.len()]; + let mut next = outcome.value(); + for t in (0..plies.len()).rev() + { + targets[t] = if plies[t].random { plies[t].score } else { next }; + next = (1.0 - lambda) * plies[t].score + lambda * targets[t]; + } + targets +} + +/// Runs `f(0..n)` across all cores, returning the results in order. +fn parallel<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync) -> Vec<T> +{ + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(n.max(1)); + let next = AtomicUsize::new(0); + let mut results: Vec<(usize, T)> = std::thread::scope(|s| + { + let handles: Vec<_> = (0..threads).map(|_| s.spawn(|| + { + let mut done = Vec::new(); + loop + { + let i = next.fetch_add(1, Ordering::Relaxed); + if i >= n + { + return done; + } + done.push((i, f(i))); + } + })).collect(); + handles.into_iter().flat_map(|h| h.join().expect("worker thread panicked")).collect() + }); + results.sort_by_key(|(i, _)| *i); + results.into_iter().map(|(_, r)| r).collect() +} + +/// A player that searches exactly `depth` plies. +fn neural(net: &Network, depth: usize) -> NeuralPlayer +{ + let mut player = NeuralPlayer::new(net.clone(), Duration::from_secs(1 << 20), 16); + player.max_depth = depth; + player +} + +fn claude(depth: usize) -> ClaudePlayer +{ + let mut player = ClaudePlayer::new(Duration::from_secs(1 << 20)); + player.max_depth = depth; + player +} + +/// The move a minimax player picks, and the score it gave it. +fn search(player: &mut (impl MinimaxPlayer + Player), board: GameBoard, turn: Turn) -> (Move, f32) +{ + let m = player.pick_move(board, turn).expect("the game is not over, so there is a move"); + (m, player.last_search().map_or(0.0, |r| r.score)) +} + +/// One self-play game of `net` against itself, as training samples. +fn self_play(net: &Network, cfg: &Config, seed: u64) -> (Outcome, Vec<Sample>) +{ + let mut player = neural(net, cfg.depth); + let mut rng = Rng::new(seed); + let (outcome, plies) = play(|b, t| search(&mut player, b, t), cfg.ply_cap, cfg.random_plies, cfg.epsilon, &mut rng); + let targets = td_targets(&plies, outcome, cfg.lambda); + let samples = plies.iter().zip(targets).map(|(p, target)| Sample { board: p.board, turn: p.turn, target }).collect(); + (outcome, samples) +} + +/// The share of points `net` scores over `games` against `opponent()` (a win is 1, a draw ½), +/// playing sheep in even games and tigers in odd ones. Each pair of games shares a seed, so both +/// sides get the same few random opening moves. +fn match_score<P: MinimaxPlayer + Player>(net: &Network, opponent: impl Fn() -> P + Sync, cfg: &Config, seed: u64) -> f32 +{ + let points: f32 = parallel(cfg.match_games, |i| + { + let mut me = neural(net, cfg.depth); + let mut them = opponent(); + let neural_side = if i % 2 == 0 { Turn::Sheep } else { Turn::Tiger }; + let mut rng = Rng::new(seed ^ (i / 2) as u64); + let (outcome, _) = play(|b, t| if t == neural_side { search(&mut me, b, t) } else { search(&mut them, b, t) }, + cfg.ply_cap, 4, 0.0, &mut rng); + match (outcome, neural_side) + { + (Outcome::Draw, _) => 0.5, + (Outcome::Tiger, Turn::Tiger) | (Outcome::Sheep, Turn::Sheep) => 1.0, + _ => 0.0, + } + }).into_iter().sum(); + points / cfg.match_games.max(1) as f32 +} + +/// The newest `gen-NNN.bin` in `dir`, as (generation, path). +fn latest_generation(dir: &Path) -> Option<(usize, PathBuf)> +{ + fs::read_dir(dir).ok()? + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let n = name.strip_prefix("gen-")?.strip_suffix(".bin")?.parse().ok()?; + Some((n, e.path())) + }) + .max_by_key(|(n, _)| *n) +} + +/// Trains a network by self-play, saving each generation to `out_dir/gen-NNN.bin` and `out_dir/latest.bin`. +/// Carries on from the newest generation already in `out_dir`, if there is one. +pub fn run(cfg: &Config) -> std::io::Result<()> +{ + fs::create_dir_all(&cfg.out_dir)?; + let (first, mut net) = match latest_generation(&cfg.out_dir) + { + Some((n, path)) => + { + println!("Carrying on from {}", path.display()); + (n + 1, Network::load(&path)?) + } + None => (1, Network::random(0x5EED)), + }; + let mut adam = Adam::new(cfg.learning_rate); + let mut buffer: VecDeque<Vec<Sample>> = VecDeque::new(); + let mut rng = Rng::new(first as u64); + println!("{:>4} {:>7} {:>6} {:>6} {:>6} {:>8} {:>8} {:>7} {:>7} {:>7}", + "gen", "samples", "tigers", "sheep", "draws", "loss", "after", "vs prev", "vs Claude", "time s"); + for generation in first..first + cfg.generations + { + let start = Instant::now(); + let seed_base = (generation as u64) << 32; + let games = parallel(cfg.games_per_generation, |i| self_play(&net, cfg, seed_base | i as u64)); + let count = |o: Outcome| games.iter().filter(|(g, _)| *g == o).count(); + let (tigers, sheep, draws) = (count(Outcome::Tiger), count(Outcome::Sheep), count(Outcome::Draw)); + buffer.push_back(games.into_iter().flat_map(|(_, s)| s).collect()); + while buffer.len() > cfg.buffer_generations + { + buffer.pop_front(); + } + + let mut samples: Vec<Sample> = buffer.iter().flatten().copied().collect(); + let loss_before = net.loss(&samples); + let previous = net.clone(); + for _ in 0..cfg.epochs + { + // Shuffle, and show each position in a random one of its 8 orientations + for i in (1..samples.len()).rev() + { + samples.swap(i, rng.below(i + 1)); + } + for s in &mut samples + { + s.board = s.board.transformed(rng.below(SYMMETRIES)); + } + for batch in samples.chunks(cfg.batch_size) + { + net.train_batch(batch, &mut adam); + } + } + let loss_after = net.loss(&samples); + + let vs_prev = match_score(&net, || neural(&previous, cfg.depth), cfg, seed_base ^ 0xA); + let vs_claude = match_score(&net, || claude(cfg.depth), cfg, seed_base ^ 0xB); + net.save(&cfg.out_dir.join(format!("gen-{generation:03}.bin")))?; + net.save(&cfg.out_dir.join("latest.bin"))?; + println!("{generation:>4} {:>7} {tigers:>6} {sheep:>6} {draws:>6} {loss_before:>8.4} {loss_after:>8.4} {:>6.0}% {:>8.0}% {:>7.1}", + samples.len(), vs_prev * 100.0, vs_claude * 100.0, start.elapsed().as_secs_f64()); + } + Ok(()) +} + +#[cfg(test)] +mod tests +{ + use super::*; + + fn ply(score: f32) -> Ply + { + Ply { board: GameBoard::default(), turn: Turn::Sheep, score, random: false } + } + + #[test] + fn td_targets_blend_scores_and_the_result() + { + let plies = [ply(0.0), ply(0.2), ply(-0.4)]; + let t = td_targets(&plies, Outcome::Tiger, 0.5); + assert_eq!(t[2], 1.0); + assert_eq!(t[1], 0.5 * -0.4 + 0.5 * 1.0); + assert_eq!(t[0], 0.5 * 0.2 + 0.5 * t[1]); + // λ = 1 is just the result + assert_eq!(td_targets(&plies, Outcome::Sheep, 1.0), vec![-1.0; 3]); + } + + #[test] + fn td_targets_stop_at_random_moves() + { + let random = Ply { random: true, ..ply(0.3) }; + let plies = [ply(0.0), random, ply(-0.4)]; + let t = td_targets(&plies, Outcome::Tiger, 1.0); + // After the random move the result counts; at it and before it, only its search score does + assert_eq!(t, vec![0.3, 0.3, 1.0]); + let t = td_targets(&plies, Outcome::Tiger, 0.5); + assert_eq!(t[1], 0.3); + assert_eq!(t[0], 0.3); + } + + #[test] + fn self_play_games_are_legal_and_repeatable() + { + let cfg = Config { depth: 1, ply_cap: 60, ..Config::default() }; + let net = Network::random(3); + let (outcome, samples) = self_play(&net, &cfg, 42); + assert_eq!(self_play(&net, &cfg, 42), (outcome, samples.clone())); + assert!(!samples.is_empty() && samples.len() <= cfg.ply_cap); + assert_eq!(samples[0].board, GameBoard::default()); + // Each position follows from the one before by a legal move + for w in samples.windows(2) + { + assert_eq!(w[1].turn, w[0].turn.next_turn()); + assert!(w[0].board.get_moves(w[0].turn).iter().any(|&m| w[0].board.after_move_unchecked(m) == w[1].board)); + } + assert!(samples.iter().all(|s| (-1.0..=1.0).contains(&s.target))); + if outcome == Outcome::Draw + { + assert_eq!(samples.len(), cfg.ply_cap); + } + } + + #[test] + fn parallel_keeps_the_order() + { + assert_eq!(parallel(100, |i| i * 2), (0..100).map(|i| i * 2).collect::<Vec<_>>()); + } +} |
