diff --git a/Gemfile b/Gemfile index 4dc547b..1d2bad6 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,6 @@ source "https://rubygems.org" gem "minitest" -gem "phlex" -gem "rack-unreloader" gem "rackup" gem "rake" gem "roda" diff --git a/Gemfile.lock b/Gemfile.lock index 11b9534..98cc59f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/Rakefile b/Rakefile index 8dd097f..6a7c2d7 100644 --- a/Rakefile +++ b/Rakefile @@ -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 + diff --git a/config.ru b/config.ru index c4a4858..8faa5a0 100644 --- a/config.ru +++ b/config.ru @@ -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) diff --git a/lib/db.rb b/lib/db.rb deleted file mode 100644 index cca0ac7..0000000 --- a/lib/db.rb +++ /dev/null @@ -1,3 +0,0 @@ -require "sequel/core" - -DB = Sequel.connect(ENV.delete("DATABASE_URL")) diff --git a/lib/rank_king.rb b/lib/rank_king.rb new file mode 100644 index 0000000..13c74d4 --- /dev/null +++ b/lib/rank_king.rb @@ -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 diff --git a/lib/models.rb b/lib/rank_king/db.rb similarity index 67% rename from lib/models.rb rename to lib/rank_king/db.rb index 4055c5b..951a620 100644 --- a/lib/models.rb +++ b/lib/rank_king/db.rb @@ -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" diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb new file mode 100644 index 0000000..c706bfb --- /dev/null +++ b/lib/rank_king/models.rb @@ -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 diff --git a/lib/rank_king/open_skill.rb b/lib/rank_king/open_skill.rb new file mode 100644 index 0000000..5598e5b --- /dev/null +++ b/lib/rank_king/open_skill.rb @@ -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 diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb new file mode 100644 index 0000000..723af51 --- /dev/null +++ b/lib/rank_king/web.rb @@ -0,0 +1,89 @@ +require "roda" + +require_relative "db" + +module RankKing + class Web < Roda + plugin :named_templates + plugin :render + + template(:layout) { <<~ERB } + + +

Rank King

+ <%= yield %> + + + ERB + + template(:pools) { <<~ERB } +

Pools

+ + ERB + + template(:pool) { <<~ERB } +

<%= @pool.name %>

+

Axes

+ +

Items

+
    + <% @pool.items.each do |item| %> +
  1. <%= item.title %>
  2. + <% end %> +
+ ERB + + template(:axis) { <<~ERB } +

<%= @axis.pool.name %> - <%= @axis.name %>

+

Items

+ + <% @axis.ratings.sort_by { -_1.ordinal }.each.with_index do |rating, i| %> + + + + + + + + <% end %> +
<%= i+1 %><%= rating.item.title %><%= rating.ordinal.round(1) %><%= rating.mu.round(1) %><%= rating.sigma.round(1) %>
+ 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 diff --git a/migrate/001_tables.rb b/migrate/001_tables.rb index 61e6806..c2c65b6 100644 --- a/migrate/001_tables.rb +++ b/migrate/001_tables.rb @@ -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 diff --git a/test/open_skill_test.rb b/test/open_skill_test.rb new file mode 100644 index 0000000..4d0c148 --- /dev/null +++ b/test/open_skill_test.rb @@ -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