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
change utturyktvyoulzzurnnyvlusylvqxswo
commit 3286f64cfc3cf3fa2eb6d0ee94766b873685c6c1
author Alpha Chen <alpha@kejadlen.dev>
date
parent vmsvsovq
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