Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding tiebreaker order #379

Merged
merged 2 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised the base math class didn't support seeding, weird

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea its frustrating. That's why I didn't go with seeds before, but I found a library that does it.

"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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was initially confused here since rng() outputs floats, but the test cases work with lists of unique integers. But it looks like it'll work fine. I guess it's technically possible for rng() to output the same number twice? but I'm not worried about that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not worried about duplicate floats. And I think in JS integers are just treated as floats.

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