From eec63ad8e7d9eefd89d3802826705eb12703cace Mon Sep 17 00:00:00 2001 From: less Date: Mon, 14 Oct 2024 12:06:20 +0700 Subject: [PATCH 1/2] feat: add RCV Copeland voting system --- .../__snapshots__/copeland.spec.js.snap | 134 +++++++++++++++ src/voting/copeland.spec.js | 129 +++++++++++++++ src/voting/copeland.ts | 155 ++++++++++++++++++ src/voting/examples/copeland.json | 32 ++++ src/voting/index.ts | 2 + src/voting/types.ts | 36 +--- 6 files changed, 460 insertions(+), 28 deletions(-) create mode 100644 src/voting/__snapshots__/copeland.spec.js.snap create mode 100644 src/voting/copeland.spec.js create mode 100644 src/voting/copeland.ts create mode 100644 src/voting/examples/copeland.json diff --git a/src/voting/__snapshots__/copeland.spec.js.snap b/src/voting/__snapshots__/copeland.spec.js.snap new file mode 100644 index 000000000..87fcb492a --- /dev/null +++ b/src/voting/__snapshots__/copeland.spec.js.snap @@ -0,0 +1,134 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Partial ranking 1`] = ` +[ + 4, + 6, + 2, + 0, +] +`; + +exports[`getScores 1`] = ` +[ + 5, + 5, + 2, + 0, +] +`; + +exports[`getScores 2`] = ` +[ + 5, + 5, + 2, + 0, +] +`; + +exports[`getScores 3`] = ` +[ + 5, + 5, + 2, + 0, +] +`; + +exports[`getScores 4`] = ` +[ + 5, + 5, + 2, + 0, +] +`; + +exports[`getScoresByStrategy 1`] = ` +[ + [ + 5, + ], + [ + 5, + ], + [ + 2, + ], + [ + 0, + ], +] +`; + +exports[`getScoresByStrategy 2`] = ` +[ + [ + 5, + ], + [ + 5, + ], + [ + 2, + ], + [ + 0, + ], +] +`; + +exports[`getScoresByStrategy 3`] = ` +[ + [ + 5, + 5, + 5, + ], + [ + 5, + 5, + 5, + ], + [ + 2, + 2, + 2, + ], + [ + 0, + 0, + 0, + ], +] +`; + +exports[`getScoresByStrategy 4`] = ` +[ + [ + 5, + 5, + 5, + ], + [ + 5, + 5, + 5, + ], + [ + 2, + 2, + 2, + ], + [ + 0, + 0, + 0, + ], +] +`; + +exports[`getScoresTotal 1`] = `4`; + +exports[`getScoresTotal 2`] = `12`; diff --git a/src/voting/copeland.spec.js b/src/voting/copeland.spec.js new file mode 100644 index 000000000..e6011fecb --- /dev/null +++ b/src/voting/copeland.spec.js @@ -0,0 +1,129 @@ +import { test, expect } from 'vitest'; +import CopelandVoting from './copeland'; +import example from './examples/copeland.json'; + +// Helper function to create a more complex example with multiple strategies +const example2 = () => { + const proposal = { + choices: ['Alice', 'Bob', 'Carol', 'David'] + }; + const strategies = [ + { name: 'ticket', network: '1', params: {} }, + { name: 'ticket', network: '1', params: {} }, + { name: 'ticket', network: '1', params: {} } + ]; + const votes = example.votes.map((vote) => ({ + choice: vote.choice, + balance: 3, + scores: [1, 1, 1] + })); + + return { + proposal, + strategies, + votes + }; +}; + +// Generate a set of votes including some invalid choices +const votesWithInvalidChoices = () => { + const invalidVotes = [ + { choice: [0, 1], balance: 1, scores: [1] }, + { choice: [1, 5], balance: 1, scores: [1] }, + { choice: [1, 1], balance: 1, scores: [1] }, + { choice: [], balance: 1, scores: [1] }, + { choice: [1, 2, 3, 4, 5], balance: 1, scores: [1] } + ]; + return [...invalidVotes, ...example.votes]; +}; + +// Generate a set of votes including some invalid choices for the multi-strategy example +const votesWithInvalidChoices2 = () => { + const invalidVotes = [ + { choice: [0, 1], balance: 3, scores: [1, 1, 1] }, + { choice: [1, 5], balance: 3, scores: [1, 1, 1] }, + { choice: [1, 1], balance: 3, scores: [1, 1, 1] }, + { choice: [], balance: 3, scores: [1, 1, 1] }, + { choice: [1, 2, 3, 4, 5], balance: 3, scores: [1, 1, 1] } + ]; + return [...invalidVotes, ...example2().votes]; +}; + +// Test cases for getScores method +test.each([ + [example.proposal, example.votes, example.strategies], + [example.proposal, votesWithInvalidChoices(), example.strategies], + [example2().proposal, example2().votes, example2().strategies], + [example2().proposal, votesWithInvalidChoices2(), example2().strategies] +])('getScores', (proposal, votes, strategies) => { + const copeland = new CopelandVoting( + proposal, + votes, + strategies, + example.selectedChoice + ); + expect(copeland.getScores()).toMatchSnapshot(); +}); + +// Test cases for getScoresByStrategy method +test.each([ + [example.proposal, example.votes, example.strategies], + [example.proposal, votesWithInvalidChoices(), example.strategies], + [example2().proposal, example2().votes, example2().strategies], + [example2().proposal, votesWithInvalidChoices2(), example2().strategies] +])('getScoresByStrategy', (proposal, votes, strategies) => { + const copeland = new CopelandVoting( + proposal, + votes, + strategies, + example.selectedChoice + ); + expect(copeland.getScoresByStrategy()).toMatchSnapshot(); +}); + +// Test cases for getScoresTotal method +test.each([ + [example.proposal, example.votes, example.strategies], + [example2().proposal, example2().votes, example2().strategies] +])('getScoresTotal', (proposal, votes, strategies) => { + const copeland = new CopelandVoting( + proposal, + votes, + strategies, + example.selectedChoice + ); + expect(copeland.getScoresTotal()).toMatchSnapshot(); +}); + +// Test cases for getChoiceString method +test.each([ + [[1, 2], 'Alice, Bob'], + [[4, 2, 3, 1], 'David, Bob, Carol, Alice'] +])('getChoiceString %s', (selected, expected) => { + const copeland = new CopelandVoting( + example.proposal, + example.votes, + example.strategies, + selected + ); + expect(copeland.getChoiceString()).toBe(expected); +}); + +// Test case for partial ranking +test('Partial ranking', () => { + const partialVotes = [ + ...example.votes, + { + choice: [2, 1], + balance: 1, + scores: [1] + } + ]; + const copeland = new CopelandVoting( + example.proposal, + partialVotes, + example.strategies, + example.selectedChoice + ); + expect(copeland.getScores()).toMatchSnapshot(); +}); diff --git a/src/voting/copeland.ts b/src/voting/copeland.ts new file mode 100644 index 000000000..859f0286c --- /dev/null +++ b/src/voting/copeland.ts @@ -0,0 +1,155 @@ +import { Strategy, RankedChoiceVote } from './types'; + +// CopelandVoting implements ranked choice voting using Copeland's method +// This method compares each pair of choices and awards points based on pairwise victories +export default class CopelandVoting { + proposal: { choices: string[] }; + votes: RankedChoiceVote[]; + strategies: Strategy[]; + selected: number[]; + + constructor( + proposal: { choices: string[] }, + votes: RankedChoiceVote[], + strategies: Strategy[], + selected: number[] + ) { + this.proposal = proposal; + this.votes = votes; + this.strategies = strategies; + this.selected = selected; + } + + // Validates if a vote choice is valid for the given proposal + // Allows partial ranking (not all choices need to be ranked) + static isValidChoice( + voteChoice: number[], + proposalChoices: string[] + ): boolean { + if ( + !Array.isArray(voteChoice) || + voteChoice.length === 0 || + voteChoice.length > proposalChoices.length || + new Set(voteChoice).size !== voteChoice.length + ) { + return false; + } + + return voteChoice.every( + (choice) => + Number.isInteger(choice) && + choice >= 1 && + choice <= proposalChoices.length + ); + } + + // Returns only the valid votes + getValidVotes(): RankedChoiceVote[] { + return this.votes.filter((vote) => + CopelandVoting.isValidChoice(vote.choice, this.proposal.choices) + ); + } + + // Calculates the Copeland scores for each choice + getScores(): number[] { + const validVotes = this.getValidVotes(); + const choicesCount = this.proposal.choices.length; + const pairwiseComparisons = Array.from({ length: choicesCount }, () => + Array(choicesCount).fill(0) + ); + + // Calculate pairwise comparisons + for (const vote of validVotes) { + for (let i = 0; i < vote.choice.length; i++) { + for (let j = i + 1; j < vote.choice.length; j++) { + const winner = vote.choice[i] - 1; + const loser = vote.choice[j] - 1; + pairwiseComparisons[winner][loser] += vote.balance; + pairwiseComparisons[loser][winner] -= vote.balance; + } + } + } + + // Calculate Copeland scores + const scores = Array(choicesCount).fill(0); + for (let i = 0; i < choicesCount; i++) { + for (let j = 0; j < choicesCount; j++) { + if (i !== j) { + if (pairwiseComparisons[i][j] > 0) { + scores[i]++; + } else if (pairwiseComparisons[i][j] < 0) { + scores[j]++; + } else { + scores[i] += 0.5; + scores[j] += 0.5; + } + } + } + } + + return scores; + } + + // Calculates the Copeland scores for each choice, broken down by strategy + getScoresByStrategy(): number[][] { + const validVotes = this.getValidVotes(); + const choicesCount = this.proposal.choices.length; + const strategiesCount = this.strategies.length; + const pairwiseComparisons = Array.from({ length: choicesCount }, () => + Array.from({ length: choicesCount }, () => Array(strategiesCount).fill(0)) + ); + + // Calculate pairwise comparisons for each strategy + for (const vote of validVotes) { + for (let i = 0; i < vote.choice.length; i++) { + for (let j = i + 1; j < vote.choice.length; j++) { + const winner = vote.choice[i] - 1; + const loser = vote.choice[j] - 1; + for (let s = 0; s < strategiesCount; s++) { + pairwiseComparisons[winner][loser][s] += vote.scores[s]; + pairwiseComparisons[loser][winner][s] -= vote.scores[s]; + } + } + } + } + + // Calculate Copeland scores for each strategy + const scores = Array.from({ length: choicesCount }, () => + Array(strategiesCount).fill(0) + ); + + for (let i = 0; i < choicesCount; i++) { + for (let j = 0; j < choicesCount; j++) { + if (i !== j) { + for (let s = 0; s < strategiesCount; s++) { + if (pairwiseComparisons[i][j][s] > 0) { + scores[i][s]++; + } else if (pairwiseComparisons[i][j][s] < 0) { + scores[j][s]++; + } else { + scores[i][s] += 0.5; + scores[j][s] += 0.5; + } + } + } + } + } + + return scores; + } + + // Calculates the total score (sum of all valid vote balances) + getScoresTotal(): number { + return this.getValidVotes().reduce( + (total, vote) => total + vote.balance, + 0 + ); + } + + // Returns a string representation of the selected choices + getChoiceString(): string { + return this.selected + .map((choice) => this.proposal.choices[choice - 1]) + .join(', '); + } +} diff --git a/src/voting/examples/copeland.json b/src/voting/examples/copeland.json new file mode 100644 index 000000000..918ff1864 --- /dev/null +++ b/src/voting/examples/copeland.json @@ -0,0 +1,32 @@ +{ + "proposal": { + "choices": ["Alice", "Bob", "Carol", "David"] + }, + "strategies": [{ "name": "ticket", "network": "1", "params": {} }], + "scores": [2.5, 2, 0.5, 1], + "scoresByStrategy": [[2.5], [2], [0.5], [1]], + "scoresTotal": 4, + "selectedChoice": [1, 2], + "votes": [ + { + "choice": [1, 2, 3, 4], + "balance": 1, + "scores": [1] + }, + { + "choice": [2, 1, 3, 4], + "balance": 1, + "scores": [1] + }, + { + "choice": [1, 3, 2, 4], + "balance": 1, + "scores": [1] + }, + { + "choice": [2, 1], + "balance": 1, + "scores": [1] + } + ] +} diff --git a/src/voting/index.ts b/src/voting/index.ts index 31042a3e7..cedafe622 100644 --- a/src/voting/index.ts +++ b/src/voting/index.ts @@ -2,6 +2,7 @@ import singleChoice from './singleChoice'; import approval from './approval'; import quadratic from './quadratic'; import rankedChoice from './rankedChoice'; +import copeland from './copeland'; import weighted from './weighted'; export default { @@ -9,6 +10,7 @@ export default { approval, quadratic, 'ranked-choice': rankedChoice, + copeland, weighted, basic: singleChoice }; diff --git a/src/voting/types.ts b/src/voting/types.ts index a06e571f9..017b6ed8f 100644 --- a/src/voting/types.ts +++ b/src/voting/types.ts @@ -4,36 +4,16 @@ export interface Strategy { params: Record; } -export interface SingleChoiceVote { - choice: number; +interface BaseVote { + choice: TChoice; balance: number; scores: number[]; } -export interface ApprovalVote { - choice: number[]; - balance: number; - scores: number[]; -} - -export interface RankedChoiceVote { - choice: number[]; - balance: number; - scores: number[]; -} - -export interface QuadraticChoice { - [key: string]: number; -} - -export interface QuadraticVote { - choice: QuadraticChoice; - balance: number; - scores: number[]; -} +type ChoiceMap = { [key: string]: number }; -export interface WeightedVote { - choice: { [key: string]: number }; - balance: number; - scores: number[]; -} +export type SingleChoiceVote = BaseVote; +export type ApprovalVote = BaseVote; +export type RankedChoiceVote = BaseVote; +export type QuadraticVote = BaseVote; +export type WeightedVote = BaseVote; From e19664c7f211275ef5cdae304ab8a0983b1d28bd Mon Sep 17 00:00:00 2001 From: less Date: Mon, 14 Oct 2024 12:10:19 +0700 Subject: [PATCH 2/2] fix: missing QuadraticChoice --- src/voting/quadratic.ts | 8 ++++---- src/voting/types.ts | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/voting/quadratic.ts b/src/voting/quadratic.ts index 24e10f9d5..71d0b1093 100644 --- a/src/voting/quadratic.ts +++ b/src/voting/quadratic.ts @@ -1,4 +1,4 @@ -import { QuadraticVote, QuadraticChoice, Strategy } from './types'; +import { QuadraticVote, ChoiceMap, Strategy } from './types'; export function calcPercentageOfSum( part: number, @@ -34,13 +34,13 @@ export default class QuadraticVoting { proposal: { choices: string[] }; votes: QuadraticVote[]; strategies: Strategy[]; - selected: QuadraticChoice; + selected: ChoiceMap; constructor( proposal: { choices: string[] }, votes: QuadraticVote[], strategies: Strategy[], - selected: QuadraticChoice + selected: ChoiceMap ) { this.proposal = proposal; this.votes = votes; @@ -49,7 +49,7 @@ export default class QuadraticVoting { } static isValidChoice( - voteChoice: QuadraticChoice, + voteChoice: ChoiceMap, proposalChoices: string[] ): boolean { return ( diff --git a/src/voting/types.ts b/src/voting/types.ts index 017b6ed8f..1747574c0 100644 --- a/src/voting/types.ts +++ b/src/voting/types.ts @@ -10,8 +10,7 @@ interface BaseVote { scores: number[]; } -type ChoiceMap = { [key: string]: number }; - +export type ChoiceMap = { [key: string]: number }; export type SingleChoiceVote = BaseVote; export type ApprovalVote = BaseVote; export type RankedChoiceVote = BaseVote;