instant_glicko_2/
lib.rs

1//! This crate provides an implementation of the [Glicko-2](https://www.glicko.net/glicko/glicko2.pdf) rating system.
2//! Due to the concept of rating periods, Glicko-2 has the problem that rankings cannot easily be updated instantly after a match concludes.
3//!
4//! This implementation aims to solve that problem by allowing fractional rating periods, so that ratings can be updated directly after every game, and not just once a rating period closes.
5//! This draws inspiration from the [rating system implementation](https://github.com/lichess-org/lila/tree/master/modules/rating/src/main/glicko2) for open-source chess website [Lichess](https://lichess.org),
6//! as well as two blogpost ([1](https://blog.hypersect.com/the-online-skill-ranking-of-inversus-deluxe/), [2](https://blog.hypersect.com/additional-thoughts-on-skill-ratings/)) by Ryan Juckett on skill ratings for [INVERSUS Deluxe](https://www.inversusgame.com/).
7//!
8//! # Examples
9//!
10//! Example calculation from [Glickman's paper](https://www.glicko.net/glicko/glicko2.pdf) using [`algorithm`]:
11//!
12//! ```
13//! use instant_glicko_2::{Parameters, PublicRating, IntoWithParameters};
14//! use instant_glicko_2::algorithm::{self, PublicGame};
15//!
16//! let parameters = Parameters::default().with_volatility_change(0.5);
17//!
18//! // Create our player's rating
19//! let mut player = PublicRating::new(1500.0, 200.0, 0.06);
20//!
21//! // Create our opponents
22//! // Their volatility is not specified in the paper and it doesn't matter in the calculation,
23//! // so we're just using the default starting volatility.
24//! let opponent_a = PublicRating::new(1400.0, 30.0, parameters.start_rating().volatility());
25//! let opponent_b = PublicRating::new(1550.0, 100.0, parameters.start_rating().volatility());
26//! let opponent_c = PublicRating::new(1700.0, 300.0, parameters.start_rating().volatility());
27//!
28//! // Create match results for our player
29//! let results = [
30//!     // Wins first game (score 1.0)
31//!     PublicGame::new(opponent_a, 1.0).into_with_parameters(parameters),
32//!     // Loses second game (score 0.0)
33//!     PublicGame::new(opponent_b, 0.0).into_with_parameters(parameters),
34//!     // Loses third game (score 0.0)
35//!     PublicGame::new(opponent_c, 0.0).into_with_parameters(parameters),
36//! ];
37//!
38//! // Update rating after rating period
39//! let new_rating: PublicRating = algorithm::rate_games_untimed(player.into_with_parameters(parameters), &results, 1.0, parameters).into_with_parameters(parameters);
40//!
41//! // The rating after the rating period are very close to the results from the paper
42//! assert!((new_rating.rating() - 1464.06).abs() < 0.01);
43//! assert!((new_rating.deviation() - 151.52).abs() < 0.01);
44//! assert!((new_rating.volatility() - 0.05999).abs() < 0.0001);
45//! ```
46//!
47//! Different example using [`RatingEngine`][engine::RatingEngine]:
48//!
49//! ```
50//! use std::time::Duration;
51//!
52//! use instant_glicko_2::{Parameters, PublicRating};
53//! use instant_glicko_2::engine::{MatchResult, RatingEngine};
54//!
55//! let parameters = Parameters::default();
56//!
57//! // Create a RatingEngine with a one day rating period duration
58//! // The first rating period starts instantly
59//! let mut engine = RatingEngine::start_new(
60//!     Duration::from_secs(60 * 60 * 24),
61//!     Parameters::default(),
62//! );
63//!
64//! // Register two players
65//! // The first player is relatively strong
66//! let player_1_rating_old = PublicRating::new(1700.0, 300.0, 0.06);
67//! let player_1 = engine.register_player(player_1_rating_old).0;
68//! // The second player hasn't played any games
69//! let player_2_rating_old = parameters.start_rating();
70//! let player_2 = engine.register_player(player_2_rating_old).0;
71//!
72//! // They play and player_2 wins
73//! engine.register_result(
74//!     player_1,
75//!     player_2,
76//!     &MatchResult::Loss,
77//! );
78//!
79//! // Print the new ratings
80//! // Type signatures are needed because we could also work with the internal InternalRating
81//! // That skips one step of calculation,
82//! // but the rating values are not as pretty and not comparable to the original Glicko ratings
83//! let player_1_rating_new: PublicRating = engine.player_rating(player_1).0;
84//! println!("Player 1 old rating: {player_1_rating_old:?}, new rating: {player_1_rating_new:?}");
85//! let player_2_rating_new: PublicRating = engine.player_rating(player_2).0;
86//! println!("Player 2 old rating: {player_2_rating_old:?}, new rating: {player_2_rating_new:?}");
87//!
88//! // Loser's rating goes down, winner's rating goes up
89//! assert!(player_1_rating_old.rating() > player_1_rating_new.rating());
90//! assert!(player_2_rating_old.rating() < player_2_rating_new.rating());
91//! ```
92//!
93//! The [`algorithm`] module provides an implementation of the Glicko-2 algorithm that allows for fractional rating periods.
94//!
95//! The [`engine`] module provides the [`RatingEngine`][engine::RatingEngine] struct which allows for adding games
96//! and getting the current rating of managed players at any point in time.
97
98#![warn(clippy::pedantic)]
99#![warn(clippy::cargo)]
100#![warn(
101    missing_docs,
102    rustdoc::missing_crate_level_docs,
103    rustdoc::private_doc_tests
104)]
105#![deny(
106    rustdoc::broken_intra_doc_links,
107    rustdoc::private_intra_doc_links,
108    rustdoc::invalid_codeblock_attributes,
109    rustdoc::invalid_rust_codeblocks
110)]
111#![forbid(unsafe_code)]
112
113// TODO: Lots of const fn
114
115use constants::RATING_SCALING_RATIO;
116
117#[cfg(feature = "serde")]
118use serde::{Deserialize, Serialize};
119
120pub mod algorithm;
121pub mod constants;
122pub mod engine;
123pub mod util;
124
125/// Trait to convert between two types with [`Parameters`].
126/// Usually used to convert between the internal rating scaling and the public Glicko rating scaling.
127///
128/// A blanket implementation [`FromWithParameters<T>`] for any `T` is provided.
129pub trait FromWithParameters<T: ?Sized> {
130    /// Performs the conversion
131    #[must_use]
132    fn from_with_parameters(_: T, parameters: Parameters) -> Self;
133}
134
135impl<T> FromWithParameters<T> for T {
136    fn from_with_parameters(t: T, _: Parameters) -> Self {
137        t
138    }
139}
140
141/// Trait to convert between two types with [`Parameters`].
142/// Usually used to convert between the internal rating scaling and the public Glicko rating scaling.
143///
144/// This trait is automatically provided for any type `T` where [`FromWithParameters<T>`] is implemented.
145pub trait IntoWithParameters<T> {
146    /// Performs the conversion
147    fn into_with_parameters(self, parameters: Parameters) -> T;
148}
149
150impl<T, U> IntoWithParameters<U> for T
151where
152    U: FromWithParameters<T>,
153{
154    fn into_with_parameters(self, parameters: Parameters) -> U {
155        U::from_with_parameters(self, parameters)
156    }
157}
158
159/// A Glicko-2 skill rating.
160#[derive(Clone, Copy, PartialEq, Debug)]
161#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
162pub struct PublicRating {
163    rating: f64,
164    deviation: f64,
165    volatility: f64,
166}
167
168impl FromWithParameters<InternalRating> for PublicRating {
169    fn from_with_parameters(scaled: InternalRating, parameters: Parameters) -> Self {
170        let public_rating =
171            scaled.rating() * RATING_SCALING_RATIO + parameters.start_rating().rating();
172        let public_deviation = scaled.deviation() * RATING_SCALING_RATIO;
173
174        PublicRating::new(public_rating, public_deviation, scaled.volatility())
175    }
176}
177
178impl PublicRating {
179    /// Creates a new [`PublicRating`] with the specified parameters.
180    ///  
181    /// # Panics
182    ///
183    /// This function panics if `deviation <= 0.0` or `volatility <= 0.0`.
184    #[must_use]
185    pub fn new(rating: f64, deviation: f64, volatility: f64) -> Self {
186        assert!(deviation > 0.0, "deviation <= 0: {deviation}");
187        assert!(volatility > 0.0, "volatility <= 0: {volatility}");
188
189        PublicRating {
190            rating,
191            deviation,
192            volatility,
193        }
194    }
195
196    /// The rating value.
197    #[must_use]
198    pub fn rating(&self) -> f64 {
199        self.rating
200    }
201
202    /// The rating deviation.
203    #[must_use]
204    pub fn deviation(&self) -> f64 {
205        self.deviation
206    }
207
208    /// The rating volatility.
209    #[must_use]
210    pub fn volatility(&self) -> f64 {
211        self.volatility
212    }
213}
214
215/// A Glicko-2 rating scaled to the internal rating scale.
216/// See "Step 2." and "Step 8." in [Glickmans' paper](http://www.glicko.net/glicko/glicko2.pdf).
217#[derive(Clone, Copy, PartialEq, Debug)]
218#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
219pub struct InternalRating {
220    rating: f64,
221    deviation: f64,
222    volatility: f64,
223}
224
225impl FromWithParameters<PublicRating> for InternalRating {
226    fn from_with_parameters(rating: PublicRating, parameters: Parameters) -> Self {
227        let scaled_rating =
228            (rating.rating() - parameters.start_rating().rating()) / RATING_SCALING_RATIO;
229        let scaled_deviation = rating.deviation() / RATING_SCALING_RATIO;
230
231        InternalRating::new(scaled_rating, scaled_deviation, rating.volatility())
232    }
233}
234
235impl InternalRating {
236    /// Creates a new [`InternalRating`] with the specified parameters.
237    ///
238    /// # Panics
239    ///
240    /// This function panics if `deviation` or `volatility <= 0.0`.
241    #[must_use]
242    pub fn new(rating: f64, deviation: f64, volatility: f64) -> Self {
243        assert!(deviation > 0.0, "deviation <= 0: {deviation}");
244        assert!(volatility > 0.0, "volatility <= 0: {volatility}");
245
246        InternalRating {
247            rating,
248            deviation,
249            volatility,
250        }
251    }
252
253    /// The rating value.
254    #[must_use]
255    pub fn rating(&self) -> f64 {
256        self.rating
257    }
258
259    /// The rating deviation.
260    #[must_use]
261    pub fn deviation(&self) -> f64 {
262        self.deviation
263    }
264
265    /// The rating volatility.
266    #[must_use]
267    pub fn volatility(&self) -> f64 {
268        self.volatility
269    }
270}
271
272// TODO: Should probably contain rating_period_duration
273/// The parameters used by the Glicko-2 algorithm.
274#[derive(Clone, Copy, PartialEq, Debug)]
275#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
276pub struct Parameters {
277    start_rating: PublicRating,
278    volatility_change: f64,
279    convergence_tolerance: f64,
280}
281
282impl Parameters {
283    /// Creates [`Parameters`] with the given parameters.
284    ///
285    /// # Arguments
286    ///
287    /// * `start_rating` - The rating value a new player starts out with. See also [`constants::DEFAULT_START_RATING`].
288    /// * `volatility_change` - Also called "system constant" or "τ".
289    /// This constant constraints change in volatility over time.
290    /// Reasonable choices are between `0.3` and `1.2`.
291    /// Small values prevent volatility and therefore rating from changing too much after improbable results.
292    /// See also "Step 1." in [Glickman's paper](http://www.glicko.net/glicko/glicko2.pdf) and [`constants::DEFAULT_VOLATILITY_CHANGE`].
293    /// * `convergence_tolerance` - The cutoff value for the converging loop algorithm in "Step 5.1." in [Glickman's paper](http://www.glicko.net/glicko/glicko2.pdf).
294    /// See also [`constants::DEFAULT_CONVERGENCE_TOLERANCE`].
295    ///
296    /// # Panics
297    ///
298    /// This function panics if `convergence_tolerance <= 0.0`.
299    #[must_use]
300    pub fn new(
301        start_rating: PublicRating,
302        volatility_change: f64,
303        convergence_tolerance: f64,
304    ) -> Self {
305        assert!(
306            convergence_tolerance > 0.0,
307            "convergence_tolerance <= 0: {convergence_tolerance}"
308        );
309
310        Parameters {
311            start_rating,
312            volatility_change,
313            convergence_tolerance,
314        }
315    }
316
317    /// Creates [`Parameters`] with the same parameters as `self`, only changing the volatility change to `volatility_change`.
318    #[must_use]
319    pub fn with_volatility_change(self, volatility_change: f64) -> Self {
320        Parameters {
321            volatility_change,
322            ..self
323        }
324    }
325
326    /// The rating value a new player starts out with.
327    ///
328    /// See also [`constants::DEFAULT_START_RATING`].
329    #[must_use]
330    pub fn start_rating(&self) -> PublicRating {
331        self.start_rating
332    }
333
334    /// `volatility_change` - Also called "system constant" or "τ".
335    /// This constant constraints change in volatility over time.
336    /// Reasonable choices are between `0.3` and `1.2`.
337    /// Small values prevent volatility and therefore rating from changing too much after improbable results.
338    ///
339    /// See also "Step 1." in [Glickman's paper](http://www.glicko.net/glicko/glicko2.pdf) and [`constants::DEFAULT_VOLATILITY_CHANGE`].
340    #[must_use]
341    pub fn volatility_change(&self) -> f64 {
342        self.volatility_change
343    }
344
345    /// The cutoff value for the converging loop algorithm in "Step 5.1." in [Glickman's paper](http://www.glicko.net/glicko/glicko2.pdf).
346    ///
347    /// See also [`constants::DEFAULT_CONVERGENCE_TOLERANCE`].
348    #[must_use]
349    pub fn convergence_tolerance(&self) -> f64 {
350        self.convergence_tolerance
351    }
352}
353
354impl Default for Parameters {
355    /// Creates a default version of this struct with the parameters defined in [`constants`].
356    fn default() -> Self {
357        Parameters::new(
358            constants::DEFAULT_START_RATING,
359            constants::DEFAULT_VOLATILITY_CHANGE,
360            constants::DEFAULT_CONVERGENCE_TOLERANCE,
361        )
362    }
363}