From 811c4dc496162baebd7df7a384bbcccfc638112a Mon Sep 17 00:00:00 2001 From: Ronald Treur Date: Tue, 1 Oct 2024 16:24:41 +0200 Subject: [PATCH] feat: improve handling redistribution of votes * calculate the voteMultiplier by dividing the number of excess total votes by the original total votes. Dividing the excess total votes by the total number of backup votes (current) means all excess votes are assigned as long as one person provided a backup option (and they are all assigned there). * always lower the quota to account for votes that were lost (no backup option provided), instead of only doing this when no one provided a backup option. A case could be made to subtract the unassigned excess votes only, but that is for another PR. --- packages/stv/src/index.test.ts | 55 +++++++++++++++++++++++++++------- packages/stv/src/index.ts | 31 ++++++++----------- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/packages/stv/src/index.test.ts b/packages/stv/src/index.test.ts index 857800f..ac2a4e7 100644 --- a/packages/stv/src/index.test.ts +++ b/packages/stv/src/index.test.ts @@ -41,7 +41,7 @@ describe('calculateStvWinners', () => { ]; const { winners, tieCount } = calculateStvWinners(voteRecords, 3); - expect(winners).toEqual(expect.arrayContaining(['Eve', 'Alice', 'Dave'])); // Eve, Alice, Dave should win + expect(winners).toEqual(expect.arrayContaining(['Eve', 'Alice', 'Bob'])); // Eve, Alice, Bob should win expect(tieCount).toBe(0); // No tie expected }); @@ -167,13 +167,13 @@ describe('calculateStvWinners', () => { it('should correctly eliminate the candidate with the fewest votes', () => { const voteRecords: VoteRecord[] = [ - { voteCount: 2, voteOrder: ['Alice', 'Bob'] }, + { voteCount: 1.5, voteOrder: ['Alice', 'Bob'] }, { voteCount: 1, voteOrder: ['Charlie', 'Bob'] }, { voteCount: 1, voteOrder: ['Bob', 'Charlie'] }, ]; const { winners, tieCount } = calculateStvWinners(voteRecords, 1); - expect(winners).toEqual(['Alice']); // Alice should win after Charlie is eliminated + expect(winners).toEqual(['Bob']); // Bob should win after Charlie is eliminated expect(tieCount).toBe(0); }); @@ -212,13 +212,48 @@ describe('calculateStvWinners', () => { it('should correctly redistribute votes when a candidate is eliminated', () => { const voteRecords: VoteRecord[] = [ - { voteCount: 2, voteOrder: ['Alice', 'Charlie'] }, - { voteCount: 1, voteOrder: ['Bob', 'Alice'] }, + { voteCount: 1.2, voteOrder: ['Alice', 'Charlie'] }, + { voteCount: 1.1, voteOrder: ['Bob', 'Charlie'] }, { voteCount: 1, voteOrder: ['Charlie', 'Bob'] }, + { voteCount: 0.9, voteOrder: ['Dave', 'Bob', 'Alice'] }, + ]; + + const { winners, tieCount } = calculateStvWinners(voteRecords, 2); + expect(winners).toEqual(['Bob', 'Alice']); // Bob wins first, followed by Alice + expect(tieCount).toBe(0); + }); + + it('should correctly handle a scenario where a winner has no backup candidates to assign excess votes to', () => { + // Quota values over time: 90, 30, 16 2/3 + const voteRecords: VoteRecord[] = [ + { voteCount: 170, voteOrder: ['Alice'] }, + { voteCount: 10, voteOrder: ['Alice'] }, + { voteCount: 40, voteOrder: ['Bob'] }, + { voteCount: 25, voteOrder: ['Charlie'] }, + { voteCount: 10, voteOrder: ['Dave', 'Charlie'] }, + { voteCount: 10, voteOrder: ['Eve', 'Charlie'] }, + { voteCount: 5, voteOrder: ['Frank', 'Charlie'] }, + ]; + + const { winners, tieCount } = calculateStvWinners(voteRecords, 2); + expect(winners).toEqual(['Alice', 'Bob']); // Alice wins first, followed by Bob + expect(tieCount).toBe(0); + }); + + it('should correctly handle a scenario where a winner has a backup candidate defined for only a small portion of excess votes', () => { + // Quota values over time: 90, 33 1/3, 20 + const voteRecords: VoteRecord[] = [ + { voteCount: 170, voteOrder: ['Alice'] }, + { voteCount: 10, voteOrder: ['Alice', 'Eve'] }, + { voteCount: 40, voteOrder: ['Bob'] }, + { voteCount: 25, voteOrder: ['Charlie'] }, + { voteCount: 10, voteOrder: ['Dave', 'Charlie'] }, + { voteCount: 10, voteOrder: ['Eve', 'Charlie'] }, + { voteCount: 5, voteOrder: ['Frank', 'Charlie'] }, ]; const { winners, tieCount } = calculateStvWinners(voteRecords, 2); - expect(winners).toEqual(['Alice', 'Charlie']); // Alice wins first, followed by Charlie + expect(winners).toEqual(['Alice', 'Bob']); // Alice wins first, followed by Bob expect(tieCount).toBe(0); }); }); @@ -749,7 +784,7 @@ describe('redistributeToCandidates', () => { { totalVotes: 20, votes: [{ voteCount: 20, voteOrder: ['C', 'A'] }] }, ], ]); - redistributeToCandidates(organizedVotes, candidateSet, 1, 10, 30); + redistributeToCandidates(organizedVotes, candidateSet, 1 / 3); expect(candidateSet.get('B')?.totalVotes).toBeCloseTo(13.33, 2); // B should receive approximately 3.33 additional votes expect(candidateSet.get('C')?.totalVotes).toBeCloseTo(26.67, 2); // C should receive approximately 6.67 additional votes }); @@ -764,7 +799,7 @@ describe('redistributeToCandidates', () => { { totalVotes: 20, votes: [{ voteCount: 20, voteOrder: ['C', 'A'] }] }, ], ]); - redistributeToCandidates(organizedVotes, candidateSet, 1, 10, 30); + redistributeToCandidates(organizedVotes, candidateSet, 1 / 3); expect(candidateSet.has('C')).toBe(true); expect(candidateSet.get('C')?.totalVotes).toBeCloseTo(6.67, 2); // C should receive approximately 6.67 additional votes }); @@ -779,7 +814,7 @@ describe('redistributeToCandidates', () => { { totalVotes: 10, votes: [{ voteCount: 10, voteOrder: ['B', 'C'] }] }, ], ]); - redistributeToCandidates(organizedVotes, candidateSet, 0.5, 10, 20); + redistributeToCandidates(organizedVotes, candidateSet, 0.5); expect(candidateSet.get('B')?.totalVotes).toBe(15); // B should receive 5 additional votes }); @@ -788,7 +823,7 @@ describe('redistributeToCandidates', () => { ['B', { totalVotes: 10, votes: [] }], ]); const organizedVotes = new Map(); - redistributeToCandidates(organizedVotes, candidateSet, 1, 10, 20); + redistributeToCandidates(organizedVotes, candidateSet, 0.5); expect(candidateSet.get('B')?.totalVotes).toBe(10); // No votes redistributed, B should remain the same }); }); diff --git a/packages/stv/src/index.ts b/packages/stv/src/index.ts index 2e7ddf9..e3c40b8 100644 --- a/packages/stv/src/index.ts +++ b/packages/stv/src/index.ts @@ -236,28 +236,26 @@ export function redistributeExcessVotes( for (const { totalVotes } of organizedVotes.values()) { totalVotesForProportion += totalVotes; } + const unassignedVotes = candidateData.totalVotes - totalVotesForProportion; - if (totalVotesForProportion === 0) { + if (unassignedVotes > 0) { // console.log( - // `No votes to redistribute, reducing quota by ${candidateData.totalVotes} votes`, + // `Reducing quota by ${candidateData.totalVotes - totalVotesForProportion} votes to account for missing backup candidates`, // ); - newQuotaTotalVotes.value -= candidateData.totalVotes; - return; + newQuotaTotalVotes.value -= unassignedVotes; + } + + if (candidateData.totalVotes === quota || totalVotesForProportion === 0) { + return; // No excess votes to redistribute, or no backup votes assigned } // Calculate the vote multiplier based on the excess votes - const votesToRedistribute = candidateData.totalVotes - quota; - const voteMultiplier = votesToRedistribute / totalVotesForProportion; - // console.log(`Redistributing ${votesToRedistribute} votes`); + const excessVotes = candidateData.totalVotes - quota; + const voteMultiplier = excessVotes / candidateData.totalVotes; + // console.log(`Redistributing ${excessVotes} excess votes`); // Redistribute the votes proportionally - redistributeToCandidates( - organizedVotes, - candidateSet, - voteMultiplier, - votesToRedistribute, - totalVotesForProportion, - ); + redistributeToCandidates(organizedVotes, candidateSet, voteMultiplier); } // Organize votes by the next candidate in the preference list @@ -290,15 +288,12 @@ export function redistributeToCandidates( organizedVotes: Map, candidateSet: Map, voteMultiplier: number, - votesToRedistribute: number, - totalVotesForProportion: number, ): void { for (const [candidate, vote] of organizedVotes) { vote.votes = combineVoteRecords(vote.votes); vote.votes.forEach((v) => (v.voteCount *= voteMultiplier)); - const votesToRedistributeForCandidate = - votesToRedistribute * (vote.totalVotes / totalVotesForProportion); + const votesToRedistributeForCandidate = vote.totalVotes * voteMultiplier; // console.log( // `Redistributing ${votesToRedistributeForCandidate} votes to ${candidate}`, // );