Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
78.19% |
147 / 188 |
|
64.29% |
9 / 14 |
CRAP | |
0.00% |
0 / 1 |
STVTallier | |
78.19% |
147 / 188 |
|
64.29% |
9 / 14 |
58.44 | |
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 | |
97.67% |
42 / 43 |
|
0.00% |
0 / 1 |
4 | |||
distributeVotes | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
7 | |||
calculateDroopQuota | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
calculateKeepFactors | |
100.00% |
8 / 8 |
|
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% |
30 / 30 |
|
100.00% |
1 / 1 |
10 |
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 | |
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 | */ |
24 | class 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 | } |