type_balancer 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ffc72598d68e02ede3d667d51d4eb3bafd48597cddbb36e124cdb95a8e6f54c
4
- data.tar.gz: fd4d236432a8490e20271c66235ecd4bcd114cb6d5ceb213dbbc7e9346f7037e
3
+ metadata.gz: 6c93fa5dcb75821b9f9ea3340f06ca63d67f84781bcf90f13ac089abac7283b7
4
+ data.tar.gz: fbfadcf9eed52f9f82a5f0a99f05fe3801c8529c32735f3d2eac5b0d42648a4b
5
5
  SHA512:
6
- metadata.gz: ea6b99ba44cb6e209a122a18a16a88cc8a8ccb6c5f8a1ceba1a443344489e1744cc3d105886d27df4e601a2149d0bbf0a72cabc9ddd5f766a3fada1d88abc64e
7
- data.tar.gz: eac933d9f6b1e77f4ec4e83794aabd09dabd47f4a83fd10e148e40680c0cff411dd33c8eaf343823b830206c7b16536c954372788e78bcd65cecbbac5d09d227
6
+ metadata.gz: 316f4c79fb96b3ff7362a61a4524f2d08e67be712b23f6c1a6f36d4b24332ae58266ce2fbd6dd39a7deea190f9f2f9e965c0bafe94443dd24be571ed62f302fd
7
+ data.tar.gz: 054a3439add0798dc20593ace1681734fbed87f4d162baeb9e41b6f672cbbb43c7c2b17cd67a92d2c1a4a047b9418707139c2168cdcbde08a105376af53eb39e
data/CHANGELOG.md CHANGED
@@ -1,11 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.2] - 2024-04-11
3
+ ## [0.1.4] - 2025-04-29
4
+
5
+ ### Fixed
6
+ - Fixed issue with providing a custom type field
7
+
8
+ ## [0.1.3] - 2025-04-27
9
+
10
+ ### Fixed
11
+ - Fixed type balancing behavior to properly handle edge cases where type ratios need to be maintained while respecting original collection order
12
+ - Enhanced position calculation to ensure consistent type distribution across the balanced collection
13
+ - Improved test coverage to verify correct type ratio preservation
14
+
15
+ ## [0.1.2] - 2025-04-11
4
16
 
5
17
  - Re-release of 0.1.1 due to RubyGems.org publishing issue
6
18
  - No functional changes from 0.1.1
7
19
 
8
- ## [0.1.1] - 2024-03-XX
20
+ ## [0.1.1] - 2025-04-10
9
21
 
10
22
  ### Refactoring
11
23
  - Major refactoring of core components to follow SOLID principles:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- type_balancer (0.1.2)
4
+ type_balancer (0.1.4)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -20,7 +20,7 @@ TypeBalancer is a sophisticated Ruby gem designed to solve the challenge of dist
20
20
 
21
21
  The gem uses advanced distribution algorithms to ensure that items are not only balanced by type but also maintain optimal spacing, preventing clusters of similar content while respecting specified ratios.
22
22
 
23
- [View Examples & Quality Tests](docs/quality.md) | [View Benchmark Results](docs/benchmarks/README.md)
23
+ [View Documentation](docs/README.md) | [View Benchmark Results](docs/benchmarks/README.md)
24
24
 
25
25
  ## Installation
26
26
 
@@ -56,6 +56,60 @@ items = [
56
56
  balanced_items = TypeBalancer.balance(items, type_field: :type)
57
57
  ```
58
58
 
59
+ ## Balancing Collections with `TypeBalancer.balance`
60
+
61
+ The primary method for balancing collections is `TypeBalancer.balance`. This method takes an array of items and distributes them by type, ensuring optimal spacing and respecting type ratios.
62
+
63
+ **Basic Example:**
64
+
65
+ ```ruby
66
+ items = [
67
+ { type: 'video', title: 'Video 1' },
68
+ { type: 'image', title: 'Image 1' },
69
+ { type: 'article', title: 'Article 1' },
70
+ # ...
71
+ ]
72
+ balanced = TypeBalancer.balance(items, type_field: :type)
73
+ # => [ { type: 'article', ... }, { type: 'image', ... }, { type: 'video', ... }, ... ]
74
+ ```
75
+
76
+ **Custom Type Order:**
77
+
78
+ You can specify a custom order for types using the `type_order` argument. This controls the priority of types in the balanced output.
79
+
80
+ ```ruby
81
+ # Prioritize images, then videos, then articles
82
+ balanced = TypeBalancer.balance(items, type_field: :type, type_order: %w[image video article])
83
+ # => [ { type: 'image', ... }, { type: 'video', ... }, { type: 'article', ... }, ... ]
84
+ ```
85
+
86
+ - `type_field`: The key to use for type extraction (default: `:type`).
87
+ - `type_order`: An array of type names (as strings) specifying the desired order.
88
+
89
+ For more advanced usage and options, see [Detailed Balance Method Documentation](docs/balance.md).
90
+
91
+ ## Calculating Positions Directly
92
+
93
+ In addition to balancing collections, you can use `TypeBalancer.calculate_positions` to determine optimal positions for a given type or subset of items within a sequence. This is useful for advanced scenarios where you need fine-grained control over item placement.
94
+
95
+ **Basic Example:**
96
+
97
+ ```ruby
98
+ # Calculate positions for 3 items in a sequence of 10 slots
99
+ positions = TypeBalancer.calculate_positions(total_count: 10, ratio: 0.3)
100
+ # => [0, 5, 9]
101
+ ```
102
+
103
+ **With Available Items:**
104
+
105
+ ```ruby
106
+ # Restrict placement to specific slots
107
+ positions = TypeBalancer.calculate_positions(total_count: 10, ratio: 0.5, available_items: [0, 1, 2])
108
+ # => [0, 1, 2]
109
+ ```
110
+
111
+ For more advanced usage and options, see [Detailed Position Calculation Documentation](docs/calculate_positions.md).
112
+
59
113
  ## Performance Characteristics
60
114
 
61
115
  TypeBalancer is designed to handle collections of varying sizes efficiently. Here are the current performance metrics:
@@ -91,7 +145,7 @@ After checking out the repo, run `bin/setup` to install dependencies. Then:
91
145
  3. Run `rake rubocop` to check code style
92
146
  4. Run `bin/console` for an interactive prompt
93
147
 
94
- For more information about the quality script and its uses, see our [quality script documentation](docs/quality.md).
148
+ For more information about the gem, its features, and quality checks, see our [documentation](docs/README.md).
95
149
 
96
150
  ## Contributing
97
151
 
@@ -140,7 +194,7 @@ We welcome contributions to TypeBalancer! Here's how you can help:
140
194
  - Quality script should pass without new issues
141
195
 
142
196
  For more detailed information about our development process and tools:
143
- - [Quality Script Documentation](docs/quality.md)
197
+ - [Documentation](docs/README.md)
144
198
  - [Benchmark Documentation](docs/benchmarks/README.md)
145
199
 
146
200
  ## License
data/docs/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # Documentation Index
2
+
3
+ Welcome to the TypeBalancer documentation! Below you'll find links to all major documentation resources for the gem.
4
+
5
+ - [Quality Script](quality.md): Integration and example script for verifying gem functionality, with instructions for running locally or from other projects.
6
+ - [Balance Method Details](balance.md): In-depth documentation for the `TypeBalancer.balance` method, including arguments, usage, and edge cases.
7
+ - [Calculate Positions Method Details](calculate_positions.md): Detailed documentation for `TypeBalancer.calculate_positions`, with examples and caveats.
8
+ - [Benchmarks](benchmarks/README.md): Performance metrics and benchmarking methodology for the gem.
data/docs/balance.md ADDED
@@ -0,0 +1,71 @@
1
+ # Detailed Documentation: `TypeBalancer.balance`
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 type orderings.
4
+
5
+ ## Method Signature
6
+
7
+ ```ruby
8
+ TypeBalancer.balance(items, type_field: :type, type_order: nil)
9
+ ```
10
+
11
+ ### Arguments
12
+ - `items` (Array<Hash>): The collection of items to balance. Each item should have a type field (default: `:type`).
13
+ - `type_field` (Symbol/String, optional): The key to use for extracting the type from each item. Default is `:type`.
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
+
16
+ ## Return Value
17
+ - Returns a new array of items, balanced by type and spaced as evenly as possible.
18
+ - The output array will have the same length as the input.
19
+
20
+ ## Usage Examples
21
+
22
+ ### 1. Basic Balancing
23
+ ```ruby
24
+ items = [
25
+ { type: 'video', title: 'Video 1' },
26
+ { type: 'image', title: 'Image 1' },
27
+ { type: 'article', title: 'Article 1' },
28
+ { type: 'article', title: 'Article 2' },
29
+ { type: 'image', title: 'Image 2' },
30
+ { type: 'video', title: 'Video 2' }
31
+ ]
32
+ balanced = TypeBalancer.balance(items)
33
+ # => [ { type: 'article', ... }, { type: 'image', ... }, { type: 'video', ... }, ... ]
34
+ ```
35
+
36
+ ### 2. Custom Type Order
37
+ ```ruby
38
+ # Prioritize images, then videos, then articles
39
+ balanced = TypeBalancer.balance(items, type_order: %w[image video article])
40
+ # => [ { type: 'image', ... }, { type: 'video', ... }, { type: 'article', ... }, ... ]
41
+ ```
42
+
43
+ ### 3. Custom Type Field
44
+ ```ruby
45
+ items = [
46
+ { category: 'video', title: 'Video 1' },
47
+ { category: 'image', title: 'Image 1' },
48
+ { category: 'article', title: 'Article 1' }
49
+ ]
50
+ balanced = TypeBalancer.balance(items, type_field: :category)
51
+ # => [ { category: 'article', ... }, { category: 'image', ... }, { category: 'video', ... } ]
52
+ ```
53
+
54
+ ### 4. Handling Missing Types
55
+ If a type in `type_order` is not present in the input, it is simply ignored in the output order.
56
+
57
+ ### 5. Edge Cases
58
+ - **Empty Input:** Raises an exception (`Collection cannot be empty`).
59
+ - **Single Type:** All items are returned in their original order.
60
+ - **Missing Type Field:** If an item is missing the type field, it is ignored or may cause an error depending on context.
61
+
62
+ ## Notes and Caveats
63
+ - The method is deterministic: the same input will always produce the same output.
64
+ - The `type_order` argument must be an array of strings matching the type values in your items.
65
+ - If you use a custom `type_field`, ensure all items have that field.
66
+ - The method does not mutate the input array.
67
+
68
+ ## See Also
69
+ - [README.md](../README.md) for general usage
70
+ - [Quality Script Documentation](quality.md) for integration tests and examples
71
+ - [Detailed Position Calculation Documentation](calculate_positions.md)
@@ -0,0 +1,87 @@
1
+ # Detailed Documentation: `TypeBalancer.calculate_positions`
2
+
3
+ `TypeBalancer.calculate_positions` is a utility method for determining the optimal positions for a given number of items (or a ratio of items) within a sequence of slots. This is useful for advanced scenarios where you want to control the distribution of a specific type or subset of items.
4
+
5
+ ## Method Signature
6
+
7
+ ```ruby
8
+ TypeBalancer.calculate_positions(total_count:, ratio:, available_items: nil)
9
+ ```
10
+
11
+ ### Arguments
12
+ - `total_count` (Integer): The total number of slots in the sequence (e.g., the length of your feed or array).
13
+ - `ratio` (Float): The desired ratio of items to place (e.g., `0.3` for 30%).
14
+ - `available_items` (Array<Integer>, optional): An array of slot indices where placement is allowed. If omitted, all slots are considered available.
15
+
16
+ ## Return Value
17
+ - Returns an array of integer indices representing the optimal positions for the items.
18
+ - The array will have a length close to `total_count * ratio`, rounded as appropriate.
19
+ - If `available_items` is provided, only those slots will be used.
20
+
21
+ ## Usage Examples
22
+
23
+ ### 1. Even Distribution
24
+ ```ruby
25
+ # Place 3 items evenly in 10 slots
26
+ TypeBalancer.calculate_positions(total_count: 10, ratio: 0.3)
27
+ # => [0, 5, 9]
28
+ ```
29
+
30
+ ### 2. Restricting to Available Slots
31
+ ```ruby
32
+ # Only use slots 0, 1, and 2
33
+ TypeBalancer.calculate_positions(total_count: 10, ratio: 0.5, available_items: [0, 1, 2])
34
+ # => [0, 1, 2]
35
+ ```
36
+
37
+ ### 3. Edge Cases
38
+ ```ruby
39
+ # Single item
40
+ TypeBalancer.calculate_positions(total_count: 1, ratio: 1.0)
41
+ # => [0]
42
+
43
+ # No items
44
+ TypeBalancer.calculate_positions(total_count: 100, ratio: 0.0)
45
+ # => []
46
+
47
+ # All items
48
+ TypeBalancer.calculate_positions(total_count: 5, ratio: 1.0)
49
+ # => [0, 1, 2, 3, 4]
50
+ ```
51
+
52
+ ### 4. Precision with Small Ratios
53
+ ```ruby
54
+ # Two positions in three slots
55
+ TypeBalancer.calculate_positions(total_count: 3, ratio: 0.67)
56
+ # => [0, 1]
57
+
58
+ # Single position in three slots
59
+ TypeBalancer.calculate_positions(total_count: 3, ratio: 0.34)
60
+ # => [0]
61
+ ```
62
+
63
+ ### 5. Available Items Edge Cases
64
+ ```ruby
65
+ # Single target with multiple available positions
66
+ TypeBalancer.calculate_positions(total_count: 5, ratio: 0.2, available_items: [1, 2, 3])
67
+ # => [1]
68
+
69
+ # Two targets with multiple available positions
70
+ TypeBalancer.calculate_positions(total_count: 10, ratio: 0.2, available_items: [1, 3, 5])
71
+ # => [1, 5]
72
+
73
+ # Exact match of available positions
74
+ TypeBalancer.calculate_positions(total_count: 10, ratio: 0.3, available_items: [2, 4, 6])
75
+ # => [2, 4, 6]
76
+ ```
77
+
78
+ ## Notes and Caveats
79
+ - If `ratio` is 0 or `total_count` is 0, returns an empty array.
80
+ - If `ratio` is 1.0, returns all available slots.
81
+ - If `available_items` is provided and its length is less than the target count, all available items are returned.
82
+ - For very small or very large ratios, the method ensures at least one or all slots are used, respectively.
83
+ - The method is deterministic and will always return the same result for the same input.
84
+
85
+ ## See Also
86
+ - [README.md](../README.md) for general usage
87
+ - [Quality Script Documentation](quality.md) for integration tests and examples
data/docs/quality.md CHANGED
@@ -8,12 +8,42 @@ The TypeBalancer gem includes a comprehensive quality check script located at `/
8
8
 
9
9
  ## Running the Script
10
10
 
11
- To run the quality script:
11
+ To run the quality script from the gem repository:
12
12
 
13
13
  ```bash
14
14
  bundle exec ruby examples/quality.rb
15
15
  ```
16
16
 
17
+ ### Running from Other Projects
18
+
19
+ You can run the quality script from any project that includes the TypeBalancer gem. This is useful for verifying the gem's behavior in your app or as part of CI for downstream projects.
20
+
21
+ **Example:**
22
+
23
+ ```bash
24
+ bundle exec ruby /path/to/gems/type_balancer/examples/quality.rb
25
+ ```
26
+
27
+ **How do I find the path to the gem?**
28
+
29
+ If you are not sure where the gem is installed, you can use Bundler to locate it:
30
+
31
+ ```bash
32
+ bundle show type_balancer
33
+ ```
34
+
35
+ This will print the path to the gem directory. The quality script is located in the `examples` subdirectory of that path. For example:
36
+
37
+ ```bash
38
+ $ bundle show type_balancer
39
+ /Users/yourname/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/type_balancer-0.1.3
40
+ $ bundle exec ruby /Users/yourname/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/type_balancer-0.1.3/examples/quality.rb
41
+ ```
42
+
43
+ - Ensure the gem is installed and available in your bundle.
44
+ - The script expects the test data file at `examples/balance_test_data.yml` (relative to the gem root).
45
+ - Output will be color-coded if your terminal supports ANSI colors.
46
+
17
47
  ## What it Tests
18
48
 
19
49
  The script tests several key aspects of the TypeBalancer gem:
@@ -23,32 +53,23 @@ The script tests several key aspects of the TypeBalancer gem:
23
53
  - Shows spacing calculations between positions
24
54
  - Verifies edge cases (single item, no items, all items)
25
55
 
26
- ### 2. Content Feed Example
56
+ ### 2. Robust Balance Method Tests
57
+ - Loads scenarios from a YAML file (`examples/balance_test_data.yml`)
58
+ - Tests `TypeBalancer.balance` with and without the `type_order` argument
59
+ - Checks type counts, custom order, and exception handling for empty input
60
+ - Prints a color-coded summary table for pass/fail counts
61
+
62
+ ### 3. Content Feed Example
27
63
  - Shows a real-world example of content type distribution
28
64
  - Verifies position allocation for different content types (video, image, article)
29
65
  - Checks distribution statistics and ratios
30
66
 
31
- ### 3. Balancer API
32
- - Tests the main TypeBalancer.balance method
33
- - Verifies batch creation and size limits
34
- - Demonstrates custom type ordering
35
-
36
- ### 4. Type Extraction
37
- - Tests type extraction from both hash and object items
38
- - Verifies support for different type field access methods
39
-
40
- ### 5. Error Handling
41
- - Validates handling of empty collections
42
- - Tests response to invalid type fields
43
- - Verifies batch size validation
44
-
45
67
  ## Output Format
46
68
 
47
- The script provides detailed output showing:
48
- - Results of each test case
49
- - Distribution statistics
50
- - Any issues found during testing
51
- - A summary of all examples run and passed
69
+ - Each section prints a color-coded summary table of passing and failing tests
70
+ - Failures and exceptions are highlighted in red; passes in green
71
+ - The final summary shows the total number of examples run and passed
72
+ - The script exits with status 0 if all tests pass, or 1 if any fail (CI-friendly)
52
73
 
53
74
  ## Using as a Development Tool
54
75
 
@@ -58,6 +79,19 @@ The quality script is particularly useful when:
58
79
  3. Verifying changes haven't broken core functionality
59
80
  4. Understanding how different features work together
60
81
 
82
+ ## Customizing/Extending the Script
83
+
84
+ - You can add new scenarios to `examples/balance_test_data.yml` to test additional cases or edge conditions.
85
+ - You may copy or extend the script for your own integration tests.
86
+ - The script can be adapted to accept a custom YAML path if needed (see comments in the script).
87
+
88
+ ## Troubleshooting
89
+
90
+ - **Color output not working:** Ensure your terminal supports ANSI colors.
91
+ - **File not found:** Make sure `examples/balance_test_data.yml` exists and is accessible from the script's location.
92
+ - **Gem not found:** Ensure the TypeBalancer gem is installed and available in your bundle.
93
+ - **Path issues:** Use an absolute or correct relative path to the script when running from another project.
94
+
61
95
  ## Extending the Script
62
96
 
63
97
  When adding new features to TypeBalancer, consider:
@@ -0,0 +1,66 @@
1
+ # Test data for TypeBalancer.balance integration tests
2
+
3
+ - name: Even distribution
4
+ items:
5
+ - { type: video, id: 1 }
6
+ - { type: video, id: 2 }
7
+ - { type: image, id: 3 }
8
+ - { type: image, id: 4 }
9
+ - { type: article, id: 5 }
10
+ - { type: article, id: 6 }
11
+ - { type: article, id: 7 }
12
+ - { type: article, id: 8 }
13
+ expected_type_counts:
14
+ video: 2
15
+ image: 2
16
+ article: 4
17
+
18
+ - name: Uneven distribution
19
+ items:
20
+ - { type: video, id: 1 }
21
+ - { type: image, id: 2 }
22
+ - { type: article, id: 3 }
23
+ - { type: article, id: 4 }
24
+ - { type: article, id: 5 }
25
+ - { type: article, id: 6 }
26
+ - { type: article, id: 7 }
27
+ - { type: article, id: 8 }
28
+ expected_type_counts:
29
+ video: 1
30
+ image: 1
31
+ article: 6
32
+
33
+ - name: Missing type (no images)
34
+ items:
35
+ - { type: video, id: 1 }
36
+ - { type: video, id: 2 }
37
+ - { type: article, id: 3 }
38
+ - { type: article, id: 4 }
39
+ - { type: article, id: 5 }
40
+ expected_type_counts:
41
+ video: 2
42
+ article: 3
43
+
44
+ - name: Custom type order
45
+ items:
46
+ - { type: video, id: 1 }
47
+ - { type: image, id: 2 }
48
+ - { type: article, id: 3 }
49
+ - { type: article, id: 4 }
50
+ - { type: image, id: 5 }
51
+ - { type: video, id: 6 }
52
+ - { type: article, id: 7 }
53
+ type_order: [image, video, article]
54
+ expected_first_type: image
55
+
56
+ - name: Edge case - empty
57
+ items: []
58
+ expected_type_counts: {}
59
+
60
+ - name: Edge case - single type
61
+ items:
62
+ - { type: article, id: 1 }
63
+ - { type: article, id: 2 }
64
+ - { type: article, id: 3 }
65
+ expected_type_counts:
66
+ article: 3
data/examples/quality.rb CHANGED
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'type_balancer'
4
+ require 'yaml'
4
5
 
5
6
  class QualityChecker
7
+ GREEN = "\e[32m"
8
+ RED = "\e[31m"
9
+ YELLOW = "\e[33m"
10
+ RESET = "\e[0m"
11
+
6
12
  def initialize
7
13
  @issues = []
8
14
  @examples_run = 0
@@ -15,7 +21,9 @@ class QualityChecker
15
21
  check_edge_cases
16
22
  check_position_precision
17
23
  check_available_positions_edge_cases
24
+ check_balance_method_robust
18
25
  check_real_world_feed
26
+ check_custom_type_field
19
27
 
20
28
  print_summary
21
29
  exit(@issues.empty? ? 0 : 1)
@@ -29,11 +37,15 @@ class QualityChecker
29
37
 
30
38
  def check_basic_distribution
31
39
  @examples_run += 1
40
+ @section_examples_run = 0 if !defined?(@section_examples_run)
41
+ @section_examples_passed = 0 if !defined?(@section_examples_passed)
42
+ @section_examples_run += 1
32
43
  puts "\nBasic Distribution Example:"
33
44
  positions = TypeBalancer.calculate_positions(total_count: 10, ratio: 0.3)
34
45
 
35
46
  if positions == [0, 5, 9]
36
47
  @examples_passed += 1
48
+ @section_examples_passed += 1
37
49
  else
38
50
  record_issue("Basic distribution positions #{positions.inspect} don't match expected [0, 5, 9]")
39
51
  end
@@ -49,6 +61,7 @@ class QualityChecker
49
61
 
50
62
  def check_available_items
51
63
  @examples_run += 1
64
+ @section_examples_run += 1
52
65
  puts "\nAvailable Items Example:"
53
66
  positions = TypeBalancer.calculate_positions(
54
67
  total_count: 10,
@@ -58,6 +71,7 @@ class QualityChecker
58
71
 
59
72
  if positions == [0, 1, 2]
60
73
  @examples_passed += 1
74
+ @section_examples_passed += 1
61
75
  else
62
76
  record_issue("Available items test returned #{positions.inspect} instead of expected [0, 1, 2]")
63
77
  end
@@ -70,33 +84,40 @@ class QualityChecker
70
84
 
71
85
  # Single item
72
86
  @examples_run += 1
87
+ @section_examples_run += 1
73
88
  single = TypeBalancer.calculate_positions(total_count: 1, ratio: 1.0)
74
89
  puts "Single item: #{single.inspect}"
75
90
  if single == [0]
76
91
  @examples_passed += 1
92
+ @section_examples_passed += 1
77
93
  else
78
94
  record_issue("Single item case returned #{single.inspect} instead of [0]")
79
95
  end
80
96
 
81
97
  # No items
82
98
  @examples_run += 1
99
+ @section_examples_run += 1
83
100
  none = TypeBalancer.calculate_positions(total_count: 100, ratio: 0.0)
84
101
  puts "No items needed: #{none.inspect}"
85
102
  if none == []
86
103
  @examples_passed += 1
104
+ @section_examples_passed += 1
87
105
  else
88
106
  record_issue("Zero ratio case returned #{none.inspect} instead of []")
89
107
  end
90
108
 
91
109
  # All items
92
110
  @examples_run += 1
111
+ @section_examples_run += 1
93
112
  all = TypeBalancer.calculate_positions(total_count: 5, ratio: 1.0)
94
113
  puts "All items needed: #{all.inspect}"
95
114
  if all == [0, 1, 2, 3, 4]
96
115
  @examples_passed += 1
116
+ @section_examples_passed += 1
97
117
  else
98
118
  record_issue("Full ratio case returned #{all.inspect} instead of [0, 1, 2, 3, 4]")
99
119
  end
120
+ print_section_table('calculate_positions')
100
121
  end
101
122
 
102
123
  def check_position_precision
@@ -104,23 +125,28 @@ class QualityChecker
104
125
 
105
126
  # Two positions in three slots
106
127
  @examples_run += 1
128
+ @section_examples_run += 1
107
129
  positions = TypeBalancer.calculate_positions(total_count: 3, ratio: 0.67)
108
130
  puts "Two positions in three slots: #{positions.inspect}"
109
131
  if positions == [0, 1]
110
132
  @examples_passed += 1
133
+ @section_examples_passed += 1
111
134
  else
112
135
  record_issue("Two in three case returned #{positions.inspect} instead of [0, 1]")
113
136
  end
114
137
 
115
138
  # Single position in three slots
116
139
  @examples_run += 1
140
+ @section_examples_run += 1
117
141
  positions = TypeBalancer.calculate_positions(total_count: 3, ratio: 0.34)
118
142
  puts "Single position in three slots: #{positions.inspect}"
119
143
  if positions == [0]
120
144
  @examples_passed += 1
145
+ @section_examples_passed += 1
121
146
  else
122
147
  record_issue("One in three case returned #{positions.inspect} instead of [0]")
123
148
  end
149
+ print_section_table('calculate_positions')
124
150
  end
125
151
 
126
152
  def check_available_positions_edge_cases
@@ -128,6 +154,7 @@ class QualityChecker
128
154
 
129
155
  # Single target with multiple available positions
130
156
  @examples_run += 1
157
+ @section_examples_run += 1
131
158
  positions = TypeBalancer.calculate_positions(
132
159
  total_count: 5,
133
160
  ratio: 0.2,
@@ -136,12 +163,14 @@ class QualityChecker
136
163
  puts "Single target with multiple available: #{positions.inspect}"
137
164
  if positions == [1]
138
165
  @examples_passed += 1
166
+ @section_examples_passed += 1
139
167
  else
140
168
  record_issue("Single target with multiple available returned #{positions.inspect} instead of [1]")
141
169
  end
142
170
 
143
171
  # Two targets with multiple available positions
144
172
  @examples_run += 1
173
+ @section_examples_run += 1
145
174
  positions = TypeBalancer.calculate_positions(
146
175
  total_count: 10,
147
176
  ratio: 0.2,
@@ -150,12 +179,14 @@ class QualityChecker
150
179
  puts "Two targets with multiple available: #{positions.inspect}"
151
180
  if positions == [1, 5]
152
181
  @examples_passed += 1
182
+ @section_examples_passed += 1
153
183
  else
154
184
  record_issue("Two targets with multiple available returned #{positions.inspect} instead of [1, 5]")
155
185
  end
156
186
 
157
187
  # Exact match of available positions
158
188
  @examples_run += 1
189
+ @section_examples_run += 1
159
190
  positions = TypeBalancer.calculate_positions(
160
191
  total_count: 10,
161
192
  ratio: 0.3,
@@ -164,17 +195,111 @@ class QualityChecker
164
195
  puts "Exact match of available positions: #{positions.inspect}"
165
196
  if positions == [2, 4, 6]
166
197
  @examples_passed += 1
198
+ @section_examples_passed += 1
167
199
  else
168
200
  record_issue("Exact match case returned #{positions.inspect} instead of [2, 4, 6]")
169
201
  end
202
+ print_section_table('calculate_positions')
203
+ end
204
+
205
+ def check_balance_method_robust
206
+ puts "\n#{YELLOW}Robust Balance Method Tests:#{RESET}"
207
+ scenarios = YAML.load_file(File.expand_path('../balance_test_data.yml', __FILE__))
208
+ section_run = 0
209
+ section_passed = 0
210
+ scenarios.each do |scenario|
211
+ @examples_run += 1
212
+ section_run += 1
213
+ # Deep symbolize keys for all items in the scenario
214
+ items = (scenario['items'] || []).map { |item| deep_symbolize_keys(item) }
215
+ type_order = scenario['type_order']
216
+ expected_type_counts = scenario['expected_type_counts'] || {}
217
+ expected_first_type = scenario['expected_first_type']
218
+
219
+ # Test with and without type_order
220
+ [nil, type_order].uniq.each do |order|
221
+ label = order ? "with type_order #{order}" : "default order"
222
+ begin
223
+ # Special handling for the empty input case
224
+ if scenario['name'] =~ /empty/i
225
+ begin
226
+ if order
227
+ TypeBalancer.balance(items, type_field: :type, type_order: order)
228
+ else
229
+ TypeBalancer.balance(items, type_field: :type)
230
+ end
231
+ # If no exception, this is a failure
232
+ record_issue("#{scenario['name']} (#{label}): Expected exception for empty input, but none was raised")
233
+ puts "#{RED}#{scenario['name']} (#{label}): Expected exception for empty input, but none was raised#{RESET}"
234
+ rescue => e
235
+ if e.message =~ /Collection cannot be empty/
236
+ @examples_passed += 1
237
+ section_passed += 1
238
+ puts "#{GREEN}#{scenario['name']} (#{label}): Correctly raised exception for empty input#{RESET}"
239
+ else
240
+ record_issue("#{scenario['name']} (#{label}): Unexpected exception: #{e}")
241
+ puts "#{RED}#{scenario['name']} (#{label}): Unexpected exception: #{e}#{RESET}"
242
+ end
243
+ end
244
+ next
245
+ end
246
+ # Normal test logic for other cases
247
+ result = if order
248
+ TypeBalancer.balance(items, type_field: :type, type_order: order)
249
+ else
250
+ TypeBalancer.balance(items, type_field: :type)
251
+ end
252
+ rescue => e
253
+ record_issue("#{scenario['name']} (#{label}): Exception raised: #{e}")
254
+ puts "#{RED}#{scenario['name']} (#{label}): Exception raised: #{e}#{RESET}"
255
+ next
256
+ end
257
+ # Check type counts
258
+ type_counts = result.group_by { |i| i[:type] }.transform_values(&:size)
259
+ if expected_type_counts && !expected_type_counts.empty?
260
+ if type_counts != expected_type_counts
261
+ record_issue("#{scenario['name']} (#{label}): Type counts #{type_counts.inspect} do not match expected #{expected_type_counts.inspect}")
262
+ puts "#{RED}#{scenario['name']} (#{label}): Type counts #{type_counts.inspect} do not match expected #{expected_type_counts.inspect}#{RESET}"
263
+ else
264
+ @examples_passed += 1
265
+ section_passed += 1
266
+ puts "#{GREEN}#{scenario['name']} (#{label}): Passed#{RESET}"
267
+ end
268
+ end
269
+ # Check first type for custom order
270
+ if expected_first_type && order
271
+ if result.first && result.first[:type] != expected_first_type
272
+ record_issue("#{scenario['name']} (#{label}): First type #{result.first[:type]} does not match expected #{expected_first_type}")
273
+ puts "#{RED}#{scenario['name']} (#{label}): First type #{result.first[:type]} does not match expected #{expected_first_type}#{RESET}"
274
+ else
275
+ @examples_passed += 1
276
+ section_passed += 1
277
+ puts "#{GREEN}#{scenario['name']} (#{label}): Custom order respected#{RESET}"
278
+ end
279
+ end
280
+ puts " Result: #{result.map { |i| i[:type] }.inspect}"
281
+ puts " Type counts: #{type_counts.inspect}"
282
+ end
283
+ end
284
+ print_section_table('balance_method', section_run, section_passed)
285
+ end
286
+
287
+ # Helper to deeply symbolize keys in a hash
288
+ def deep_symbolize_keys(obj)
289
+ case obj
290
+ when Hash
291
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize_keys(v) }
292
+ when Array
293
+ obj.map { |v| deep_symbolize_keys(v) }
294
+ else
295
+ obj
296
+ end
170
297
  end
171
298
 
172
299
  def check_real_world_feed
173
300
  @examples_run += 1
174
- puts "\nReal World Example - Content Feed:"
301
+ puts "\n#{YELLOW}Real World Example - Content Feed:#{RESET}"
175
302
  feed_size = 20
176
-
177
- # Create test items
178
303
  items = [
179
304
  { type: 'video', id: 1 },
180
305
  { type: 'video', id: 2 },
@@ -186,87 +311,44 @@ class QualityChecker
186
311
  { type: 'article', id: 8 },
187
312
  { type: 'article', id: 9 }
188
313
  ]
189
-
190
- # Track allocated positions
191
- allocated_positions = []
192
- content_positions = {}
193
-
194
- # Calculate positions for each type
195
- content_positions[:video] = TypeBalancer.calculate_positions(
196
- total_count: feed_size,
197
- ratio: 0.3,
198
- available_items: (0..7).to_a - allocated_positions
199
- )
200
- allocated_positions += content_positions[:video]
201
-
202
- content_positions[:image] = TypeBalancer.calculate_positions(
203
- total_count: feed_size,
204
- ratio: 0.4,
205
- available_items: (0..14).to_a - allocated_positions
206
- )
207
- allocated_positions += content_positions[:image]
208
-
209
- content_positions[:article] = TypeBalancer.calculate_positions(
210
- total_count: feed_size,
211
- ratio: 0.3,
212
- available_items: (0..19).to_a - allocated_positions
213
- )
214
-
215
- puts "\nContent Type Positions:"
216
- content_positions.each do |type, pos|
217
- puts "#{type}: #{pos.inspect}"
218
- end
219
-
220
- # Check for overlaps
221
- all_positions = content_positions.values.compact.flatten
222
- if all_positions == all_positions.uniq
223
- puts "\nSuccess: No overlapping positions!"
224
- @examples_passed += 1
225
- else
226
- overlaps = all_positions.group_by { |e| e }.select { |_, v| v.size > 1 }.keys
227
- record_issue("Found overlapping positions at indices: #{overlaps.inspect}")
228
- puts "\nWarning: Some positions overlap!"
229
- end
230
-
231
- # Verify distribution
232
- puts "\nDistribution Stats:"
233
- expected_counts = { video: 6, image: 8, article: 6 }
234
- content_positions.each do |type, positions|
235
- count = positions&.length || 0
236
- percentage = (count.to_f / feed_size * 100).round(1)
237
- puts "#{type}: #{count} items (#{percentage}% of feed)"
238
-
239
- if count != expected_counts[type]
240
- record_issue("#{type} count #{count} doesn't match expected #{expected_counts[type]}")
241
- end
242
- end
243
-
244
314
  # Test with custom type order
245
315
  ordered_result = TypeBalancer.balance(
246
316
  items,
247
317
  type_field: :type,
248
318
  type_order: %w[article image video]
249
319
  )
250
-
251
- # Verify type order is respected
252
320
  if ordered_result.first[:type] == 'article'
253
321
  @examples_passed += 1
322
+ puts "#{GREEN}Custom type order respected in real world feed#{RESET}"
323
+ print_section_table('real_world_feed', 1, 1)
254
324
  else
255
- record_issue("Custom type order not respected")
325
+ record_issue("Custom type order not respected in real world feed")
326
+ puts "#{RED}Custom type order not respected in real world feed#{RESET}"
327
+ print_section_table('real_world_feed', 1, 0)
256
328
  end
329
+ puts " Balanced items with custom order: #{ordered_result.map { |i| i[:type] }.inspect}"
330
+ end
257
331
 
258
- # Test position calculation
259
- positions = TypeBalancer::Distributor.calculate_target_positions(
260
- total_count: 10,
261
- ratio: 0.3
262
- )
263
- if positions.is_a?(Array) && positions.all? { |p| p.is_a?(Integer) }
332
+ def check_custom_type_field
333
+ @examples_run += 1
334
+ puts "\nCustom Type Field Example:"
335
+ data = [
336
+ { category: 'A', payload: 1 },
337
+ { category: 'B', payload: 2 },
338
+ { category: 'C', payload: 3 },
339
+ { category: 'A', payload: 4 }
340
+ ]
341
+ balanced = TypeBalancer.balance(data, type_field: :category)
342
+ found = balanced.map { |i| i[:category] }.uniq.sort
343
+ expected = %w[A B C]
344
+ if found == expected
264
345
  @examples_passed += 1
346
+ puts "#{GREEN}Custom field respected: #{found.inspect}#{RESET}"
265
347
  else
266
- record_issue("Position calculation failed")
348
+ record_issue("Expected #{expected.inspect}, got #{found.inspect}")
349
+ puts "#{RED}Custom field test failed: #{found.inspect}#{RESET}"
267
350
  end
268
-
269
- puts "\nBalanced items with custom order:"
351
+ print_section_table('custom_type_field', 1, found == expected ? 1 : 0)
270
352
  end
271
353
 
272
354
  def print_summary
@@ -276,15 +358,28 @@ class QualityChecker
276
358
  puts "Expectations Passed: #{@examples_passed}"
277
359
 
278
360
  if @issues.empty?
279
- puts "\nAll quality checks passed! "
361
+ puts "\n#{GREEN}All quality checks passed! ✓#{RESET}"
280
362
  else
281
- puts "\nQuality check failed with #{@issues.size} issues:"
363
+ puts "\n#{RED}Quality check failed with #{@issues.size} issues:#{RESET}"
282
364
  @issues.each_with_index do |issue, index|
283
- puts "#{index + 1}. #{issue}"
365
+ puts "#{RED}#{index + 1}. #{issue}#{RESET}"
284
366
  end
285
367
  end
286
368
  puts "#{'-' * 50}"
287
369
  end
370
+
371
+ # Print a summary table for a section
372
+ def print_section_table(section, run = @section_examples_run, passed = @section_examples_passed)
373
+ failed = run - passed
374
+ puts "\nSection: #{section}"
375
+ puts "-----------------------------"
376
+ puts " #{GREEN}Passing: #{passed}#{RESET}"
377
+ puts " #{RED}Failing: #{failed}#{RESET}"
378
+ puts "-----------------------------\n"
379
+ # Reset section counters for next section
380
+ @section_examples_run = 0
381
+ @section_examples_passed = 0
382
+ end
288
383
  end
289
384
 
290
385
  QualityChecker.new.run
@@ -3,6 +3,7 @@
3
3
  require_relative 'ratio_calculator'
4
4
  require_relative 'batch_processing'
5
5
  require_relative 'position_calculator'
6
+ require_relative 'type_extractor_registry'
6
7
 
7
8
  module TypeBalancer
8
9
  # Handles balancing of items across batches based on type ratios
@@ -10,9 +11,11 @@ module TypeBalancer
10
11
  # Initialize a new Balancer instance
11
12
  #
12
13
  # @param types [Array<String>, nil] Optional types
14
+ # @param type_field [Symbol] Field to use for type extraction (default: :type)
13
15
  # @param type_order [Array<String>, nil] Optional order of types
14
- def initialize(types = nil, type_order: nil)
16
+ def initialize(types = nil, type_field: :type, type_order: nil)
15
17
  @types = Array(types) if types
18
+ @type_field = type_field
16
19
  @type_order = type_order
17
20
  validate_types! if @types
18
21
  end
@@ -23,7 +26,18 @@ module TypeBalancer
23
26
  # @return [Array] Balanced items
24
27
  def call(collection)
25
28
  validate_collection!(collection)
26
- items_by_type = group_items_by_type(collection)
29
+ extractor = TypeExtractorRegistry.get(@type_field)
30
+
31
+ begin
32
+ items_by_type = extractor.group_by_type(collection)
33
+ rescue TypeBalancer::Error => e
34
+ raise TypeBalancer::Error, "Cannot access type field '#{@type_field}': #{e.message}"
35
+ end
36
+
37
+ # Remove nil types and validate
38
+ items_by_type.delete(nil)
39
+ raise TypeBalancer::Error, "Cannot access type field '#{@type_field}'" if items_by_type.empty?
40
+
27
41
  validate_types_in_collection!(items_by_type)
28
42
 
29
43
  target_counts = calculate_target_counts(items_by_type)
@@ -70,24 +84,7 @@ module TypeBalancer
70
84
  raise TypeBalancer::Error, "Invalid type(s): #{invalid_types.join(', ')}" if invalid_types.any?
71
85
  end
72
86
 
73
- def group_items_by_type(collection)
74
- collection.group_by do |item|
75
- extract_type(item)
76
- end
77
- end
78
-
79
- def extract_type(item)
80
- return item[:type] || item['type'] || raise(TypeBalancer::Error, 'Cannot access type field') if item.is_a?(Hash)
81
-
82
- begin
83
- item.type
84
- rescue NoMethodError
85
- raise TypeBalancer::Error, 'Cannot access type field'
86
- end
87
- end
88
-
89
87
  def calculate_target_counts(items_by_type)
90
- items_by_type.values.sum(&:size)
91
88
  items_by_type.transform_values(&:size)
92
89
  end
93
90
 
@@ -20,10 +20,15 @@ module TypeBalancer
20
20
  if item.respond_to?(@type_field)
21
21
  item.send(@type_field)
22
22
  elsif item.respond_to?(:[])
23
- item[@type_field] || item[@type_field.to_s]
23
+ value = item[@type_field] || item[@type_field.to_s]
24
+ raise TypeBalancer::Error, "Cannot access type field '#{@type_field}' on item #{item.inspect}" if value.nil?
25
+
26
+ value
24
27
  else
25
- raise Error, "Cannot access type field '#{@type_field}' on item #{item}"
28
+ raise TypeBalancer::Error, "Cannot access type field '#{@type_field}' on item #{item.inspect}"
26
29
  end
30
+ rescue NoMethodError, TypeError
31
+ raise TypeBalancer::Error, "Cannot access type field '#{@type_field}' on item #{item.inspect}"
27
32
  end
28
33
  end
29
34
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeBalancer
4
+ # Registry to memoize TypeExtractor per type_field in thread/request scope
5
+ class TypeExtractorRegistry
6
+ STORAGE_KEY = :type_balancer_extractors
7
+
8
+ def self.get(type_field)
9
+ cache[type_field] ||= TypeExtractor.new(type_field)
10
+ end
11
+
12
+ def self.clear!
13
+ Thread.current[STORAGE_KEY] = nil
14
+ end
15
+
16
+ def self.cache
17
+ Thread.current[STORAGE_KEY] ||= {}
18
+ end
19
+ end
20
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypeBalancer
4
- VERSION = '0.1.2'
4
+ VERSION = '0.1.4'
5
5
  end
data/lib/type_balancer.rb CHANGED
@@ -6,6 +6,8 @@ require_relative 'type_balancer/balancer'
6
6
  require_relative 'type_balancer/ratio_calculator'
7
7
  require_relative 'type_balancer/batch_processing'
8
8
  require 'type_balancer/position_calculator'
9
+ require_relative 'type_balancer/type_extractor'
10
+ require_relative 'type_balancer/type_extractor_registry'
9
11
 
10
12
  module TypeBalancer
11
13
  class Error < StandardError; end
@@ -33,31 +35,27 @@ module TypeBalancer
33
35
  # Input validation
34
36
  raise EmptyCollectionError, 'Collection cannot be empty' if items.empty?
35
37
 
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) }
38
+ # Use centralized extractor
39
+ extractor = TypeExtractorRegistry.get(type_field)
40
+ begin
41
+ types = extractor.extract_types(items)
42
+ raise Error, "Invalid type field: #{type_field}" if types.empty?
43
+ rescue Error => e
44
+ raise Error, "Cannot access type field '#{type_field}': #{e.message}"
45
+ end
42
46
 
43
- # Initialize balancer with type order if provided
44
- balancer = Balancer.new(types, type_order: type_order)
47
+ # Initialize balancer with type order and type field
48
+ balancer = Balancer.new(types, type_field: type_field, type_order: type_order)
45
49
 
46
50
  # Balance items
47
51
  balancer.call(items)
48
52
  end
49
53
 
54
+ # Backward compatibility methods
50
55
  def self.extract_types(items, type_field)
51
- items.map { |item| extract_type(item, type_field) }.uniq
52
- end
53
-
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
56
+ TypeExtractorRegistry.get(type_field).extract_types(items)
57
+ rescue Error
58
+ # For backward compatibility, return array with nil for inaccessible type fields
59
+ [nil]
62
60
  end
63
61
  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.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Smith
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2025-04-29 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
@@ -37,8 +37,12 @@ files:
37
37
  - benchmark_results/ruby3.3.7_yjit.txt
38
38
  - benchmark_results/ruby3.4.2.txt
39
39
  - benchmark_results/ruby3.4.2_yjit.txt
40
+ - docs/README.md
41
+ - docs/balance.md
40
42
  - docs/benchmarks/README.md
43
+ - docs/calculate_positions.md
41
44
  - docs/quality.md
45
+ - examples/balance_test_data.yml
42
46
  - examples/quality.rb
43
47
  - lib/type_balancer.rb
44
48
  - lib/type_balancer/alternating_filler.rb
@@ -52,6 +56,7 @@ files:
52
56
  - lib/type_balancer/ratio_calculator.rb
53
57
  - lib/type_balancer/sequential_filler.rb
54
58
  - lib/type_balancer/type_extractor.rb
59
+ - lib/type_balancer/type_extractor_registry.rb
55
60
  - lib/type_balancer/version.rb
56
61
  - type_balancer.gemspec
57
62
  homepage: https://github.com/llwebconsulting/type_balancer
@@ -76,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
76
81
  - !ruby/object:Gem::Version
77
82
  version: '0'
78
83
  requirements: []
79
- rubygems_version: 3.6.7
84
+ rubygems_version: 3.6.2
80
85
  specification_version: 4
81
86
  summary: Balances types in collections
82
87
  test_files: []