use std::f64::consts::PI;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::{
glicko::GlickoRating, glicko2::Glicko2Rating, glicko_boost::GlickoBoostRating, Outcomes,
Rating, RatingPeriodSystem, RatingSystem,
};
#[derive(Copy, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct StickoRating {
pub rating: f64,
pub deviation: f64,
}
impl StickoRating {
#[must_use]
pub const fn new() -> Self {
Self {
rating: 1500.0,
deviation: 350.0,
}
}
}
impl Default for StickoRating {
fn default() -> Self {
Self::new()
}
}
impl Rating for StickoRating {
fn rating(&self) -> f64 {
self.rating
}
fn uncertainty(&self) -> Option<f64> {
Some(self.deviation)
}
fn new(rating: Option<f64>, uncertainty: Option<f64>) -> Self {
Self {
rating: rating.unwrap_or(1500.0),
deviation: uncertainty.unwrap_or(350.0),
}
}
}
impl From<(f64, f64)> for StickoRating {
fn from((r, d): (f64, f64)) -> Self {
Self {
rating: r,
deviation: d,
}
}
}
impl From<GlickoRating> for StickoRating {
fn from(g: GlickoRating) -> Self {
Self {
rating: g.rating,
deviation: g.deviation,
}
}
}
impl From<Glicko2Rating> for StickoRating {
fn from(g: Glicko2Rating) -> Self {
Self {
rating: g.rating,
deviation: g.deviation,
}
}
}
impl From<GlickoBoostRating> for StickoRating {
fn from(g: GlickoBoostRating) -> Self {
Self {
rating: g.rating,
deviation: g.deviation,
}
}
}
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct StickoConfig {
pub h: f64,
pub beta: f64,
pub lambda: f64,
pub gamma: f64,
pub c: f64,
}
impl StickoConfig {
#[must_use]
pub const fn new() -> Self {
Self {
h: 10.0,
beta: 0.0,
lambda: 2.0,
gamma: 0.0,
c: 10.0,
}
}
}
impl Default for StickoConfig {
fn default() -> Self {
Self::new()
}
}
pub struct Sticko {
config: StickoConfig,
}
impl RatingSystem for Sticko {
type RATING = StickoRating;
type CONFIG = StickoConfig;
fn new(config: Self::CONFIG) -> Self {
Self { config }
}
fn rate(
&self,
player_one: &StickoRating,
player_two: &StickoRating,
outcome: &Outcomes,
) -> (StickoRating, StickoRating) {
sticko(player_one, player_two, outcome, &self.config)
}
fn expected_score(&self, player_one: &StickoRating, player_two: &StickoRating) -> (f64, f64) {
expected_score(player_one, player_two, &self.config)
}
}
impl RatingPeriodSystem for Sticko {
type RATING = StickoRating;
type CONFIG = StickoConfig;
fn new(config: Self::CONFIG) -> Self {
Self { config }
}
fn rate(&self, player: &StickoRating, results: &[(StickoRating, Outcomes)]) -> StickoRating {
let new_results: Vec<(StickoRating, Outcomes, bool)> =
results.iter().map(|r| (r.0, r.1, true)).collect();
sticko_rating_period(player, &new_results[..], &self.config)
}
fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec<f64> {
let new_opponents: Vec<(StickoRating, bool)> =
opponents.iter().map(|o| (*o, true)).collect();
expected_score_rating_period(player, &new_opponents, &self.config)
}
}
#[must_use]
pub fn sticko(
player_one: &StickoRating,
player_two: &StickoRating,
outcome: &Outcomes,
config: &StickoConfig,
) -> (StickoRating, StickoRating) {
let q = 10.0_f64.ln() / 400.0;
let outcome1 = outcome.to_chess_points();
let outcome2 = 1.0 - outcome1;
let colour1 = 1.0;
let colour2 = -1.0;
let lambda1 = (config.lambda / 100.0) * (player_two.rating - player_one.rating);
let lambda2 = (config.lambda / 100.0) * (player_one.rating - player_two.rating);
let g1 = g_value(q, player_two.deviation);
let g2 = g_value(q, player_one.deviation);
let e1 = e_value(
g1,
player_one.rating,
player_two.rating,
config.gamma,
colour1,
);
let e2 = e_value(
g2,
player_two.rating,
player_one.rating,
config.gamma,
colour2,
);
let d1 = d_value(q, g1, e1);
let d2 = d_value(q, g2, e2);
let player_one_pre_deviation = player_one.deviation.hypot(config.c).min(350.0);
let player_two_pre_deviation = player_two.deviation.hypot(config.c).min(350.0);
let player_one_new_rating = new_rating(
player_one.rating,
player_one_pre_deviation,
q,
d1,
g1,
outcome1,
e1,
config.beta,
lambda1,
);
let player_two_new_rating = new_rating(
player_two.rating,
player_two_pre_deviation,
q,
d2,
g2,
outcome2,
e2,
config.beta,
lambda2,
);
let player_one_new_deviation = new_deviation(player_one_pre_deviation, d1, config.h);
let player_two_new_deviation = new_deviation(player_two_pre_deviation, d2, config.h);
(
StickoRating {
rating: player_one_new_rating,
deviation: player_one_new_deviation,
},
StickoRating {
rating: player_two_new_rating,
deviation: player_two_new_deviation,
},
)
}
#[must_use]
pub fn sticko_rating_period(
player: &StickoRating,
results: &[(StickoRating, Outcomes, bool)],
config: &StickoConfig,
) -> StickoRating {
let q = 10_f64.ln() / 400.0;
if results.is_empty() {
return decay_deviation(player, config);
}
let matches = results.len() as f64;
let d_sq: f64 = (q.powi(2)
* results
.iter()
.map(|r| {
let g = g_value(q, r.0.deviation);
let e = e_value(
g,
player.rating,
r.0.rating,
config.gamma,
if r.2 { 1.0 } else { -1.0 },
);
g.powi(2) * e * (1.0 - e)
})
.sum::<f64>())
.recip();
let lambda = (config.lambda / 100.0)
* ((results.iter().map(|r| r.0.rating).sum::<f64>() / matches) - player.rating);
let m: f64 = results
.iter()
.map(|r| {
let g = g_value(q, r.0.deviation);
let e = e_value(
g,
player.rating,
r.0.rating,
config.gamma,
if r.2 { 1.0 } else { -1.0 },
);
let s = r.1.to_chess_points();
g * (s - e + config.beta)
})
.sum();
let pre_deviation = player.deviation.hypot(config.c).min(350.0);
let new_deviation = ((pre_deviation
.mul_add(pre_deviation, config.h * matches)
.recip()
+ d_sq.recip())
.recip()
.sqrt())
.min(350.0);
let new_rating =
(q / (pre_deviation.powi(2).recip() + d_sq.recip())).mul_add(m, player.rating) + lambda;
StickoRating {
rating: new_rating,
deviation: new_deviation,
}
}
#[must_use]
pub fn expected_score(
player_one: &StickoRating,
player_two: &StickoRating,
config: &StickoConfig,
) -> (f64, f64) {
let q = 10_f64.ln() / 400.0;
let g = g_value(q, player_one.deviation.hypot(player_two.deviation));
let exp_one = (1.0
+ 10_f64.powf(-g * (player_one.rating + config.gamma - player_two.rating) / 400.0))
.recip();
let exp_two = 1.0 - exp_one;
(exp_one, exp_two)
}
#[must_use]
pub fn expected_score_rating_period(
player: &StickoRating,
opponents: &[(StickoRating, bool)],
config: &StickoConfig,
) -> Vec<f64> {
opponents
.iter()
.map(|o| {
let q = 10_f64.ln() / 400.0;
let g = g_value(q, player.deviation.hypot(o.0.deviation));
let gamma = if o.1 { config.gamma } else { -config.gamma };
(1.0 + 10_f64.powf(-g * (player.rating + gamma - o.0.rating) / 400.0)).recip()
})
.collect()
}
#[must_use]
pub fn decay_deviation(player: &StickoRating, config: &StickoConfig) -> StickoRating {
let new_player_deviation = player.deviation.hypot(config.c).min(350.0);
StickoRating {
rating: player.rating,
deviation: new_player_deviation,
}
}
#[must_use]
pub fn confidence_interval(player: &StickoRating) -> (f64, f64) {
(
1.96f64.mul_add(-player.deviation, player.rating),
1.96f64.mul_add(player.deviation, player.rating),
)
}
fn new_deviation(old_deviation: f64, d: f64, h: f64) -> f64 {
((old_deviation.mul_add(old_deviation, h).recip() + d.recip())
.recip()
.sqrt())
.min(350.0)
}
#[allow(clippy::too_many_arguments)]
fn new_rating(
old_rating: f64,
deviation: f64,
q: f64,
d: f64,
g: f64,
score: f64,
e: f64,
beta: f64,
lambda: f64,
) -> f64 {
((q / (deviation.powi(2).recip() + d.recip())) * g).mul_add(score - e + beta, old_rating)
+ lambda
}
fn g_value(q: f64, opponent_deviation: f64) -> f64 {
(1.0 + ((3.0 * q.powi(2) * opponent_deviation.powi(2)) / (PI.powi(2))))
.sqrt()
.recip()
}
fn e_value(g: f64, rating: f64, opponent_rating: f64, advantage: f64, colour: f64) -> f64 {
(1.0 + (10_f64.powf(-g * advantage.mul_add(colour, rating - opponent_rating) / 400.0))).recip()
}
fn d_value(q: f64, g: f64, e: f64) -> f64 {
(q.powi(2) * g.powi(2) * e * (1.0 - e)).powi(-1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sticko() {
let config = StickoConfig {
h: 0.0,
beta: 0.0,
lambda: 0.0,
gamma: 0.0,
c: 0.0,
};
let player1 = StickoRating {
rating: 1500.0,
deviation: 200.0,
};
let opponent1 = StickoRating {
rating: 1400.0,
deviation: 30.0,
};
let opponent2 = StickoRating {
rating: 1550.0,
deviation: 100.0,
};
let opponent3 = StickoRating {
rating: 1700.0,
deviation: 300.0,
};
let (player1, _) = sticko(&player1, &opponent1, &Outcomes::WIN, &config);
let (player1, _) = sticko(&player1, &opponent2, &Outcomes::LOSS, &config);
let (player1, _) = sticko(&player1, &opponent3, &Outcomes::LOSS, &config);
assert!((player1.rating.round() - 1464.0).abs() < f64::EPSILON);
assert!((player1.deviation - 151.253_743_431_783_2).abs() < f64::EPSILON);
}
#[test]
fn test_sticko_rating_period() {
let config = StickoConfig {
h: 0.0,
beta: 0.0,
lambda: 0.0,
gamma: 0.0,
c: 0.0,
};
let player = StickoRating {
rating: 1500.0,
deviation: 200.0,
};
let opponent1 = StickoRating {
rating: 1400.0,
deviation: 30.0,
};
let opponent2 = StickoRating {
rating: 1550.0,
deviation: 100.0,
};
let opponent3 = StickoRating {
rating: 1700.0,
deviation: 300.0,
};
let new_player = sticko_rating_period(
&player,
&[
(opponent1, Outcomes::WIN, true),
(opponent2, Outcomes::LOSS, true),
(opponent3, Outcomes::LOSS, true),
],
&config,
);
assert!((new_player.rating.round() - 1464.0).abs() < f64::EPSILON);
assert!((new_player.deviation - 151.398_902_447_969_33).abs() < f64::EPSILON);
let after_player = sticko_rating_period(&player, &[], &config);
assert_eq!(player, after_player);
}
#[test]
fn test_single_rp() {
let player = StickoRating {
rating: 1300.0,
deviation: 25.0,
};
let opponent = StickoRating {
rating: 1500.0,
deviation: 34.0,
};
let config = StickoConfig {
h: 10.0,
beta: 5.0,
lambda: 5.0,
gamma: 30.0,
c: 10.0,
};
let (np, _) = sticko(&player, &opponent, &Outcomes::WIN, &config);
let rp = sticko_rating_period(&player, &[(opponent, Outcomes::WIN, true)], &config);
assert_eq!(rp, np);
}
#[test]
fn test_sticko_draw() {
let player_one = StickoRating {
rating: 2330.0,
deviation: 200.0,
};
let player_two = StickoRating {
rating: 1800.0,
deviation: 20.0,
};
let config = StickoConfig::new();
let (p1, p2) = sticko(&player_one, &player_two, &Outcomes::DRAW, &config);
assert!((p1.rating.round() - 2220.0).abs() < f64::EPSILON);
assert!((p1.deviation.round() - 195.0).abs() < f64::EPSILON);
assert!((p2.rating.round() - 1812.0).abs() < f64::EPSILON);
assert!((p2.deviation.round() - 23.0).abs() < f64::EPSILON);
}
#[test]
fn test_expected_score() {
let player_one = StickoRating {
rating: 1400.0,
deviation: 40.0,
};
let player_two = StickoRating {
rating: 1500.0,
deviation: 150.0,
};
let config_no_adv = StickoConfig::new();
let config_adv = StickoConfig {
gamma: 30.0,
..Default::default()
};
let (exp_one, exp_two) = expected_score(&player_one, &player_two, &config_no_adv);
assert!((exp_one - 0.373_700_405_951_935).abs() < f64::EPSILON);
assert!((exp_two - 0.626_299_594_048_065).abs() < f64::EPSILON);
assert!((exp_one + exp_two - 1.0).abs() < f64::EPSILON);
let (exp_one, exp_two) = expected_score(&player_one, &player_two, &config_adv);
assert!((exp_one - 0.410_605_680_590_947_1).abs() < f64::EPSILON);
assert!((exp_two - 0.589_394_319_409_053).abs() < f64::EPSILON);
assert!((exp_one + exp_two - 1.0).abs() < f64::EPSILON);
}
#[test]
#[allow(clippy::similar_names)]
fn sticko_glicko_conversions() {
let sticko = StickoRating::new();
let glicko_conv = GlickoRating::from(sticko);
let glicko2_conv = Glicko2Rating::from(sticko);
let glickob_conv = GlickoBoostRating::from(sticko);
assert!((glicko_conv.rating - 1500.0).abs() < f64::EPSILON);
assert!((glicko2_conv.rating - 1500.0).abs() < f64::EPSILON);
assert!((glickob_conv.rating - 1500.0).abs() < f64::EPSILON);
let glicko2 = Glicko2Rating::new();
let glicko = GlickoRating::new();
let glickob = GlickoBoostRating::new();
assert_eq!(StickoRating::new(), StickoRating::from(glicko2));
assert_eq!(StickoRating::default(), StickoRating::from(glicko));
assert_eq!(StickoRating::default(), StickoRating::from(glickob));
}
#[test]
fn test_confidence_interval() {
let player = StickoRating {
rating: 1500.0,
deviation: 30.0,
};
let ci = confidence_interval(&player);
assert!((ci.0.round() - 1441.0).abs() < f64::EPSILON);
assert!((ci.1.round() - 1559.0).abs() < f64::EPSILON);
}
#[test]
fn test_white_black() {
let player = StickoRating {
rating: 1300.0,
deviation: 120.0,
};
let opponent1 = StickoRating {
rating: 1500.0,
deviation: 105.0,
};
let opponent2 = StickoRating {
rating: 1200.0,
deviation: 125.0,
};
let opponent3 = StickoRating {
rating: 1560.0,
deviation: 140.0,
};
let config = StickoConfig {
gamma: 30.0,
..Default::default()
};
let tournament1 = vec![
(opponent1, Outcomes::WIN, true),
(opponent2, Outcomes::LOSS, true),
(opponent3, Outcomes::DRAW, true),
];
let tournament2 = vec![
(opponent1, Outcomes::WIN, false),
(opponent2, Outcomes::LOSS, false),
(opponent3, Outcomes::DRAW, false),
];
let comp_player = sticko_rating_period(&player, &tournament1, &StickoConfig::new());
assert!((comp_player.rating.round() - 1329.0).abs() < f64::EPSILON);
assert!((comp_player.deviation.round() - 108.0).abs() < f64::EPSILON);
let white_player = sticko_rating_period(&player, &tournament1, &config);
assert!((white_player.rating.round() - 1323.0).abs() < f64::EPSILON);
assert!((white_player.deviation.round() - 108.0).abs() < f64::EPSILON);
let black_player = sticko_rating_period(&player, &tournament2, &config);
assert!((black_player.rating.round() - 1335.0).abs() < f64::EPSILON);
assert!((black_player.deviation.round() - 109.0).abs() < f64::EPSILON);
}
#[test]
#[allow(clippy::clone_on_copy)]
fn test_misc_stuff() {
let player_one = StickoRating::new();
let config = StickoConfig::new();
assert_eq!(player_one, player_one.clone());
assert!((config.c - config.clone().c).abs() < f64::EPSILON);
assert!(!format!("{player_one:?}").is_empty());
assert!(!format!("{config:?}").is_empty());
assert_eq!(player_one, StickoRating::from((1500.0, 350.0)));
}
#[test]
fn test_traits() {
let player_one: StickoRating = Rating::new(Some(240.0), Some(90.0));
let player_two: StickoRating = Rating::new(Some(240.0), Some(90.0));
let rating_system: Sticko = RatingSystem::new(StickoConfig::new());
assert!((player_one.rating() - 240.0).abs() < f64::EPSILON);
assert_eq!(player_one.uncertainty(), Some(90.0));
let (new_player_one, new_player_two) =
RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN);
let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two);
assert!((new_player_one.rating - 261.352_796_989_360_1).abs() < f64::EPSILON);
assert!((new_player_two.rating - 218.647_203_010_639_9).abs() < f64::EPSILON);
assert!((exp1 - 0.5).abs() < f64::EPSILON);
assert!((exp2 - 0.5).abs() < f64::EPSILON);
let rating_period_system: Sticko = RatingPeriodSystem::new(StickoConfig::new());
let exp_rp =
RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]);
assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON);
let player_one: StickoRating = Rating::new(Some(240.0), Some(90.0));
let player_two: StickoRating = Rating::new(Some(240.0), Some(90.0));
let rating_period: Sticko = RatingPeriodSystem::new(StickoConfig::new());
let new_player_one =
RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]);
assert!((new_player_one.rating - 261.352_796_989_360_1).abs() < f64::EPSILON);
}
}