README.md

# glicko_rating_system.ex

[![CI](https://github.com/sashite/glicko_rating_system.ex/actions/workflows/elixir.yml/badge.svg?branch=main)](https://github.com/sashite/glicko_rating_system.ex/actions)
[![Hex Version](https://img.shields.io/hexpm/v/glicko_rating_system.svg)](https://hex.pm/packages/glicko_rating_system)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/glicko_rating_system)
[![Elixir](https://img.shields.io/badge/elixir-~>_1.14-blueviolet.svg)](https://elixir-lang.org/)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/sashite/glicko_rating_system.ex/blob/main/LICENSE)

A pure, zero-dependency Elixir implementation of the [Glicko-2 rating system](http://www.glicko.net/glicko/glicko2.pdf) for two-player games.

This implementation conforms to:

- [Glickman, M. E. (2001) — *Example of the Glicko-2 system*](http://www.glicko.net/glicko/glicko2.pdf) — Algorithm specification
- [Glickman, M. E. (1999) — *Parameter estimation in large dynamic paired comparison experiments*](https://doi.org/10.1111/1467-9876.00159) — Theoretical foundation

> **Note**
> The Glicko and Glicko-2 systems are in the [public domain](http://www.glicko.net/glicko.html). This implementation follows the published algorithm faithfully, with no ad-hoc modifications. The only extension is accepting fractional scores, which is natively supported by the mathematical formulation.

## Installation

Add `glicko_rating_system` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:glicko_rating_system, "~> 1.0"}
  ]
end
```

## Usage

```elixir
player = {1500.0, 200.0, 0.06}
opponent = {1400.0, 30.0, 0.06}

GlickoRatingSystem.rate(player, [{opponent, 1.0}])
#=> {~1563.6, ~175.4, ~0.06}
```

## Behavior

### Rating parameters

Each player is represented by a `{rating, deviation, volatility}` tuple:

| Parameter | Default | Description |
|-----------|---------|-------------|
| Rating | `1500.0` | Estimated skill level |
| Deviation | `350.0` | Uncertainty — high means the rating is less reliable |
| Volatility | `0.06` | Expected fluctuation — high means erratic performance |

```elixir
new_player = {
  GlickoRatingSystem.default_rating(),
  GlickoRatingSystem.default_deviation(),
  GlickoRatingSystem.default_volatility()
}
#=> {1500.0, 350.0, 0.06}
```

### Scores

Scores are floats in `[0.0, 1.0]`. The standard values are `1.0` (win), `0.5` (draw), and `0.0` (loss), but any value in the range is accepted:

```elixir
player = {1500.0, 200.0, 0.06}
opponent = {1500.0, 200.0, 0.06}

# Win (1.0) — maximum reward
{r_win, _, _} = GlickoRatingSystem.rate(player, [{opponent, 1.0}])

# Partial win (0.75) — less reward
{r_partial, _, _} = GlickoRatingSystem.rate(player, [{opponent, 0.75}])

# Draw (0.5) — no net change against equal opponent
{r_draw, _, _} = GlickoRatingSystem.rate(player, [{opponent, 0.5}])

r_win > r_partial   #=> true
r_partial > r_draw  #=> true
```

The only constraint is that scores for both sides of a game should sum to `1.0` to preserve the zero-sum property that keeps the rating pool stable over time.

### Rating periods

The algorithm processes all games within a rating period as a batch. Pass the full list of results for the period in a single call:

```elixir
player = {1500.0, 200.0, 0.06}

results = [
  {{1400.0, 30.0, 0.06}, 1.0},   # win
  {{1550.0, 100.0, 0.06}, 0.0},  # loss
  {{1700.0, 300.0, 0.06}, 0.0}   # loss
]

GlickoRatingSystem.rate(player, results)
#=> {~1464.1, ~151.5, ~0.06}
```

### Inactivity

When a player does not compete during a rating period, pass an empty list. The rating and volatility remain unchanged, but the deviation increases to reflect growing uncertainty:

```elixir
GlickoRatingSystem.rate({1500.0, 200.0, 0.06}, [])
#=> {1500.0, ~200.3, 0.06}
```

This means a long-inactive player's rating becomes progressively less reliable, and their first games back will cause larger rating adjustments.

### Opponent strength

Rating changes depend on the opponent's strength relative to the player. Beating a stronger opponent yields more points; losing to a weaker one costs more:

```elixir
player = {1500.0, 200.0, 0.06}
strong = {1800.0, 50.0, 0.06}
weak = {1200.0, 50.0, 0.06}

# Win against strong opponent — large gain
{r1, _, _} = GlickoRatingSystem.rate(player, [{strong, 1.0}])

# Win against weak opponent — small gain
{r2, _, _} = GlickoRatingSystem.rate(player, [{weak, 1.0}])

r1 > r2   #=> true
```

### Deviation and confidence

A player's deviation reflects how confident the system is in their rating. New players have high deviation (`350.0`), which decreases as they play more games:

```elixir
new_player = {1500.0, 350.0, 0.06}
opponent = {1500.0, 100.0, 0.06}

{_, rd1, _} = GlickoRatingSystem.rate(new_player, [{opponent, 1.0}])
# rd1 is significantly lower than 350.0 — one game provided a lot of information

established = {1500.0, 50.0, 0.06}
{_, rd2, _} = GlickoRatingSystem.rate(established, [{opponent, 1.0}])
# rd2 is close to 50.0 — the system was already confident
```

The deviation also affects how much weight the opponent's result carries in the rating update. Playing against an opponent with high deviation (uncertain rating) provides less information than playing against one with low deviation.

### Expected score

The `expected_score/2` function returns the probability that a player will beat an opponent, accounting for both players' rating deviations:

```elixir
player = {1500.0, 200.0, 0.06}
opponent = {1400.0, 30.0, 0.06}

GlickoRatingSystem.expected_score(player, opponent)
#=> ~0.64
```

A match between two uncertain players produces a prediction closer to `0.5` than the same rating gap between two well-established players:

```elixir
# Same 100-point gap, different deviations
uncertain = GlickoRatingSystem.expected_score({1500.0, 300.0, 0.06}, {1400.0, 300.0, 0.06})
certain = GlickoRatingSystem.expected_score({1500.0, 50.0, 0.06}, {1400.0, 50.0, 0.06})

abs(uncertain - 0.5) < abs(certain - 0.5)   #=> true
```

### System constant (τ)

The `:tau` option controls how much the volatility can change between rating periods. Glickman recommends values between `0.3` and `1.2`:

```elixir
player = {1500.0, 200.0, 0.06}
result = [{{1400.0, 30.0, 0.06}, 1.0}]

# Conservative — volatility changes slowly
GlickoRatingSystem.rate(player, result, tau: 0.3)

# Responsive — volatility adapts faster
GlickoRatingSystem.rate(player, result, tau: 1.2)
```

The default value is `0.5`.

## Scoring Model

The Glicko-2 algorithm accepts any score in the continuous range `[0.0, 1.0]`. This makes it straightforward to model graded outcomes without modifying the algorithm itself.

### Example: Chess with Decisive Stalemate

In the [Sashité](https://sashite.com/) ecosystem, stalemate results in a win for the player who delivered it — but a less decisive win than checkmate. This maps naturally to fractional scores:

| Outcome | Winner's score | Loser's score |
|---------|---------------|---------------|
| Checkmate | `1.0` | `0.0` |
| Stalemate | `0.75` | `0.25` |
| Draw by agreement | `0.5` | `0.5` |

```elixir
player = {1500.0, 200.0, 0.06}
opponent = {1500.0, 200.0, 0.06}

# Checkmate — full reward
{r_mat, _, _} = GlickoRatingSystem.rate(player, [{opponent, 1.0}])

# Stalemate — partial reward
{r_pat, _, _} = GlickoRatingSystem.rate(player, [{opponent, 0.75}])

r_mat > r_pat   #=> true
```

### Choosing Score Values

The specific values (`0.75`/`0.25`, `0.8`/`0.2`, etc.) are a design decision for each platform. The algorithm is agnostic — it only requires that both sides of a game sum to `1.0`. Consider:

- **Closer to 1.0/0.0** — stalemate is treated almost like checkmate, small rating difference.
- **Closer to 0.5/0.5** — stalemate is treated almost like a draw, large rating difference from checkmate.

## API Reference

### Types

A player's rating state is represented as a plain tuple:

```elixir
@type player :: {rating, deviation, volatility}
@type rating :: float()
@type deviation :: float()
@type volatility :: float()
```

A game result pairs an opponent's rating state with the score achieved against them:

```elixir
@type game_result :: {player, score}
@type score :: float()  # in [0.0, 1.0]
```

### Functions

| Function | Description |
|----------|-------------|
| `rate(player, game_results, opts \\ [])` | Compute updated rating after a rating period |
| `expected_score(player, opponent)` | Predict win probability between two players |
| `default_rating()` | Default initial rating: `1500.0` |
| `default_deviation()` | Default initial deviation: `350.0` |
| `default_volatility()` | Default initial volatility: `0.06` |

### Validation Errors

All inputs are validated at the boundary. Invalid data is rejected with an `ArgumentError` before any computation occurs.

| Error message | Cause |
|---------------|-------|
| `"score must be between 0.0 and 1.0, got S"` | Score outside valid range |
| `"deviation must be positive, got D"` | Zero or negative rating deviation |
| `"volatility must be positive, got V"` | Zero or negative volatility |
| `"rating must be a float, got T"` | Non-float rating value |
| `"deviation must be a float, got T"` | Non-float deviation value |
| `"volatility must be a float, got T"` | Non-float volatility value |
| `"score must be a float, got T"` | Non-float score value |

## Design Principles

**Pure functions, no state.** Every function takes plain data and returns plain data. There are no GenServers, no ETS tables, no mutable state. Rating computations are deterministic — the same inputs always produce the same outputs.

**Plain tuples, no structs.** Rating state is a simple `{rating, deviation, volatility}` tuple. This keeps the data transparent, pattern-matchable, and trivial to serialize to and from any storage backend.

**Bounded computation.** The Glicko-2 volatility update uses an iterative procedure (the Illinois algorithm) that is guaranteed to converge. The implementation caps iterations to prevent pathological inputs from causing unbounded computation.

**Strict validation at the boundary.** All inputs are validated before computation. Scores must be floats in `[0.0, 1.0]`, deviations and volatilities must be positive floats. Invalid data is rejected immediately with a clear error message, rather than producing silently incorrect ratings.

**Zero dependencies.** `GlickoRatingSystem` relies only on the Elixir standard library and `:math` from Erlang/OTP. No transitive dependency tree to audit, no version conflicts to resolve.

**Faithful to the specification.** The implementation follows Glickman's published algorithm step by step. No ad-hoc modifications, no undocumented adjustments. The only extension is accepting fractional scores, which is natively supported by the mathematical formulation.

## Concurrency

All functions are pure and operate on immutable data. They are safe to call concurrently from any number of processes without synchronization. A typical architecture might store player ratings in a database and compute updates in parallel across multiple processes or nodes.

## Algorithm conformance

| Step | Description | Status |
|------|-------------|--------|
| Step 1 | Scale rating and RD to Glicko-2 scale | ✅ Implemented |
| Step 2 | Compute variance `v` from opponent data | ✅ Implemented |
| Step 3 | Compute estimated improvement `Δ` | ✅ Implemented |
| Step 4 | Determine new volatility `σ′` | ✅ Implemented |
| Step 5 | Illinois algorithm for iterative convergence | ✅ Implemented |
| Step 6 | Update deviation to pre-rating period value `φ*` | ✅ Implemented |
| Step 7 | Compute new rating `μ′` and deviation `φ′` | ✅ Implemented |
| Step 8 | Convert back to original scale | ✅ Implemented |

The convergence tolerance is `ε = 10⁻⁶` with a maximum of 100 iterations, matching the precision recommended by Glickman. The implementation includes the [March 2022 correction](http://www.glicko.net/glicko.html) to item 4(b) of Step 5 (`<` replaced by `<=`).

## Ecosystem

`GlickoRatingSystem` is part of the [Sashité](https://sashite.dev/) ecosystem, a platform for chess, shogi, xiangqi, and related variants. It provides the skill rating foundation that other components build upon for matchmaking and leaderboards.

## Documentation

- [API documentation on HexDocs](https://hexdocs.pm/glicko_rating_system)
- [Glickman, M. E. (2001) — *Example of the Glicko-2 system*](http://www.glicko.net/glicko/glicko2.pdf)
- [Glickman, M. E. (1999) — *Parameter estimation in large dynamic paired comparison experiments*](https://doi.org/10.1111/1467-9876.00159)
- [Glicko rating system — Official website](http://www.glicko.net/glicko.html)

## Versioning

This library follows [Semantic Versioning 2.0](https://semver.org/).

## License

Available as open source under the terms of the [Apache License 2.0](https://opensource.org/licenses/Apache-2.0).