From ce73556764c280452d30aab41e08ace8f1e45925 Mon Sep 17 00:00:00 2001 From: eznarf <41272412+eznarf@users.noreply.github.com> Date: Sun, 5 Nov 2023 12:11:08 -0800 Subject: [PATCH 1/2] Adding tiebreaker order --- .../getElectionResultsController.ts | 4 +- backend/src/Tabulators/AllocatedScore.test.ts | 37 +++++++++++++--- backend/src/Tabulators/AllocatedScore.ts | 35 +++++++-------- backend/src/Tabulators/Approval.test.ts | 35 ++++++++++++++- backend/src/Tabulators/Approval.ts | 18 ++++---- backend/src/Tabulators/IRV.ts | 18 ++++---- backend/src/Tabulators/ITabulators.ts | 1 + backend/src/Tabulators/Plurality.test.ts | 40 ++++++++++++++++- backend/src/Tabulators/Plurality.ts | 18 ++++---- backend/src/Tabulators/RankedRobin.test.ts | 18 +++++++- backend/src/Tabulators/RankedRobin.ts | 19 ++++---- backend/src/Tabulators/Star.test.ts | 44 +++++++++++++++---- backend/src/Tabulators/Star.ts | 29 +++++++----- 13 files changed, 229 insertions(+), 87 deletions(-) diff --git a/backend/src/Controllers/getElectionResultsController.ts b/backend/src/Controllers/getElectionResultsController.ts index 50ddf1cc..6ac87cce 100644 --- a/backend/src/Controllers/getElectionResultsController.ts +++ b/backend/src/Controllers/getElectionResultsController.ts @@ -48,8 +48,10 @@ const getElectionResults = async (req: IElectionRequest, res: Response, next: Ne } const msg = `Tabulating results for ${voting_method} election` Logger.info(req, msg); - results[race_index] = VotingMethods[voting_method](candidateNames, cvr, num_winners) + const candidateIDs = election.races[race_index].candidates.map((Candidate) => (Candidate.candidate_id)) + results[race_index] = VotingMethods[voting_method](candidateNames, cvr, num_winners, candidateIDs) } + res.json( { Election: election, diff --git a/backend/src/Tabulators/AllocatedScore.test.ts b/backend/src/Tabulators/AllocatedScore.test.ts index 7290909b..bd18b249 100644 --- a/backend/src/Tabulators/AllocatedScore.test.ts +++ b/backend/src/Tabulators/AllocatedScore.test.ts @@ -16,7 +16,7 @@ describe("Allocated Score Tests", () => { [0, 0, 4, 5], [0, 0, 4, 5], [0, 0, 4, 5]] - const results = AllocatedScore(candidates, votes, 2, false, false) + const results = AllocatedScore(candidates, votes, 2, [], false, false) expect(results.elected.length).toBe(2); expect(results.elected[0].name).toBe('Allison'); expect(results.elected[1].name).toBe('Doug'); @@ -40,7 +40,7 @@ describe("Allocated Score Tests", () => { [0, 0, 4, 5], [0, 0, 4, 5], [0, 0, 4, 5]] - const results = AllocatedScore(candidates, votes, 2, false, false) + const results = AllocatedScore(candidates, votes, 2, [], false, false) expect(results.elected.length).toBe(2); expect(results.elected[0].name).toBe('Allison'); expect(results.elected[1].name).toBe('Doug'); @@ -66,7 +66,7 @@ describe("Allocated Score Tests", () => { [0, 0, 4, 5], [0, 0, 4, 5], [0, 0, 4, 5]] - const results = AllocatedScore(candidates, votes, 2, false, false) + const results = AllocatedScore(candidates, votes, 2, [], false, false) console.log(results.summaryData.weightedScoresByRound) expect(results.elected.length).toBe(2); expect(results.elected[0].name).toBe('Allison'); @@ -76,7 +76,8 @@ describe("Allocated Score Tests", () => { }) test("Random Tiebreaker", () => { - // Two winners, two candidates tie for first, break tie randomly + // Two winners, two candidates tie for first + // Tiebreak order not defined, select lower index const candidates = ['Allison', 'Bill', 'Carmen', 'Doug'] const votes = [ [5, 5, 1, 0], @@ -89,10 +90,32 @@ describe("Allocated Score Tests", () => { [0, 0, 4, 5], [0, 0, 4, 5], [0, 0, 4, 5]] - const results = AllocatedScore(candidates, votes, 2, true, false) + const results = AllocatedScore(candidates, votes, 2, [], true, false) expect(results.elected.length).toBe(2); expect(results.tied[0].length).toBe(2); // two candidates tied in forst round - expect(['Allison','Bill']).toContain(results.elected[0].name) // random tiebreaker, second place can either be Allison or Bill + expect(results.elected[0].name).toBe('Allison') // random tiebreaker, second place lower index 1 + expect(results.elected[1].name).toBe('Doug'); + }) + + test("Random Tiebreaker, tiebreak order defined", () => { + // Two winners, two candidates tie for first + // Tiebreak order defined, select lower + const candidates = ['Allison', 'Bill', 'Carmen', 'Doug'] + const votes = [ + [5, 5, 1, 0], + [5, 5, 1, 0], + [5, 5, 1, 0], + [5, 5, 1, 0], + [5, 5, 4, 0], + [0, 0, 0, 3], + [0, 0, 4, 5], + [0, 0, 4, 5], + [0, 0, 4, 5], + [0, 0, 4, 5]] + const results = AllocatedScore(candidates, votes, 2, ['4','3','2','1'], true, false) + expect(results.elected.length).toBe(2); + expect(results.tied[0].length).toBe(2); // two candidates tied in forst round + expect(results.elected[0].name).toBe('Bill') // random tiebreaker, second place lower index 1 expect(results.elected[1].name).toBe('Doug'); }) @@ -110,7 +133,7 @@ describe("Allocated Score Tests", () => { [0, 5, 0], [0, 0, 5], ] - const results = AllocatedScore(candidates, votes, 1, false, false) + const results = AllocatedScore(candidates, votes, 1, [], false, false) expect(results.summaryData.nValidVotes).toBe(8); expect(results.summaryData.nInvalidVotes).toBe(2); expect(results.summaryData.nUnderVotes).toBe(2); diff --git a/backend/src/Tabulators/AllocatedScore.ts b/backend/src/Tabulators/AllocatedScore.ts index 71c04e0c..12950742 100644 --- a/backend/src/Tabulators/AllocatedScore.ts +++ b/backend/src/Tabulators/AllocatedScore.ts @@ -2,6 +2,7 @@ import { ballot, candidate, fiveStarCount, allocatedScoreResults, allocatedScore import { IparsedData } from './ParseData' import Fraction from 'fraction.js' +import { sortByTieBreakOrder } from "./Star"; const ParseData = require("./ParseData"); declare namespace Intl { @@ -22,12 +23,13 @@ interface winner_scores { type ballotFrac = Fraction[] -export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = 3, breakTiesRandomly = true, enablefiveStarTiebreaker = true) { +export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = 3, randomTiebreakOrder: string[] = [], breakTiesRandomly = true, enablefiveStarTiebreaker = true) { // Determines STAR-PR winners for given election using Allocated Score // Parameters: // candidates: Array of candidate names // votes: Array of votes, size nVoters x Candidates // nWiners: Number of winners in election, defaulted to 3 + // 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 // enablefiveStarTiebreaker: In the event of a true tie in the runoff round, should the five-star tiebreaker be used (select candidate with the most 5 star votes), defaulted to true // Parse the votes for valid, invalid, and undervotes, and identifies bullet votes @@ -37,7 +39,7 @@ export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = // total scores // score histograms // preference and pairwise matrices - const summaryData = getSummaryData(candidates, parsedData) + const summaryData = getSummaryData(candidates, parsedData, randomTiebreakOrder) // Initialize output data structure const results: allocatedScoreResults = { @@ -81,13 +83,9 @@ export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = summaryData.weightedScoresByRound.push(weighted_sums.map(w => w.valueOf())) candidatesByRound.push([...remainingCandidates]) // get index of winner - var maxAndTies = indexOfMax(weighted_sums, breakTiesRandomly); + var maxAndTies = indexOfMax(weighted_sums, summaryData.candidates, breakTiesRandomly); var w = maxAndTies.maxIndex; - var roundTies: candidate[] = []; - maxAndTies.ties.forEach((index, i) => { - roundTies.push(summaryData.candidates[index]); - }); - results.tied.push(roundTies); + results.tied.push(maxAndTies.ties); // add winner to winner list results.elected.push(summaryData.candidates[w]); // Set all scores for winner to zero @@ -162,8 +160,11 @@ export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = return results } -function getSummaryData(candidates: string[], parsedData: IparsedData): allocatedScoreSummaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: string[]): allocatedScoreSummaryData { const nCandidates = candidates.length + if (randomTiebreakOrder.length < nCandidates) { + randomTiebreakOrder = candidates.map((c,index) => index.toString()) + } // Initialize summary data structures // Total scores for each candidate, includes candidate indexes for easier sorting const totalScores: totalScore[] = Array(nCandidates) @@ -219,7 +220,7 @@ function getSummaryData(candidates: string[], parsedData: IparsedData): allocate } } - const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate })) + const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: randomTiebreakOrder[index] })) return { candidates: candidatesWithIndexes, totalScores, @@ -304,33 +305,29 @@ function findWeightOnSplit(cand_df: winner_scores[], split_point: Fraction) { return weight_on_split; } -function indexOfMax(arr: Fraction[], breakTiesRandomly: boolean) { +function indexOfMax(arr: Fraction[], candidates: candidate[], breakTiesRandomly: boolean) { if (arr.length === 0) { return { maxIndex: -1, ties: [] }; } var max = arr[0]; var maxIndex = 0; - var ties: number[] = [0]; + var ties: candidate[] = [candidates[0]]; for (var i = 1; i < arr.length; i++) { if (max.equals(arr[i])) { - ties.push(i); + ties.push(candidates[i]); } else if (arr[i].compare(max) > 0) { maxIndex = i; max = arr[i]; - ties = [i]; + ties = [candidates[i]]; } } if (breakTiesRandomly && ties.length > 1) { - maxIndex = ties[getRandomInt(ties.length)] + maxIndex = candidates.indexOf(sortByTieBreakOrder(ties)[0]) } return { maxIndex, ties }; } -function getRandomInt(max: number) { - return Math.floor(Math.random() * max); -} - function normalizeArray(scores: ballot[], maxScore: number) { // Normalize scores array var scoresNorm: ballotFrac[] = Array(scores.length); diff --git a/backend/src/Tabulators/Approval.test.ts b/backend/src/Tabulators/Approval.test.ts index 5dd8dee0..44c26c4c 100644 --- a/backend/src/Tabulators/Approval.test.ts +++ b/backend/src/Tabulators/Approval.test.ts @@ -67,6 +67,7 @@ describe("Approval Tests", () => { test("Ties Test", () => { // Tie for second + // Tiebreak order not defined, select lower index const candidates = ['Alice', 'Bob', 'Carol', 'Dave'] const votes = [ @@ -84,9 +85,39 @@ describe("Approval Tests", () => { expect(results.summaryData.totalScores[0].score).toBe(7) expect(results.summaryData.totalScores[0].index).toBe(3) expect(results.summaryData.totalScores[1].score).toBe(6) - expect([1,2]).toContain(results.summaryData.totalScores[1].index) // random tiebreaker, second place can either be 1 or 2 + expect(results.summaryData.totalScores[1].index).toBe(1) // random tiebreaker, second place lower index 1 expect(results.summaryData.totalScores[2].score).toBe(6) - expect([1,2]).toContain(results.summaryData.totalScores[2].index) // random tiebreaker, third place can either be 1 or 2 + expect(results.summaryData.totalScores[2].index).toBe(2) // random tiebreaker, third place higher index 2 + expect(results.summaryData.totalScores[3].score).toBe(1) + expect(results.summaryData.totalScores[3].index).toBe(0) + + expect(results.summaryData.nUnderVotes).toBe(0) + expect(results.summaryData.nValidVotes).toBe(7) + expect(results.summaryData.nInvalidVotes).toBe(0) + }) + test("Ties Test, tiebreak order defined", () => { + // Tie for second + // Tiebreak order defined, select lower + const candidates = ['Alice', 'Bob', 'Carol', 'Dave'] + + const votes = [ + [1, 1, 1, 1], + [0, 1, 1, 1], + [0, 1, 1, 1], + [0, 1, 1, 1], + [0, 1, 1, 1], + [0, 1, 1, 1], + [0, 0, 0, 1], + ] + const results = Approval(candidates, votes, 1, ['4','3','2','1']) + expect(results.elected.length).toBe(1); + expect(results.elected[0].name).toBe('Dave'); + expect(results.summaryData.totalScores[0].score).toBe(7) + expect(results.summaryData.totalScores[0].index).toBe(3) + expect(results.summaryData.totalScores[1].score).toBe(6) + expect(results.summaryData.totalScores[1].index).toBe(2) // random tiebreaker, second place lower in tiebreak order + expect(results.summaryData.totalScores[2].score).toBe(6) + expect(results.summaryData.totalScores[2].index).toBe(1) // random tiebreaker, third place higher in tiebreak order expect(results.summaryData.totalScores[3].score).toBe(1) expect(results.summaryData.totalScores[3].index).toBe(0) diff --git a/backend/src/Tabulators/Approval.ts b/backend/src/Tabulators/Approval.ts index bcba1f8c..b4b9dce0 100644 --- a/backend/src/Tabulators/Approval.ts +++ b/backend/src/Tabulators/Approval.ts @@ -3,9 +3,9 @@ import { approvalResults, approvalSummaryData, ballot, candidate, totalScore } f import { IparsedData } from './ParseData' const ParseData = require("./ParseData"); -export function Approval(candidates: string[], votes: ballot[], nWinners = 1, breakTiesRandomly = true) { +export function Approval(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true) { const parsedData = ParseData(votes, getApprovalBallotValidity) - const summaryData = getSummaryData(candidates, parsedData) + const summaryData = getSummaryData(candidates, parsedData, randomTiebreakOrder) const results: approvalResults = { elected: [], tied: [], @@ -16,7 +16,8 @@ export function Approval(candidates: string[], votes: ballot[], nWinners = 1, br const sortedScores = summaryData.totalScores.sort((a: totalScore, b: totalScore) => { if (a.score > b.score) return -1 if (a.score < b.score) return 1 - return 0.5 - Math.random() + if (summaryData.candidates[a.index].tieBreakOrder < summaryData.candidates[b.index].tieBreakOrder) return -1 + return 1 }) var remainingCandidates = [...summaryData.candidates] @@ -46,9 +47,12 @@ export function Approval(candidates: string[], votes: ballot[], nWinners = 1, br return results; } -function getSummaryData(candidates: string[], parsedData: IparsedData): approvalSummaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:string[]): approvalSummaryData { // Initialize summary data structures const nCandidates = candidates.length + if (randomTiebreakOrder.length < nCandidates) { + randomTiebreakOrder = candidates.map((c,index) => index.toString()) + } const totalScores = Array(nCandidates) for (let i = 0; i < nCandidates; i++) { totalScores[i] = { index: i, score: 0 }; @@ -67,7 +71,7 @@ function getSummaryData(candidates: string[], parsedData: IparsedData): approval nBulletVotes += 1 } }) - const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate })) + const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: randomTiebreakOrder[index] })) return { candidates: candidatesWithIndexes, totalScores, @@ -91,8 +95,4 @@ function getApprovalBallotValidity(ballot: ballot) { } } return { isValid: true, isUnderVote: isUnderVote } -} - -function getRandomInt(max: number) { - return Math.floor(Math.random() * max); } \ No newline at end of file diff --git a/backend/src/Tabulators/IRV.ts b/backend/src/Tabulators/IRV.ts index 938d7aec..811820e3 100644 --- a/backend/src/Tabulators/IRV.ts +++ b/backend/src/Tabulators/IRV.ts @@ -3,18 +3,19 @@ import { ballot, candidate, irvResults, irvSummaryData, totalScore } from "./ITa import { IparsedData } from './ParseData' const ParseData = require("./ParseData"); -export function IRV(candidates: string[], votes: ballot[], nWinners = 1, breakTiesRandomly = true) { +export function IRV(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true) { // Determines Instant Runoff winners for given election // 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 // Parse the votes for valid, invalid, and undervotes, and identifies bullet votes const parsedData = ParseData(votes, getIRVBallotValidity) - const summaryData = getSummaryData(candidates, parsedData) + const summaryData = getSummaryData(candidates, parsedData, randomTiebreakOrder) // Initialize output data structure const results: irvResults = { @@ -123,8 +124,11 @@ function getIRVBallotValidity(ballot: ballot) { return { isValid: true, isUnderVote: isUnderVote } } -function getSummaryData(candidates: string[], parsedData: IparsedData): irvSummaryData { - const nCandidates = candidates.length +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:string[]): irvSummaryData { + const nCandidates = candidates.length + if (randomTiebreakOrder.length < nCandidates) { + randomTiebreakOrder = candidates.map((c,index) => index.toString()) + } // Initialize summary data structures // Total scores for each candidate, includes candidate indexes for easier sorting const totalScores: totalScore[] = Array(nCandidates) @@ -186,7 +190,7 @@ function getSummaryData(candidates: string[], parsedData: IparsedData): irvSumma } } - const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate })) + const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: randomTiebreakOrder[index]})) return { candidates: candidatesWithIndexes, totalScores, @@ -224,10 +228,6 @@ function sortData(summaryData: irvSummaryData, order: candidate[]): irvSummaryDa } } -function getRandomInt(max: number) { - return Math.floor(Math.random() * max); -} - function sortMatrix(matrix: number[][], order: number[]) { var newMatrix = Array(order.length); for (let i = 0; i < order.length; i++) { diff --git a/backend/src/Tabulators/ITabulators.ts b/backend/src/Tabulators/ITabulators.ts index 72f309ee..fca90ed5 100644 --- a/backend/src/Tabulators/ITabulators.ts +++ b/backend/src/Tabulators/ITabulators.ts @@ -7,6 +7,7 @@ export type ballots = ballot[] export interface candidate { index: number, name: string, + tieBreakOrder: string, } export interface voter { diff --git a/backend/src/Tabulators/Plurality.test.ts b/backend/src/Tabulators/Plurality.test.ts index 7720d27c..914da558 100644 --- a/backend/src/Tabulators/Plurality.test.ts +++ b/backend/src/Tabulators/Plurality.test.ts @@ -74,6 +74,7 @@ describe("Plurality Tests", () => { test("Ties Test", () => { // Tie for second + // Tiebreak order not defined, select lower index const candidates = ['Alice', 'Bob', 'Carol', 'Dave'] const votes = [ @@ -95,9 +96,44 @@ describe("Plurality Tests", () => { expect(results.summaryData.totalScores[0].score).toBe(5) expect(results.summaryData.totalScores[0].index).toBe(3) expect(results.summaryData.totalScores[1].score).toBe(3) - expect([1,2]).toContain(results.summaryData.totalScores[1].index)// random tiebreaker, second place can either be 1 or 2 + expect(results.summaryData.totalScores[1].index).toBe(1) // random tiebreaker, second place lower index 1 expect(results.summaryData.totalScores[2].score).toBe(3) - expect([1,2]).toContain(results.summaryData.totalScores[2].index)// random tiebreaker, third place can either be 1 or 2 + expect(results.summaryData.totalScores[2].index).toBe(2) // random tiebreaker, third place higher index 2 + expect(results.summaryData.totalScores[3].score).toBe(0) + expect(results.summaryData.totalScores[3].index).toBe(0) + + expect(results.summaryData.nUnderVotes).toBe(0) + expect(results.summaryData.nValidVotes).toBe(11) + expect(results.summaryData.nInvalidVotes).toBe(0) + }) + + test("Ties Test, tiebreak order defined", () => { + // Tie for second + // Tiebreak order defined, select lower + const candidates = ['Alice', 'Bob', 'Carol', 'Dave'] + + const votes = [ + [0, 1, 0, 0], + [0, 1, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + [0, 0, 0, 1], + [0, 0, 0, 1], + [0, 0, 0, 1], + [0, 0, 0, 1], + ] + const results = Plurality(candidates, votes, 1,['4','3','2','1']) + expect(results.elected.length).toBe(1); + expect(results.elected[0].name).toBe('Dave'); + expect(results.summaryData.totalScores[0].score).toBe(5) + expect(results.summaryData.totalScores[0].index).toBe(3) + expect(results.summaryData.totalScores[1].score).toBe(3) + expect(results.summaryData.totalScores[1].index).toBe(2) // random tiebreaker, second place lower in tiebreak order + expect(results.summaryData.totalScores[2].score).toBe(3) + expect(results.summaryData.totalScores[2].index).toBe(1) // random tiebreaker, third place higher in tiebreak order expect(results.summaryData.totalScores[3].score).toBe(0) expect(results.summaryData.totalScores[3].index).toBe(0) diff --git a/backend/src/Tabulators/Plurality.ts b/backend/src/Tabulators/Plurality.ts index bda80495..9c5c4f3f 100644 --- a/backend/src/Tabulators/Plurality.ts +++ b/backend/src/Tabulators/Plurality.ts @@ -3,9 +3,9 @@ import { approvalResults, approvalSummaryData, ballot, candidate, pluralityResul import { IparsedData } from './ParseData' const ParseData = require("./ParseData"); -export function Plurality(candidates: string[], votes: ballot[], nWinners = 1, breakTiesRandomly = true) { +export function Plurality(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true) { const parsedData = ParseData(votes, getPluralityBallotValidity) - const summaryData = getSummaryData(candidates, parsedData) + const summaryData = getSummaryData(candidates, parsedData, randomTiebreakOrder) const results: pluralityResults = { elected: [], tied: [], @@ -16,7 +16,8 @@ export function Plurality(candidates: string[], votes: ballot[], nWinners = 1, b const sortedScores = summaryData.totalScores.sort((a: totalScore, b: totalScore) => { if (a.score > b.score) return -1 if (a.score < b.score) return 1 - return 0.5 - Math.random() + if (summaryData.candidates[a.index].tieBreakOrder < summaryData.candidates[b.index].tieBreakOrder) return -1 + return 1 }) var remainingCandidates = [...summaryData.candidates] while (remainingCandidates.length>0) { @@ -45,9 +46,12 @@ export function Plurality(candidates: string[], votes: ballot[], nWinners = 1, b return results; } -function getSummaryData(candidates: string[], parsedData: IparsedData): pluralitySummaryData{ +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: string[]): pluralitySummaryData{ // Initialize summary data structures const nCandidates = candidates.length + if (randomTiebreakOrder.length < nCandidates) { + randomTiebreakOrder = candidates.map((c,index) => index.toString()) + } const totalScores = Array(nCandidates) for (let i = 0; i < nCandidates; i++) { totalScores[i] = { index: i, score: 0 }; @@ -58,7 +62,7 @@ function getSummaryData(candidates: string[], parsedData: IparsedData): pluralit totalScores[i].score += vote[i] } }) - const candidatesWithIndexes = candidates.map((candidate, index) => ({ index: index, name: candidate })) + const candidatesWithIndexes = candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: randomTiebreakOrder[index] })) return { candidates: candidatesWithIndexes, totalScores, @@ -86,8 +90,4 @@ function getPluralityBallotValidity(ballot: ballot) { return { isValid: false, isUnderVote: isUnderVote } } return { isValid: true, isUnderVote: isUnderVote } -} - -function getRandomInt(max: number) { - return Math.floor(Math.random() * max); } \ No newline at end of file diff --git a/backend/src/Tabulators/RankedRobin.test.ts b/backend/src/Tabulators/RankedRobin.test.ts index b86ded09..cd2ffed5 100644 --- a/backend/src/Tabulators/RankedRobin.test.ts +++ b/backend/src/Tabulators/RankedRobin.test.ts @@ -67,6 +67,7 @@ describe("Ranked Robin Tests", () => { expect(results.summaryData.nInvalidVotes).toBe(0) }) test("Ties", () => { + // Tiebreak order not defined, select lower index const candidates = ['Alice', 'Bob', 'Carol', 'Dave'] const votes = [ @@ -78,7 +79,7 @@ describe("Ranked Robin Tests", () => { [2, 1, 3, 4], ] const results = RankedRobin(candidates, votes) - expect(['Alice','Bob']).toContain(results.elected[0].name) + expect(results.elected[0].name).toBe('Alice') expect(results.elected.length).toBe(1); expect(results.summaryData.preferenceMatrix[0]).toStrictEqual([0,3,6,6]); expect(results.summaryData.preferenceMatrix[1]).toStrictEqual([3,0,6,6]); @@ -102,5 +103,20 @@ describe("Ranked Robin Tests", () => { expect(results.summaryData.nValidVotes).toBe(6) expect(results.summaryData.nInvalidVotes).toBe(0) }) + test("Ties, tiebreak order defined", () => { + // Tiebreak order defined, select lower + const candidates = ['Alice', 'Bob', 'Carol', 'Dave'] + const votes = [ + [1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4], + [2, 1, 3, 4], + [2, 1, 3, 4], + [2, 1, 3, 4], + ] + const results = RankedRobin(candidates, votes,1,['4','3','2','1']) + expect(results.elected[0].name).toBe('Bob') + expect(results.elected.length).toBe(1); + }) }) \ No newline at end of file diff --git a/backend/src/Tabulators/RankedRobin.ts b/backend/src/Tabulators/RankedRobin.ts index b53540b4..d520ae5d 100644 --- a/backend/src/Tabulators/RankedRobin.ts +++ b/backend/src/Tabulators/RankedRobin.ts @@ -1,14 +1,16 @@ import { ballot, candidate, rankedRobinResults, rankedRobinSummaryData, results, roundResults, summaryData, totalScore } from "./ITabulators"; import { IparsedData } from './ParseData' +import { sortByTieBreakOrder } from "./Star"; const ParseData = require("./ParseData"); -export function RankedRobin(candidates: string[], votes: ballot[], nWinners = 1, breakTiesRandomly = true) { +export function RankedRobin(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true) { // Determines Ranked Robin winners for given election // Parameters: // candidates: Array of candidate names // votes: Array of votes, size nVoters x Candidates // nWiners: Number of winners in election, defaulted to 1 + // 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 // Parse the votes for valid, invalid, and undervotes, and identifies bullet votes @@ -18,7 +20,7 @@ export function RankedRobin(candidates: string[], votes: ballot[], nWinners = 1, // total scores // score histograms // preference and pairwise matrices - const summaryData = getSummaryData(candidates, parsedData) + const summaryData = getSummaryData(candidates, parsedData, randomTiebreakOrder) // Initialize output data structure const results: rankedRobinResults = { @@ -71,8 +73,11 @@ function getRankedRobinBallotValidity(ballot: ballot) { return { isValid: true, isUnderVote: isUnderVote } } -function getSummaryData(candidates: string[], parsedData: IparsedData): rankedRobinSummaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:string[]): rankedRobinSummaryData { const nCandidates = candidates.length + if (randomTiebreakOrder.length < nCandidates) { + randomTiebreakOrder = candidates.map((c,index) => index.toString()) + } // Initialize summary data structures // Total scores for each candidate, includes candidate indexes for easier sorting const totalScores: totalScore[] = Array(nCandidates) @@ -134,7 +139,7 @@ function getSummaryData(candidates: string[], parsedData: IparsedData): rankedRo } } - const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate })) + const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: randomTiebreakOrder[index] })) return { candidates: candidatesWithIndexes, totalScores, @@ -206,7 +211,7 @@ function runRankedRobinRound(summaryData: rankedRobinSummaryData, remainingCandi } } if (breakTiesRandomly) { - const randomWinner = winners[getRandomInt(winners.length)] + const randomWinner = sortByTieBreakOrder(winners)[0] roundResults.winners.push(randomWinner) roundResults.logs.push(`${winners[0].name} picked in random tie-breaker, more robust tiebreaker not yet implemented.`) return roundResults @@ -257,10 +262,6 @@ function runScoreTiebreaker(summaryData: summaryData, scoreWinners: candidate[]) else return condorcetWinners } -function getRandomInt(max: number) { - return Math.floor(Math.random() * max); -} - function sortMatrix(matrix: number[][], order: number[]) { var newMatrix = Array(order.length); for (let i = 0; i < order.length; i++) { diff --git a/backend/src/Tabulators/Star.test.ts b/backend/src/Tabulators/Star.test.ts index 1c674759..a73bd2e2 100644 --- a/backend/src/Tabulators/Star.test.ts +++ b/backend/src/Tabulators/Star.test.ts @@ -16,7 +16,7 @@ describe("STAR Tests", () => { [4, 0, 5, 1], [3, 4, 5, 0], [3, 5, 5, 5]] - const results = Star(candidates, votes, 1, false, false) + const results = Star(candidates, votes, 1, [], false, false) expect(results.elected[0].name).toBe('Allison'); expect(results.roundResults[0].runner_up[0].name).toBe('Carmen'); }) @@ -33,7 +33,7 @@ describe("STAR Tests", () => { [4, 0, 5, 1], [3, 4, 5, 0], [3, 5, 5, 4]] - const results = Star(candidates, votes, 1, false, false) + const results = Star(candidates, votes, 1, [], false, false) expect(results.elected[0].name).toBe('Allison'); expect(results.roundResults[0].runner_up[0].name).toBe('Bill'); // expect(results.tied.length).toBe(2) @@ -46,7 +46,7 @@ describe("STAR Tests", () => { [0, 5], [2, 4], ] - const results = Star(candidates, votes, 1, false, false) + const results = Star(candidates, votes, 1, [], false, false) expect(results.elected[0].name).toBe('Bill'); expect(results.roundResults[0].runner_up[0].name).toBe('Allison'); }) @@ -57,7 +57,7 @@ describe("STAR Tests", () => { [0, 5], [3, 2], ] - const results = Star(candidates, votes, 1, false, false) + const results = Star(candidates, votes, 1, [], false, false) expect(results.elected[0].name).toBe('Bill'); expect(results.roundResults[0].runner_up[0].name).toBe('Allison'); }) @@ -68,7 +68,7 @@ describe("STAR Tests", () => { [2, 4], [5, 3], ] - const results = Star(candidates, votes, 1, false, true) + const results = Star(candidates, votes, 1, [], false, true) expect(results.elected[0].name).toBe('Allison'); expect(results.elected.length).toBe(1); expect(results.tied.length).toBe(0); @@ -80,12 +80,40 @@ describe("STAR Tests", () => { [1, 3], [4, 2], ] - const results = Star(candidates, votes, 1, false, true) + const results = Star(candidates, votes, 1, [], false, true) // No candidates elected expect(results.elected.length).toBe(0); // Two candidates marked as tied expect(results.tied.length).toBe(2); }) + test("True Tie, use five-star tiebreaker, still tied, select lower index", () => { + // Both candidates have same score and runoff votes, five star tiebreaker selected, candidates still tied + // Tie break order not defined, select winner based on index + const candidates = ['Allison', 'Bill'] + const votes = [ + [1, 3], + [4, 2], + ] + const results = Star(candidates, votes, 1, [], true, true) + // No candidates elected + expect(results.elected[0].name).toBe('Allison'); + expect(results.elected.length).toBe(1); + expect(results.tied.length).toBe(0); + }) + test("True Tie, use five-star tiebreaker, still tied, tiebreak order defined", () => { + // Both candidates have same score and runoff votes, five star tiebreaker selected, candidates still tied + // Tie break order defined, select lower + const candidates = ['Allison', 'Bill'] + const votes = [ + [1, 3], + [4, 2], + ] + const results = Star(candidates, votes, 1, ['Z','A'], true, true) + // No candidates elected + expect(results.elected[0].name).toBe('Bill'); + expect(results.elected.length).toBe(1); + expect(results.tied.length).toBe(0); + }) test("Test valid/invalid/under/bullet vote counts", () => { const candidates = ['Allison', 'Bill', 'Carmen'] const votes = [ @@ -100,7 +128,7 @@ describe("STAR Tests", () => { [0, 5, 0], [0, 0, 5], ] - const results = Star(candidates, votes, 1, false, false) + const results = Star(candidates, votes, 1, [], false, false) expect(results.summaryData.nValidVotes).toBe(8); expect(results.summaryData.nInvalidVotes).toBe(2); expect(results.summaryData.nUnderVotes).toBe(2); @@ -110,7 +138,7 @@ describe("STAR Tests", () => { function buildTestSummaryData(candidates: string[], scores: number[], pairwiseMatrix: number[][], fiveStarCounts: number[]) { return { - candidates: candidates.map((candidate, index) => ({ index: index, name: candidate })), + candidates: candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: index.toString() })), totalScores: scores.map((score, index) => ({ index, score })), scoreHist: fiveStarCounts.map(count => [0, 0, 0, 0, 0, count]), preferenceMatrix: pairwiseMatrix, diff --git a/backend/src/Tabulators/Star.ts b/backend/src/Tabulators/Star.ts index fc0f03e1..527a4e66 100644 --- a/backend/src/Tabulators/Star.ts +++ b/backend/src/Tabulators/Star.ts @@ -11,12 +11,13 @@ declare namespace Intl { // converts list of strings to string with correct grammar ([a,b,c] => 'a, b, and c') const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }); -export function Star(candidates: string[], votes: ballot[], nWinners = 1, breakTiesRandomly = true, enablefiveStarTiebreaker = true) { +export function Star(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true, enablefiveStarTiebreaker = true) { // Determines STAR winners for given election // Parameters: // candidates: Array of candidate names // votes: Array of votes, size nVoters x Candidates // nWiners: Number of winners in election, defaulted to 1 + // 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 // enablefiveStarTiebreaker: In the event of a true tie in the runoff round, should the five-star tiebreaker be used (select candidate with the most 5 star votes), defaulted to true @@ -27,7 +28,7 @@ export function Star(candidates: string[], votes: ballot[], nWinners = 1, breakT // total scores // score histograms // preference and pairwise matrices - const summaryData = getSummaryData(candidates, parsedData) + const summaryData = getSummaryData(candidates, parsedData,randomTiebreakOrder) // Initialize output data structure const results: results = { @@ -65,8 +66,11 @@ export function Star(candidates: string[], votes: ballot[], nWinners = 1, breakT return results } -function getSummaryData(candidates: string[], parsedData: IparsedData): summaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: string[]): summaryData { const nCandidates = candidates.length + if (randomTiebreakOrder.length < nCandidates) { + randomTiebreakOrder = candidates.map((c,index) => index.toString()) + } // Initialize summary data structures // Total scores for each candidate, includes candidate indexes for easier sorting const totalScores: totalScore[] = Array(nCandidates) @@ -122,7 +126,7 @@ function getSummaryData(candidates: string[], parsedData: IparsedData): summaryD } } - const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate })) + const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: randomTiebreakOrder[index]})) return { candidates: candidatesWithIndexes, totalScores, @@ -244,7 +248,7 @@ export function runStarRound(summaryData: summaryData, remainingCandidates: cand // True tie. Break tie randomly or mark all as tied. if (breakTiesRandomly) { // Random tiebreaker enabled, selects a candidate at random - const randomWinner = tiedCandidates[getRandomInt(tiedCandidates.length)] + const randomWinner = sortByTieBreakOrder(tiedCandidates)[0] roundResults.logs.push(`${randomWinner.name} wins random tiebreaker and advances to the runoff round.`) finalists.push(randomWinner) continue scoreLoop @@ -316,10 +320,10 @@ export function runStarRound(summaryData: summaryData, remainingCandidates: cand } if (breakTiesRandomly) { // Break tie randomly - const randomWinner = getRandomInt(2) - roundResults.winners = [finalists[randomWinner]] - roundResults.runner_up = [finalists[1 - randomWinner]] - roundResults.logs.push(`${finalists[randomWinner].name} defeats ${finalists[1 - randomWinner].name} in random tiebreaker.`) + const sortedCandidates = sortByTieBreakOrder(finalists) + roundResults.winners = [sortedCandidates[0]] + roundResults.runner_up = [sortedCandidates[1]] + roundResults.logs.push(`${sortedCandidates[0].name} defeats ${sortedCandidates[1].name} in random tiebreaker.`) return roundResults } // Tie could not be resolved, select both tied candidates as winners of round @@ -362,8 +366,11 @@ function runRunoffTiebreaker(summaryData: summaryData, runoffCandidates: candida return null } -function getRandomInt(max: number) { - return Math.floor(Math.random() * max); +export function sortByTieBreakOrder(candidates: candidate[]) { + return candidates.sort((a,b) => { + if (a.tieBreakOrder < b.tieBreakOrder) return -1 + else return 1 + }) } function sortMatrix(matrix: number[][], order: number[]) { From 9fb4a312a641321da3a100849128ec661efd5ffe Mon Sep 17 00:00:00 2001 From: eznarf <41272412+eznarf@users.noreply.github.com> Date: Sun, 12 Nov 2023 16:15:55 -0800 Subject: [PATCH 2/2] Uses election id and #ballots for random seed instead --- backend/package-lock.json | 11 +++++++++++ backend/package.json | 1 + .../src/Controllers/getElectionResultsController.ts | 6 ++++-- backend/src/Tabulators/AllocatedScore.test.ts | 2 +- backend/src/Tabulators/AllocatedScore.ts | 8 ++++---- backend/src/Tabulators/Approval.test.ts | 2 +- backend/src/Tabulators/Approval.ts | 6 +++--- backend/src/Tabulators/IRV.ts | 6 +++--- backend/src/Tabulators/ITabulators.ts | 2 +- backend/src/Tabulators/Plurality.test.ts | 2 +- backend/src/Tabulators/Plurality.ts | 6 +++--- backend/src/Tabulators/RankedRobin.test.ts | 2 +- backend/src/Tabulators/RankedRobin.ts | 8 ++++---- backend/src/Tabulators/Star.test.ts | 4 ++-- backend/src/Tabulators/Star.ts | 8 ++++---- 15 files changed, 44 insertions(+), 30 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 020285b9..8912f7e3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -36,6 +36,7 @@ "pg-boss": "^8.0.0", "pg-format": "^1.0.4", "qs": "^6.10.3", + "seedrandom": "^3.0.5", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, @@ -5940,6 +5941,11 @@ "node": ">=10" } }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "node_modules/semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -11654,6 +11660,11 @@ "xmlchars": "^2.2.0" } }, + "seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", diff --git a/backend/package.json b/backend/package.json index 63f6b661..6b8d995c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,6 +41,7 @@ "pg-boss": "^8.0.0", "pg-format": "^1.0.4", "qs": "^6.10.3", + "seedrandom": "^3.0.5", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, diff --git a/backend/src/Controllers/getElectionResultsController.ts b/backend/src/Controllers/getElectionResultsController.ts index 6ac87cce..5b3e70e6 100644 --- a/backend/src/Controllers/getElectionResultsController.ts +++ b/backend/src/Controllers/getElectionResultsController.ts @@ -8,6 +8,7 @@ import { permissions } from '../../../domain_model/permissions'; import { VotingMethods } from '../Tabulators/VotingMethodSelecter'; import { IElectionRequest } from "../IRequest"; import { Response, NextFunction } from 'express'; +var seedrandom = require('seedrandom'); const BallotModel = ServiceLocator.ballotsDb(); @@ -48,8 +49,9 @@ const getElectionResults = async (req: IElectionRequest, res: Response, next: Ne } const msg = `Tabulating results for ${voting_method} election` Logger.info(req, msg); - const candidateIDs = election.races[race_index].candidates.map((Candidate) => (Candidate.candidate_id)) - results[race_index] = VotingMethods[voting_method](candidateNames, cvr, num_winners, candidateIDs) + let rng = seedrandom(election.election_id + ballots.length.toString()) + const tieBreakOrders = election.races[race_index].candidates.map((Candidate) => (rng() as number)) + results[race_index] = VotingMethods[voting_method](candidateNames, cvr, num_winners, tieBreakOrders) } res.json( diff --git a/backend/src/Tabulators/AllocatedScore.test.ts b/backend/src/Tabulators/AllocatedScore.test.ts index bd18b249..5383c0e4 100644 --- a/backend/src/Tabulators/AllocatedScore.test.ts +++ b/backend/src/Tabulators/AllocatedScore.test.ts @@ -112,7 +112,7 @@ describe("Allocated Score Tests", () => { [0, 0, 4, 5], [0, 0, 4, 5], [0, 0, 4, 5]] - const results = AllocatedScore(candidates, votes, 2, ['4','3','2','1'], true, false) + const results = AllocatedScore(candidates, votes, 2, [4,3,2,1], true, false) expect(results.elected.length).toBe(2); expect(results.tied[0].length).toBe(2); // two candidates tied in forst round expect(results.elected[0].name).toBe('Bill') // random tiebreaker, second place lower index 1 diff --git a/backend/src/Tabulators/AllocatedScore.ts b/backend/src/Tabulators/AllocatedScore.ts index 12950742..11b0c25e 100644 --- a/backend/src/Tabulators/AllocatedScore.ts +++ b/backend/src/Tabulators/AllocatedScore.ts @@ -23,13 +23,13 @@ interface winner_scores { type ballotFrac = Fraction[] -export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = 3, randomTiebreakOrder: string[] = [], breakTiesRandomly = true, enablefiveStarTiebreaker = true) { +export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = 3, randomTiebreakOrder: number[] = [], breakTiesRandomly = true, enablefiveStarTiebreaker = true) { // Determines STAR-PR winners for given election using Allocated Score // Parameters: // candidates: Array of candidate names // votes: Array of votes, size nVoters x Candidates // nWiners: Number of winners in election, defaulted to 3 - // randomTiebreakOrder: Array to determine tiebreak order, uses strings to allow comparing UUIDs. If empty or not same length as candidates, set to candidate indexes + // randomTiebreakOrder: Array to determine tiebreak order. 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 // enablefiveStarTiebreaker: In the event of a true tie in the runoff round, should the five-star tiebreaker be used (select candidate with the most 5 star votes), defaulted to true // Parse the votes for valid, invalid, and undervotes, and identifies bullet votes @@ -160,10 +160,10 @@ export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = return results } -function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: string[]): allocatedScoreSummaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: number[]): allocatedScoreSummaryData { const nCandidates = candidates.length if (randomTiebreakOrder.length < nCandidates) { - randomTiebreakOrder = candidates.map((c,index) => index.toString()) + randomTiebreakOrder = candidates.map((c,index) => index) } // Initialize summary data structures // Total scores for each candidate, includes candidate indexes for easier sorting diff --git a/backend/src/Tabulators/Approval.test.ts b/backend/src/Tabulators/Approval.test.ts index 44c26c4c..9d3c91e6 100644 --- a/backend/src/Tabulators/Approval.test.ts +++ b/backend/src/Tabulators/Approval.test.ts @@ -109,7 +109,7 @@ describe("Approval Tests", () => { [0, 1, 1, 1], [0, 0, 0, 1], ] - const results = Approval(candidates, votes, 1, ['4','3','2','1']) + const results = Approval(candidates, votes, 1, [4,3,2,1]) expect(results.elected.length).toBe(1); expect(results.elected[0].name).toBe('Dave'); expect(results.summaryData.totalScores[0].score).toBe(7) diff --git a/backend/src/Tabulators/Approval.ts b/backend/src/Tabulators/Approval.ts index b4b9dce0..9b154fb0 100644 --- a/backend/src/Tabulators/Approval.ts +++ b/backend/src/Tabulators/Approval.ts @@ -3,7 +3,7 @@ import { approvalResults, approvalSummaryData, ballot, candidate, totalScore } f import { IparsedData } from './ParseData' const ParseData = require("./ParseData"); -export function Approval(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true) { +export function Approval(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:number[] = [], breakTiesRandomly = true) { const parsedData = ParseData(votes, getApprovalBallotValidity) const summaryData = getSummaryData(candidates, parsedData, randomTiebreakOrder) const results: approvalResults = { @@ -47,11 +47,11 @@ export function Approval(candidates: string[], votes: ballot[], nWinners = 1, ra return results; } -function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:string[]): approvalSummaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:number[]): approvalSummaryData { // Initialize summary data structures const nCandidates = candidates.length if (randomTiebreakOrder.length < nCandidates) { - randomTiebreakOrder = candidates.map((c,index) => index.toString()) + randomTiebreakOrder = candidates.map((c,index) => index) } const totalScores = Array(nCandidates) for (let i = 0; i < nCandidates; i++) { diff --git a/backend/src/Tabulators/IRV.ts b/backend/src/Tabulators/IRV.ts index 811820e3..e59581ef 100644 --- a/backend/src/Tabulators/IRV.ts +++ b/backend/src/Tabulators/IRV.ts @@ -3,7 +3,7 @@ import { ballot, candidate, irvResults, irvSummaryData, totalScore } from "./ITa import { IparsedData } from './ParseData' const ParseData = require("./ParseData"); -export function IRV(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true) { +export function IRV(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:number[] = [], breakTiesRandomly = true) { // Determines Instant Runoff winners for given election // Parameters: // candidates: Array of candidate names @@ -124,10 +124,10 @@ function getIRVBallotValidity(ballot: ballot) { return { isValid: true, isUnderVote: isUnderVote } } -function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:string[]): irvSummaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:number[]): irvSummaryData { const nCandidates = candidates.length if (randomTiebreakOrder.length < nCandidates) { - randomTiebreakOrder = candidates.map((c,index) => index.toString()) + randomTiebreakOrder = candidates.map((c,index) => index) } // Initialize summary data structures // Total scores for each candidate, includes candidate indexes for easier sorting diff --git a/backend/src/Tabulators/ITabulators.ts b/backend/src/Tabulators/ITabulators.ts index fca90ed5..8efeb071 100644 --- a/backend/src/Tabulators/ITabulators.ts +++ b/backend/src/Tabulators/ITabulators.ts @@ -7,7 +7,7 @@ export type ballots = ballot[] export interface candidate { index: number, name: string, - tieBreakOrder: string, + tieBreakOrder: number, } export interface voter { diff --git a/backend/src/Tabulators/Plurality.test.ts b/backend/src/Tabulators/Plurality.test.ts index 914da558..08b02406 100644 --- a/backend/src/Tabulators/Plurality.test.ts +++ b/backend/src/Tabulators/Plurality.test.ts @@ -125,7 +125,7 @@ describe("Plurality Tests", () => { [0, 0, 0, 1], [0, 0, 0, 1], ] - const results = Plurality(candidates, votes, 1,['4','3','2','1']) + const results = Plurality(candidates, votes, 1,[4,3,2,1]) expect(results.elected.length).toBe(1); expect(results.elected[0].name).toBe('Dave'); expect(results.summaryData.totalScores[0].score).toBe(5) diff --git a/backend/src/Tabulators/Plurality.ts b/backend/src/Tabulators/Plurality.ts index 9c5c4f3f..782a0bb2 100644 --- a/backend/src/Tabulators/Plurality.ts +++ b/backend/src/Tabulators/Plurality.ts @@ -3,7 +3,7 @@ import { approvalResults, approvalSummaryData, ballot, candidate, pluralityResul import { IparsedData } from './ParseData' const ParseData = require("./ParseData"); -export function Plurality(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true) { +export function Plurality(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:number[] = [], breakTiesRandomly = true) { const parsedData = ParseData(votes, getPluralityBallotValidity) const summaryData = getSummaryData(candidates, parsedData, randomTiebreakOrder) const results: pluralityResults = { @@ -46,11 +46,11 @@ export function Plurality(candidates: string[], votes: ballot[], nWinners = 1, r return results; } -function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: string[]): pluralitySummaryData{ +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: number[]): pluralitySummaryData{ // Initialize summary data structures const nCandidates = candidates.length if (randomTiebreakOrder.length < nCandidates) { - randomTiebreakOrder = candidates.map((c,index) => index.toString()) + randomTiebreakOrder = candidates.map((c,index) => index) } const totalScores = Array(nCandidates) for (let i = 0; i < nCandidates; i++) { diff --git a/backend/src/Tabulators/RankedRobin.test.ts b/backend/src/Tabulators/RankedRobin.test.ts index cd2ffed5..d36e96d0 100644 --- a/backend/src/Tabulators/RankedRobin.test.ts +++ b/backend/src/Tabulators/RankedRobin.test.ts @@ -115,7 +115,7 @@ describe("Ranked Robin Tests", () => { [2, 1, 3, 4], [2, 1, 3, 4], ] - const results = RankedRobin(candidates, votes,1,['4','3','2','1']) + const results = RankedRobin(candidates, votes,1,[4,3,2,1]) expect(results.elected[0].name).toBe('Bob') expect(results.elected.length).toBe(1); }) diff --git a/backend/src/Tabulators/RankedRobin.ts b/backend/src/Tabulators/RankedRobin.ts index d520ae5d..864100fe 100644 --- a/backend/src/Tabulators/RankedRobin.ts +++ b/backend/src/Tabulators/RankedRobin.ts @@ -4,13 +4,13 @@ import { IparsedData } from './ParseData' import { sortByTieBreakOrder } from "./Star"; const ParseData = require("./ParseData"); -export function RankedRobin(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true) { +export function RankedRobin(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:number[] = [], breakTiesRandomly = true) { // Determines Ranked Robin winners for given election // Parameters: // candidates: Array of candidate names // votes: Array of votes, size nVoters x Candidates // nWiners: Number of winners in election, defaulted to 1 - // randomTiebreakOrder: Array to determine tiebreak order, uses strings to allow comparing UUIDs. If empty or not same length as candidates, set to candidate indexes + // randomTiebreakOrder: Array to determine tiebreak order. 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 // Parse the votes for valid, invalid, and undervotes, and identifies bullet votes @@ -73,10 +73,10 @@ function getRankedRobinBallotValidity(ballot: ballot) { return { isValid: true, isUnderVote: isUnderVote } } -function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:string[]): rankedRobinSummaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder:number[]): rankedRobinSummaryData { const nCandidates = candidates.length if (randomTiebreakOrder.length < nCandidates) { - randomTiebreakOrder = candidates.map((c,index) => index.toString()) + randomTiebreakOrder = candidates.map((c,index) => index) } // Initialize summary data structures // Total scores for each candidate, includes candidate indexes for easier sorting diff --git a/backend/src/Tabulators/Star.test.ts b/backend/src/Tabulators/Star.test.ts index a73bd2e2..30b02fa3 100644 --- a/backend/src/Tabulators/Star.test.ts +++ b/backend/src/Tabulators/Star.test.ts @@ -108,7 +108,7 @@ describe("STAR Tests", () => { [1, 3], [4, 2], ] - const results = Star(candidates, votes, 1, ['Z','A'], true, true) + const results = Star(candidates, votes, 1, [2,1], true, true) // No candidates elected expect(results.elected[0].name).toBe('Bill'); expect(results.elected.length).toBe(1); @@ -138,7 +138,7 @@ describe("STAR Tests", () => { function buildTestSummaryData(candidates: string[], scores: number[], pairwiseMatrix: number[][], fiveStarCounts: number[]) { return { - candidates: candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: index.toString() })), + candidates: candidates.map((candidate, index) => ({ index: index, name: candidate, tieBreakOrder: index })), totalScores: scores.map((score, index) => ({ index, score })), scoreHist: fiveStarCounts.map(count => [0, 0, 0, 0, 0, count]), preferenceMatrix: pairwiseMatrix, diff --git a/backend/src/Tabulators/Star.ts b/backend/src/Tabulators/Star.ts index 527a4e66..4e148728 100644 --- a/backend/src/Tabulators/Star.ts +++ b/backend/src/Tabulators/Star.ts @@ -11,13 +11,13 @@ declare namespace Intl { // converts list of strings to string with correct grammar ([a,b,c] => 'a, b, and c') const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }); -export function Star(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:string[] = [], breakTiesRandomly = true, enablefiveStarTiebreaker = true) { +export function Star(candidates: string[], votes: ballot[], nWinners = 1, randomTiebreakOrder:number[] = [], breakTiesRandomly = true, enablefiveStarTiebreaker = true) { // Determines STAR winners for given election // Parameters: // candidates: Array of candidate names // votes: Array of votes, size nVoters x Candidates // nWiners: Number of winners in election, defaulted to 1 - // randomTiebreakOrder: Array to determine tiebreak order, uses strings to allow comparing UUIDs. If empty or not same length as candidates, set to candidate indexes + // randomTiebreakOrder: Array to determine tiebreak order. 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 // enablefiveStarTiebreaker: In the event of a true tie in the runoff round, should the five-star tiebreaker be used (select candidate with the most 5 star votes), defaulted to true @@ -66,10 +66,10 @@ export function Star(candidates: string[], votes: ballot[], nWinners = 1, random return results } -function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: string[]): summaryData { +function getSummaryData(candidates: string[], parsedData: IparsedData, randomTiebreakOrder: number[]): summaryData { const nCandidates = candidates.length if (randomTiebreakOrder.length < nCandidates) { - randomTiebreakOrder = candidates.map((c,index) => index.toString()) + randomTiebreakOrder = candidates.map((c,index) => index) } // Initialize summary data structures // Total scores for each candidate, includes candidate indexes for easier sorting