From 8a291eb8603ef41ffbaf54a899e4790e5a7d2622 Mon Sep 17 00:00:00 2001 From: eznarf <41272412+eznarf@users.noreply.github.com> Date: Sun, 21 May 2023 21:09:09 -0700 Subject: [PATCH 1/4] Adding Typescript version of STAR-PR with tests --- backend/src/Tabulators/AllocatedScore.js | 371 ------------------ backend/src/Tabulators/AllocatedScore.test.ts | 92 +++++ backend/src/Tabulators/AllocatedScore.ts | 369 +++++++++++++++++ backend/src/Tabulators/ITabulators.ts | 23 ++ .../src/Tabulators/VotingMethodSelecter.ts | 4 +- .../components/Election/Results/Results.js | 39 +- 6 files changed, 508 insertions(+), 390 deletions(-) delete mode 100644 backend/src/Tabulators/AllocatedScore.js create mode 100644 backend/src/Tabulators/AllocatedScore.test.ts create mode 100644 backend/src/Tabulators/AllocatedScore.ts diff --git a/backend/src/Tabulators/AllocatedScore.js b/backend/src/Tabulators/AllocatedScore.js deleted file mode 100644 index f74a9027..00000000 --- a/backend/src/Tabulators/AllocatedScore.js +++ /dev/null @@ -1,371 +0,0 @@ -const minScore = 0; -const maxScore = 5; - -function parseData(header, data, nWinners) { - // Inspect the data to determine the type of data in each column - const transforms = getTransforms(header, data); - - // The list of candidates is based on the columns containing STAR scores - const candidates = []; - for (let i = 0; i < transforms.length; i++) { - if (transforms[i] === transformScore) { - const candidate = { - name: transformAny(header[i]), - index: candidates.length, - csvColumn: i, - totalScore: 0, - support: new Array(6).fill(0) - }; - candidates.push(candidate); - } - } - - // Initialize arrays - const scores = Array(candidates.length); - for (let i = 0; i < candidates.length; i++) { - scores[i] = []; - } - const voters = []; - const undervotes = []; - const bulletvotes = []; - - // Parse each row of data into voter, undervote, and score arrays - data.forEach((row, n) => { - const voter = { csvRow: n + 1 }; - const score = []; - let total = 0; - let hasData = false; - let candidatesSupported = 0; - header.forEach((col, i) => { - const value = transforms[i](row[i]); - if (row[i] !== null && row[i] !== "") { - hasData = true; - } - if (transforms[i] === transformScore) { - score.push(value); - total += value; - if (value > 0) { - candidatesSupported++; - } - } else { - voter[col] = value; - } - }); - - // Check for blank lines and undervote - if (hasData) { - if (total > 0) { - for (let i = 0; i < score.length; i++) { - scores[i].push(score[i]); - } - voters.push(voter); - if (candidatesSupported === 1) { - bulletvotes.push(voters.length - 1); - } - } else { - undervotes.push(voter); - } - } - }); - - - const prResults = splitPR([...candidates], scores, nWinners); - - return { - header, - data, - candidates, - scores, - voters, - undervotes, - bulletvotes, - prResults - }; -} - -// Format a Timestamp value into a compact string for display; -function formatTimestamp(value) { - const d = new Date(Date.parse(value)); - const month = d.getMonth() + 1; - const date = d.getDate(); - const year = d.getFullYear(); - const currentYear = new Date().getFullYear(); - const hour = d.getHours(); - const minute = d.getMinutes(); - - const fullDate = - year === currentYear - ? `${month}/${date}` - : year >= 2000 && year < 2100 - ? `${month}/${date}/${year - 2000}` - : `${month}/${date}/${year}`; - - const timeStamp = `${fullDate} ${hour}:${minute}`; - return timeStamp; -} - -// Functions to parse STAR scores -const isScore = (value) => - !isNaN(value) && (value === null || (value > -10 && value < 10)); -const transformScore = (value) => - value ? Math.min(maxScore, Math.max(minScore, value)) : 0; - -// Functions to parse Timestamps -const isTimestamp = (value) => !isNaN(Date.parse(value)); -const transformTimestamp = (value) => formatTimestamp(value); - -// Functions to parse everything else -const isAny = (value) => true; -const transformAny = (value) => (value ? value.toString().trim() : ""); - -// Column types to recognize in Cast Vote Records passed as CSV data -const columnTypes = [ - { test: isScore, transform: transformScore }, - { test: isTimestamp, transform: transformTimestamp }, - // Last row MUST accept anything! - { test: isAny, transform: transformAny } -]; - -function getTransforms(header, data) { - const transforms = []; - const rowCount = Math.min(data.length, 3); - header.forEach((title, n) => { - var transformIndex = 0; - if (title === "Timestamp") { - transformIndex = 1; - } else { - for (let i = 0; i < rowCount; i++) { - const value = data[i][n]; - const index = columnTypes.findIndex((element) => element.test(value)); - if (index > transformIndex) { - transformIndex = index; - } - if (transformIndex >= columnTypes.length) { - break; - } - } - } - // We don't have to check for out-of-bound index because - // the last row in columnTypes accepts anything - transforms.push(columnTypes[transformIndex].transform); - }); - return transforms; -} - -function splitPR(candidates, scores, nWinners) { - // Handle degenerate edge cases - if (!candidates || candidates.length === 0) { - return { winners: [], losers: [], others: [] }; - } - var num_candidates = candidates.length - if (num_candidates === 1) { - return { winners: candidates, losers: [], others: [] }; - } - - // Normalize scores array - var scoresNorm = normalizeArray(scores, maxScore); - - // Find number of voters and quota size - const V = scoresNorm[0].length; - const quota = V / nWinners; - - var ballot_weights = Array(V).fill(1); - // Initialize output arrays - var winners = []; - var losers = []; - var others = []; - var debuginfo = { splitPoints: [], spentAboves: [], weight_on_splits: [] }; - var ties = []; - var weightedSumsByRound = [] - var candidatesByRound = [] - // run loop until specified number of winners are found - while (winners.length < nWinners) { - // weight the scores - var weighted_scores = Array(scoresNorm.length); - var weighted_sums = Array(scoresNorm.length); - scoresNorm.forEach((row, r) => { - weighted_scores[r] = []; - row.forEach((score, s) => { - weighted_scores[r][s] = score * ballot_weights[s]; - }); - // sum scores for each candidate - weighted_sums[r] = sumArray(weighted_scores[r]); - }); - weightedSumsByRound.push(weighted_sums) - candidatesByRound.push([...candidates]) - // get index of winner - var maxAndTies = indexOfMax(weighted_sums); - var w = maxAndTies.maxIndex; - var roundTies = []; - maxAndTies.ties.forEach((index, i) => { - roundTies.push(candidates[index]); - }); - ties.push(roundTies); - // add winner to winner list, remove from ballots - winners.push(candidates[w]); - scoresNorm.splice(w, 1); - candidates.splice(w, 1); - - // create arrays for sorting ballots - var cand_df = []; - var cand_df_sorted = []; - - weighted_scores[w].forEach((weighted_score, i) => { - cand_df.push({ - index: i, - ballot_weight: ballot_weights[i], - weighted_score: weighted_score - }); - cand_df_sorted.push({ - index: i, - ballot_weight: ballot_weights[i], - weighted_score: weighted_score - }); - }); - cand_df_sorted.sort((a, b) => - a.weighted_score < b.weighted_score ? 1 : -1 - ); - - var split_point = findSplitPoint(cand_df_sorted, quota); - - debuginfo.splitPoints.push(split_point); - - var spent_above = 0; - cand_df.forEach((c, i) => { - if (c.weighted_score > split_point) { - spent_above += c.ballot_weight; - } - }); - debuginfo.spentAboves.push(spent_above); - - if (spent_above > 0) { - cand_df.forEach((c, i) => { - if (c.weighted_score > split_point) { - cand_df[i].ballot_weight = 0; - } - }); - } - - var weight_on_split = findWeightOnSplit(cand_df, split_point); - - debuginfo.weight_on_splits.push(weight_on_split); - ballot_weights = updateBallotWeights( - cand_df, - ballot_weights, - weight_on_split, - quota, - spent_above, - split_point - ); - } - //Moving weighted sum totals to matrix for easier plotting - var weightedSumsData = [] - for (let c = 0; c < num_candidates; c++) { - weightedSumsData.push(Array(weightedSumsByRound.length).fill(0)) - } - weightedSumsByRound.map((weightedSums,i) => { - weightedSums.map((weightedSum,j) => { - let candidate = candidatesByRound[i][j] - weightedSumsData[candidate.index][i] = weightedSum*5 - }) - }) - losers = candidates; - return { winners, losers, others, ties, debuginfo,weightedSumsData }; -} - -function updateBallotWeights( - cand_df, - ballot_weights, - weight_on_split, - quota, - spent_above, - split_point -) { - if (weight_on_split > 0) { - var spent_value = (quota - spent_above) / weight_on_split; - cand_df.forEach((c, i) => { - if (c.weighted_score === split_point) { - cand_df[i].ballot_weight = cand_df[i].ballot_weight * (1 - spent_value); - } - }); - } - cand_df.forEach((c, i) => { - if (c.ballot_weight > 1) { - ballot_weights[i] = 1; - } else if (c.ballot_weight < 0) { - ballot_weights[i] = 0; - } else { - ballot_weights[i] = c.ballot_weight; - } - }); - - return ballot_weights; -} - -function findWeightOnSplit(cand_df, split_point) { - var weight_on_split = 0; - cand_df.forEach((c, i) => { - if (c.weighted_score === split_point) { - weight_on_split += c.ballot_weight; - } - }); - return weight_on_split; -} - -function indexOfMax(arr) { - if (arr.length === 0) { - return -1; - } - - var max = arr[0]; - var maxIndex = 0; - var ties = []; - for (var i = 1; i < arr.length; i++) { - if (arr[i] > max) { - maxIndex = i; - max = arr[i]; - ties = []; - } else if (arr[i] === max) { - ties.push(i); - } - } - - return { maxIndex, ties }; -} - -function sumArray(arr) { - return arr.reduce((a, b) => a + b, 0); -} - -function normalizeArray(scores, maxScore) { - // Normalize scores array - var scoresNorm = Array(scores.length); - scores.forEach((row, r) => { - scoresNorm[r] = []; - row.forEach((score, s) => { - scoresNorm[r][s] = score / maxScore; - }); - }); - return scoresNorm; -} - -function findSplitPoint(cand_df_sorted, quota) { - var under_quota = []; - var under_quota_scores = []; - var cumsum = 0; - cand_df_sorted.forEach((c, i) => { - cumsum += c.ballot_weight; - if (cumsum < quota) { - under_quota.push(c); - under_quota_scores.push(c.weighted_score); - } - }); - return Math.min(...under_quota_scores); -} - -/***************************** Public API *****************************/ - -module.exports = function StarResults(candidates, votes, nWinners = 1) { - const cvr = parseData(candidates, votes, nWinners); - return { cvr }; -} diff --git a/backend/src/Tabulators/AllocatedScore.test.ts b/backend/src/Tabulators/AllocatedScore.test.ts new file mode 100644 index 00000000..ca7992c0 --- /dev/null +++ b/backend/src/Tabulators/AllocatedScore.test.ts @@ -0,0 +1,92 @@ +import { AllocatedScore } from './AllocatedScore' + +describe("Allocated Score Tests", () => { + test("Basic Example", () => { + // Two winners, two main parties, Allison wins first round with highest score, 1 quota of voters are allocated reducing Bill's score to zero + // Carmen's score is reduced some causing Doug to win second + 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, 4, 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, false, false) + expect(results.elected.length).toBe(2); + expect(results.elected[0].name).toBe('Allison'); + expect(results.elected[1].name).toBe('Doug'); + expect(results.summaryData.weightedScoresByRound[0]).toStrictEqual([25, 24, 24, 23]); + expect(results.summaryData.weightedScoresByRound[1]).toStrictEqual([0, 0, 16, 23]); + }) + test("Fractional surplus", () => { + // Two winners, two main parties, Allison wins first round with highest score, Allison has 8 highest level supporters, more than the quota of 6 voters + // Voters who gave Allison their highest score have their ballot weight reduced to (1-6/8) = 0.25 + 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, 1, 0], + [5, 5, 1, 0], + [5, 5, 1, 0], + [5, 4, 4, 0], + [0, 0, 0, 3], + [0, 0, 4, 5], + [0, 0, 4, 5], + [0, 0, 4, 5]] + 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'); + expect(results.summaryData.weightedScoresByRound[0]).toStrictEqual([40, 39, 23, 18]); + expect(results.summaryData.weightedScoresByRound[1]).toStrictEqual([0, 9.75, 14.75, 18]); + }) + + test("Random Tiebreaker", () => { + // Two winners, two candidates tie for first, break tie randomly + 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, 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[1].name).toBe('Doug'); + }) + + test("Test valid/invalid/under/bullet vote counts", () => { + const candidates = ['Allison', 'Bill', 'Carmen'] + const votes = [ + [1, 3, 5], + [1, 3, 5], + [1, 3, 5], + [0, 0, 0], + [0, 0, 0], + [-1, 3, 5], + [0, 3, 6], + [5, 0, 0], + [0, 5, 0], + [0, 0, 5], + ] + 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); + expect(results.summaryData.nBulletVotes).toBe(3); + }) +}) diff --git a/backend/src/Tabulators/AllocatedScore.ts b/backend/src/Tabulators/AllocatedScore.ts new file mode 100644 index 00000000..a85d0739 --- /dev/null +++ b/backend/src/Tabulators/AllocatedScore.ts @@ -0,0 +1,369 @@ +import { ballot, candidate, fiveStarCount, allocatedScoreResults, allocatedScoreSummaryData, summaryData, totalScore } from "./ITabulators"; + +import { IparsedData } from './ParseData' +const ParseData = require("./ParseData"); +declare namespace Intl { + class ListFormat { + constructor(locales?: string | string[], options?: {}); + public format: (items: string[]) => string; + } +} +// 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' }); + +const minScore = 0; +const maxScore = 5; + +interface winner_scores { + index: number + ballot_weight: number, + weighted_score: number +} + +export function AllocatedScore(candidates: string[], votes: ballot[], nWinners = 3, 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 + // 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 + const parsedData: IparsedData = ParseData(votes) + + // Compress valid votes into data needed to run election including + // total scores + // score histograms + // preference and pairwise matrices + const summaryData = getSummaryData(candidates, parsedData) + + // Initialize output data structure + const results: allocatedScoreResults = { + elected: [], + tied: [], + other: [], + roundResults: [], + summaryData: summaryData, + } + var remainingCandidates = [...summaryData.candidates] + // Run election rounds until there are no remaining candidates + // Keep running elections rounds even if all seats have been filled to determine candidate order + + // Normalize scores array + var scoresNorm = normalizeArray(parsedData.scores, maxScore); + + // Find number of voters and quota size + const V = scoresNorm.length; + const quota = V / nWinners; + var num_candidates = candidates.length + + var ballot_weights: number[] = Array(V).fill(1); + + var ties = []; + // var weightedSumsByRound = [] + var candidatesByRound: candidate[][] = [] + // run loop until specified number of winners are found + while (results.elected.length < nWinners) { + // weight the scores + var weighted_scores: ballot[] = Array(scoresNorm.length); + var weighted_sums: number[] = Array(num_candidates).fill(0); + scoresNorm.forEach((ballot, b) => { + weighted_scores[b] = []; + ballot.forEach((score, s) => { + weighted_scores[b][s] = score * ballot_weights[b]; + weighted_sums[s] += weighted_scores[b][s] + }); + // sum scores for each candidate + // weighted_sums[r] = sumArray(weighted_scores[r]); + }); + summaryData.weightedScoresByRound.push(weighted_sums) + candidatesByRound.push([...remainingCandidates]) + // get index of winner + var maxAndTies = indexOfMax(weighted_sums, breakTiesRandomly); + var w = maxAndTies.maxIndex; + var roundTies: candidate[] = []; + maxAndTies.ties.forEach((index, i) => { + roundTies.push(summaryData.candidates[index]); + }); + results.tied.push(roundTies); + // add winner to winner list + results.elected.push(summaryData.candidates[w]); + // Set all scores for winner to zero + scoresNorm.forEach((ballot, b) => { + ballot[w] = 0 + }) + remainingCandidates = remainingCandidates.filter(c => c != summaryData.candidates[w]) + // remainingCandidates.splice(w, 1); + + // create arrays for sorting ballots + var cand_df: winner_scores[] = []; + var cand_df_sorted: winner_scores[] = []; + + weighted_scores.forEach((weighted_score, i) => { + cand_df.push({ + index: i, + ballot_weight: ballot_weights[i], + weighted_score: weighted_score[w] + }); + cand_df_sorted.push({ + index: i, + ballot_weight: ballot_weights[i], + weighted_score: weighted_score[w] + }); + }); + + cand_df_sorted.sort((a, b) => + a.weighted_score < b.weighted_score ? 1 : -1 + ); + + var split_point = findSplitPoint(cand_df_sorted, quota); + + summaryData.splitPoints.push(split_point); + + var spent_above = 0; + cand_df.forEach((c, i) => { + if (c.weighted_score > split_point) { + spent_above += c.ballot_weight; + } + }); + summaryData.spentAboves.push(spent_above); + + if (spent_above > 0) { + cand_df.forEach((c, i) => { + if (c.weighted_score > split_point) { + cand_df[i].ballot_weight = 0; + } + }); + } + + var weight_on_split = findWeightOnSplit(cand_df, split_point); + + summaryData.weight_on_splits.push(weight_on_split); + ballot_weights = updateBallotWeights( + cand_df, + ballot_weights, + weight_on_split, + quota, + spent_above, + split_point + ); + } + + for (let i = 0; i < summaryData.weightedScoresByRound.length; i++) { + for (let j = 0; j < summaryData.weightedScoresByRound[i].length; j++) { + summaryData.weightedScoresByRound[i][j] *= maxScore + } + } + + results.other = remainingCandidates; + + return results +} + +function getSummaryData(candidates: string[], parsedData: IparsedData): allocatedScoreSummaryData { + const nCandidates = candidates.length + // Initialize summary data structures + // Total scores for each candidate, includes candidate indexes for easier sorting + const totalScores: totalScore[] = Array(nCandidates) + for (let i = 0; i < nCandidates; i++) { + totalScores[i] = { index: i, score: 0 }; + } + + // Score histograms for data analysis and five-star tiebreakers + const scoreHist: number[][] = Array(nCandidates); + for (let i = 0; i < nCandidates; i++) { + scoreHist[i] = Array(6).fill(0); + } + + // Matrix for voter preferences + const preferenceMatrix: number[][] = Array(nCandidates); + const pairwiseMatrix: number[][] = Array(nCandidates); + for (let i = 0; i < nCandidates; i++) { + preferenceMatrix[i] = Array(nCandidates).fill(0); + pairwiseMatrix[i] = Array(nCandidates).fill(0); + } + let nBulletVotes = 0 + + // Iterate through ballots and populate data structures + parsedData.scores.forEach((vote) => { + let nSupported = 0 + for (let i = 0; i < nCandidates; i++) { + totalScores[i].score += vote[i] + scoreHist[i][vote[i]] += 1 + for (let j = 0; j < nCandidates; j++) { + if (i !== j) { + if (vote[i] > vote[j]) { + preferenceMatrix[i][j] += 1 + } + } + } + if (vote[i] > 0) { + nSupported += 1 + } + } + if (nSupported === 1) { + nBulletVotes += 1 + } + }) + + for (let i = 0; i < nCandidates; i++) { + for (let j = 0; j < nCandidates; j++) { + if (preferenceMatrix[i][j] > preferenceMatrix[j][i]) { + pairwiseMatrix[i][j] = 1 + } + else if (preferenceMatrix[i][j] < preferenceMatrix[j][i]) { + pairwiseMatrix[j][i] = 1 + } + + } + } + const candidatesWithIndexes: candidate[] = candidates.map((candidate, index) => ({ index: index, name: candidate })) + return { + candidates: candidatesWithIndexes, + totalScores, + scoreHist, + preferenceMatrix, + pairwiseMatrix, + nValidVotes: parsedData.validVotes.length, + nInvalidVotes: parsedData.invalidVotes.length, + nUnderVotes: parsedData.underVotes, + nBulletVotes: nBulletVotes, + splitPoints: [], + spentAboves: [], + weight_on_splits: [], + weightedScoresByRound: [] + } +} + +function sortData(summaryData: allocatedScoreSummaryData, order: candidate[]): allocatedScoreSummaryData { + // sorts summary data to be in specified order + const indexOrder = order.map(c => c.index) + const candidates = indexOrder.map(ind => (summaryData.candidates[ind])) + candidates.forEach((c, i) => { + c.index = i + }) + const totalScores = indexOrder.map((ind, i) => ({ index: i, score: summaryData.totalScores[ind].score })) + const scoreHist = indexOrder.map((ind) => summaryData.scoreHist[ind]) + const preferenceMatrix = sortMatrix(summaryData.preferenceMatrix, indexOrder) + const pairwiseMatrix = sortMatrix(summaryData.pairwiseMatrix, indexOrder) + return { + candidates, + totalScores, + scoreHist, + preferenceMatrix, + pairwiseMatrix, + nValidVotes: summaryData.nValidVotes, + nInvalidVotes: summaryData.nInvalidVotes, + nUnderVotes: summaryData.nUnderVotes, + nBulletVotes: summaryData.nBulletVotes, + splitPoints: summaryData.splitPoints, + spentAboves: summaryData.spentAboves, + weight_on_splits: summaryData.weight_on_splits, + weightedScoresByRound: summaryData.weightedScoresByRound, + } +} + +function updateBallotWeights( + cand_df: winner_scores[], + ballot_weights: number[], + weight_on_split: number, + quota: number, + spent_above: number, + split_point: number +) { + if (weight_on_split > 0) { + var spent_value = (quota - spent_above) / weight_on_split; + cand_df.forEach((c, i) => { + if (c.weighted_score === split_point) { + cand_df[i].ballot_weight = cand_df[i].ballot_weight * (1 - spent_value); + } + }); + } + cand_df.forEach((c, i) => { + if (c.ballot_weight > 1) { + ballot_weights[i] = 1; + } else if (c.ballot_weight < 0) { + ballot_weights[i] = 0; + } else { + ballot_weights[i] = c.ballot_weight; + } + }); + + return ballot_weights; +} + +function findWeightOnSplit(cand_df: winner_scores[], split_point: number) { + var weight_on_split = 0; + cand_df.forEach((c, i) => { + if (c.weighted_score === split_point) { + weight_on_split += c.ballot_weight; + } + }); + return weight_on_split; +} + +function indexOfMax(arr: number[], breakTiesRandomly: boolean) { + if (arr.length === 0) { + return { maxIndex: -1, ties: [] }; + } + + var max = arr[0]; + var maxIndex = 0; + var ties: number[] = [0]; + for (var i = 1; i < arr.length; i++) { + if (arr[i] > max) { + maxIndex = i; + max = arr[i]; + ties = [i]; + } else if (arr[i] === max) { + ties.push(i); + } + } + if (breakTiesRandomly && ties.length > 1) { + maxIndex = ties[getRandomInt(ties.length)] + } + return { maxIndex, ties }; +} + +function getRandomInt(max: number) { + return Math.floor(Math.random() * max); +} + +function normalizeArray(scores: ballot[], maxScore: number) { + // Normalize scores array + var scoresNorm: ballot[] = Array(scores.length); + scores.forEach((row, r) => { + scoresNorm[r] = []; + row.forEach((score, s) => { + scoresNorm[r][s] = score / maxScore; + }); + }); + return scoresNorm; +} + +function findSplitPoint(cand_df_sorted: winner_scores[], quota: number) { + var under_quota = []; + var under_quota_scores: number[] = []; + var cumsum = 0; + cand_df_sorted.forEach((c, i) => { + cumsum += c.ballot_weight; + if (cumsum < quota) { + under_quota.push(c); + under_quota_scores.push(c.weighted_score); + } + }); + return Math.min(...under_quota_scores); +} + +function sortMatrix(matrix: number[][], order: number[]) { + var newMatrix: number[][] = Array(order.length); + for (let i = 0; i < order.length; i++) { + newMatrix[i] = Array(order.length).fill(0); + } + order.forEach((i, iInd) => { + order.forEach((j, jInd) => { + newMatrix[iInd][jInd] = matrix[i][j]; + }); + }); + return newMatrix +} \ No newline at end of file diff --git a/backend/src/Tabulators/ITabulators.ts b/backend/src/Tabulators/ITabulators.ts index f0ed4b45..72f309ee 100644 --- a/backend/src/Tabulators/ITabulators.ts +++ b/backend/src/Tabulators/ITabulators.ts @@ -32,6 +32,22 @@ export interface summaryData { nUnderVotes: number, nBulletVotes: number } + +export interface allocatedScoreSummaryData { + candidates: candidate[], + totalScores: totalScore[], + scoreHist: number[][], + preferenceMatrix: number[][], + pairwiseMatrix: number[][], + nValidVotes: number, + nInvalidVotes: number, + nUnderVotes: number, + nBulletVotes: number, + splitPoints: number[], + spentAboves: number[], + weight_on_splits: number[], + weightedScoresByRound: number[][] +} export interface approvalSummaryData { candidates: candidate[], totalScores: totalScore[], @@ -83,6 +99,13 @@ export interface results { roundResults: roundResults[], summaryData: summaryData, } +export interface allocatedScoreResults { + elected: candidate[], + tied: candidate[][], + other: candidate[], + roundResults: roundResults[], + summaryData: allocatedScoreSummaryData, +} export interface approvalResults { elected: candidate[], diff --git a/backend/src/Tabulators/VotingMethodSelecter.ts b/backend/src/Tabulators/VotingMethodSelecter.ts index 23740969..9f9a1575 100644 --- a/backend/src/Tabulators/VotingMethodSelecter.ts +++ b/backend/src/Tabulators/VotingMethodSelecter.ts @@ -3,11 +3,11 @@ import { Approval } from "./Approval"; import { Plurality } from "./Plurality"; import { IRV } from "./IRV"; import { RankedRobin } from "./RankedRobin"; -const AllocatedScoreResults = require('../Tabulators/AllocatedScore') +import { AllocatedScore } from "./AllocatedScore"; export const VotingMethods: { [id: string]: Function } = { STAR: Star, - STAR_PR: AllocatedScoreResults, + STAR_PR: AllocatedScore, Approval: Approval, Plurality: Plurality, IRV: IRV, diff --git a/frontend/src/components/Election/Results/Results.js b/frontend/src/components/Election/Results/Results.js index b5969258..e5b20a5d 100644 --- a/frontend/src/components/Election/Results/Results.js +++ b/frontend/src/components/Election/Results/Results.js @@ -396,34 +396,39 @@ function PRResultsViewer({ result }) {

Summary

Voting Method: Proportional STAR Voting

-

{`Winners: ${result.cvr.prResults.winners.map((winner) => winner.name).join(', ')}`}

-

{`Number of voters: ${result.cvr.voters.length}`}

+

{`Winners: ${result.elected.map((winner) => winner.name).join(', ')}`}

+

{`Number of voters: ${result.summaryData.nValidVotes}`}

Detailed Results

Scores By Round

- {result.cvr.prResults.winners.map((c, n) => ( + {result.elected.map((c, n) => ( ))} - {/* {console.log(data.Results.cvr.prResults.weightedSumsData)} */} - {result.cvr.prResults.weightedSumsData.map((row, i) => ( - - - {row.map((col, j) => ( - result.cvr.prResults.winners[j].index === result.cvr.candidates[i].index ? - - : - - ))} + {/* Loop over each candidate, for each loop over each round and return score */} + {/* Data is stored in the transpose of the desired format which is why loops look weird */} + {result.summaryData.weightedScoresByRound[0].map((col, cand_ind) => ( + + + {result.summaryData.weightedScoresByRound.map((row, round_ind) => { + const score = Math.round(result.summaryData.weightedScoresByRound[round_ind][cand_ind] * 10) / 10 + return ( + result.elected[round_ind].index === result.summaryData.candidates[cand_ind].index ? + + : + + ) + } + )} ))} From e8f033cea8e8e84daadfe3aa2b5154f5d09b6a6f Mon Sep 17 00:00:00 2001 From: eznarf <41272412+eznarf@users.noreply.github.com> Date: Mon, 22 May 2023 00:12:56 -0700 Subject: [PATCH 2/4] Adding a check that values are approximately equal --- backend/src/Tabulators/AllocatedScore.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/Tabulators/AllocatedScore.ts b/backend/src/Tabulators/AllocatedScore.ts index a85d0739..5c05b716 100644 --- a/backend/src/Tabulators/AllocatedScore.ts +++ b/backend/src/Tabulators/AllocatedScore.ts @@ -311,12 +311,12 @@ function indexOfMax(arr: number[], breakTiesRandomly: boolean) { var maxIndex = 0; var ties: number[] = [0]; for (var i = 1; i < arr.length; i++) { - if (arr[i] > max) { + if (Math.abs(max - arr[i]) < 1e-8) { + ties.push(i); + } else if (arr[i] > max) { maxIndex = i; max = arr[i]; ties = [i]; - } else if (arr[i] === max) { - ties.push(i); } } if (breakTiesRandomly && ties.length > 1) { From a2e4e5f1b0957a3f4df1e04dee70b0b298b8a56c Mon Sep 17 00:00:00 2001 From: eznarf <41272412+eznarf@users.noreply.github.com> Date: Wed, 31 May 2023 21:27:28 -0700 Subject: [PATCH 3/4] Adding extra test case for fractional surplus --- backend/src/Tabulators/AllocatedScore.test.ts | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/backend/src/Tabulators/AllocatedScore.test.ts b/backend/src/Tabulators/AllocatedScore.test.ts index ca7992c0..7290909b 100644 --- a/backend/src/Tabulators/AllocatedScore.test.ts +++ b/backend/src/Tabulators/AllocatedScore.test.ts @@ -28,14 +28,14 @@ describe("Allocated Score Tests", () => { // Voters who gave Allison their highest score have their ballot weight reduced to (1-6/8) = 0.25 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, 1, 0], - [5, 5, 1, 0], - [5, 5, 1, 0], - [5, 4, 4, 0], + [5, 5, 1, 0],// Ballot weight reduced to 0.25 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0.25 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0.25 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0.25 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0.25 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0.25 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0.25 after first round + [5, 4, 4, 0],// Ballot weight reduced to 0.25 after first round [0, 0, 0, 3], [0, 0, 4, 5], [0, 0, 4, 5], @@ -48,6 +48,33 @@ describe("Allocated Score Tests", () => { expect(results.summaryData.weightedScoresByRound[1]).toStrictEqual([0, 9.75, 14.75, 18]); }) + test("Fractional surplus on lower split", () => { + // Two winners, two main parties, Allison wins first round with highest score, Allison has 8 highest level supporters, more than the quota of 6 voters + // 4 gave Allison 5s, 4 gave Allison 4s. The 5's contribute 100% of their ballot weight to the quota bring the quota to 4/6 filled + // 4 who gave Allison 4s share remaining 2/6 quota equally, reducing their ballot weight to 0.5 + const candidates = ['Allison', 'Bill', 'Carmen', 'Doug'] + const votes = [ + [4, 4, 1, 0],// Ballot weight reduced to 0.50 after first round + [4, 4, 1, 0],// Ballot weight reduced to 0.50 after first round + [4, 4, 1, 0],// Ballot weight reduced to 0.50 after first round + [4, 4, 1, 0],// Ballot weight reduced to 0.50 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0 after first round + [5, 5, 1, 0],// Ballot weight reduced to 0 after first round + [5, 4, 4, 0],// Ballot weight reduced to 0 after first round + [0, 0, 0, 3], + [0, 0, 4, 5], + [0, 0, 4, 5], + [0, 0, 4, 5]] + 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'); + expect(results.elected[1].name).toBe('Doug'); + expect(results.summaryData.weightedScoresByRound[0]).toStrictEqual([36, 35, 23, 18]); + expect(results.summaryData.weightedScoresByRound[1]).toStrictEqual([0, 8, 14, 18]); + }) + test("Random Tiebreaker", () => { // Two winners, two candidates tie for first, break tie randomly const candidates = ['Allison', 'Bill', 'Carmen', 'Doug'] From 1f2cd7e680e9bea1f6d1c276b892038dfd4d060c Mon Sep 17 00:00:00 2001 From: eznarf <41272412+eznarf@users.noreply.github.com> Date: Wed, 31 May 2023 21:28:06 -0700 Subject: [PATCH 4/4] Removing unused code --- backend/src/Tabulators/AllocatedScore.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/src/Tabulators/AllocatedScore.ts b/backend/src/Tabulators/AllocatedScore.ts index 5c05b716..d732e1e8 100644 --- a/backend/src/Tabulators/AllocatedScore.ts +++ b/backend/src/Tabulators/AllocatedScore.ts @@ -8,8 +8,6 @@ declare namespace Intl { public format: (items: string[]) => string; } } -// 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' }); const minScore = 0; const maxScore = 5;
CandidateRound {n + 1}
{result.cvr.candidates[i].name} -

{Math.round(col * 10) / 10}

-
-

{Math.round(col * 10) / 10}

-
{result.summaryData.candidates[cand_ind].name} +

{ score }

+
+

{score}

+