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 +4 -4
- data/CHANGELOG.md +14 -2
- data/Gemfile.lock +1 -1
- data/README.md +57 -3
- data/docs/README.md +8 -0
- data/docs/balance.md +71 -0
- data/docs/calculate_positions.md +87 -0
- data/docs/quality.md +55 -21
- data/examples/balance_test_data.yml +66 -0
- data/examples/quality.rb +168 -73
- data/lib/type_balancer/balancer.rb +16 -19
- data/lib/type_balancer/type_extractor.rb +7 -2
- data/lib/type_balancer/type_extractor_registry.rb +20 -0
- data/lib/type_balancer/version.rb +1 -1
- data/lib/type_balancer.rb +17 -19
- metadata +8 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6c93fa5dcb75821b9f9ea3340f06ca63d67f84781bcf90f13ac089abac7283b7
|
4
|
+
data.tar.gz: fbfadcf9eed52f9f82a5f0a99f05fe3801c8529c32735f3d2eac5b0d42648a4b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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] -
|
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
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
|
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
|
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
|
-
- [
|
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.
|
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
|
-
|
48
|
-
-
|
49
|
-
-
|
50
|
-
-
|
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 "\
|
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
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
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("
|
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 "\
|
361
|
+
puts "\n#{GREEN}All quality checks passed! ✓#{RESET}"
|
280
362
|
else
|
281
|
-
puts "\
|
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
|
-
|
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
|
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
|
-
#
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
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.
|
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:
|
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.
|
84
|
+
rubygems_version: 3.6.2
|
80
85
|
specification_version: 4
|
81
86
|
summary: Balances types in collections
|
82
87
|
test_files: []
|