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.
- checksums.yaml +7 -0
- data/.rubocop.yml +96 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/Dockerfile +38 -0
- data/Gemfile +19 -0
- data/Gemfile.lock +101 -0
- data/LICENSE.txt +21 -0
- data/README.md +86 -0
- data/Rakefile +101 -0
- data/benchmark/README.md +90 -0
- data/benchmark/end_to_end_benchmark.rb +65 -0
- data/benchmark/quick_benchmark.rb +70 -0
- data/benchmark_output.log +1581 -0
- data/benchmark_results/ruby3.2.8.txt +55 -0
- data/benchmark_results/ruby3.2.8_yjit.txt +55 -0
- data/benchmark_results/ruby3.3.7.txt +55 -0
- data/benchmark_results/ruby3.3.7_yjit.txt +55 -0
- data/benchmark_results/ruby3.4.2.txt +55 -0
- data/benchmark_results/ruby3.4.2_yjit.txt +55 -0
- data/docs/benchmarks/README.md +180 -0
- data/examples/quality.rb +178 -0
- data/lib/type_balancer/alternating_filler.rb +46 -0
- data/lib/type_balancer/balancer.rb +126 -0
- data/lib/type_balancer/calculator.rb +218 -0
- data/lib/type_balancer/distribution_calculator.rb +21 -0
- data/lib/type_balancer/distributor.rb +61 -0
- data/lib/type_balancer/ordered_collection_manager.rb +95 -0
- data/lib/type_balancer/sequential_filler.rb +32 -0
- data/lib/type_balancer/version.rb +5 -0
- data/lib/type_balancer.rb +44 -0
- data/sig/type_balancer.rbs +85 -0
- data/type_balancer.gemspec +33 -0
- metadata +77 -0
@@ -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,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
|