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.
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 check_real_world_feed
122
+ def check_position_precision
123
+ puts "\nPosition Precision Cases:"
124
+
125
+ # Two positions in three slots
101
126
  @examples_run += 1
102
- puts "\nReal World Example - Content Feed:"
103
- feed_size = 20
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
- # Track allocated positions
106
- allocated_positions = []
107
- content_positions = {}
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
- # Calculate positions for each type
110
- content_positions[:video] = TypeBalancer.calculate_positions(
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
- content_positions[:image] = TypeBalancer.calculate_positions(
118
- total_count: feed_size,
119
- ratio: 0.4,
120
- available_items: (0..14).to_a - allocated_positions
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
- allocated_positions += content_positions[:image]
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
- content_positions[:article] = TypeBalancer.calculate_positions(
125
- total_count: feed_size,
126
- ratio: 0.3,
127
- available_items: (0..19).to_a - allocated_positions
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
- puts "\nContent Type Positions:"
131
- content_positions.each do |type, pos|
132
- puts "#{type}: #{pos.inspect}"
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
- # Check for overlaps
136
- all_positions = content_positions.values.compact.flatten
137
- if all_positions == all_positions.uniq
138
- puts "\nSuccess: No overlapping positions!"
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
- overlaps = all_positions.group_by { |e| e }.select { |_, v| v.size > 1 }.keys
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
- # Verify distribution
147
- puts "\nDistribution Stats:"
148
- expected_counts = { video: 6, image: 8, article: 6 }
149
- content_positions.each do |type, positions|
150
- count = positions&.length || 0
151
- percentage = (count.to_f / feed_size * 100).round(1)
152
- puts "#{type}: #{count} items (#{percentage}% of feed)"
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
- if count != expected_counts[type]
155
- record_issue("#{type} count #{count} doesn't match expected #{expected_counts[type]}")
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 "Examples Passed: #{@examples_passed}"
335
+ puts "Expectations Passed: #{@examples_passed}"
165
336
 
166
337
  if @issues.empty?
167
- puts "\nAll quality checks passed! "
338
+ puts "\n#{GREEN}All quality checks passed! ✓#{RESET}"
168
339
  else
169
- puts "\nQuality check failed with #{@issues.size} issues:"
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
- # Main class responsible for balancing items in a collection based on their types.
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
- BATCH_SIZE = 500 # Process items in batches of 500 for better performance
9
-
10
- def initialize(collection, type_field: :type, types: nil, distribution_calculator: nil)
11
- @collection = collection
12
- @type_field = type_field
13
- @types = types || extract_types
14
- @distribution_calculator = distribution_calculator || Distributor
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
- def call
18
- return [] if @collection.empty?
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
- if @collection.size <= BATCH_SIZE
21
- process_single_batch(@collection)
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 process_single_batch(items)
30
- # Group items by type
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 process_multiple_batches
47
- result = []
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 calculate_positions_by_type(items_by_type, ratios, total_count)
55
- positions_by_type = {}
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
- positions_by_type
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 place_items_in_positions(items_by_type, positions_by_type, total_count)
68
- balanced_items = Array.new(total_count)
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 fill_gaps(balanced_items, original_items)
86
- # Fill any gaps with remaining items
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
- balanced_items[pos] = remaining_items[idx]
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 calculate_ratios(_items_by_type)
100
- case @types.size
101
- when 1
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 get_type(item)
113
- if item.respond_to?(@type_field)
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
- def extract_types
123
- TypeBalancer.extract_types(@collection, @type_field)
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