type_balancer 0.1.0 → 0.1.3

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.
@@ -2,60 +2,33 @@
2
2
 
3
3
  module TypeBalancer
4
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
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypeBalancer
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.3'
5
5
  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
- PositionCalculator.calculate_positions(
25
+ Distributor.calculate_target_positions(
20
26
  total_count: total_count,
21
27
  ratio: ratio,
22
- available_items: available_items
28
+ available_positions: available_items
23
29
  )
24
30
  end
25
31
 
26
- def self.balance(collection, type_field: :type, type_order: nil)
27
- Balancer.new(collection, type_field: type_field, types: type_order).call
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(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
50
+ def self.extract_types(items, type_field)
51
+ items.map { |item| extract_type(item, type_field) }.uniq
40
52
  end
41
53
 
42
- # Error raised when input validation fails
43
- class ValidationError < StandardError; end
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.0
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Smith
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-04-10 00:00:00.000000000 Z
10
+ date: 2025-04-28 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
@@ -36,18 +37,26 @@ files:
36
37
  - benchmark_results/ruby3.3.7_yjit.txt
37
38
  - benchmark_results/ruby3.4.2.txt
38
39
  - benchmark_results/ruby3.4.2_yjit.txt
40
+ - docs/README.md
41
+ - docs/balance.md
39
42
  - docs/benchmarks/README.md
43
+ - docs/calculate_positions.md
44
+ - docs/quality.md
45
+ - examples/balance_test_data.yml
40
46
  - examples/quality.rb
41
47
  - lib/type_balancer.rb
42
48
  - lib/type_balancer/alternating_filler.rb
43
49
  - lib/type_balancer/balancer.rb
50
+ - lib/type_balancer/batch_processing.rb
44
51
  - lib/type_balancer/calculator.rb
45
52
  - lib/type_balancer/distribution_calculator.rb
46
53
  - lib/type_balancer/distributor.rb
47
54
  - lib/type_balancer/ordered_collection_manager.rb
55
+ - lib/type_balancer/position_calculator.rb
56
+ - lib/type_balancer/ratio_calculator.rb
48
57
  - lib/type_balancer/sequential_filler.rb
58
+ - lib/type_balancer/type_extractor.rb
49
59
  - lib/type_balancer/version.rb
50
- - sig/type_balancer.rbs
51
60
  - type_balancer.gemspec
52
61
  homepage: https://github.com/llwebconsulting/type_balancer
53
62
  licenses:
@@ -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