type_balancer 0.1.4 → 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.
data/examples/quality.rb CHANGED
@@ -24,6 +24,7 @@ class QualityChecker
24
24
  check_balance_method_robust
25
25
  check_real_world_feed
26
26
  check_custom_type_field
27
+ check_strategy_system
27
28
 
28
29
  print_summary
29
30
  exit(@issues.empty? ? 0 : 1)
@@ -351,6 +352,75 @@ class QualityChecker
351
352
  print_section_table('custom_type_field', 1, found == expected ? 1 : 0)
352
353
  end
353
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
+
354
424
  def print_summary
355
425
  puts "\n#{'-' * 50}"
356
426
  puts 'Quality Check Summary:'
@@ -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 || extract_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
- validate_items!
121
-
122
- items_by_type = @types.map { |type| @items.select { |item| item[@type_field].to_s == type } }
123
-
124
- # Calculate target positions for each type
125
- target_positions = calculate_target_positions(items_by_type)
126
-
127
- # Place items at their target positions
128
- place_items_at_positions(items_by_type, target_positions)
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 and round down for specific ratios
10
- target_count = if ratio <= 0.34
11
- 1 # For ratios <= 0.34, always use 1 position
12
- elsif ratio <= 0.67
13
- 2 # For ratios <= 0.67, always use 2 positions
14
- else
15
- (total_count * ratio).ceil
16
- end
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
- # Special case for 3 slots
22
- if total_count == 3
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
- TypeBalancer::PositionCalculator.calculate_positions(
28
- total_count: total_count,
29
- ratio: ratio,
30
- available_items: available_positions
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ # Module containing all balancing strategies
5
+ module Strategies
6
+ end
7
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypeBalancer
4
- VERSION = '0.1.4'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/type_balancer.rb CHANGED
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'type_balancer/version'
4
- require 'type_balancer/calculator'
5
4
  require_relative 'type_balancer/balancer'
6
5
  require_relative 'type_balancer/ratio_calculator'
7
6
  require_relative 'type_balancer/batch_processing'
8
- require 'type_balancer/position_calculator'
7
+ require_relative 'type_balancer/position_calculator'
9
8
  require_relative 'type_balancer/type_extractor'
10
9
  require_relative 'type_balancer/type_extractor_registry'
10
+ require_relative 'type_balancer/strategies/base_strategy'
11
+ require_relative 'type_balancer/strategies/sliding_window_strategy'
12
+ require_relative 'type_balancer/strategy_factory'
11
13
 
12
14
  module TypeBalancer
13
15
  class Error < StandardError; end
@@ -16,22 +18,32 @@ module TypeBalancer
16
18
  class EmptyCollectionError < Error; end
17
19
  class InvalidTypeError < Error; end
18
20
 
21
+ # Register default strategies
22
+ StrategyFactory.register(:sliding_window, Strategies::SlidingWindowStrategy)
23
+
19
24
  # Load Ruby implementations
20
25
  require_relative 'type_balancer/distribution_calculator'
21
26
  require_relative 'type_balancer/ordered_collection_manager'
22
- require_relative 'type_balancer/alternating_filler'
23
- require_relative 'type_balancer/sequential_filler'
27
+ require_relative 'type_balancer/type_extractor'
28
+ require_relative 'type_balancer/type_extractor_registry'
29
+ require_relative 'type_balancer/ratio_calculator'
30
+ require_relative 'type_balancer/position_calculator'
24
31
  require_relative 'type_balancer/distributor'
32
+ require_relative 'type_balancer/sequential_filler'
33
+ require_relative 'type_balancer/alternating_filler'
34
+ require_relative 'type_balancer/balancer'
35
+ require_relative 'type_balancer/batch_processing'
36
+ require_relative 'type_balancer/calculator'
25
37
 
26
38
  def self.calculate_positions(total_count:, ratio:, available_items: nil)
27
- Distributor.calculate_target_positions(
39
+ PositionCalculator.calculate_positions(
28
40
  total_count: total_count,
29
41
  ratio: ratio,
30
- available_positions: available_items
42
+ available_items: available_items
31
43
  )
32
44
  end
33
45
 
34
- def self.balance(items, type_field: :type, type_order: nil)
46
+ def self.balance(items, type_field: :type, type_order: nil, strategy: nil, **strategy_options)
35
47
  # Input validation
36
48
  raise EmptyCollectionError, 'Collection cannot be empty' if items.empty?
37
49
 
@@ -44,11 +56,17 @@ module TypeBalancer
44
56
  raise Error, "Cannot access type field '#{type_field}': #{e.message}"
45
57
  end
46
58
 
47
- # Initialize balancer with type order and type field
48
- balancer = Balancer.new(types, type_field: type_field, type_order: type_order)
59
+ # Create calculator with strategy options
60
+ calculator = Calculator.new(
61
+ items,
62
+ type_field: type_field,
63
+ types: type_order || types,
64
+ strategy: strategy,
65
+ **strategy_options
66
+ )
49
67
 
50
68
  # Balance items
51
- balancer.call(items)
69
+ calculator.call
52
70
  end
53
71
 
54
72
  # Backward compatibility methods