├── tests ├── _bootstrap.php ├── unit.suite.yml ├── coding_standard.xml ├── _support │ └── UnitTester.php └── unit │ └── ProbabilitySelectorTest.php ├── phpstan.neon ├── .gitignore ├── phpcs.xml ├── codeception.yml ├── .scrutinizer.yml ├── LICENSE ├── composer.json ├── .github └── workflows │ └── test_master.yml ├── README.md └── src └── ProbabilitySelector.php /tests/_bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | ./ 4 | ./vendor/* 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/unit.suite.yml: -------------------------------------------------------------------------------- 1 | # Codeception Test Suite Configuration 2 | # 3 | # Suite for unit or integration tests. 4 | 5 | class_name: UnitTester 6 | modules: 7 | enabled: 8 | - Asserts 9 | -------------------------------------------------------------------------------- /tests/coding_standard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | The coding standard for Range PHP. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /codeception.yml: -------------------------------------------------------------------------------- 1 | actor: Tester 2 | bootstrap: _bootstrap.php 3 | paths: 4 | tests: tests 5 | log: tests/_output 6 | output: tests/_output 7 | data: tests/_data 8 | helpers: tests/_support 9 | settings: 10 | memory_limit: 1024M 11 | colors: true 12 | coverage: 13 | enabled: true 14 | show_uncovered: false 15 | include: 16 | - src/* 17 | exclude: 18 | - vendor/* 19 | - tests/* 20 | -------------------------------------------------------------------------------- /tests/_support/UnitTester.php: -------------------------------------------------------------------------------- 1 | =7.4", 14 | "ext-json": "*", 15 | "ext-mbstring": "*" 16 | }, 17 | "require-dev": { 18 | "codeception/codeception": "^4.2.1", 19 | "codeception/module-asserts": "^2.0", 20 | "php-coveralls/php-coveralls": "^2.0", 21 | "squizlabs/php_codesniffer": "3.*", 22 | "phpstan/phpstan": "^1.8" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Smoren\\ProbabilitySelector\\": "src" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Smoren\\ProbabilitySelector\\Tests\\Unit\\": "tests/unit" 32 | } 33 | }, 34 | "config": { 35 | "fxp-asset": { 36 | "enabled": false 37 | } 38 | }, 39 | "repositories": [ 40 | { 41 | "type": "composer", 42 | "url": "https://asset-packagist.org" 43 | } 44 | ], 45 | "scripts": { 46 | "test-init": ["./vendor/bin/codecept build"], 47 | "test-all": ["composer test-coverage", "composer codesniffer", "composer stan"], 48 | "test": ["./vendor/bin/codecept run unit tests/unit"], 49 | "test-coverage": ["./vendor/bin/codecept run unit tests/unit --coverage"], 50 | "test-coverage-html": ["./vendor/bin/codecept run unit tests/unit --coverage-html"], 51 | "test-coverage-xml": ["./vendor/bin/codecept run unit tests/unit --coverage-xml"], 52 | "codesniffer": ["./vendor/bin/phpcs --ignore=vendor,tests --standard=tests/coding_standard.xml -s ."], 53 | "stan": ["./vendor/bin/phpstan analyse"] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/test_master.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | name: Test 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | php: ['7.4', '8.0', '8.1', '8.2', '8.3'] 18 | 19 | steps: 20 | - name: Set up PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php }} 24 | coverage: xdebug 25 | tools: composer:v2 26 | 27 | - name: Checkout code 28 | uses: actions/checkout@v3 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: PHP Version Check 33 | run: php -v 34 | 35 | - name: Validate Composer JSON 36 | run: composer validate 37 | 38 | - name: Run Composer 39 | run: composer install --no-interaction 40 | 41 | - name: Unit tests 42 | run: | 43 | composer test-init 44 | composer test 45 | 46 | - name: PHP Code Sniffer 47 | run: composer codesniffer 48 | 49 | - name: PHPStan analysis 50 | run: composer stan 51 | 52 | code-coverage: 53 | name: Code coverage 54 | runs-on: ubuntu-latest 55 | strategy: 56 | matrix: 57 | php: ['7.4'] 58 | 59 | steps: 60 | - name: Set up PHP 61 | uses: shivammathur/setup-php@v2 62 | with: 63 | php-version: ${{ matrix.php }} 64 | coverage: xdebug 65 | tools: composer:v2 66 | 67 | - name: Checkout code 68 | uses: actions/checkout@v3 69 | with: 70 | fetch-depth: 0 71 | 72 | - name: Run Composer 73 | run: composer install --no-interaction 74 | 75 | - name: Unit tests 76 | run: | 77 | composer test-init 78 | composer test-coverage-xml 79 | mkdir -p ./build/logs 80 | cp ./tests/_output/coverage.xml ./build/logs/clover.xml 81 | - name: Code Coverage (Coveralls) 82 | env: 83 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | run: php vendor/bin/php-coveralls -v 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Probability Selector 2 | 3 | ![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/smoren/probability-selector) 4 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Smoren/probability-selector-php/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Smoren/probability-selector-php/?branch=master) 5 | [![Coverage Status](https://coveralls.io/repos/github/Smoren/probability-selector-php/badge.svg?branch=master)](https://coveralls.io/github/Smoren/probability-selector-php?branch=master) 6 | ![Build and test](https://github.com/Smoren/probability-selector-php/actions/workflows/test_master.yml/badge.svg) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 8 | 9 | Selection manager for choosing next elements to use from data source based on uniform distribution of selections. 10 | 11 | #### Infinite iteration 12 | ```php 13 | use Smoren\ProbabilitySelector\ProbabilitySelector; 14 | 15 | $ps = new ProbabilitySelector([ 16 | // data // weight // initial usage counter 17 | ['first', 1, 0], 18 | ['second', 2, 0], 19 | ['third', 3, 4], 20 | ]); 21 | 22 | foreach ($ps as $datum) { 23 | echo "{$datum}, "; 24 | } 25 | // second, second, first, second, third, third, second, first, third, second, third, third, second, first, third, ... 26 | ``` 27 | 28 | #### Iteration limit and export 29 | ```php 30 | use Smoren\ProbabilitySelector\ProbabilitySelector; 31 | 32 | $ps = new ProbabilitySelector([ 33 | // data // weight 34 | ['first', 1], 35 | ['second', 2], 36 | ]); 37 | foreach ($ps->getIterator(6) as $datum) { 38 | echo "{$datum}, "; 39 | } 40 | // second, second, first, second, second, first 41 | 42 | print_r($ps->export()); 43 | /* 44 | [ 45 | ['first', 1, 2], 46 | ['second', 2, 4], 47 | ] 48 | */ 49 | ``` 50 | 51 | #### Single decision 52 | ```php 53 | use Smoren\ProbabilitySelector\ProbabilitySelector; 54 | 55 | $ps = new ProbabilitySelector([ 56 | // data // weight 57 | ['first', 1], 58 | ['second', 2], 59 | ]); 60 | $ps->decide(); // second 61 | $ps->decide(); // second 62 | $ps->decide(); // first 63 | ``` 64 | 65 | ## Unit testing 66 | ``` 67 | composer install 68 | composer test-init 69 | composer test 70 | ``` 71 | 72 | ## Standards 73 | 74 | PHP Probability Selector conforms to the following standards: 75 | 76 | * PSR-1 — [Basic coding standard](https://www.php-fig.org/psr/psr-1/) 77 | * PSR-4 — [Autoloader](https://www.php-fig.org/psr/psr-4/) 78 | * PSR-12 — [Extended coding style guide](https://www.php-fig.org/psr/psr-12/) 79 | 80 | 81 | ## License 82 | 83 | PHP Probability Selector is licensed under the MIT License. 84 | -------------------------------------------------------------------------------- /src/ProbabilitySelector.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class ProbabilitySelector implements \IteratorAggregate 15 | { 16 | /** 17 | * @var array data storage 18 | */ 19 | protected array $data = []; 20 | 21 | /** 22 | * @var array 23 | */ 24 | protected array $probabilities = []; 25 | 26 | /** 27 | * @var float sum of all the weights of data 28 | */ 29 | protected float $weightSum = 0; 30 | 31 | /** 32 | * @var int usage counters sum of data 33 | */ 34 | protected int $totalUsageCounter = 0; 35 | 36 | /** 37 | * ProbabilitySelector constructor. 38 | * 39 | * @param array $data 40 | */ 41 | public function __construct(array $data = []) 42 | { 43 | foreach ($data as $item) { 44 | if (\count($item) === 2) { 45 | $item[] = 0; 46 | } 47 | 48 | /** @var array{T, float, int} $item */ 49 | [$datum, $weight, $usageCounter] = $item; 50 | $this->addItem($datum, $weight, $usageCounter); 51 | } 52 | } 53 | 54 | /** 55 | * Adds datum to the select list. 56 | * 57 | * @param T $datum datum to add 58 | * @param float $weight weight of datum 59 | * @param int $usageCounter initial usage counter value for datum 60 | * 61 | * @return $this 62 | */ 63 | public function addItem($datum, float $weight, int $usageCounter): self 64 | { 65 | if ($weight <= 0) { 66 | throw new \InvalidArgumentException('Weight cannot be negative'); 67 | } 68 | 69 | $this->data[] = $datum; 70 | $this->probabilities[] = [$weight, $usageCounter]; 71 | $this->weightSum += $weight; 72 | $this->totalUsageCounter += $usageCounter; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * Chooses and returns datum from select list, marks it used. 79 | * 80 | * @return T chosen datum 81 | * 82 | * @throws \LengthException when selectable list is empty 83 | */ 84 | public function decide() 85 | { 86 | $maxScore = -INF; 87 | $maxScoreWeight = -INF; 88 | $maxScoreId = null; 89 | 90 | if (\count($this->probabilities) === 0) { 91 | throw new \LengthException('Candidate not found in empty list'); 92 | } 93 | 94 | foreach ($this->probabilities as $id => [$weight, $usageCounter]) { 95 | $score = $weight / ($usageCounter + 1); 96 | 97 | if ($this->areFloatsEqual($score, $maxScore) && $weight > $maxScoreWeight || $score > $maxScore) { 98 | $maxScore = $score; 99 | $maxScoreWeight = $weight; 100 | $maxScoreId = $id; 101 | } 102 | } 103 | 104 | /** @var int $maxScoreId */ 105 | $this->incrementUsageCounter($maxScoreId); 106 | return $this->data[$maxScoreId]; 107 | } 108 | 109 | /** 110 | * Returns iterator to get decisions sequence. 111 | * 112 | * @param int|null $limit 113 | * 114 | * @return \Generator 115 | */ 116 | public function getIterator(?int $limit = null): \Generator 117 | { 118 | for ($i = 0; $limit === null || $i < $limit; ++$i) { 119 | yield $this->totalUsageCounter => $this->decide(); 120 | } 121 | } 122 | 123 | /** 124 | * Exports data with probabilities and usage counters. 125 | * 126 | * @return array 127 | */ 128 | public function export(): array 129 | { 130 | return array_map(fn ($datum, $config) => [$datum, ...$config], $this->data, $this->probabilities); 131 | } 132 | 133 | /** 134 | * Increments usage counter of datum by its ID. 135 | * 136 | * @param int $id datum ID 137 | * 138 | * @return int current value of usage counter 139 | */ 140 | protected function incrementUsageCounter(int $id): int 141 | { 142 | $this->totalUsageCounter++; 143 | return ++$this->probabilities[$id][1]; 144 | } 145 | 146 | /** 147 | * Returns true if parameters are equal. 148 | * 149 | * @param float $lhs 150 | * @param float $rhs 151 | * 152 | * @return bool 153 | */ 154 | protected function areFloatsEqual(float $lhs, float $rhs): bool 155 | { 156 | return \abs($lhs - $rhs) < PHP_FLOAT_EPSILON; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /tests/unit/ProbabilitySelectorTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $result); 38 | } 39 | 40 | /** 41 | * @dataProvider dataProviderForDemo 42 | * @dataProvider dataProviderForZeroInitialUsageCount 43 | * @dataProvider dataProviderForSpecificUsageCount 44 | * @param array $input 45 | * @param int $steps 46 | * @param array $expected 47 | * @return void 48 | */ 49 | public function testDecisionSequencesUnlimited(array $input, int $steps, array $expected): void 50 | { 51 | // Given 52 | $ps = new ProbabilitySelector($input); 53 | $result = []; 54 | 55 | // When 56 | foreach ($ps->getIterator($steps) as $datum) { 57 | $result[] = $datum; 58 | } 59 | 60 | // Then 61 | $this->assertEquals($expected, $result); 62 | } 63 | 64 | public function dataProviderForDemo(): array 65 | { 66 | return [ 67 | [ 68 | [ 69 | ['first', 1, 0], 70 | ['second', 2, 0], 71 | ['third', 3, 4], 72 | ], 73 | 15, 74 | ['second', 'second', 'first', 'second', 'third', 'third', 'second', 'first', 'third', 'second', 'third', 'third', 'second', 'first', 'third'], 75 | ], 76 | ]; 77 | } 78 | 79 | public function dataProviderForZeroInitialUsageCount(): array 80 | { 81 | return [ 82 | [ 83 | [ 84 | ['a', 1], 85 | ], 86 | 3, 87 | ['a', 'a', 'a'], 88 | ], 89 | [ 90 | [ 91 | ['a', 0.5], 92 | ], 93 | 3, 94 | ['a', 'a', 'a'], 95 | ], 96 | [ 97 | [ 98 | ['a', 1], 99 | ['b', 1], 100 | ], 101 | 5, 102 | ['a', 'b', 'a', 'b', 'a'], 103 | ], 104 | [ 105 | [ 106 | ['a', 2], 107 | ['b', 1], 108 | ], 109 | 6, 110 | ['a', 'a', 'b', 'a', 'a', 'b'], 111 | ], 112 | [ 113 | [ 114 | ['a', 1], 115 | ['b', 2], 116 | ], 117 | 6, 118 | ['b', 'b', 'a', 'b', 'b', 'a'], 119 | ], 120 | [ 121 | [ 122 | ['a', 1], 123 | ['b', 2], 124 | ['c', 1], 125 | ], 126 | 10, 127 | ['b', 'b', 'a', 'c', 'b', 'b', 'a', 'c', 'b', 'b'], 128 | ], 129 | [ 130 | [ 131 | ['a', 0.1], 132 | ['b', 0.2], 133 | ['c', 0.1], 134 | ], 135 | 10, 136 | ['b', 'b', 'a', 'c', 'b', 'b', 'a', 'c', 'b', 'b'], 137 | ], 138 | [ 139 | [ 140 | ['a', 1], 141 | ['b', 2], 142 | ['c', 3], 143 | ], 144 | 12, 145 | ['c', 'b', 'c', 'c', 'b', 'a', 'c', 'b', 'c', 'c', 'b', 'a'], 146 | ], 147 | [ 148 | [ 149 | ['a', 2], 150 | ['b', 4], 151 | ['c', 6], 152 | ], 153 | 12, 154 | ['c', 'b', 'c', 'c', 'b', 'a', 'c', 'b', 'c', 'c', 'b', 'a'], 155 | ], 156 | [ 157 | [ 158 | ['a', 0.2], 159 | ['b', 0.4], 160 | ['c', 0.6], 161 | ], 162 | 12, 163 | ['c', 'b', 'c', 'c', 'b', 'a', 'c', 'b', 'c', 'c', 'b', 'a'], 164 | ], 165 | [ 166 | [ 167 | ['a', 1], 168 | ['b', 2], 169 | ['c', 4], 170 | ], 171 | 12, 172 | ['c', 'c', 'b', 'c', 'c', 'b', 'a', 'c', 'c', 'b', 'c', 'c'], 173 | ], 174 | ]; 175 | } 176 | 177 | public function dataProviderForSpecificUsageCount(): array 178 | { 179 | return [ 180 | [ 181 | [ 182 | ['a', 1], 183 | ['b', 1, 2], 184 | ], 185 | 10, 186 | ['a', 'a', 'a', 'b', 'a', 'b', 'a', 'b', 'a', 'b'], 187 | ], 188 | [ 189 | [ 190 | ['a', 1], 191 | ['b', 1, 3], 192 | ], 193 | 10, 194 | ['a', 'a', 'a', 'a', 'b', 'a', 'b', 'a', 'b', 'a'], 195 | ], 196 | [ 197 | [ 198 | ['a', 1], 199 | ['b', 2, 3], 200 | ], 201 | 10, 202 | ['a', 'b', 'a', 'b', 'b', 'a', 'b', 'b', 'a', 'b'], 203 | ], 204 | ]; 205 | } 206 | 207 | /** 208 | * @dataProvider dataProviderForExport 209 | * @param array $input 210 | * @param int $count 211 | * @param array $expected 212 | * @return void 213 | */ 214 | public function testExport(array $input, int $count, array $expected): void 215 | { 216 | // Given 217 | $ps = new ProbabilitySelector($input); 218 | 219 | // When 220 | foreach ($ps->getIterator($count) as $_) { 221 | } 222 | 223 | // Then 224 | $this->assertEquals($expected, $ps->export()); 225 | } 226 | 227 | public function dataProviderForExport(): array 228 | { 229 | return [ 230 | [ 231 | [ 232 | ['a', 2, 0], 233 | ['b', 1, 0], 234 | ], 235 | 6, 236 | [ 237 | ['a', 2, 4], 238 | ['b', 1, 2], 239 | ], 240 | ], 241 | [ 242 | [ 243 | ['a', 2, 0], 244 | ['b', 1, 1], 245 | ], 246 | 5, 247 | [ 248 | ['a', 2, 4], 249 | ['b', 1, 2], 250 | ], 251 | ], 252 | ]; 253 | } 254 | 255 | /** 256 | * @dataProvider dataProviderForAxiomatic 257 | * @param array $input 258 | * @param int $cyclesCount 259 | * @return void 260 | */ 261 | public function testAxiomatic(array $input, int $cyclesCount) 262 | { 263 | // Given 264 | $ps = new ProbabilitySelector($input); 265 | $countMap = \array_map(fn ($item) => 0, \array_flip(\array_map(fn ($item) => $item[0], $input))); 266 | $weightSum = \array_sum(\array_map(fn ($item) => $item[1], $input)); 267 | $count = \round($cyclesCount * $weightSum, 4); 268 | 269 | // When 270 | for ($i = 0; $i < $count; ++$i) { 271 | $datum = $ps->decide(); 272 | $countMap[$datum]++; 273 | } 274 | 275 | $result = \array_map(fn (int $count, array $inputItem) => $count / $inputItem[1], $countMap, $input); 276 | $result = \array_unique($result); 277 | 278 | // Then 279 | $this->assertCount(1, $result); 280 | } 281 | 282 | public function dataProviderForAxiomatic(): array 283 | { 284 | return [ 285 | [ 286 | [ 287 | ['a', 1], 288 | ['b', 2], 289 | ['c', 3], 290 | ], 291 | 1, 292 | ], 293 | [ 294 | [ 295 | ['a', 1], 296 | ['b', 2], 297 | ['c', 3], 298 | ], 299 | 10, 300 | ], 301 | [ 302 | [ 303 | ['a', 1], 304 | ['b', 2], 305 | ['c', 3], 306 | ], 307 | 100, 308 | ], 309 | [ 310 | [ 311 | ['a', 2], 312 | ['b', 4], 313 | ['c', 6], 314 | ], 315 | 100, 316 | ], 317 | [ 318 | [ 319 | ['a', 0.1], 320 | ['b', 0.2], 321 | ['c', 0.3], 322 | ], 323 | 100, 324 | ], 325 | [ 326 | [ 327 | ['a', 1], 328 | ['b', 2], 329 | ['c', 4], 330 | ], 331 | 100, 332 | ], 333 | [ 334 | [ 335 | ['a', 1], 336 | ['b', 1], 337 | ['c', 3], 338 | ], 339 | 100, 340 | ], 341 | [ 342 | [ 343 | ['a', 0.1], 344 | ['b', 1], 345 | ['c', 3], 346 | ], 347 | 100, 348 | ], 349 | [ 350 | [ 351 | ['a', 0.1], 352 | ['b', 1], 353 | ['c', 30], 354 | ], 355 | 100, 356 | ], 357 | [ 358 | [ 359 | ['a', 0.1], 360 | ['b', 2], 361 | ['c', 0.5], 362 | ['d', 3], 363 | ['e', 2.2], 364 | ['f', 3], 365 | ['g', 30], 366 | ['h', 30], 367 | ], 368 | 100, 369 | ], 370 | ]; 371 | } 372 | 373 | /** 374 | * @return void 375 | */ 376 | public function testErrorOnEmptyList(): void 377 | { 378 | // Given 379 | $ps = new ProbabilitySelector(); 380 | 381 | // Then 382 | $this->expectException(\LengthException::class); 383 | $this->expectExceptionMessage('Candidate not found in empty list'); 384 | 385 | // When 386 | $ps->decide(); 387 | } 388 | 389 | /** 390 | * @dataProvider dataProviderForErrorOnNegativeWeight 391 | * @param array $input 392 | * @return void 393 | */ 394 | public function testErrorOnNegativeWeight(array $input): void 395 | { 396 | // Then 397 | $this->expectException(\InvalidArgumentException::class); 398 | $this->expectExceptionMessage('Weight cannot be negative'); 399 | 400 | // When 401 | new ProbabilitySelector($input); 402 | } 403 | 404 | public function dataProviderForErrorOnNegativeWeight(): array 405 | { 406 | return [ 407 | [ 408 | [ 409 | ['a', -1], 410 | ], 411 | ], 412 | [ 413 | [ 414 | ['a', -1, 0], 415 | ], 416 | ], 417 | [ 418 | [ 419 | ['a', -0.1], 420 | ], 421 | ], 422 | [ 423 | [ 424 | ['a', 1], 425 | ['b', -0.2], 426 | ['c', 3], 427 | ], 428 | ], 429 | ]; 430 | } 431 | } 432 | --------------------------------------------------------------------------------