Skip to content

Commit

Permalink
Merge pull request #379 from mikefranze/random-tiebreakers
Browse files Browse the repository at this point in the history
Adding tiebreaker order
  • Loading branch information
mikefranze authored Nov 14, 2023
2 parents 2c3284d + 9fb4a31 commit c987c5c
Show file tree
Hide file tree
Showing 15 changed files with 243 additions and 87 deletions.
11 changes: 11 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
6 changes: 5 additions & 1 deletion backend/src/Controllers/getElectionResultsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -48,8 +49,11 @@ 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)
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(
{
Election: election,
Expand Down
37 changes: 30 additions & 7 deletions backend/src/Tabulators/AllocatedScore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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],
Expand All @@ -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');
})

Expand All @@ -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);
Expand Down
35 changes: 16 additions & 19 deletions backend/src/Tabulators/AllocatedScore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: 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. 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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: number[]): allocatedScoreSummaryData {
const nCandidates = candidates.length
if (randomTiebreakOrder.length < nCandidates) {
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 @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 33 additions & 2 deletions backend/src/Tabulators/Approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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)

Expand Down
18 changes: 9 additions & 9 deletions backend/src/Tabulators/Approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:number[] = [], breakTiesRandomly = true) {
const parsedData = ParseData(votes, getApprovalBallotValidity)
const summaryData = getSummaryData(candidates, parsedData)
const summaryData = getSummaryData(candidates, parsedData, randomTiebreakOrder)
const results: approvalResults = {
elected: [],
tied: [],
Expand All @@ -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]
Expand Down Expand Up @@ -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:number[]): approvalSummaryData {
// Initialize summary data structures
const nCandidates = candidates.length
if (randomTiebreakOrder.length < nCandidates) {
randomTiebreakOrder = candidates.map((c,index) => index)
}
const totalScores = Array(nCandidates)
for (let i = 0; i < nCandidates; i++) {
totalScores[i] = { index: i, score: 0 };
Expand All @@ -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,
Expand All @@ -91,8 +95,4 @@ function getApprovalBallotValidity(ballot: ballot) {
}
}
return { isValid: true, isUnderVote: isUnderVote }
}

function getRandomInt(max: number) {
return Math.floor(Math.random() * max);
}
Loading

0 comments on commit c987c5c

Please sign in to comment.