diff --git a/lib/glicko.ex b/lib/glicko.ex index 9d2dd3b..76e6936 100644 --- a/lib/glicko.ex +++ b/lib/glicko.ex @@ -6,9 +6,6 @@ defmodule Glicko do ## Usage - Players can be represented by either the convenience `Glicko.Player` module or a tuple (see `player_t`). - Results can be represented by either the convenience `Glicko.Result` module or a tuple (see `result_t`). - Get a player's new rating after a series of matches in a rating period. iex> results = [Result.new(Player.new_v1([rating: 1400, rating_deviation: 30]), :win), @@ -16,13 +13,13 @@ defmodule Glicko do ...> Result.new(Player.new_v1([rating: 1700, rating_deviation: 300]), :loss)] iex> player = Player.new_v1([rating: 1500, rating_deviation: 200]) iex> Glicko.new_rating(player, results, [system_constant: 0.5]) - %Glicko.Player{version: :v1, rating: 1464.0506705393013, rating_deviation: 151.51652412385727, volatility: nil} + {1464.0506705393013, 151.51652412385727} Get a player's new rating when they haven't played within a rating period. iex> player = Player.new_v1([rating: 1500, rating_deviation: 200]) iex> Glicko.new_rating(player, [], [system_constant: 0.5]) - %Glicko.Player{version: :v1, rating: 1.5e3, rating_deviation: 200.27141669877065, volatility: nil} + {1.5e3, 200.27141669877065} """ @@ -34,18 +31,6 @@ defmodule Glicko do @default_system_constant 0.8 @default_convergence_tolerance 1.0e-7 - @type version_t :: :v1 | :v2 - @type rating_t :: float - @type rating_deviation_t :: float - @type volatility_t :: float - @type score_t :: float - - @type player_t :: player_v1_t | player_v2_t - @type player_v1_t :: {rating :: rating_t, rating_deviation :: rating_deviation_t} - @type player_v2_t :: {rating :: rating_t, rating_deviation :: rating_deviation_t, volatility :: volatility_t} - - @type result_t :: {opponent :: player_t, score :: score_t} - @type new_rating_opts_t :: [system_constant: float, convergence_tolerance: float] @doc """ @@ -53,11 +38,16 @@ defmodule Glicko do Returns the updated player with the same version given to the function. """ - @spec new_rating(player :: player_t | Player.t, results :: list(result_t | Result.t), opts :: new_rating_opts_t) :: player_t | Player.t - def new_rating(player, results, opts \\ []) do - {cast_from, internal_player} = cast_player_to_internal(player) - internal_player = do_new_rating(internal_player, results, opts) - cast_internal_to_player({cast_from, internal_player}) + @spec new_rating(player :: Player.t, results :: list(Result.t), opts :: new_rating_opts_t) :: Player.t + def new_rating(player, results, opts \\ []) + def new_rating(player, results, opts) when tuple_size(player) == 3 do + do_new_rating(player, results, opts) + end + def new_rating(player, results, opts) when tuple_size(player) == 2 do + player + |> Player.to_v2 + |> do_new_rating(results, opts) + |> Player.to_v1 end defp do_new_rating({player_rating, player_rating_deviation, player_volatility}, [], _) do @@ -111,21 +101,16 @@ defmodule Glicko do {ctx.new_player_rating, ctx.new_player_rating_deviation, ctx.new_player_volatility} end - defp build_internal_result(ctx, {opponent, score}) do - {_, {opponent_rating, opponent_rating_deviation, _}} = cast_player_to_internal(opponent) - + defp build_internal_result(ctx, result) do result = Map.new - |> Map.put(:score, score) - |> Map.put(:opponent_rating, opponent_rating) - |> Map.put(:opponent_rating_deviation, opponent_rating_deviation) - |> Map.put(:opponent_rating_deviation_g, calc_g(opponent_rating_deviation)) + |> Map.put(:score, Result.score(result)) + |> Map.put(:opponent_rating, Result.opponent_rating(result)) + |> Map.put(:opponent_rating_deviation, Result.opponent_rating_deviation(result)) + |> Map.put(:opponent_rating_deviation_g, calc_g(Result.opponent_rating_deviation(result))) Map.put(result, :e, calc_e(ctx.player_rating, result)) end - defp build_internal_result(ctx, %Result{score: score, opponent: opponent}) do - build_internal_result(ctx, {opponent, score}) - end # Calculation of the estimated variance of the player's rating based on game outcomes defp calc_variance_estimate(ctx) do @@ -218,26 +203,4 @@ defmodule Glicko do defp calc_e(player_rating, result) do 1 / (1 + :math.exp(-1 * result.opponent_rating_deviation_g * (player_rating - result.opponent_rating))) end - - defp cast_player_to_internal(player) when is_tuple(player) and tuple_size(player) == 2 do - {:v1, Player.new_v1(player) |> Player.to_v2 |> cast_player_to_internal |> elem(1)} - end - defp cast_player_to_internal(player) when is_tuple(player) and tuple_size(player) == 3 do - {:v2, player} - end - defp cast_player_to_internal(player = %Player{version: :v1}) do - {:player_v1, player |> Player.to_v2 |> cast_player_to_internal |> elem(1)} - end - defp cast_player_to_internal(player = %Player{version: :v2}) do - {:player_v2, {player.rating, player.rating_deviation, player.volatility}} - end - - defp cast_internal_to_player({:v1, {rating, rating_deviation, _}}), do: { - rating |> Player.scale_rating_to(:v1), - rating_deviation |> Player.scale_rating_deviation_to(:v1), - } - defp cast_internal_to_player({:v2, player}), do: player - defp cast_internal_to_player({:player_v1, player}), do: Player.new_v2(player) |> Player.to_v1 - defp cast_internal_to_player({:player_v2, player}), do: Player.new_v2(player) - end diff --git a/lib/glicko/player.ex b/lib/glicko/player.ex index 57a8d6e..08f098b 100644 --- a/lib/glicko/player.ex +++ b/lib/glicko/player.ex @@ -1,28 +1,33 @@ defmodule Glicko.Player do @moduledoc """ - A convenience wrapper that handles conversions between glicko versions one and two. + Provides convenience functions that handle conversions between Glicko versions one and two. ## Usage - Create a player with the default values for an unrated player. + Create a *v1* player with the default values for an unrated player. + + iex> Player.new_v1 + {1.5e3, 350.0} + + Create a *v2* player with the default values for an unrated player. iex> Player.new_v2 - %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06} + {0.0, 2.014761872416068, 0.06} Create a player with custom values. - iex> Player.new_v2([rating: 1500, rating_deviation: 50, volatility: 0.05]) - %Player{version: :v2, rating: 1500, rating_deviation: 50, volatility: 0.05} + iex> Player.new_v2([rating: 3.0, rating_deviation: 2.0, volatility: 0.05]) + {3.0, 2.0, 0.05} Convert a *v2* player to a *v1*. Note this drops the volatility. iex> Player.new_v2 |> Player.to_v1 - %Player{version: :v1, rating: 1.5e3, rating_deviation: 350.0, volatility: nil} + {1.5e3, 350.0} Convert a *v1* player to a *v2*. iex> Player.new_v1 |> Player.to_v2(0.06) - %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06} + {0.0, 2.014761872416068, 0.06} Note calling `to_v1` with a *v1* player or likewise with `to_v2` and a *v2* player will pass-through unchanged. The volatility arg in this case is ignored. @@ -38,89 +43,56 @@ defmodule Glicko.Player do @type t :: v1_t | v2_t - @type v1_t :: %__MODULE__{ - version: :v1, - rating: Glicko.rating_t, - rating_deviation: Glicko.rating_deviation_t, - volatility: nil, - } + @type v1_t :: {rating_t, rating_deviation_t} + @type v2_t :: {rating_t, rating_deviation_t, volatility_t} - @type v2_t :: %__MODULE__{ - version: :v2, - rating: Glicko.rating_t, - rating_deviation: Glicko.rating_deviation_t, - volatility: Glicko.volatility_t, - } - - defstruct [ - :version, - :rating, - :rating_deviation, - :volatility, - ] + @type version_t :: :v1 | :v2 + @type rating_t :: float + @type rating_deviation_t :: float + @type volatility_t :: float @doc """ The recommended initial rating value for a new player. """ - @spec initial_rating(Glicko.version_t) :: Glicko.rating_t - def initial_rating(:v1), do: 1500.0 - def initial_rating(:v2), do: initial_rating(:v1) |> scale_rating_to(:v2) + @spec initial_rating(version_t) :: rating_t + def initial_rating(_version = :v1), do: 1500.0 + def initial_rating(_version = :v2), do: :v1 |> initial_rating |> scale_rating_to(:v2) @doc """ The recommended initial rating deviation value for a new player. """ - @spec initial_rating_deviation(Glicko.version_t) :: Glicko.rating_deviation_t - def initial_rating_deviation(:v1), do: 350.0 - def initial_rating_deviation(:v2), do: initial_rating_deviation(:v1) |> scale_rating_deviation_to(:v2) + @spec initial_rating_deviation(version_t) :: rating_deviation_t + def initial_rating_deviation(_version = :v1), do: 350.0 + def initial_rating_deviation(_version = :v2), do: :v1 |> initial_rating_deviation |> scale_rating_deviation_to(:v2) @doc """ The recommended initial volatility value for a new player. """ - @spec initial_v2_volatility :: Glicko.volatility_t - def initial_v2_volatility, do: 0.06 + @spec initial_volatility :: volatility_t + def initial_volatility, do: 0.06 @doc """ Creates a new v1 player. If not overriden, will use the default values for an unrated player. """ - @spec new_v1( - {Glicko.rating_t, Glicko.rating_deviation_t} | - [rating: Glicko.rating_t, rating_deviation: Glicko.rating_deviation_t] - ) :: v1_t - def new_v1(opts \\ []) - def new_v1({rating, rating_deviation}), do: %__MODULE__{ - version: :v1, - rating: rating, - rating_deviation: rating_deviation, - volatility: nil, - } - def new_v1(opts), do: new_v1({ + @spec new_v1([rating: rating_t, rating_deviation: rating_deviation_t]) :: v1_t + def new_v1(opts \\ []) when is_list(opts), do: { Keyword.get(opts, :rating, initial_rating(:v1)), Keyword.get(opts, :rating_deviation, initial_rating_deviation(:v1)), - }) + } @doc """ Creates a new v2 player. If not overriden, will use default values for an unrated player. """ - @spec new_v2( - {Glicko.rating_t, Glicko.rating_deviation_t, Glicko.volatility_t} | - [rating: Glicko.rating_t, rating_deviation: Glicko.rating_deviation_t, volatility: Glicko.volatility_t] - ) :: v2_t - def new_v2(opts \\ []) - def new_v2({rating, rating_deviation, volatility}), do: %__MODULE__{ - version: :v2, - rating: rating, - rating_deviation: rating_deviation, - volatility: volatility, - } - def new_v2(opts), do: new_v2({ + @spec new_v2([rating: rating_t, rating_deviation: rating_deviation_t, volatility: volatility_t]) :: v2_t + def new_v2(opts \\ []) when is_list(opts), do: { Keyword.get(opts, :rating, initial_rating(:v2)), Keyword.get(opts, :rating_deviation, initial_rating_deviation(:v2)), - Keyword.get(opts, :volatility, initial_v2_volatility()), - }) + Keyword.get(opts, :volatility, initial_volatility()), + } @doc """ Converts a v2 player to a v1. @@ -130,25 +102,57 @@ defmodule Glicko.Player do Note the volatility field used in a v2 player will be lost in the conversion. """ @spec to_v1(player :: t) :: v1_t - def to_v1(player = %__MODULE__{version: :v1}), do: player - def to_v1(player = %__MODULE__{version: :v2}), do: new_v1([ - rating: player.rating |> scale_rating_to(:v1), - rating_deviation: player.rating_deviation |> scale_rating_deviation_to(:v1), - ]) + def to_v1({rating, rating_deviation}), do: {rating, rating_deviation} + def to_v1({rating, rating_deviation, _}), do: { + rating |> scale_rating_to(:v1), + rating_deviation |> scale_rating_deviation_to(:v1), + } @doc """ Converts a v1 player to a v2. A v2 player will pass-through unchanged with the volatility arg ignored. """ - @spec to_v2(player :: t, volatility :: float) :: v2_t - def to_v2(player, volatility \\ initial_v2_volatility()) - def to_v2(player = %__MODULE__{version: :v2}, _volatility), do: player - def to_v2(player = %__MODULE__{version: :v1}, volatility), do: new_v2([ - rating: player.rating |> scale_rating_to(:v2), - rating_deviation: player.rating_deviation |> scale_rating_deviation_to(:v2), - volatility: volatility, - ]) + @spec to_v2(player :: t, volatility :: volatility_t) :: v2_t + def to_v2(player, volatility \\ initial_volatility()) + def to_v2({rating, rating_deviation, volatility}, _volatility), do: {rating, rating_deviation, volatility} + def to_v2({rating, rating_deviation}, volatility), do: { + rating |> scale_rating_to(:v2), + rating_deviation |> scale_rating_deviation_to(:v2), + volatility, + } + + @doc """ + A version agnostic method for getting a player's rating. + """ + @spec rating(player :: t, as_version :: version_t) :: rating_t + def rating(player, as_version \\ nil) + def rating({rating, _}, nil), do: rating + def rating({rating, _, _}, nil), do: rating + def rating({rating, _}, :v1), do: rating + def rating({rating, _}, :v2), do: rating |> scale_rating_to(:v2) + def rating({rating, _, _}, :v1), do: rating |> scale_rating_to(:v1) + def rating({rating, _, _}, :v2), do: rating + + @doc """ + A version agnostic method for getting a player's rating deviation. + """ + @spec rating_deviation(player :: t, as_version :: version_t) :: rating_deviation_t + def rating_deviation(player, as_version \\ nil) + def rating_deviation({_, rating_deviation}, nil), do: rating_deviation + def rating_deviation({_, rating_deviation, _}, nil), do: rating_deviation + def rating_deviation({_, rating_deviation}, :v1), do: rating_deviation + def rating_deviation({_, rating_deviation}, :v2), do: rating_deviation |> scale_rating_deviation_to(:v2) + def rating_deviation({_, rating_deviation, _}, :v1), do: rating_deviation |> scale_rating_deviation_to(:v1) + def rating_deviation({_, rating_deviation, _}, :v2), do: rating_deviation + + @doc """ + A version agnostic method for getting a player's volatility. + """ + @spec volatility(player :: t, default_volatility :: volatility_t) :: volatility_t + def volatility(player, default_volatility \\ initial_volatility()) + def volatility({_, _}, default_volatility), do: default_volatility + def volatility({_, _, volatility}, _), do: volatility @doc """ A convenience function for summarizing a player's strength as a 95% @@ -166,23 +170,23 @@ defmodule Glicko.Player do be 95% confident about a player’s strength being in a small interval of values. """ @spec rating_interval(player :: t) :: {rating_low :: float, rating_high :: float} - def rating_interval(player), do: { - player.rating - player.rating_deviation * 2, - player.rating + player.rating_deviation * 2, + def rating_interval(player, as_version \\ nil), do: { + rating(player, as_version) - rating_deviation(player, as_version) * 2, + rating(player, as_version) + rating_deviation(player, as_version) * 2, } @doc """ Scales a player's rating. """ - @spec scale_rating_to(rating :: Glicko.rating_t, to_version :: Glicko.version_t) :: Glicko.rating_t - def scale_rating_to(rating, :v1), do: (rating * @magic_version_scale) + @magic_version_scale_rating - def scale_rating_to(rating, :v2), do: (rating - @magic_version_scale_rating) / @magic_version_scale + @spec scale_rating_to(rating :: rating_t, to_version :: version_t) :: rating_t + def scale_rating_to(rating, _version = :v1), do: (rating * @magic_version_scale) + @magic_version_scale_rating + def scale_rating_to(rating, _version = :v2), do: (rating - @magic_version_scale_rating) / @magic_version_scale @doc """ Scales a player's rating deviation. """ - @spec scale_rating_deviation_to(rating_deviation :: Glicko.rating_deviation_t, to_version :: Glicko.version_t) :: Glicko.rating_deviation_t - def scale_rating_deviation_to(rating_deviation, :v1), do: rating_deviation * @magic_version_scale - def scale_rating_deviation_to(rating_deviation, :v2), do: rating_deviation / @magic_version_scale + @spec scale_rating_deviation_to(rating_deviation :: rating_deviation_t, to_version :: version_t) :: rating_deviation_t + def scale_rating_deviation_to(rating_deviation, _version = :v1), do: rating_deviation * @magic_version_scale + def scale_rating_deviation_to(rating_deviation, _version = :v2), do: rating_deviation / @magic_version_scale end diff --git a/lib/glicko/result.ex b/lib/glicko/result.ex index 93a7250..c029a97 100644 --- a/lib/glicko/result.ex +++ b/lib/glicko/result.ex @@ -1,41 +1,66 @@ defmodule Glicko.Result do @moduledoc """ - A convenience wrapper representing a result against an opponent. + Convenience functions for handling a result against an opponent. ## Usage iex> opponent = Player.new_v2 - iex> Result.new(opponent, 0.0) - %Result{score: 0.0, opponent: %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}} - iex> Result.new(opponent, :win) # With shortcut - %Result{score: 1.0, opponent: %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}} + iex> Result.new(opponent, 1.0) + {0.0, 2.014761872416068, 1.0} + iex> Result.new(opponent, :draw) # With shortcut + {0.0, 2.014761872416068, 0.5} """ alias Glicko.Player - defstruct [ - :score, - :opponent, - ] + @type t :: {Player.rating_t, Player.rating_deviation_t, score_t} - @type t :: %__MODULE__{score: Glicko.score_t, opponent: Glicko.player_t | Player.t} + @type score_t :: float + @type score_shortcut_t :: :loss | :draw | :win - @type result_type_t :: :loss | :draw | :win - - @result_type_map %{loss: 0.0, draw: 0.5, win: 1.0} + @score_shortcut_map %{loss: 0.0, draw: 0.5, win: 1.0} + @score_shortcuts Map.keys(@score_shortcut_map) @doc """ - Creates a new Result against an opponent. + Creates a new result from an opponent rating, opponent rating deviation and score. Supports passing either `:loss`, `:draw`, or `:win` as shortcuts. """ - @spec new(opponent :: Glicko.player_t | Player.t, result_type_t | float) :: t - def new(opponent, result_type) when is_atom(result_type) and result_type in [:loss, :draw, :win] do - new(opponent, Map.fetch!(@result_type_map, result_type)) + @spec new(Player.rating_t, Player.rating_deviation_t, score_t | score_shortcut_t) :: t + def new(opponent_rating, opponent_rating_deviation, score) when is_number(score) do + {opponent_rating, opponent_rating_deviation, score} end - def new(opponent, score) when is_number(score), do: %__MODULE__{ - score: score, - opponent: opponent, - } + def new(opponent_rating, opponent_rating_deviation, score_type) when is_atom(score_type) and score_type in @score_shortcuts do + {opponent_rating, opponent_rating_deviation, Map.fetch!(@score_shortcut_map, score_type)} + end + + @doc """ + Creates a new result from an opponent and score. + + Supports passing either `:loss`, `:draw`, or `:win` as shortcuts. + """ + @spec new(opponent :: Player.t, score :: score_t | score_shortcut_t) :: t + def new(opponent, score) do + new(Player.rating(opponent, :v2), Player.rating_deviation(opponent, :v2), score) + end + + @doc """ + Convenience function for accessing an opponent's rating. + """ + @spec opponent_rating(result :: Result.t) :: Player.rating_t + def opponent_rating(_result = {rating, _, _}), do: rating + + @doc """ + Convenience function for accessing an opponent's rating deviation. + """ + @spec opponent_rating_deviation(result :: Result.t) :: Player.rating_deviation_t + def opponent_rating_deviation(_result = {_, rating_deviation, _}), do: rating_deviation + + @doc """ + Convenience function for accessing the score. + """ + @spec score(result :: Result.t) :: score_t + def score(_result = {_, _, score}), do: score + end diff --git a/test/glicko_test.exs b/test/glicko_test.exs index 0341f68..76cd6db 100644 --- a/test/glicko_test.exs +++ b/test/glicko_test.exs @@ -11,8 +11,8 @@ defmodule GlickoTest do @player Player.new_v1([rating: 1500, rating_deviation: 200]) |> Player.to_v2 @results [ - {{1400, 30}, 1.0}, - Result.new({1550, 100}, :loss), + Result.new(Player.new_v1([rating: 1400, rating_deviation: 30]), :win), + Result.new(Player.new_v1([rating: 1550, rating_deviation: 100]), :loss), Result.new(Player.new_v1([rating: 1700, rating_deviation: 300]), :loss), ] @@ -23,17 +23,16 @@ defmodule GlickoTest do @valid_player_rating_deviation_after_no_results 200.2714 |> Player.scale_rating_deviation_to(:v2) test "new rating (with results)" do - %Player{rating: new_rating, rating_deviation: new_rating_deviation, volatility: new_volatility} = - Glicko.new_rating(@player, @results, [system_constant: 0.5]) + player = Glicko.new_rating(@player, @results, [system_constant: 0.5]) - assert_in_delta new_rating, @valid_player_rating_after_results, 1.0e-4 - assert_in_delta new_rating_deviation, @valid_player_rating_deviation_after_results, 1.0e-4 - assert_in_delta new_volatility, @valid_player_volatility_after_results, 1.0e-5 + assert_in_delta Player.rating(player), @valid_player_rating_after_results, 1.0e-4 + assert_in_delta Player.rating_deviation(player), @valid_player_rating_deviation_after_results, 1.0e-4 + assert_in_delta Player.volatility(player), @valid_player_volatility_after_results, 1.0e-5 end test "new rating (no results)" do - %Player{rating_deviation: new_rating_deviation} = Glicko.new_rating(@player, []) + player = Glicko.new_rating(@player, []) - assert_in_delta new_rating_deviation, @valid_player_rating_deviation_after_no_results, 1.0e-4 + assert_in_delta Player.rating_deviation(player), @valid_player_rating_deviation_after_no_results, 1.0e-4 end end diff --git a/test/player_test.exs b/test/player_test.exs index 1718380..1665d77 100644 --- a/test/player_test.exs +++ b/test/player_test.exs @@ -5,8 +5,8 @@ defmodule Glicko.PlayerTest do doctest Player - @valid_v1_base %Player{version: :v1, rating: 1.0, rating_deviation: 2.0, volatility: nil} - @valid_v2_base %Player{version: :v2, rating: 1.0, rating_deviation: 2.0, volatility: 3.0} + @valid_v1_base {1.0, 2.0} + @valid_v2_base {1.0, 2.0, 3.0} test "create v1" do assert @valid_v1_base == Player.new_v1([rating: 1.0, rating_deviation: 2.0]) @@ -17,21 +17,11 @@ defmodule Glicko.PlayerTest do end test "convert player v1 -> v2" do - assert %Player{ - version: :v2, - rating: Player.scale_rating_to(1.0, :v2), - rating_deviation: Player.scale_rating_deviation_to(2.0, :v2), - volatility: 3.0, - } == Player.to_v2(@valid_v1_base, 3.0) + assert {Player.scale_rating_to(1.0, :v2), Player.scale_rating_deviation_to(2.0, :v2), 3.0} == Player.to_v2(@valid_v1_base, 3.0) end test "convert player v2 -> v1" do - assert %Player{ - version: :v1, - rating: Player.scale_rating_to(1.0, :v1), - rating_deviation: Player.scale_rating_deviation_to(2.0, :v1), - volatility: nil, - } == Player.to_v1(@valid_v2_base) + assert {Player.scale_rating_to(1.0, :v1), Player.scale_rating_deviation_to(2.0, :v1)} == Player.to_v1(@valid_v2_base) end test "convert player v1 -> v1" do diff --git a/test/result_test.exs b/test/result_test.exs index 6356a35..3890321 100644 --- a/test/result_test.exs +++ b/test/result_test.exs @@ -10,7 +10,7 @@ defmodule Glicko.ResultTest do @opponent Player.new_v2 - @valid_game_result %Result{opponent: @opponent, score: 0.0} + @valid_game_result Result.new(@opponent, 0.0) test "create game result" do assert @valid_game_result == Result.new(@opponent, 0.0)