diff options
| -rw-r--r-- | CLAUDE.md | 2 | ||||
| -rw-r--r-- | Cargo.lock | 109 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | src/main.rs | 233 |
5 files changed, 311 insertions, 47 deletions
@@ -41,7 +41,7 @@ The toolchain comes from a Nix flake (`flake.nix`, loaded via direnv `.envrc` ↠- `src/network.rs` — `Network`, a small MLP (52 inputs → 64 ReLU → 32 ReLU → tanh, positive favours tigers) with hand-written backprop and an `Adam` optimiser, all in one flat `params` Vec at the `W1`/`B1`/… offsets. Inputs are only the raw position: a bit per point for tigers and for sheep, sheep in hand / 20, and whether tigers are to move. `forward` adds up the layer-1 weight rows of the set bits rather than multiplying through the zeros. Also `Rng` (xorshift64*) and `Sample` (board, turn, target). File format (`save`/`load`): `TSNN`, then version, inputs, hidden1, hidden2 as little-endian u32s, then every param as a little-endian f32; changing the layer sizes makes old files fail to load. - `src/neural-player.rs` — `NeuralPlayer`, a `MinimaxPlayer` like `ClaudePlayer` whose `evaluate` is a `Network`. - `src/train.rs` — self-play training. Each generation plays `games_per_generation` games of the net against itself at a fixed depth (default 3) across all cores. Moves are random for the first `random_plies`, and with chance `epsilon` after that. A game reaching `ply_cap` is a draw, since the rules have no draw. Each position is labelled with a TD(λ) target worked backwards from the result (±1, or 0) through the searched scores. A position where a random move was played is labelled with its own search score instead, which cuts the chain there. Without that cut, the sheep's random moves (which usually hang a sheep) taught the net that tigers nearly always win. Self-play still ends mostly in tiger wins, because of those random moves, so judge progress by the match against `ClaudePlayer` (`match_games` = 100). The net then trains on the last `buffer_generations` generations, each position shown in a random one of the board's 8 symmetries (`GameBoard::transformed`). With `balance_outcomes` (the default), `balanced` builds each epoch from as many positions of sheep-win games as of tiger-win games: their mean, so the epoch stays the same size. Tiger-win positions are subsampled and sheep-win ones repeated; draws are kept as they are. The output shows the mean error on tiger-win (T) and sheep-win (S) positions separately, before and after training. After that it plays matches against the previous generation and against `ClaudePlayer`, which is only a yardstick and never a training signal. Each generation is saved as `out_dir/gen-NNN.bin` and `latest.bin`, and a rerun carries on from the newest `gen-NNN.bin`. `Config::from` (`--from`) starts from any weights file instead. Generation numbers still follow on from the out dir. The Adam state and the sample buffer are not saved, so they start empty either way. -- `src/main.rs` — `bench [depth]` runs the benchmark; `train [generations] [games per generation] [out dir] [--from <weights>]` trains a network (use `--release`; `--from` may go anywhere); `neural <weights> [sheep|tiger]` plays you, on that side (default sheep), against a `NeuralPlayer`; otherwise it plays a game in the TUI (the sheep player first, then the tigers) and prints the winner. Which players it uses changes: `LocalPlayer` or `ClaudePlayer` on either side. +- `src/main.rs` — the command line, parsed with clap's derive API (`Cli`, `Command`, `PlayArgs`, `TrainArgs`). With no command, or `play`, it plays a game in the TUI and prints the winner; `--sheep`/`--tiger` take `human`, `claude` or a weights file (`parse_player` → `PlayerKind`, turned into a `Box<dyn Player>` by `make_player`), and `--time` is each computer player's time limit in seconds. `neural <weights> [sheep|tiger]` is shorthand for you against a `NeuralPlayer`. `bench [depth]` runs the benchmark. `train [generations] [games] [out dir]` trains a network (use `--release`); every other `train::Config` field is a `--long-option` whose default is read from `Config::default()`, so keep `TrainArgs` and its `From` impl in step with `Config`. Notation (`pos_name`, `impl Display for Move` in board.rs): files `a`–`e` are x 0–4, ranks are `5 - y` (y = 0 is drawn at the top as rank 5). Moves are `sc3` (place), `sc3c2` (sheep move), `ta5b4` (tiger step), `ta5xc3` (tiger jump). @@ -18,6 +18,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -139,6 +189,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] name = "compact_str" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -501,6 +597,12 @@ dependencies = [ ] [[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -732,6 +834,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] name = "ordered-float" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1444,6 +1552,7 @@ dependencies = [ name = "tigers-and-sheep-rs" version = "0.1.0" dependencies = [ + "clap", "ratatui", ] @@ -8,6 +8,7 @@ name = "tigers-and-sheep-rs" path = "src/main.rs" [dependencies] +clap = { version = "4.6.7", features = ["derive"] } ratatui = "0.30.2" [features] @@ -39,8 +39,15 @@ With plain Rust (no Nix), `cargo` from a recent stable toolchain works too. - **Click a move in the history** to go back to just before it. - **q**, **Esc** or **Ctrl+C** quits. -Which players `cargo run` uses is set in `src/main.rs`: `LocalPlayer` (you, with the mouse) or -`ClaudePlayer` (the minimax computer player) on either side. +By default the computer plays itself. Pick the players with `--sheep` and `--tiger`: `human` +(you, with the mouse), `claude` (the minimax computer player), or a weights file for a neural +player. `--time` sets how many seconds a computer player may think per move (default 2). + +```bash +cargo run --release -- --sheep human --tiger claude --time 5 +``` + +`--help` lists every command and option. ## Computer players @@ -64,6 +71,8 @@ and against `ClaudePlayer`. The score against `ClaudePlayer` is the best guide t Each generation is saved as `gen-NNN.bin`, and the newest as `latest.bin`. Running `train` again carries on from the newest generation in the folder. `--from` starts from any weights file instead. +The other training settings (search depth, ε, λ, epochs and so on) are options too; see +`train --help`. Then play against it, as sheep (the default) or as tigers: diff --git a/src/main.rs b/src/main.rs index 1404a9f..b6a4ad0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ -use std::{path::Path, time::Duration}; +use std::{path::PathBuf, time::Duration}; -use crate::{board::Turn, claude_player::ClaudePlayer, game::GameHandler, local_player::LocalPlayer, network::Network, neural_player::NeuralPlayer, player::Player}; +use clap::{Args, Parser, Subcommand, ValueEnum}; + +use crate::{claude_player::ClaudePlayer, game::GameHandler, local_player::LocalPlayer, network::Network, neural_player::NeuralPlayer, player::Player}; mod board; mod player; @@ -17,63 +19,206 @@ mod network; mod neural_player; mod train; -fn main() -> std::io::Result<()> { - let mut args: Vec<String> = std::env::args().collect(); - // `--from <weights>` can go anywhere after `train` - let from = args.iter().position(|a| a == "--from").map(|i| +/// Tigers and Sheep, a hunt game on a 5x5 board. With no command, plays a game in the terminal. +#[derive(Parser)] +#[command(version, args_conflicts_with_subcommands = true)] +struct Cli +{ + #[command(subcommand)] + command: Option<Command>, + #[command(flatten)] + play: PlayArgs, +} + +#[derive(Subcommand)] +enum Command +{ + /// Play a game in the terminal (the default) + Play(PlayArgs), + /// Play against a trained network + Neural { - let path = args.get(i + 1).cloned().expect("usage: --from <weights file>"); - args.drain(i..=i + 1); - path - }); - 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) + /// Weights file written by `train` + weights: PathBuf, + /// The side you play + #[arg(value_enum, default_value_t = Side::Sheep)] + side: Side, + /// Seconds the network may think per move + #[arg(short, long, default_value_t = 2.0)] + time: f64, + }, + /// Time the computer player's search on fixed positions, without the TUI + Bench { - // `bench [depth]` times the computer player's search without the TUI - Some("bench") => + /// Search depth + #[arg(default_value_t = 6)] + depth: usize, + }, + /// Train a network by self-play + /// + /// Starts from `--from`, or else the newest gen-NNN.bin in the out dir, or else random weights. + Train(TrainArgs), +} + +#[derive(Args)] +struct PlayArgs +{ + /// Who plays the sheep: `human`, `claude`, or a weights file for a neural player + #[arg(short = 'S', long, default_value = "claude", value_parser = parse_player)] + sheep: PlayerKind, + /// Who plays the tigers: `human`, `claude`, or a weights file for a neural player + #[arg(short = 'T', long, default_value = "claude", value_parser = parse_player)] + tiger: PlayerKind, + /// Seconds each computer player may think per move + #[arg(short, long, default_value_t = 2.0)] + time: f64, +} + +#[derive(Clone)] +enum PlayerKind +{ + Human, + Claude, + Neural(PathBuf), +} + +fn parse_player(s: &str) -> Result<PlayerKind, String> +{ + match s + { + "human" => Ok(PlayerKind::Human), + "claude" => Ok(PlayerKind::Claude), + path if PathBuf::from(path).is_file() => Ok(PlayerKind::Neural(path.into())), + _ => Err(format!("expected `human`, `claude` or a weights file, but `{s}` is none of these")), + } +} + +#[derive(Clone, Copy, ValueEnum)] +enum Side +{ + Sheep, + Tiger, +} + +#[derive(Args)] +struct TrainArgs +{ + /// Generations to train + #[arg(default_value_t = train::Config::default().generations)] + generations: usize, + /// Self-play games per generation + #[arg(default_value_t = train::Config::default().games_per_generation)] + games: usize, + /// Where each generation's weights are saved + #[arg(default_value = "weights")] + out_dir: PathBuf, + /// Start from this weights file + #[arg(long)] + from: Option<PathBuf>, + /// Search depth for every move in self-play and matches + #[arg(long, default_value_t = train::Config::default().depth)] + depth: usize, + /// A game this many plies long is a draw + #[arg(long, default_value_t = train::Config::default().ply_cap)] + ply_cap: usize, + /// Plies of random moves at the start of each self-play game + #[arg(long, default_value_t = train::Config::default().random_plies)] + random_plies: usize, + /// Chance of a random move after that + #[arg(long, default_value_t = train::Config::default().epsilon)] + epsilon: f32, + /// TD(λ): 1 trains on each game's result alone, smaller trusts later search scores more + #[arg(long, default_value_t = train::Config::default().lambda)] + lambda: f32, + /// Train on the samples of this many of the latest generations + #[arg(long, default_value_t = train::Config::default().buffer_generations)] + buffer_generations: usize, + /// Don't balance positions from sheep-win and tiger-win games + #[arg(long)] + no_balance: bool, + /// Passes over the samples per generation + #[arg(long, default_value_t = train::Config::default().epochs)] + epochs: usize, + /// Positions per optimiser step + #[arg(long, default_value_t = train::Config::default().batch_size)] + batch_size: usize, + /// Adam's learning rate + #[arg(long, default_value_t = train::Config::default().learning_rate)] + learning_rate: f32, + /// Games against ClaudePlayer, and against the previous generation, after each generation + #[arg(long, default_value_t = train::Config::default().match_games)] + match_games: usize, +} + +impl From<TrainArgs> for train::Config +{ + fn from(args: TrainArgs) -> Self + { + Self { - bench::run(number(2, 6)); - return Ok(()); + generations: args.generations, + games_per_generation: args.games, + out_dir: args.out_dir, + from: args.from, + depth: args.depth, + ply_cap: args.ply_cap, + random_plies: args.random_plies, + epsilon: args.epsilon, + lambda: args.lambda, + buffer_generations: args.buffer_generations, + balance_outcomes: !args.no_balance, + epochs: args.epochs, + batch_size: args.batch_size, + learning_rate: args.learning_rate, + match_games: args.match_games, } - // `train [generations] [games per generation] [out dir] [--from <weights>]` trains a network - // by self-play, starting from `--from`, or the newest generation in the out dir, or random weights - Some("train") => + } +} + +fn main() -> std::io::Result<()> +{ + let cli = Cli::parse(); + match cli.command + { + Some(Command::Bench { depth }) => { - 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), - from: from.map(Into::into), - ..defaults - }; - return train::run(&cfg); + bench::run(depth); + Ok(()) } - // `neural <weights> [sheep|tiger]`: you play the given side against a trained network - Some("neural") => + Some(Command::Train(args)) => train::run(&args.into()), + Some(Command::Neural { weights, side, time }) => { - 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 + let human = PlayerKind::Human; + let computer = PlayerKind::Neural(weights); + let (sheep, tiger) = match side { - Turn::Sheep => (&mut human, &mut computer), - Turn::Tiger => (&mut computer, &mut human), + Side::Sheep => (human, computer), + Side::Tiger => (computer, human), }; - return play(sheep, tiger); + play(PlayArgs { sheep, tiger, time }) } - _ => (), + Some(Command::Play(args)) => play(args), + None => play(cli.play), } - 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<()> +fn make_player(kind: PlayerKind, time_limit: Duration) -> std::io::Result<Box<dyn Player>> +{ + Ok(match kind + { + PlayerKind::Human => Box::new(LocalPlayer::default()), + PlayerKind::Claude => Box::new(ClaudePlayer::new(time_limit)), + PlayerKind::Neural(path) => Box::new(NeuralPlayer::new(Network::load(&path)?, time_limit, 20)), + }) +} + +fn play(args: PlayArgs) -> std::io::Result<()> { + let time_limit = Duration::from_secs_f64(args.time); + let mut sheep = make_player(args.sheep, time_limit)?; + let mut tiger = make_player(args.tiger, time_limit)?; let mut game = GameHandler::default(); - if let Some(winner) = game.play_game(sheep, tiger)? + if let Some(winner) = game.play_game(sheep.as_mut(), tiger.as_mut())? { println!("{winner:?} wins!"); } |
