Passed
Push — master ( 20ed2c...ae37f2 )
by Smoren
02:19
created

ProbabilitySelector::getIterator()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 2
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 3
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Smoren\ProbabilitySelector;
6
7
/**
8
 * Probability-based selection manager.
9
 *
10
 * @template T
11
 *
12
 * @implements \IteratorAggregate<int, T>
13
 */
14
class ProbabilitySelector implements \IteratorAggregate
15
{
16
    /**
17
     * @var array<T> data storage
18
     */
19
    protected array $data = [];
20
21
    /**
22
     * @var array<array{float, int}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<array{float, int}> at position 4 could not be parsed: Expected ':' at position 4, but found 'float'.
Loading history...
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<array{T, float}|array{T, float, int}> $data
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<array{T, float}|array{T, float, int}> at position 4 could not be parsed: Expected ':' at position 4, but found 'T'.
Loading history...
40
     */
41 45
    public function __construct(array $data = [])
42
    {
43 45
        foreach ($data as $item) {
44 44
            if (\count($item) === 2) {
45 41
                $item[] = 0;
46
            }
47
48
            /** @var array{T, float, int} $item */
49 44
            [$datum, $weight, $usageCounter] = $item;
50 44
            $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 44
    public function addItem($datum, float $weight, int $usageCounter): self
64
    {
65 44
        if ($weight <= 0) {
66 4
            throw new \InvalidArgumentException('Weight cannot be negative');
67
        }
68
69 41
        $this->data[] = $datum;
70 41
        $this->probabilities[] = [$weight, $usageCounter];
71 41
        $this->weightSum += $weight;
72 41
        $this->totalUsageCounter += $usageCounter;
73
74 41
        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 41
    public function decide()
85
    {
86 41
        $maxScore = -INF;
87 41
        $maxScoreWeight = -INF;
88 41
        $maxScoreId = null;
89
90 41
        if (\count($this->probabilities) === 0) {
91 1
            throw new \LengthException('Candidate not found in empty list');
92
        }
93
94 40
        foreach ($this->probabilities as $id => [$weight, $usageCounter]) {
95 40
            $score = $weight / ($usageCounter + 1);
96
97 40
            if ($this->areFloatsEqual($score, $maxScore) && $weight > $maxScoreWeight || $score > $maxScore) {
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: ($this->areFloatsEqual($...) || $score > $maxScore, Probably Intended Meaning: $this->areFloatsEqual($s... || $score > $maxScore)
Loading history...
98 40
                $maxScore = $score;
99 40
                $maxScoreWeight = $weight;
100 40
                $maxScoreId = $id;
101
            }
102
        }
103
104
        /** @var int $maxScoreId */
105 40
        $this->incrementUsageCounter($maxScoreId);
106 40
        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 30
    public function getIterator(?int $limit = null): \Generator
117
    {
118 30
        for ($i = 0; $limit === null || $i < $limit; ++$i) {
119 30
            yield $this->totalUsageCounter => $this->decide();
120
        }
121
    }
122
123
    /**
124
     * Increments usage counter of datum by its ID.
125
     *
126
     * @param int $id datum ID
127
     *
128
     * @return int current value of usage counter
129
     */
130 40
    protected function incrementUsageCounter(int $id): int
131
    {
132 40
        $this->totalUsageCounter++;
133 40
        return ++$this->probabilities[$id][1];
134
    }
135
136
    /**
137
     * Returns true if parameters are equal.
138
     *
139
     * @param float $lhs
140
     * @param float $rhs
141
     *
142
     * @return bool
143
     */
144 40
    protected function areFloatsEqual(float $lhs, float $rhs): bool
145
    {
146 40
        return \abs($lhs - $rhs) < PHP_FLOAT_EPSILON;
147
    }
148
}
149