feat: initial implementation

This commit is contained in:
swaits committed 2023-12-04 19:59:30 -07:00
commit 6c565ff0a3
9 files changed
+624

No files matched your search

+15
View File
@@ -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
Generated
+222
View File
@@ -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"
+14
View File
@@ -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"
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2023 Stephen Waits <steve@waits.net>
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.
+27
View File
@@ -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.
+90
View File
@@ -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<Event>.+?)"\]\s*
\[Site\s+"(?P<Site>.+?)"\]\s*
\[Date\s+"(?P<Date>.+?)"\]\s*
\[Round\s+"(?P<Round>.+?)"\]\s*
\[White\s+"(?P<White>.+?)"\]\s*
\[Black\s+"(?P<Black>.+?)"\]\s*
\[Result\s+"(?P<Result>.+?)"\]\s*
\[CurrentPosition\s+"(?P<CurrentPosition>.+?)"\]\s*
\[Timezone\s+"(?P<Timezone>.+?)"\]\s*
\[ECO\s+"(?P<ECO>.+?)"\]\s*
\[ECOUrl\s+"(?P<ECOUrl>.+?)"\]\s*
\[UTCDate\s+"(?P<UTCDate>.+?)"\]\s*
\[UTCTime\s+"(?P<UTCTime>.+?)"\]\s*
\[WhiteElo\s+"(?P<WhiteElo>.+?)"\]\s*
\[BlackElo\s+"(?P<BlackElo>.+?)"\]\s*
\[TimeControl\s+"(?P<TimeControl>.+?)"\]\s*
\[Termination\s+"(?P<Termination>.+?)"\]\s*
\[StartTime\s+"(?P<StartTime>.+?)"\]\s*
\[EndDate\s+"(?P<EndDate>.+?)"\]\s*
\[EndTime\s+"(?P<EndTime>.+?)"\]\s*
\[Link\s+"(?P<Link>.+?)"\]\s*
(?P<PGN>.+?)\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<Game> {
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
}
+73
View File
@@ -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<Self, Self::Err> {
let num = s.parse::<u32>()?; // 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,
}
}
+124
View File
@@ -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<Game>` from the chess.com API output
fn load_games() -> Vec<Game> {
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<Game>
parse_games(&contents)
}
// find all the win streaks in a `Vec<Game>` for players matching `player_name`
fn generate_win_streak_histogram(player_name: &str, games: &[Game]) -> HashMap<usize, usize> {
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<usize, usize>, divisor: Option<u32>) {
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<HashMap<usize, usize>> = (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));
}
+37
View File
@@ -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::<f64>();
// 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<Game>` series
pub fn simulate_games(actual: &[Game]) -> Vec<Game> {
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()
}