active_genie 0.29.1 → 0.30.1

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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/VERSION +1 -1
  4. data/lib/active_genie/{battle/generalist.json → comparator/debate.json} +2 -2
  5. data/lib/active_genie/{battle/generalist.prompt.md → comparator/debate.prompt.md} +1 -1
  6. data/lib/active_genie/{battle/generalist.rb → comparator/debate.rb} +20 -21
  7. data/lib/active_genie/{battle → comparator}/fight.rb +7 -7
  8. data/lib/active_genie/comparator.rb +24 -0
  9. data/lib/active_genie/{config/scoring_config.rb → configs/comparator_config.rb} +1 -1
  10. data/lib/active_genie/{config/data_extractor_config.rb → configs/extractor_config.rb} +1 -1
  11. data/lib/active_genie/{config/factory_config.rb → configs/lister_config.rb} +1 -1
  12. data/lib/active_genie/{config → configs}/llm_config.rb +7 -6
  13. data/lib/active_genie/{config → configs}/providers_config.rb +1 -1
  14. data/lib/active_genie/{config/ranking_config.rb → configs/ranker_config.rb} +1 -1
  15. data/lib/active_genie/{config/battle_config.rb → configs/scorer_config.rb} +1 -1
  16. data/lib/active_genie/configuration.rb +19 -19
  17. data/lib/active_genie/errors/invalid_provider_error.rb +1 -1
  18. data/lib/active_genie/{data_extractor/generalist.rb → extractor/explanation.rb} +11 -11
  19. data/lib/active_genie/{data_extractor/from_informal.rb → extractor/litote.rb} +8 -8
  20. data/lib/active_genie/extractor.rb +22 -0
  21. data/lib/active_genie/{factory → lister}/feud.rb +8 -8
  22. data/lib/active_genie/lister/juries.prompt.md +20 -0
  23. data/lib/active_genie/lister/juries.rb +82 -0
  24. data/lib/active_genie/{factory.rb → lister.rb} +7 -6
  25. data/lib/active_genie/{clients/providers/anthropic_client.rb → providers/anthropic_provider.rb} +4 -4
  26. data/lib/active_genie/{clients/providers/base_client.rb → providers/base_provider.rb} +14 -7
  27. data/lib/active_genie/{clients/providers/deepseek_client.rb → providers/deepseek_provider.rb} +3 -3
  28. data/lib/active_genie/{clients/providers/google_client.rb → providers/google_provider.rb} +6 -6
  29. data/lib/active_genie/{clients/providers/openai_client.rb → providers/openai_provider.rb} +3 -3
  30. data/lib/active_genie/providers/unified_provider.rb +44 -0
  31. data/lib/active_genie/{ranking/elo_round.rb → ranker/elo.rb} +37 -36
  32. data/lib/active_genie/ranker/entities/player.rb +124 -0
  33. data/lib/active_genie/ranker/entities/players.rb +102 -0
  34. data/lib/active_genie/{ranking → ranker}/free_for_all.rb +9 -9
  35. data/lib/active_genie/ranker/scoring.rb +68 -0
  36. data/lib/active_genie/{ranking/ranking.rb → ranker/tournament.rb} +22 -31
  37. data/lib/active_genie/ranker.rb +32 -0
  38. data/lib/active_genie/scorer/jury_bench.rb +121 -0
  39. data/lib/active_genie/scorer.rb +17 -0
  40. data/lib/active_genie/utils/fiber_by_batch.rb +21 -0
  41. data/lib/active_genie.rb +9 -9
  42. data/lib/tasks/test.rake +4 -0
  43. metadata +67 -52
  44. data/lib/active_genie/battle.rb +0 -31
  45. data/lib/active_genie/clients/unified_client.rb +0 -50
  46. data/lib/active_genie/data_extractor.rb +0 -23
  47. data/lib/active_genie/ranking/player.rb +0 -122
  48. data/lib/active_genie/ranking/players_collection.rb +0 -95
  49. data/lib/active_genie/ranking/ranking_scoring.rb +0 -69
  50. data/lib/active_genie/ranking.rb +0 -14
  51. data/lib/active_genie/scoring/generalist.json +0 -9
  52. data/lib/active_genie/scoring/generalist.rb +0 -119
  53. data/lib/active_genie/scoring/recommended_reviewers.rb +0 -87
  54. data/lib/active_genie/scoring.rb +0 -23
  55. /data/lib/active_genie/{battle → comparator}/fight.json +0 -0
  56. /data/lib/active_genie/{battle → comparator}/fight.prompt.md +0 -0
  57. /data/lib/active_genie/{config → configs}/log_config.rb +0 -0
  58. /data/lib/active_genie/{config → configs}/providers/anthropic_config.rb +0 -0
  59. /data/lib/active_genie/{config → configs}/providers/deepseek_config.rb +0 -0
  60. /data/lib/active_genie/{config → configs}/providers/google_config.rb +0 -0
  61. /data/lib/active_genie/{config → configs}/providers/openai_config.rb +0 -0
  62. /data/lib/active_genie/{config → configs}/providers/provider_base.rb +0 -0
  63. /data/lib/active_genie/{data_extractor/generalist.json → extractor/explanation.json} +0 -0
  64. /data/lib/active_genie/{data_extractor/generalist.prompt.md → extractor/explanation.prompt.md} +0 -0
  65. /data/lib/active_genie/{data_extractor/from_informal.json → extractor/litote.json} +0 -0
  66. /data/lib/active_genie/{factory → lister}/feud.json +0 -0
  67. /data/lib/active_genie/{factory → lister}/feud.prompt.md +0 -0
  68. /data/lib/active_genie/{scoring/generalist.prompt.md → scorer/jury_bench.prompt.md} +0 -0
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../providers/unified_provider'
4
+
5
+ module ActiveGenie
6
+ module Lister
7
+ # The Juries class intelligently suggests appropriate jury roles
8
+ # for evaluating text content based on specific criteria. It uses AI to analyze
9
+ # the content and criteria to identify the most suitable subject matter experts.
10
+ #
11
+ # The class ensures a balanced and comprehensive review process by recommending
12
+ # three distinct jury roles with complementary expertise and perspectives.
13
+ #
14
+ # @example Getting jury for technical content
15
+ # Juries.call("Technical documentation about API design",
16
+ # "Evaluate technical accuracy and clarity")
17
+ # # => { jury1: "API Architect", jury2: "Technical Writer",
18
+ # # jury3: "Developer Advocate", reasoning: "..." }
19
+ #
20
+ class Juries
21
+ def self.call(...)
22
+ new(...).call
23
+ end
24
+
25
+ # Initializes a new jury recommendation instance
26
+ #
27
+ # @param text [String] The text content to analyze for jury recommendations
28
+ # @param criteria [String] The evaluation criteria that will guide jury selection
29
+ # @param config [Hash] Additional configuration config that modify the recommendation process
30
+ def initialize(text, criteria, config: {})
31
+ @text = text
32
+ @criteria = criteria
33
+ @config = ActiveGenie.configuration.merge(config)
34
+ end
35
+
36
+ def call
37
+ messages = [
38
+ { role: 'system', content: prompt },
39
+ { role: 'user', content: "<criteria> #{@criteria}</criteria>" },
40
+ { role: 'user', content: "<text-to-score>#{@text}</text-to-score>" }
41
+ ]
42
+
43
+ function = {
44
+ name: 'identify_jury',
45
+ description: 'Discover a list of juries based on the text and given criteria.',
46
+ parameters: {
47
+ type: 'object',
48
+ properties: {
49
+ reasoning: { type: 'string' },
50
+ juries: {
51
+ type: 'array',
52
+ description: 'The list of best juries',
53
+ items: {
54
+ type: 'string'
55
+ }
56
+ }
57
+ },
58
+ required: %w[reasoning juries]
59
+ }
60
+ }
61
+
62
+ result = client.function_calling(
63
+ messages,
64
+ function,
65
+ config: @config
66
+ )
67
+
68
+ result['juries'] || []
69
+ end
70
+
71
+ private
72
+
73
+ def client
74
+ ::ActiveGenie::Providers::UnifiedProvider
75
+ end
76
+
77
+ def prompt
78
+ @prompt ||= File.read(File.join(__dir__, 'juries.prompt.md'))
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,21 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'factory/feud'
3
+ require_relative 'lister/feud'
4
+ require_relative 'lister/juries'
4
5
 
5
6
  module ActiveGenie
6
- module Factory
7
+ module Lister
7
8
  module_function
8
9
 
9
- def feud(...)
10
+ def call(...)
10
11
  Feud.call(...)
11
12
  end
12
13
 
13
- def list(...)
14
+ def with_feud(...)
14
15
  Feud.call(...)
15
16
  end
16
17
 
17
- def call(...)
18
- Feud.call(...)
18
+ def with_juries(...)
19
+ Juries.call(...)
19
20
  end
20
21
  end
21
22
  end
@@ -3,12 +3,12 @@
3
3
  require 'json'
4
4
  require 'net/http'
5
5
  require 'uri'
6
- require_relative 'base_client'
6
+ require_relative 'base_provider'
7
7
 
8
8
  module ActiveGenie
9
- module Clients
10
- # Client for interacting with the Anthropic (Claude) API with json response
11
- class AnthropicClient < BaseClient
9
+ module Providers
10
+ # Provider for interacting with the Anthropic (Claude) API with json response
11
+ class AnthropicProvider < BaseProvider
12
12
  # Requests structured JSON output from the Anthropic Claude model based on a schema.
13
13
  #
14
14
  # @param messages [Array<Hash>] A list of messages representing the conversation history.
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'net/http'
4
+
3
5
  module ActiveGenie
4
- module Clients
5
- class BaseClient
6
- class ClientError < StandardError; end
6
+ module Providers
7
+ class BaseProvider
8
+ class ProviderUnknownError < StandardError; end
9
+ class ProviderServerError < StandardError; end
7
10
 
8
11
  DEFAULT_HEADERS = {
9
12
  'Content-Type': 'application/json',
@@ -81,7 +84,7 @@ module ActiveGenie
81
84
 
82
85
  response = http_request(request, uri)
83
86
 
84
- raise ClientError, "Unexpected response: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess)
87
+ raise ProviderUnknownError, "Unexpected response: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess)
85
88
 
86
89
  parsed_response = parse_response(response)
87
90
 
@@ -143,7 +146,7 @@ module ActiveGenie
143
146
  begin
144
147
  JSON.parse(response.body)
145
148
  rescue JSON::ParserError => e
146
- raise ClientError, "Failed to parse JSON response: #{e.message}"
149
+ raise ProviderUnknownError, "Failed to parse JSON response: #{e.message}"
147
150
  end
148
151
  end
149
152
 
@@ -169,8 +172,12 @@ module ActiveGenie
169
172
  retries = 0
170
173
 
171
174
  begin
172
- yield
173
- rescue Net::OpenTimeout, Net::ReadTimeout, ClientError => e
175
+ response = yield
176
+
177
+ raise ProviderServerError, "Provider server error: #{response.code} - #{response.body}" if !response.is_a?(Net::HTTPSuccess) && response.code.to_i >= 500
178
+
179
+ response
180
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, ProviderServerError => e
174
181
  raise if retries > max_retries
175
182
 
176
183
  sleep_time = retry_delay * (2**retries)
@@ -3,11 +3,11 @@
3
3
  require 'json'
4
4
  require 'net/http'
5
5
 
6
- require_relative 'base_client'
6
+ require_relative 'base_provider'
7
7
 
8
8
  module ActiveGenie
9
- module Clients
10
- class DeepseekClient < BaseClient
9
+ module Providers
10
+ class DeepseekProvider < BaseProvider
11
11
  class InvalidResponseError < StandardError; end
12
12
 
13
13
  # Requests structured JSON output from the Deepseek model based on a schema.
@@ -3,12 +3,12 @@
3
3
  require 'json'
4
4
  require 'net/http'
5
5
  require 'uri'
6
- require_relative 'base_client'
6
+ require_relative 'base_provider'
7
7
 
8
8
  module ActiveGenie
9
- module Clients
10
- # Client for interacting with the Google Generative Language API.
11
- class GoogleClient < BaseClient
9
+ module Providers
10
+ # Provider for interacting with the Google Generative Language API.
11
+ class GoogleProvider < BaseProvider
12
12
  # Requests structured JSON output from the Google Generative Language model based on a schema.
13
13
  #
14
14
  # @param messages [Array<Hash>] A list of messages representing the conversation history.
@@ -94,9 +94,9 @@ module ActiveGenie
94
94
  json_instruction = <<~PROMPT
95
95
  Generate a JSON object that strictly adheres to the following JSON schema:
96
96
 
97
- ```json
97
+ <json_schema>
98
98
  #{JSON.pretty_generate(function_schema[:parameters])}
99
- ```
99
+ </json_schema>
100
100
 
101
101
  IMPORTANT: Only output the raw JSON object. Do not include any other text, explanations, or markdown formatting like ```json ... ``` wrappers around the final output.
102
102
  PROMPT
@@ -3,11 +3,11 @@
3
3
  require 'json'
4
4
  require 'net/http'
5
5
 
6
- require_relative 'base_client'
6
+ require_relative 'base_provider'
7
7
 
8
8
  module ActiveGenie
9
- module Clients
10
- class OpenaiClient < BaseClient
9
+ module Providers
10
+ class OpenaiProvider < BaseProvider
11
11
  class InvalidResponseError < StandardError; end
12
12
 
13
13
  # Requests structured JSON output from the OpenAI model based on a schema.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'openai_provider'
4
+ require_relative 'anthropic_provider'
5
+ require_relative 'google_provider'
6
+ require_relative 'deepseek_provider'
7
+ require_relative '../errors/invalid_provider_error'
8
+
9
+ module ActiveGenie
10
+ module Providers
11
+ class UnifiedProvider
12
+ class << self
13
+ PROVIDER_NAME_TO_PROVIDER = {
14
+ openai: OpenaiProvider,
15
+ anthropic: AnthropicProvider,
16
+ google: GoogleProvider,
17
+ deepseek: DeepseekProvider
18
+ }.freeze
19
+
20
+ def function_calling(messages, function, config: {})
21
+ provider_name = config.llm.provider_name || config.providers.default
22
+ provider = PROVIDER_NAME_TO_PROVIDER[provider_name.to_sym]
23
+
24
+ raise ActiveGenie::InvalidProviderError, provider_name if provider.nil?
25
+
26
+ response = provider.new(config).function_calling(messages, function)
27
+
28
+ normalize_response(response)
29
+ end
30
+
31
+ private
32
+
33
+ def normalize_response(response)
34
+ response.each do |key, value|
35
+ response[key] = nil if ['null', 'none', 'undefined', '', 'unknown',
36
+ '<unknown>'].include?(value.to_s.strip.downcase)
37
+ end
38
+
39
+ response
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,38 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveGenie
4
- module Ranking
5
- class EloRound
4
+ module Ranker
5
+ class Elo
6
6
  def self.call(...)
7
7
  new(...).call
8
8
  end
9
9
 
10
10
  def initialize(players, criteria, config: nil)
11
11
  @players = players
12
- @relegation_tier = players.calc_relegation_tier
13
- @defender_tier = players.calc_defender_tier
12
+ @higher_tier = players.calc_higher_tier
13
+ @lower_tier = players.calc_lower_tier
14
14
  @criteria = criteria
15
15
  @config = ActiveGenie.configuration.merge(config)
16
- @tmp_defenders = []
16
+ @tmp_highers = []
17
17
  @total_tokens = 0
18
18
  @previous_elo = @players.to_h { |player| [player.id, player.elo] }
19
- @previous_highest_elo = @defender_tier.max_by(&:elo).elo
19
+ @previous_highest_elo = @higher_tier.max_by(&:elo).elo
20
20
  end
21
21
 
22
22
  def call
23
23
  @config.log.add_observer(observers: ->(log) { log_observer(log) })
24
- @config.log.additional_context = { elo_round_id: }
24
+ @config.log.additional_context = { elo_id: }
25
+
26
+ ActiveGenie::FiberByBatch.call(matches, config: @config) do |player_a, player_b|
27
+ winner, loser = debate(player_a, player_b)
25
28
 
26
- matches.each do |player_a, player_b|
27
- # TODO: battle can take a while, can be parallelized
28
- winner, loser = battle(player_a, player_b)
29
29
  update_players_elo(winner, loser)
30
30
  end
31
31
 
32
32
  build_report
33
33
  end
34
34
 
35
- BATTLE_PER_PLAYER = 3
35
+ DEBATE_PER_PLAYER = 3
36
36
  K = 32
37
37
 
38
38
  private
@@ -40,26 +40,26 @@ module ActiveGenie
40
40
  def matches
41
41
  match_keys = {}
42
42
 
43
- @relegation_tier.each_with_object([]) do |attack_player, matches|
44
- BATTLE_PER_PLAYER.times do
45
- defense_player = next_defense_player
43
+ @higher_tier.each_with_object([]) do |attack_player, matches|
44
+ DEBATE_PER_PLAYER.times do
45
+ higher_player = next_higher_player
46
46
 
47
- next if match_keys["#{attack_player.id}_#{defense_player.id}"]
47
+ next if match_keys["#{attack_player.id}_#{higher_player.id}"]
48
48
 
49
- match_keys["#{attack_player.id}_#{defense_player.id}"] = true
50
- matches << [attack_player, defense_player]
49
+ match_keys["#{attack_player.id}_#{higher_player.id}"] = true
50
+ matches << [attack_player, higher_player]
51
51
  end
52
52
  end
53
53
  end
54
54
 
55
- def next_defense_player
56
- @tmp_defenders = @defender_tier.shuffle if @tmp_defenders.empty?
55
+ def next_higher_player
56
+ @tmp_highers = @higher_tier.shuffle if @tmp_highers.empty?
57
57
 
58
- @tmp_defenders.pop
58
+ @tmp_highers.pop
59
59
  end
60
60
 
61
- def battle(player_a, player_b)
62
- result = ActiveGenie::Battle.call(
61
+ def debate(player_a, player_b)
62
+ result = ActiveGenie::Comparator.by_debate(
63
63
  player_a.content,
64
64
  player_b.content,
65
65
  @criteria,
@@ -89,43 +89,44 @@ module ActiveGenie
89
89
  player_rating + (K * (score - expected_score)).round
90
90
  end
91
91
 
92
- def elo_round_id
93
- @elo_round_id ||= begin
94
- relegation_tier_ids = @relegation_tier.map(&:id).join(',')
95
- defender_tier_ids = @defender_tier.map(&:id).join(',')
92
+ def elo_id
93
+ @elo_id ||= begin
94
+ higher_tier_ids = @higher_tier.map(&:id).join(',')
95
+ lower_tier_ids = @lower_tier.map(&:id).join(',')
96
96
 
97
- ranking_unique_key = [relegation_tier_ids, defender_tier_ids, @criteria, @config.to_json].join('-')
98
- Digest::MD5.hexdigest(ranking_unique_key)
97
+ ranker_unique_key = [higher_tier_ids, lower_tier_ids, @criteria, @config.to_json].join('-')
98
+ Digest::MD5.hexdigest(ranker_unique_key)
99
99
  end
100
100
  end
101
101
 
102
102
  def build_report
103
103
  report = {
104
- elo_round_id:,
105
- players_in_round: players_in_round.map(&:id),
106
- battles_count: matches.size,
104
+ elo_id:,
105
+ players_in: players_in.map(&:id),
106
+ debates_count: matches.size,
107
107
  total_tokens: @total_tokens,
108
+ players_in_round: players_in.map(&:id),
108
109
  previous_highest_elo: @previous_highest_elo,
109
110
  highest_elo:,
110
111
  highest_elo_diff: highest_elo - @previous_highest_elo,
111
112
  players_elo_diff:
112
113
  }
113
114
 
114
- @config.logger.call({ elo_round_id:, code: :elo_round_report, **report })
115
+ @config.logger.call({ elo_id:, code: :elo_report, **report })
115
116
 
116
117
  report
117
118
  end
118
119
 
119
- def players_in_round
120
- @defender_tier + @relegation_tier
120
+ def players_in
121
+ @lower_tier + @higher_tier
121
122
  end
122
123
 
123
124
  def highest_elo
124
- players_in_round.max_by(&:elo).elo
125
+ players_in.max_by(&:elo).elo
125
126
  end
126
127
 
127
128
  def players_elo_diff
128
- elo_diffs = players_in_round.map do |player|
129
+ elo_diffs = players_in.map do |player|
129
130
  [player.id, player.elo - @previous_elo[player.id]]
130
131
  end
131
132
  elo_diffs.sort_by { |_, diff| -(diff || 0) }.to_h
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module ActiveGenie
6
+ module Ranker
7
+ module Entities
8
+ class Player
9
+ def initialize(params)
10
+ @params = params.is_a?(String) ? { content: params } : params.dup
11
+ @params[:content] ||= @params
12
+ end
13
+
14
+ def content
15
+ @content ||= @params[:content]
16
+ end
17
+
18
+ def name
19
+ @name ||= @params[:name] || content[0..10]
20
+ end
21
+
22
+ def id
23
+ @id ||= @params[:id] || Digest::MD5.hexdigest(content.to_s)
24
+ end
25
+
26
+ def score
27
+ @score ||= @params[:score]
28
+ end
29
+
30
+ def elo
31
+ @elo = @elo || @params[:elo] || generate_elo_by_score
32
+ end
33
+
34
+ def ffa_win_count
35
+ @ffa_win_count ||= @params[:ffa_win_count] || 0
36
+ end
37
+
38
+ def ffa_lose_count
39
+ @ffa_lose_count ||= @params[:ffa_lose_count] || 0
40
+ end
41
+
42
+ def ffa_draw_count
43
+ @ffa_draw_count ||= @params[:ffa_draw_count] || 0
44
+ end
45
+
46
+ def eliminated
47
+ @eliminated ||= @params[:eliminated]
48
+ end
49
+
50
+ def score=(value)
51
+ @score = value
52
+ @elo = generate_elo_by_score
53
+ end
54
+
55
+ def elo=(value)
56
+ @elo = value || BASE_ELO
57
+ end
58
+
59
+ attr_writer :eliminated
60
+
61
+ def draw!
62
+ @ffa_draw_count = ffa_draw_count + 1
63
+ end
64
+
65
+ def win!
66
+ @ffa_win_count = ffa_win_count + 1
67
+ end
68
+
69
+ def lose!
70
+ @ffa_lose_count = ffa_lose_count + 1
71
+ end
72
+
73
+ def ffa_score
74
+ (ffa_win_count * 3) + ffa_draw_count
75
+ end
76
+
77
+ def sort_value
78
+ (ffa_score * 1_000_000) + ((elo || 0) * 100) + (score || 0)
79
+ end
80
+
81
+ def to_json(*_args)
82
+ to_h.to_json
83
+ end
84
+
85
+ def to_h
86
+ {
87
+ id:, name:, content:,
88
+
89
+ score:, elo:,
90
+ ffa_win_count:, ffa_lose_count:, ffa_draw_count:,
91
+ eliminated:, ffa_score:, sort_value:
92
+ }
93
+ end
94
+
95
+ def method_missing(method_name, *args, &)
96
+ if method_name == :[] && args.size == 1
97
+ attr_name = args.first.to_sym
98
+
99
+ return send(attr_name) if respond_to?(attr_name)
100
+
101
+ return nil
102
+
103
+ end
104
+
105
+ super
106
+ end
107
+
108
+ def respond_to_missing?(method_name, include_private = false)
109
+ method_name == :[] || super
110
+ end
111
+
112
+ BASE_ELO = 1000
113
+
114
+ private
115
+
116
+ def generate_elo_by_score
117
+ return BASE_ELO if @score.nil?
118
+
119
+ BASE_ELO + (@score - 50)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'player'
4
+
5
+ module ActiveGenie
6
+ module Ranker
7
+ module Entities
8
+ class Players
9
+ def initialize(players)
10
+ @players = if players.is_a?(Players)
11
+ players.players
12
+ else
13
+ build(players)
14
+ end
15
+ end
16
+
17
+ attr_reader :players
18
+
19
+ def coefficient_of_variation
20
+ mean = score_mean
21
+
22
+ return 0 if mean.zero?
23
+
24
+ variance = all_scores.map { |num| (num - mean)**2 }.sum / all_scores.size
25
+ standard_deviation = Math.sqrt(variance)
26
+
27
+ (standard_deviation / mean) * 100
28
+ end
29
+
30
+ def all_scores
31
+ eligible.map(&:score).compact
32
+ end
33
+
34
+ def score_mean
35
+ return 0 if all_scores.empty?
36
+
37
+ all_scores.sum.to_f / all_scores.size
38
+ end
39
+
40
+ def calc_higher_tier
41
+ eligible[(tier_size * -1)..]
42
+ end
43
+
44
+ def calc_lower_tier
45
+ eligible[(tier_size * -2)...(tier_size * -1)]
46
+ end
47
+
48
+ def eligible
49
+ sorted.reject(&:eliminated)
50
+ end
51
+
52
+ def eligible_size
53
+ @players.reject(&:eliminated).size
54
+ end
55
+
56
+ def elo_eligible?
57
+ eligible.size > 15
58
+ end
59
+
60
+ def sorted
61
+ @players.sort_by { |p| -p.sort_value }
62
+ end
63
+
64
+ def to_json(*_args)
65
+ @players.map(&:to_h).to_json
66
+ end
67
+
68
+ def method_missing(...)
69
+ @players.send(...)
70
+ end
71
+
72
+ def respond_to_missing?(method_name, include_private = false)
73
+ @players.respond_to?(method_name, include_private)
74
+ end
75
+
76
+ private
77
+
78
+ def build(param_players)
79
+ param_players.map { |p| Player.new(p) }
80
+ end
81
+
82
+ # Returns the number of players to debate in each round
83
+ # based on the eligible size, start fast and go slow until top 10
84
+ # Example:
85
+ # - 50 eligible, tier_size: 15
86
+ # - 35 eligible, tier_size: 11
87
+ # - 24 eligible, tier_size: 10
88
+ # - 14 eligible, tier_size: 4
89
+ # 4 rounds to reach top 10 with 50 players
90
+ def tier_size
91
+ size = (eligible_size / 3).ceil
92
+
93
+ if eligible_size < 10
94
+ (eligible_size / 2).ceil
95
+ else
96
+ size.clamp(10, eligible_size - 10)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end