type_balancer 0.1.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.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ # Fills gaps by alternating between primary and secondary items
5
+ class AlternatingFiller
6
+ def initialize(collection, primary_items, secondary_items)
7
+ @collection = collection
8
+ @primary_items = primary_items
9
+ @secondary_items = secondary_items
10
+ end
11
+
12
+ def self.fill(collection, positions, primary_items, secondary_items)
13
+ new(collection, primary_items, secondary_items).fill_gaps(positions)
14
+ end
15
+
16
+ def fill_gaps(positions)
17
+ return positions if positions.compact.size == positions.size
18
+ return [] if positions.empty?
19
+
20
+ filled_positions = positions.dup
21
+ use_primary = true
22
+
23
+ positions.each_with_index do |pos, idx|
24
+ next unless pos.nil?
25
+
26
+ item = select_next_item(use_primary)
27
+ break unless item
28
+
29
+ filled_positions[idx] = item
30
+ use_primary = !use_primary
31
+ end
32
+
33
+ filled_positions
34
+ end
35
+
36
+ private
37
+
38
+ def select_next_item(use_primary)
39
+ if (use_primary || @secondary_items.empty?) && !@primary_items.empty?
40
+ @primary_items.shift
41
+ elsif !@secondary_items.empty?
42
+ @secondary_items.shift
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ 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.
7
+ 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
15
+ end
16
+
17
+ def call
18
+ return [] if @collection.empty?
19
+
20
+ if @collection.size <= BATCH_SIZE
21
+ process_single_batch(@collection)
22
+ else
23
+ process_multiple_batches
24
+ end
25
+ end
26
+
27
+ private
28
+
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)
44
+ end
45
+
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
52
+ end
53
+
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
63
+
64
+ positions_by_type
65
+ end
66
+
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
80
+ end
81
+
82
+ balanced_items
83
+ end
84
+
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
92
+
93
+ balanced_items[pos] = remaining_items[idx]
94
+ end
95
+
96
+ balanced_items.compact
97
+ end
98
+
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
110
+ end
111
+
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
121
+
122
+ def extract_types
123
+ TypeBalancer.extract_types(@collection, @type_field)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ # Handles calculation of positions for balanced item distribution
5
+ class PositionCalculator
6
+ # Represents a batch of position calculations
7
+ class PositionBatch
8
+ attr_reader :total_count, :ratio, :available_items
9
+
10
+ def initialize(total_count:, ratio:, available_items: nil)
11
+ @total_count = total_count
12
+ @ratio = ratio
13
+ @available_items = available_items
14
+ end
15
+
16
+ def valid?
17
+ valid_basic_inputs? && valid_available_items?
18
+ end
19
+
20
+ def target_count
21
+ @target_count ||= (total_count * ratio).round
22
+ end
23
+
24
+ private
25
+
26
+ def valid_basic_inputs?
27
+ total_count.positive? && ratio.positive? && ratio <= 1.0
28
+ end
29
+
30
+ def valid_available_items?
31
+ return true if available_items.nil?
32
+
33
+ valid_array? && valid_indices?
34
+ end
35
+
36
+ def valid_array?
37
+ available_items.is_a?(Array) && available_items.all? { |i| i.is_a?(Integer) }
38
+ end
39
+
40
+ def valid_indices?
41
+ available_items.none? { |i| i.negative? || i >= total_count }
42
+ end
43
+ end
44
+
45
+ class << self
46
+ # Calculate positions for a single input (legacy support)
47
+ def calculate_positions(total_count:, ratio:, available_items: nil)
48
+ return [] if total_count.zero? || ratio.zero?
49
+ return nil unless valid_inputs?(total_count, ratio)
50
+
51
+ target_count = (total_count * ratio).round
52
+ return [] if target_count.zero?
53
+
54
+ if available_items
55
+ calculate_positions_with_available_items(total_count, target_count, available_items)
56
+ else
57
+ calculate_evenly_spaced_positions(total_count, target_count, ratio)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def calculate_positions_with_available_items(total_count, target_count, available_items)
64
+ return nil if available_items.any? { |pos| pos >= total_count }
65
+ return available_items.take(target_count) if available_items.size <= target_count
66
+
67
+ if target_count == 1
68
+ [available_items.first]
69
+ else
70
+ distribute_available_positions(available_items, target_count)
71
+ end
72
+ end
73
+
74
+ def distribute_available_positions(available_items, target_count)
75
+ indices = (0...target_count).map do |i|
76
+ ((available_items.size - 1) * i.fdiv(target_count - 1)).round
77
+ end
78
+ indices.map { |i| available_items[i] }
79
+ end
80
+
81
+ def calculate_evenly_spaced_positions(total_count, target_count, ratio)
82
+ return [0] if target_count == 1
83
+ return handle_two_thirds_case(total_count) if two_thirds_ratio?(ratio, total_count)
84
+
85
+ (0...target_count).map do |i|
86
+ ((total_count - 1) * i.fdiv(target_count - 1)).round
87
+ end
88
+ end
89
+
90
+ def two_thirds_ratio?(ratio, total_count)
91
+ (ratio - (2.0 / 3.0)).abs < 1e-6 && total_count == 3
92
+ end
93
+
94
+ def handle_two_thirds_case(_total_count)
95
+ [0, 1]
96
+ end
97
+
98
+ def valid_inputs?(total_count, ratio)
99
+ total_count >= 0 && ratio >= 0 && ratio <= 1.0
100
+ end
101
+ end
102
+ end
103
+
104
+ # Main calculator that handles type-based balancing of items
105
+ class Calculator
106
+ DEFAULT_TYPE_ORDER = %w[video image strip article].freeze
107
+
108
+ def initialize(items, type_field: :type, types: nil)
109
+ raise ArgumentError, 'Items cannot be nil' if items.nil?
110
+ raise ArgumentError, 'Type field cannot be nil' if type_field.nil?
111
+
112
+ @items = items
113
+ @type_field = type_field
114
+ @types = types || extract_types
115
+ end
116
+
117
+ def call
118
+ return [] if @items.empty?
119
+
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)
129
+ end
130
+
131
+ private
132
+
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
+ def extract_types
141
+ types = @items.map { |item| item[@type_field].to_s }.uniq
142
+ DEFAULT_TYPE_ORDER.select { |type| types.include?(type) } + (types - DEFAULT_TYPE_ORDER)
143
+ 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
+ end
218
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ # Calculates the optimal distribution of items by type in a sequence.
5
+ # For each type, it determines the ideal positions where items of that type
6
+ # should be placed to achieve a balanced distribution.
7
+ class DistributionCalculator
8
+ def initialize(target_ratio = 0.2)
9
+ @target_ratio = target_ratio
10
+ end
11
+
12
+ def calculate_target_positions(total_count, available_items_count, target_ratio = @target_ratio)
13
+ # Use the C extension for the calculation
14
+ TypeBalancer::Distributor.calculate_target_positions(
15
+ total_count,
16
+ available_items_count,
17
+ target_ratio
18
+ )
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ module Distributor
5
+ class << self
6
+ def calculate_target_positions(total_count, available_count, ratio, available_items = nil)
7
+ # Input validation
8
+ return [] if total_count <= 0 || available_count <= 0 || ratio <= 0 || ratio > 1
9
+ return [] if available_count > total_count
10
+
11
+ # Calculate target count
12
+ target_count = (total_count * ratio).ceil
13
+ target_count = [target_count, available_count].min
14
+
15
+ # Special cases
16
+ return [] if target_count.zero?
17
+ return [available_items&.first || 0] if target_count == 1
18
+
19
+ # If specific positions are available, use those
20
+ if available_items
21
+ # Ensure available_items are valid
22
+ available_items = available_items.select { |pos| pos >= 0 && pos < total_count }.sort
23
+ return [] if available_items.empty?
24
+
25
+ # If we have fewer available positions than target count, use what we have
26
+ target_count = [target_count, available_items.size].min
27
+
28
+ # Calculate spacing within available positions
29
+ return [available_items.first] unless target_count > 1
30
+
31
+ step = (available_items.size - 1).to_f / (target_count - 1)
32
+ return target_count.times.map { |i| available_items[(i * step).round] }
33
+
34
+ end
35
+
36
+ # Calculate spacing for the general case
37
+ spacing = total_count.to_f / target_count
38
+
39
+ # Generate positions
40
+ positions = Array.new(target_count)
41
+ target_count.times do |i|
42
+ # Calculate ideal position
43
+ ideal_pos = i * spacing
44
+
45
+ # Round to nearest integer, ensuring we don't exceed bounds
46
+ pos = ideal_pos.round
47
+ pos = [pos, total_count - 1].min
48
+ pos = [pos, 0].max
49
+
50
+ positions[i] = pos
51
+ end
52
+
53
+ # Ensure positions are unique and sorted
54
+ positions.uniq!
55
+ positions.sort!
56
+
57
+ positions
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ # Manages an ordered collection of items, providing methods to place items
5
+ # at specific positions and fill gaps using different strategies.
6
+ # This class acts as a facade for the gap filling functionality.
7
+ class OrderedCollectionManager
8
+ def initialize(size)
9
+ @collection = Array.new(size)
10
+ @item_order = [] # Track items in their original order
11
+ @size = size
12
+ end
13
+
14
+ def place_at_positions(items, positions)
15
+ # Store items in their original order
16
+ @item_order.concat(items)
17
+
18
+ # Place items at their positions
19
+ positions.each_with_index do |pos, i|
20
+ break if i >= items.size
21
+
22
+ @collection[pos] = items[i] if pos >= 0 && pos < @size
23
+ end
24
+ end
25
+
26
+ alias place_items_at_positions place_at_positions
27
+
28
+ def fill_gaps_alternating(primary_items, secondary_items)
29
+ # Add new items to the order list before filling gaps
30
+ @item_order.concat(primary_items)
31
+ @item_order.concat(secondary_items)
32
+
33
+ # Find empty positions
34
+ empty_positions = find_empty_positions
35
+ return if empty_positions.empty?
36
+
37
+ # Create a copy of the collection for filling
38
+ collection_copy = @collection.dup
39
+
40
+ # Use C extension for alternating filling
41
+ result = TypeBalancer::AlternatingFiller.fill(
42
+ collection_copy,
43
+ empty_positions,
44
+ primary_items,
45
+ secondary_items
46
+ )
47
+
48
+ # Update collection if we got a valid result
49
+ @collection = result if result.is_a?(Array)
50
+ end
51
+
52
+ def fill_remaining_gaps(items_arrays)
53
+ # Add all new items to the order list before filling gaps
54
+ items_arrays.each { |items| @item_order.concat(items) }
55
+
56
+ # Find empty positions
57
+ empty_positions = find_empty_positions
58
+ return if empty_positions.empty?
59
+
60
+ # Create a copy of the collection for filling
61
+ collection_copy = @collection.dup
62
+
63
+ # Use C extension for sequential filling
64
+ result = TypeBalancer::SequentialFiller.fill(
65
+ collection_copy,
66
+ empty_positions,
67
+ items_arrays
68
+ )
69
+
70
+ # Update collection if we got a valid result
71
+ @collection = result if result.is_a?(Array)
72
+ end
73
+
74
+ def result
75
+ # If no items have been placed, return an empty array
76
+ return [] if @item_order.empty?
77
+
78
+ # Get all non-nil items from the collection
79
+ non_nil_items = @collection.compact
80
+
81
+ # Return empty array if no items were successfully placed
82
+ return [] if non_nil_items.empty?
83
+
84
+ # Return all items that were successfully placed, in their original order
85
+ @item_order.select { |item| non_nil_items.include?(item) }
86
+ end
87
+
88
+ private
89
+
90
+ def find_empty_positions
91
+ # Find all positions that are nil and within bounds
92
+ (0...@size).select { |i| @collection[i].nil? }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ # Fills gaps in positions sequentially with remaining items
5
+ class SequentialFiller
6
+ def initialize(collection, items_arrays)
7
+ @collection = collection
8
+ @items_arrays = items_arrays
9
+ end
10
+
11
+ def self.fill(collection, positions, items_arrays)
12
+ new(collection, items_arrays).fill_gaps(positions)
13
+ end
14
+
15
+ def fill_gaps(positions)
16
+ return [] if positions.nil? || positions.empty?
17
+ return positions if positions.compact.size == positions.size
18
+
19
+ remaining_items = @items_arrays.flatten
20
+ filled_positions = positions.dup
21
+
22
+ positions.each_with_index do |pos, idx|
23
+ next unless pos.nil?
24
+ break if remaining_items.empty?
25
+
26
+ filled_positions[idx] = remaining_items.shift
27
+ end
28
+
29
+ filled_positions
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'type_balancer/version'
4
+ require 'type_balancer/calculator'
5
+
6
+ module TypeBalancer
7
+ class Error < StandardError; end
8
+ class ConfigurationError < Error; end
9
+
10
+ # Load Ruby implementations
11
+ require_relative 'type_balancer/distribution_calculator'
12
+ require_relative 'type_balancer/ordered_collection_manager'
13
+ require_relative 'type_balancer/alternating_filler'
14
+ require_relative 'type_balancer/sequential_filler'
15
+ require_relative 'type_balancer/balancer'
16
+ require_relative 'type_balancer/distributor'
17
+
18
+ def self.calculate_positions(total_count:, ratio:, available_items: nil)
19
+ PositionCalculator.calculate_positions(
20
+ total_count: total_count,
21
+ ratio: ratio,
22
+ available_items: available_items
23
+ )
24
+ end
25
+
26
+ def self.balance(collection, type_field: :type, type_order: nil)
27
+ Balancer.new(collection, type_field: type_field, types: type_order).call
28
+ end
29
+
30
+ def self.extract_types(collection, type_field)
31
+ collection.map do |item|
32
+ if item.respond_to?(type_field)
33
+ item.send(type_field)
34
+ elsif item.respond_to?(:[])
35
+ item[type_field] || item[type_field.to_s]
36
+ else
37
+ raise Error, "Cannot access type field '#{type_field}' on item #{item}"
38
+ end
39
+ end.uniq
40
+ end
41
+
42
+ # Error raised when input validation fails
43
+ class ValidationError < StandardError; end
44
+ end