type_balancer 0.1.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +38 -2
- data/Gemfile.lock +1 -1
- data/README.md +65 -4
- data/Rakefile +19 -1
- data/docs/balance.md +113 -10
- data/docs/quality.md +10 -2
- data/examples/large_scale_balance_test.rb +289 -0
- data/examples/quality.rb +93 -0
- data/lib/type_balancer/balancer.rb +16 -19
- data/lib/type_balancer/calculator.rb +27 -91
- data/lib/type_balancer/distributor.rb +70 -17
- data/lib/type_balancer/strategies/base_strategy.rb +49 -0
- data/lib/type_balancer/strategies/sliding_window_strategy.rb +140 -0
- data/lib/type_balancer/strategies.rb +7 -0
- data/lib/type_balancer/strategy_factory.rb +41 -0
- data/lib/type_balancer/type_extractor.rb +7 -2
- data/lib/type_balancer/type_extractor_registry.rb +20 -0
- data/lib/type_balancer/version.rb +1 -1
- data/lib/type_balancer.rb +43 -27
- metadata +8 -2
data/examples/quality.rb
CHANGED
@@ -23,6 +23,8 @@ class QualityChecker
|
|
23
23
|
check_available_positions_edge_cases
|
24
24
|
check_balance_method_robust
|
25
25
|
check_real_world_feed
|
26
|
+
check_custom_type_field
|
27
|
+
check_strategy_system
|
26
28
|
|
27
29
|
print_summary
|
28
30
|
exit(@issues.empty? ? 0 : 1)
|
@@ -328,6 +330,97 @@ class QualityChecker
|
|
328
330
|
puts " Balanced items with custom order: #{ordered_result.map { |i| i[:type] }.inspect}"
|
329
331
|
end
|
330
332
|
|
333
|
+
def check_custom_type_field
|
334
|
+
@examples_run += 1
|
335
|
+
puts "\nCustom Type Field Example:"
|
336
|
+
data = [
|
337
|
+
{ category: 'A', payload: 1 },
|
338
|
+
{ category: 'B', payload: 2 },
|
339
|
+
{ category: 'C', payload: 3 },
|
340
|
+
{ category: 'A', payload: 4 }
|
341
|
+
]
|
342
|
+
balanced = TypeBalancer.balance(data, type_field: :category)
|
343
|
+
found = balanced.map { |i| i[:category] }.uniq.sort
|
344
|
+
expected = %w[A B C]
|
345
|
+
if found == expected
|
346
|
+
@examples_passed += 1
|
347
|
+
puts "#{GREEN}Custom field respected: #{found.inspect}#{RESET}"
|
348
|
+
else
|
349
|
+
record_issue("Expected #{expected.inspect}, got #{found.inspect}")
|
350
|
+
puts "#{RED}Custom field test failed: #{found.inspect}#{RESET}"
|
351
|
+
end
|
352
|
+
print_section_table('custom_type_field', 1, found == expected ? 1 : 0)
|
353
|
+
end
|
354
|
+
|
355
|
+
def check_strategy_system
|
356
|
+
puts "\n#{YELLOW}Strategy System Tests:#{RESET}"
|
357
|
+
@section_examples_run = 0
|
358
|
+
@section_examples_passed = 0
|
359
|
+
|
360
|
+
# Test default strategy
|
361
|
+
@examples_run += 1
|
362
|
+
@section_examples_run += 1
|
363
|
+
items = [
|
364
|
+
{ type: 'video', id: 1 },
|
365
|
+
{ type: 'image', id: 2 },
|
366
|
+
{ type: 'video', id: 3 }
|
367
|
+
]
|
368
|
+
result = TypeBalancer.balance(items, type_field: :type)
|
369
|
+
if result.size == items.size && result.map { |i| i[:id] }.sort == [1, 2, 3]
|
370
|
+
@examples_passed += 1
|
371
|
+
@section_examples_passed += 1
|
372
|
+
puts "#{GREEN}Default strategy test passed#{RESET}"
|
373
|
+
else
|
374
|
+
record_issue("Default strategy test failed: unexpected result #{result.inspect}")
|
375
|
+
puts "#{RED}Default strategy test failed#{RESET}"
|
376
|
+
end
|
377
|
+
|
378
|
+
# Test sliding window strategy with custom window size
|
379
|
+
@examples_run += 1
|
380
|
+
@section_examples_run += 1
|
381
|
+
items = [
|
382
|
+
{ type: 'video', id: 1 },
|
383
|
+
{ type: 'image', id: 2 },
|
384
|
+
{ type: 'video', id: 3 },
|
385
|
+
{ type: 'image', id: 4 }
|
386
|
+
]
|
387
|
+
result = TypeBalancer.balance(items, type_field: :type, strategy: :sliding_window, window_size: 2)
|
388
|
+
if result.size == items.size &&
|
389
|
+
result[0..1].map { |i| i[:type] }.uniq.size == 2 # First window has both types
|
390
|
+
@examples_passed += 1
|
391
|
+
@section_examples_passed += 1
|
392
|
+
puts "#{GREEN}Sliding window strategy with custom window size test passed#{RESET}"
|
393
|
+
else
|
394
|
+
record_issue("Sliding window strategy test failed: unexpected result #{result.inspect}")
|
395
|
+
puts "#{RED}Sliding window strategy with custom window size test failed#{RESET}"
|
396
|
+
end
|
397
|
+
|
398
|
+
# Test strategy with custom type order
|
399
|
+
@examples_run += 1
|
400
|
+
@section_examples_run += 1
|
401
|
+
items = [
|
402
|
+
{ type: 'video', id: 1 },
|
403
|
+
{ type: 'image', id: 2 },
|
404
|
+
{ type: 'article', id: 3 }
|
405
|
+
]
|
406
|
+
result = TypeBalancer.balance(
|
407
|
+
items,
|
408
|
+
type_field: :type,
|
409
|
+
strategy: :sliding_window,
|
410
|
+
types: %w[image video article]
|
411
|
+
)
|
412
|
+
if result.size == items.size && result.first[:type] == 'image'
|
413
|
+
@examples_passed += 1
|
414
|
+
@section_examples_passed += 1
|
415
|
+
puts "#{GREEN}Strategy with custom type order test passed#{RESET}"
|
416
|
+
else
|
417
|
+
record_issue("Strategy with custom type order test failed: unexpected result #{result.inspect}")
|
418
|
+
puts "#{RED}Strategy with custom type order test failed#{RESET}"
|
419
|
+
end
|
420
|
+
|
421
|
+
print_section_table('strategy_system')
|
422
|
+
end
|
423
|
+
|
331
424
|
def print_summary
|
332
425
|
puts "\n#{'-' * 50}"
|
333
426
|
puts 'Quality Check Summary:'
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require_relative 'ratio_calculator'
|
4
4
|
require_relative 'batch_processing'
|
5
5
|
require_relative 'position_calculator'
|
6
|
+
require_relative 'type_extractor_registry'
|
6
7
|
|
7
8
|
module TypeBalancer
|
8
9
|
# Handles balancing of items across batches based on type ratios
|
@@ -10,9 +11,11 @@ module TypeBalancer
|
|
10
11
|
# Initialize a new Balancer instance
|
11
12
|
#
|
12
13
|
# @param types [Array<String>, nil] Optional types
|
14
|
+
# @param type_field [Symbol] Field to use for type extraction (default: :type)
|
13
15
|
# @param type_order [Array<String>, nil] Optional order of types
|
14
|
-
def initialize(types = nil, type_order: nil)
|
16
|
+
def initialize(types = nil, type_field: :type, type_order: nil)
|
15
17
|
@types = Array(types) if types
|
18
|
+
@type_field = type_field
|
16
19
|
@type_order = type_order
|
17
20
|
validate_types! if @types
|
18
21
|
end
|
@@ -23,7 +26,18 @@ module TypeBalancer
|
|
23
26
|
# @return [Array] Balanced items
|
24
27
|
def call(collection)
|
25
28
|
validate_collection!(collection)
|
26
|
-
|
29
|
+
extractor = TypeExtractorRegistry.get(@type_field)
|
30
|
+
|
31
|
+
begin
|
32
|
+
items_by_type = extractor.group_by_type(collection)
|
33
|
+
rescue TypeBalancer::Error => e
|
34
|
+
raise TypeBalancer::Error, "Cannot access type field '#{@type_field}': #{e.message}"
|
35
|
+
end
|
36
|
+
|
37
|
+
# Remove nil types and validate
|
38
|
+
items_by_type.delete(nil)
|
39
|
+
raise TypeBalancer::Error, "Cannot access type field '#{@type_field}'" if items_by_type.empty?
|
40
|
+
|
27
41
|
validate_types_in_collection!(items_by_type)
|
28
42
|
|
29
43
|
target_counts = calculate_target_counts(items_by_type)
|
@@ -70,24 +84,7 @@ module TypeBalancer
|
|
70
84
|
raise TypeBalancer::Error, "Invalid type(s): #{invalid_types.join(', ')}" if invalid_types.any?
|
71
85
|
end
|
72
86
|
|
73
|
-
def group_items_by_type(collection)
|
74
|
-
collection.group_by do |item|
|
75
|
-
extract_type(item)
|
76
|
-
end
|
77
|
-
end
|
78
|
-
|
79
|
-
def extract_type(item)
|
80
|
-
return item[:type] || item['type'] || raise(TypeBalancer::Error, 'Cannot access type field') if item.is_a?(Hash)
|
81
|
-
|
82
|
-
begin
|
83
|
-
item.type
|
84
|
-
rescue NoMethodError
|
85
|
-
raise TypeBalancer::Error, 'Cannot access type field'
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
87
|
def calculate_target_counts(items_by_type)
|
90
|
-
items_by_type.values.sum(&:size)
|
91
88
|
items_by_type.transform_values(&:size)
|
92
89
|
end
|
93
90
|
|
@@ -1,5 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative 'strategy_factory'
|
4
|
+
require_relative 'strategies/base_strategy'
|
5
|
+
require_relative 'strategies/sliding_window_strategy'
|
6
|
+
|
3
7
|
module TypeBalancer
|
4
8
|
# Handles calculation of positions for balanced item distribution
|
5
9
|
class PositionCalculator
|
@@ -80,6 +84,7 @@ module TypeBalancer
|
|
80
84
|
|
81
85
|
def calculate_evenly_spaced_positions(total_count, target_count, ratio)
|
82
86
|
return [0] if target_count == 1
|
87
|
+
return handle_two_positions_in_three_slots if target_count == 2 && total_count == 3
|
83
88
|
return handle_two_thirds_case(total_count) if two_thirds_ratio?(ratio, total_count)
|
84
89
|
|
85
90
|
(0...target_count).map do |i|
|
@@ -95,6 +100,10 @@ module TypeBalancer
|
|
95
100
|
[0, 1]
|
96
101
|
end
|
97
102
|
|
103
|
+
def handle_two_positions_in_three_slots
|
104
|
+
[0, 1]
|
105
|
+
end
|
106
|
+
|
98
107
|
def valid_inputs?(total_count, ratio)
|
99
108
|
total_count >= 0 && ratio >= 0 && ratio <= 1.0
|
100
109
|
end
|
@@ -105,114 +114,41 @@ module TypeBalancer
|
|
105
114
|
class Calculator
|
106
115
|
DEFAULT_TYPE_ORDER = %w[video image strip article].freeze
|
107
116
|
|
108
|
-
def initialize(items, type_field: :type, types: nil)
|
117
|
+
def initialize(items, type_field: :type, types: nil, strategy: nil, **strategy_options)
|
109
118
|
raise ArgumentError, 'Items cannot be nil' if items.nil?
|
110
119
|
raise ArgumentError, 'Type field cannot be nil' if type_field.nil?
|
111
120
|
|
112
121
|
@items = items
|
113
122
|
@type_field = type_field
|
114
|
-
@types = types
|
123
|
+
@types = types
|
124
|
+
@strategy_name = strategy
|
125
|
+
@strategy_options = strategy_options
|
115
126
|
end
|
116
127
|
|
117
128
|
def call
|
118
129
|
return [] if @items.empty?
|
119
130
|
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
131
|
+
# Create strategy instance
|
132
|
+
strategy = StrategyFactory.create(
|
133
|
+
@strategy_name,
|
134
|
+
items: @items,
|
135
|
+
type_field: @type_field,
|
136
|
+
types: @types || extract_types,
|
137
|
+
**@strategy_options
|
138
|
+
)
|
139
|
+
|
140
|
+
# Balance items using strategy
|
141
|
+
strategy.balance
|
129
142
|
end
|
130
143
|
|
131
144
|
private
|
132
145
|
|
133
|
-
def validate_items!
|
134
|
-
@items.each do |item|
|
135
|
-
raise ArgumentError, 'All items must have a type field' unless item.key?(@type_field)
|
136
|
-
raise ArgumentError, 'Type values cannot be empty' if item[@type_field].to_s.strip.empty?
|
137
|
-
end
|
138
|
-
end
|
139
|
-
|
140
146
|
def extract_types
|
141
147
|
types = @items.map { |item| item[@type_field].to_s }.uniq
|
142
148
|
DEFAULT_TYPE_ORDER.select { |type| types.include?(type) } + (types - DEFAULT_TYPE_ORDER)
|
143
149
|
end
|
144
|
-
|
145
|
-
def calculate_target_positions(items_by_type)
|
146
|
-
total_count = @items.size
|
147
|
-
available_positions = (0...total_count).to_a
|
148
|
-
|
149
|
-
items_by_type.map.with_index do |_items, index|
|
150
|
-
ratio = calculate_ratio(items_by_type.size, index)
|
151
|
-
target_count = (total_count * ratio).round
|
152
|
-
|
153
|
-
# Calculate positions based on ratio and total count
|
154
|
-
if target_count == 1
|
155
|
-
[index]
|
156
|
-
else
|
157
|
-
# For better distribution, calculate positions based on available slots
|
158
|
-
step = available_positions.size.fdiv(target_count)
|
159
|
-
positions = (0...target_count).map do |i|
|
160
|
-
pos_index = (i * step).round
|
161
|
-
available_positions[pos_index]
|
162
|
-
end
|
163
|
-
|
164
|
-
# Remove used positions from available ones
|
165
|
-
positions.each { |pos| available_positions.delete(pos) }
|
166
|
-
positions
|
167
|
-
end
|
168
|
-
end
|
169
|
-
end
|
170
|
-
|
171
|
-
def calculate_ratio(type_count, index)
|
172
|
-
case type_count
|
173
|
-
when 1 then 1.0
|
174
|
-
when 2 then index.zero? ? 0.6 : 0.4
|
175
|
-
else
|
176
|
-
# For 3+ types: first type gets 0.4, rest split remaining 0.6 evenly
|
177
|
-
remaining = (0.6 / (type_count - 1).to_f).round(6)
|
178
|
-
index.zero? ? 0.4 : remaining
|
179
|
-
end
|
180
|
-
end
|
181
|
-
|
182
|
-
def place_items_at_positions(items_by_type, target_positions)
|
183
|
-
result = Array.new(@items.size)
|
184
|
-
used_items = place_items_at_target_positions(items_by_type, target_positions, result)
|
185
|
-
fill_empty_slots(result, used_items)
|
186
|
-
result.compact
|
187
|
-
end
|
188
|
-
|
189
|
-
def place_items_at_target_positions(items_by_type, target_positions, result)
|
190
|
-
used_items = []
|
191
|
-
items_by_type.each_with_index do |items, type_index|
|
192
|
-
positions = target_positions[type_index] || []
|
193
|
-
place_type_items(items, positions, result, used_items)
|
194
|
-
end
|
195
|
-
used_items
|
196
|
-
end
|
197
|
-
|
198
|
-
def place_type_items(items, positions, result, used_items)
|
199
|
-
items.take(positions.size).each_with_index do |item, item_index|
|
200
|
-
pos = positions[item_index]
|
201
|
-
next unless pos && result[pos].nil?
|
202
|
-
|
203
|
-
result[pos] = item
|
204
|
-
used_items << item
|
205
|
-
end
|
206
|
-
end
|
207
|
-
|
208
|
-
def fill_empty_slots(result, used_items)
|
209
|
-
remaining_items = @items - used_items
|
210
|
-
empty_slots = result.each_index.select { |i| result[i].nil? }
|
211
|
-
empty_slots.zip(remaining_items).each do |slot, item|
|
212
|
-
break unless item
|
213
|
-
|
214
|
-
result[slot] = item
|
215
|
-
end
|
216
|
-
end
|
217
150
|
end
|
218
151
|
end
|
152
|
+
|
153
|
+
# Register default strategy
|
154
|
+
TypeBalancer::StrategyFactory.register(:sliding_window, TypeBalancer::Strategies::SlidingWindowStrategy)
|
@@ -6,29 +6,82 @@ module TypeBalancer
|
|
6
6
|
# Validate inputs
|
7
7
|
return [] if total_count <= 0 || ratio <= 0 || ratio > 1
|
8
8
|
|
9
|
-
# Calculate target count
|
10
|
-
target_count =
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
9
|
+
# Calculate base target count
|
10
|
+
target_count = (total_count * ratio).ceil
|
11
|
+
|
12
|
+
# Special case for 3 slots
|
13
|
+
if total_count == 3
|
14
|
+
target_count = if ratio <= 0.34
|
15
|
+
1
|
16
|
+
elsif ratio <= 0.67
|
17
|
+
2
|
18
|
+
else
|
19
|
+
3
|
20
|
+
end
|
21
|
+
end
|
17
22
|
|
18
23
|
return [] if target_count.zero?
|
19
24
|
return (0...total_count).to_a if target_count >= total_count
|
20
25
|
|
21
|
-
|
22
|
-
|
26
|
+
if available_positions
|
27
|
+
# Filter out invalid positions and sort them
|
28
|
+
valid_positions = available_positions.select { |pos| pos >= 0 && pos < total_count }.sort
|
29
|
+
return [] if valid_positions.empty?
|
30
|
+
|
31
|
+
# For single target position, use first available
|
32
|
+
return [valid_positions.first] if target_count == 1
|
33
|
+
|
34
|
+
# For two positions
|
35
|
+
if target_count == 2
|
36
|
+
# Special case for three slots
|
37
|
+
if total_count == 3
|
38
|
+
return [valid_positions[0], valid_positions[1]] if valid_positions.size >= 2
|
39
|
+
|
40
|
+
return [valid_positions.first, valid_positions.first + 1]
|
41
|
+
end
|
42
|
+
|
43
|
+
# Special case for invalid positions that go beyond total_count
|
44
|
+
if available_positions.any? { |pos| pos >= total_count }
|
45
|
+
valid_positions = available_positions.select { |pos| pos >= 0 }.sort
|
46
|
+
return [valid_positions.first, valid_positions.last]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Otherwise use first and last
|
50
|
+
return [valid_positions.first, valid_positions.last]
|
51
|
+
end
|
52
|
+
|
53
|
+
# If we have fewer or equal positions than needed, use all available up to target_count
|
54
|
+
return valid_positions if valid_positions.size <= target_count
|
55
|
+
|
56
|
+
# For more positions, take the first N positions where N is target_count
|
57
|
+
return valid_positions.first(target_count) if target_count <= 3
|
58
|
+
|
59
|
+
# For larger target counts, distribute evenly
|
60
|
+
target_positions = []
|
61
|
+
step = (valid_positions.size - 1).fdiv(target_count - 1)
|
62
|
+
(0...target_count).each do |i|
|
63
|
+
index = (i * step).round
|
64
|
+
target_positions << valid_positions[index]
|
65
|
+
end
|
66
|
+
target_positions
|
67
|
+
else
|
68
|
+
# Handle single target position
|
23
69
|
return [0] if target_count == 1
|
24
|
-
return [0, 1] if target_count == 2
|
25
|
-
end
|
26
70
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
71
|
+
# For two positions
|
72
|
+
if target_count == 2
|
73
|
+
# Special case for three slots
|
74
|
+
return [0, 1] if total_count == 3
|
75
|
+
|
76
|
+
# Otherwise use first and last
|
77
|
+
return [0, total_count - 1]
|
78
|
+
end
|
79
|
+
|
80
|
+
# Calculate evenly spaced positions for multiple targets
|
81
|
+
(0...target_count).map do |i|
|
82
|
+
((total_count - 1) * i.fdiv(target_count - 1)).round
|
83
|
+
end
|
84
|
+
end
|
32
85
|
end
|
33
86
|
end
|
34
87
|
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypeBalancer
|
4
|
+
module Strategies
|
5
|
+
# Base class for all balancing strategies
|
6
|
+
class BaseStrategy
|
7
|
+
def initialize(items:, type_field:, types: nil)
|
8
|
+
@items = items
|
9
|
+
@type_field = type_field
|
10
|
+
@types = types
|
11
|
+
end
|
12
|
+
|
13
|
+
# Interface method that all strategies must implement
|
14
|
+
def balance
|
15
|
+
raise NotImplementedError, 'Strategies must implement #balance'
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
|
20
|
+
def validate_items!
|
21
|
+
@items.each do |item|
|
22
|
+
raise ArgumentError, 'All items must have a type field' unless item.key?(@type_field)
|
23
|
+
raise ArgumentError, 'Type values cannot be empty' if item[@type_field].to_s.strip.empty?
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def extract_types
|
28
|
+
types = @items.map { |item| item[@type_field].to_s }.uniq
|
29
|
+
DEFAULT_TYPE_ORDER.select { |type| types.include?(type) } + (types - DEFAULT_TYPE_ORDER)
|
30
|
+
end
|
31
|
+
|
32
|
+
def group_items_by_type
|
33
|
+
# First, create a hash to store items by type while preserving order
|
34
|
+
type_queues = {}
|
35
|
+
@types.each { |type| type_queues[type] = [] }
|
36
|
+
|
37
|
+
# Add items to their respective queues in order
|
38
|
+
@items.each do |item|
|
39
|
+
type = item[@type_field].to_s
|
40
|
+
type_queues[type] << item if type_queues.key?(type)
|
41
|
+
end
|
42
|
+
|
43
|
+
type_queues
|
44
|
+
end
|
45
|
+
|
46
|
+
DEFAULT_TYPE_ORDER = %w[video image strip article].freeze
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base_strategy'
|
4
|
+
|
5
|
+
module TypeBalancer
|
6
|
+
module Strategies
|
7
|
+
# Implements a sliding window approach to balance items
|
8
|
+
class SlidingWindowStrategy < BaseStrategy
|
9
|
+
def initialize(items:, type_field:, types: nil, window_size: 10)
|
10
|
+
super(items: items, type_field: type_field, types: types)
|
11
|
+
@window_size = window_size
|
12
|
+
@types = types || extract_types
|
13
|
+
end
|
14
|
+
|
15
|
+
def balance
|
16
|
+
return [] if @items.empty?
|
17
|
+
|
18
|
+
validate_items!
|
19
|
+
return @items.dup if group_items_by_type.size == 1
|
20
|
+
|
21
|
+
type_queues = group_items_by_type
|
22
|
+
type_ratios = calculate_type_ratios(type_queues)
|
23
|
+
|
24
|
+
process_windows(type_queues, type_ratios)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def calculate_type_ratios(type_queues)
|
30
|
+
total_items = @items.size.to_f
|
31
|
+
type_queues.transform_values { |list| list.size / total_items }
|
32
|
+
end
|
33
|
+
|
34
|
+
def process_windows(type_queues, type_ratios)
|
35
|
+
result = []
|
36
|
+
used_items = Set.new
|
37
|
+
|
38
|
+
until result.size == @items.size
|
39
|
+
size = next_window_size(result)
|
40
|
+
window = balance_window(type_queues, type_ratios, size, used_items)
|
41
|
+
|
42
|
+
if window.empty?
|
43
|
+
append_remaining(result, used_items)
|
44
|
+
else
|
45
|
+
window.each do |item|
|
46
|
+
next if used_items.include?(item)
|
47
|
+
|
48
|
+
result << item
|
49
|
+
used_items.add(item)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
result
|
55
|
+
end
|
56
|
+
|
57
|
+
def next_window_size(result)
|
58
|
+
(@items.size - result.size).clamp(1, @window_size)
|
59
|
+
end
|
60
|
+
|
61
|
+
def append_remaining(result, used_items)
|
62
|
+
@items.each do |item|
|
63
|
+
next if used_items.include?(item)
|
64
|
+
|
65
|
+
result << item
|
66
|
+
used_items.add(item)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def balance_window(type_queues, type_ratios, window_size, used_items)
|
71
|
+
window_items = []
|
72
|
+
target_counts = calculate_window_targets(type_ratios, window_size)
|
73
|
+
current_counts = Hash.new(0)
|
74
|
+
|
75
|
+
while window_items.size < window_size
|
76
|
+
type_to_add = find_next_type(type_ratios, current_counts, target_counts, type_queues, used_items)
|
77
|
+
break unless type_to_add
|
78
|
+
|
79
|
+
next_item = type_queues[type_to_add].find { |i| !used_items.include?(i) }
|
80
|
+
break unless next_item
|
81
|
+
|
82
|
+
window_items << next_item
|
83
|
+
current_counts[type_to_add] += 1
|
84
|
+
end
|
85
|
+
|
86
|
+
window_items
|
87
|
+
end
|
88
|
+
|
89
|
+
def calculate_window_targets(type_ratios, window_size)
|
90
|
+
targets = type_ratios.transform_values { |ratio| (window_size * ratio).floor }
|
91
|
+
ensure_minimum_representation(targets, type_ratios)
|
92
|
+
scale_down_if_needed(targets, window_size)
|
93
|
+
distribute_remaining_slots(targets, type_ratios, window_size)
|
94
|
+
targets
|
95
|
+
end
|
96
|
+
|
97
|
+
def find_next_type(type_ratios, current_counts, target_counts, type_queues, used_items)
|
98
|
+
current_ratios = compute_current_ratios(current_counts, type_ratios)
|
99
|
+
eligible = eligible_types(type_ratios, current_counts, target_counts, type_queues, used_items)
|
100
|
+
eligible.min_by { |t| (current_ratios[t] || 0) - type_ratios[t] }
|
101
|
+
end
|
102
|
+
|
103
|
+
def ensure_minimum_representation(targets, type_ratios)
|
104
|
+
type_ratios.each_key do |t|
|
105
|
+
targets[t] = 1 if type_ratios[t].positive? && targets[t] < 1
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def scale_down_if_needed(targets, window_size)
|
110
|
+
total = targets.values.sum
|
111
|
+
return unless total > window_size
|
112
|
+
|
113
|
+
factor = window_size.to_f / total
|
114
|
+
targets.transform_values! { |count| (count * factor).floor }
|
115
|
+
end
|
116
|
+
|
117
|
+
def distribute_remaining_slots(targets, type_ratios, window_size)
|
118
|
+
remaining = window_size - targets.values.sum
|
119
|
+
return unless remaining.positive?
|
120
|
+
|
121
|
+
sorted = type_ratios.sort_by { |_t, r| -r }.map(&:first)
|
122
|
+
remaining.times { |i| targets[sorted[i % sorted.size]] += 1 }
|
123
|
+
end
|
124
|
+
|
125
|
+
def compute_current_ratios(current_counts, type_ratios)
|
126
|
+
total = current_counts.values.sum.to_f
|
127
|
+
return type_ratios.dup if total.zero?
|
128
|
+
|
129
|
+
current_counts.transform_values { |c| c / total }
|
130
|
+
end
|
131
|
+
|
132
|
+
def eligible_types(type_ratios, current_counts, target_counts, type_queues, used_items)
|
133
|
+
type_ratios.keys.select do |t|
|
134
|
+
type_queues[t].any? { |i| !used_items.include?(i) } &&
|
135
|
+
current_counts[t] < target_counts[t]
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypeBalancer
|
4
|
+
# Factory for creating and managing balancing strategies
|
5
|
+
class StrategyFactory
|
6
|
+
class << self
|
7
|
+
def create(strategy_name = nil, **)
|
8
|
+
strategy_name ||= default_strategy
|
9
|
+
strategy_class = find_strategy(strategy_name)
|
10
|
+
|
11
|
+
raise ArgumentError, "Unknown strategy: #{strategy_name}" unless strategy_class
|
12
|
+
|
13
|
+
strategy_class.new(**)
|
14
|
+
end
|
15
|
+
|
16
|
+
def register(name, strategy_class)
|
17
|
+
strategies[name.to_sym] = strategy_class
|
18
|
+
end
|
19
|
+
|
20
|
+
def default_strategy=(name)
|
21
|
+
raise ArgumentError, "Unknown strategy: #{name}" unless strategies.key?(name.to_sym)
|
22
|
+
|
23
|
+
@default_strategy = name.to_sym
|
24
|
+
end
|
25
|
+
|
26
|
+
def default_strategy
|
27
|
+
@default_strategy ||= :sliding_window
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def strategies
|
33
|
+
@strategies ||= {}
|
34
|
+
end
|
35
|
+
|
36
|
+
def find_strategy(name)
|
37
|
+
strategies[name.to_sym]
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -20,10 +20,15 @@ module TypeBalancer
|
|
20
20
|
if item.respond_to?(@type_field)
|
21
21
|
item.send(@type_field)
|
22
22
|
elsif item.respond_to?(:[])
|
23
|
-
item[@type_field] || item[@type_field.to_s]
|
23
|
+
value = item[@type_field] || item[@type_field.to_s]
|
24
|
+
raise TypeBalancer::Error, "Cannot access type field '#{@type_field}' on item #{item.inspect}" if value.nil?
|
25
|
+
|
26
|
+
value
|
24
27
|
else
|
25
|
-
raise Error, "Cannot access type field '#{@type_field}' on item #{item}"
|
28
|
+
raise TypeBalancer::Error, "Cannot access type field '#{@type_field}' on item #{item.inspect}"
|
26
29
|
end
|
30
|
+
rescue NoMethodError, TypeError
|
31
|
+
raise TypeBalancer::Error, "Cannot access type field '#{@type_field}' on item #{item.inspect}"
|
27
32
|
end
|
28
33
|
end
|
29
34
|
end
|