Extract Game value type from raw nested arrays
Game wraps the three-dimensional structure (placements → teams →
players) and owns the derived data: #teams, #rank, #team_ratings.
Absorbs Util.rankings (now private) and Util.team_rating. PlackettLuce
and Model#rate both accept a Game instead of flat arrays + rank kwargs.

Game.win_lose provides a convenience constructor for the common 1v1.

Assisted-by: Claude Opus 4.6 via pi
change psvlkmrvxvvosrnlzonxyorktyumyypt
commit d4b0c54eae16e767819a6ba18cb0377901287929
author Alpha Chen <alpha@kejadlen.dev>
date
parent xytwmmkp
diff --git a/lib/open_skill.rb b/lib/open_skill.rb
index 7c47e63..aaf8285 100644
--- a/lib/open_skill.rb
+++ b/lib/open_skill.rb
@@ -4,6 +4,7 @@
 # Implements the Plackett-Luce model for skill rating.
 
 require_relative "open_skill/rating"
+require_relative "open_skill/game"
 require_relative "open_skill/statistics"
 require_relative "open_skill/util"
 require_relative "open_skill/plackett_luce"
diff --git a/lib/open_skill/game.rb b/lib/open_skill/game.rb
new file mode 100644
index 0000000..080bf84
--- /dev/null
+++ b/lib/open_skill/game.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module OpenSkill
+  # A game result: placements (first to last), each containing one or more
+  # tied teams, each containing one or more player Ratings.
+  #
+  #   Game.new([[winner]], [[loser]])
+  #   Game.new([[first]], [[tied_a], [tied_b]], [[last]])
+  #
+  # Each argument is a placement group — an array of teams that tied at
+  # that position. Each team is an array of Rating objects.
+  class Game
+    attr_reader :rank_groups, :teams, :rank
+
+    def initialize(*rank_groups)
+      @rank_groups = rank_groups.freeze
+      @teams = @rank_groups.flatten(1).freeze
+      @rank = @rank_groups.flat_map.with_index { |group, i| [i] * group.size }.freeze
+      freeze
+    end
+
+    def self.win_lose(winner, loser)
+      new([winner], [loser])
+    end
+
+    def team_ratings
+      teams.zip(rankings).map { |team, rank|
+        TeamRating.new(
+          mu: team.sum(&:mu),
+          sigma_sq: team.sum { |p| p.sigma**2 },
+          team: team,
+          rank: rank,
+        )
+      }
+    end
+
+    private
+
+    def rankings
+      s = 0
+      rank.map.with_index { |_, j|
+        s = j if j > 0 && rank[j - 1] < rank[j]
+        s
+      }
+    end
+  end
+end
diff --git a/lib/open_skill/model.rb b/lib/open_skill/model.rb
index b3f7c37..2678808 100644
--- a/lib/open_skill/model.rb
+++ b/lib/open_skill/model.rb
@@ -27,37 +27,34 @@ module OpenSkill
       Rating.new(mu, sigma)
     end
 
-    # Rate teams grouped by placement. The structure has three levels because
-    # the data is three-dimensional: placements contain teams, teams contain
-    # players. Flattening any level loses information — either tie boundaries
-    # or team boundaries. A 1v1 with solo players hits the worst case
-    # (+[[[r]], [[r]]]+) where every level is a single-element array.
+    # Rate teams given a Game — placements (first to last), each containing
+    # one or more tied teams, each containing player Ratings.
     #
-    #   model.rate([[[winner]], [[loser]]])
-    #   model.rate([[[first]], [[tied_a], [tied_b]], [[last]]])
+    #   model.rate(Game.new([[winner]], [[loser]]))
+    #   model.rate(Game.new([[first]], [[tied_a], [tied_b]], [[last]]))
     #
     # Returns a flat array of updated teams in the same (flattened) order.
-    def rate(rank_groups)
-      teams = rank_groups.flatten(1)
-      rank = rank_groups.flat_map.with_index { |group, i| [i] * group.size }
-
-      processed_teams = teams
+    def rate(game)
+      processed_game = game
 
       # If tau is provided, use additive dynamics factor to keep ratings pliable
       if tau
         tau_sq = tau**2
-        processed_teams = teams.map { |team|
-          team.map { |p| Rating.new(p.mu, Math.sqrt(p.sigma**2 + tau_sq)) }
+        inflated_groups = game.rank_groups.map { |group|
+          group.map { |team|
+            team.map { |p| Rating.new(p.mu, Math.sqrt(p.sigma**2 + tau_sq)) }
+          }
         }
+        processed_game = Game.new(*inflated_groups)
       end
 
-      new_ratings = @rating_model.rate(processed_teams, rank: rank)
+      new_ratings = @rating_model.rate(processed_game)
 
       # Cap sigma at pre-tau value so ordinal never drops after a win
       if tau && limit_sigma
         new_ratings = new_ratings.map.with_index { |team, i|
           team.map.with_index { |p, j|
-            Rating.new(p.mu, [p.sigma, teams[i][j].sigma].min)
+            Rating.new(p.mu, [p.sigma, game.teams[i][j].sigma].min)
           }
         }
       end
diff --git a/lib/open_skill/plackett_luce.rb b/lib/open_skill/plackett_luce.rb
index 8d9bdea..0f257bc 100644
--- a/lib/open_skill/plackett_luce.rb
+++ b/lib/open_skill/plackett_luce.rb
@@ -9,10 +9,10 @@ module OpenSkill
       @epsilon = model.epsilon
     end
 
-    def rate(game, rank:)
-      return game if game.length <= 1
+    def rate(game)
+      return game.teams if game.teams.length <= 1
 
-      team_ratings = Util.team_rating(game, rank: rank)
+      team_ratings = game.team_ratings
       c = Util.util_c(team_ratings, @beta_sq)
       sum_q = Util.util_sum_q(team_ratings, c)
       a = Util.util_a(team_ratings)
diff --git a/lib/open_skill/rating.rb b/lib/open_skill/rating.rb
index b347fe4..80f2310 100644
--- a/lib/open_skill/rating.rb
+++ b/lib/open_skill/rating.rb
@@ -6,7 +6,7 @@ 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.
+  # Produced by Game#team_ratings, consumed by PlackettLuce and Util helpers.
   TeamRating = Data.define(:mu, :sigma_sq, :team, :rank) do
     def to_a = [mu, sigma_sq, team, rank]
   end
diff --git a/lib/open_skill/util.rb b/lib/open_skill/util.rb
index b03b2cb..3882c84 100644
--- a/lib/open_skill/util.rb
+++ b/lib/open_skill/util.rb
@@ -12,31 +12,12 @@ module OpenSkill
       end
     end
 
-    def self.rankings(rank)
-      s = 0
-      rank.map.with_index { |_, j|
-        s = j if j > 0 && rank[j - 1] < rank[j]
-        s
-      }
-    end
-
     def self.ladder_pairs(ranks)
       return [[]] if ranks.length <= 1
 
       [nil, *ranks, nil].each_cons(3).map { |l, _, r| [l, r].compact }
     end
 
-    def self.team_rating(game, rank:)
-      game.zip(rankings(rank)).map { |team, rank|
-        TeamRating.new(
-          mu: team.sum(&:mu),
-          sigma_sq: team.sum { |p| p.sigma**2 },
-          team: team,
-          rank: rank,
-        )
-      }
-    end
-
     def self.util_c(team_ratings, beta_sq)
       Math.sqrt(team_ratings.sum { |tr| tr.sigma_sq + beta_sq })
     end
diff --git a/test/test_open_skill.rb b/test/test_open_skill.rb
index 512f7c1..a202a0c 100644
--- a/test/test_open_skill.rb
+++ b/test/test_open_skill.rb
@@ -5,6 +5,8 @@
 require "minitest/autorun"
 require_relative "../lib/open_skill"
 
+Game = OpenSkill::Game
+
 # 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
@@ -263,37 +265,6 @@ class TestScore < Minitest::Test
   end
 end
 
-# https://github.com/philihp/openskill.js/blob/main/src/__tests__/util/rankings.test.ts
-class TestRankings < Minitest::Test
-  def test_ranks_empty
-    assert_equal [], OpenSkill::Util.rankings([])
-  end
-
-  def test_ranks_single
-    assert_equal [0], OpenSkill::Util.rankings([0])
-  end
-
-  def test_ranks_incremental
-    assert_equal [0, 1, 2, 3], OpenSkill::Util.rankings([0, 1, 2, 3])
-  end
-
-  def test_ranks_with_ties_at_start
-    assert_equal [0, 0, 2, 3], OpenSkill::Util.rankings([0, 0, 2, 3])
-  end
-
-  def test_ranks_with_ties_at_end
-    assert_equal [0, 1, 2, 2], OpenSkill::Util.rankings([0, 1, 2, 2])
-  end
-
-  def test_ranks_with_ties_in_middle
-    assert_equal [0, 1, 1, 3], OpenSkill::Util.rankings([0, 1, 1, 3])
-  end
-
-  def test_ranks_sparse_values
-    assert_equal [0, 1, 2, 2, 4], OpenSkill::Util.rankings([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
@@ -327,14 +298,14 @@ class TestTeamRating < Minitest::Test
     r = @model.rating
     team1 = [r]
     team2 = [r, r]
-    result = OpenSkill::Util.team_rating([team1, team2], rank: [0, 1])
+    result = Game.new([team1], [team2]).team_ratings
     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 = @model.rating
-    result = OpenSkill::Util.team_rating([[r] * 5, [r] * 5], rank: [0, 1])
+    result = Game.new([[r] * 5], [[r] * 5]).team_ratings
     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
@@ -352,14 +323,14 @@ class TestUtilC < Minitest::Test
     r = @model.rating
     team1 = [r]
     team2 = [r, r]
-    team_ratings = OpenSkill::Util.team_rating([team2, team1], rank: [0, 1])
+    team_ratings = Game.new([team2], [team1]).team_ratings
     c = OpenSkill::Util.util_c(team_ratings, @model.beta_sq)
     assert_in_delta 15.590239, c, 0.001
   end
 
   def test_util_c_5v5
     r = @model.rating
-    team_ratings = OpenSkill::Util.team_rating([[r] * 5, [r] * 5], rank: [0, 1])
+    team_ratings = Game.new([[r] * 5], [[r] * 5]).team_ratings
     c = OpenSkill::Util.util_c(team_ratings, @model.beta_sq)
     assert_in_delta 27.003, c, 0.001
   end
@@ -375,7 +346,7 @@ class TestUtilSumQ < Minitest::Test
     r = @model.rating
     team1 = [r]
     team2 = [r, r]
-    team_ratings = OpenSkill::Util.team_rating([team1, team2], rank: [0, 1])
+    team_ratings = Game.new([team1], [team2]).team_ratings
     c = OpenSkill::Util.util_c(team_ratings, @model.beta_sq)
     sum_q = OpenSkill::Util.util_sum_q(team_ratings, c)
     assert_equal [29.67892702634643, 24.70819334370875], sum_q
@@ -383,7 +354,7 @@ class TestUtilSumQ < Minitest::Test
 
   def test_util_sum_q_5v5
     r = @model.rating
-    team_ratings = OpenSkill::Util.team_rating([[r] * 5, [r] * 5], rank: [0, 1])
+    team_ratings = Game.new([[r] * 5], [[r] * 5]).team_ratings
     c = OpenSkill::Util.util_c(team_ratings, @model.beta_sq)
     sum_q = OpenSkill::Util.util_sum_q(team_ratings, c)
     assert_in_delta 204.84, sum_q[0], 0.01
@@ -399,21 +370,21 @@ class TestUtilA < Minitest::Test
 
   def test_util_a_basic
     r = @model.rating
-    team_ratings = OpenSkill::Util.team_rating([[r], [r, r]], rank: [0, 1])
+    team_ratings = Game.new([[r]], [[r, r]]).team_ratings
     a = OpenSkill::Util.util_a(team_ratings)
     assert_equal [1, 1], a
   end
 
   def test_util_a_four_teams
     r = @model.rating
-    team_ratings = OpenSkill::Util.team_rating([[r], [r, r], [r, r], [r]], rank: [0, 1, 2, 3])
+    team_ratings = Game.new([[r]], [[r, r]], [[r, r]], [[r]]).team_ratings
     a = OpenSkill::Util.util_a(team_ratings)
     assert_equal [1, 1, 1, 1], a
   end
 
   def test_util_a_with_ties
     r = @model.rating
-    team_ratings = OpenSkill::Util.team_rating([[r], [r, r], [r, r], [r]], rank: [0, 0, 0, 3])
+    team_ratings = Game.new([[r], [r, r], [r, r]], [[r]]).team_ratings
     a = OpenSkill::Util.util_a(team_ratings)
     assert_equal [3, 3, 3, 1], a
   end
@@ -427,20 +398,20 @@ class TestPlackettLuce < Minitest::Test
 
   def test_solo_game_unchanged
     r = @model.rating
-    result = OpenSkill::PlackettLuce.new(@model).rate([[r]], rank: [0])
+    result = OpenSkill::PlackettLuce.new(@model).rate(Game.new([[r]]))
     assert_equal [[r]], result
   end
 
   def test_2p_ffa
     r = @model.rating
-    result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r]], rank: [0, 1])
+    result = OpenSkill::PlackettLuce.new(@model).rate(Game.new([[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 = @model.rating
-    result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r], [r]], rank: [0, 1, 2])
+    result = OpenSkill::PlackettLuce.new(@model).rate(Game.new([[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]
@@ -448,7 +419,7 @@ class TestPlackettLuce < Minitest::Test
 
   def test_4p_ffa
     r = @model.rating
-    result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r], [r], [r]], rank: [0, 1, 2, 3])
+    result = OpenSkill::PlackettLuce.new(@model).rate(Game.new([[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]
@@ -457,7 +428,7 @@ class TestPlackettLuce < Minitest::Test
 
   def test_5p_ffa
     r = @model.rating
-    result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r], [r], [r], [r]], rank: [0, 1, 2, 3, 4])
+    result = OpenSkill::PlackettLuce.new(@model).rate(Game.new([[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]
@@ -467,7 +438,7 @@ class TestPlackettLuce < Minitest::Test
 
   def test_3_teams_different_sizes
     r = @model.rating
-    result = OpenSkill::PlackettLuce.new(@model).rate([[r, r, r], [r], [r, r]], rank: [0, 1, 2])
+    result = OpenSkill::PlackettLuce.new(@model).rate(Game.new([[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]
@@ -496,7 +467,7 @@ class TestRate < Minitest::Test
     c1 = OpenSkill::Model.new(mu: 16.672, sigma: 6.217).rating
     d1 = @model.rating
 
-    result = @model.rate([[[a1]], [[b1]], [[c1]], [[d1]]])
+    result = @model.rate(Game.new([[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]
@@ -509,7 +480,7 @@ class TestRate < Minitest::Test
     c1 = OpenSkill::Model.new(mu: 16.672, sigma: 6.217).rating
     d1 = @model.rating
 
-    result = OpenSkill::Model.new(tau: 0.01).rate([[[a1]], [[b1]], [[c1]], [[d1]]])
+    result = OpenSkill::Model.new(tau: 0.01).rate(Game.new([[a1]], [[b1]], [[c1]], [[d1]]))
     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]
@@ -520,7 +491,7 @@ class TestRate < Minitest::Test
     a1 = OpenSkill::Model.new(mu: 6.672, sigma: 0.0001).rating
     b1 = OpenSkill::Model.new(mu: 29.182, sigma: 4.782).rating
 
-    result = OpenSkill::Model.new(tau: 0.01, limit_sigma: true).rate([[[a1]], [[b1]]])
+    result = OpenSkill::Model.new(tau: 0.01, limit_sigma: true).rate(Game.new([[a1]], [[b1]]))
     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]
@@ -530,7 +501,7 @@ class TestRate < Minitest::Test
     a = OpenSkill::Model.new(mu: 10, sigma: 8).rating
     b = OpenSkill::Model.new(mu: 5, sigma: 10).rating
     c = OpenSkill::Model.new(mu: 0, sigma: 12).rating
-    result = @model.rate([[[a]], [[b], [c]]])
+    result = @model.rate(Game.new([[a]], [[b], [c]]))
     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])
@@ -538,7 +509,7 @@ class TestRate < Minitest::Test
 
   def test_four_way_tie
     r = @model.rating
-    result = @model.rate([[[r], [r], [r], [r]]])
+    result = @model.rate(Game.new([[r], [r], [r], [r]]))
     result.flatten.each do |rating|
       assert_rating 25, 8.263160757613477, rating
     end
@@ -546,7 +517,7 @@ class TestRate < Minitest::Test
 
   def test_three_way_tie
     e1 = @model.rating
-    result = @model.rate([[[e1], [e1], [e1]]])
+    result = @model.rate(Game.new([[e1], [e1], [e1]]))
     result.flatten.each do |rating|
       assert_rating 25, 8.204837030780652, rating
     end
@@ -555,7 +526,7 @@ class TestRate < Minitest::Test
   def test_tau_term
     a = OpenSkill::Model.new(mu: 25, sigma: 3).rating
     b = OpenSkill::Model.new(mu: 25, sigma: 3).rating
-    result = OpenSkill::Model.new(tau: 0.3).rate([[[a]], [[b]]])
+    result = OpenSkill::Model.new(tau: 0.3).rate(Game.new([[a]], [[b]]))
     assert_rating 25.624880438870754, 2.9879993738476953, result[0][0]
     assert_rating 24.375119561129246, 2.9879993738476953, result[1][0]
   end
@@ -563,7 +534,7 @@ class TestRate < Minitest::Test
   def test_prevents_sigma_from_rising
     a = OpenSkill::Model.new(mu: 40, sigma: 3).rating
     b = OpenSkill::Model.new(mu: -20, sigma: 3).rating
-    result = OpenSkill::Model.new(tau: 0.3, limit_sigma: true).rate([[[a]], [[b]]])
+    result = OpenSkill::Model.new(tau: 0.3, limit_sigma: true).rate(Game.new([[a]], [[b]]))
     assert_rating 40.00032667136128, 3, result[0][0]
     assert_rating(-20.000326671361275, 3, result[1][0])
   end
@@ -574,7 +545,7 @@ class TestRate < Minitest::Test
     c1 = OpenSkill::Model.new(mu: 16.672, sigma: 6.217).rating
     d1 = @model.rating
 
-    result = @model.rate([[[a1, b1]], [[c1, d1]]])
+    result = @model.rate(Game.new([[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]
@@ -602,18 +573,18 @@ class TestPlackettLuceSeries < Minitest::Test
     p30 = @model.rating
     p40 = @model.rating
 
-    r1 = @model.rate([[[p00]], [[p10], [p20]], [[p30], [p40]]])
+    r1 = @model.rate(Game.new([[p00]], [[p10], [p20]], [[p30], [p40]]))
     p01, p11, p21, p31, p41 = r1.map(&:first)
 
     p02 = p01
     p32 = p31
 
-    r2 = @model.rate([[[p41]], [[p21], [p11]]])
+    r2 = @model.rate(Game.new([[p41]], [[p21], [p11]]))
     p42, p22, p12 = r2.map(&:first)
 
     p43 = p42
 
-    r3 = @model.rate([[[p32], [p12]], [[p22], [p02]]])
+    r3 = @model.rate(Game.new([[p32], [p12]], [[p22], [p02]]))
     p33, p13, p23, p03 = r3.map(&:first)
 
     assert_in_delta 26.353761103, p03.mu, 0.001