Refactor OpenSkill internals for idiomatic Ruby
Replace array tuples with TeamRating Data class so callers
use named fields instead of positional indices.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/lib/open_skill.rb b/lib/open_skill.rb
index 649ef1d..fe92539 100644
--- a/lib/open_skill.rb
+++ b/lib/open_skill.rb
@@ -12,12 +12,8 @@ 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)
+ def self.rating(**options)
+ env = constants(**options)
Rating.new(env[:mu], env[:sigma])
end
end
diff --git a/lib/open_skill/plackett_luce.rb b/lib/open_skill/plackett_luce.rb
index b625a82..0318081 100644
--- a/lib/open_skill/plackett_luce.rb
+++ b/lib/open_skill/plackett_luce.rb
@@ -7,41 +7,35 @@ module OpenSkill
def self.rate(game, **options)
return game if game.length <= 1
- env = OpenSkill.constants(**options)
- epsilon = env[:epsilon]
+ epsilon = OpenSkill.constants(**options)[: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)
+ team_ratings.each_with_index.map do |tr, i|
+ mu_over_ce = Math.exp(tr.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
+ team_ratings.each_with_index do |qr, q|
+ next unless qr.rank <= tr.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
+ quotient = mu_over_ce / sum_q[q]
+ omega_sum += ((i == q ? 1 : 0) - quotient) / a[q]
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)
+ g = Util.gamma(c, team_ratings.length, tr)
+ omega = omega_sum * (tr.sigma_sq / c)
+ delta = g * delta_sum * (tr.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)
+ tr.team.map do |player|
+ share = player.sigma**2 / tr.sigma_sq
+ new_mu = player.mu + share * omega
+ new_sigma = player.sigma * Math.sqrt([1 - share * delta, epsilon].max)
Rating.new(new_mu, new_sigma)
end
end
diff --git a/lib/open_skill/rate.rb b/lib/open_skill/rate.rb
index d07f8c8..4cd2e92 100644
--- a/lib/open_skill/rate.rb
+++ b/lib/open_skill/rate.rb
@@ -5,44 +5,39 @@
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 tau is provided, use additive dynamics factor to keep ratings pliable
if options.key?(:tau)
- tau_squared = tau**2
+ tau_sq = env[:tau]**2
processed_teams = teams.map do |team|
- team.map do |p|
- Rating.new(p.mu, Math.sqrt(p.sigma**2 + tau_squared))
- end
+ team.map { |p| Rating.new(p.mu, Math.sqrt(p.sigma**2 + tau_sq)) }
end
end
- # Build rank from score or use provided rank, or default 1..n
+ # Build rank from score (negated so higher score = lower 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] }
+ # Sort by rank, run the model, then restore original order
+ sort_order = rank.each_with_index.sort_by(&:first).map(&:last)
+ ordered_teams = sort_order.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] }
+ result = Array.new(teams.length)
+ sort_order.each_with_index { |orig, sorted| result[orig] = new_ratings[sorted] }
- # Limit sigma if requested
- if options.key?(:tau) && limit_sigma
- reordered = reordered.each_with_index.map do |team, i|
+ # Cap sigma at pre-tau value so ordinal never drops after a win
+ if options.key?(:tau) && env[:limit_sigma]
+ result = result.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
+ result
end
end
diff --git a/lib/open_skill/rating.rb b/lib/open_skill/rating.rb
index cfdb203..b347fe4 100644
--- a/lib/open_skill/rating.rb
+++ b/lib/open_skill/rating.rb
@@ -4,4 +4,10 @@
module OpenSkill
Rating = Data.define(:mu, :sigma)
+
+ # Intermediate structure for team-level aggregation during rating.
+ # Produced by Util.team_rating, consumed by PlackettLuce and Util helpers.
+ TeamRating = Data.define(:mu, :sigma_sq, :team, :rank) do
+ def to_a = [mu, sigma_sq, team, rank]
+ end
end
diff --git a/lib/open_skill/statistics.rb b/lib/open_skill/statistics.rb
index 403a1f5..8de0b2d 100644
--- a/lib/open_skill/statistics.rb
+++ b/lib/open_skill/statistics.rb
@@ -18,6 +18,7 @@ module OpenSkill
xt = x - t
denom = phi_major(xt)
return -xt if denom < Float::EPSILON
+
phi_minor(xt) / denom
end
@@ -25,25 +26,29 @@ module OpenSkill
xt = x - t
denom = phi_major(xt)
if denom < Float::EPSILON
- return x < 0 ? 1 : 0
+ return x.negative? ? 1 : 0
end
- v(x, t) * (v(x, t) + xt)
+
+ vxt = v(x, t)
+ vxt * (vxt + 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
+ return x.negative? ? -x - t : -x + t
end
+
a = phi_minor(-t - xx) - phi_minor(t - xx)
- (x < 0 ? -a : a) / b
+ (x.negative? ? -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
diff --git a/lib/open_skill/util.rb b/lib/open_skill/util.rb
index 3bf970c..4f4c6f8 100644
--- a/lib/open_skill/util.rb
+++ b/lib/open_skill/util.rb
@@ -4,84 +4,66 @@
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
+ case q <=> i
+ when -1 then 0.0
+ when 0 then 0.5
+ when 1 then 1.0
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)
+ team_scores = teams.each_index.map { |i| rank&.at(i) || i }
s = 0
- team_scores.each_with_index do |_score, j|
+ team_scores.each_with_index.map do |_, j|
s = j if j > 0 && team_scores[j - 1] < team_scores[j]
- out_rank[j] = s
+ s
end
- out_rank
end
def self.ladder_pairs(ranks)
- size = ranks.length
- return [[]] if size <= 1
+ return [[]] if ranks.length <= 1
- left = [nil, *ranks[0...(size - 1)]]
- right = [*ranks[1..], nil]
+ padded_left = [nil, *ranks[...-1]]
+ padded_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
+ padded_left.zip(padded_right).map { |pair| pair.compact }
end
- def self.team_rating(game, rank: nil, **_options)
+ def self.team_rating(game, rank: nil, **)
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]]
+ TeamRating.new(
+ mu: team.sum(&:mu),
+ sigma_sq: team.sum { |p| p.sigma**2 },
+ team: team,
+ rank: 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 })
+ def self.util_c(team_ratings, **)
+ beta_sq = OpenSkill.constants(**)[:beta_sq]
+ Math.sqrt(team_ratings.sum { |tr| tr.sigma_sq + beta_sq })
end
def self.util_sum_q(team_ratings, c)
- team_ratings.map do |q_rating|
- q_rank = q_rating[3]
+ team_ratings.map do |qr|
team_ratings
- .select { |i_rating| i_rating[3] >= q_rank }
- .sum { |i_rating| Math.exp(i_rating[0] / c) }
+ .select { |ir| ir.rank >= qr.rank }
+ .sum { |ir| Math.exp(ir.mu / 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
+ team_ratings.map { |ir| team_ratings.count { |qr| ir.rank == qr.rank } }
end
- def self.gamma(c, _k, _mu, sigma_sq, _team, _rank)
- Math.sqrt(sigma_sq) / c
+ def self.gamma(c, _k, tr)
+ Math.sqrt(tr.sigma_sq) / c
end
end
end
diff --git a/test/test_open_skill.rb b/test/test_open_skill.rb
index c2deb15..8f3fcf4 100644
--- a/test/test_open_skill.rb
+++ b/test/test_open_skill.rb
@@ -337,17 +337,17 @@ class TestTeamRating < Minitest::Test
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]
+ assert_equal [25, 69.44444444444446, team1, 0], result[0].to_a
+ assert_equal [50, 138.8888888888889, team2, 1], result[1].to_a
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
+ assert_in_delta 125, result[0].mu
+ assert_in_delta 125, result[1].mu
+ assert_in_delta 347.22, result[0].sigma_sq, 0.01
+ assert_in_delta 347.22, result[1].sigma_sq, 0.01
end
end