├── 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 | 
4 | [](https://scrutinizer-ci.com/g/Smoren/probability-selector-php/?branch=master)
5 | [](https://coveralls.io/github/Smoren/probability-selector-php?branch=master)
6 | 
7 | [](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 |
--------------------------------------------------------------------------------