Skip to content

Commit

Permalink
Merge pull request #670 from mikefranze/multiwinner_updates
Browse files Browse the repository at this point in the history
Adding block STV and IRV
  • Loading branch information
mikefranze authored Sep 20, 2024
2 parents b453b61 + baa043a commit 0eebc22
Show file tree
Hide file tree
Showing 8 changed files with 264 additions and 73 deletions.
26 changes: 26 additions & 0 deletions packages/backend/src/Tabulators/IRV.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@ describe("IRV Tests", () => {
expect(results.voteCounts[0]).toStrictEqual([5,2,1,1]);

})

test("Multiwinner ", () => {
// Simple multiwinner test, shows first winner's votes transfer correctly
const candidates = ['Alice', 'Bob', 'Carol', 'Dave']

const votes = [
[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 3, 2, 4],
[2, 1, 3, 4],
[2, 1, 3, 4],
[2, 3, 1, 4],
[2, 3, 4, 1],
]
const results = IRV(candidates, votes, 2)
expect(results.elected.length).toBe(2);
expect(results.elected[0].name).toBe('Alice');
expect(results.elected[1].name).toBe('Bob');
expect(results.voteCounts.length).toBe(2);
expect(results.voteCounts[0]).toStrictEqual([5,2,1,1]);
expect(results.voteCounts[1]).toStrictEqual([0,6,2,1]);

})

test("2 round test", () => {
// Majority can't be found in first round
const candidates = ['Alice', 'Bob', 'Carol']
Expand Down
253 changes: 184 additions & 69 deletions packages/backend/src/Tabulators/IRV.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { ballot, candidate, irvResults, irvSummaryData, totalScore } from "@equal-vote/star-vote-shared/domain_model/ITabulators";
import { ballot, candidate, irvResults, irvRoundResults, irvSummaryData, totalScore } from "@equal-vote/star-vote-shared/domain_model/ITabulators";

import { IparsedData } from './ParseData'
// import Fraction from "fraction.js";
const ParseData = require("./ParseData");

export function IRV(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:number[] = [], breakTiesRandomly = true) {
// Determines Instant Runoff winners for given election

const Fraction = require('fraction.js');

type weightedVote = {
weight: typeof Fraction,
vote: ballot,
overvote: boolean
}

export function IRV(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder: number[] = [], breakTiesRandomly = true) {
return IRV_STV(candidates, votes, nWinners, randomTiebreakOrder, breakTiesRandomly, false)
}

export function STV(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder: number[] = [], breakTiesRandomly = true) {
return IRV_STV(candidates, votes, nWinners, randomTiebreakOrder, breakTiesRandomly, true)
}

export function IRV_STV(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder: number[] = [], breakTiesRandomly = true, proportional = true) {
// Determines Instant Runoff winners for given election, results are either block or proportional
// Parameters:
// candidates: Array of candidate names
// votes: Array of votes, size nVoters x Candidates, zeros indicate no rank
// nWiners: Number of winners in election, defaulted to 1 (only supports 1 at the moment)
// randomTiebreakOrder: Array to determine tiebreak order, uses strings to allow comparing UUIDs. If empty or not same length as candidates, set to candidate indexes
// breakTiesRandomly: In the event of a true tie, should a winner be selected at random, defaulted to true
// proportional: Determines if results are block IRV or proportional STV

// Parse the votes for valid, invalid, and undervotes, and identifies bullet votes
const parsedData = ParseData(votes, getIRVBallotValidity)
Expand All @@ -23,94 +42,190 @@ export function IRV(candidates: string[], votes: ballot[], nWinners = 1, randomT
tied: [],
other: [],
summaryData: summaryData,
roundResults: [],
logs: [],
voteCounts: [],
exhaustedVoteCounts: [],
overVoteCounts: [],
tieBreakType: 'none',
}



let remainingCandidates = [...summaryData.candidates]
let activeVotes = parsedData.scores
let exhaustedVotes: ballot[] = []
let activeVotes: ballot[] = parsedData.scores
let exhaustedVotes: weightedVote[] = []
let exhaustedVoteCount = 0
let overVoteCount = 0
while (results.elected.length < 1) { //TODO: multiwinner STV
let roundVoteCounts = Array(summaryData.candidates.length).fill(0)
for (let i = activeVotes.length - 1; i >= 0; i--) {
// loop backwards over the ballots so exhausted votes can be removed
let ballot = activeVotes[i]
let topRemainingRank: number = 0
let topRemainingRankIndex: number = 0
let isOverVote = false
remainingCandidates.forEach(candidate => {
// loop over remaining candidates
// find top (non-zero) rank, if duplicate top ranks mark as overvote
if (ballot[candidate.index] == 0) return;
if (ballot[candidate.index] == null) return;

// candidate has rank

if (topRemainingRank == 0) {
// set initial top rank
topRemainingRankIndex = candidate.index
topRemainingRank = ballot[candidate.index]
isOverVote = false
}
else if (ballot[candidate.index] < ballot[topRemainingRankIndex]) {
// set new top rank
topRemainingRankIndex = candidate.index
topRemainingRank = ballot[candidate.index]
isOverVote = false
}
else if (ballot[candidate.index] === ballot[topRemainingRankIndex]) {
// multiple top ranks, mark as over vote
isOverVote = true
}
})
if (isOverVote) {
// ballot is overvote
overVoteCount += 1
}
if (topRemainingRank === 0 || isOverVote) {
// ballot is exhausted
exhaustedVoteCount += 1
exhaustedVotes.push(...activeVotes.splice(i, 1))

let weightedVotes: weightedVote[] = activeVotes.map(vote => ({ weight: Fraction(1), vote: vote, overvote: false }))

// Initialize candidate vote pools to empty arrays
let candidateVotes: weightedVote[][] = Array(summaryData.candidates.length)
for (var i = 0; i < candidateVotes.length; i++) {
candidateVotes[i] = [];
}

// Initial vote distribution, moves weighted votes into the appropriate candidate pools
distributeVotes(remainingCandidates, candidateVotes, exhaustedVotes, weightedVotes)

// Set quota based on number of winners and if its proportional
let quota = 0
if (proportional) {
quota = Math.floor(activeVotes.length/(nWinners + 1) + 1)
}
else {
quota = Math.floor(activeVotes.length/2 + 1)
}

while (results.elected.length < nWinners) {

let roundResults: irvRoundResults = {
winners: [],
eliminated: [],
logs: []
}

let roundVoteCounts = candidateVotes.map((c, i) => ({ index: i, voteCount: addWeightedVotes(c) }))

let sortedVoteCounts = [...roundVoteCounts].sort((a, b) => {
if (a.voteCount !== b.voteCount) {
return (b.voteCount - a.voteCount)
}
else {
// give vote to top rank candidate
roundVoteCounts[topRemainingRankIndex] += 1

// break tie in favor of candidate with higher score in previous rounds
for (let i = results.voteCounts.length - 1; i >= 0; i--) {
if (results.voteCounts[i][b.index] !== results.voteCounts[i][a.index]) {
return results.voteCounts[i][b.index] - results.voteCounts[i][a.index]
}
}
}
results.voteCounts.push(roundVoteCounts)
results.exhaustedVoteCounts.push(exhaustedVoteCount)
results.overVoteCounts.push(overVoteCount)
let voteThreshold = activeVotes.length / 2

return (summaryData.candidates[a.index].tieBreakOrder - summaryData.candidates[b.index].tieBreakOrder)

})

results.voteCounts.push(roundVoteCounts.map(c => c.voteCount.valueOf()))
results.exhaustedVoteCounts.push(exhaustedVotes.length)
results.overVoteCounts.push(exhaustedVotes.filter(ev => ev.overvote).length)

// get max number of votes
let remainingCandidatesIndexes = remainingCandidates.map(c => c.index)
let maxVotes = Math.max(...roundVoteCounts.filter((c, i) => remainingCandidatesIndexes.includes(i)))
if (maxVotes > voteThreshold || remainingCandidates.length <= 2) {

let maxVotes = sortedVoteCounts[0].voteCount
let nActiveVotes = candidateVotes.map(c => c.length).reduce((a, b) => a + b, 0)
if (!proportional) {
quota = Math.floor(nActiveVotes /2 + 1)
}

if (maxVotes >= quota) {
// candidate meets the threshold
// get index of winning candidate
let winningCandidateIndex = roundVoteCounts.indexOf(maxVotes)
let winningCandidateIndex = sortedVoteCounts[0].index
// add winner, remove from remaining candidates
results.elected.push(summaryData.candidates[winningCandidateIndex])
remainingCandidates = remainingCandidates.filter(c => !results.elected.includes(c))
// TODO: Some STV stuff
roundResults.winners.push(summaryData.candidates[winningCandidateIndex])
if (proportional) {
remainingCandidates = remainingCandidates.filter(c => !results.elected.includes(c))
let fractionalSurplus = new Fraction(maxVotes - quota).div(maxVotes)
let winningCandidateVotes = candidateVotes[winningCandidateIndex]
candidateVotes[winningCandidateIndex] = []
winningCandidateVotes.forEach(vote => {
vote.weight = vote.weight.mul(fractionalSurplus).floor(5)
})
distributeVotes(remainingCandidates, candidateVotes, exhaustedVotes, winningCandidateVotes)
}
else {
// Reset candidate pool and remove elected candidates
remainingCandidates = [...summaryData.candidates].filter(c => !results.elected.includes(c))

// Reset candidate vote counts and redistribute votes
for (var i = 0; i < candidateVotes.length; i++) {
candidateVotes[i] = [];
}
distributeVotes(remainingCandidates, candidateVotes, exhaustedVotes, weightedVotes)
}

}
else if ( proportional && remainingCandidates.length <= (nWinners - results.elected.length)) {
// If number of candidates remaining can fill seats, elect them and end election
// Only used in proportional as order can matter in block
results.elected.push(...remainingCandidates)
roundResults.winners.push(...remainingCandidates)
}
else {
// find candidate with least votes and remove from remaining candidates
let remainingVoteCounts = roundVoteCounts.filter((c, i) => remainingCandidatesIndexes.includes(i))
let lastPlaceCandidateIndex = remainingVoteCounts.indexOf(Math.min(...remainingVoteCounts))
remainingCandidates = remainingCandidates.filter(c => c !== summaryData.candidates[remainingCandidatesIndexes[lastPlaceCandidateIndex]])
let remainingVoteCounts = sortedVoteCounts.filter(c => remainingCandidatesIndexes.includes(c.index))
let lastPlaceCandidateIndex = remainingVoteCounts[remainingVoteCounts.length - 1].index
remainingCandidates = remainingCandidates.filter(c => c.index !== lastPlaceCandidateIndex)
let eliminatedCandidateVotes = candidateVotes[lastPlaceCandidateIndex]
candidateVotes[lastPlaceCandidateIndex] = []
distributeVotes(remainingCandidates, candidateVotes, exhaustedVotes, eliminatedCandidateVotes)
roundResults.eliminated.push(summaryData.candidates[lastPlaceCandidateIndex])
}
results.roundResults.push(roundResults)
}
// Sort data in order of candidate placements
// results.summaryData = sortData(summaryData, results.elected.concat(results.tied).concat(results.other))

return results
}

function addWeightedVotes(weightedVotes: weightedVote[]) {
let voteTotal = new Fraction(0)
weightedVotes.forEach(vote => {
voteTotal = voteTotal.add(vote.weight)
})
return voteTotal
}



function distributeVotes(remainingCandidates: candidate[], candidateVotes: weightedVote[][], exhaustedVotes: weightedVote[], votesToDistribute: weightedVote[]) {
for (let i = votesToDistribute.length - 1; i >= 0; i--) {
// loop backwards over the ballots so exhausted votes can be removed
let ballot = votesToDistribute[i]
let topRemainingRank: number = 0
let topRemainingRankIndex: number = 0
let isOverVote = false
remainingCandidates.forEach(candidate => {
// loop over remaining candidates
// find top (non-zero) rank, if duplicate top ranks mark as overvote
if (ballot.vote[candidate.index] == 0) return;
if (ballot.vote[candidate.index] == null) return;

// candidate has rank

if (topRemainingRank == 0) {
// set initial top rank
topRemainingRankIndex = candidate.index
topRemainingRank = ballot.vote[candidate.index]
isOverVote = false
}
else if (ballot.vote[candidate.index] < ballot.vote[topRemainingRankIndex]) {
// set new top rank
topRemainingRankIndex = candidate.index
topRemainingRank = ballot.vote[candidate.index]
isOverVote = false
}
else if (ballot.vote[candidate.index] === ballot.vote[topRemainingRankIndex]) {
// multiple top ranks, mark as over vote
isOverVote = true
}
})

ballot.overvote = isOverVote

if (topRemainingRank === 0 || isOverVote) {
// ballot is exhausted
exhaustedVotes.push(ballot)
}
else {
// give vote to top rank candidate
candidateVotes[topRemainingRankIndex].push(ballot)
}
}


}

function getIRVBallotValidity(ballot: ballot) {
const minScore = 0
const maxScore = ballot.length
Expand All @@ -126,11 +241,11 @@ function getIRVBallotValidity(ballot: ballot) {
return { isValid: true, isUnderVote: isUnderVote }
}

function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:number[]): irvSummaryData {
const nCandidates = candidates.length
function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: number[]): irvSummaryData {
const nCandidates = candidates.length
if (randomTiebreakOrder.length < nCandidates) {
randomTiebreakOrder = candidates.map((c,index) => index)
}
randomTiebreakOrder = candidates.map((c, index) => index)
}
// Initialize summary data structures
// Total scores for each candidate, includes candidate indexes for easier sorting
const totalScores: totalScore[] = Array(nCandidates)
Expand Down Expand Up @@ -192,7 +307,7 @@ function getSummaryData(candidates: string[], parsedData: IparsedData, randomTie
}
}

const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: randomTiebreakOrder[index]}))
const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: randomTiebreakOrder[index] }))
return {
candidates: candidatesWithIndexes,
totalScores,
Expand Down
32 changes: 32 additions & 0 deletions packages/backend/src/Tabulators/STV.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { STV } from './IRV'

describe("STV Tests", () => {
test("Two winner test", () => {
// Simple two winner STV test.
// Shows fractional surplus and candidate elimination works

const candidates = ['Alice', 'Bob', 'Carol', 'Dave']

const votes = [
[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 2, 3, 4],
[2, 1, 3, 4],
[2, 1, 3, 4],
[2, 3, 1, 4],
[2, 3, 4, 1],
]
const results = STV(candidates, votes,2)
expect(results.elected.length).toBe(2);
expect(results.elected[0].name).toBe('Alice');
expect(results.elected[1].name).toBe('Bob');
expect(results.voteCounts.length).toBe(3);
expect(results.voteCounts[0]).toStrictEqual([5,2,1,1]);
expect(results.voteCounts[1]).toStrictEqual([0,3,1,1]); // first 5 voters weight reduced to 1/5 and transfered to Bob
expect(results.voteCounts[2]).toStrictEqual([0,4,1,0]); // Dave eliminated and transfered to Bob

})

})
3 changes: 2 additions & 1 deletion packages/backend/src/Tabulators/VotingMethodSelecter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Star } from "./Star";
import { Approval } from "./Approval";
import { Plurality } from "./Plurality";
import { IRV } from "./IRV";
import { IRV, STV } from "./IRV";
import { RankedRobin } from "./RankedRobin";
import { AllocatedScore } from "./AllocatedScore";

Expand All @@ -11,5 +11,6 @@ export const VotingMethods: { [id: string]: Function } = {
Approval: Approval,
Plurality: Plurality,
IRV: IRV,
STV: STV,
RankedRobin: RankedRobin
}
Loading

0 comments on commit 0eebc22

Please sign in to comment.