Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
81.90% |
181 / 221 |
|
73.33% |
11 / 15 |
CRAP | |
0.00% |
0 / 1 |
STVTallier | |
81.90% |
181 / 221 |
|
73.33% |
11 / 15 |
53.96 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
addVote | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
loadJSONResult | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getJSONResult | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getHtmlResult | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
2 | |||
getTextResult | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
finishTally | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
calculateRound | |
100.00% |
43 / 43 |
|
100.00% |
1 / 1 |
4 | |||
distributeVotes | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
7 | |||
calculateDroopQuota | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
calculateKeepFactors | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
calculateSurplus | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
declareWinners | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
declareEliminated | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
10 | |||
bcabs | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Talliers; |
4 | |
5 | use MediaWiki\Extension\SecurePoll\Context; |
6 | use MediaWiki\Extension\SecurePoll\Entities\Question; |
7 | use MediaWiki\Extension\SecurePoll\Talliers\STVFormatter\HtmlFormatter; |
8 | use MediaWiki\Extension\SecurePoll\Talliers\STVFormatter\WikitextFormatter; |
9 | use 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 | */ |
25 | class 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 | } |