Completed
Push — master ( efd3e9...5888f9 )
by Michael
03:40
created

VoteHandler::getLowestCandidates()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
dl 0
loc 25
ccs 0
cts 13
cp 0
rs 8.5806
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
        {
82
            if (!$this->checkCandidates($candidates))
83
            {
84
                $this->eliminateCandidates($candidates);
85
            }
86
        }
87
88
        $this->logger->notice('Election complete');
89
90
        return $this->election->getElectedCandidates();
91
    }
92
93
    /**
94
     * Perform the initial vote allocation
95
     *
96
     * @return
97
     */
98
    protected function firstStep()
99
    {
100
        $this->logger->info('Beginning the first step');
101
102
        foreach ($this->ballots as $i => $ballot)
103
        {
104
            $this->logger->debug("Processing ballot $i in stage 1",
105
                ['ballot' => $ballot,]
106
            );
107
108
            $this->allocateVotes($ballot);
109
        }
110
111
        $this->logger->notice('First step complete',
112
            ['candidatesStatus' => $this->election->getCandidatesStatus()]
113
        );
114
115
        return;
116
    }
117
118
    /**
119
     * Check if any candidates have reached the quota and can be elected
120
     *
121
     * @param  array  $candidates 	Array of active candidates to check
122
     * @return bool 				Whether any candidates were changed to elected
123
     */
124
    protected function checkCandidates(array $candidates): bool
125
    {
126
        $this->logger->info('Checking if candidates have passed quota');
127
128
        foreach ($candidates as $i => $candidate)
129
        {
130
            $this->logger->debug('Checking candidate', ['candidate' => $candidate]);
131
132
            if ($candidate->getVotes() >= $this->quota)
133
            {
134
                $this->electCandidate($candidate);
135
                $elected = true;
136
            }
137
        }
138
139
        $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...
140
141
        return ($elected ?? false);
142
    }
143
144
    /**
145
     * Allocate the next votes from a Ballot
146
     *
147
     * @param Ballot $ballot 		The ballot to allocate the votes from
148
     * @param float  $multiplier 	Number to multiply the weight by (surplus)
149
     * @param float  $divisor 		The divisor of the weight (Total number of
150
     *                          	candidate votes)
151
     * @return Ballot 	The same ballot passed in modified
152
     */
153
    protected function allocateVotes(Ballot $ballot, float $multiplier = 1.0, float $divisor = 1.0): Ballot
154
    {
155
        $weight = $ballot->setWeight(($ballot->getWeight() * $multiplier) / $divisor);
156
        $candidate = $ballot->getNextChoice();
157
158
        $this->logger->debug('Allocating votes of ballot', array(
159
            'ballot' => $ballot,
160
            'weight' => $weight,
161
            'candidate' => $candidate
162
        ));
163
164
        if ($candidate !== null)
165
        {
166
            $this->election->getCandidate($candidate)->addVotes($weight);
167
            $ballot->incrementLevelUsed();
168
            $this->logger->debug('Vote added to candidate');
169
        }
170
171
        return $ballot;
172
    }
173
174
    /**
175
     * Transfer the votes from one candidate to other candidates
176
     *
177
     * @param  float     $surplus   The number of surplus votes to transfer
178
     * @param  Candidate $candidate The candidate being elected to transfer
179
     *                              the votes from
180
     * @return
181
     */
182
    protected function transferSurplusVotes(float $surplus, Candidate $candidate)
183
    {
184
    	$totalVotes = $candidate->getVotes();
185
    	$candidateId = $candidate->getId();
186
187
        $this->logger->info('Transfering surplus votes', array(
188
            'surplus' => $surplus,
189
            'candidate' => $candidate,
190
            'totalVotes' => $totalVotes
191
        ));
192
193
    	foreach ($this->ballots as $i => $ballot)
194
    	{
195
        	if ($ballot->getLastChoice() == $candidateId)
196
        	{
197
		        $this->allocateVotes($ballot, $surplus, $totalVotes);
198
        	}
199
    	}
200
201
        return;
202
    }
203
204
    /**
205
     * Transfer the votes from one eliminated candidate to other candidates
206
     *
207
     * @param  Candidate $candidate  Candidate being eliminated to transfer
208
     *                               the votes from
209
     * @return
210
     */
211
    protected function transferEliminatedVotes(Candidate $candidate)
212
    {
213
    	$candidateId = $candidate->getId();
214
215
        $this->logger->info('Transfering votes from eliminated candidate', array(
216
            'votes' => $candidate->getVotes(),
217
            'candidate' => $candidate
218
        ));
219
220
    	foreach ($this->ballots as $i => $ballot)
221
        {
222
        	if ($ballot->getLastChoice() == $candidateId)
223
        	{
224
		        $this->allocateVotes($ballot);
225
        	}
226
        }
227
228
        return;
229
    }
230
231
    /**
232
     * Elect a candidate after they've passed the threshold
233
     *
234
     * @param  \Michaelc\Voting\STV\Candidate $candidate
235
     * @return null
236
     */
237
    protected function electCandidate(Candidate $candidate)
238
    {
239
        if ($candidate->getVotes() < $this->quota)
240
        {
241
            throw new VotingException("We shouldn't be electing someone who hasn't met the quota");
242
        }
243
244
        $this->logger->notice('Electing a candidate', array(
245
            'candidate' => $candidate
246
        ));
247
248
        $candidate->setState(Candidate::ELECTED);
249
        $this->electedCandidates++;
250
251
        if ($this->electedCandidates < $this->election->getWinnersCount())
252
        {
253
            $surplus = $candidate->getVotes() - $this->quota;
254
            $this->transferSurplusVotes($surplus, $candidate);
255
        }
256
257
        return;
258
    }
259
260
    /**
261
     * Eliminate the candidate with the lowest number of votes
262
     * and reallocated their votes
263
     *
264
     * TODO: Eliminate all lowest candidates after step one, then
265
     * randomly choose.
266
     *
267
     * @param  \Michaelc\Voting\STV\Candidate[] $candidates
268
     *                              Array of active candidates
269
     * @return int 					Number of candidates eliminated
270
     */
271
    protected function eliminateCandidates(array $candidates): int
272
    {
273
        $minimumCandidates = $this->getLowestCandidates($candidates);
274
275
        foreach ($minimumCandidates as $minimumCandidate)
276
        {
277
            $this->logger->notice('Eliminating a candidate', array(
278
                '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...
279
            ));
280
281
            $this->transferEliminatedVotes($minimumCandidate);
282
            $minimumCandidate->setState(Candidate::DEFEATED);
283
        }
284
285
        return count($minimumCandidates);
286
    }
287
288
    /**
289
     * Get candidates with the lowest number of votes
290
     *
291
     * @param  \Michaelc\Voting\STV\Candidate[] $candidates
292
     *                             Array of active candidates
293
     * @return \Michaelc\Voting\STV\Candidate[]
294
     *                             Candidates with lowest score
295
     */
296
    public function getLowestCandidates(array $candidates): array
297
    {
298
        $minimum = 0;
299
        $minimumCandidates = [];
300
301
        foreach ($candidates as $i => $candidate)
302
        {
303
            if ($candidate->getVotes() > $minimum)
304
            {
305
                $minimum = $candidate->getVotes();
306
                unset($minimumCandidates);
307
                $minimumCandidates[] = $candidate;
308
            }
309
            elseif ($candidate->getVotes() == $minimum)
310
            {
311
                $minimumCandidates[] = $candidate;
312
            }
313
        }
314
315
        $this->logger->info('Calculated lowest candidates', array(
316
            'minimumCandidates' => $minimumCandidates
317
        ));
318
319
        return $minimumCandidates;
320
    }
321
322
    /**
323
     * Reject any invalid ballots
324
     *
325
     * @return int    Number of rejected ballots
326
     */
327
    protected function rejectInvalidBallots(): int
328
    {
329
        foreach ($this->ballots as $i => $ballot)
330
        {
331
            if (!$this->checkBallotValidity($ballot))
332
            {
333
                $this->rejectedBallots[] = clone $ballot;
334
                unset($this->ballots[$i]);
335
            }
336
        }
337
338
        $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...
339
340
        $this->logger->notice('Found $count rejected ballots ', array(
341
            'ballots' => $this->rejectedBallots,
342
        ));
343
344
        return count($this->rejectedBallots);
345
    }
346
347
    /**
348
     * Check if ballot is valid
349
     *
350
     * @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...
351
     * @return bool             True if valid, false if invalid
352
     */
353
    public function checkBallotValidity(Ballot $ballot): bool
354
    {
355
        if (count($ballot->getRanking()) > $this->election->getCandidateCount())
356
        {
357
            $this->logger->debug('Invalid ballot - number of candidates', array(
358
                'ballot' => $ballot
359
            ));
360
361
            return false;
362
        }
363
        else
364
        {
365
            $candidateIds = $this->election->getCandidateIds();
366
367
            foreach ($ballot->getRanking() as $i => $candidate)
368
            {
369
                if (!in_array($candidate, $candidateIds))
370
                {
371
                    $this->logger->debug('Invalid ballot - invalid candidate', array(
372
                        'ballot' => $ballot,
373
                        'candidate' => $candidate,
374
                        'candidates' => $candidateIds
375
                    ));
376
377
                    return false;
378
                }
379
            }
380
        }
381
382
        $this->logger->debug('No invalid ballots found');
383
384
        return true;
385
    }
386
387
    /**
388
     * Get the quota to win
389
     *
390
     * @return int
391
     */
392
    public function getQuota(): int
393
    {
394
        $quota = floor(
395
            ($this->election->getNumBallots() /
396
                ($this->election->getWinnersCount() + 1)
397
            )
398
            + 1);
399
400
        $this->logger->info("Quota set: $quota");
401
402
        return $quota;
403
    }
404
}
405