type_balancer 0.1.4 → 0.2.1
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/.rubocop.yml +3 -0
- data/CHANGELOG.md +51 -0
- data/Gemfile.lock +1 -1
- data/README.md +65 -4
- data/Rakefile +19 -1
- data/benchmark_results/ruby3.2.8.txt +8 -8
- data/benchmark_results/ruby3.2.8_yjit.txt +8 -8
- data/benchmark_results/ruby3.3.7.txt +8 -8
- data/benchmark_results/ruby3.3.7_yjit.txt +8 -8
- data/benchmark_results/ruby3.4.2.txt +8 -8
- data/benchmark_results/ruby3.4.2_yjit.txt +8 -8
- data/docs/balance.md +113 -10
- data/docs/quality.md +10 -2
- data/examples/large_scale_balance_test.rb +168 -0
- data/examples/quality.rb +80 -7
- data/lib/type_balancer/calculator.rb +37 -90
- data/lib/type_balancer/configuration.rb +31 -0
- data/lib/type_balancer/distributor.rb +70 -17
- data/lib/type_balancer/strategies/base_strategy.rb +58 -0
- data/lib/type_balancer/strategies/sliding_window_strategy.rb +199 -0
- data/lib/type_balancer/strategies.rb +7 -0
- data/lib/type_balancer/strategy_factory.rb +41 -0
- data/lib/type_balancer/version.rb +1 -1
- data/lib/type_balancer.rb +35 -10
- metadata +8 -2
data/docs/balance.md
CHANGED
@@ -1,25 +1,124 @@
|
|
1
1
|
# Detailed Documentation: `TypeBalancer.balance`
|
2
2
|
|
3
|
-
`TypeBalancer.balance` is the main method for distributing items of different types across a sequence, ensuring optimal spacing and respecting type ratios. It is highly configurable and supports custom type fields and
|
3
|
+
`TypeBalancer.balance` is the main method for distributing items of different types across a sequence, ensuring optimal spacing and respecting type ratios. It is highly configurable and supports custom type fields, type orderings, and different balancing strategies.
|
4
4
|
|
5
5
|
## Method Signature
|
6
6
|
|
7
7
|
```ruby
|
8
|
-
TypeBalancer.balance(items, type_field: :type, type_order: nil)
|
8
|
+
TypeBalancer.balance(items, type_field: :type, type_order: nil, strategy: nil, **strategy_options)
|
9
9
|
```
|
10
10
|
|
11
11
|
### Arguments
|
12
12
|
- `items` (Array<Hash>): The collection of items to balance. Each item should have a type field (default: `:type`).
|
13
13
|
- `type_field` (Symbol/String, optional): The key to use for extracting the type from each item. Default is `:type`.
|
14
14
|
- `type_order` (Array<String>, optional): An array specifying the desired order of types in the output. If omitted, the gem determines the order automatically.
|
15
|
+
- `strategy` (Symbol, optional): The balancing strategy to use. Default is `:sliding_window`.
|
16
|
+
- `strategy_options` (Hash, optional): Additional options specific to the chosen strategy.
|
15
17
|
|
16
|
-
##
|
17
|
-
|
18
|
-
|
18
|
+
## Available Strategies
|
19
|
+
|
20
|
+
### 1. Sliding Window Strategy (default)
|
21
|
+
The sliding window strategy is a sophisticated approach that balances items by examining fixed-size windows of items sequentially. For each window, it:
|
22
|
+
1. Calculates the target ratio of each type based on the overall collection
|
23
|
+
2. Ensures minimum representation of each type when possible
|
24
|
+
3. Distributes remaining slots to maintain target ratios
|
25
|
+
4. Handles transitions between windows to maintain smooth distribution
|
26
|
+
|
27
|
+
**Technical Details:**
|
28
|
+
- Default window size: 10 items
|
29
|
+
- Minimum representation: Each type gets at least one slot in a window if ratio > 0
|
30
|
+
- Ratio preservation: Maintains approximate global ratios while ensuring local diversity
|
31
|
+
- Adaptive sizing: Window size automatically adjusts near the end of the collection
|
32
|
+
|
33
|
+
**Configuration Options:**
|
34
|
+
```ruby
|
35
|
+
TypeBalancer.balance(items,
|
36
|
+
strategy: :sliding_window,
|
37
|
+
window_size: 25, # Size of the sliding window
|
38
|
+
type_field: :type, # Field containing type information
|
39
|
+
type_order: %w[...] # Optional: preferred type order
|
40
|
+
)
|
41
|
+
```
|
42
|
+
|
43
|
+
**When to Use:**
|
44
|
+
1. **Content Feed Optimization**
|
45
|
+
- Perfect for social media feeds, blog lists, or any paginated content
|
46
|
+
- Ensures users see a diverse mix regardless of where they stop scrolling
|
47
|
+
```ruby
|
48
|
+
TypeBalancer.balance(posts,
|
49
|
+
strategy: :sliding_window,
|
50
|
+
window_size: 10
|
51
|
+
)
|
52
|
+
```
|
53
|
+
|
54
|
+
2. **E-commerce Category Display**
|
55
|
+
- Balances product types in search results or category pages
|
56
|
+
- Maintains category ratios while ensuring variety
|
57
|
+
```ruby
|
58
|
+
TypeBalancer.balance(products,
|
59
|
+
strategy: :sliding_window,
|
60
|
+
window_size: 15,
|
61
|
+
type_field: :category
|
62
|
+
)
|
63
|
+
```
|
64
|
+
|
65
|
+
3. **News Feed Management**
|
66
|
+
- Mixes different news categories while maintaining importance
|
67
|
+
- Larger windows allow for some natural clustering
|
68
|
+
```ruby
|
69
|
+
TypeBalancer.balance(articles,
|
70
|
+
strategy: :sliding_window,
|
71
|
+
window_size: 25,
|
72
|
+
type_order: %w[breaking featured regular]
|
73
|
+
)
|
74
|
+
```
|
75
|
+
|
76
|
+
**Window Size Guidelines:**
|
77
|
+
- **Small (5-10 items)**
|
78
|
+
- Strictest local balance
|
79
|
+
- Best for: Short lists, critical diversity needs
|
80
|
+
- Example: Featured content sections
|
81
|
+
|
82
|
+
- **Medium (15-25 items)**
|
83
|
+
- Balanced local/global distribution
|
84
|
+
- Best for: Standard content feeds
|
85
|
+
- Example: Blog post listings
|
86
|
+
|
87
|
+
- **Large (30+ items)**
|
88
|
+
- More gradual transitions
|
89
|
+
- Best for: Long-form content, natural grouping
|
90
|
+
- Example: Search results with category clustering
|
91
|
+
|
92
|
+
**Implementation Notes:**
|
93
|
+
- The strategy maintains a queue for each type
|
94
|
+
- Window calculations consider both used and available items
|
95
|
+
- Edge cases (end of collection, single type) are handled gracefully
|
96
|
+
- Performance scales linearly with collection size
|
97
|
+
|
98
|
+
**Example with Analysis:**
|
99
|
+
```ruby
|
100
|
+
# Balance a feed with analytics
|
101
|
+
items = [
|
102
|
+
{ type: 'video', id: 1 },
|
103
|
+
{ type: 'article', id: 2 },
|
104
|
+
# ... more items
|
105
|
+
]
|
106
|
+
|
107
|
+
balanced = TypeBalancer.balance(items,
|
108
|
+
strategy: :sliding_window,
|
109
|
+
window_size: 15,
|
110
|
+
type_field: :type
|
111
|
+
)
|
112
|
+
|
113
|
+
# Analyze distribution in first window
|
114
|
+
first_window = balanced.first(15)
|
115
|
+
distribution = first_window.group_by { |i| i[:type] }
|
116
|
+
.transform_values(&:count)
|
117
|
+
```
|
19
118
|
|
20
119
|
## Usage Examples
|
21
120
|
|
22
|
-
### 1. Basic Balancing
|
121
|
+
### 1. Basic Balancing (Default Strategy)
|
23
122
|
```ruby
|
24
123
|
items = [
|
25
124
|
{ type: 'video', title: 'Video 1' },
|
@@ -33,11 +132,14 @@ balanced = TypeBalancer.balance(items)
|
|
33
132
|
# => [ { type: 'article', ... }, { type: 'image', ... }, { type: 'video', ... }, ... ]
|
34
133
|
```
|
35
134
|
|
36
|
-
### 2. Custom
|
135
|
+
### 2. Custom Strategy Options
|
37
136
|
```ruby
|
38
|
-
#
|
39
|
-
balanced = TypeBalancer.balance(items,
|
40
|
-
|
137
|
+
# Large window size for more gradual transitions
|
138
|
+
balanced = TypeBalancer.balance(items,
|
139
|
+
strategy: :sliding_window,
|
140
|
+
window_size: 50,
|
141
|
+
type_order: %w[image video article]
|
142
|
+
)
|
41
143
|
```
|
42
144
|
|
43
145
|
### 3. Custom Type Field
|
@@ -64,6 +166,7 @@ If a type in `type_order` is not present in the input, it is simply ignored in t
|
|
64
166
|
- The `type_order` argument must be an array of strings matching the type values in your items.
|
65
167
|
- If you use a custom `type_field`, ensure all items have that field.
|
66
168
|
- The method does not mutate the input array.
|
169
|
+
- Strategy options are specific to each strategy and are ignored by other strategies.
|
67
170
|
|
68
171
|
## See Also
|
69
172
|
- [README.md](../README.md) for general usage
|
data/docs/quality.md
CHANGED
@@ -53,15 +53,23 @@ The script tests several key aspects of the TypeBalancer gem:
|
|
53
53
|
- Shows spacing calculations between positions
|
54
54
|
- Verifies edge cases (single item, no items, all items)
|
55
55
|
|
56
|
-
### 2.
|
56
|
+
### 2. Strategy System
|
57
|
+
- Tests the default sliding window strategy
|
58
|
+
- Verifies behavior with different window sizes
|
59
|
+
- Checks strategy options and customization
|
60
|
+
- Ensures backward compatibility with existing code
|
61
|
+
|
62
|
+
### 3. Robust Balance Method Tests
|
57
63
|
- Loads scenarios from a YAML file (`examples/balance_test_data.yml`)
|
58
64
|
- Tests `TypeBalancer.balance` with and without the `type_order` argument
|
65
|
+
- Tests different strategy configurations
|
59
66
|
- Checks type counts, custom order, and exception handling for empty input
|
60
67
|
- Prints a color-coded summary table for pass/fail counts
|
61
68
|
|
62
|
-
###
|
69
|
+
### 4. Content Feed Example
|
63
70
|
- Shows a real-world example of content type distribution
|
64
71
|
- Verifies position allocation for different content types (video, image, article)
|
72
|
+
- Tests strategy behavior with real-world data
|
65
73
|
- Checks distribution statistics and ratios
|
66
74
|
|
67
75
|
## Output Format
|
@@ -0,0 +1,168 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# rubocop:disable Metrics/ClassLength
|
5
|
+
# rubocop:disable Metrics/MethodLength
|
6
|
+
# rubocop:disable Metrics/AbcSize
|
7
|
+
|
8
|
+
require_relative '../lib/type_balancer'
|
9
|
+
require 'yaml'
|
10
|
+
require 'json'
|
11
|
+
|
12
|
+
class LargeScaleBalanceTest
|
13
|
+
GREEN = "\e[32m"
|
14
|
+
RED = "\e[31m"
|
15
|
+
YELLOW = "\e[33m"
|
16
|
+
RESET = "\e[0m"
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@total_records = 500
|
20
|
+
@type_distribution = {
|
21
|
+
'type_a' => 250, # 50%
|
22
|
+
'type_b' => 175, # 35%
|
23
|
+
'type_c' => 75 # 15%
|
24
|
+
}
|
25
|
+
@window_sizes = [10, 25, 50, 100]
|
26
|
+
@failures = []
|
27
|
+
@tests_run = 0
|
28
|
+
@tests_passed = 0
|
29
|
+
end
|
30
|
+
|
31
|
+
def run
|
32
|
+
puts "\n#{YELLOW}Running Large Scale Balance Test#{RESET}"
|
33
|
+
puts "Total Records: #{@total_records}"
|
34
|
+
puts 'Distribution:'
|
35
|
+
@type_distribution.each do |type, count|
|
36
|
+
puts " #{type}: #{count} (#{(count.to_f / @total_records * 100).round(1)}%)"
|
37
|
+
end
|
38
|
+
|
39
|
+
test_data = generate_test_data
|
40
|
+
|
41
|
+
# Test default strategy
|
42
|
+
puts "\n#{YELLOW}Testing Default Strategy (Sliding Window)#{RESET}"
|
43
|
+
run_balance_test(test_data)
|
44
|
+
|
45
|
+
# Test with different window sizes
|
46
|
+
@window_sizes.each do |size|
|
47
|
+
puts "\n#{YELLOW}Testing Sliding Window Strategy with Window Size #{size}#{RESET}"
|
48
|
+
run_balance_test(test_data, strategy: :sliding_window, window_size: size)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Test with custom type order
|
52
|
+
puts "\n#{YELLOW}Testing with Custom Type Order#{RESET}"
|
53
|
+
run_balance_test(
|
54
|
+
test_data,
|
55
|
+
strategy: :sliding_window,
|
56
|
+
types: %w[type_c type_b type_a],
|
57
|
+
window_size: 25
|
58
|
+
)
|
59
|
+
|
60
|
+
print_summary
|
61
|
+
@failures.empty?
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def record_failure(message)
|
67
|
+
@failures << message
|
68
|
+
end
|
69
|
+
|
70
|
+
def generate_test_data
|
71
|
+
items = []
|
72
|
+
@type_distribution.each do |type, count|
|
73
|
+
count.times do |i|
|
74
|
+
items << { type: type, id: "#{type}_#{i + 1}" }
|
75
|
+
end
|
76
|
+
end
|
77
|
+
items.shuffle
|
78
|
+
end
|
79
|
+
|
80
|
+
def run_balance_test(test_data, **options)
|
81
|
+
@tests_run += 1
|
82
|
+
puts "\nRunning balance test..."
|
83
|
+
|
84
|
+
begin
|
85
|
+
result = TypeBalancer.balance(test_data, type_field: :type, **options)
|
86
|
+
analyze_result(result, options)
|
87
|
+
@tests_passed += 1
|
88
|
+
rescue StandardError => e
|
89
|
+
record_failure("Balance test failed: #{e.message}")
|
90
|
+
puts "#{RED}Balance test failed: #{e.message}#{RESET}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def analyze_result(result, options)
|
95
|
+
window_size = options[:window_size] || 10
|
96
|
+
puts "\nAnalyzing windows of size #{window_size}:"
|
97
|
+
|
98
|
+
result.each_slice(window_size).with_index(1) do |window, index|
|
99
|
+
analyze_window(window, index)
|
100
|
+
end
|
101
|
+
|
102
|
+
analyze_overall_distribution(result)
|
103
|
+
end
|
104
|
+
|
105
|
+
def analyze_window(window, window_number)
|
106
|
+
puts "\nAnalyzing window #{window_number} (#{window.size} items):"
|
107
|
+
type_counts = window.group_by { |item| item[:type] }.transform_values(&:size)
|
108
|
+
total_in_window = window.size.to_f
|
109
|
+
|
110
|
+
type_counts.each do |type, count|
|
111
|
+
percentage = (count / total_in_window * 100).round(1)
|
112
|
+
target_percentage = (@type_distribution[type].to_f / @total_records * 100).round(1)
|
113
|
+
diff = (percentage - target_percentage).abs.round(1)
|
114
|
+
|
115
|
+
message = "#{type}: #{count} (#{percentage}%), "
|
116
|
+
message += if diff <= 15
|
117
|
+
"#{GREEN}acceptable deviation#{RESET}"
|
118
|
+
else
|
119
|
+
"#{RED}high deviation#{RESET}"
|
120
|
+
end
|
121
|
+
|
122
|
+
puts message
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def analyze_overall_distribution(result)
|
127
|
+
puts "\nOverall Distribution:"
|
128
|
+
type_counts = result.group_by { |item| item[:type] }.transform_values(&:size)
|
129
|
+
total = result.size.to_f
|
130
|
+
|
131
|
+
type_counts.each do |type, count|
|
132
|
+
percentage = (count / total * 100).round(1)
|
133
|
+
target_percentage = (@type_distribution[type].to_f / @total_records * 100).round(1)
|
134
|
+
diff = (percentage - target_percentage).abs.round(1)
|
135
|
+
|
136
|
+
message = "#{type}: #{count} (#{percentage}% vs target #{target_percentage}%), "
|
137
|
+
message += if diff <= 5
|
138
|
+
"#{GREEN}good distribution#{RESET}"
|
139
|
+
else
|
140
|
+
"#{RED}distribution needs improvement#{RESET}"
|
141
|
+
end
|
142
|
+
|
143
|
+
puts message
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def print_summary
|
148
|
+
puts "\n#{YELLOW}Test Summary:#{RESET}"
|
149
|
+
puts "Tests Run: #{@tests_run}"
|
150
|
+
puts "Tests Passed: #{@tests_passed}"
|
151
|
+
puts "Failures: #{@failures.size}"
|
152
|
+
|
153
|
+
if @failures.any?
|
154
|
+
puts "\n#{RED}Failures:#{RESET}"
|
155
|
+
@failures.each { |failure| puts "- #{failure}" }
|
156
|
+
else
|
157
|
+
puts "\n#{GREEN}All tests passed!#{RESET}"
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Run the test
|
163
|
+
test = LargeScaleBalanceTest.new
|
164
|
+
exit(test.run ? 0 : 1)
|
165
|
+
|
166
|
+
# rubocop:enable Metrics/ClassLength
|
167
|
+
# rubocop:enable Metrics/MethodLength
|
168
|
+
# rubocop:enable Metrics/AbcSize
|
data/examples/quality.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require_relative '../lib/type_balancer'
|
4
4
|
require 'yaml'
|
5
5
|
|
6
6
|
class QualityChecker
|
@@ -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,21 +352,93 @@ 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
|
-
puts "\n#{'-' *
|
356
|
-
puts
|
425
|
+
puts "\n#{'-' * 80}"
|
426
|
+
puts "#{YELLOW}QUALITY CHECK SUMMARY#{RESET}"
|
427
|
+
puts "#{'-' * 80}"
|
357
428
|
puts "Examples Run: #{@examples_run}"
|
358
|
-
puts "
|
429
|
+
puts "Examples Passed: #{@examples_passed}"
|
430
|
+
puts "Examples Failed: #{@examples_run - @examples_passed}"
|
431
|
+
puts "#{'-' * 80}"
|
359
432
|
|
360
433
|
if @issues.empty?
|
361
|
-
puts "\n#{GREEN}
|
434
|
+
puts "\n#{GREEN}✓ FINAL STATUS: ALL QUALITY CHECKS PASSED!#{RESET}"
|
362
435
|
else
|
363
|
-
puts "\n#{RED}
|
436
|
+
puts "\n#{RED}✗ FINAL STATUS: QUALITY CHECKS FAILED WITH #{@issues.size} ISSUES:#{RESET}"
|
364
437
|
@issues.each_with_index do |issue, index|
|
365
438
|
puts "#{RED}#{index + 1}. #{issue}#{RESET}"
|
366
439
|
end
|
367
440
|
end
|
368
|
-
puts "#{'-' *
|
441
|
+
puts "#{'-' * 80}"
|
369
442
|
end
|
370
443
|
|
371
444
|
# Print a summary table for a section
|
@@ -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,52 @@ module TypeBalancer
|
|
105
114
|
class Calculator
|
106
115
|
DEFAULT_TYPE_ORDER = %w[video image strip article].freeze
|
107
116
|
|
108
|
-
|
117
|
+
# rubocop:disable Metrics/ParameterLists
|
118
|
+
def initialize(items, type_field: :type, types: nil, type_order: nil, strategy: nil, **strategy_options)
|
109
119
|
raise ArgumentError, 'Items cannot be nil' if items.nil?
|
110
120
|
raise ArgumentError, 'Type field cannot be nil' if type_field.nil?
|
111
121
|
|
112
122
|
@items = items
|
113
123
|
@type_field = type_field
|
114
|
-
@types = types
|
124
|
+
@types = types
|
125
|
+
@type_order = type_order
|
126
|
+
@strategy_name = strategy
|
127
|
+
@strategy_options = strategy_options
|
115
128
|
end
|
129
|
+
# rubocop:enable Metrics/ParameterLists
|
116
130
|
|
117
131
|
def call
|
118
132
|
return [] if @items.empty?
|
119
133
|
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
134
|
+
# Create strategy instance with all options
|
135
|
+
strategy = StrategyFactory.create(
|
136
|
+
@strategy_name,
|
137
|
+
items: @items,
|
138
|
+
type_field: @type_field,
|
139
|
+
types: @types || extract_types,
|
140
|
+
type_order: @type_order,
|
141
|
+
**@strategy_options
|
142
|
+
)
|
143
|
+
|
144
|
+
# Balance items using strategy
|
145
|
+
strategy.balance
|
129
146
|
end
|
130
147
|
|
131
148
|
private
|
132
149
|
|
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
150
|
def extract_types
|
141
151
|
types = @items.map { |item| item[@type_field].to_s }.uniq
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
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
|
152
|
+
if @type_order
|
153
|
+
# First include ordered types that exist in the items
|
154
|
+
ordered = @type_order & types
|
155
|
+
# Then append any remaining types that weren't in the order
|
156
|
+
ordered + (types - @type_order)
|
175
157
|
else
|
176
|
-
|
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
|
158
|
+
DEFAULT_TYPE_ORDER.select { |type| types.include?(type) } + (types - DEFAULT_TYPE_ORDER)
|
215
159
|
end
|
216
160
|
end
|
217
161
|
end
|
218
162
|
end
|
163
|
+
|
164
|
+
# Register default strategy
|
165
|
+
TypeBalancer::StrategyFactory.register(:sliding_window, TypeBalancer::Strategies::SlidingWindowStrategy)
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypeBalancer
|
4
|
+
# Configuration class to handle all balancing options
|
5
|
+
class Configuration
|
6
|
+
attr_accessor :type_field, :type_order, :strategy, :window_size, :batch_size, :types
|
7
|
+
attr_reader :strategy_options
|
8
|
+
|
9
|
+
def initialize(options = {})
|
10
|
+
@type_field = options.fetch(:type_field, :type)
|
11
|
+
@type_order = options[:type_order]
|
12
|
+
@strategy = options[:strategy]
|
13
|
+
@window_size = options[:window_size]
|
14
|
+
@batch_size = options[:batch_size]
|
15
|
+
@types = options[:types]
|
16
|
+
@strategy_options = extract_strategy_options(options)
|
17
|
+
end
|
18
|
+
|
19
|
+
def merge_window_size
|
20
|
+
return strategy_options unless window_size
|
21
|
+
|
22
|
+
strategy_options.merge(window_size: window_size)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def extract_strategy_options(options)
|
28
|
+
options.reject { |key, _| %i[type_field type_order strategy window_size types].include?(key) }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|