Alpha Chen 2 years ago
parent c63e099693
commit b19a8260c7
Signed by: alpha
SSH Key Fingerprint: SHA256:3fOT8fiYQG/aK9ntivV3Bqtg8AYQ7q4nV6ZgihOA20g

@ -3,8 +3,6 @@
source "https://rubygems.org"
gem "minitest"
gem "phlex"
gem "rack-unreloader"
gem "rackup"
gem "rake"
gem "roda"

@ -1,20 +1,11 @@
GEM
remote: https://rubygems.org/
specs:
cgi (0.3.6)
concurrent-ruby (1.2.2)
erb (4.0.2)
cgi (>= 0.3.3)
language_server-protocol (3.17.0.3)
mini_portile2 (2.8.1)
minitest (5.18.0)
phlex (1.8.1)
concurrent-ruby (~> 1.2)
erb (>= 4)
zeitwerk (~> 2.6)
prettier_print (1.2.1)
rack (3.0.8)
rack-unreloader (2.1.0)
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
@ -33,7 +24,6 @@ GEM
prettier_print (>= 1.2.0)
tilt (2.2.0)
webrick (1.8.1)
zeitwerk (2.6.8)
PLATFORMS
arm64-darwin-21
@ -41,8 +31,6 @@ PLATFORMS
DEPENDENCIES
minitest
phlex
rack-unreloader
rackup
rake
roda

@ -4,13 +4,63 @@ task :default => :test
Minitest::TestTask.create
task :migrate do
# Always applies up to latest version for now
version = nil
require_relative "lib/db"
require "logger"
Sequel.extension :migration
DB.loggers << Logger.new($stdout) if DB.loggers.empty?
Sequel::Migrator.apply(DB, "migrate", version)
desc "Open a dev console"
task :console do
require_relative "lib/rank_king"
include RankKing
binding.irb
end
namespace :serve do
desc "Run a development server"
task :watch do
loop do
sh "fd . | entr -dr rackup"
end
end
end
namespace :db do
desc "Migrate DB to latest"
task :migrate, %i[version] do |t, args|
version = args.fetch(:version, nil)
version = version.to_i unless version.nil? # distinguish nil from 0
require_relative "lib/rank_king/db"
require "logger"
Sequel.extension :migration
DB.loggers << Logger.new($stdout) if DB.loggers.empty?
Sequel::Migrator.apply(DB, "migrate", version)
end
desc "Seed DB with data"
task :seed do
require_relative "lib/rank_king/models"
include RankKing
DB.transaction do
pool = Pool.create(name: "desserts")
desserts = <<~DESSERTS.lines(chomp: true)
Chocolate cake
Apple pie
Cheesecake
Brownies
DESSERTS
desserts.each do |dessert|
Item.create(pool:, title: dessert)
end
Axis.create(pool:, name: "Taste")
end
end
end
namespace :test do
desc "Run tests on source changes"
task :watch do
loop do
sh "fd . | entr -d rake test:isolated"
end
end
end

@ -5,14 +5,5 @@ if dev
logger = Logger.new($stdout)
end
require "rack/unreloader"
Unreloader = Rack::Unreloader.new(
subclasses: %w[Roda Sequel::Model Phlex::HTML],
logger: logger,
reload: dev,
autoload: dev,
) { RankKing::App }
# require_relative("lib/models")
Unreloader.require("lib/views.rb")
Unreloader.require("lib/app.rb") { "RankKing::App" }
run(dev ? Unreloader : RankKing::App.freeze.app)
require_relative "lib/rank_king"
run(RankKing::Web.freeze.app)

@ -1,3 +0,0 @@
require "sequel/core"
DB = Sequel.connect(ENV.delete("DATABASE_URL"))

@ -0,0 +1,44 @@
require_relative "rank_king/web"
require_relative "rank_king/models"
require_relative "rank_king/open_skill"
module RankKing
OS = OpenSkill.new
Match = Data.define(:a, :b)
def self.find_match(axis:)
items = axis.pool.items
items.combination(2).sort_by {|combo|
ratings = combo.map {|item| os_rating(axis:, item:) }
Math.sqrt(
[ratings, ratings.reverse]
.map {|game| [game, OS.rate(game.map {[_1]}).flatten] }
.sum {|pre,post| pre.zip(post).sum { (_1.sigma - _2.sigma)**2 }}
)
}.last
end
def self.rank(axis:, winner:, loser:)
fail unless [winner, loser].all? { _1.pool == axis.pool }
game = [winner, loser].map { os_rating(axis:, item: _1) }
result = OS.rate(game.map { [_1] }).flatten
DB.transaction do
Ranking.create(axis:, winner:, loser:)
Rating.update_or_create({axis:, item: winner}, result.fetch(0).to_h)
Rating.update_or_create({axis:, item: loser}, result.fetch(1).to_h)
end
end
private
def self.os_rating(**filter)
if result = Rating.where(**filter).first
OS.rating(mu: result.mu, sigma: result.sigma)
else
OS.rating
end
end
end

@ -1,8 +1,10 @@
# frozen_string_literal: true
require_relative "db"
require "sequel/core"
require "sequel/model"
DB = Sequel.connect(ENV.delete("DATABASE_URL"))
if ENV["RACK_ENV"] == "development"
Sequel::Model.cache_associations = false
end
@ -11,13 +13,7 @@ Sequel::Model.plugin :auto_validations
Sequel::Model.plugin :require_valid_schema
Sequel::Model.plugin :subclasses unless ENV["RACK_ENV"] == "development"
Sequel::Model.plugin :timestamps, update_on_create: true
unless defined?(Unreloader)
require "rack/unreloader"
Unreloader = Rack::Unreloader.new(reload: false, autoload: !ENV["NO_AUTOLOAD"])
end
# Unreloader.autoload("models"){|f| Sequel::Model.send(:camelize, File.basename(f).sub(/\.rb\z/, ''))}
Sequel::Model.plugin :update_or_create
if ENV["RACK_ENV"] == "development" || ENV["RACK_ENV"] == "test"
require "logger"

@ -0,0 +1,41 @@
require "forwardable"
require "sequel"
require_relative "db"
require_relative "open_skill"
module RankKing
class Pool < Sequel::Model
one_to_many :items
one_to_many :axes, class: "RankKing::Axis"
end
class Item < Sequel::Model
many_to_one :pool
one_to_many :ratings
end
class Axis < Sequel::Model(DB[:axes])
many_to_one :pool
one_to_many :ratings
end
class Rating < Sequel::Model
many_to_one :item
many_to_one :axis
extend Forwardable
def_delegators :openskill, :ordinal, :with
def openskill
OpenSkill::Rating.new(mu: self.mu, sigma: self.sigma)
end
end
class Ranking < Sequel::Model
many_to_one :axis
many_to_one :winner, class: :Item
many_to_one :loser, class: :Item
end
end

@ -0,0 +1,116 @@
module RankKing
class OpenSkill
# Gaussian curve where mu is the mean and sigma the stddev
Rating = Data.define(:mu, :sigma) do
def ordinal = self.mu - 3*self.sigma
end
TeamRating = Data.define(:mu, :sigma_sq, :team, :rank)
def initialize(tau: nil)
@z = 3.0
@mu = 25.0
@tau = tau || @mu / 300.0
@sigma = @mu / @z
@epsilon = 0.0001
@beta = @sigma / 2.0
end
# TODO take an implicit array
# TODO array-ify elements
def rate(teams)
# tau keeps sigma from dropping too low so that ratings stay pliable
# after many games
tau_sq = @tau ** 2
teams = teams.map {|team|
team.map {|r| r.with(sigma: Math.sqrt(r.sigma ** 2 + tau_sq)) }
}
rank = (0...teams.size).to_a
ordered_teams, tenet = self.class.unwind(teams, rank)
new_ratings = plackett_luce(ordered_teams, rank)
reordered_teams = self.class.unwind(new_ratings, tenet)
# TODO prevent sigma increase?
reordered_teams
end
def self.unwind(src, rank)
fail unless src.size == rank.size
return [[], []] if src.empty?
src.each.with_index
.sort_by { rank.fetch(_2) }
.transpose
end
def plackett_luce(game, rank)
team_ratings = self.team_ratings(game)
c = util_c(team_ratings)
sum_q = util_sum_q(team_ratings, c)
a = util_a(team_ratings)
team_ratings.map.with_index {|x, i|
x_mu_over_ce = Math.exp(x.mu / c) # tmp1
omega_sum, delta_sum = team_ratings.each.with_index
.filter {|y,_| y.rank <= x.rank }
.inject([0, 0]) {|(omega, delta), (y, i)|
quotient = x_mu_over_ce / sum_q.fetch(i)
[
omega + (x == y ? 1 - quotient : -quotient) / a.fetch(i),
delta + (quotient * (1 - quotient)) / a.fetch(i),
]
}
x_gamma = Math.sqrt(x.sigma_sq) / c
x_omega = omega_sum * (x.sigma_sq / c)
x_delta = x_gamma * delta_sum * (x.sigma_sq / c ** 2)
x.team.map {|team| Rating.new(
mu: team.mu + (team.sigma ** 2 / x.sigma_sq) * x_omega,
sigma: team.sigma * Math.sqrt([1 - (team.sigma ** 2 / x.sigma_sq) * x_delta, @epsilon].max),
)}
}
end
def rating(mu: nil, sigma: nil)
mu ||= @mu
sigma ||= mu / @z
Rating.new(mu: mu.to_f, sigma: sigma.to_f)
end
def team_ratings(game)
game.map.with_index {|team, i|
TeamRating.new(
mu: team.sum(&:mu),
sigma_sq: team.sum { _1.sigma ** 2 },
team:,
rank: i,
)
}
end
def util_a(team_ratings)
team_ratings.map {|q|
team_ratings.count {|i| i.rank == q.rank }
}
end
def util_c(team_ratings)
Math.sqrt(team_ratings.sum { _1.sigma_sq + @beta ** 2 })
end
def util_sum_q(team_ratings, c)
team_ratings.map {|q|
team_ratings
.select {|i| i.rank >= q.rank }
.sum {|i| Math.exp(i.mu / c) }
}
end
end
end

@ -0,0 +1,89 @@
require "roda"
require_relative "db"
module RankKing
class Web < Roda
plugin :named_templates
plugin :render
template(:layout) { <<~ERB }
<html>
<body>
<h1>Rank King</h1>
<%= yield %>
</body>
</html>
ERB
template(:pools) { <<~ERB }
<h2>Pools</h2>
<ul>
<% @pools.each do |pool| %>
<li><a href="/pools/<%= pool.id %>"><%= pool.name %></a></li>
<% end %>
</ul>
ERB
template(:pool) { <<~ERB }
<h2><%= @pool.name %></h2>
<h3>Axes</h3>
<ul>
<% @pool.axes.each do |axis| %>
<li><a href="/pools/<%= @pool.id %>/axes/<%= axis.id %>"><%= axis.name %></a></li>
<% end %>
</ul>
<h3>Items</h3>
<ol>
<% @pool.items.each do |item| %>
<li><%= item.title %></li>
<% end %>
</ol>
ERB
template(:axis) { <<~ERB }
<h2><%= @axis.pool.name %> - <%= @axis.name %></h2>
<h3>Items</h3>
<table>
<% @axis.ratings.sort_by { -_1.ordinal }.each.with_index do |rating, i| %>
<tr>
<td><%= i+1 %></td>
<td><%= rating.item.title %></td>
<td><%= rating.ordinal.round(1) %></td>
<td><%= rating.mu.round(1) %></td>
<td><%= rating.sigma.round(1) %></td>
</tr>
<% end %>
</table>
ERB
route do |r|
r.root do
r.redirect "/pools"
end
r.on "pools" do
r.is do
@pools = Pool.all
view :pools
end
r.on Integer do |id|
@pool = Pool[id]
r.is do
view :pool
end
r.on "axes" do
r.is Integer do |id|
@axis = Axis[id]
view :axis
end
end
end
end
end
end
end

@ -17,7 +17,7 @@ Sequel.migration do
foreign_key :pool_id, :pools, null: false
String :title, null: false
String :body, null: false
String :body, default: "", null: false
DateTime :created_at, null: false
DateTime :updated_at, null: false
@ -29,8 +29,8 @@ Sequel.migration do
foreign_key :pool_id, :pools, null: false
String :name, null: false
String :better_legend, null: false
String :worse_legend, null: false
String :better_legend, default: "", null: false
String :worse_legend, default: "", null: false
DateTime :created_at, null: false
DateTime :updated_at, null: false
@ -39,9 +39,12 @@ Sequel.migration do
create_table(:ratings) do
primary_key :id
# TODO constrain these to be from the same pool
foreign_key :axis_id, :axes, null: false
foreign_key :winner_id, :items, null: false
foreign_key :loser_id, :items, null: false
foreign_key :item_id, :items, null: false
float :mu, null: false
float :sigma, null: false
DateTime :created_at, null: false
DateTime :updated_at, null: false
@ -50,11 +53,10 @@ Sequel.migration do
create_table(:rankings) do
primary_key :id
# TODO constrain these to be from the same pool
foreign_key :axis_id, :axes, null: false
foreign_key :item_id, :items, null: false
float :mu, null: false
float :sigma, null: false
foreign_key :winner_id, :items, null: false
foreign_key :loser_id, :items, null: false
DateTime :created_at, null: false
DateTime :updated_at, null: false

@ -0,0 +1,132 @@
require "rank_king/open_skill"
include RankKing
class TestRate < Minitest::Test
def setup
@os = OpenSkill.new(tau: 0)
@a1 = @os.rating(mu: 29.182, sigma: 4.782)
@b1 = @os.rating(mu: 27.174, sigma: 4.922)
@c1 = @os.rating(mu: 16.672, sigma: 6.217)
@d1 = @os.rating
@e1 = @os.rating
@f1 = @os.rating
@w1 = @os.rating(mu: 15)
@x1 = @os.rating(mu: 20)
@y1 = @os.rating(mu: 25)
@z1 = @os.rating(mu: 30)
end
def test_rate
a2, b2, c2, d2 = @os.rate([[@a1], [@b1], [@c1], [@d1]]).flatten
assert_rating a2, OpenSkill::Rating.new(mu: 30.209971908310553, sigma: 4.764898977359521)
assert_rating b2, OpenSkill::Rating.new(mu: 27.64460833689499, sigma: 4.882789305097372)
assert_rating c2, OpenSkill::Rating.new(mu: 17.403586731283518, sigma: 6.100723440599442)
assert_rating d2, OpenSkill::Rating.new(mu: 19.214790707434826, sigma: 7.8542613981643985)
end
def test_tau
# TODO
end
private
def assert_rating(expected, actual)
assert_in_delta expected.mu, actual.mu, 0.0001, "Expected #{actual} to equal #{expected}"
assert_in_delta expected.sigma, actual.sigma, 0.0001, "Expected #{actual} to equal #{expected}"
end
end
class TestRating < Minitest::Test
def test_initialize_rating
rating = OpenSkill.new.rating(mu: 12, sigma: 34)
assert_in_delta 12, rating.mu
assert_in_delta 34, rating.sigma
rating = OpenSkill.new.rating(mu: 42)
assert_in_delta 42, rating.mu
assert_in_delta 14, rating.sigma
rating = OpenSkill.new.rating(sigma: 6.283185)
assert_in_delta 25, rating.mu
assert_in_delta 6.283185, rating.sigma
rating = OpenSkill.new.rating(sigma: 0)
assert_in_delta 25, rating.mu
assert_in_delta 0, rating.sigma
end
def test_ordinal
assert_in_delta (-1), OpenSkill::Rating.new(mu: 5, sigma: 2).ordinal
end
end
class TestUtils < Minitest::Test
def setup
@os = OpenSkill.new
@r = @os.rating
end
def test_team_ratings
# TODO
end
def test_utils
team_1 = [@r]
team_2 = [@r, @r]
team_ratings = @os.team_ratings([team_1, team_2])
c = @os.util_c(team_ratings)
assert_in_delta 15.590239, c
sum_q = @os.util_sum_q(team_ratings, c)
[29.67892702634643, 24.70819334370875].zip(sum_q).each do |actual, expected|
assert_in_delta actual, expected
end
end
def test_utils_5v5
team = Array.new(5) { @r }
team_ratings = @os.team_ratings([team, team])
c = @os.util_c(team_ratings)
assert_in_delta 27.003, c
sum_q = @os.util_sum_q(team_ratings, c)
[204.84, 102.42].zip(sum_q).each do |actual, expected|
assert_in_delta actual, expected, 0.01
end
end
def test_util_a
team = [@r]
team_ratings = @os.team_ratings([team, team, team, team])
.zip([1, 1, 1, 4])
.map { _1.with(rank: _2) }
a = @os.util_a(team_ratings)
assert_equal [3, 3, 3, 1], a
end
end
class TestUnwind < Minitest::Test
def test_unwind
assert_equal [[], []], OpenSkill.unwind([], [])
assert_equal [%w[a], [0]], OpenSkill.unwind(%w[a], [0])
assert_equal [%w[a b], [1, 0]], OpenSkill.unwind(%w[b a], [1, 0])
assert_equal [%w[b c a], [1, 2, 0]], OpenSkill.unwind(%w[a b c], [2, 0, 1])
assert_equal [%w[b d c a], [1, 3, 2, 0]], OpenSkill.unwind(%w[a b c d], [3, 0, 2, 1])
end
def test_reverse
src = (?a..?z).to_a.shuffle
rank = (0..25).to_a.shuffle
trans, derank = OpenSkill.unwind(src, rank)
dst, dederank = OpenSkill.unwind(trans, derank)
assert_equal src, dst
refute_same rank, dederank
end
end
Loading…
Cancel
Save