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
change vmsvsovqpsstzlqqkzqukpzuxtmxnwqn
commit bca376428f2b9cea79fc33f1fb57b4bbc3d9fdb8
author Alpha Chen <alpha@kejadlen.dev>
date
parent mnvtvxtk
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