lib/active_genie/ranking/ranking.rb
require_relative '../concerns/loggable' require_relative './players_collection' require_relative './free_for_all' require_relative './elo_round' require_relative './ranking_scoring' # This class orchestrates player ranking through multiple evaluation stages # using Elo ranking and free-for-all match simulations. # 1. Sets initial scores # 2. Eliminates low performers # 3. Runs Elo ranking (for large groups) # 4. Conducts free-for-all matches # # @example Basic usage # Ranking.call(players, criteria) # # @param param_players [Array<Hash|String>] Collection of player objects to evaluate # Example: ["Circle", "Triangle", "Square"] # or # [ # { content: "Circle", score: 10 }, # { content: "Triangle", score: 7 }, # { content: "Square", score: 5 } # ] # @param criteria [String] Evaluation criteria configuration # Example: "What is more similar to the letter 'O'?" # @param config [Hash] Additional configuration config # Example: { model: "gpt-4o", api_key: ENV['OPENAI_API_KEY'] } # @return [Hash] Final ranked player results module ActiveGenie::Ranking class Ranking include ActiveGenie::Concerns::Loggable def self.call(...) new(...).call end def initialize(param_players, criteria, reviewers: [], config: {}) @criteria = criteria @reviewers = Array(reviewers).compact.uniq @config = ActiveGenie::Configuration.to_h(config) @players = PlayersCollection.new(param_players) @elo_rounds_played = 0 @elo_round_battle_count = 0 @free_for_all_battle_count = 0 @total_tokens = 0 @start_time = Time.now end def call initial_log set_initial_player_scores! eliminate_obvious_bad_players! while @players.elo_eligible? elo_report = run_elo_round! eliminate_relegation_players! rebalance_players!(elo_report) end run_free_for_all! final_logs @players.sorted end private SCORE_VARIATION_THRESHOLD = 15 ELIMINATION_VARIATION = 'variation_too_high' ELIMINATION_RELEGATION = 'relegation_tier' with_logging_context :log_context, ->(log) { @total_tokens += log[:total_tokens] || 0 if log[:code] == :llm_usage } def initial_log @players.each { |p| ActiveGenie::Logger.debug({ code: :new_player, player: p.to_h }) } end def set_initial_player_scores! RankingScoring.call(@players, @criteria, reviewers: @reviewers, config: @config) end def eliminate_obvious_bad_players! while @players.coefficient_of_variation >= SCORE_VARIATION_THRESHOLD @players.eligible.last.eliminated = ELIMINATION_VARIATION end end def run_elo_round! @elo_rounds_played += 1 elo_report = EloRound.call(@players, @criteria, config: @config) @elo_round_battle_count += elo_report[:battles_count] elo_report end def eliminate_relegation_players! @players.calc_relegation_tier.each { |player| player.eliminated = ELIMINATION_RELEGATION } end def rebalance_players!(elo_report) return if elo_report[:highest_elo_diff].negative? @players.eligible.each do |player| next if elo_report[:players_in_round].include?(player.id) player.elo += elo_report[:highest_elo_diff] end end def run_free_for_all! ffa_report = FreeForAll.call(@players, @criteria, config: @config) @free_for_all_battle_count += ffa_report[:battles_count] end def report { ranking_id: ranking_id, players_count: @players.size, variation_too_high: @players.select { |player| player.eliminated == ELIMINATION_VARIATION }.size, elo_rounds_played: @elo_rounds_played, elo_round_battle_count: @elo_round_battle_count, relegation_tier: @players.select { |player| player.eliminated == ELIMINATION_RELEGATION }.size, ffa_round_battle_count: @free_for_all_battle_count, top3: @players.eligible[0..2].map(&:id), total_tokens: @total_tokens, duration_seconds: Time.now - @start_time, } end def final_logs ActiveGenie::Logger.debug({ code: :ranking_final, players: @players.sorted.map(&:to_h) }) ActiveGenie::Logger.info({ code: :ranking, **report }) end def log_context { config: @config[:log], ranking_id: } end def ranking_id player_ids = @players.map(&:id).join(',') ranking_unique_key = [player_ids, @criteria, @config.to_json].join('-') Digest::MD5.hexdigest(ranking_unique_key) end end end