Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.90% covered (warning)
81.90%
181 / 221
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
STVTallier
81.90% covered (warning)
81.90%
181 / 221
73.33% covered (warning)
73.33%
11 / 15
53.96
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addVote
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 loadJSONResult
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getJSONResult
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getHtmlResult
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 getTextResult
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 finishTally
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 calculateRound
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
4
 distributeVotes
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
7
 calculateDroopQuota
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 calculateKeepFactors
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 calculateSurplus
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 declareWinners
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 declareEliminated
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
10
 bcabs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Talliers;
4
5use MediaWiki\Extension\SecurePoll\Context;
6use MediaWiki\Extension\SecurePoll\Entities\Question;
7use MediaWiki\Extension\SecurePoll\Talliers\STVFormatter\HtmlFormatter;
8use MediaWiki\Extension\SecurePoll\Talliers\STVFormatter\WikitextFormatter;
9use OOUI\StackLayout;
10
11/**
12 * A STVTallier class,
13 * This implementation is based mainly on
14 * https://web.archive.org/web/20210225045400/https://prfound.org/resources/reference/reference-meek-rule/
15 * and
16 * Hill's programmatic implementation - We follow NZ implementation found in this paper
17 * https://web.archive.org/web/20210723092928/https://ucid.es/system/files/meekm_0.pdf
18 * Major differences being
19 * 1. We do not use pseudo random elimination of candidates.
20 *
21 * Other sources
22 * https://web.archive.org/web/20210723092923/https://rangevoting.org/MeekSTV2.html
23 * https://web.archive.org/web/20200712142326/http://www.votingmatters.org.uk/ISSUE1/P1.HTM
24 */
25class STVTallier extends Tallier {
26
27    /**
28     * An array of vote combinations keyed by underscore-delimited
29     * and ranked options. Each vote has a rank array (which allows
30     * index-1 access to each ranked option) and a count
31     * @var array
32     */
33    public $rankedVotes = [];
34
35    /**
36     * An array of results, gets populated per round
37     * holds current round, both elected and eliminated candidate and the total votes per round.
38     * @var array[]
39     */
40    public $resultsLog = [
41        'elected' => [],
42        'eliminated' => [],
43        'rounds' => []
44    ];
45
46    /**
47     * The precision for all fixed-point arithmetic done while tallying. All
48     * arithmetic done in this class must use bc math function paired with this
49     * precision value (T361077).
50     */
51    private const PRECISION = 10;
52
53    /**
54     * Number of seats to be filled.
55     * @var int
56     */
57    private $seats;
58
59    /**
60     * An array of all candidates in the election.
61     * @var array<int, string>
62     */
63    private $candidates = [];
64
65    /**
66     * @param Context $context
67     * @param ElectionTallier $electionTallier
68     * @param Question $question
69     */
70    public function __construct( $context, $electionTallier, $question ) {
71        parent::__construct( $context, $electionTallier, $question );
72        foreach ( $question->getOptions() as $option ) {
73            $this->candidates[ $option->getId() ] = $option->getMessage( 'text' );
74        }
75        $this->seats = $question->getProperty( 'min-seats' );
76    }
77
78    /**
79     * @inheritDoc
80     */
81    public function addVote( $scores ) {
82        $id = implode( '_', $scores );
83        $rank = [];
84        foreach ( $scores as $ranked => $optionId ) {
85            $rank[ $ranked + 1 ] = $optionId;
86        }
87
88        if ( !isset( $this->rankedVotes[ $id ] ) ) {
89            $this->rankedVotes[ $id ] = [
90                'count' => 1,
91                'rank' => $rank,
92            ];
93        } else {
94            $this->rankedVotes[ $id ][ 'count' ] += 1;
95        }
96
97        return true;
98    }
99
100    /** @inheritDoc */
101    public function loadJSONResult( array $data ) {
102        $this->resultsLog = $data['resultsLog'];
103        $this->rankedVotes = $data['rankedVotes'];
104    }
105
106    /** @inheritDoc */
107    public function getJSONResult() {
108        return [
109            'resultsLog' => $this->resultsLog,
110            'rankedVotes' => $this->rankedVotes,
111        ];
112    }
113
114    /** @inheritDoc */
115    public function getHtmlResult() {
116        $htmlFormatter = new HtmlFormatter(
117            $this->resultsLog,
118            $this->rankedVotes,
119            $this->seats,
120            $this->candidates
121        );
122        $htmlPreamble = $htmlFormatter->formatPreamble(
123            $this->resultsLog['elected'],
124            $this->resultsLog['eliminated']
125        );
126        $htmlRounds = $htmlFormatter->formatRoundsPreamble();
127        $htmlRounds->appendContent( $htmlFormatter->formatRound() );
128        return new StackLayout( [
129            'items' => [
130                $htmlPreamble,
131                $htmlRounds,
132            ],
133            'continuous' => true,
134            'expanded' => false,
135            'classes' => [ 'election-tally-results--stv' ]
136        ] );
137    }
138
139    /** @inheritDoc */
140    public function getTextResult() {
141        $wikitextFormatter = new WikitextFormatter(
142            $this->resultsLog,
143            $this->rankedVotes,
144            $this->seats,
145            $this->candidates
146        );
147        $wikitext = $wikitextFormatter->formatPreamble(
148            $this->resultsLog['elected'],
149            $this->resultsLog['eliminated']
150        );
151        $wikitext .= $wikitextFormatter->formatRoundsPreamble();
152        $wikitext .= $wikitextFormatter->formatRound();
153        return $wikitext;
154    }
155
156    /**
157     * @inheritDoc
158     */
159    public function finishTally() {
160        // Instantiate first round for the iterator
161        $keepFactors = array_fill_keys( array_keys( $this->candidates ), '1.0' );
162        $voteCount = $this->distributeVotes( $this->rankedVotes, $keepFactors );
163        $quota = $this->calculateDroopQuota( $voteCount['totalVotes'], (string)$this->seats );
164        $round = [
165            'round' => 1,
166            'surplus' => '0',
167            'rankings' => $voteCount['ranking'],
168            'totalVotes' => $voteCount['totalVotes'],
169            'keepFactors' => $keepFactors,
170            'quota' => $quota,
171            'elected' => [],
172            'eliminated' => []
173        ];
174        $this->resultsLog['rounds'][] = $round;
175
176        // Iterate
177        $this->calculateRound( $round );
178    }
179
180    /**
181     * @param array $prevRound
182     */
183    private function calculateRound( $prevRound ) {
184        if ( count( $this->resultsLog['elected'] ) >= $this->seats ) {
185            return;
186        }
187
188        // Advance the round
189        $round = [
190            'round' => $prevRound['round'] + 1,
191            'elected' => [],
192            'eliminated' => [],
193            'surplus' => '0.0',
194        ];
195
196        $keepFactors =
197            $round['keepFactors'] =
198            $this->calculateKeepFactors(
199                $this->candidates,
200                $prevRound['quota'],
201                $prevRound['keepFactors'],
202                $this->resultsLog['elected'],
203                $this->resultsLog['eliminated'],
204                $prevRound['rankings']
205            );
206
207        $voteCount = $this->distributeVotes( $this->rankedVotes, $keepFactors, $prevRound['rankings'] );
208        $round['quota'] = $this->calculateDroopQuota( $voteCount['totalVotes'], (string)$this->seats );
209        $round['rankings'] = $voteCount['ranking'];
210        $round['totalVotes'] = $voteCount['totalVotes'];
211
212        // Check for unique winners
213        $allWinners = $this->declareWinners( $round['rankings'], $round['quota'] );
214        $roundWinners = $round['elected'] = array_diff( $allWinners, $this->resultsLog['elected'] );
215
216        // If no winners, check for eliminated (no distribution happens here)
217        $round['surplus'] = $this->calculateSurplus( $round['rankings'], $allWinners, $round['quota'] );
218        $allEliminated = $this->declareEliminated(
219            $round['rankings'],
220            $round['surplus'],
221            $this->resultsLog['eliminated'],
222            $this->resultsLog['elected'],
223            $prevRound['surplus']
224        );
225        $roundEliminated = $round['eliminated'] = !$roundWinners ?
226            array_diff( $allEliminated, $this->resultsLog['eliminated'] ) : [];
227
228        $this->resultsLog['rounds'][] = $round;
229        $this->resultsLog['elected'] = array_merge( $this->resultsLog['elected'], $roundWinners );
230        $this->resultsLog['eliminated'] = array_merge( $this->resultsLog['eliminated'], $roundEliminated );
231
232        // Recurse?
233        $hopefuls = array_diff(
234            array_keys( $this->candidates ),
235            array_merge( $this->resultsLog['elected'], $this->resultsLog['eliminated'] )
236        );
237        if ( $hopefuls ) {
238            $this->calculateRound( $round );
239        }
240    }
241
242    /**
243     * Distribute votes per ballot
244     * For each ballot: set ballot weight($weight) to 1,
245     * and then for each candidate,
246     * in order of rank on that ballot:
247     * add $weight multiplied by the keep factor ($voteValue)  of the candidate ($keepFactors[$candidate])
248     * to that candidate’s vote $voteTotals[$candidate]['total'], and reduce $weight by $voteValue,
249     * until no further candidate remains on the ballot.
250     * @param array $ballots
251     * @param array $keepFactors
252     * @param null $prevDistribution
253     * @return array
254     */
255    private function distributeVotes( $ballots, $keepFactors, $prevDistribution = null ): array {
256        $voteTotals = [];
257        $totalVotes = '0.0';
258        foreach ( $keepFactors as $candidate => $kf ) {
259            $voteTotals[$candidate] = [
260                'votes' => '0',
261                'earned' => '0',
262                'total' => '0',
263            ];
264        }
265
266        // Apply previous round's votes to the count to get "earned" votes
267        if ( $prevDistribution ) {
268            foreach ( $prevDistribution as $candidate => $candidateVotes ) {
269                $voteTotals[$candidate]['votes'] = $candidateVotes['total'];
270
271                // earned = earned - total
272                $voteTotals[$candidate]['earned'] = bcsub(
273                    $voteTotals[$candidate]['earned'],
274                    $candidateVotes['total'],
275                    self::PRECISION
276                );
277            }
278        }
279
280        foreach ( $ballots as $ballot ) {
281            $weight = '1.0';
282
283            foreach ( $ballot['rank'] as $candidate ) {
284                // weight > 0
285                if ( bccomp( $weight, '0.0', self::PRECISION ) === 1 ) {
286                    // voteValue = weight * keepFactor
287                    $voteValue = bcmul( $weight, $keepFactors[$candidate], self::PRECISION );
288
289                    // earned = earned + (voteValue * ballotCount)
290                    $voteTotals[$candidate]['earned'] = bcadd(
291                        $voteTotals[$candidate]['earned'],
292                        bcmul( $voteValue, (string)$ballot['count'], self::PRECISION ),
293                        self::PRECISION
294                    );
295
296                    // total = total + (voteValue * ballotCount)
297                    $voteTotals[$candidate]['total'] = bcadd(
298                        $voteTotals[$candidate]['total'],
299                        bcmul( $voteValue, (string)$ballot['count'], self::PRECISION ),
300                        self::PRECISION
301                    );
302
303                    // weight = weight - voteValue
304                    $weight = bcsub( $weight, $voteValue, self::PRECISION );
305
306                    // totalVotes = totalVotes + (voteValue + ballotCount)
307                    $totalVotes = bcadd(
308                        $totalVotes,
309                        bcmul( $voteValue, $ballot['count'], self::PRECISION ),
310                        self::PRECISION
311                    );
312                } else {
313                    break;
314                }
315            }
316        }
317
318        return [
319            'ranking' => $voteTotals,
320            'totalVotes' => $totalVotes,
321        ];
322    }
323
324    /**
325     * @param string $votes
326     * @param string $seats
327     * @return string
328     */
329    private function calculateDroopQuota( $votes, $seats ): string {
330        // droopQuota = (votes / (seats + 1)) + 0.000001
331        return bcadd(
332            bcdiv( $votes, bcadd( $seats, '1.0', self::PRECISION ), self::PRECISION ),
333            '0.000001',
334            self::PRECISION
335        );
336    }
337
338    /**
339     * Calculates keep factors of all elected candidate at every round
340     * calculated as: current keepfactor multiplied by current quota divided by candidates current vote($voteTotals)
341     * @param array $candidates
342     * @param string $quota
343     * @param array $currentFactors
344     * @param array $winners
345     * @param array $eliminated
346     * @param array $voteTotals
347     * @return array
348     */
349    private function calculateKeepFactors( $candidates, $quota, $currentFactors, $winners, $eliminated, $voteTotals ) {
350        $keepFactors = [];
351
352        foreach ( $candidates as $candidateId => $candidateName ) {
353            $keepFactors[$candidateId] = in_array( $candidateId, $eliminated ) ? '0.0' : '1.0';
354        }
355
356        foreach ( $winners as $winner ) {
357            $voteCount = $voteTotals[$winner]['total'];
358            $prevKeepFactor = $currentFactors[$winner];
359
360            // keepFactor = (prevKeepFactor * quota) / voteCount
361            $keepFactors[$winner] = bcdiv(
362                bcmul( $prevKeepFactor, $quota, self::PRECISION ),
363                $voteCount,
364                self::PRECISION
365            );
366        }
367
368        return $keepFactors;
369    }
370
371    /**
372     * @param array $ranking
373     * @param array $winners
374     * @param string $quota
375     * @return string
376     */
377    private function calculateSurplus( $ranking, $winners, $quota ) {
378        $voteSurplus = '0.0';
379        foreach ( $winners as $winner ) {
380            $voteTotal = $ranking[$winner]['total'];
381
382            // voteSurplus = (voteSurplus + voteTotal) - quota
383            $voteSurplus = bcsub( bcadd( $voteSurplus, $voteTotal, self::PRECISION ), $quota, self::PRECISION );
384        }
385        return $voteSurplus;
386    }
387
388    /**
389     * @param array $ranking
390     * @param string $quota
391     * @return array
392     */
393    private function declareWinners( $ranking, $quota ) {
394        $winners = [];
395        foreach ( $ranking as $option => $votes ) {
396            // voteTotal >= quota
397            if ( bccomp( $votes['total'], $quota, self::PRECISION ) >= 0 ) {
398                   $winners[] = $option;
399            }
400        }
401        return $winners;
402    }
403
404    /**
405     * @param array $ranking
406     * @param string $surplus
407     * @param array $eliminated
408     * @param array $elected
409     * @param string $prevSurplus
410     * @return array
411     */
412    private function declareEliminated( $ranking, $surplus, $eliminated, $elected, $prevSurplus ) {
413        // Make sure it's ordered by vote totals
414        uksort(
415            $ranking,
416            static function ( $aKey, $bKey ) use ( $ranking ) {
417                $a = $ranking[$aKey];
418                $b = $ranking[$bKey];
419                // $a['total'] === $b['total']
420                if ( bccomp( $a['total'], $b['total'], self::PRECISION ) === 0 ) {
421                    // ascending sort ($aKey <=> $bKey)
422                    return bccomp( $aKey, $bKey, self::PRECISION );
423                }
424                // descending sort ($b['total'] <=> $a['total'])
425                return bccomp( $b['total'], $a['total'], self::PRECISION );
426            }
427        );
428
429        // Remove anyone who was already eliminated or elected
430        $ranking = array_filter( $ranking, static function ( $key ) use ( $eliminated ) {
431            return !in_array( $key, $eliminated );
432        }, ARRAY_FILTER_USE_KEY );
433
434        // Manually implement array_unique with higher precision than the default function
435        $voteTotals = [];
436        foreach ( $ranking as $candidate ) {
437            // Since it's already been ordered by vote totals, we only need to check the
438            // difference against the last recorded unique value
439            if ( count( $voteTotals ) === 0 ) {
440                // First value is always unique
441                $voteTotals[] = $candidate['total'];
442            } elseif ( isset( $voteTotals[ count( $voteTotals ) - 1 ] ) ) {
443                if (
444                    // abs(candidateTotal - voteTotal[lastSeat]) > 0
445                    bccomp( $this->bcabs( bcsub(
446                        $candidate['total'],
447                        $voteTotals[ count( $voteTotals ) - 1 ],
448                        self::PRECISION
449                    ) ), '0.0', self::PRECISION ) === 1
450                ) {
451                    $voteTotals[] = $candidate['total'];
452                }
453            }
454        }
455
456        // If everyone left is tied and no one made quota
457        // Everyone left gets eliminated
458        if ( count( $voteTotals ) === 1 ) {
459            return array_keys( $ranking );
460        }
461
462        // Get the lowest ranking candidates
463        [ $secondLowest, $lowest ] = array_slice( $voteTotals, -2 );
464
465        // Check if we can eliminate the lowest candidate
466        // using Hill's surplus-based short circuit elimination
467        $bcabs = [ $this, 'bcabs' ];
468        $lastPlace = array_keys( array_filter( $ranking, static function ( $ranked ) use ( $bcabs, $lowest, $elected ) {
469            // abs(rankedTotal - lowest) <= 0
470            return bccomp( $bcabs( bcsub( $ranked['total'], $lowest, self::PRECISION ) ), '0.0', self::PRECISION ) <= 0
471                && !in_array( key( $ranked ), $elected );
472        } ) );
473
474        if (
475            // (lowest * lastPlaceCount) + surplus < secondLowest
476            bccomp( bcadd( bcmul( $lowest, (string)count( $lastPlace ) ), $surplus ), $secondLowest ) === -1 ||
477            // abs(surplus - prevSurplus) <= 0
478            bccomp( $this->bcabs( bcsub( $surplus, $prevSurplus, self::PRECISION ) ), '0.0', self::PRECISION ) <= 0
479        ) {
480            return $lastPlace;
481        }
482
483        return [];
484    }
485
486    /**
487     * A custom fixed-point abs function since PHP doesn't provide one.
488     *
489     * @param string $number
490     * @return string
491     */
492    private function bcabs( $number ) {
493        // number < 0
494        if ( bccomp( $number, '0.0', self::PRECISION ) === -1 ) {
495            // number * -1
496            return bcmul( $number, '-1.0', self::PRECISION );
497        }
498        return $number;
499    }
500}