Skip to content

Commit e07e51e

Browse files
committed
fix: evoting IRV logic
1 parent 23eade2 commit e07e51e

File tree

3 files changed

+95
-9
lines changed

3 files changed

+95
-9
lines changed

platforms/eVoting/src/app/(app)/[id]/page.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -477,12 +477,16 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {
477477
isWinner = result.totalPoints === Math.max(...resultsData.results.map(r => r.totalPoints));
478478
percentage = resultsData.totalVotes > 0 ? (result.totalPoints / resultsData.results.reduce((sum, r) => sum + r.totalPoints, 0)) * 100 : 0;
479479
} else if (resultsData.mode === "rank") {
480-
// Rank-based voting: show percentage only
481-
displayValue = `${result.totalScore} points`;
482-
isWinner = result.totalScore === Math.max(...resultsData.results.map(r => r.totalScore));
483-
percentage = resultsData.totalVotes > 0 ? (result.totalScore / resultsData.results.reduce((sum, r) => sum + r.totalScore, 0)) * 100 : 0;
484-
// For rank voting, just show the percentage in the display
485-
displayValue = `${percentage.toFixed(1)}%`;
480+
// Rank-based voting: show winner status instead of misleading vote counts
481+
displayValue = result.isWinner ? "🏆 Winner" : "Eliminated";
482+
isWinner = result.isWinner || false; // Use the isWinner flag from backend
483+
percentage = result.percentage || 0; // Use the percentage from backend
484+
485+
// Check if there might have been a tie situation
486+
if (resultsData.irvDetails && resultsData.irvDetails.rounds.length > 1) {
487+
// If multiple rounds, there might have been ties
488+
console.log(`[IRV Debug] Poll had ${resultsData.irvDetails.rounds.length} rounds, check console for tie warnings`);
489+
}
486490
} else {
487491
// Normal voting: show votes and percentage
488492
displayValue = `${result.votes} votes`;
@@ -511,7 +515,7 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {
511515
)}
512516
</div>
513517
<span className="text-sm text-gray-600">
514-
{displayValue} ({percentage.toFixed(1)}%)
518+
{resultsData.mode === "rank" ? displayValue : `${displayValue} (${percentage.toFixed(1)}%)`}
515519
</span>
516520
</div>
517521
<div className="w-full bg-gray-200 rounded-full h-2">

platforms/eVoting/src/lib/pollApi.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,25 @@ export interface PollResults {
6363
totalVotes: number;
6464
totalEligibleVoters?: number;
6565
turnout?: number;
66+
mode?: "normal" | "point" | "rank";
6667
results: {
6768
option: string;
6869
votes: number;
6970
percentage: number;
71+
// Additional fields for different voting modes
72+
totalPoints?: number;
73+
averagePoints?: number;
74+
isWinner?: boolean;
75+
finalRound?: number;
7076
}[];
77+
// Detailed IRV info for rank mode
78+
irvDetails?: {
79+
winnerIndex: number | null;
80+
winnerOption?: string;
81+
rounds: any[];
82+
rejectedBallots: number;
83+
rejectedReasons: any[];
84+
};
7185
}
7286

7387
export interface SigningSession {

platforms/evoting-api/src/services/VoteService.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,13 +296,56 @@ export class VoteService {
296296
// Calculate rank-based voting results using Instant Runoff Voting (IRV)
297297
const irvResult = this.tallyIRV(votes, poll.options);
298298

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+
299340
return {
300341
pollId,
301342
totalVotes: votes.length,
302343
totalEligibleVoters,
303344
turnout: totalEligibleVoters > 0 ? (votes.length / totalEligibleVoters) * 100 : 0,
304345
mode: "rank",
305-
irvResult
346+
results,
347+
// Keep the detailed IRV info for advanced users who need it
348+
irvDetails: irvResult
306349
};
307350
}
308351

@@ -725,6 +768,22 @@ export class VoteService {
725768
rejectedReasons
726769
};
727770
}
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+
}
728787

729788
// Initialize IRV process
730789
let activeCandidates = Array.from({ length: numCandidates }, (_, i) => i);
@@ -745,6 +804,11 @@ export class VoteService {
745804
exhausted++;
746805
}
747806
}
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);
748812

749813
const activeVotes = validBallots.length - exhausted;
750814
const majorityThreshold = Math.floor(activeVotes / 2) + 1;
@@ -803,6 +867,8 @@ export class VoteService {
803867
// Find candidate to eliminate
804868
const candidateToEliminate = this.findCandidateToEliminate(counts, activeCandidates, rounds);
805869

870+
console.log(`[IRV Round ${round}] Eliminating candidate ${candidateToEliminate} (${options[candidateToEliminate]})`);
871+
806872
// Update active candidates
807873
activeCandidates = activeCandidates.filter(c => c !== candidateToEliminate);
808874

@@ -913,7 +979,9 @@ export class VoteService {
913979
return candidatesWithMinFirstChoice[0];
914980
}
915981

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.`);
917985
return Math.min(...candidatesWithMinFirstChoice);
918986
}
919987

0 commit comments

Comments
 (0)