From 71b81789f3f34a6cbb58eb76621b1f0df9b379ae Mon Sep 17 00:00:00 2001 From: Kiana Sheibani Date: Fri, 13 Oct 2023 15:23:36 -0400 Subject: [PATCH] Transition to Glicko-based scoring system --- src/datasets.rs | 76 +++++++++++++--- src/queries/event_sets.rs | 10 ++- src/sync.rs | 177 ++++++++++++++++++++++++++++++++------ 3 files changed, 225 insertions(+), 38 deletions(-) diff --git a/src/datasets.rs b/src/datasets.rs index c06562f..61699ae 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -104,7 +104,10 @@ pub fn new_dataset( r#"CREATE TABLE "{0}_players" ( id INTEGER PRIMARY KEY, name TEXT, - prefix TEXT + prefix TEXT, + deviation REAL NOT NULL, + volatility REAL NOT NULL, + last_played INTEGER NOT NULL ); CREATE TABLE "{0}_network" ( @@ -196,9 +199,10 @@ pub fn add_players( connection: &Connection, dataset: &str, teams: &Teams, + time: Timestamp, ) -> sqlite::Result<()> { let query = format!( - r#"INSERT OR IGNORE INTO "{}_players" VALUES (?, ?, ?)"#, + r#"INSERT OR IGNORE INTO "{}_players" VALUES (?, ?, ?, ?, ?, ?)"#, dataset ); @@ -208,11 +212,56 @@ pub fn add_players( statement.bind((1, id.0 as i64))?; statement.bind((2, name.as_ref().map(|x| &x[..])))?; statement.bind((3, prefix.as_ref().map(|x| &x[..])))?; + statement.bind((4, 2.01))?; + statement.bind((5, 0.06))?; + statement.bind((6, time.0 as i64))?; statement.into_iter().try_for_each(|x| x.map(|_| ())) }) }) } +pub fn get_player_data( + connection: &Connection, + dataset: &str, + player: PlayerId, +) -> sqlite::Result<(f64, f64, Timestamp)> { + let query = format!( + r#"SELECT deviation, volatility, last_played FROM "{}_players" WHERE id = ?"#, + dataset + ); + + let mut statement = connection.prepare(&query)?; + statement.bind((1, player.0 as i64))?; + statement.next()?; + Ok(( + statement.read::("deviation")?, + statement.read::("volatility")?, + Timestamp(statement.read::("last_played")? as u64), + )) +} + +pub fn set_player_data( + connection: &Connection, + dataset: &str, + player: PlayerId, + last_played: Timestamp, + deviation: f64, + volatility: f64, +) -> sqlite::Result<()> { + let query = format!( + r#"UPDATE "{}_players" SET deviation = ?, volatility = ?, last_played = ? WHERE id = ?"#, + dataset + ); + + let mut statement = connection.prepare(&query)?; + statement.bind((1, deviation))?; + statement.bind((2, volatility))?; + statement.bind((3, last_played.0 as i64))?; + statement.bind((4, player.0 as i64))?; + statement.next()?; + Ok(()) +} + pub fn get_advantage( connection: &Connection, dataset: &str, @@ -412,11 +461,11 @@ pub fn initialize_edge( // Tests #[cfg(test)] -mod tests { +pub mod tests { use super::*; // Mock a database file in transient memory - fn mock_datasets() -> sqlite::Result { + pub fn mock_datasets() -> sqlite::Result { let query = " PRAGMA foreign_keys = ON; @@ -435,7 +484,7 @@ mod tests { // Functions to generate placeholder data - fn metadata() -> DatasetMetadata { + pub fn metadata() -> DatasetMetadata { DatasetMetadata { last_sync: Timestamp(1), game_id: VideogameId(0), @@ -444,7 +493,7 @@ mod tests { } } - fn players(num: u64) -> Vec { + pub fn players(num: u64) -> Vec { (1..=num) .map(|i| PlayerData { id: PlayerId(i), @@ -477,7 +526,7 @@ mod tests { let connection = mock_datasets()?; new_dataset(&connection, "test", metadata())?; - add_players(&connection, "test", &vec![players(2)])?; + add_players(&connection, "test", &vec![players(2)], Timestamp(0))?; let mut statement = connection.prepare("SELECT * FROM dataset_test_players WHERE id = 1")?; @@ -493,7 +542,7 @@ mod tests { fn edge_insert_get() -> sqlite::Result<()> { let connection = mock_datasets()?; new_dataset(&connection, "test", metadata())?; - add_players(&connection, "test", &vec![players(2)])?; + add_players(&connection, "test", &vec![players(2)], Timestamp(0))?; insert_advantage(&connection, "test", PlayerId(2), PlayerId(1), 1.0)?; @@ -513,7 +562,7 @@ mod tests { fn player_all_edges() -> sqlite::Result<()> { let connection = mock_datasets()?; new_dataset(&connection, "test", metadata())?; - add_players(&connection, "test", &vec![players(3)])?; + add_players(&connection, "test", &vec![players(3)], Timestamp(0))?; insert_advantage(&connection, "test", PlayerId(2), PlayerId(1), 1.0)?; insert_advantage(&connection, "test", PlayerId(1), PlayerId(3), 5.0)?; @@ -539,7 +588,12 @@ mod tests { let connection = mock_datasets()?; new_dataset(&connection, "test", metadata())?; - add_players(&connection, "test", &vec![players(num_players)])?; + add_players( + &connection, + "test", + &vec![players(num_players)], + Timestamp(0), + )?; for i in 1..=num_players { for j in 1..=num_players { @@ -557,7 +611,7 @@ mod tests { fn hypoth_adv1() -> sqlite::Result<()> { let connection = mock_datasets()?; new_dataset(&connection, "test", metadata())?; - add_players(&connection, "test", &vec![players(2)])?; + add_players(&connection, "test", &vec![players(2)], Timestamp(0))?; insert_advantage(&connection, "test", PlayerId(1), PlayerId(2), 1.0)?; diff --git a/src/queries/event_sets.rs b/src/queries/event_sets.rs index 55ccd23..3b41f36 100644 --- a/src/queries/event_sets.rs +++ b/src/queries/event_sets.rs @@ -1,4 +1,4 @@ -use super::{EntrantId, EventId, PlayerData, PlayerId, QueryUnwrap}; +use super::{EntrantId, EventId, PlayerData, PlayerId, QueryUnwrap, Timestamp}; use cynic::GraphQlResponse; use schema::schema; @@ -42,6 +42,7 @@ struct PageInfo { #[derive(cynic::QueryFragment, Debug)] struct Set { + start_at: Option, #[arguments(includeByes: true)] #[cynic(flatten)] slots: Vec, @@ -81,6 +82,7 @@ pub struct EventSetsResponse { #[derive(Debug)] pub struct SetData { + pub time: Timestamp, pub teams: Teams, pub winner: usize, } @@ -122,7 +124,11 @@ impl QueryUnwrap for EventSets { .try_collect() }) .try_collect()?; - Some(SetData { teams, winner }) + Some(SetData { + time: set.start_at?, + teams, + winner, + }) }) .collect::>(); diff --git a/src/sync.rs b/src/sync.rs index c264f9d..f9d5325 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,3 +1,4 @@ +use std::f64::consts::PI; use std::thread::sleep; use std::time::Duration; @@ -6,6 +7,77 @@ use crate::error; use crate::queries::*; use sqlite::*; +// Glicko-2 system calculation + +fn time_adjust(periods: f64, old_dev_sq: f64, volatility: f64) -> f64 { + (old_dev_sq + periods * volatility * volatility).sqrt() +} + +fn illinois_optimize(fun: impl Fn(f64) -> f64, mut a: f64, mut b: f64) -> f64 { + let mut f_a = fun(a); + let mut f_b = fun(b); + + while (b - a).abs() > 1e-6 { + let c = a + (a - b) * f_a / (f_b - f_a); + let f_c = fun(c); + if f_c * f_b > 0.0 { + f_a = f_a / 2.0; + } else { + a = b; + f_a = f_b; + } + b = c; + f_b = f_c; + } + a +} + +fn glicko_adjust( + advantage: f64, + deviation: f64, + volatility: f64, + other_deviation: f64, + won: bool, + time: u64, +) -> (f64, f64, f64) { + // TODO: Turn this into dataset metadata + let tau = 0.2; + let period = (3600 * 24 * 30) as f64; + + let g_val = 1.0 / (1.0 + 3.0 * other_deviation * other_deviation / PI / PI).sqrt(); + let exp_val = 1.0 / (1.0 + f64::exp(-g_val * advantage)); + + let variance = 1.0 / (g_val * g_val * exp_val * (1.0 - exp_val)); + + let score = if won { 1.0 } else { 0.0 }; + let delta = variance * g_val * (score - exp_val); + + let delta_sq = delta * delta; + let dev_sq = deviation * deviation; + let a = (volatility * volatility).ln(); + let vol_fn = |x| { + let ex = f64::exp(x); + let subf = dev_sq + variance + ex; + ((ex * (delta_sq - dev_sq - variance - ex)) / 2.0 / subf / subf) - (x - a) / tau / tau + }; + + let initial_b = if delta_sq > dev_sq + variance { + (delta_sq - dev_sq - variance).ln() + } else { + (1..) + .map(|k| vol_fn(a - k as f64 * tau)) + .find(|x| x >= &0.0) + .unwrap() + }; + let vol_new = f64::exp(illinois_optimize(vol_fn, a, initial_b) / 2.0); + + let dev_time = time_adjust(time as f64 / period as f64, dev_sq, vol_new); + let dev_new = 1.0 / (1.0 / dev_time / dev_time + 1.0 / variance).sqrt(); + let adjust = dev_new * dev_new * g_val * (score - exp_val); + + (adjust, dev_new, vol_new) +} + // Extract set data fn get_event_sets(event: EventId, auth: &str) -> Option> { @@ -105,9 +177,9 @@ fn get_tournament_events(metadata: &DatasetMetadata, auth: &str) -> Option sqlite::Result<()> { let players_data = results.teams; - add_players(connection, dataset, &players_data)?; + add_players(connection, dataset, &players_data, results.time)?; - // Singles matches are currently not supported + // Non-singles matches are currently not supported if players_data.len() != 2 || players_data[0].len() != 1 || players_data[1].len() != 1 { return Ok(()); } @@ -115,36 +187,58 @@ fn update_from_set(connection: &Connection, dataset: &str, results: SetData) -> let mut it = players_data.into_iter(); let player1 = it.next().unwrap()[0].id; let player2 = it.next().unwrap()[0].id; + drop(it); + let (deviation1, volatility1, last_played1) = get_player_data(connection, dataset, player1)?; + let (deviation2, volatility2, last_played2) = get_player_data(connection, dataset, player1)?; let advantage = match get_advantage(connection, dataset, player1, player2) { Err(e) => Err(e)?, Ok(None) => initialize_edge(connection, dataset, player1, player2)?, Ok(Some(adv)) => adv, }; - let adjust = 30.0 * (1.0 - 1.0 / (1.0 + 10_f64.powf(advantage / 400.0))); + let (adjust1, dev_new1, vol_new1) = glicko_adjust( + -advantage, + deviation1, + volatility1, + deviation2, + results.winner == 0, + results.time.0 - last_played1.0, + ); + let (adjust2, dev_new2, vol_new2) = glicko_adjust( + advantage, + deviation2, + volatility2, + deviation1, + results.winner == 1, + results.time.0 - last_played2.0, + ); - if results.winner == 0 { - adjust_advantages(connection, dataset, player1, 0.5 * adjust)?; - adjust_advantages(connection, dataset, player2, -0.5 * adjust)?; - adjust_advantage( - connection, - dataset, - player1, - player2, - -2.0 * (1.0 - 0.5) * adjust, - )?; - } else { - adjust_advantages(connection, dataset, player1, -0.5 * adjust)?; - adjust_advantages(connection, dataset, player2, 0.5 * adjust)?; - adjust_advantage( - connection, - dataset, - player1, - player2, - 2.0 * (1.0 - 0.5) * adjust, - )?; - } - Ok(()) + set_player_data( + connection, + dataset, + player1, + results.time, + dev_new1, + vol_new1, + )?; + set_player_data( + connection, + dataset, + player2, + results.time, + dev_new2, + vol_new2, + )?; + + adjust_advantages(connection, dataset, player1, 0.5 * adjust1)?; + adjust_advantages(connection, dataset, player2, 0.5 * adjust2)?; + adjust_advantage( + connection, + dataset, + player1, + player2, + (1.0 - 0.5) * (adjust2 - adjust1), + ) } pub fn sync_dataset( @@ -175,3 +269,36 @@ pub fn sync_dataset( } connection.execute("COMMIT;") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::datasets::tests::*; + + #[test] + fn glicko_single() -> sqlite::Result<()> { + let connection = mock_datasets()?; + new_dataset(&connection, "test", metadata())?; + let players = players(2).into_iter().map(|x| vec![x]).collect(); + add_players(&connection, "test", &players, Timestamp(0))?; + + update_from_set( + &connection, + "test", + SetData { + time: Timestamp(0), + teams: players, + winner: 0, + }, + )?; + + println!( + "{:?}", + get_advantage(&connection, "test", PlayerId(1), PlayerId(2))?.unwrap() + ); + println!("{:?}", get_player_data(&connection, "test", PlayerId(1))); + println!("{:?}", get_player_data(&connection, "test", PlayerId(2))); + + Ok(()) + } +}