Implement OpenSkill Plackett-Luce rating system
Port of openskill.js. 93 tests ported from the JS test suite;
statistics tests use assert_in_delta because Ruby's Math.erf is
higher precision than the gaussian npm package's erfc approximation.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/lib/open_skill.rb b/lib/open_skill.rb
new file mode 100644
index 0000000..649ef1d
--- /dev/null
+++ b/lib/open_skill.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# Ruby port of https://github.com/philihp/openskill.js
+# Implements the Plackett-Luce model for skill rating.
+
+require_relative "open_skill/rating"
+require_relative "open_skill/constants"
+require_relative "open_skill/statistics"
+require_relative "open_skill/util"
+require_relative "open_skill/plackett_luce"
+require_relative "open_skill/rate"
+require_relative "open_skill/ordinal"
+
+module OpenSkill
+ def self.rating(mu: nil, sigma: nil, z: nil, **rest)
+ opts = rest.dup
+ opts[:z] = z if z
+ opts[:mu] = mu if mu
+ opts[:sigma] = sigma if sigma
+ env = constants(**opts)
+ Rating.new(env[:mu], env[:sigma])
+ end
+end
diff --git a/lib/open_skill/constants.rb b/lib/open_skill/constants.rb
new file mode 100644
index 0000000..74a9aff
--- /dev/null
+++ b/lib/open_skill/constants.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+# Port of https://github.com/philihp/openskill.js/blob/main/src/constants.ts
+
+module OpenSkill
+ def self.constants(**options)
+ z = options.fetch(:z, 3)
+ mu = options.fetch(:mu, 25)
+ epsilon = options.fetch(:epsilon, 0.0001)
+ alpha = options.fetch(:alpha, 1)
+ target = options.fetch(:target, 0)
+ prevent_sigma_increase = options.fetch(:prevent_sigma_increase, false)
+
+ tau = options.fetch(:tau, mu / 300.0)
+ sigma = options.fetch(:sigma, mu.to_f / z)
+ beta = options.fetch(:beta, sigma / 2.0)
+ limit_sigma = options.fetch(:limit_sigma, prevent_sigma_increase)
+
+ beta_sq = beta**2
+
+ {
+ sigma: sigma,
+ mu: mu,
+ epsilon: epsilon,
+ two_beta_sq: 2 * beta_sq,
+ beta: beta,
+ beta_sq: beta_sq,
+ z: z,
+ alpha: alpha,
+ target: target,
+ tau: tau,
+ limit_sigma: limit_sigma,
+ }
+ end
+end
diff --git a/lib/open_skill/ordinal.rb b/lib/open_skill/ordinal.rb
new file mode 100644
index 0000000..f3be42d
--- /dev/null
+++ b/lib/open_skill/ordinal.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# Port of https://github.com/philihp/openskill.js/blob/main/src/ordinal.ts
+
+module OpenSkill
+ def self.ordinal(rating, **options)
+ env = constants(**options)
+ env[:target] + env[:alpha] * (rating.mu - env[:z] * rating.sigma)
+ end
+end
diff --git a/lib/open_skill/plackett_luce.rb b/lib/open_skill/plackett_luce.rb
new file mode 100644
index 0000000..b625a82
--- /dev/null
+++ b/lib/open_skill/plackett_luce.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# Port of https://github.com/philihp/openskill.js/blob/main/src/models/plackett-luce.ts
+
+module OpenSkill
+ module PlackettLuce
+ def self.rate(game, **options)
+ return game if game.length <= 1
+
+ env = OpenSkill.constants(**options)
+ epsilon = env[:epsilon]
+
+ team_ratings = Util.team_rating(game, **options)
+ c = Util.util_c(team_ratings, **options)
+ sum_q = Util.util_sum_q(team_ratings, c)
+ a = Util.util_a(team_ratings)
+
+ team_ratings.each_with_index.map do |i_team_rating, i|
+ i_mu, i_sigma_sq, i_team, i_rank = i_team_rating
+ i_mu_over_ce = Math.exp(i_mu / c)
+
+ omega_sum = 0.0
+ delta_sum = 0.0
+
+ team_ratings.each_with_index do |q_team_rating, q|
+ q_rank = q_team_rating[3]
+ next unless q_rank <= i_rank
+
+ quotient = i_mu_over_ce / sum_q[q]
+ if i == q
+ omega_sum += (1 - quotient) / a[q]
+ else
+ omega_sum += (-quotient) / a[q]
+ end
+ delta_sum += (quotient * (1 - quotient)) / a[q]
+ end
+
+ i_gamma = Util.gamma(c, team_ratings.length, *i_team_rating)
+ i_omega = omega_sum * (i_sigma_sq / c)
+ i_delta = i_gamma * delta_sum * (i_sigma_sq / c**2)
+
+ i_team.map do |player|
+ new_mu = player.mu + (player.sigma**2 / i_sigma_sq) * i_omega
+ new_sigma = player.sigma * Math.sqrt([1 - (player.sigma**2 / i_sigma_sq) * i_delta, epsilon].max)
+ Rating.new(new_mu, new_sigma)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/open_skill/rate.rb b/lib/open_skill/rate.rb
new file mode 100644
index 0000000..d07f8c8
--- /dev/null
+++ b/lib/open_skill/rate.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+# Port of https://github.com/philihp/openskill.js/blob/main/src/rate.ts
+
+module OpenSkill
+ def self.rate(teams, **options)
+ env = constants(**options)
+ limit_sigma = env[:limit_sigma]
+ tau = env[:tau]
+
+ processed_teams = teams
+
+ # If tau is provided, use additive dynamics factor
+ if options.key?(:tau)
+ tau_squared = tau**2
+ processed_teams = teams.map do |team|
+ team.map do |p|
+ Rating.new(p.mu, Math.sqrt(p.sigma**2 + tau_squared))
+ end
+ end
+ end
+
+ # Build rank from score or use provided rank, or default 1..n
+ rank = options[:rank] || options[:score]&.map { |s| -s } || (1..teams.length).to_a
+
+ # Sort teams by rank, process, then unsort
+ tenet = rank.each_with_index.sort_by { |r, _| r }.map(&:last)
+ ordered_teams = tenet.map { |i| processed_teams[i] }
+ sorted_rank = rank.sort
+
+ new_ratings = PlackettLuce.rate(ordered_teams, **options.merge(rank: sorted_rank))
+
+ # Unsort back to original order
+ reordered = Array.new(teams.length)
+ tenet.each_with_index { |orig_idx, sorted_idx| reordered[orig_idx] = new_ratings[sorted_idx] }
+
+ # Limit sigma if requested
+ if options.key?(:tau) && limit_sigma
+ reordered = reordered.each_with_index.map do |team, i|
+ team.each_with_index.map do |p, j|
+ Rating.new(p.mu, [p.sigma, teams[i][j].sigma].min)
+ end
+ end
+ end
+
+ reordered
+ end
+end
diff --git a/lib/open_skill/rating.rb b/lib/open_skill/rating.rb
new file mode 100644
index 0000000..cfdb203
--- /dev/null
+++ b/lib/open_skill/rating.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Port of https://github.com/philihp/openskill.js/blob/main/src/rating.ts
+
+module OpenSkill
+ Rating = Data.define(:mu, :sigma)
+end
diff --git a/lib/open_skill/statistics.rb b/lib/open_skill/statistics.rb
new file mode 100644
index 0000000..403a1f5
--- /dev/null
+++ b/lib/open_skill/statistics.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# Port of https://github.com/philihp/openskill.js/blob/main/src/statistics.ts
+
+module OpenSkill
+ module Statistics
+ # Standard normal PDF
+ def self.phi_minor(x)
+ Math.exp(-0.5 * x * x) / Math.sqrt(2 * Math::PI)
+ end
+
+ # Standard normal CDF via Math.erf
+ def self.phi_major(x)
+ 0.5 * (1.0 + Math.erf(x / Math.sqrt(2)))
+ end
+
+ def self.v(x, t)
+ xt = x - t
+ denom = phi_major(xt)
+ return -xt if denom < Float::EPSILON
+ phi_minor(xt) / denom
+ end
+
+ def self.w(x, t)
+ xt = x - t
+ denom = phi_major(xt)
+ if denom < Float::EPSILON
+ return x < 0 ? 1 : 0
+ end
+ v(x, t) * (v(x, t) + xt)
+ end
+
+ def self.vt(x, t)
+ xx = x.abs
+ b = phi_major(t - xx) - phi_major(-t - xx)
+ if b < 1e-5
+ return x < 0 ? -x - t : -x + t
+ end
+ a = phi_minor(-t - xx) - phi_minor(t - xx)
+ (x < 0 ? -a : a) / b
+ end
+
+ def self.wt(x, t)
+ xx = x.abs
+ b = phi_major(t - xx) - phi_major(-t - xx)
+ return 1.0 if b < Float::EPSILON
+ ((t - xx) * phi_minor(t - xx) + (t + xx) * phi_minor(-t - xx)) / b + vt(x, t)**2
+ end
+ end
+end
diff --git a/lib/open_skill/util.rb b/lib/open_skill/util.rb
new file mode 100644
index 0000000..3bf970c
--- /dev/null
+++ b/lib/open_skill/util.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+# Port of https://github.com/philihp/openskill.js/blob/main/src/util.ts
+
+module OpenSkill
+ module Util
+ # TeamRating: [mu, sigma_sq, team, rank]
+
+ def self.score(q, i)
+ if q < i
+ 0.0
+ elsif q > i
+ 1.0
+ else
+ 0.5
+ end
+ end
+
+ def self.rankings(teams, rank)
+ return [] if teams.empty?
+
+ team_scores = teams.each_index.map { |i| rank && rank[i] ? rank[i] : i }
+ out_rank = Array.new(teams.length)
+
+ s = 0
+ team_scores.each_with_index do |_score, j|
+ s = j if j > 0 && team_scores[j - 1] < team_scores[j]
+ out_rank[j] = s
+ end
+ out_rank
+ end
+
+ def self.ladder_pairs(ranks)
+ size = ranks.length
+ return [[]] if size <= 1
+
+ left = [nil, *ranks[0...(size - 1)]]
+ right = [*ranks[1..], nil]
+
+ left.zip(right).map do |l, r|
+ if l && r
+ [l, r]
+ elsif l
+ [l]
+ elsif r
+ [r]
+ else
+ []
+ end
+ end
+ end
+
+ def self.team_rating(game, rank: nil, **_options)
+ ranks = rankings(game, rank)
+ game.each_with_index.map do |team, i|
+ mu = team.sum(&:mu)
+ sigma_sq = team.sum { |p| p.sigma**2 }
+ [mu, sigma_sq, team, ranks[i]]
+ end
+ end
+
+ def self.util_c(team_ratings, **options)
+ beta_sq = OpenSkill.constants(**options)[:beta_sq]
+ Math.sqrt(team_ratings.sum { |tr| tr[1] + beta_sq })
+ end
+
+ def self.util_sum_q(team_ratings, c)
+ team_ratings.map do |q_rating|
+ q_rank = q_rating[3]
+ team_ratings
+ .select { |i_rating| i_rating[3] >= q_rank }
+ .sum { |i_rating| Math.exp(i_rating[0] / c) }
+ end
+ end
+
+ def self.util_a(team_ratings)
+ team_ratings.map do |i_rating|
+ i_rank = i_rating[3]
+ team_ratings.count { |q_rating| i_rank == q_rating[3] }
+ end
+ end
+
+ def self.gamma(c, _k, _mu, sigma_sq, _team, _rank)
+ Math.sqrt(sigma_sq) / c
+ end
+ end
+end
diff --git a/test/test_open_skill.rb b/test/test_open_skill.rb
new file mode 100644
index 0000000..c2deb15
--- /dev/null
+++ b/test/test_open_skill.rb
@@ -0,0 +1,640 @@
+# frozen_string_literal: true
+
+# Tests ported from https://github.com/philihp/openskill.js/tree/main/src/__tests__
+
+require "minitest/autorun"
+require_relative "../lib/open_skill"
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/statistics/v.test.ts
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/statistics/w.test.ts
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/statistics/vt.test.ts
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/statistics/wt.test.ts
+class TestStatistics < Minitest::Test
+ # JS expected values come from the `gaussian` npm package which uses a
+ # Numerical Recipes erfc approximation. Ruby's Math.erf is higher
+ # precision, so we assert_in_delta instead of exact equality.
+
+ def test_v_1_2
+ assert_in_delta 1.5251352044082924, OpenSkill::Statistics.v(1, 2), 1e-6
+ end
+
+ def test_v_0_2
+ assert_in_delta 2.3732157475120528, OpenSkill::Statistics.v(0, 2), 1e-6
+ end
+
+ def test_v_0_neg1
+ assert_in_delta 0.2875999734906994, OpenSkill::Statistics.v(0, -1), 1e-6
+ end
+
+ def test_v_denominator_less_than_threshold
+ assert_equal 10, OpenSkill::Statistics.v(0, 10)
+ end
+
+ def test_w_1_2
+ assert_in_delta 0.8009021873172315, OpenSkill::Statistics.w(1, 2), 1e-6
+ end
+
+ def test_w_0_2
+ assert_in_delta 0.8857214892150859, OpenSkill::Statistics.w(0, 2), 1e-5
+ end
+
+ def test_w_0_neg1
+ assert_in_delta 0.3703137182425503, OpenSkill::Statistics.w(0, -1), 1e-6
+ end
+
+ def test_w_denominator_less_than_threshold
+ assert_equal 0, OpenSkill::Statistics.w(0, 10)
+ end
+
+ def test_w_tiny_denom_negative_x
+ assert_equal 1, OpenSkill::Statistics.w(-1, 10)
+ end
+
+ def test_vt_small_b_small_x
+ assert_equal 1100, OpenSkill::Statistics.vt(-1000, -100)
+ end
+
+ def test_vt_small_b_big_x
+ assert_equal(-1100, OpenSkill::Statistics.vt(1000, -100))
+ end
+
+ def test_vt_big_b_small_x
+ assert_in_delta 0.79788, OpenSkill::Statistics.vt(-1000, 1000), 0.001
+ end
+
+ def test_vt_big_b_big_x
+ assert_in_delta 0, OpenSkill::Statistics.vt(0, 1000), 0.001
+ end
+
+ def test_wt_1_2
+ assert_in_delta 0.38385826878672835, OpenSkill::Statistics.wt(1, 2), 1e-6
+ end
+
+ def test_wt_0_2
+ assert_in_delta 0.22625869547437663, OpenSkill::Statistics.wt(0, 2), 1e-6
+ end
+
+ def test_wt_0_neg1
+ assert_equal 1, OpenSkill::Statistics.wt(0, -1)
+ end
+
+ def test_wt_0_0
+ assert_equal 1.0, OpenSkill::Statistics.wt(0, 0)
+ end
+
+ def test_wt_0_10
+ assert_in_delta 0, OpenSkill::Statistics.wt(0, 10), 0.001
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/constants.test.ts
+class TestConstants < Minitest::Test
+ def test_z_defaults_to_3
+ env = OpenSkill.constants
+ assert_equal 3, env[:z]
+ end
+
+ def test_z_accepts_override
+ env = OpenSkill.constants(z: 2)
+ assert_equal 2, env[:z]
+ assert_equal 6.25, env[:beta]
+ assert_equal 39.0625, env[:beta_sq]
+ end
+
+ def test_mu_defaults_to_25
+ env = OpenSkill.constants
+ assert_equal 25, env[:mu]
+ end
+
+ def test_mu_accepts_override
+ env = OpenSkill.constants(mu: 300)
+ assert_equal 50, env[:beta]
+ assert_equal 2500, env[:beta_sq]
+ assert_in_delta 1, env[:tau]
+ assert_equal 3, env[:z]
+ end
+
+ def test_tau_default
+ assert_in_delta 0.08333333, OpenSkill.constants[:tau]
+ end
+
+ def test_tau_accepts_override
+ env = OpenSkill.constants(tau: 0.0042)
+ assert_equal 0.0042, env[:tau]
+ end
+
+ def test_sigma_default
+ assert_in_delta 8.333333333333334, OpenSkill.constants[:sigma]
+ end
+
+ def test_sigma_accepts_override
+ env = OpenSkill.constants(sigma: 7)
+ assert_equal 3.5, env[:beta]
+ assert_equal 12.25, env[:beta_sq]
+ end
+
+ def test_epsilon_default
+ assert_equal 0.0001, OpenSkill.constants[:epsilon]
+ end
+
+ def test_epsilon_accepts_override
+ env = OpenSkill.constants(epsilon: 0.001)
+ assert_equal 0.001, env[:epsilon]
+ end
+
+ def test_beta_default
+ assert_in_delta 4.166666666666667, OpenSkill.constants[:beta]
+ end
+
+ def test_beta_accepts_override
+ env = OpenSkill.constants(beta: 100)
+ assert_equal 100, env[:beta]
+ assert_equal 10000, env[:beta_sq]
+ end
+
+ def test_beta_sq_default
+ assert_in_delta 17.361111111111114, OpenSkill.constants[:beta_sq]
+ end
+
+ def test_limit_sigma_defaults_false
+ refute OpenSkill.constants[:limit_sigma]
+ end
+
+ def test_limit_sigma_accepts_flag
+ env = OpenSkill.constants(limit_sigma: true)
+ assert env[:limit_sigma]
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/rating.test.ts
+class TestRating < Minitest::Test
+ def test_defaults_mu_to_25
+ r = OpenSkill.rating
+ assert_equal 25.0, r.mu
+ end
+
+ def test_defaults_sigma_to_8_333
+ r = OpenSkill.rating
+ assert_in_delta 8.333, r.sigma, 0.001
+ end
+
+ def test_accepts_new_mu
+ r = OpenSkill.rating(mu: 42)
+ assert_equal 42, r.mu
+ assert_in_delta 42.0 / 3, r.sigma, 0.001
+ end
+
+ def test_accepts_new_sigma
+ r = OpenSkill.rating(sigma: 6.283185)
+ assert_equal 25, r.mu
+ assert_equal 6.283185, r.sigma
+ end
+
+ def test_mu_of_zero
+ r = OpenSkill.rating(mu: 0)
+ assert_equal 0, r.mu
+ assert_equal 0, r.sigma
+ end
+
+ def test_sigma_of_zero
+ r = OpenSkill.rating(sigma: 0)
+ assert_equal 25, r.mu
+ assert_equal 0, r.sigma
+ end
+
+ def test_different_z
+ r = OpenSkill.rating(z: 5)
+ assert_equal 25, r.mu
+ assert_equal 5, r.sigma
+ end
+
+ def test_different_z_and_mu
+ r = OpenSkill.rating(mu: 15, z: 3)
+ assert_equal 15, r.mu
+ assert_equal 5, r.sigma
+ end
+
+ def test_different_z_and_sigma
+ r = OpenSkill.rating(sigma: 5, z: 3)
+ assert_equal 25, r.mu
+ assert_equal 5, r.sigma
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/ordinal.test.ts
+class TestOrdinal < Minitest::Test
+ def test_converts_rating_to_ordinal
+ r = OpenSkill::Rating.new(5.0, 2.0)
+ assert_equal(-1.0, OpenSkill.ordinal(r))
+ end
+
+ def test_respects_alpha_and_target
+ r = OpenSkill.rating(mu: 24.0, sigma: 6.0)
+ result = OpenSkill.ordinal(r, alpha: 24.0, target: 1500.0)
+ assert_equal 1644.0, result
+ end
+
+ def test_respects_custom_z
+ r = OpenSkill.rating(mu: 24.0, sigma: 6.0, z: 2)
+ result = OpenSkill.ordinal(r, z: 2)
+ assert_equal 12.0, result
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/util/score.test.ts
+class TestScore < Minitest::Test
+ def test_returns_1_on_wins
+ assert_equal 1.0, OpenSkill::Util.score(2, 1)
+ end
+
+ def test_returns_1_on_big_win
+ assert_equal 1.0, OpenSkill::Util.score(34, 6)
+ end
+
+ def test_returns_0_on_losses
+ assert_equal 0.0, OpenSkill::Util.score(1, 2)
+ end
+
+ def test_returns_0_on_big_loss
+ assert_equal 0.0, OpenSkill::Util.score(3, 58)
+ end
+
+ def test_returns_half_on_ties
+ assert_equal 0.5, OpenSkill::Util.score(1, 1)
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/util/rankings.test.ts
+class TestRankings < Minitest::Test
+ def test_ranks_0_element_array
+ assert_equal [], OpenSkill::Util.rankings([], nil)
+ end
+
+ def test_ranks_0_element_with_reordering
+ assert_equal [], OpenSkill::Util.rankings([], [])
+ end
+
+ def test_ranks_1_element_without_reordering
+ assert_equal [0], OpenSkill::Util.rankings([:a], nil)
+ end
+
+ def test_ranks_1_element_with_reordering
+ assert_equal [0], OpenSkill::Util.rankings([:a], [1])
+ end
+
+ def test_ranks_given_nil
+ assert_equal [0, 1, 2, 3], OpenSkill::Util.rankings([:a, :b, :c, :d], nil)
+ end
+
+ def test_ranks_given_incremental
+ assert_equal [0, 1, 2, 3], OpenSkill::Util.rankings([:a, :b, :c, :d], [1, 2, 3, 4])
+ end
+
+ def test_ranks_with_ties_at_start
+ assert_equal [0, 0, 2, 3], OpenSkill::Util.rankings([:a, :b, :c, :d], [1, 1, 3, 4])
+ end
+
+ def test_ranks_with_ties_at_end
+ assert_equal [0, 1, 2, 2], OpenSkill::Util.rankings([:a, :b, :c, :d], [1, 2, 3, 3])
+ end
+
+ def test_ranks_with_ties_in_middle
+ assert_equal [0, 1, 1, 3], OpenSkill::Util.rankings([:a, :b, :c, :d], [1, 2, 2, 4])
+ end
+
+ def test_ranks_sparse_scores
+ assert_equal [0, 1, 2, 2, 4], OpenSkill::Util.rankings([:a, :b, :c, :d, :e], [14, 32, 47, 47, 48])
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/util/ladder-pairs.test.ts
+class TestLadderPairs < Minitest::Test
+ def test_0_elements
+ assert_equal [[]], OpenSkill::Util.ladder_pairs([])
+ end
+
+ def test_1_element
+ assert_equal [[]], OpenSkill::Util.ladder_pairs([1])
+ end
+
+ def test_2_elements
+ assert_equal [[2], [1]], OpenSkill::Util.ladder_pairs([1, 2])
+ end
+
+ def test_3_elements
+ assert_equal [[2], [1, 3], [2]], OpenSkill::Util.ladder_pairs([1, 2, 3])
+ end
+
+ def test_4_elements
+ assert_equal [[2], [1, 3], [2, 4], [3]], OpenSkill::Util.ladder_pairs([1, 2, 3, 4])
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/util/team-rating.test.ts
+class TestTeamRating < Minitest::Test
+ def test_aggregates_all_players
+ r = OpenSkill.rating
+ team1 = [r]
+ team2 = [r, r]
+ result = OpenSkill::Util.team_rating([team1, team2])
+ assert_equal [25, 69.44444444444446, team1, 0], result[0]
+ assert_equal [50, 138.8888888888889, team2, 1], result[1]
+ end
+
+ def test_aggregates_5v5
+ r = OpenSkill.rating
+ result = OpenSkill::Util.team_rating([[r] * 5, [r] * 5])
+ assert_in_delta 125, result[0][0]
+ assert_in_delta 125, result[1][0]
+ assert_in_delta 347.22, result[0][1], 0.01
+ assert_in_delta 347.22, result[1][1], 0.01
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/util/util-c.test.ts
+class TestUtilC < Minitest::Test
+ def test_util_c_computes
+ r = OpenSkill.rating
+ team1 = [r]
+ team2 = [r, r]
+ team_ratings = OpenSkill::Util.team_rating([team2, team1])
+ c = OpenSkill::Util.util_c(team_ratings)
+ assert_in_delta 15.590239, c, 0.001
+ end
+
+ def test_util_c_5v5
+ r = OpenSkill.rating
+ team_ratings = OpenSkill::Util.team_rating([[r] * 5, [r] * 5])
+ c = OpenSkill::Util.util_c(team_ratings)
+ assert_in_delta 27.003, c, 0.001
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/util/util-sum-q.test.ts
+class TestUtilSumQ < Minitest::Test
+ def test_util_sum_q_computes
+ r = OpenSkill.rating
+ team1 = [r]
+ team2 = [r, r]
+ team_ratings = OpenSkill::Util.team_rating([team1, team2])
+ c = OpenSkill::Util.util_c(team_ratings)
+ sum_q = OpenSkill::Util.util_sum_q(team_ratings, c)
+ assert_equal [29.67892702634643, 24.70819334370875], sum_q
+ end
+
+ def test_util_sum_q_5v5
+ r = OpenSkill.rating
+ team_ratings = OpenSkill::Util.team_rating([[r] * 5, [r] * 5])
+ c = OpenSkill::Util.util_c(team_ratings)
+ sum_q = OpenSkill::Util.util_sum_q(team_ratings, c)
+ assert_in_delta 204.84, sum_q[0], 0.01
+ assert_in_delta 102.42, sum_q[1], 0.01
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/util/util-a.test.ts
+class TestUtilA < Minitest::Test
+ def test_util_a_basic
+ r = OpenSkill.rating
+ team_ratings = OpenSkill::Util.team_rating([[r], [r, r]])
+ a = OpenSkill::Util.util_a(team_ratings)
+ assert_equal [1, 1], a
+ end
+
+ def test_util_a_four_teams
+ r = OpenSkill.rating
+ team_ratings = OpenSkill::Util.team_rating([[r], [r, r], [r, r], [r]])
+ a = OpenSkill::Util.util_a(team_ratings)
+ assert_equal [1, 1, 1, 1], a
+ end
+
+ def test_util_a_with_ties
+ r = OpenSkill.rating
+ team_ratings = OpenSkill::Util.team_rating([[r], [r, r], [r, r], [r]], rank: [1, 1, 1, 4])
+ a = OpenSkill::Util.util_a(team_ratings)
+ assert_equal [3, 3, 3, 1], a
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/models/__tests__/plackett-luce.test.ts
+class TestPlackettLuce < Minitest::Test
+ def test_solo_game_unchanged
+ r = OpenSkill.rating
+ result = OpenSkill::PlackettLuce.rate([[r]])
+ assert_equal [[r]], result
+ end
+
+ def test_2p_ffa
+ r = OpenSkill.rating
+ result = OpenSkill::PlackettLuce.rate([[r], [r]])
+ assert_rating 27.63523138347365, 8.065506316323548, result[0][0]
+ assert_rating 22.36476861652635, 8.065506316323548, result[1][0]
+ end
+
+ def test_3p_ffa
+ r = OpenSkill.rating
+ result = OpenSkill::PlackettLuce.rate([[r], [r], [r]])
+ assert_rating 27.868876552746237, 8.204837030780652, result[0][0]
+ assert_rating 25.717219138186557, 8.057829747583874, result[1][0]
+ assert_rating 21.413904309067206, 8.057829747583874, result[2][0]
+ end
+
+ def test_4p_ffa
+ r = OpenSkill.rating
+ result = OpenSkill::PlackettLuce.rate([[r], [r], [r], [r]])
+ assert_rating 27.795084971874736, 8.263160757613477, result[0][0]
+ assert_rating 26.552824984374855, 8.179213704945203, result[1][0]
+ assert_rating 24.68943500312503, 8.083731307186588, result[2][0]
+ assert_rating 20.96265504062538, 8.083731307186588, result[3][0]
+ end
+
+ def test_5p_ffa
+ r = OpenSkill.rating
+ result = OpenSkill::PlackettLuce.rate([[r], [r], [r], [r], [r]])
+ assert_rating 27.666666666666668, 8.290556877154474, result[0][0]
+ assert_rating 26.833333333333332, 8.240145629781066, result[1][0]
+ assert_rating 25.72222222222222, 8.179996679645559, result[2][0]
+ assert_rating 24.055555555555557, 8.111796013701358, result[3][0]
+ assert_rating 20.72222222222222, 8.111796013701358, result[4][0]
+ end
+
+ def test_3_teams_different_sizes
+ r = OpenSkill.rating
+ result = OpenSkill::PlackettLuce.rate([[r, r, r], [r], [r, r]])
+ assert_rating 25.939870821784513, 8.247641552260456, result[0][0]
+ assert_rating 25.939870821784513, 8.247641552260456, result[0][1]
+ assert_rating 25.939870821784513, 8.247641552260456, result[0][2]
+ assert_rating 27.21366020491262, 8.274321317985242, result[1][0]
+ assert_rating 21.84646897330287, 8.213058173195341, result[2][0]
+ assert_rating 21.84646897330287, 8.213058173195341, result[2][1]
+ end
+
+ private
+
+ def assert_rating(expected_mu, expected_sigma, rating)
+ assert_in_delta expected_mu, rating.mu, 1e-10, "mu mismatch"
+ assert_in_delta expected_sigma, rating.sigma, 1e-10, "sigma mismatch"
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/__tests__/rate.test.ts
+class TestRate < Minitest::Test
+ def test_default_plackett_luce
+ a1 = OpenSkill.rating(mu: 29.182, sigma: 4.782)
+ b1 = OpenSkill.rating(mu: 27.174, sigma: 4.922)
+ c1 = OpenSkill.rating(mu: 16.672, sigma: 6.217)
+ d1 = OpenSkill.rating
+
+ result = OpenSkill.rate([[a1], [b1], [c1], [d1]])
+ assert_rating 30.209971908310553, 4.764898977359521, result[0][0]
+ assert_rating 27.64460833689499, 4.882789305097372, result[1][0]
+ assert_rating 17.403586731283518, 6.100723440599442, result[2][0]
+ assert_rating 19.214790707434826, 7.8542613981643985, result[3][0]
+ end
+
+ def test_with_tau
+ a1 = OpenSkill.rating(mu: 29.182, sigma: 4.782)
+ b1 = OpenSkill.rating(mu: 27.174, sigma: 4.922)
+ c1 = OpenSkill.rating(mu: 16.672, sigma: 6.217)
+ d1 = OpenSkill.rating
+
+ result = OpenSkill.rate([[a1], [b1], [c1], [d1]], tau: 0.01)
+ assert_rating 30.20997558824299, 4.764909330988368, result[0][0]
+ assert_rating 27.64461002009721, 4.882799245921361, result[1][0]
+ assert_rating 17.403587237635527, 6.100731158882956, result[2][0]
+ assert_rating 19.21478808745494, 7.854267281042293, result[3][0]
+ end
+
+ def test_with_tau_and_prevent_sigma_increase
+ a1 = OpenSkill.rating(mu: 6.672, sigma: 0.0001)
+ b1 = OpenSkill.rating(mu: 29.182, sigma: 4.782)
+
+ result = OpenSkill.rate([[a1], [b1]], tau: 0.01, limit_sigma: true)
+ assert_operator result[0][0].sigma, :<=, a1.sigma
+ assert_rating 6.672012533190158, 0.0001, result[0][0]
+ assert_rating 26.316243774876106, 4.7540633621019, result[1][0]
+ end
+
+ def test_reverses_rank
+ r = OpenSkill.rating
+ result = OpenSkill.rate([[r], [r]], rank: [2, 1])
+ loser, winner = result[0][0], result[1][0]
+ assert_rating 27.63523138347365, 8.065506316323548, winner
+ assert_rating 22.36476861652635, 8.065506316323548, loser
+ end
+
+ def test_keeps_rank
+ r = OpenSkill.rating
+ result = OpenSkill.rate([[r], [r]], rank: [1, 2])
+ loser, winner = result[0][0], result[1][0]
+ assert_rating 22.36476861652635, 8.065506316323548, winner
+ assert_rating 27.63523138347365, 8.065506316323548, loser
+ end
+
+ def test_misordered_rank
+ d1 = OpenSkill.rating
+ result = OpenSkill.rate([[d1], [d1], [d1], [d1]], rank: [2, 1, 4, 3])
+ assert_rating 26.552824984374855, 8.179213704945203, result[0][0]
+ assert_rating 27.795084971874736, 8.263160757613477, result[1][0]
+ assert_rating 20.96265504062538, 8.083731307186588, result[2][0]
+ assert_rating 24.68943500312503, 8.083731307186588, result[3][0]
+ end
+
+ def test_allows_ties
+ a = OpenSkill.rating(mu: 10, sigma: 8)
+ b = OpenSkill.rating(mu: 5, sigma: 10)
+ c = OpenSkill.rating(mu: 0, sigma: 12)
+ result = OpenSkill.rate([[a], [b], [c]], rank: [1, 2, 2])
+ assert_rating 11.942833056030613, 7.926463661123746, result[0][0]
+ assert_rating 2.938193791485662, 9.65347573412201, result[1][0]
+ assert_rating(-1.4023734358082325, 11.323360667700934, result[2][0])
+ end
+
+ def test_four_way_tie_newbies
+ r = OpenSkill.rating
+ result = OpenSkill.rate([[r], [r], [r], [r]], rank: [1, 1, 1, 1])
+ result.flatten.each do |rating|
+ assert_rating 25, 8.263160757613477, rating
+ end
+ end
+
+ def test_score_instead_of_rank
+ e1 = OpenSkill.rating
+ result = OpenSkill.rate([[e1], [e1], [e1]], score: [1, 1, 1])
+ result.flatten.each do |rating|
+ assert_rating 25, 8.204837030780652, rating
+ end
+ end
+
+ def test_tau_term
+ a = OpenSkill.rating(mu: 25, sigma: 3)
+ b = OpenSkill.rating(mu: 25, sigma: 3)
+ result = OpenSkill.rate([[a], [b]], tau: 0.3)
+ assert_rating 25.624880438870754, 2.9879993738476953, result[0][0]
+ assert_rating 24.375119561129246, 2.9879993738476953, result[1][0]
+ end
+
+ def test_prevents_sigma_from_rising
+ a = OpenSkill.rating(mu: 40, sigma: 3)
+ b = OpenSkill.rating(mu: -20, sigma: 3)
+ result = OpenSkill.rate([[a], [b]], tau: 0.3, limit_sigma: true)
+ assert_rating 40.00032667136128, 3, result[0][0]
+ assert_rating(-20.000326671361275, 3, result[1][0])
+ end
+
+ def test_teams
+ a1 = OpenSkill.rating(mu: 29.182, sigma: 4.782)
+ b1 = OpenSkill.rating(mu: 27.174, sigma: 4.922)
+ c1 = OpenSkill.rating(mu: 16.672, sigma: 6.217)
+ d1 = OpenSkill.rating
+
+ result = OpenSkill.rate([[a1, b1], [c1, d1]])
+ assert_rating 29.607218266047376, 4.754597315295896, result[0][0]
+ assert_rating 27.624480490655575, 4.89211428863373, result[0][1]
+ assert_rating 15.953288649990139, 6.125357588584119, result[1][0]
+ assert_rating 23.708690706816785, 8.111298027437888, result[1][1]
+ end
+
+ private
+
+ def assert_rating(expected_mu, expected_sigma, rating)
+ assert_in_delta expected_mu, rating.mu, 1e-10, "mu mismatch"
+ assert_in_delta expected_sigma, rating.sigma, 1e-10, "sigma mismatch"
+ end
+end
+
+# https://github.com/philihp/openskill.js/blob/main/src/models/__tests__/plackett-luce-series.test.ts
+class TestPlackettLuceSeries < Minitest::Test
+ def test_series
+ p00 = OpenSkill.rating
+ p10 = OpenSkill.rating
+ p20 = OpenSkill.rating
+ p30 = OpenSkill.rating
+ p40 = OpenSkill.rating
+
+ r1 = OpenSkill.rate([[p00], [p10], [p20], [p30], [p40]], score: [9, 7, 7, 5, 5])
+ p01, p11, p21, p31, p41 = r1.map(&:first)
+
+ p02 = p01
+ p32 = p31
+
+ r2 = OpenSkill.rate([[p41], [p21], [p11]], score: [9, 5, 5])
+ p42, p22, p12 = r2.map(&:first)
+
+ p43 = p42
+
+ r3 = OpenSkill.rate([[p32], [p12], [p22], [p02]], score: [9, 9, 7, 7])
+ p33, p13, p23, p03 = r3.map(&:first)
+
+ assert_in_delta 26.353761103, p03.mu, 0.001
+ assert_in_delta 8.11102706, p03.sigma, 0.001
+ assert_in_delta 24.618479789, p13.mu, 0.001
+ assert_in_delta 7.90533551, p13.sigma, 0.001
+ assert_in_delta 23.065819512, p23.mu, 0.001
+ assert_in_delta 7.822005595, p23.sigma, 0.001
+ assert_in_delta 24.476332403, p33.mu, 0.001
+ assert_in_delta 8.106111471, p33.sigma, 0.001
+ assert_in_delta 26.385499685, p43.mu, 0.001
+ assert_in_delta 8.054090809, p43.sigma, 0.001
+ end
+end