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 } + +
+<%= i+1 %> | +<%= rating.item.title %> | +<%= rating.ordinal.round(1) %> | +<%= rating.mu.round(1) %> | +<%= rating.sigma.round(1) %> | +