active_genie 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,22 @@
1
- # league
1
+ # Ranking
2
2
 
3
- The `ActiveGenie::League` module organizes players based on scores derived from textual evaluations and then ranks them using a multi-stage process. It leverages the scoring system from the `ActiveGenie::Scoring` module to assign initial scores, and then applies advanced ranking methods to produce a competitive league.
3
+ The `ActiveGenie::Ranking` module organizes players based on scores derived from textual evaluations and then ranks them using a multi-stage process. It leverages the scoring system from the `ActiveGenie::Scoring` module to assign initial scores, and then applies advanced ranking methods to produce a competitive ranking.
4
4
 
5
5
  ## Overview
6
6
 
7
- The league system performs the following steps:
7
+ The ranking system performs the following steps:
8
8
 
9
9
  1. **Initial Scoring**: Each player’s textual content is evaluated using `ActiveGenie::Scoring`. This produces a `score` based on multiple expert reviews.
10
10
 
11
11
  2. **Elimination of Poor Performers**: Players whose scores show a high coefficient of variation (indicating inconsistency) are progressively eliminated. This ensures that only competitive candidates continue in the ranking process.
12
12
 
13
- 3. **ELO Ranking**: If there are more than 10 eligible players, an ELO-based ranking is applied. Battles between players are simulated via `ActiveGenie::Battle`, and scores are updated using an ELO algorithm tailored to the league.
13
+ 3. **ELO Ranking**: If there are more than 10 eligible players, an ELO-based ranking is applied. Battles between players are simulated via `ActiveGenie::Battle`, and scores are updated using an ELO algorithm tailored to the ranking.
14
14
 
15
15
  4. **Free for all Matches**: Finally, the remaining players engage in head-to-head matches where each unique pair competes. Match outcomes (wins, losses, draws) are recorded to finalize the rankings.
16
16
 
17
17
  ## Components
18
18
 
19
- - **league**: Orchestrates the entire ranking process. Initializes player scores, eliminates outliers, and coordinates ranking rounds.
19
+ - **ranking**: Orchestrates the entire ranking process. Initializes player scores, eliminates outliers, and coordinates ranking rounds.
20
20
 
21
21
  - **EloRanking**: Applies an ELO-based system to rank players through simulated battles. It updates players’ ELO scores based on match outcomes and predefined rules (including penalties for losses).
22
22
 
@@ -24,10 +24,10 @@ The league system performs the following steps:
24
24
 
25
25
  ## Usage
26
26
 
27
- Call the league using:
27
+ Call the ranking using:
28
28
 
29
29
  ```ruby
30
- result = ActiveGenie::League::league.call(players, criteria, config: {})
30
+ result = ActiveGenie::Ranking.call(players, criteria, config: {})
31
31
  ```
32
32
 
33
33
  - `players`: A collection of player instances, each containing textual content to be scored.
@@ -0,0 +1,113 @@
1
+ require_relative '../battle/basic'
2
+
3
+ module ActiveGenie::Ranking
4
+ class EloRound
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(players, criteria, config: {})
10
+ @players = players
11
+ @relegation_tier = players.calc_relegation_tier
12
+ @defender_tier = players.calc_defender_tier
13
+ @criteria = criteria
14
+ @config = config
15
+ @tmp_defenders = []
16
+ end
17
+
18
+ def call
19
+ ActiveGenie::Logger.with_context(log_context) do
20
+ matches.each do |player_a, player_b|
21
+ # TODO: battle can take a while, can be parallelized
22
+ winner, loser = battle(player_a, player_b)
23
+
24
+ next if winner.nil? || loser.nil?
25
+
26
+ new_winner_elo, new_loser_elo = calculate_new_elo(winner.elo, loser.elo)
27
+
28
+ winner.elo = new_winner_elo
29
+ loser.elo = new_loser_elo
30
+ end
31
+
32
+ # TODO: add a round report. Duration, Elo changes, etc.
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ BATTLE_PER_PLAYER = 3
39
+ LOSE_PENALTY = 15
40
+ K = 32
41
+
42
+ def matches
43
+ @relegation_tier.reduce([]) do |matches, attack_player|
44
+ BATTLE_PER_PLAYER.times do
45
+ matches << [attack_player, next_defense_player].shuffle
46
+ end
47
+ matches
48
+ end
49
+ end
50
+
51
+ def next_defense_player
52
+ @tmp_defenders = @defender_tier if @tmp_defenders.size.zero?
53
+
54
+ @tmp_defenders.shuffle.pop!
55
+ end
56
+
57
+ def battle(player_a, player_b)
58
+ result = ActiveGenie::Battle.basic(
59
+ player_a,
60
+ player_b,
61
+ @criteria,
62
+ config: @config
63
+ )
64
+
65
+ winner, loser = case result['winner']
66
+ when 'player_a' then [player_a, player_b]
67
+ when 'player_b' then [player_b, player_a]
68
+ when 'draw' then [nil, nil]
69
+ end
70
+
71
+ ActiveGenie::Logger.debug({
72
+ step: :elo_round_battle,
73
+ player_ids: [player_a.id, player_b.id],
74
+ winner_id: winner&.id,
75
+ loser_id: loser&.id,
76
+ reasoning: result['reasoning']
77
+ })
78
+
79
+ [winner, loser]
80
+ end
81
+
82
+ # INFO: Read more about the Elo rating system on https://en.wikipedia.org/wiki/Elo_rating_system
83
+ def calculate_new_elo(winner_elo, loser_elo)
84
+ expected_score_a = 1 / (1 + 10**((loser_elo - winner_elo) / 400))
85
+ expected_score_b = 1 - expected_score_a
86
+
87
+ new_winner_elo = [winner_elo + K * (1 - expected_score_a), max_defense_elo].min
88
+ new_loser_elo = [loser_elo + K * (1 - expected_score_b) - LOSE_PENALTY, min_relegation_elo].max
89
+
90
+ [new_winner_elo, new_loser_elo]
91
+ end
92
+
93
+ def max_defense_elo
94
+ @defender_tier.max_by(&:elo).elo
95
+ end
96
+
97
+ def min_relegation_elo
98
+ @relegation_tier.min_by(&:elo).elo
99
+ end
100
+
101
+ def log_context
102
+ { elo_round_id: }
103
+ end
104
+
105
+ def elo_round_id
106
+ relegation_tier_ids = @relegation_tier.map(&:id).join(',')
107
+ defender_tier_ids = @defender_tier.map(&:id).join(',')
108
+
109
+ ranking_unique_key = [relegation_tier_ids, defender_tier_ids, @criteria, @config.to_json].join('-')
110
+ Digest::MD5.hexdigest(ranking_unique_key)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,76 @@
1
+ require_relative '../battle/basic'
2
+
3
+ module ActiveGenie::Ranking
4
+ class FreeForAll
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(players, criteria, config: {})
10
+ @players = players
11
+ @criteria = criteria
12
+ @config = config
13
+ end
14
+
15
+ def call
16
+ ActiveGenie::Logger.with_context(log_context) do
17
+ matches.each do |player_a, player_b|
18
+ winner, loser = battle(player_a, player_b)
19
+
20
+ if winner.nil? || loser.nil?
21
+ player_a.draw!
22
+ player_b.draw!
23
+ else
24
+ winner.win!
25
+ loser.lose!
26
+ end
27
+ end
28
+
29
+ # TODO: add a freeForAll report. Duration, Elo changes, etc.
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # TODO: reduce the number of matches based on transitivity.
36
+ # For example, if A is better than B, and B is better than C, then battle between A and C should be auto win A
37
+ def matches
38
+ @players.eligible.combination(2).to_a
39
+ end
40
+
41
+ def battle(player_a, player_b)
42
+ result = ActiveGenie::Battle.basic(
43
+ player_a,
44
+ player_b,
45
+ @criteria,
46
+ config: @config
47
+ )
48
+
49
+ winner, loser = case result['winner']
50
+ when 'player_a' then [player_a, player_b, result['reasoning']]
51
+ when 'player_b' then [player_b, player_a, result['reasoning']]
52
+ when 'draw' then [nil, nil, result['reasoning']]
53
+ end
54
+
55
+ ActiveGenie::Logger.debug({
56
+ step: :free_for_all_battle,
57
+ player_ids: [player_a.id, player_b.id],
58
+ winner_id: winner&.id,
59
+ loser_id: loser&.id,
60
+ reasoning: result['reasoning']
61
+ })
62
+
63
+ [winner, loser]
64
+ end
65
+
66
+ def log_context
67
+ { free_for_all_id: }
68
+ end
69
+
70
+ def free_for_all_id
71
+ eligible_ids = @players.eligible.map(&:id).join(',')
72
+ ranking_unique_key = [eligible_ids, @criteria, @config.to_json].join('-')
73
+ Digest::MD5.hexdigest(ranking_unique_key)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,97 @@
1
+ require 'digest'
2
+
3
+ module ActiveGenie::Ranking
4
+ class Player
5
+ def initialize(params)
6
+ params = { content: params } if params.is_a?(String)
7
+
8
+ @content = params.dig(:content) || params
9
+ @id = params.dig(:id) || Digest::MD5.hexdigest(@content)
10
+ @score = params.dig(:score) || nil
11
+ @elo = params.dig(:elo) || nil
12
+ @ffa_win_count = params.dig(:ffa_win_count) || 0
13
+ @ffa_lose_count = params.dig(:ffa_lose_count) || 0
14
+ @ffa_draw_count = params.dig(:ffa_draw_count) || 0
15
+ @eliminated = params.dig(:eliminated) || nil
16
+ end
17
+
18
+ attr_reader :id, :content, :score, :elo,
19
+ :ffa_win_count, :ffa_lose_count, :ffa_draw_count,
20
+ :eliminated
21
+ attr_accessor :rank
22
+
23
+ def score=(value)
24
+ @score = value
25
+ ActiveGenie::Logger.debug({ step: :new_score, player_id: id, score: value })
26
+ end
27
+
28
+ def elo
29
+ generate_elo_by_score if @elo.nil?
30
+
31
+ @elo
32
+ end
33
+
34
+ def elo=(value)
35
+ @elo = value
36
+ ActiveGenie::Logger.debug({ step: :new_elo, player_id: id, elo: value })
37
+ end
38
+
39
+ def eliminated=(value)
40
+ @eliminated = value
41
+ ActiveGenie::Logger.debug({ step: :new_eliminated, player_id: id, eliminated: value })
42
+ end
43
+
44
+ def draw!
45
+ @ffa_draw_count += 1
46
+ ActiveGenie::Logger.debug({ step: :new_ffa_score, player_id: id, result: 'draw', ffa_score: })
47
+ end
48
+
49
+ def win!
50
+ @ffa_win_count += 1
51
+ ActiveGenie::Logger.debug({ step: :new_ffa_score, player_id: id, result: 'win', ffa_score: })
52
+ end
53
+
54
+ def lose!
55
+ @ffa_lose_count += 1
56
+ ActiveGenie::Logger.debug({ step: :new_ffa_score, player_id: id, result: 'lose', ffa_score: })
57
+ end
58
+
59
+ def ffa_score
60
+ @ffa_win_count * 3 + @ffa_draw_count
61
+ end
62
+
63
+ def to_h
64
+ {
65
+ id:, content:, score:, elo:,
66
+ ffa_win_count:, ffa_lose_count:, ffa_draw_count:,
67
+ eliminated:, ffa_score:
68
+ }
69
+ end
70
+
71
+ def method_missing(method_name, *args, &block)
72
+ if method_name == :[] && args.size == 1
73
+ attr_name = args.first.to_sym
74
+
75
+ if respond_to?(attr_name)
76
+ return send(attr_name)
77
+ else
78
+ return nil
79
+ end
80
+ end
81
+
82
+ super
83
+ end
84
+
85
+ def respond_to_missing?(method_name, include_private = false)
86
+ method_name == :[] || super
87
+ end
88
+
89
+ private
90
+
91
+ BASE_ELO = 1000
92
+
93
+ def generate_elo_by_score
94
+ @elo = BASE_ELO + ((@score || 0) - 50)
95
+ end
96
+ end
97
+ end
@@ -1,7 +1,6 @@
1
- require_relative '../utils/math'
2
1
  require_relative './player'
3
2
 
4
- module ActiveGenie::Leaderboard
3
+ module ActiveGenie::Ranking
5
4
  class PlayersCollection
6
5
  def initialize(param_players)
7
6
  @players = build(param_players)
@@ -9,9 +8,11 @@ module ActiveGenie::Leaderboard
9
8
  attr_reader :players
10
9
 
11
10
  def coefficient_of_variation
12
- score_list = eligible.map(&:score)
11
+ score_list = eligible.map(&:score).compact
12
+ return nil if score_list.empty?
13
+
13
14
  mean = score_list.sum.to_f / score_list.size
14
- return nil if mean == 0 # To avoid division by zero
15
+ return nil if mean == 0
15
16
 
16
17
  variance = score_list.map { |num| (num - mean) ** 2 }.sum / score_list.size
17
18
  standard_deviation = Math.sqrt(variance)
@@ -19,11 +20,11 @@ module ActiveGenie::Leaderboard
19
20
  (standard_deviation / mean) * 100
20
21
  end
21
22
 
22
- def tier_relegation
23
+ def calc_relegation_tier
23
24
  eligible[(tier_size*-1)..-1]
24
25
  end
25
26
 
26
- def tier_defense
27
+ def calc_defender_tier
27
28
  eligible[(tier_size*-2)...(tier_size*-1)]
28
29
  end
29
30
 
@@ -35,18 +36,24 @@ module ActiveGenie::Leaderboard
35
36
  @players.reject(&:eliminated).size
36
37
  end
37
38
 
39
+ def elo_eligible?
40
+ eligible.size > 15
41
+ end
42
+
43
+ def sorted
44
+ @players.sort_by { |p| [-p.ffa_score, -(p.elo || 0), -(p.score || 0)] }
45
+ @players.each_with_index { |p, i| p.rank = i + 1 }
46
+ @players
47
+ end
48
+
38
49
  def to_h
39
- sorted.map(&:to_h)
50
+ sorted.map { |p| p.to_h }
40
51
  end
41
52
 
42
53
  def method_missing(...)
43
54
  @players.send(...)
44
55
  end
45
56
 
46
- def sorted
47
- @players.sort_by { |p| [-p.league_score, -(p.elo || 0), -p.score] }
48
- end
49
-
50
57
  private
51
58
 
52
59
  def build(param_players)
@@ -0,0 +1,98 @@
1
+ require_relative './players_collection'
2
+ require_relative './free_for_all'
3
+ require_relative './elo_round'
4
+ require_relative './ranking_scoring'
5
+
6
+ # This class orchestrates player ranking through multiple evaluation stages
7
+ # using Elo ranking and free-for-all match simulations.
8
+ # 1. Sets initial scores
9
+ # 2. Eliminates low performers
10
+ # 3. Runs Elo ranking (for large groups)
11
+ # 4. Conducts free-for-all matches
12
+ #
13
+ # @example Basic usage
14
+ # Ranking.call(players, criteria)
15
+ #
16
+ # @param param_players [Array<Hash|String>] Collection of player objects to evaluate
17
+ # Example: ["Circle", "Triangle", "Square"]
18
+ # or
19
+ # [
20
+ # { content: "Circle", score: 10 },
21
+ # { content: "Triangle", score: 7 },
22
+ # { content: "Square", score: 5 }
23
+ # ]
24
+ # @param criteria [String] Evaluation criteria configuration
25
+ # Example: "What is more similar to the letter 'O'?"
26
+ # @param config [Hash] Additional configuration config
27
+ # Example: { model: "gpt-4o", api_key: ENV['OPENAI_API_KEY'] }
28
+ # @return [Hash] Final ranked player results
29
+ module ActiveGenie::Ranking
30
+ class Ranking
31
+ def self.call(...)
32
+ new(...).call
33
+ end
34
+
35
+ def initialize(param_players, criteria, reviewers: [], config: {})
36
+ @param_players = param_players
37
+ @criteria = criteria
38
+ @reviewers = Array(reviewers).compact.uniq
39
+ @config = ActiveGenie::Configuration.to_h(config)
40
+ @players = nil
41
+ end
42
+
43
+ def call
44
+ @players = PlayersCollection.new(@param_players)
45
+
46
+ ActiveGenie::Logger.with_context(log_context) do
47
+ set_initial_player_scores!
48
+ eliminate_obvious_bad_players!
49
+
50
+ while @players.elo_eligible?
51
+ run_elo_round!
52
+ eliminate_relegation_players!
53
+ end
54
+
55
+ run_free_for_all!
56
+ end
57
+
58
+ @players.sorted
59
+ end
60
+
61
+ private
62
+
63
+ SCORE_VARIATION_THRESHOLD = 10
64
+
65
+ def set_initial_player_scores!
66
+ RankingScoring.call(@players, @criteria, reviewers: @reviewers, config: @config)
67
+ end
68
+
69
+ def eliminate_obvious_bad_players!
70
+ while @players.coefficient_of_variation >= SCORE_VARIATION_THRESHOLD
71
+ @players.eligible.last.eliminated = 'variation_too_high'
72
+ end
73
+ end
74
+
75
+ def run_elo_round!
76
+ EloRound.call(@players, @criteria, config: @config)
77
+ end
78
+
79
+ def eliminate_relegation_players!
80
+ @players.calc_relegation_tier.each { |player| player.eliminated = 'relegation_tier' }
81
+ end
82
+
83
+ def run_free_for_all!
84
+ FreeForAll.call(@players, @criteria, config: @config)
85
+ end
86
+
87
+ def log_context
88
+ { config: @config[:log], ranking_id: }
89
+ end
90
+
91
+ def ranking_id
92
+ player_ids = @players.map(&:id).join(',')
93
+ ranking_unique_key = [player_ids, @criteria, @config.to_json].join('-')
94
+
95
+ Digest::MD5.hexdigest(ranking_unique_key)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,71 @@
1
+ require_relative '../scoring/recommended_reviewers'
2
+
3
+ module ActiveGenie::Ranking
4
+ class RankingScoring
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(players, criteria, reviewers: [], config: {})
10
+ @players = players
11
+ @criteria = criteria
12
+ @config = ActiveGenie::Configuration.to_h(config)
13
+ @reviewers = Array(reviewers).compact.uniq
14
+ end
15
+
16
+ def call
17
+ ActiveGenie::Logger.with_context(log_context) do
18
+ @reviewers = generate_reviewers
19
+
20
+ players_without_score.each do |player|
21
+ # TODO: This can take a while, can be parallelized
22
+ player.score = generate_score(player)
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def players_without_score
30
+ @players_without_score ||= @players.select { |player| player.score.nil? }
31
+ end
32
+
33
+ def generate_score(player)
34
+ score, reasoning = ActiveGenie::Scoring::Basic.call(
35
+ player.content,
36
+ @criteria,
37
+ @reviewers,
38
+ config: @config
39
+ ).values_at('final_score', 'final_reasoning')
40
+
41
+ ActiveGenie::Logger.debug({step: :new_score, player_id: player.id, score:, reasoning: })
42
+
43
+ score
44
+ end
45
+
46
+ def generate_reviewers
47
+ return @reviewers if @reviewers.size > 0
48
+
49
+ reviewer1, reviewer2, reviewer3 = ActiveGenie::Scoring::RecommendedReviewers.call(
50
+ [@players.sample.content, @players.sample.content].join("\n\n"),
51
+ @criteria,
52
+ config: @config
53
+ ).values_at('reviewer1', 'reviewer2', 'reviewer3')
54
+
55
+ ActiveGenie::Logger.debug({step: :new_reviewers, reviewers: [reviewer1, reviewer2, reviewer3] })
56
+
57
+ [reviewer1, reviewer2, reviewer3]
58
+ end
59
+
60
+ def log_context
61
+ { ranking_scoring_id: }
62
+ end
63
+
64
+ def ranking_scoring_id
65
+ player_ids = players_without_score.map(&:id).join(',')
66
+ ranking_unique_key = [player_ids, @criteria, @config.to_json].join('-')
67
+
68
+ Digest::MD5.hexdigest(ranking_unique_key)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,12 @@
1
+ require_relative 'ranking/ranking'
2
+
3
+ module ActiveGenie
4
+ # See the [ranking README](lib/active_genie/ranking/README.md) for more information.
5
+ module Ranking
6
+ module_function
7
+
8
+ def call(...)
9
+ Ranking.call(...)
10
+ end
11
+ end
12
+ end
@@ -58,7 +58,7 @@ Main interface for scoring text content.
58
58
  - `reviewers` [Array<String>] - Optional list of specific reviewers
59
59
  - `config` [Hash] - Additional configuration config
60
60
 
61
- ### `RecommendedReviews.call(text, criteria, config: {})`
61
+ ### `RecommendedReviewers.call(text, criteria, config: {})`
62
62
  Recommends appropriate reviewers based on content and criteria.
63
63
 
64
64
  #### Parameters