@@ -296,13 +296,56 @@ export class VoteService {
296
296
// Calculate rank-based voting results using Instant Runoff Voting (IRV)
297
297
const irvResult = this . tallyIRV ( votes , poll . options ) ;
298
298
299
+ // Convert IRV results to the same format as other voting modes for consistent frontend rendering
300
+ let results ;
301
+ if ( irvResult . winnerIndex !== null ) {
302
+ // Create results array with winner first, then others in order
303
+ results = poll . options . map ( ( option , index ) => {
304
+ if ( index === irvResult . winnerIndex ) {
305
+ return {
306
+ option,
307
+ votes : votes . length , // All votes contributed to the winner
308
+ percentage : 100 , // Winner gets 100% in final round
309
+ isWinner : true ,
310
+ finalRound : irvResult . rounds . length
311
+ } ;
312
+ } else {
313
+ return {
314
+ option,
315
+ votes : 0 , // Eliminated candidates get 0 votes in final round
316
+ percentage : 0 ,
317
+ isWinner : false ,
318
+ finalRound : irvResult . rounds . length
319
+ } ;
320
+ }
321
+ } ) ;
322
+
323
+ // Sort: winner first, then by original option order
324
+ results . sort ( ( a , b ) => {
325
+ if ( a . isWinner && ! b . isWinner ) return - 1 ;
326
+ if ( ! a . isWinner && b . isWinner ) return 1 ;
327
+ return poll . options . indexOf ( a . option ) - poll . options . indexOf ( b . option ) ;
328
+ } ) ;
329
+ } else {
330
+ // No winner determined, show all options with 0 votes
331
+ results = poll . options . map ( ( option , index ) => ( {
332
+ option,
333
+ votes : 0 ,
334
+ percentage : 0 ,
335
+ isWinner : false ,
336
+ finalRound : irvResult . rounds . length
337
+ } ) ) ;
338
+ }
339
+
299
340
return {
300
341
pollId,
301
342
totalVotes : votes . length ,
302
343
totalEligibleVoters,
303
344
turnout : totalEligibleVoters > 0 ? ( votes . length / totalEligibleVoters ) * 100 : 0 ,
304
345
mode : "rank" ,
305
- irvResult
346
+ results,
347
+ // Keep the detailed IRV info for advanced users who need it
348
+ irvDetails : irvResult
306
349
} ;
307
350
}
308
351
@@ -725,6 +768,22 @@ export class VoteService {
725
768
rejectedReasons
726
769
} ;
727
770
}
771
+
772
+ // Check for initial tie (all candidates have same first-choice votes)
773
+ const firstRoundCounts : Record < number , number > = { } ;
774
+ validBallots . forEach ( ballot => {
775
+ const firstChoice = ballot . ranking [ 0 ] ;
776
+ if ( firstChoice !== undefined ) {
777
+ firstRoundCounts [ firstChoice ] = ( firstRoundCounts [ firstChoice ] || 0 ) + 1 ;
778
+ }
779
+ } ) ;
780
+
781
+ const firstChoiceValues = Object . values ( firstRoundCounts ) ;
782
+ const allSame = firstChoiceValues . length > 0 && firstChoiceValues . every ( v => v === firstChoiceValues [ 0 ] ) ;
783
+
784
+ if ( allSame && firstChoiceValues . length > 1 ) {
785
+ console . warn ( `⚠️ IRV Initial Tie: All candidates have the same number of first-choice votes (${ firstChoiceValues [ 0 ] } ). This will result in arbitrary elimination.` ) ;
786
+ }
728
787
729
788
// Initialize IRV process
730
789
let activeCandidates = Array . from ( { length : numCandidates } , ( _ , i ) => i ) ;
@@ -745,6 +804,11 @@ export class VoteService {
745
804
exhausted ++ ;
746
805
}
747
806
}
807
+
808
+ // Log round details for debugging
809
+ console . log ( `[IRV Round ${ round } ] Vote counts:` , counts ) ;
810
+ console . log ( `[IRV Round ${ round } ] Active candidates:` , activeCandidates . map ( i => `${ i } :${ options [ i ] } ` ) ) ;
811
+ console . log ( `[IRV Round ${ round } ] Exhausted ballots:` , exhausted ) ;
748
812
749
813
const activeVotes = validBallots . length - exhausted ;
750
814
const majorityThreshold = Math . floor ( activeVotes / 2 ) + 1 ;
@@ -803,6 +867,8 @@ export class VoteService {
803
867
// Find candidate to eliminate
804
868
const candidateToEliminate = this . findCandidateToEliminate ( counts , activeCandidates , rounds ) ;
805
869
870
+ console . log ( `[IRV Round ${ round } ] Eliminating candidate ${ candidateToEliminate } (${ options [ candidateToEliminate ] } )` ) ;
871
+
806
872
// Update active candidates
807
873
activeCandidates = activeCandidates . filter ( c => c !== candidateToEliminate ) ;
808
874
@@ -913,7 +979,9 @@ export class VoteService {
913
979
return candidatesWithMinFirstChoice [ 0 ] ;
914
980
}
915
981
916
- // Tie-breaker 2: Lowest index
982
+ // Tie-breaker 2: Lowest index (arbitrary but deterministic)
983
+ // TODO: Consider implementing coin flip or other fair tie-breaking for production
984
+ console . warn ( `⚠️ IRV Tie detected: Multiple candidates tied for elimination. Using arbitrary index-based tie-breaker.` ) ;
917
985
return Math . min ( ...candidatesWithMinFirstChoice ) ;
918
986
}
919
987
0 commit comments