Require callers to pass teams in rank order
Model#rate now takes rank_groups — an array of placement groups where
position is rank and each group holds tied teams. This eliminates the
rank:/score: kwargs and the internal sort/unsort dance.
The structure is three-dimensional (placements → teams → players)
because flattening any level loses either tie boundaries or team
boundaries.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/lib/open_skill/model.rb b/lib/open_skill/model.rb
index a601b97..b3f7c37 100644
--- a/lib/open_skill/model.rb
+++ b/lib/open_skill/model.rb
@@ -27,7 +27,20 @@ module OpenSkill
Rating.new(mu, sigma)
end
- def rate(teams, rank: nil, score: nil)
+ # 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.
+ #
+ # model.rate([[[winner]], [[loser]]])
+ # model.rate([[[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
# If tau is provided, use additive dynamics factor to keep ratings pliable
@@ -38,28 +51,18 @@ module OpenSkill
}
end
- # Build rank from score (negated so higher score = lower rank) or default 1..n
- rank = rank || score&.map { |s| -s } || (1..teams.length).to_a
-
- # Sort by rank, run the model, then restore original order
- ordered = processed_teams.zip(rank).sort_by(&:last)
- ordered_teams = ordered.map(&:first)
- sorted_rank = ordered.map(&:last)
-
- new_ratings = @rating_model.rate(ordered_teams, rank: sorted_rank)
-
- result = rank.zip(new_ratings).sort_by(&:first).map(&:last)
+ new_ratings = @rating_model.rate(processed_teams, rank: rank)
# Cap sigma at pre-tau value so ordinal never drops after a win
if tau && limit_sigma
- result = result.map.with_index { |team, i|
+ 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)
}
}
end
- result
+ new_ratings
end
def ordinal(rating)
diff --git a/lib/open_skill/plackett_luce.rb b/lib/open_skill/plackett_luce.rb
index dba4e44..8d9bdea 100644
--- a/lib/open_skill/plackett_luce.rb
+++ b/lib/open_skill/plackett_luce.rb
@@ -9,7 +9,7 @@ module OpenSkill
@epsilon = model.epsilon
end
- def rate(game, rank: nil)
+ def rate(game, rank:)
return game if game.length <= 1
team_ratings = Util.team_rating(game, rank: rank)
diff --git a/lib/open_skill/util.rb b/lib/open_skill/util.rb
index 5e6366c..b03b2cb 100644
--- a/lib/open_skill/util.rb
+++ b/lib/open_skill/util.rb
@@ -12,14 +12,10 @@ module OpenSkill
end
end
- def self.rankings(teams, rank)
- return [] if teams.empty?
-
- team_scores = teams.map.with_index { |_, i| rank&.at(i) || i }
-
+ def self.rankings(rank)
s = 0
- team_scores.map.with_index { |_, j|
- s = j if j > 0 && team_scores[j - 1] < team_scores[j]
+ rank.map.with_index { |_, j|
+ s = j if j > 0 && rank[j - 1] < rank[j]
s
}
end
@@ -30,8 +26,8 @@ module OpenSkill
[nil, *ranks, nil].each_cons(3).map { |l, _, r| [l, r].compact }
end
- def self.team_rating(game, rank: nil)
- game.zip(rankings(game, rank)).map { |team, rank|
+ 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 },
diff --git a/test/test_open_skill.rb b/test/test_open_skill.rb
index c3744a1..512f7c1 100644
--- a/test/test_open_skill.rb
+++ b/test/test_open_skill.rb
@@ -265,44 +265,32 @@ 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)
+ def test_ranks_empty
+ assert_equal [], OpenSkill::Util.rankings([])
end
- def test_ranks_0_element_with_reordering
- assert_equal [], OpenSkill::Util.rankings([], [])
+ def test_ranks_single
+ assert_equal [0], OpenSkill::Util.rankings([0])
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])
+ 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([:a, :b, :c, :d], [1, 1, 3, 4])
+ 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([:a, :b, :c, :d], [1, 2, 3, 3])
+ 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([:a, :b, :c, :d], [1, 2, 2, 4])
+ assert_equal [0, 1, 1, 3], OpenSkill::Util.rankings([0, 1, 1, 3])
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])
+ def test_ranks_sparse_values
+ assert_equal [0, 1, 2, 2, 4], OpenSkill::Util.rankings([14, 32, 47, 47, 48])
end
end
@@ -339,14 +327,14 @@ class TestTeamRating < Minitest::Test
r = @model.rating
team1 = [r]
team2 = [r, r]
- result = OpenSkill::Util.team_rating([team1, team2])
+ result = OpenSkill::Util.team_rating([team1, team2], rank: [0, 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 = @model.rating
- result = OpenSkill::Util.team_rating([[r] * 5, [r] * 5])
+ result = OpenSkill::Util.team_rating([[r] * 5, [r] * 5], rank: [0, 1])
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
@@ -364,14 +352,14 @@ class TestUtilC < Minitest::Test
r = @model.rating
team1 = [r]
team2 = [r, r]
- team_ratings = OpenSkill::Util.team_rating([team2, team1])
+ team_ratings = OpenSkill::Util.team_rating([team2, team1], rank: [0, 1])
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])
+ team_ratings = OpenSkill::Util.team_rating([[r] * 5, [r] * 5], rank: [0, 1])
c = OpenSkill::Util.util_c(team_ratings, @model.beta_sq)
assert_in_delta 27.003, c, 0.001
end
@@ -387,7 +375,7 @@ class TestUtilSumQ < Minitest::Test
r = @model.rating
team1 = [r]
team2 = [r, r]
- team_ratings = OpenSkill::Util.team_rating([team1, team2])
+ team_ratings = OpenSkill::Util.team_rating([team1, team2], rank: [0, 1])
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
@@ -395,7 +383,7 @@ class TestUtilSumQ < Minitest::Test
def test_util_sum_q_5v5
r = @model.rating
- team_ratings = OpenSkill::Util.team_rating([[r] * 5, [r] * 5])
+ team_ratings = OpenSkill::Util.team_rating([[r] * 5, [r] * 5], rank: [0, 1])
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
@@ -411,21 +399,21 @@ class TestUtilA < Minitest::Test
def test_util_a_basic
r = @model.rating
- team_ratings = OpenSkill::Util.team_rating([[r], [r, r]])
+ team_ratings = OpenSkill::Util.team_rating([[r], [r, r]], rank: [0, 1])
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]])
+ team_ratings = OpenSkill::Util.team_rating([[r], [r, r], [r, r], [r]], rank: [0, 1, 2, 3])
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: [1, 1, 1, 4])
+ team_ratings = OpenSkill::Util.team_rating([[r], [r, r], [r, r], [r]], rank: [0, 0, 0, 3])
a = OpenSkill::Util.util_a(team_ratings)
assert_equal [3, 3, 3, 1], a
end
@@ -439,20 +427,20 @@ class TestPlackettLuce < Minitest::Test
def test_solo_game_unchanged
r = @model.rating
- result = OpenSkill::PlackettLuce.new(@model).rate([[r]])
+ result = OpenSkill::PlackettLuce.new(@model).rate([[r]], rank: [0])
assert_equal [[r]], result
end
def test_2p_ffa
r = @model.rating
- result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r]])
+ result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r]], rank: [0, 1])
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]])
+ result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r], [r]], rank: [0, 1, 2])
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]
@@ -460,7 +448,7 @@ class TestPlackettLuce < Minitest::Test
def test_4p_ffa
r = @model.rating
- result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r], [r], [r]])
+ result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r], [r], [r]], rank: [0, 1, 2, 3])
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]
@@ -469,7 +457,7 @@ class TestPlackettLuce < Minitest::Test
def test_5p_ffa
r = @model.rating
- result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r], [r], [r], [r]])
+ result = OpenSkill::PlackettLuce.new(@model).rate([[r], [r], [r], [r], [r]], rank: [0, 1, 2, 3, 4])
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]
@@ -479,7 +467,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]])
+ result = OpenSkill::PlackettLuce.new(@model).rate([[r, r, r], [r], [r, r]], rank: [0, 1, 2])
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]
@@ -508,7 +496,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([[[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]
@@ -521,7 +509,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([[[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]
@@ -532,58 +520,33 @@ 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([[[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]
end
- def test_reverses_rank
- r = @model.rating
- result = @model.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 = @model.rating
- result = @model.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 = @model.rating
- result = @model.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::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]], rank: [1, 2, 2])
+ result = @model.rate([[[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])
end
- def test_four_way_tie_newbies
+ def test_four_way_tie
r = @model.rating
- result = @model.rate([[r], [r], [r], [r]], rank: [1, 1, 1, 1])
+ result = @model.rate([[[r], [r], [r], [r]]])
result.flatten.each do |rating|
assert_rating 25, 8.263160757613477, rating
end
end
- def test_score_instead_of_rank
+ def test_three_way_tie
e1 = @model.rating
- result = @model.rate([[e1], [e1], [e1]], score: [1, 1, 1])
+ result = @model.rate([[[e1], [e1], [e1]]])
result.flatten.each do |rating|
assert_rating 25, 8.204837030780652, rating
end
@@ -592,7 +555,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([[[a]], [[b]]])
assert_rating 25.624880438870754, 2.9879993738476953, result[0][0]
assert_rating 24.375119561129246, 2.9879993738476953, result[1][0]
end
@@ -600,7 +563,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([[[a]], [[b]]])
assert_rating 40.00032667136128, 3, result[0][0]
assert_rating(-20.000326671361275, 3, result[1][0])
end
@@ -611,7 +574,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([[[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]
@@ -639,18 +602,18 @@ class TestPlackettLuceSeries < Minitest::Test
p30 = @model.rating
p40 = @model.rating
- r1 = @model.rate([[p00], [p10], [p20], [p30], [p40]], score: [9, 7, 7, 5, 5])
+ r1 = @model.rate([[[p00]], [[p10], [p20]], [[p30], [p40]]])
p01, p11, p21, p31, p41 = r1.map(&:first)
p02 = p01
p32 = p31
- r2 = @model.rate([[p41], [p21], [p11]], score: [9, 5, 5])
+ r2 = @model.rate([[[p41]], [[p21], [p11]]])
p42, p22, p12 = r2.map(&:first)
p43 = p42
- r3 = @model.rate([[p32], [p12], [p22], [p02]], score: [9, 9, 7, 7])
+ r3 = @model.rate([[[p32], [p12]], [[p22], [p02]]])
p33, p13, p23, p03 = r3.map(&:first)
assert_in_delta 26.353761103, p03.mu, 0.001