weighted_list_rank 0.5.3 → 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: 5e3b387809912d1000f8f8ee13704cec1b04d691b783d6bd19a9f9b6e6cf0d6d
4
- data.tar.gz: caadd577bf061e63c1357943da439e06d6c1c5e441bb84ddbff250fb49a45020
3
+ metadata.gz: 0e8758848cc7ed9090ee9348afeb2efd6fe7d27506b00a2dd997ffce53959615
4
+ data.tar.gz: ff68f02846e841ac26fb3863d50b1309543c2d1e8368024ff01e7de44041cb23
5
5
  SHA512:
6
- metadata.gz: eb8c04911951b50c39b7f45583b1932274377106357fae605185bad10f8e37fb4e5c86047698e5e1752778311a5ea3403ce82fe494d44035ac889625b0703056
7
- data.tar.gz: 955ad9589e4418ca509065a7dc92b23af4c3c962ee8523ab89585bda58f04a9cedbedc11124523c69b385ba3f146d7db6856be6f86dc66143c251055b69bc545
6
+ metadata.gz: bf99a1e72f8dc738541b1002ef1ddb9d590a198ac957aa59e2ee570b4dcf82d9702f78291459ad2d9f98250482a7b421809e3a0aa557f445454b0545c914e43d
7
+ data.tar.gz: d9f13250eac45ebe3466c8020a16a5f290fa693174d574d64e35f17010cf0624993c0d575d53d319b7b21510a9a8e022af625b4bdedf39f9c7b64d4952c77e7c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
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
+
15
+ ## [0.6.0] - 2025-06-29
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.
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%).
18
+ - Penalties are applied to the total score after all list scores are aggregated but before sorting.
19
+ - This feature is fully backwards compatible and stacks with individual item penalties.
20
+ - Updated all dependencies to their latest versions.
21
+
1
22
  ## [0.5.3] - 2024-08-22
2
23
  - Fixed an issue in the `Exponential` strategy where items with positions higher than the total number of items in a list could cause errors. Now, such items are treated as if they were in the last position, and a warning is logged.
3
24
  - Added a new test case to verify the handling of items with positions exceeding the list size.
data/Gemfile.lock CHANGED
@@ -1,62 +1,69 @@
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
- ast (2.4.2)
10
- json (2.7.2)
11
- language_server-protocol (3.17.0.3)
9
+ ast (2.4.3)
10
+ json (2.12.2)
11
+ language_server-protocol (3.17.0.6)
12
12
  lint_roller (1.1.0)
13
- minitest (5.25.1)
14
- parallel (1.26.3)
15
- parser (3.3.5.0)
13
+ minitest (5.27.0)
14
+ parallel (1.27.0)
15
+ parser (3.3.12.0)
16
16
  ast (~> 2.4.1)
17
17
  racc
18
+ prism (1.4.0)
18
19
  racc (1.8.1)
19
20
  rainbow (3.1.1)
20
- rake (13.2.1)
21
- regexp_parser (2.9.2)
22
- rexml (3.3.8)
23
- rubocop (1.65.1)
21
+ rake (13.4.2)
22
+ regexp_parser (2.10.0)
23
+ rubocop (1.75.8)
24
24
  json (~> 2.3)
25
- language_server-protocol (>= 3.17.0)
25
+ language_server-protocol (~> 3.17.0.2)
26
+ lint_roller (~> 1.1.0)
26
27
  parallel (~> 1.10)
27
28
  parser (>= 3.3.0.2)
28
29
  rainbow (>= 2.2.2, < 4.0)
29
- regexp_parser (>= 2.4, < 3.0)
30
- rexml (>= 3.2.5, < 4.0)
31
- rubocop-ast (>= 1.31.1, < 2.0)
30
+ regexp_parser (>= 2.9.3, < 3.0)
31
+ rubocop-ast (>= 1.44.0, < 2.0)
32
32
  ruby-progressbar (~> 1.7)
33
- unicode-display_width (>= 2.4.0, < 3.0)
34
- rubocop-ast (1.32.3)
35
- parser (>= 3.3.1.0)
36
- rubocop-minitest (0.36.0)
37
- rubocop (>= 1.61, < 2.0)
38
- rubocop-ast (>= 1.31.1, < 2.0)
39
- rubocop-performance (1.21.1)
40
- rubocop (>= 1.48.1, < 2.0)
41
- rubocop-ast (>= 1.31.1, < 2.0)
42
- rubocop-rake (0.6.0)
43
- rubocop (~> 1.0)
33
+ unicode-display_width (>= 2.4.0, < 4.0)
34
+ rubocop-ast (1.45.1)
35
+ parser (>= 3.3.7.2)
36
+ prism (~> 1.4)
37
+ rubocop-minitest (0.40.0)
38
+ lint_roller (~> 1.1)
39
+ rubocop (>= 1.75.0, < 2.0)
40
+ rubocop-ast (>= 1.38.0, < 2.0)
41
+ rubocop-performance (1.25.0)
42
+ lint_roller (~> 1.1)
43
+ rubocop (>= 1.75.0, < 2.0)
44
+ rubocop-ast (>= 1.38.0, < 2.0)
45
+ rubocop-rake (0.7.1)
46
+ lint_roller (~> 1.1)
47
+ rubocop (>= 1.72.1)
44
48
  ruby-progressbar (1.13.0)
45
- standard (1.40.1)
49
+ standard (1.50.0)
46
50
  language_server-protocol (~> 3.17.0.2)
47
51
  lint_roller (~> 1.0)
48
- rubocop (~> 1.65.0)
52
+ rubocop (~> 1.75.5)
49
53
  standard-custom (~> 1.0.0)
50
- standard-performance (~> 1.4)
54
+ standard-performance (~> 1.8)
51
55
  standard-custom (1.0.2)
52
56
  lint_roller (~> 1.0)
53
57
  rubocop (~> 1.50)
54
- standard-performance (1.4.0)
58
+ standard-performance (1.8.0)
55
59
  lint_roller (~> 1.1)
56
- rubocop-performance (~> 1.21.0)
57
- unicode-display_width (2.6.0)
60
+ rubocop-performance (~> 1.25.0)
61
+ unicode-display_width (3.2.0)
62
+ unicode-emoji (~> 4.1)
63
+ unicode-emoji (4.2.0)
58
64
 
59
65
  PLATFORMS
66
+ arm64-darwin-24
60
67
  x86_64-linux
61
68
 
62
69
  DEPENDENCIES
data/README.md CHANGED
@@ -204,6 +204,80 @@ ranked_items.each do |item|
204
204
  end
205
205
  ```
206
206
 
207
+ ### Penalizing Items by Number of Lists
208
+ You can optionally penalize items that appear on only a small number of lists. This is useful for de-emphasizing items that are not widely represented across your data. To use this feature, pass a `list_count_penalties` hash to the `RankingContext` constructor, where the keys are the number of lists and the values are the penalty percentages (as a float between 0 and 1).
209
+
210
+ For example, to penalize items that appear on only 1 list by 50%, and items on 2 lists by 25%:
211
+
212
+ ```ruby
213
+ list_count_penalties = {1 => 0.50, 2 => 0.25}
214
+ ranking_context = WeightedListRank::RankingContext.new(
215
+ WeightedListRank::Strategies::Exponential.new,
216
+ list_count_penalties: list_count_penalties
217
+ )
218
+
219
+ ranked_items = ranking_context.rank([list1, list2, list3])
220
+ ```
221
+
222
+ - Items that appear on only 1 list will have their total score multiplied by 0.5 (50% penalty).
223
+ - Items that appear on only 2 lists will have their total score multiplied by 0.75 (25% penalty).
224
+ - Items that appear on more lists will not be penalized unless specified in the hash.
225
+
226
+ #### How it works
227
+ - The penalty is applied to the total score of each item after all list scores are aggregated, but before sorting.
228
+ - If an item appears on a number of lists not present in the hash, no penalty is applied.
229
+ - This feature is fully backwards compatible: if you do not provide `list_count_penalties`, no penalties are applied.
230
+ - This penalty stacks with individual item penalties (e.g., `score_penalty` on an item).
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
+
207
281
  ## Development
208
282
 
209
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.
@@ -3,12 +3,15 @@ module WeightedListRank
3
3
  # It aggregates scores for each item across all lists, based on the provided strategy.
4
4
  class RankingContext
5
5
  # @strategy: The strategy used for calculating scores.
6
- attr_reader :strategy
6
+ # @list_count_penalties: Hash mapping list counts to penalty percentages (e.g., {1 => 0.50, 2 => 0.25})
7
+ attr_reader :strategy, :list_count_penalties
7
8
 
8
- # Initializes a new RankingContext with an optional ranking strategy.
9
+ # Initializes a new RankingContext with an optional ranking strategy and list count penalties.
9
10
  # @param strategy [Strategy] the strategy to use for ranking items, defaults to Strategies::Exponential.
10
- def initialize(strategy = Strategies::Exponential.new)
11
+ # @param list_count_penalties [Hash] hash mapping list counts to penalty percentages, defaults to empty hash.
12
+ def initialize(strategy = Strategies::Exponential.new, list_count_penalties: {})
11
13
  @strategy = strategy
14
+ @list_count_penalties = list_count_penalties
12
15
  end
13
16
 
14
17
  # Ranks items across multiple lists according to the strategy's score calculation.
@@ -16,32 +19,52 @@ module WeightedListRank
16
19
  # @return [Array<Hash>] a sorted array of item scores, with each item's details including ID, score details, and total score.
17
20
  def rank(lists)
18
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
+
19
27
  lists.each do |list|
20
- list.items.each do |item|
21
- score = strategy.calculate_score(list, item)
22
- items[item.id] ||= {}
23
- # Ensure the list_details array exists, then append the new score detail
24
- items[item.id][:list_details] ||= []
25
- items[item.id][:list_details] << {list_id: list.id, score: score, weight: list.weight, score_penalty: item.score_penalty}
26
-
27
- # Ensure the total_score is initialized, then add the score
28
- items[item.id][:total_score] ||= 0
29
- 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
30
43
  end
31
44
  end
32
45
 
33
- # Convert hash to a sorted array
34
- sorted_items = items.map do |id, details|
46
+ apply_penalties = !list_count_penalties.empty?
47
+
48
+ # Convert hash to a formatted array
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
+
35
58
  {
36
59
  id: id,
37
60
  # Sort the score_details array by score in descending order before including it
38
- score_details: details[:list_details].sort_by { |detail| -detail[:score] },
39
- total_score: details[:total_score]
61
+ score_details: list_details.sort_by { |detail| -detail[:score] },
62
+ total_score: total_score
40
63
  }
41
64
  end
42
65
 
43
66
  # Sort the array by total_score in descending order
44
- sorted_items.sort_by { |item| -item[:total_score] }
67
+ formatted_items.sort_by { |item| -item[:total_score] }
45
68
  end
46
69
  end
47
70
  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.5.3"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: weighted_list_rank
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shane Sherman
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-10-06 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rubocop
@@ -97,7 +96,6 @@ metadata:
97
96
  homepage_uri: https://github.com/ssherman/weighted_list_rank
98
97
  source_code_uri: https://github.com/ssherman/weighted_list_rank
99
98
  changelog_uri: https://github.com/ssherman/weighted_list_rank/CHANGELOG.md
100
- post_install_message:
101
99
  rdoc_options: []
102
100
  require_paths:
103
101
  - lib
@@ -112,8 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
110
  - !ruby/object:Gem::Version
113
111
  version: '0'
114
112
  requirements: []
115
- rubygems_version: 3.5.11
116
- signing_key:
113
+ rubygems_version: 4.0.16
117
114
  specification_version: 4
118
115
  summary: generate ranks of items from weighted lists
119
116
  test_files: []