Completed
Push — master ( b8d256...a49b1d )
by Michael
03:45
created

VoteHandler::getLowestCandidates()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
dl 0
loc 21
ccs 0
cts 13
cp 0
rs 9.0534
c 0
b 0
f 0
cc 4
eloc 13
nc 4
nop 1
crap 20
1
<?php
2
3
namespace Michaelc\Voting\STV;
4
5
use Psr\Log\LoggerInterface as Logger;
6
7
class VoteHandler
8
{
9
    /**
10
     * Election object.
11
     *
12
     * @var \Michaelc\Voting\STV\Election;
13
     */
14
    protected $election;
15
16
    /**
17
     * Array of all ballots in election.
18
     *
19
     * @var \MichaelC\Voting\STV\Ballot[]
20
     */
21
    protected $ballots;
22
23
    /**
24
     * Quota of votes needed for a candidate to be elected.
25
     *
26
     * @var int
27
     */
28
    protected $quota;
29
30
    /**
31
     * Number of candidates elected so far.
32
     *
33
     * @var int
34
     */
35
    protected $electedCandidates;
36
37
    /**
38
     * Invalid ballots.
39
     *
40
     * @var \MichaelC\Voting\STV\Ballot[]
41
     */
42
    protected $rejectedBallots;
43
44
    /**
45
     * Logger.
46
     *
47
     * @var Psr\Log\LoggerInterface
48
     */
49
    protected $logger;
50
51
    /**
52
     * Constructor.
53
     *
54
     * @param Election $election
55
     */
56 1
    public function __construct(Election $election, Logger $logger)
57
    {
58 1
        $this->logger = $logger;
0 ignored issues
show
Documentation Bug introduced by
It seems like $logger of type object<Psr\Log\LoggerInterface> is incompatible with the declared type object<Michaelc\Voting\S...sr\Log\LoggerInterface> of property $logger.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
59 1
        $this->election = $election;
60 1
        $this->ballots = $this->election->getBallots();
61 1
        $this->rejectedBallots = [];
62 1
    }
63
64
    /**
65
     * Run the election.
66
     *
67
     * @return \MichaelC\Voting\STV\Candidate[] Winning candidates
68
     */
69
    public function run()
70
    {
71
        $this->logger->notice('Starting to run an election');
72
73
        $this->rejectInvalidBallots();
74
        $this->quota = $this->getQuota();
75
76
        $this->firstStep();
77
78
        $candidates = $this->election->getActiveCandidates();
79
80
        while ($this->electedCandidates < $this->election->getWinnersCount()) {
81
            if (!$this->checkCandidates($candidates)) {
82
                $this->eliminateCandidates($candidates);
83
            }
84
        }
85
86
        $this->logger->notice('Election complete');
87
88
        return $this->election->getElectedCandidates();
89
    }
90
91
    /**
92
     * Perform the initial vote allocation.
93
     *
94
     * @return
95
     */
96
    protected function firstStep()
97
    {
98
        $this->logger->info('Beginning the first step');
99
100
        foreach ($this->ballots as $i => $ballot) {
101
            $this->logger->debug("Processing ballot $i in stage 1",
102
                ['ballot' => $ballot]
103
            );
104
105
            $this->allocateVotes($ballot);
106
        }
107
108
        $this->logger->notice('First step complete',
109
            ['candidatesStatus' => $this->election->getCandidatesStatus()]
110
        );
111
112
        return;
113
    }
114
115
    /**
116
     * Check if any candidates have reached the quota and can be elected.
117
     *
118
     * @param array $candidates Array of active candidates to check
119
     *
120
     * @return bool Whether any candidates were changed to elected
121
     */
122
    protected function checkCandidates(array $candidates): bool
123
    {
124
        $this->logger->info('Checking if candidates have passed quota');
125
126
        foreach ($candidates as $i => $candidate) {
127
            $this->logger->debug('Checking candidate', ['candidate' => $candidate]);
128
129
            if ($candidate->getVotes() >= $this->quota) {
130
                $this->electCandidate($candidate);
131
                $elected = true;
132
            }
133
        }
134
135
        $this->logger->info("Candidate checking complete. Someone was elected: $elected");
0 ignored issues
show
Bug introduced by
The variable $elected does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
136
137
        return $elected ?? false;
138
    }
139
140
    /**
141
     * Allocate the next votes from a Ballot.
142
     *
143
     * @param Ballot $ballot     The ballot to allocate the votes from
144
     * @param float  $multiplier Number to multiply the weight by (surplus)
145
     * @param float  $divisor    The divisor of the weight (Total number of
146
     *                           candidate votes)
147
     *
148
     * @return Ballot The same ballot passed in modified
149
     */
150
    protected function allocateVotes(Ballot $ballot, float $multiplier = 1.0, float $divisor = 1.0): Ballot
151
    {
152
        $weight = $ballot->setWeight(($ballot->getWeight() * $multiplier) / $divisor);
153
        $candidate = $ballot->getNextChoice();
154
155
        $this->logger->debug('Allocating votes of ballot', array(
156
            'ballot' => $ballot,
157
            'weight' => $weight,
158
            'candidate' => $candidate,
159
        ));
160
161
        if ($candidate !== null) {
162
            $this->election->getCandidate($candidate)->addVotes($weight);
163
            $ballot->incrementLevelUsed();
164
            $this->logger->debug('Vote added to candidate');
165
        }
166
167
        return $ballot;
168
    }
169
170
    /**
171
     * Transfer the votes from one candidate to other candidates.
172
     *
173
     * @param float     $surplus   The number of surplus votes to transfer
174
     * @param Candidate $candidate The candidate being elected to transfer
175
     *                             the votes from
176
     *
177
     * @return
178
     */
179
    protected function transferSurplusVotes(float $surplus, Candidate $candidate)
180
    {
181
        $totalVotes = $candidate->getVotes();
182
        $candidateId = $candidate->getId();
183
184
        $this->logger->info('Transfering surplus votes', array(
185
            'surplus' => $surplus,
186
            'candidate' => $candidate,
187
            'totalVotes' => $totalVotes,
188
        ));
189
190
        foreach ($this->ballots as $i => $ballot) {
191
            if ($ballot->getLastChoice() == $candidateId) {
192
                $this->allocateVotes($ballot, $surplus, $totalVotes);
193
            }
194
        }
195
196
        return;
197
    }
198
199
    /**
200
     * Transfer the votes from one eliminated candidate to other candidates.
201
     *
202
     * @param Candidate $candidate Candidate being eliminated to transfer
203
     *                             the votes from
204
     *
205
     * @return
206
     */
207
    protected function transferEliminatedVotes(Candidate $candidate)
208
    {
209
        $candidateId = $candidate->getId();
210
211
        $this->logger->info('Transfering votes from eliminated candidate', array(
212
            'votes' => $candidate->getVotes(),
213
            'candidate' => $candidate,
214
        ));
215
216
        foreach ($this->ballots as $i => $ballot) {
217
            if ($ballot->getLastChoice() == $candidateId) {
218
                $this->allocateVotes($ballot);
219
            }
220
        }
221
222
        return;
223
    }
224
225
    /**
226
     * Elect a candidate after they've passed the threshold.
227
     *
228
     * @param \Michaelc\Voting\STV\Candidate $candidate
229
     */
230
    protected function electCandidate(Candidate $candidate)
231
    {
232
        if ($candidate->getVotes() < $this->quota) {
233
            throw new VotingException("We shouldn't be electing someone who hasn't met the quota");
234
        }
235
236
        $this->logger->notice('Electing a candidate', array(
237
            'candidate' => $candidate,
238
        ));
239
240
        $candidate->setState(Candidate::ELECTED);
241
        ++$this->electedCandidates;
242
243
        if ($this->electedCandidates < $this->election->getWinnersCount()) {
244
            $surplus = $candidate->getVotes() - $this->quota;
245
            $this->transferSurplusVotes($surplus, $candidate);
246
        }
247
248
        return;
249
    }
250
251
    /**
252
     * Eliminate the candidate with the lowest number of votes
253
     * and reallocated their votes.
254
     *
255
     * TODO: Eliminate all lowest candidates after step one, then
256
     * randomly choose.
257
     *
258
     * @param \Michaelc\Voting\STV\Candidate[] $candidates
259
     *                                                     Array of active candidates
260
     *
261
     * @return int Number of candidates eliminated
262
     */
263
    protected function eliminateCandidates(array $candidates): int
264
    {
265
        $minimumCandidates = $this->getLowestCandidates($candidates);
266
267
        foreach ($minimumCandidates as $minimumCandidate) {
268
            $this->logger->notice('Eliminating a candidate', array(
269
                'candidate' => $candidate,
0 ignored issues
show
Bug introduced by
The variable $candidate does not exist. Did you mean $candidates?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
270
            ));
271
272
            $this->transferEliminatedVotes($minimumCandidate);
273
            $minimumCandidate->setState(Candidate::DEFEATED);
274
        }
275
276
        return count($minimumCandidates);
277
    }
278
279
    /**
280
     * Get candidates with the lowest number of votes.
281
     *
282
     * @param \Michaelc\Voting\STV\Candidate[] $candidates
283
     *                                                     Array of active candidates
284
     *
285
     * @return \Michaelc\Voting\STV\Candidate[]
286
     *                                          Candidates with lowest score
287
     */
288
    public function getLowestCandidates(array $candidates): array
289
    {
290
        $minimum = 0;
291
        $minimumCandidates = [];
292
293
        foreach ($candidates as $i => $candidate) {
294
            if ($candidate->getVotes() > $minimum) {
295
                $minimum = $candidate->getVotes();
296
                unset($minimumCandidates);
297
                $minimumCandidates[] = $candidate;
298
            } elseif ($candidate->getVotes() == $minimum) {
299
                $minimumCandidates[] = $candidate;
300
            }
301
        }
302
303
        $this->logger->info('Calculated lowest candidates', array(
304
            'minimumCandidates' => $minimumCandidates,
305
        ));
306
307
        return $minimumCandidates;
308
    }
309
310
    /**
311
     * Reject any invalid ballots.
312
     *
313
     * @return int Number of rejected ballots
314
     */
315
    protected function rejectInvalidBallots(): int
316
    {
317
        foreach ($this->ballots as $i => $ballot) {
318
            if (!$this->checkBallotValidity($ballot)) {
319
                $this->rejectedBallots[] = clone $ballot;
320
                unset($this->ballots[$i]);
321
            }
322
        }
323
324
        $count = count($this->rejectedBallots);
0 ignored issues
show
Unused Code introduced by
$count is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
325
326
        $this->logger->notice('Found $count rejected ballots ', array(
327
            'ballots' => $this->rejectedBallots,
328
        ));
329
330
        return count($this->rejectedBallots);
331
    }
332
333
    /**
334
     * Check if ballot is valid.
335
     *
336
     * @param Ballot $Ballot Ballot to test
0 ignored issues
show
Documentation introduced by
There is no parameter named $Ballot. Did you maybe mean $ballot?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function. It has, however, found a similar but not annotated parameter which might be a good fit.

Consider the following example. The parameter $ireland is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $ireland
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was changed, but the annotation was not.

Loading history...
337
     *
338
     * @return bool True if valid, false if invalid
339
     */
340
    public function checkBallotValidity(Ballot $ballot): bool
341
    {
342
        if (count($ballot->getRanking()) > $this->election->getCandidateCount()) {
343
            $this->logger->debug('Invalid ballot - number of candidates', array(
344
                'ballot' => $ballot,
345
            ));
346
347
            return false;
348
        } else {
349
            $candidateIds = $this->election->getCandidateIds();
350
351
            foreach ($ballot->getRanking() as $i => $candidate) {
352
                if (!in_array($candidate, $candidateIds)) {
353
                    $this->logger->debug('Invalid ballot - invalid candidate', array(
354
                        'ballot' => $ballot,
355
                        'candidate' => $candidate,
356
                        'candidates' => $candidateIds,
357
                    ));
358
359
                    return false;
360
                }
361
            }
362
        }
363
364
        $this->logger->debug('No invalid ballots found');
365
366
        return true;
367
    }
368
369
    /**
370
     * Get the quota to win.
371
     *
372
     * @return int
373
     */
374
    public function getQuota(): int
375
    {
376
        $quota = floor(
377
            ($this->election->getNumBallots() /
378
                ($this->election->getWinnersCount() + 1)
379
            )
380
            + 1);
381
382
        $this->logger->info("Quota set: $quota");
383
384
        return $quota;
385
    }
386
}
387