type_balancer 0.1.0 → 0.1.2
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/.DS_Store +0 -0
- data/CHANGELOG.md +37 -3
- data/Dockerfile +3 -1
- data/Gemfile.lock +1 -1
- data/README.md +75 -9
- data/Rakefile +7 -29
- data/benchmark/end_to_end_benchmark.rb +6 -3
- data/benchmark_results/ruby3.2.8.txt +8 -8
- data/benchmark_results/ruby3.2.8_yjit.txt +13 -13
- data/benchmark_results/ruby3.3.7.txt +8 -8
- data/benchmark_results/ruby3.3.7_yjit.txt +13 -13
- data/benchmark_results/ruby3.4.2.txt +8 -8
- data/benchmark_results/ruby3.4.2_yjit.txt +13 -13
- data/docs/benchmarks/README.md +57 -51
- data/docs/quality.md +67 -0
- data/examples/quality.rb +113 -1
- data/lib/type_balancer/balancer.rb +71 -94
- data/lib/type_balancer/batch_processing.rb +35 -0
- data/lib/type_balancer/distributor.rb +26 -53
- data/lib/type_balancer/position_calculator.rb +61 -0
- data/lib/type_balancer/ratio_calculator.rb +91 -0
- data/lib/type_balancer/type_extractor.rb +29 -0
- data/lib/type_balancer/version.rb +1 -1
- data/lib/type_balancer.rb +36 -17
- metadata +9 -4
- data/sig/type_balancer.rbs +0 -85
@@ -2,60 +2,33 @@
|
|
2
2
|
|
3
3
|
module TypeBalancer
|
4
4
|
module Distributor
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
5
|
+
def self.calculate_target_positions(total_count:, ratio:, available_positions: nil)
|
6
|
+
# Validate inputs
|
7
|
+
return [] if total_count <= 0 || ratio <= 0 || ratio > 1
|
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
|
17
|
+
|
18
|
+
return [] if target_count.zero?
|
19
|
+
return (0...total_count).to_a if target_count >= total_count
|
20
|
+
|
21
|
+
# Special case for 3 slots
|
22
|
+
if total_count == 3
|
23
|
+
return [0] if target_count == 1
|
24
|
+
return [0, 1] if target_count == 2
|
58
25
|
end
|
26
|
+
|
27
|
+
TypeBalancer::PositionCalculator.calculate_positions(
|
28
|
+
total_count: total_count,
|
29
|
+
ratio: ratio,
|
30
|
+
available_items: available_positions
|
31
|
+
)
|
59
32
|
end
|
60
33
|
end
|
61
34
|
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypeBalancer
|
4
|
+
class PositionCalculator
|
5
|
+
class << self
|
6
|
+
def calculate_positions(total_count:, ratio:, available_items: nil)
|
7
|
+
return [] unless valid_input?(total_count, ratio)
|
8
|
+
|
9
|
+
target_count = (total_count * ratio).ceil
|
10
|
+
return [] if target_count.zero?
|
11
|
+
return (0...total_count).to_a if target_count >= total_count
|
12
|
+
|
13
|
+
if available_items
|
14
|
+
calculate_with_available_items(available_items, target_count)
|
15
|
+
else
|
16
|
+
calculate_evenly_spaced_positions(total_count, target_count)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def valid_input?(total_count, ratio)
|
23
|
+
total_count.positive? && ratio.positive? && ratio <= 1
|
24
|
+
end
|
25
|
+
|
26
|
+
def calculate_with_available_items(available_items, target_count)
|
27
|
+
return [] if available_items.empty?
|
28
|
+
return [available_items.first] if target_count == 1
|
29
|
+
return [available_items.first, available_items.last] if target_count == 2
|
30
|
+
|
31
|
+
available_items.take(target_count)
|
32
|
+
end
|
33
|
+
|
34
|
+
def calculate_evenly_spaced_positions(total_count, target_count)
|
35
|
+
return [0] if target_count == 1
|
36
|
+
|
37
|
+
max_pos = total_count - 1
|
38
|
+
return [0, max_pos] if target_count == 2
|
39
|
+
|
40
|
+
calculate_multi_position_spacing(max_pos, target_count)
|
41
|
+
end
|
42
|
+
|
43
|
+
def calculate_multi_position_spacing(max_pos, target_count)
|
44
|
+
first_gap = (max_pos / (target_count - 1.0)).ceil
|
45
|
+
positions = [0]
|
46
|
+
remaining_gaps = target_count - 2
|
47
|
+
remaining_space = max_pos - first_gap
|
48
|
+
|
49
|
+
if remaining_gaps.positive?
|
50
|
+
step = remaining_space / remaining_gaps.to_f
|
51
|
+
(1...target_count - 1).each do |i|
|
52
|
+
positions << (first_gap + ((i - 1) * step)).round
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
positions << max_pos
|
57
|
+
positions
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypeBalancer
|
4
|
+
# Calculates ratios and positions for balanced distribution of items
|
5
|
+
module RatioCalculator
|
6
|
+
module_function
|
7
|
+
|
8
|
+
# Calculate positions for each type based on ratios and batch size
|
9
|
+
#
|
10
|
+
# @param ratios [Hash<String, Float>] Ratios for each type
|
11
|
+
# @param batch_size [Integer] Size of each batch
|
12
|
+
# @return [Hash<String, Array<Integer>>] Positions for each type in batches
|
13
|
+
def calculate_positions(ratios, batch_size)
|
14
|
+
# Initialize variables
|
15
|
+
positions = {}
|
16
|
+
total_positions = 0
|
17
|
+
|
18
|
+
# First pass: Calculate minimum positions for each type
|
19
|
+
ratios.each do |type, ratio|
|
20
|
+
min_positions = (batch_size * ratio).ceil
|
21
|
+
positions[type] = min_positions
|
22
|
+
total_positions += min_positions
|
23
|
+
end
|
24
|
+
|
25
|
+
# Second pass: Adjust if we have too many positions
|
26
|
+
reduce_positions(positions, batch_size) if total_positions > batch_size
|
27
|
+
|
28
|
+
# Third pass: Fill remaining positions if we have too few
|
29
|
+
fill_remaining_positions(positions, batch_size - total_positions, ratios) if total_positions < batch_size
|
30
|
+
|
31
|
+
positions
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def reduce_positions(positions, target_size)
|
37
|
+
while positions.values.sum > target_size
|
38
|
+
# Find type with most positions relative to its ratio
|
39
|
+
type_to_reduce = positions.max_by { |_, count| count }[0]
|
40
|
+
positions[type_to_reduce] -= 1
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def fill_remaining_positions(positions, remaining_count, ratios)
|
45
|
+
remaining_count.times do
|
46
|
+
# Add position to type with lowest current/ratio ratio
|
47
|
+
type_to_increase = find_type_needing_position(positions, ratios)
|
48
|
+
positions[type_to_increase] += 1
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_type_needing_position(positions, ratios)
|
53
|
+
ratios.min_by { |type, ratio| positions[type].to_f / (ratio * positions.values.sum) }[0]
|
54
|
+
end
|
55
|
+
|
56
|
+
class << self
|
57
|
+
def calculate_ratios(types, items_by_type)
|
58
|
+
return { types.first => 1.0 } if types.size == 1
|
59
|
+
|
60
|
+
type_counts = calculate_type_counts(types, items_by_type)
|
61
|
+
initial_ratios = calculate_initial_ratios(types, type_counts)
|
62
|
+
normalize_ratios(initial_ratios)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def calculate_type_counts(types, items_by_type)
|
68
|
+
types.to_h { |type| [type, items_by_type.fetch(type, []).size] }
|
69
|
+
end
|
70
|
+
|
71
|
+
def calculate_initial_ratios(types, type_counts)
|
72
|
+
total_count = type_counts.values.sum.to_f
|
73
|
+
min_ratio = 0.1
|
74
|
+
remaining_ratio = 1.0 - (min_ratio * types.size)
|
75
|
+
|
76
|
+
type_counts.transform_values do |count|
|
77
|
+
if count.zero?
|
78
|
+
min_ratio
|
79
|
+
else
|
80
|
+
min_ratio + ((count / total_count) * remaining_ratio)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def normalize_ratios(ratios)
|
86
|
+
sum = ratios.values.sum
|
87
|
+
ratios.transform_values { |ratio| ratio / sum }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypeBalancer
|
4
|
+
class TypeExtractor
|
5
|
+
def initialize(type_field)
|
6
|
+
@type_field = type_field
|
7
|
+
end
|
8
|
+
|
9
|
+
def extract_types(collection)
|
10
|
+
collection.map { |item| get_type(item) }.uniq
|
11
|
+
end
|
12
|
+
|
13
|
+
def group_by_type(collection)
|
14
|
+
collection.group_by { |item| get_type(item) }
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def get_type(item)
|
20
|
+
if item.respond_to?(@type_field)
|
21
|
+
item.send(@type_field)
|
22
|
+
elsif item.respond_to?(:[])
|
23
|
+
item[@type_field] || item[@type_field.to_s]
|
24
|
+
else
|
25
|
+
raise Error, "Cannot access type field '#{@type_field}' on item #{item}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/type_balancer.rb
CHANGED
@@ -2,43 +2,62 @@
|
|
2
2
|
|
3
3
|
require 'type_balancer/version'
|
4
4
|
require 'type_balancer/calculator'
|
5
|
+
require_relative 'type_balancer/balancer'
|
6
|
+
require_relative 'type_balancer/ratio_calculator'
|
7
|
+
require_relative 'type_balancer/batch_processing'
|
8
|
+
require 'type_balancer/position_calculator'
|
5
9
|
|
6
10
|
module TypeBalancer
|
7
11
|
class Error < StandardError; end
|
8
12
|
class ConfigurationError < Error; end
|
13
|
+
class ValidationError < Error; end
|
14
|
+
class EmptyCollectionError < Error; end
|
15
|
+
class InvalidTypeError < Error; end
|
9
16
|
|
10
17
|
# Load Ruby implementations
|
11
18
|
require_relative 'type_balancer/distribution_calculator'
|
12
19
|
require_relative 'type_balancer/ordered_collection_manager'
|
13
20
|
require_relative 'type_balancer/alternating_filler'
|
14
21
|
require_relative 'type_balancer/sequential_filler'
|
15
|
-
require_relative 'type_balancer/balancer'
|
16
22
|
require_relative 'type_balancer/distributor'
|
17
23
|
|
18
24
|
def self.calculate_positions(total_count:, ratio:, available_items: nil)
|
19
|
-
|
25
|
+
Distributor.calculate_target_positions(
|
20
26
|
total_count: total_count,
|
21
27
|
ratio: ratio,
|
22
|
-
|
28
|
+
available_positions: available_items
|
23
29
|
)
|
24
30
|
end
|
25
31
|
|
26
|
-
def self.balance(
|
27
|
-
|
32
|
+
def self.balance(items, type_field: :type, type_order: nil)
|
33
|
+
# Input validation
|
34
|
+
raise EmptyCollectionError, 'Collection cannot be empty' if items.empty?
|
35
|
+
|
36
|
+
# Extract and validate types
|
37
|
+
types = extract_types(items, type_field)
|
38
|
+
raise Error, "Invalid type field: #{type_field}" if types.empty?
|
39
|
+
|
40
|
+
# Group items by type
|
41
|
+
items.group_by { |item| extract_type(item, type_field) }
|
42
|
+
|
43
|
+
# Initialize balancer with type order if provided
|
44
|
+
balancer = Balancer.new(types, type_order: type_order)
|
45
|
+
|
46
|
+
# Balance items
|
47
|
+
balancer.call(items)
|
28
48
|
end
|
29
49
|
|
30
|
-
def self.extract_types(
|
31
|
-
|
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
|
50
|
+
def self.extract_types(items, type_field)
|
51
|
+
items.map { |item| extract_type(item, type_field) }.uniq
|
40
52
|
end
|
41
53
|
|
42
|
-
|
43
|
-
|
54
|
+
def self.extract_type(item, type_field)
|
55
|
+
if item.is_a?(Hash)
|
56
|
+
item[type_field] || item[type_field.to_s]
|
57
|
+
else
|
58
|
+
item.public_send(type_field)
|
59
|
+
end
|
60
|
+
rescue NoMethodError
|
61
|
+
nil
|
62
|
+
end
|
44
63
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: type_balancer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Carl Smith
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date:
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
11
|
dependencies: []
|
12
12
|
description: Balances types in collections by ensuring each type appears a similar
|
13
13
|
number of times
|
@@ -17,6 +17,7 @@ executables: []
|
|
17
17
|
extensions: []
|
18
18
|
extra_rdoc_files: []
|
19
19
|
files:
|
20
|
+
- ".DS_Store"
|
20
21
|
- ".rubocop.yml"
|
21
22
|
- ".ruby-version"
|
22
23
|
- CHANGELOG.md
|
@@ -37,17 +38,21 @@ files:
|
|
37
38
|
- benchmark_results/ruby3.4.2.txt
|
38
39
|
- benchmark_results/ruby3.4.2_yjit.txt
|
39
40
|
- docs/benchmarks/README.md
|
41
|
+
- docs/quality.md
|
40
42
|
- examples/quality.rb
|
41
43
|
- lib/type_balancer.rb
|
42
44
|
- lib/type_balancer/alternating_filler.rb
|
43
45
|
- lib/type_balancer/balancer.rb
|
46
|
+
- lib/type_balancer/batch_processing.rb
|
44
47
|
- lib/type_balancer/calculator.rb
|
45
48
|
- lib/type_balancer/distribution_calculator.rb
|
46
49
|
- lib/type_balancer/distributor.rb
|
47
50
|
- lib/type_balancer/ordered_collection_manager.rb
|
51
|
+
- lib/type_balancer/position_calculator.rb
|
52
|
+
- lib/type_balancer/ratio_calculator.rb
|
48
53
|
- lib/type_balancer/sequential_filler.rb
|
54
|
+
- lib/type_balancer/type_extractor.rb
|
49
55
|
- lib/type_balancer/version.rb
|
50
|
-
- sig/type_balancer.rbs
|
51
56
|
- type_balancer.gemspec
|
52
57
|
homepage: https://github.com/llwebconsulting/type_balancer
|
53
58
|
licenses:
|
@@ -71,7 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
76
|
- !ruby/object:Gem::Version
|
72
77
|
version: '0'
|
73
78
|
requirements: []
|
74
|
-
rubygems_version: 3.6.
|
79
|
+
rubygems_version: 3.6.7
|
75
80
|
specification_version: 4
|
76
81
|
summary: Balances types in collections
|
77
82
|
test_files: []
|
data/sig/type_balancer.rbs
DELETED
@@ -1,85 +0,0 @@
|
|
1
|
-
module TypeBalancer
|
2
|
-
VERSION: String
|
3
|
-
# See the writing guide of rbs: https://github.com/ruby/rbs#guides
|
4
|
-
|
5
|
-
class Error < StandardError
|
6
|
-
end
|
7
|
-
|
8
|
-
class ConfigurationError < Error
|
9
|
-
end
|
10
|
-
|
11
|
-
class ValidationError < Error
|
12
|
-
end
|
13
|
-
|
14
|
-
# Main class responsible for balancing items in a collection based on their types
|
15
|
-
class Balancer
|
16
|
-
def initialize: (Array[untyped] collection, ?type_field: Symbol | String, ?types: Array[String]?) -> void
|
17
|
-
def call: -> Array[untyped]
|
18
|
-
|
19
|
-
private
|
20
|
-
def calculate_ratios: (Hash[String, Array[untyped]] items_by_type) -> Array[Float]
|
21
|
-
def get_type: (untyped item) -> String
|
22
|
-
def extract_types: -> Array[String]
|
23
|
-
end
|
24
|
-
|
25
|
-
module Ruby
|
26
|
-
class Calculator
|
27
|
-
def self.calculate_positions: (total_count: Integer, ratio: Float, ?available_items: Array[Integer]?) -> Array[Integer]
|
28
|
-
|
29
|
-
private
|
30
|
-
def self.validate_inputs: (Integer total_count, Float ratio) -> void
|
31
|
-
def self.validate_available_items: (Array[Integer]? available_items, Integer total_count) -> void
|
32
|
-
def self.calculate_positions_without_available: (Integer total_count, Integer target_count) -> Array[Integer]
|
33
|
-
def self.calculate_positions_with_available: (Integer total_count, Integer target_count, Array[Integer] available_items) -> Array[Integer]
|
34
|
-
end
|
35
|
-
|
36
|
-
class BatchCalculator
|
37
|
-
class PositionBatch
|
38
|
-
attr_reader total_count: Integer
|
39
|
-
attr_reader available_count: Integer
|
40
|
-
attr_reader ratio: Float
|
41
|
-
|
42
|
-
def initialize: (total_count: Integer, available_count: Integer, ratio: Float) -> void
|
43
|
-
def valid?: -> bool
|
44
|
-
end
|
45
|
-
|
46
|
-
def self.calculate_positions_batch: (PositionBatch batch, ?Integer iterations) -> Array[Integer]
|
47
|
-
private
|
48
|
-
def self.calculate_target_count: (PositionBatch batch) -> Integer
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
class OrderedCollectionManager
|
53
|
-
def initialize: (Integer size) -> void
|
54
|
-
def place_at_positions: (Array[untyped] items, Array[Integer] positions) -> void
|
55
|
-
def fill_gaps_alternating: (Array[untyped] primary_items, Array[untyped] secondary_items) -> void
|
56
|
-
def fill_remaining_gaps: (Array[Array[untyped]] items_arrays) -> void
|
57
|
-
def result: -> Array[untyped]
|
58
|
-
private
|
59
|
-
def find_empty_positions: -> Array[Integer]
|
60
|
-
end
|
61
|
-
|
62
|
-
class SequentialFiller
|
63
|
-
def initialize: (Array[untyped] collection, Array[Array[untyped]] items_arrays) -> void
|
64
|
-
def self.fill: (Array[untyped] collection, Array[Integer] positions, Array[Array[untyped]] items_arrays) -> void
|
65
|
-
def fill_gaps: (Array[Integer] positions) -> void
|
66
|
-
end
|
67
|
-
|
68
|
-
class AlternatingFiller
|
69
|
-
def initialize: (Array[untyped] collection, Array[untyped] primary_items, Array[untyped] secondary_items) -> void
|
70
|
-
def self.fill: (Array[untyped] collection, Array[Integer] positions, Array[untyped] primary_items, Array[untyped] secondary_items) -> void
|
71
|
-
def fill_gaps: (Array[Integer] positions) -> void
|
72
|
-
end
|
73
|
-
|
74
|
-
class DistributionCalculator
|
75
|
-
def initialize: (?Float target_ratio) -> void
|
76
|
-
def calculate_target_positions: (Integer total_count, Integer available_items_count, ?Float target_ratio) -> Array[Integer]
|
77
|
-
end
|
78
|
-
|
79
|
-
module Distributor
|
80
|
-
def self.calculate_target_positions: (Integer total_count, Integer available_count, Float ratio) -> Array[Integer]
|
81
|
-
end
|
82
|
-
|
83
|
-
def self.balance: (Array[untyped] collection, ?type_field: Symbol | String, ?type_order: Array[String]?) -> Array[untyped]
|
84
|
-
def self.extract_types: (Array[untyped] collection, Symbol | String type_field) -> Array[String]
|
85
|
-
end
|