commit 6c565ff0a375671743be9d6f51ae2fc68163808a Author: Stephen Waits Date: Mon Dec 4 19:57:24 2023 -0700 feat: initial implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64b338c --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +#--------------------------------------------------# +# The following was generated with gitignore.nvim: # +#--------------------------------------------------# +# Gitignore for the following technologies: Rust + +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..75e62b2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,222 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "assert_approx_eq" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c07dab4369547dbe5114677b33fbbf724971019f3818172d59a97a61c774ffd" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "crossbeam-deque" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + +[[package]] +name = "getrandom" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hikaru" +version = "0.1.0" +dependencies = [ + "assert_approx_eq", + "indoc", + "lazy_static", + "rand", + "rayon", + "regex", +] + +[[package]] +name = "indoc" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" + +[[package]] +name = "memchr" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c27db03db7734835b3f53954b534c91069375ce6ccaa2e065441e07d9b6cdb1" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce3fb6ad83f861aac485e76e1985cd109d9a3713802152be56c3b1f0e0658ed" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..479ee52 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "hikaru" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +assert_approx_eq = "1.1.0" +indoc = "2.0.4" +lazy_static = "1.4.0" +rand = "0.8.5" +rayon = "1.8.0" +regex = "1.10.2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc115c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Stephen Waits + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..24cd9ce --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +This is a hacked together Monte Carlo simulation of Hikaru's chess.com games. + +For details, please see the [accompanying blog post](https://swaits.com/hikaru-winning-streaks/). + +This repo does not include data. To fetch it, you'll need to grab it from +chess.com. I did it like this: + +```bash +# Fetch the list of game archive URLs for the player 'Hikaru' from Chess.com +archive_urls=$(curl -Ls https://api.chess.com/pub/player/Hikaru/games/archives | jq -rc ".archives[]") + +# Iterate over each archive URL +for url in $archive_urls; do + # Fetch the games from each archive and extract the PGN + curl -Ls "$url" | jq -rc ".games[].pgn" +done >> games.pgn # Append all PGNs to the 'games.pgn' file +``` + +Then just: + +```bash +git clone https://git.sr.ht/~swaits/hikaru +cd hikaru +cargo run --release +``` + +If you find mistakes, please let me know. I'll be very grateful. diff --git a/src/chess.rs b/src/chess.rs new file mode 100644 index 0000000..3f985ef --- /dev/null +++ b/src/chess.rs @@ -0,0 +1,90 @@ +use std::str::FromStr; + +use indoc::indoc; +use lazy_static::lazy_static; +use regex::Regex; + +use crate::elo::Rating; + +// a chess player - the minimum data we care about, just name and rating +#[derive(Clone, Debug)] +pub struct Player { + pub name: String, + pub rating: Rating, +} + +// a single chess game, white/black players and the outcome (actual or simulated) +#[derive(Clone, Debug)] +pub struct Game { + pub white: Player, + pub black: Player, + pub outcome: Outcome, +} + +// possible outocmes of a chess game +#[derive(Clone, Debug)] +pub enum Outcome { + WhiteWin, + BlackWin, + Draw, +} + +// this regex captures all the possible fields in a PGN as exported from chess.com's API +lazy_static! { + static ref RE: Regex = Regex::new(indoc! {r#" + \[Event\s+"(?P.+?)"\]\s* + \[Site\s+"(?P.+?)"\]\s* + \[Date\s+"(?P.+?)"\]\s* + \[Round\s+"(?P.+?)"\]\s* + \[White\s+"(?P.+?)"\]\s* + \[Black\s+"(?P.+?)"\]\s* + \[Result\s+"(?P.+?)"\]\s* + \[CurrentPosition\s+"(?P.+?)"\]\s* + \[Timezone\s+"(?P.+?)"\]\s* + \[ECO\s+"(?P.+?)"\]\s* + \[ECOUrl\s+"(?P.+?)"\]\s* + \[UTCDate\s+"(?P.+?)"\]\s* + \[UTCTime\s+"(?P.+?)"\]\s* + \[WhiteElo\s+"(?P.+?)"\]\s* + \[BlackElo\s+"(?P.+?)"\]\s* + \[TimeControl\s+"(?P.+?)"\]\s* + \[Termination\s+"(?P.+?)"\]\s* + \[StartTime\s+"(?P.+?)"\]\s* + \[EndDate\s+"(?P.+?)"\]\s* + \[EndTime\s+"(?P.+?)"\]\s* + \[Link\s+"(?P.+?)"\]\s* + (?P.+?)\n + "#}) + .unwrap(); +} + +// given a sting (expected to be a dump of chess.com's API), parse and return a list of games +pub fn parse_games(input: &str) -> Vec { + let mut games = Vec::new(); + + for caps in RE.captures_iter(input) { + let white_player = Player { + name: caps["White"].to_string(), + rating: Rating::from_str(caps["WhiteElo"].to_string().as_str()).unwrap(), + }; + let black_player = Player { + name: caps["Black"].to_string(), + // rating: caps["BlackElo"].to_string().as_str().into(), + rating: Rating::from_str(caps["BlackElo"].to_string().as_str()).unwrap(), + }; + let outcome = match &caps["Result"] { + "1-0" => Outcome::WhiteWin, + "0-1" => Outcome::BlackWin, + "1/2-1/2" => Outcome::Draw, + _ => continue, // or handle unknown result + }; + + games.push(Game { + white: white_player, + black: black_player, + outcome, + }); + } + + games +} diff --git a/src/elo.rs b/src/elo.rs new file mode 100644 index 0000000..3afc5df --- /dev/null +++ b/src/elo.rs @@ -0,0 +1,73 @@ +// standard imports +use std::{num::ParseIntError, str::FromStr}; + +// crate imports +use assert_approx_eq::assert_approx_eq; + +// probabilities of white or black winning or drawing +#[derive(Debug)] +pub struct ExpectedOutcome { + pub white: f64, + pub black: f64, + pub draw: f64, +} + +// a new type for an Elo rating +#[derive(Clone, Debug)] +pub struct Rating(pub u32); + +// create a `Rating` from a string (used during loading/parsing) +impl FromStr for Rating { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + let num = s.parse::()?; // Or whatever the primitive type should be + Ok(Self(num)) // Replace with the correct method to construct a Rating + } +} + +// estimate probability of draw given two Elo ratings +fn probability_of_draw(white_elo: &Rating, black_elo: &Rating) -> f64 { + // Constants for draw probability estimation + let max_draw_probability = 0.18; // Upper limit of draw probability + let rating_difference_sensitivity = 0.05; // Adjusts sensitivity to rating difference + let rating_diff = (black_elo.0 as f64 - white_elo.0 as f64).abs(); + max_draw_probability / (1.0 + rating_difference_sensitivity * rating_diff) +} + +// estimate probability of white winning, given two Elo ratings +fn probability_of_white_win(white_elo: &Rating, black_elo: &Rating) -> f64 { + // Expected outcomes for players A and B + // E_a = 1 / (1 + 10^((b - a) / 400) + // E_b = 1 - E_a + 1.0 / (1.0 + 10.0f64.powf((black_elo.0 as f64 - white_elo.0 as f64) / 400.0)) +} + +// estimate probability of black winning given white's probability of winning +fn probability_of_black_win(probability_of_white_win: f64) -> f64 { + 1.0 - probability_of_white_win +} + +// given two ratings, compute probabilities of win/loss/draw and return in `ExpectedOutcome` +pub fn calculate_expected_outcome(white_elo: &Rating, black_elo: &Rating) -> ExpectedOutcome { + // Estimate win probabilities + let mut p_white = probability_of_white_win(white_elo, black_elo); + let mut p_black = probability_of_black_win(p_white); + + // Estimate draw probability based on rating difference + let p_draw = probability_of_draw(white_elo, black_elo); + + // Adjust win probabilities to account for draw probability + p_white *= 1.0 - p_draw; + p_black *= 1.0 - p_draw; + + // Sanity check + assert_approx_eq!((p_white + p_black + p_draw), 1.0); + + ExpectedOutcome { + // Estimate win probabilities + white: p_white, + black: p_black, + draw: p_draw, + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8f3bba9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,124 @@ +// project modules +mod chess; +mod elo; +mod stochastic; + +// standard imports +use std::{collections::HashMap, fs, sync::Arc}; + +// crate imports +use rayon::prelude::*; + +// project imports +use chess::{parse_games, Game, Outcome}; +use stochastic::simulate_games; + +// load games into `Vec` from the chess.com API output +fn load_games() -> Vec { + let filename = "games.pgn"; + + // Read the contents of the file into a string + let contents = fs::read_to_string(filename).unwrap(); + + // Parse the contents into a Vec + parse_games(&contents) +} + +// find all the win streaks in a `Vec` for players matching `player_name` +fn generate_win_streak_histogram(player_name: &str, games: &[Game]) -> HashMap { + let mut histogram = HashMap::new(); + let mut current_streak = 0; + + for game in games { + let is_player_white = game.white.name == player_name; + let is_player_black = game.black.name == player_name; + + assert!(is_player_white || is_player_black); + if !(is_player_white || is_player_black) { + continue; + } + + let won_as_white = is_player_white && matches!(game.outcome, Outcome::WhiteWin); + let won_as_black = is_player_black && matches!(game.outcome, Outcome::BlackWin); + + if won_as_white || won_as_black { + // our player won + current_streak += 1; + } else if current_streak > 0 { + // our playe lost or drew, record streak and reset it + *histogram.entry(current_streak).or_insert(0) += 1; + current_streak = 0; + } + } + + // Handle the last streak if the games list ends on a winning streak + if current_streak > 0 { + *histogram.entry(current_streak).or_insert(0) += 1; + } + + histogram +} + +// dump the histogram to stdout +fn print_histogram(histogram: &HashMap, divisor: Option) { + let max_streak = histogram.keys().max().cloned().unwrap_or(0); + + println!("Win Streak Histogram:"); + for streak_length in 1..=max_streak { + let count = histogram.get(&streak_length).cloned().unwrap_or(0); + + match divisor { + Some(div) => { + let expected_count = (count as f64) / (div as f64); + let probability = expected_count.min(1.0) * 100.0; + println!( + "Streak Length: {:>3}, Expected Count: {:>5.0}, Probability: {:>6.2}%", + streak_length, expected_count, probability + ); + } + None => { + println!("Streak Length: {:>3}, Count: {}", streak_length, count); + } + } + } +} + +fn main() { + // load actual games + println!("Loading..."); + let games = Arc::new(load_games()); // Arc because of rayon use below + println!("Loaded {} games", games.len()); + println!(); + + println!("Actual Results Histogram:"); + let histogram = generate_win_streak_histogram("Hikaru", &games); + print_histogram(&histogram, None); + println!(); + + // Run the simulations in parallel and collect the histograms + println!("Simulating..."); + println!(); + let iterations = 10_000; + let histograms: Vec> = (0..iterations) + .into_par_iter() + .map(|_| { + let simulated_games = simulate_games(&games); + generate_win_streak_histogram("Hikaru", &simulated_games) + }) + .collect(); + + // Reduce the histograms into a single histogram + let merged_histogram = histograms + .into_iter() + .reduce(|mut acc, h| { + for (k, v) in h { + *acc.entry(k).or_insert(0) += v; + } + acc + }) + .unwrap(); + + // And print it! + println!("Simulated Results ({} games):", iterations); + print_histogram(&merged_histogram, Some(iterations)); +} diff --git a/src/stochastic.rs b/src/stochastic.rs new file mode 100644 index 0000000..8cd2411 --- /dev/null +++ b/src/stochastic.rs @@ -0,0 +1,37 @@ +use rand::Rng; + +use crate::{ + chess::{Game, Outcome}, + elo::{calculate_expected_outcome, Rating}, +}; + +// simulates a single game +fn simulate_game(white_elo: &Rating, black_elo: &Rating) -> Outcome { + // Get expected outcomes + let e = calculate_expected_outcome(white_elo, black_elo); + + // Roll the dice + let mut rng = rand::thread_rng(); + let random_value = rng.gen::(); + + // Determine and return outcome + if random_value <= e.white { + Outcome::WhiteWin + } else if random_value <= (e.white + e.black) { + Outcome::BlackWin + } else { + Outcome::Draw + } +} + +// simulate a series of games and return a new simulated `Vec` series +pub fn simulate_games(actual: &[Game]) -> Vec { + actual + .iter() + .map(|g| Game { + white: g.white.to_owned(), + black: g.black.to_owned(), + outcome: simulate_game(&g.white.rating, &g.black.rating), + }) + .collect() +}