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