Passed
Push — master ( 28035d...b1f12b )
by Smoren
02:05
created

ProbabilitySelector::export()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
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 47
    public function __construct(array $data = [])
42
    {
43 47
        foreach ($data as $item) {
44 46
            if (\count($item) === 2) {
45 41
                $item[] = 0;
46
            }
47
48
            /** @var array{T, float, int} $item */
49 46
            [$datum, $weight, $usageCounter] = $item;
50 46
            $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 46
    public function addItem($datum, float $weight, int $usageCounter): self
64
    {
65 46
        if ($weight <= 0) {
66 4
            throw new \InvalidArgumentException('Weight cannot be negative');
67
        }
68
69 43
        $this->data[] = $datum;
70 43
        $this->probabilities[] = [$weight, $usageCounter];
71 43
        $this->weightSum += $weight;
72 43
        $this->totalUsageCounter += $usageCounter;
73
74 43
        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 43
    public function decide()
85
    {
86 43
        $maxScore = -INF;
87 43
        $maxScoreWeight = -INF;
88 43
        $maxScoreId = null;
89
90 43
        if (\count($this->probabilities) === 0) {
91 1
            throw new \LengthException('Candidate not found in empty list');
92
        }
93
94 42
        foreach ($this->probabilities as $id => [$weight, $usageCounter]) {
95 42
            $score = $weight / ($usageCounter + 1);
96
97 42
            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 42
                $maxScore = $score;
99 42
                $maxScoreWeight = $weight;
100 42
                $maxScoreId = $id;
101
            }
102
        }
103
104
        /** @var int $maxScoreId */
105 42
        $this->incrementUsageCounter($maxScoreId);
106 42
        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 32
    public function getIterator(?int $limit = null): \Generator
117
    {
118 32
        for ($i = 0; $limit === null || $i < $limit; ++$i) {
119 32
            yield $this->totalUsageCounter => $this->decide();
120
        }
121
    }
122
123
    /**
124
     * Exports data with probabilities and usage counters.
125
     *
126
     * @return array<array{T, float, int}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<array{T, float, int}> at position 4 could not be parsed: Expected ':' at position 4, but found 'T'.
Loading history...
127
     */
128 2
    public function export(): array
129
    {
130 2
        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 42
    protected function incrementUsageCounter(int $id): int
141
    {
142 42
        $this->totalUsageCounter++;
143 42
        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 42
    protected function areFloatsEqual(float $lhs, float $rhs): bool
155
    {
156 42
        return \abs($lhs - $rhs) < PHP_FLOAT_EPSILON;
157
    }
158
}
159