type_balancer 0.1.0 → 0.1.3
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/.DS_Store +0 -0
- data/CHANGELOG.md +37 -3
- data/Dockerfile +3 -1
- data/Gemfile.lock +1 -1
- data/README.md +129 -9
- data/Rakefile +7 -29
- data/benchmark/end_to_end_benchmark.rb +6 -3
- data/benchmark_results/ruby3.2.8.txt +8 -8
- data/benchmark_results/ruby3.2.8_yjit.txt +13 -13
- data/benchmark_results/ruby3.3.7.txt +8 -8
- data/benchmark_results/ruby3.3.7_yjit.txt +13 -13
- data/benchmark_results/ruby3.4.2.txt +8 -8
- data/benchmark_results/ruby3.4.2_yjit.txt +13 -13
- data/docs/README.md +8 -0
- data/docs/balance.md +71 -0
- data/docs/benchmarks/README.md +57 -51
- data/docs/calculate_positions.md +87 -0
- data/docs/quality.md +101 -0
- data/examples/balance_test_data.yml +66 -0
- data/examples/quality.rb +230 -46
- data/lib/type_balancer/balancer.rb +71 -94
- data/lib/type_balancer/batch_processing.rb +35 -0
- data/lib/type_balancer/distributor.rb +26 -53
- data/lib/type_balancer/position_calculator.rb +61 -0
- data/lib/type_balancer/ratio_calculator.rb +91 -0
- data/lib/type_balancer/type_extractor.rb +29 -0
- data/lib/type_balancer/version.rb +1 -1
- data/lib/type_balancer.rb +36 -17
- metadata +12 -3
- data/sig/type_balancer.rbs +0 -85
data/examples/quality.rb
CHANGED
@@ -1,8 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'type_balancer'
|
4
|
+
require 'yaml'
|
4
5
|
|
5
6
|
class QualityChecker
|
7
|
+
GREEN = "\e[32m"
|
8
|
+
RED = "\e[31m"
|
9
|
+
YELLOW = "\e[33m"
|
10
|
+
RESET = "\e[0m"
|
11
|
+
|
6
12
|
def initialize
|
7
13
|
@issues = []
|
8
14
|
@examples_run = 0
|
@@ -13,6 +19,9 @@ class QualityChecker
|
|
13
19
|
check_basic_distribution
|
14
20
|
check_available_items
|
15
21
|
check_edge_cases
|
22
|
+
check_position_precision
|
23
|
+
check_available_positions_edge_cases
|
24
|
+
check_balance_method_robust
|
16
25
|
check_real_world_feed
|
17
26
|
|
18
27
|
print_summary
|
@@ -27,11 +36,15 @@ class QualityChecker
|
|
27
36
|
|
28
37
|
def check_basic_distribution
|
29
38
|
@examples_run += 1
|
39
|
+
@section_examples_run = 0 if !defined?(@section_examples_run)
|
40
|
+
@section_examples_passed = 0 if !defined?(@section_examples_passed)
|
41
|
+
@section_examples_run += 1
|
30
42
|
puts "\nBasic Distribution Example:"
|
31
43
|
positions = TypeBalancer.calculate_positions(total_count: 10, ratio: 0.3)
|
32
44
|
|
33
45
|
if positions == [0, 5, 9]
|
34
46
|
@examples_passed += 1
|
47
|
+
@section_examples_passed += 1
|
35
48
|
else
|
36
49
|
record_issue("Basic distribution positions #{positions.inspect} don't match expected [0, 5, 9]")
|
37
50
|
end
|
@@ -47,6 +60,7 @@ class QualityChecker
|
|
47
60
|
|
48
61
|
def check_available_items
|
49
62
|
@examples_run += 1
|
63
|
+
@section_examples_run += 1
|
50
64
|
puts "\nAvailable Items Example:"
|
51
65
|
positions = TypeBalancer.calculate_positions(
|
52
66
|
total_count: 10,
|
@@ -56,6 +70,7 @@ class QualityChecker
|
|
56
70
|
|
57
71
|
if positions == [0, 1, 2]
|
58
72
|
@examples_passed += 1
|
73
|
+
@section_examples_passed += 1
|
59
74
|
else
|
60
75
|
record_issue("Available items test returned #{positions.inspect} instead of expected [0, 1, 2]")
|
61
76
|
end
|
@@ -68,111 +83,280 @@ class QualityChecker
|
|
68
83
|
|
69
84
|
# Single item
|
70
85
|
@examples_run += 1
|
86
|
+
@section_examples_run += 1
|
71
87
|
single = TypeBalancer.calculate_positions(total_count: 1, ratio: 1.0)
|
72
88
|
puts "Single item: #{single.inspect}"
|
73
89
|
if single == [0]
|
74
90
|
@examples_passed += 1
|
91
|
+
@section_examples_passed += 1
|
75
92
|
else
|
76
93
|
record_issue("Single item case returned #{single.inspect} instead of [0]")
|
77
94
|
end
|
78
95
|
|
79
96
|
# No items
|
80
97
|
@examples_run += 1
|
98
|
+
@section_examples_run += 1
|
81
99
|
none = TypeBalancer.calculate_positions(total_count: 100, ratio: 0.0)
|
82
100
|
puts "No items needed: #{none.inspect}"
|
83
101
|
if none == []
|
84
102
|
@examples_passed += 1
|
103
|
+
@section_examples_passed += 1
|
85
104
|
else
|
86
105
|
record_issue("Zero ratio case returned #{none.inspect} instead of []")
|
87
106
|
end
|
88
107
|
|
89
108
|
# All items
|
90
109
|
@examples_run += 1
|
110
|
+
@section_examples_run += 1
|
91
111
|
all = TypeBalancer.calculate_positions(total_count: 5, ratio: 1.0)
|
92
112
|
puts "All items needed: #{all.inspect}"
|
93
113
|
if all == [0, 1, 2, 3, 4]
|
94
114
|
@examples_passed += 1
|
115
|
+
@section_examples_passed += 1
|
95
116
|
else
|
96
117
|
record_issue("Full ratio case returned #{all.inspect} instead of [0, 1, 2, 3, 4]")
|
97
118
|
end
|
119
|
+
print_section_table('calculate_positions')
|
98
120
|
end
|
99
121
|
|
100
|
-
def
|
122
|
+
def check_position_precision
|
123
|
+
puts "\nPosition Precision Cases:"
|
124
|
+
|
125
|
+
# Two positions in three slots
|
101
126
|
@examples_run += 1
|
102
|
-
|
103
|
-
|
127
|
+
@section_examples_run += 1
|
128
|
+
positions = TypeBalancer.calculate_positions(total_count: 3, ratio: 0.67)
|
129
|
+
puts "Two positions in three slots: #{positions.inspect}"
|
130
|
+
if positions == [0, 1]
|
131
|
+
@examples_passed += 1
|
132
|
+
@section_examples_passed += 1
|
133
|
+
else
|
134
|
+
record_issue("Two in three case returned #{positions.inspect} instead of [0, 1]")
|
135
|
+
end
|
104
136
|
|
105
|
-
#
|
106
|
-
|
107
|
-
|
137
|
+
# Single position in three slots
|
138
|
+
@examples_run += 1
|
139
|
+
@section_examples_run += 1
|
140
|
+
positions = TypeBalancer.calculate_positions(total_count: 3, ratio: 0.34)
|
141
|
+
puts "Single position in three slots: #{positions.inspect}"
|
142
|
+
if positions == [0]
|
143
|
+
@examples_passed += 1
|
144
|
+
@section_examples_passed += 1
|
145
|
+
else
|
146
|
+
record_issue("One in three case returned #{positions.inspect} instead of [0]")
|
147
|
+
end
|
148
|
+
print_section_table('calculate_positions')
|
149
|
+
end
|
108
150
|
|
109
|
-
|
110
|
-
|
111
|
-
total_count: feed_size,
|
112
|
-
ratio: 0.3,
|
113
|
-
available_items: (0..7).to_a - allocated_positions
|
114
|
-
)
|
115
|
-
allocated_positions += content_positions[:video]
|
151
|
+
def check_available_positions_edge_cases
|
152
|
+
puts "\nAvailable Positions Edge Cases:"
|
116
153
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
154
|
+
# Single target with multiple available positions
|
155
|
+
@examples_run += 1
|
156
|
+
@section_examples_run += 1
|
157
|
+
positions = TypeBalancer.calculate_positions(
|
158
|
+
total_count: 5,
|
159
|
+
ratio: 0.2,
|
160
|
+
available_items: [1, 2, 3]
|
121
161
|
)
|
122
|
-
|
162
|
+
puts "Single target with multiple available: #{positions.inspect}"
|
163
|
+
if positions == [1]
|
164
|
+
@examples_passed += 1
|
165
|
+
@section_examples_passed += 1
|
166
|
+
else
|
167
|
+
record_issue("Single target with multiple available returned #{positions.inspect} instead of [1]")
|
168
|
+
end
|
123
169
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
170
|
+
# Two targets with multiple available positions
|
171
|
+
@examples_run += 1
|
172
|
+
@section_examples_run += 1
|
173
|
+
positions = TypeBalancer.calculate_positions(
|
174
|
+
total_count: 10,
|
175
|
+
ratio: 0.2,
|
176
|
+
available_items: [1, 3, 5]
|
128
177
|
)
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
178
|
+
puts "Two targets with multiple available: #{positions.inspect}"
|
179
|
+
if positions == [1, 5]
|
180
|
+
@examples_passed += 1
|
181
|
+
@section_examples_passed += 1
|
182
|
+
else
|
183
|
+
record_issue("Two targets with multiple available returned #{positions.inspect} instead of [1, 5]")
|
133
184
|
end
|
134
185
|
|
135
|
-
#
|
136
|
-
|
137
|
-
|
138
|
-
|
186
|
+
# Exact match of available positions
|
187
|
+
@examples_run += 1
|
188
|
+
@section_examples_run += 1
|
189
|
+
positions = TypeBalancer.calculate_positions(
|
190
|
+
total_count: 10,
|
191
|
+
ratio: 0.3,
|
192
|
+
available_items: [2, 4, 6]
|
193
|
+
)
|
194
|
+
puts "Exact match of available positions: #{positions.inspect}"
|
195
|
+
if positions == [2, 4, 6]
|
139
196
|
@examples_passed += 1
|
197
|
+
@section_examples_passed += 1
|
140
198
|
else
|
141
|
-
|
142
|
-
record_issue("Found overlapping positions at indices: #{overlaps.inspect}")
|
143
|
-
puts "\nWarning: Some positions overlap!"
|
199
|
+
record_issue("Exact match case returned #{positions.inspect} instead of [2, 4, 6]")
|
144
200
|
end
|
201
|
+
print_section_table('calculate_positions')
|
202
|
+
end
|
145
203
|
|
146
|
-
|
147
|
-
puts "\
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
204
|
+
def check_balance_method_robust
|
205
|
+
puts "\n#{YELLOW}Robust Balance Method Tests:#{RESET}"
|
206
|
+
scenarios = YAML.load_file(File.expand_path('../balance_test_data.yml', __FILE__))
|
207
|
+
section_run = 0
|
208
|
+
section_passed = 0
|
209
|
+
scenarios.each do |scenario|
|
210
|
+
@examples_run += 1
|
211
|
+
section_run += 1
|
212
|
+
# Deep symbolize keys for all items in the scenario
|
213
|
+
items = (scenario['items'] || []).map { |item| deep_symbolize_keys(item) }
|
214
|
+
type_order = scenario['type_order']
|
215
|
+
expected_type_counts = scenario['expected_type_counts'] || {}
|
216
|
+
expected_first_type = scenario['expected_first_type']
|
153
217
|
|
154
|
-
|
155
|
-
|
218
|
+
# Test with and without type_order
|
219
|
+
[nil, type_order].uniq.each do |order|
|
220
|
+
label = order ? "with type_order #{order}" : "default order"
|
221
|
+
begin
|
222
|
+
# Special handling for the empty input case
|
223
|
+
if scenario['name'] =~ /empty/i
|
224
|
+
begin
|
225
|
+
if order
|
226
|
+
TypeBalancer.balance(items, type_field: :type, type_order: order)
|
227
|
+
else
|
228
|
+
TypeBalancer.balance(items, type_field: :type)
|
229
|
+
end
|
230
|
+
# If no exception, this is a failure
|
231
|
+
record_issue("#{scenario['name']} (#{label}): Expected exception for empty input, but none was raised")
|
232
|
+
puts "#{RED}#{scenario['name']} (#{label}): Expected exception for empty input, but none was raised#{RESET}"
|
233
|
+
rescue => e
|
234
|
+
if e.message =~ /Collection cannot be empty/
|
235
|
+
@examples_passed += 1
|
236
|
+
section_passed += 1
|
237
|
+
puts "#{GREEN}#{scenario['name']} (#{label}): Correctly raised exception for empty input#{RESET}"
|
238
|
+
else
|
239
|
+
record_issue("#{scenario['name']} (#{label}): Unexpected exception: #{e}")
|
240
|
+
puts "#{RED}#{scenario['name']} (#{label}): Unexpected exception: #{e}#{RESET}"
|
241
|
+
end
|
242
|
+
end
|
243
|
+
next
|
244
|
+
end
|
245
|
+
# Normal test logic for other cases
|
246
|
+
result = if order
|
247
|
+
TypeBalancer.balance(items, type_field: :type, type_order: order)
|
248
|
+
else
|
249
|
+
TypeBalancer.balance(items, type_field: :type)
|
250
|
+
end
|
251
|
+
rescue => e
|
252
|
+
record_issue("#{scenario['name']} (#{label}): Exception raised: #{e}")
|
253
|
+
puts "#{RED}#{scenario['name']} (#{label}): Exception raised: #{e}#{RESET}"
|
254
|
+
next
|
255
|
+
end
|
256
|
+
# Check type counts
|
257
|
+
type_counts = result.group_by { |i| i[:type] }.transform_values(&:size)
|
258
|
+
if expected_type_counts && !expected_type_counts.empty?
|
259
|
+
if type_counts != expected_type_counts
|
260
|
+
record_issue("#{scenario['name']} (#{label}): Type counts #{type_counts.inspect} do not match expected #{expected_type_counts.inspect}")
|
261
|
+
puts "#{RED}#{scenario['name']} (#{label}): Type counts #{type_counts.inspect} do not match expected #{expected_type_counts.inspect}#{RESET}"
|
262
|
+
else
|
263
|
+
@examples_passed += 1
|
264
|
+
section_passed += 1
|
265
|
+
puts "#{GREEN}#{scenario['name']} (#{label}): Passed#{RESET}"
|
266
|
+
end
|
267
|
+
end
|
268
|
+
# Check first type for custom order
|
269
|
+
if expected_first_type && order
|
270
|
+
if result.first && result.first[:type] != expected_first_type
|
271
|
+
record_issue("#{scenario['name']} (#{label}): First type #{result.first[:type]} does not match expected #{expected_first_type}")
|
272
|
+
puts "#{RED}#{scenario['name']} (#{label}): First type #{result.first[:type]} does not match expected #{expected_first_type}#{RESET}"
|
273
|
+
else
|
274
|
+
@examples_passed += 1
|
275
|
+
section_passed += 1
|
276
|
+
puts "#{GREEN}#{scenario['name']} (#{label}): Custom order respected#{RESET}"
|
277
|
+
end
|
278
|
+
end
|
279
|
+
puts " Result: #{result.map { |i| i[:type] }.inspect}"
|
280
|
+
puts " Type counts: #{type_counts.inspect}"
|
156
281
|
end
|
157
282
|
end
|
283
|
+
print_section_table('balance_method', section_run, section_passed)
|
284
|
+
end
|
285
|
+
|
286
|
+
# Helper to deeply symbolize keys in a hash
|
287
|
+
def deep_symbolize_keys(obj)
|
288
|
+
case obj
|
289
|
+
when Hash
|
290
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize_keys(v) }
|
291
|
+
when Array
|
292
|
+
obj.map { |v| deep_symbolize_keys(v) }
|
293
|
+
else
|
294
|
+
obj
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def check_real_world_feed
|
299
|
+
@examples_run += 1
|
300
|
+
puts "\n#{YELLOW}Real World Example - Content Feed:#{RESET}"
|
301
|
+
feed_size = 20
|
302
|
+
items = [
|
303
|
+
{ type: 'video', id: 1 },
|
304
|
+
{ type: 'video', id: 2 },
|
305
|
+
{ type: 'video', id: 3 },
|
306
|
+
{ type: 'image', id: 4 },
|
307
|
+
{ type: 'image', id: 5 },
|
308
|
+
{ type: 'image', id: 6 },
|
309
|
+
{ type: 'article', id: 7 },
|
310
|
+
{ type: 'article', id: 8 },
|
311
|
+
{ type: 'article', id: 9 }
|
312
|
+
]
|
313
|
+
# Test with custom type order
|
314
|
+
ordered_result = TypeBalancer.balance(
|
315
|
+
items,
|
316
|
+
type_field: :type,
|
317
|
+
type_order: %w[article image video]
|
318
|
+
)
|
319
|
+
if ordered_result.first[:type] == 'article'
|
320
|
+
@examples_passed += 1
|
321
|
+
puts "#{GREEN}Custom type order respected in real world feed#{RESET}"
|
322
|
+
print_section_table('real_world_feed', 1, 1)
|
323
|
+
else
|
324
|
+
record_issue("Custom type order not respected in real world feed")
|
325
|
+
puts "#{RED}Custom type order not respected in real world feed#{RESET}"
|
326
|
+
print_section_table('real_world_feed', 1, 0)
|
327
|
+
end
|
328
|
+
puts " Balanced items with custom order: #{ordered_result.map { |i| i[:type] }.inspect}"
|
158
329
|
end
|
159
330
|
|
160
331
|
def print_summary
|
161
332
|
puts "\n#{'-' * 50}"
|
162
333
|
puts 'Quality Check Summary:'
|
163
334
|
puts "Examples Run: #{@examples_run}"
|
164
|
-
puts "
|
335
|
+
puts "Expectations Passed: #{@examples_passed}"
|
165
336
|
|
166
337
|
if @issues.empty?
|
167
|
-
puts "\
|
338
|
+
puts "\n#{GREEN}All quality checks passed! ✓#{RESET}"
|
168
339
|
else
|
169
|
-
puts "\
|
340
|
+
puts "\n#{RED}Quality check failed with #{@issues.size} issues:#{RESET}"
|
170
341
|
@issues.each_with_index do |issue, index|
|
171
|
-
puts "#{index + 1}. #{issue}"
|
342
|
+
puts "#{RED}#{index + 1}. #{issue}#{RESET}"
|
172
343
|
end
|
173
344
|
end
|
174
345
|
puts "#{'-' * 50}"
|
175
346
|
end
|
347
|
+
|
348
|
+
# Print a summary table for a section
|
349
|
+
def print_section_table(section, run = @section_examples_run, passed = @section_examples_passed)
|
350
|
+
failed = run - passed
|
351
|
+
puts "\nSection: #{section}"
|
352
|
+
puts "-----------------------------"
|
353
|
+
puts " #{GREEN}Passing: #{passed}#{RESET}"
|
354
|
+
puts " #{RED}Failing: #{failed}#{RESET}"
|
355
|
+
puts "-----------------------------\n"
|
356
|
+
# Reset section counters for next section
|
357
|
+
@section_examples_run = 0
|
358
|
+
@section_examples_passed = 0
|
359
|
+
end
|
176
360
|
end
|
177
361
|
|
178
362
|
QualityChecker.new.run
|
@@ -1,126 +1,103 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative 'ratio_calculator'
|
4
|
+
require_relative 'batch_processing'
|
5
|
+
require_relative 'position_calculator'
|
6
|
+
|
3
7
|
module TypeBalancer
|
4
|
-
#
|
5
|
-
# It uses a distribution calculator to determine optimal positions for each type
|
6
|
-
# and a gap filler strategy to place items in the final sequence.
|
8
|
+
# Handles balancing of items across batches based on type ratios
|
7
9
|
class Balancer
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
@types = types
|
14
|
-
@
|
10
|
+
# Initialize a new Balancer instance
|
11
|
+
#
|
12
|
+
# @param types [Array<String>, nil] Optional types
|
13
|
+
# @param type_order [Array<String>, nil] Optional order of types
|
14
|
+
def initialize(types = nil, type_order: nil)
|
15
|
+
@types = Array(types) if types
|
16
|
+
@type_order = type_order
|
17
|
+
validate_types! if @types
|
15
18
|
end
|
16
19
|
|
17
|
-
|
18
|
-
|
20
|
+
# Main entry point for balancing items
|
21
|
+
#
|
22
|
+
# @param collection [Array] Items to balance
|
23
|
+
# @return [Array] Balanced items
|
24
|
+
def call(collection)
|
25
|
+
validate_collection!(collection)
|
26
|
+
items_by_type = group_items_by_type(collection)
|
27
|
+
validate_types_in_collection!(items_by_type)
|
28
|
+
|
29
|
+
target_counts = calculate_target_counts(items_by_type)
|
30
|
+
available_positions = (0...collection.size).to_a
|
31
|
+
|
32
|
+
result = Array.new(collection.size)
|
33
|
+
sorted_types = sort_types(items_by_type.keys)
|
34
|
+
|
35
|
+
sorted_types.each do |type|
|
36
|
+
items = items_by_type[type]
|
37
|
+
target_count = target_counts[type]
|
38
|
+
ratio = target_count.to_f / collection.size
|
39
|
+
positions = PositionCalculator.calculate_positions(
|
40
|
+
total_count: collection.size,
|
41
|
+
ratio: ratio,
|
42
|
+
available_items: available_positions
|
43
|
+
)
|
44
|
+
|
45
|
+
positions.each_with_index do |pos, idx|
|
46
|
+
result[pos] = items[idx]
|
47
|
+
end
|
19
48
|
|
20
|
-
|
21
|
-
|
22
|
-
else
|
23
|
-
process_multiple_batches
|
49
|
+
# Remove used positions from available positions
|
50
|
+
available_positions -= positions
|
24
51
|
end
|
52
|
+
|
53
|
+
result.compact
|
25
54
|
end
|
26
55
|
|
27
56
|
private
|
28
57
|
|
29
|
-
def
|
30
|
-
|
31
|
-
items_by_type = items.group_by { |item| get_type(item) }
|
32
|
-
|
33
|
-
# Calculate ratios based on type order and counts
|
34
|
-
ratios = calculate_ratios(items_by_type)
|
35
|
-
|
36
|
-
# Calculate positions for each type
|
37
|
-
positions_by_type = calculate_positions_by_type(items_by_type, ratios, items.size)
|
38
|
-
|
39
|
-
# Map items to their balanced positions
|
40
|
-
balanced_items = place_items_in_positions(items_by_type, positions_by_type, items.size)
|
41
|
-
|
42
|
-
# Fill any gaps with remaining items
|
43
|
-
fill_gaps(balanced_items, items)
|
58
|
+
def validate_types!
|
59
|
+
raise ArgumentError, 'Types cannot be empty' if @types.empty?
|
44
60
|
end
|
45
61
|
|
46
|
-
def
|
47
|
-
|
48
|
-
@collection.each_slice(BATCH_SIZE) do |batch|
|
49
|
-
result.concat(process_single_batch(batch))
|
50
|
-
end
|
51
|
-
result
|
62
|
+
def validate_collection!(collection)
|
63
|
+
raise ArgumentError, 'Collection cannot be empty' if collection.empty?
|
52
64
|
end
|
53
65
|
|
54
|
-
def
|
55
|
-
|
56
|
-
|
57
|
-
@types.each_with_index do |type, index|
|
58
|
-
items = items_by_type[type] || []
|
59
|
-
ratio = ratios[index]
|
60
|
-
positions = @distribution_calculator.calculate_target_positions(total_count, items.size, ratio)
|
61
|
-
positions_by_type[type] = positions
|
62
|
-
end
|
66
|
+
def validate_types_in_collection!(items_by_type)
|
67
|
+
return unless @types
|
63
68
|
|
64
|
-
|
69
|
+
invalid_types = items_by_type.keys - @types
|
70
|
+
raise TypeBalancer::Error, "Invalid type(s): #{invalid_types.join(', ')}" if invalid_types.any?
|
65
71
|
end
|
66
72
|
|
67
|
-
def
|
68
|
-
|
69
|
-
|
70
|
-
@types.each do |type|
|
71
|
-
items = items_by_type[type] || []
|
72
|
-
positions = positions_by_type[type] || []
|
73
|
-
|
74
|
-
items.each_with_index do |item, index|
|
75
|
-
pos = positions[index]
|
76
|
-
next unless pos && pos < total_count && balanced_items[pos].nil?
|
77
|
-
|
78
|
-
balanced_items[pos] = item
|
79
|
-
end
|
73
|
+
def group_items_by_type(collection)
|
74
|
+
collection.group_by do |item|
|
75
|
+
extract_type(item)
|
80
76
|
end
|
81
|
-
|
82
|
-
balanced_items
|
83
77
|
end
|
84
78
|
|
85
|
-
def
|
86
|
-
|
87
|
-
remaining_items = original_items.reject { |item| balanced_items.include?(item) }
|
88
|
-
empty_positions = balanced_items.each_index.select { |i| balanced_items[i].nil? }
|
89
|
-
|
90
|
-
empty_positions.each_with_index do |pos, idx|
|
91
|
-
break unless idx < remaining_items.size
|
79
|
+
def extract_type(item)
|
80
|
+
return item[:type] || item['type'] || raise(TypeBalancer::Error, 'Cannot access type field') if item.is_a?(Hash)
|
92
81
|
|
93
|
-
|
82
|
+
begin
|
83
|
+
item.type
|
84
|
+
rescue NoMethodError
|
85
|
+
raise TypeBalancer::Error, 'Cannot access type field'
|
94
86
|
end
|
95
|
-
|
96
|
-
balanced_items.compact
|
97
87
|
end
|
98
88
|
|
99
|
-
def
|
100
|
-
|
101
|
-
|
102
|
-
[1.0]
|
103
|
-
when 2
|
104
|
-
[0.6, 0.4]
|
105
|
-
else
|
106
|
-
# First type gets 0.4, rest split remaining 0.6 evenly
|
107
|
-
remaining = (0.6 / (@types.size - 1).to_f).round(6)
|
108
|
-
[0.4] + Array.new(@types.size - 1, remaining)
|
109
|
-
end
|
89
|
+
def calculate_target_counts(items_by_type)
|
90
|
+
items_by_type.values.sum(&:size)
|
91
|
+
items_by_type.transform_values(&:size)
|
110
92
|
end
|
111
93
|
|
112
|
-
def
|
113
|
-
|
114
|
-
item.send(@type_field)
|
115
|
-
elsif item.respond_to?(:[])
|
116
|
-
item[@type_field] || item[@type_field.to_s]
|
117
|
-
else
|
118
|
-
raise Error, "Cannot access type field '#{@type_field}' on item #{item}"
|
119
|
-
end
|
120
|
-
end
|
94
|
+
def sort_types(types)
|
95
|
+
return types.sort unless @type_order
|
121
96
|
|
122
|
-
|
123
|
-
|
97
|
+
types.sort_by do |type|
|
98
|
+
idx = @type_order.index(type)
|
99
|
+
idx || Float::INFINITY
|
100
|
+
end
|
124
101
|
end
|
125
102
|
end
|
126
103
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypeBalancer
|
4
|
+
class BatchProcessing
|
5
|
+
def initialize(batch_size)
|
6
|
+
@batch_size = batch_size
|
7
|
+
end
|
8
|
+
|
9
|
+
def create_batches(items_by_type, positions_by_type)
|
10
|
+
total_items = items_by_type.values.sum(&:size)
|
11
|
+
batches = []
|
12
|
+
current_batch = []
|
13
|
+
|
14
|
+
(0...total_items).each do |position|
|
15
|
+
type = find_type_for_position(position, positions_by_type)
|
16
|
+
current_batch << items_by_type[type].shift if type && !items_by_type[type].empty?
|
17
|
+
|
18
|
+
if current_batch.size >= @batch_size || position == total_items - 1
|
19
|
+
batches << current_batch unless current_batch.empty?
|
20
|
+
current_batch = []
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
batches
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def find_type_for_position(position, positions_by_type)
|
30
|
+
positions_by_type.find do |_, positions|
|
31
|
+
positions.include?(position)
|
32
|
+
end&.first
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|