Skip to content

refactor: improve ranked choice voting readability and add test #1177

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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
176 changes: 172 additions & 4 deletions src/voting/rankedChoice.spec.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { test, expect } from 'vitest';
import RankedChoiceVoting from './rankedChoice';
import { test, expect, describe } from 'vitest';
import RankedChoiceVoting, { getFinalRound } from './rankedChoice';
import example from './examples/rankedChoice.json';

const TEST_CHOICES = ['Alice', 'Bob', 'Carol', 'David'];

const example2 = () => {
// Example with multiple (3) strategies
const proposal = {
choices: ['Alice', 'Bob', 'Carol', 'David']
choices: TEST_CHOICES
};
const strategies = [
{ name: 'ticket', network: 1, params: {} },
Expand Down Expand Up @@ -153,6 +155,37 @@ test.each(getScoresByStrategyTests)(
}
);

test('getScoresByStrategy should handle empty scores array from getFinalRound', () => {
// Using same votes as majority winner test where some choices have no first-place votes
const votes = [
{ choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
{ choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
{ choice: [1, 4, 2, 3], balance: 150, scores: [150] }, // First: Alice
{ choice: [2, 1, 3, 4], balance: 50, scores: [50] } // First: Bob
];

const proposal = { choices: TEST_CHOICES };
const strategies = [{ name: 'ticket', network: 1, params: {} }];

const ranked = new RankedChoiceVoting(
proposal,
votes,
strategies,
example.selectedChoice
);

// This should not throw an error when reduce encounters empty scores arrays
const result = ranked.getScoresByStrategy();

// Expected: Alice gets all her votes (450), Bob gets his (50), Carol and David get 0
expect(result).toEqual([
[450], // Alice: 100 + 200 + 150
[50], // Bob: 50
[0], // Carol: no first-place votes
[0] // David: no first-place votes
]);
});

const getScoresTotalTests = [
[example.proposal, example.votes, example.strategies, example.scoresTotal],
[
Expand All @@ -178,7 +211,17 @@ test.each(getScoresTotalTests)(

test.each([
[[1, 2, 3, 4], '(1st) Alice, (2nd) Bob, (3rd) Carol, (4th) David'],
[[4, 2, 3, 1], '(1st) David, (2nd) Bob, (3rd) Carol, (4th) Alice']
[[4, 2, 3, 1], '(1st) David, (2nd) Bob, (3rd) Carol, (4th) Alice'],
// Invalid choices (out of range indices)
[[5], ''], // Choice index 5 doesn't exist (only 1-4 available)
[[0], ''], // Choice index 0 is invalid (choices are 1-indexed)
[[-1], ''], // Negative choice index
[[1, 5], '(1st) Alice'], // Mix of valid (1) and invalid (5) - invalid filtered out
[[0, 2], '(1st) Bob'], // Mix of invalid (0) and valid (2) - invalid filtered out
[[5, 6, 7], ''], // All invalid indices - all filtered out
[[1, 0, 3], '(1st) Alice, (2nd) Carol'], // Valid-invalid-valid pattern - invalid filtered out
[[100], ''], // Very high invalid index
[[], ''] // Empty array
])('getChoiceString %s %s', (selected, expected) => {
const ranked = new RankedChoiceVoting(
example.proposal,
Expand All @@ -188,3 +231,128 @@ test.each([
);
expect(ranked.getChoiceString()).toEqual(expected);
});

describe('isValidChoice', () => {
test.each([
[[1, 2, 3, 4], TEST_CHOICES],
[[4, 3, 2, 1], TEST_CHOICES],
[[2, 1, 4, 3], TEST_CHOICES],
[[1], ['Alice']]
])('should accept valid ranked choice: %s', (voteChoice, proposalChoices) => {
expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
true
);
});

test.each([
['not-array', TEST_CHOICES],
[null, TEST_CHOICES],
[undefined, TEST_CHOICES]
])('should reject non-array input: %s', (voteChoice, proposalChoices) => {
expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
false
);
});

test.each([
[[], TEST_CHOICES],
[[], []]
])('should reject empty choice array: %s', (voteChoice, proposalChoices) => {
expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
false
);
});

test.each([
[[1, 5], TEST_CHOICES],
[[0, 1, 2, 3, 4], TEST_CHOICES],
[[-1, 1, 2, 3], TEST_CHOICES],
[[100], TEST_CHOICES]
])(
'should reject out-of-range indices: %s',
(voteChoice, proposalChoices) => {
expect(
RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)
).toBe(false);
}
);

test.each([
[[1, 1, 2, 3], TEST_CHOICES],
[[1, 2, 2, 3], TEST_CHOICES],
[
[1, 1],
['Alice', 'Bob']
]
])('should reject duplicate choices: %s', (voteChoice, proposalChoices) => {
expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
false
);
});

test.each([
[[1], TEST_CHOICES],
[[1, 2], TEST_CHOICES]
])(
'should reject incomplete ranking when multiple choices available: %s',
(voteChoice, proposalChoices) => {
expect(
RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)
).toBe(false);
}
);
});

describe('getFinalRound', () => {
test('should execute instant runoff voting with multiple elimination rounds', () => {
const votes = [
{ choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
{ choice: [2, 1, 3, 4], balance: 200, scores: [200] }, // First: Bob
{ choice: [3, 1, 2, 4], balance: 150, scores: [150] }, // First: Carol
{ choice: [4, 1, 2, 3], balance: 50, scores: [50] } // First: David
];

const result = getFinalRound(votes);

expect(result).toEqual([
['2', [350, [350]]], // Bob wins with 350 total (200+150 after IRV)
['3', [150, [150]]] // Carol second with 150
]);
});

test('should handle choices with no first-place votes', () => {
const votes = [
{ choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
{ choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
{ choice: [2, 1, 3, 4], balance: 150, scores: [150] }, // First: Bob
{ choice: [2, 3, 1, 4], balance: 300, scores: [300] } // First: Bob
];

const result = getFinalRound(votes);

expect(result).toEqual([
['2', [450, [450]]], // Bob wins with 450 (150+300)
['1', [300, [300]]], // Alice second with 300 (100+200)
['3', [0, []]], // Carol has no first place votes - empty scores array (our fix)
['4', [0, []]] // David has no first place votes - empty scores array (our fix)
]);
});

test('should declare winner in first round when candidate has majority', () => {
const votes = [
{ choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
{ choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
{ choice: [1, 4, 2, 3], balance: 150, scores: [150] }, // First: Alice
{ choice: [2, 1, 3, 4], balance: 50, scores: [50] } // First: Bob
];

const result = getFinalRound(votes);

expect(result).toEqual([
['1', [450, [450]]], // Alice wins with majority in round 1 (100+200+150)
['2', [50, [50]]], // Bob gets remaining votes
['3', [0, []]], // Carol has no first place votes
['4', [0, []]] // David has no first place votes
]);
});
});
94 changes: 87 additions & 7 deletions src/voting/rankedChoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,27 @@ function irv(
);
}

function getFinalRound(
/**
* Runs the complete Instant Runoff Voting (IRV) algorithm and returns the final results.
*
* Executes all elimination rounds until a winner is determined or fewer than 3 candidates remain.
* Each round eliminates the candidate with the fewest votes and redistributes their votes
* to voters' next preferences.
*
* @param votes - Array of valid ranked choice votes to process
* @returns Array of tuples representing the final candidate rankings, sorted by vote count (highest first).
* Each tuple contains [candidateIndex, [totalBalance, scoresArray]] where:
* - totalBalance: Sum of voting power from all voters who support this candidate
* - scoresArray: Breakdown of that voting power by voting strategy
* The relationship: totalBalance === scoresArray.reduce((a,b) => a + b, 0)
*
* @example
* // Returns final results after IRV elimination rounds
* // [["2", [150, [60,50,40]]], ["1", [120, [70,30,20]]], ...]
* // Candidate 2 wins with 150 total voting power (60+50+40 from 3 strategies)
* // Candidate 1 has 120 total voting power (70+30+20 from 3 strategies)
*/
export function getFinalRound(
Copy link
Preview

Copilot AI Aug 12, 2025

Choose a reason for hiding this comment

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

Exporting getFinalRound creates a breaking change to the module's public API. Consider whether this function should be exported or if it's intended for internal use only.

Suggested change
export function getFinalRound(
function getFinalRound(

Copilot uses AI. Check for mistakes.

votes: RankedChoiceVote[]
): [string, [number, number[]]][] {
const rounds = irv(
Expand All @@ -77,7 +97,26 @@ function getFinalRound(
return finalRound.sortedByHighest;
}

function getScoresMethod(
/**
* Converts IRV final results into a simple array of scores indexed by proposal choice order.
*
* Takes the ranked results from getFinalRound() (sorted by winner) and transforms them
* into an array where each position corresponds to the original proposal choice index.
* This allows easy lookup of any candidate's final vote total by their position in the proposal.
*
* @param votes - Array of valid ranked choice votes to process
* @param proposal - Proposal object containing the choices array
* @returns Array of total voting power for each choice, indexed by proposal order.
* Position 0 = first choice's votes, position 1 = second choice's votes, etc.
*
* @example
* // proposal.choices = ['Alice', 'Bob', 'Carol', 'David']
* // After IRV: Bob won (150), David 2nd (120), Alice 3rd (100), Carol 4th (80)
* // Returns: [100, 150, 80, 120]
* // ↑ ↑ ↑ ↑
* // Alice Bob Carol David (proposal order)
*/
function getFinalScoresByChoice(
votes: RankedChoiceVote[],
proposal: { choices: string[] }
) {
Expand Down Expand Up @@ -134,7 +173,7 @@ export default class RankedChoiceVoting {
}

getScores(): number[] {
return getScoresMethod(this.getValidVotes(), this.proposal);
return getFinalScoresByChoice(this.getValidVotes(), this.proposal);
}

getScoresByStrategy(): number[][] {
Expand All @@ -148,16 +187,57 @@ export default class RankedChoiceVoting {
);
}

/**
* Returns the total voting power from all submitted votes, including invalid ones.
*
* This method sums the balance (voting power) from ALL votes submitted to the proposal,
* regardless of whether they have valid choice arrays. This is useful for calculating
* total participation, quorum requirements, and percentage of total voting power.
*
* Note: This differs from IRV final results which only include valid votes. Invalid votes
* are excluded from IRV calculations but their voting power is still counted here for
* participation metrics.
*
* @returns Total voting power from all votes (valid + invalid)
*
* @example
* // votes = [
* // { choice: [1,2,3,4], balance: 1000 }, // Valid
* // { choice: [1,5,2], balance: 500 }, // Invalid (index 5)
* // { choice: [2,1,4,3], balance: 750 } // Valid
* // ]
* // Returns: 2250 (includes invalid vote's 500 balance)
*/
getScoresTotal(): number {
return this.votes.reduce((a, b: any) => a + b.balance, 0);
}

/**
* Converts the selected choice indices into a human-readable string representation.
*
* Note: This method supports partial ranking where not all available choices
* need to be selected. The ordinal positions (1st, 2nd, etc.) reflect the
* order of valid selections only. Invalid choice indices are filtered out.
*
* @returns A formatted string showing the ranked choices with ordinal positions.
* Only valid choices are included, invalid indices are silently ignored.
*
* @example
* // With choices ['Alice', 'Bob', 'Carol', 'David'] and selected [1, 3, 2]
* // Returns: "(1st) Alice, (2nd) Carol, (3rd) Bob"
*
* @example
* // Partial ranking with choices ['Alice', 'Bob', 'Carol', 'David'] and selected [4, 1]
* // Returns: "(1st) David, (2nd) Alice"
*
* @example
* // With invalid choice index 5 in selected [1, 5]
* // Returns: "(1st) Alice" - invalid choice 5 is filtered out
*/
getChoiceString(): string {
return this.selected
.map((choice) => {
if (this.proposal.choices[choice - 1])
return this.proposal.choices[choice - 1];
})
.map((choice) => this.proposal.choices[choice - 1])
.filter(Boolean)
.map((el, i) => `(${getNumberWithOrdinal(i + 1)}) ${el}`)
.join(', ');
}
Expand Down