weighted_list_rank 0.6.0 → 0.7.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34f4b87da4046f63fd0771a985c63fab24e30dc51445ce9ceabcce4bde5b3f45
4
- data.tar.gz: d857a6cb9a7e9114d2157a05a373876f08c52661130cf50126fa6258b4beaa5b
3
+ metadata.gz: 0e8758848cc7ed9090ee9348afeb2efd6fe7d27506b00a2dd997ffce53959615
4
+ data.tar.gz: ff68f02846e841ac26fb3863d50b1309543c2d1e8368024ff01e7de44041cb23
5
5
  SHA512:
6
- metadata.gz: ea9c40e27791fafe638694e34a440c4df6e8e8c20770b2f9b1da6ca8e8d37d755ae5eaaeeb89fe2ca43b569805b9fde43c1b2616eb0a135bbdea37b02ae5bf4a
7
- data.tar.gz: 7ad51b9288339b0728841a30d91ee9b0b5824f101801a090e7533a93f090a40b8ef0ea8143fea32c533fc333e0a96037ee3cba684efa9eaa8dfc9388edc20889
6
+ metadata.gz: bf99a1e72f8dc738541b1002ef1ddb9d590a198ac957aa59e2ee570b4dcf82d9702f78291459ad2d9f98250482a7b421809e3a0aa557f445454b0545c914e43d
7
+ data.tar.gz: d9f13250eac45ebe3466c8020a16a5f290fa693174d574d64e35f17010cf0624993c0d575d53d319b7b21510a9a8e022af625b4bdedf39f9c7b64d4952c77e7c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.7.0] - 2026-08-23
2
+ ### Performance
3
+ - Ranking a list is now linear in the number of items instead of quadratic. `Exponential#calculate_score` recomputed the ranked-item count and the total exponential factor for every item, even though both are constant across a list. They are now computed once per list.
4
+ - Measured on synthetic data shaped like a real catalogue: 700 lists / ~19k entries went from 226ms to 13ms (17x). The gain grows with the size of your longest list — a single 1,600-item list went from 217ms to 1.4ms (158x).
5
+ - Ranking output is unchanged. Scores, `score_details`, and ordering are bit-for-bit identical to 0.6.0, verified across four data profiles and a 3,000-case edge-case matrix.
6
+
7
+ ### Added
8
+ - `Strategy#calculate_scores(list)`, which returns an array of scores positionally matching `list.items`. This is now the entry point `RankingContext` uses. The default implementation delegates to `calculate_score` for each item, so existing custom strategies keep working unchanged. Strategies with per-list invariants should override it — see "Writing a Custom Strategy" in the README.
9
+ - `warn_on_invalid_position` option on the `Exponential` strategy, defaulting to `false`.
10
+
11
+ ### Changed
12
+ - Items whose position exceeds the number of items in their list are still clamped to the last position, but this is no longer reported by default. Previously every such item printed a warning to `stdout`, which was noisy inside an application. Enable `warn_on_invalid_position: true` to get a single `stderr` warning per affected list.
13
+ - Updated all development dependencies to their latest versions.
14
+
1
15
  ## [0.6.0] - 2025-06-29
2
16
  - Added `list_count_penalties` feature to `RankingContext` to allow penalizing items based on the number of lists they appear on. This is useful for de-emphasizing items that are not widely represented across your data.
3
17
  - The feature accepts a hash mapping list counts to penalty percentages (e.g., `{1 => 0.50, 2 => 0.25}` to penalize items on 1 list by 50% and items on 2 lists by 25%).
data/Gemfile.lock CHANGED
@@ -1,24 +1,24 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- weighted_list_rank (0.5.3)
4
+ weighted_list_rank (0.7.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
9
  ast (2.4.3)
10
10
  json (2.12.2)
11
- language_server-protocol (3.17.0.5)
11
+ language_server-protocol (3.17.0.6)
12
12
  lint_roller (1.1.0)
13
- minitest (5.25.5)
13
+ minitest (5.27.0)
14
14
  parallel (1.27.0)
15
- parser (3.3.8.0)
15
+ parser (3.3.12.0)
16
16
  ast (~> 2.4.1)
17
17
  racc
18
18
  prism (1.4.0)
19
19
  racc (1.8.1)
20
20
  rainbow (3.1.1)
21
- rake (13.3.0)
21
+ rake (13.4.2)
22
22
  regexp_parser (2.10.0)
23
23
  rubocop (1.75.8)
24
24
  json (~> 2.3)
@@ -34,7 +34,7 @@ GEM
34
34
  rubocop-ast (1.45.1)
35
35
  parser (>= 3.3.7.2)
36
36
  prism (~> 1.4)
37
- rubocop-minitest (0.38.1)
37
+ rubocop-minitest (0.40.0)
38
38
  lint_roller (~> 1.1)
39
39
  rubocop (>= 1.75.0, < 2.0)
40
40
  rubocop-ast (>= 1.38.0, < 2.0)
@@ -58,9 +58,9 @@ GEM
58
58
  standard-performance (1.8.0)
59
59
  lint_roller (~> 1.1)
60
60
  rubocop-performance (~> 1.25.0)
61
- unicode-display_width (3.1.4)
62
- unicode-emoji (~> 4.0, >= 4.0.4)
63
- unicode-emoji (4.0.4)
61
+ unicode-display_width (3.2.0)
62
+ unicode-emoji (~> 4.1)
63
+ unicode-emoji (4.2.0)
64
64
 
65
65
  PLATFORMS
66
66
  arm64-darwin-24
data/README.md CHANGED
@@ -229,6 +229,55 @@ ranked_items = ranking_context.rank([list1, list2, list3])
229
229
  - This feature is fully backwards compatible: if you do not provide `list_count_penalties`, no penalties are applied.
230
230
  - This penalty stacks with individual item penalties (e.g., `score_penalty` on an item).
231
231
 
232
+ ### Handling Invalid Positions
233
+ If a list contains an item whose `position` is greater than the number of items in that list, the position is clamped to the last position. This is silent by default, so the gem stays quiet inside an application.
234
+
235
+ While investigating your data you can opt into a warning. It is written to `stderr`, and at most one is emitted per affected list no matter how many items are involved:
236
+
237
+ ```ruby
238
+ strategy = WeightedListRank::Strategies::Exponential.new(warn_on_invalid_position: true)
239
+ ```
240
+
241
+ ```
242
+ WeightedListRank: list 42 has 3 item(s) with a position greater than the number of items in the list (25); clamping to 25.
243
+ ```
244
+
245
+ ### Writing a Custom Strategy
246
+ A strategy needs to implement `calculate_score(list, item)`:
247
+
248
+ ```ruby
249
+ class MyStrategy < WeightedListRank::Strategy
250
+ def calculate_score(list, item)
251
+ list.weight * (1.0 / item.position)
252
+ end
253
+ end
254
+ ```
255
+
256
+ `RankingContext` scores a list through `calculate_scores(list)`, which returns an array of scores positionally matching `list.items`. The default implementation calls `calculate_score` once per item, so implementing only the single-item method is enough to get a working strategy.
257
+
258
+ If your scoring depends on values that are constant across the list — a total, a count, a normalizing factor — override `calculate_scores` and compute them once. Recomputing per-list values inside `calculate_score` makes ranking quadratic in list length, which is what the built-in `Exponential` strategy used to do:
259
+
260
+ ```ruby
261
+ class MyStrategy < WeightedListRank::Strategy
262
+ def calculate_scores(list)
263
+ items = list.items
264
+ total = items.sum { |i| i.position || 0 } # computed once for the whole list
265
+
266
+ items.map { |item| score_for(list, item, total) }
267
+ end
268
+
269
+ def calculate_score(list, item)
270
+ score_for(list, item, list.items.sum { |i| i.position || 0 })
271
+ end
272
+
273
+ private
274
+
275
+ def score_for(list, item, total)
276
+ list.weight * (item.position.to_f / total)
277
+ end
278
+ end
279
+ ```
280
+
232
281
  ## Development
233
282
 
234
283
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -19,40 +19,50 @@ module WeightedListRank
19
19
  # @return [Array<Hash>] a sorted array of item scores, with each item's details including ID, score details, and total score.
20
20
  def rank(lists)
21
21
  items = {}
22
+
23
+ # Strategies subclassing Strategy always have this; the fallback covers
24
+ # duck-typed strategies that only define #calculate_score.
25
+ batch_capable = strategy.respond_to?(:calculate_scores)
26
+
22
27
  lists.each do |list|
23
- list.items.each do |item|
24
- score = strategy.calculate_score(list, item)
25
- items[item.id] ||= {}
26
- # Ensure the list_details array exists, then append the new score detail
27
- items[item.id][:list_details] ||= []
28
- items[item.id][:list_details] << {list_id: list.id, score: score, weight: list.weight, score_penalty: item.score_penalty}
29
-
30
- # Ensure the total_score is initialized, then add the score
31
- items[item.id][:total_score] ||= 0
32
- items[item.id][:total_score] += score
28
+ list_items = list.items
29
+ scores = if batch_capable
30
+ strategy.calculate_scores(list)
31
+ else
32
+ list_items.map { |item| strategy.calculate_score(list, item) }
33
+ end
34
+
35
+ list_id = list.id
36
+ weight = list.weight
37
+
38
+ list_items.each_with_index do |item, index|
39
+ score = scores[index]
40
+ details = (items[item.id] ||= {list_details: [], total_score: 0})
41
+ details[:list_details] << {list_id: list_id, score: score, weight: weight, score_penalty: item.score_penalty}
42
+ details[:total_score] += score
33
43
  end
34
44
  end
35
45
 
46
+ apply_penalties = !list_count_penalties.empty?
47
+
36
48
  # Convert hash to a formatted array
37
49
  formatted_items = items.map do |id, details|
50
+ list_details = details[:list_details]
51
+ total_score = details[:total_score]
52
+
53
+ # Apply list count penalties if configured
54
+ if apply_penalties && (penalty = list_count_penalties[list_details.length])
55
+ total_score *= (1 - penalty)
56
+ end
57
+
38
58
  {
39
59
  id: id,
40
60
  # Sort the score_details array by score in descending order before including it
41
- score_details: details[:list_details].sort_by { |detail| -detail[:score] },
42
- total_score: details[:total_score]
61
+ score_details: list_details.sort_by { |detail| -detail[:score] },
62
+ total_score: total_score
43
63
  }
44
64
  end
45
65
 
46
- # Apply list count penalties if configured
47
- if !list_count_penalties.empty?
48
- formatted_items.each do |item|
49
- list_count = item[:score_details].length
50
- if (penalty = list_count_penalties[list_count])
51
- item[:total_score] *= (1 - penalty)
52
- end
53
- end
54
- end
55
-
56
66
  # Sort the array by total_score in descending order
57
67
  formatted_items.sort_by { |item| -item[:total_score] }
58
68
  end
@@ -1,7 +1,8 @@
1
1
  module WeightedListRank
2
2
  module Strategies
3
3
  class Exponential < WeightedListRank::Strategy
4
- attr_reader :exponent, :bonus_pool_percentage, :average_list_length, :include_unranked_items
4
+ attr_reader :exponent, :bonus_pool_percentage, :average_list_length, :include_unranked_items,
5
+ :warn_on_invalid_position
5
6
 
6
7
  # Initializes the Exponential strategy with optional parameters for exponent,
7
8
  # bonus pool percentage, average list length, and whether to include unranked items in the bonus pool.
@@ -13,87 +14,144 @@ module WeightedListRank
13
14
  # defaults to nil.
14
15
  # @param include_unranked_items [Boolean] whether to include unranked items in the bonus pool calculation,
15
16
  # defaults to false for backward compatibility.
16
- def initialize(exponent: 1.5, bonus_pool_percentage: 1.0, average_list_length: nil, include_unranked_items: false)
17
+ # @param warn_on_invalid_position [Boolean] whether to emit a warning when a list contains items whose
18
+ # position exceeds the number of items in the list. Such positions are always clamped to the last position;
19
+ # this only controls whether that is reported. Defaults to false, so scoring is silent.
20
+ def initialize(exponent: 1.5, bonus_pool_percentage: 1.0, average_list_length: nil,
21
+ include_unranked_items: false, warn_on_invalid_position: false)
17
22
  @exponent = exponent
18
23
  @bonus_pool_percentage = bonus_pool_percentage
19
24
  @average_list_length = average_list_length
20
25
  @include_unranked_items = include_unranked_items
26
+ @warn_on_invalid_position = warn_on_invalid_position
21
27
  end
22
28
 
23
- # Calculates the score of an item within a list based on its rank position, the total number of items,
29
+ # Calculates the scores for every item in a list in one pass.
30
+ #
31
+ # The number of ranked items, the adjusted bonus pool, and the total
32
+ # exponential factor are all constant across the list, so they are
33
+ # computed once here instead of once per item. Scoring a list is
34
+ # therefore linear in the number of items.
35
+ #
36
+ # @param list [WeightedListRank::List] the list whose items should be scored.
37
+ # @return [Array<Float>] scores positionally matching +list.items+.
38
+ def calculate_scores(list)
39
+ items = list.items
40
+ total_items = items.count
41
+ return [] if total_items.zero?
42
+
43
+ num_ranked_items = count_ranked(items)
44
+ adjusted_bonus_pool = adjusted_bonus_pool_for(list, total_items)
45
+
46
+ # Only lists containing ranked items need this, and it is the most
47
+ # expensive value to produce, so it is computed on first use.
48
+ total_exponential_factor = nil
49
+ invalid_positions = 0
50
+
51
+ scores = items.map do |item|
52
+ position = item.position
53
+
54
+ if position.nil?
55
+ score = list.weight
56
+ if num_ranked_items.zero? && include_unranked_items
57
+ score += adjusted_bonus_pool / total_items
58
+ end
59
+ else
60
+ if position > total_items
61
+ invalid_positions += 1
62
+ position = total_items
63
+ end
64
+ total_exponential_factor ||= exponential_factor_sum(total_items)
65
+ exponential_factor = (total_items + 1 - position)**exponent
66
+ score = list.weight + (exponential_factor / total_exponential_factor) * adjusted_bonus_pool
67
+ end
68
+
69
+ floor(apply_penalty(score, item.score_penalty))
70
+ end
71
+
72
+ report_invalid_positions(list, invalid_positions, total_items)
73
+ scores
74
+ end
75
+
76
+ # Calculates the score of a single item within a list based on its rank position, the total number of items,
24
77
  # and the list's weight, using an exponential formula. The bonus pool for score adjustments is determined
25
78
  # by the specified bonus pool percentage of the list's total weight, adjusted by the average list length.
26
79
  #
27
80
  # If +include_unranked_items+ is true, unranked items will also receive a portion of the bonus pool.
28
81
  # Ranked items will receive an exponential bonus, while unranked items will split the remaining bonus pool evenly.
29
82
  #
83
+ # Scoring a whole list is cheaper through {#calculate_scores}, which shares the per-list work across items.
84
+ #
30
85
  # @param list [WeightedListRank::List] the list containing the item being scored.
31
86
  # @param item [WeightedListRank::Item] the item for which to calculate the score.
32
87
  #
33
88
  # @return [Float] the calculated score for the item, adjusted by the list's weight, the specified exponent,
34
89
  # and the bonus pool percentage.
35
90
  def calculate_score(list, item)
36
- # Default score to the list's weight
37
- score = list.weight
38
-
39
- # Total number of items in the list
40
- total_items = list.items.count
41
-
42
- # Separate ranked and unranked items
43
- ranked_items = list.items.select { |i| i.position }
44
- num_ranked_items = ranked_items.count
91
+ items = list.items
92
+ total_items = items.count
93
+ num_ranked_items = count_ranked(items)
94
+ position = item.position
45
95
 
46
- if num_ranked_items > 0 && item.position.nil?
47
- # If there are ranked items, unranked items get no bonus, only the list's weight
96
+ if position.nil?
48
97
  score = list.weight
98
+ if num_ranked_items.zero? && include_unranked_items
99
+ score += adjusted_bonus_pool_for(list, total_items) / total_items
100
+ end
49
101
  else
50
- # Calculate the total bonus pool
51
- total_bonus_pool = list.weight * bonus_pool_percentage
52
-
53
- # Adjust the bonus pool based on the average list length
54
- adjusted_bonus_pool = if average_list_length && average_list_length > 0
55
- total_bonus_pool * (total_items / average_list_length.to_f)
56
- else
57
- total_bonus_pool
102
+ if position > total_items
103
+ report_invalid_positions(list, 1, total_items)
104
+ position = total_items
58
105
  end
106
+ exponential_factor = (total_items + 1 - position)**exponent
107
+ score = list.weight +
108
+ (exponential_factor / exponential_factor_sum(total_items)) * adjusted_bonus_pool_for(list, total_items)
109
+ end
59
110
 
60
- if item.position.nil?
61
- # Unranked items get no bonus if there are ranked items
62
- if include_unranked_items && num_ranked_items == 0
63
- unranked_bonus = adjusted_bonus_pool / total_items
64
- score += unranked_bonus
65
- end
66
- else
67
- # Check if the item's position is higher than the total number of items
68
- if item.position > total_items
69
- puts "Warning: Item position (#{item.position}) is higher than the total number of items (#{total_items}) in the list. Using total items as position."
70
- item_position = total_items
71
- else
72
- item_position = item.position
73
- end
111
+ floor(apply_penalty(score, item.score_penalty))
112
+ end
74
113
 
75
- # Ranked items receive a bonus calculated using the exponential formula
76
- exponential_factor = (total_items + 1 - item_position)**exponent
77
- total_exponential_factor = (1..total_items).sum { |pos| (total_items + 1 - pos)**exponent }
114
+ private
78
115
 
79
- # Allocate a portion of the adjusted bonus pool based on the item's exponential factor
80
- item_bonus = (exponential_factor / total_exponential_factor) * adjusted_bonus_pool
81
- score += item_bonus
82
- end
83
- end
116
+ def count_ranked(items)
117
+ count = 0
118
+ items.each { |item| count += 1 if item.position }
119
+ count
120
+ end
84
121
 
85
- # Apply score penalty if it exists, for both ranked and unranked items
86
- score = apply_penalty(score, item.score_penalty)
122
+ def adjusted_bonus_pool_for(list, total_items)
123
+ total_bonus_pool = list.weight * bonus_pool_percentage
87
124
 
88
- # Ensure the score is not less than 1
89
- [score, 1].max
125
+ if average_list_length && average_list_length > 0
126
+ total_bonus_pool * (total_items / average_list_length.to_f)
127
+ else
128
+ total_bonus_pool
129
+ end
90
130
  end
91
131
 
92
- private
132
+ # The sum of every item's exponential factor, used to normalize each
133
+ # item's share of the bonus pool. Constant for a given list length.
134
+ def exponential_factor_sum(total_items)
135
+ (1..total_items).sum { |pos| (total_items + 1 - pos)**exponent }
136
+ end
93
137
 
94
138
  def apply_penalty(score, penalty)
95
139
  penalty ? score * (1 - penalty) : score
96
140
  end
141
+
142
+ # Ensure the score is not less than 1.
143
+ def floor(score)
144
+ (score < 1) ? 1 : score
145
+ end
146
+
147
+ # Emits at most one warning per list, and only when explicitly enabled.
148
+ def report_invalid_positions(list, count, total_items)
149
+ return unless warn_on_invalid_position
150
+ return if count.zero?
151
+
152
+ warn "WeightedListRank: list #{list.id} has #{count} item(s) with a position greater than the " \
153
+ "number of items in the list (#{total_items}); clamping to #{total_items}."
154
+ end
97
155
  end
98
156
  end
99
157
  end
@@ -7,5 +7,22 @@ module WeightedListRank
7
7
  def calculate_score(list, item)
8
8
  raise NotImplementedError
9
9
  end
10
+
11
+ # Calculates the scores for every item in a list, returned in the same order
12
+ # as +list.items+.
13
+ #
14
+ # This is the entry point {RankingContext} uses. The default implementation
15
+ # simply delegates to {#calculate_score} for each item, so strategies that
16
+ # implement only the single-item API continue to work unchanged.
17
+ #
18
+ # Strategies whose scoring depends on values that are constant across the
19
+ # list (a total, a count, a normalizing factor) should override this and
20
+ # compute those values once, rather than recomputing them for every item.
21
+ #
22
+ # @param list [List] the list whose items should be scored.
23
+ # @return [Array<Numeric>] scores positionally matching +list.items+.
24
+ def calculate_scores(list)
25
+ list.items.map { |item| calculate_score(list, item) }
26
+ end
10
27
  end
11
28
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WeightedListRank
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/weighted_list_rank/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "weighted_list_rank"
7
+ spec.version = WeightedListRank::VERSION
8
+ spec.authors = ["Shane Sherman"]
9
+ spec.email = ["shane.sherman@gmail.com"]
10
+
11
+ spec.summary = "generate ranks of items from weighted lists"
12
+ spec.description = "generate ranks of items from weighted lists"
13
+ spec.homepage = "https://github.com/ssherman/weighted_list_rank"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.6.0"
16
+
17
+ # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/ssherman/weighted_list_rank"
21
+ spec.metadata["changelog_uri"] = "https://github.com/ssherman/weighted_list_rank/CHANGELOG.md"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ # Uncomment to register a new dependency of your gem
35
+ # spec.add_dependency "example-gem", "~> 1.0"
36
+ spec.add_development_dependency "rubocop", "~> 1.0"
37
+ spec.add_development_dependency "standard", ">= 1.35.1"
38
+ spec.add_development_dependency "rubocop-minitest", "~> 0.3"
39
+ spec.add_development_dependency "rubocop-rake", "~> 0.6"
40
+ # For more information and examples about making a new gem, check out our
41
+ # guide at: https://bundler.io/guides/creating_gem.html
42
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: weighted_list_rank
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shane Sherman
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-06-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rubocop
@@ -88,6 +88,7 @@ files:
88
88
  - lib/weighted_list_rank/strategy.rb
89
89
  - lib/weighted_list_rank/version.rb
90
90
  - sig/weighted_list_rank.rbs
91
+ - weighted_list_rank.gemspec
91
92
  homepage: https://github.com/ssherman/weighted_list_rank
92
93
  licenses:
93
94
  - MIT
@@ -109,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
110
  - !ruby/object:Gem::Version
110
111
  version: '0'
111
112
  requirements: []
112
- rubygems_version: 3.6.2
113
+ rubygems_version: 4.0.16
113
114
  specification_version: 4
114
115
  summary: generate ranks of items from weighted lists
115
116
  test_files: []