Skip to content

Commit

Permalink
Merge pull request #717 from ArendPeter/single-surplus
Browse files Browse the repository at this point in the history
Fix edge case for single fractional surplus
  • Loading branch information
ArendPeter authored Dec 16, 2024
2 parents f121aeb + 83ba050 commit a114de7
Show file tree
Hide file tree
Showing 4 changed files with 57 additions and 31 deletions.
42 changes: 42 additions & 0 deletions packages/backend/src/Tabulators/AllocatedScore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,48 @@ describe("Allocated Score Tests", () => {
expect(results.summaryData.weightedScoresByRound[0]).toStrictEqual([25, 24, 24, 23]);
expect(results.summaryData.weightedScoresByRound[1]).toStrictEqual([0, 0, 16, 23]);
})
test("Single vote fractional surplus", () => {
// Two winners, two main parties
// Allison wins first round with highest score
// quota = 4.5
// Round 1: Allison's 5 star supporters are weighted to 0, her 4 star supported is weighted to 0.5
// Carmen's score is reduced some causing Doug to win second
// Round 2: Doug wins
const candidates = ['Allison', 'Bill', 'Carmen', 'Doug']
const votes = [
[5, 5, 0, 0], // Round 1 Weight Change: 1 -> 0
[5, 5, 0, 0], // Round 1 Weight Change: 1 -> 0
[5, 5, 0, 0], // Round 1 Weight Change: 1 -> 0
[5, 5, 0, 0], // Round 1 Weight Change: 1 -> 0
[4, 0, 4, 0], // Round 1 Weight Change: 1 -> 0.5
[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([24, 20, 16, 18]);
expect(results.summaryData.weightedScoresByRound[1]).toStrictEqual([0, 0, 14, 18]);
expect(results.summaryData.splitPoints[0]).toStrictEqual(0.8);
})
test("Voters < Winners", () => {
const candidates = ['Allison', 'Bill', 'Carmen', 'Doug']
const votes = [
[5, 5, 0, 0],
[5, 4, 3, 0],
]
const results = AllocatedScore(candidates, votes, 3, [], false, false)
expect(results.elected.length).toBe(3);
expect(results.elected[0].name).toBe('Allison');
expect(results.elected[1].name).toBe('Bill');
expect(results.elected[2].name).toBe('Carmen');
expect(results.summaryData.weightedScoresByRound[0]).toStrictEqual([10, 9, 3, 0]);
expect(results.summaryData.weightedScoresByRound[1]).toStrictEqual([0, 6, 2, 0]);
expect(results.summaryData.weightedScoresByRound[2]).toStrictEqual([0, 0, 2, 0]);
})
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
Expand Down
35 changes: 12 additions & 23 deletions packages/backend/src/Tabulators/AllocatedScore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export function AllocatedScore(candidates: string[], votes: ballot[], nWinners =
summaryData.spentAboves.push(spent_above.valueOf());

if (spent_above.valueOf() > 0) {
results.logs.push(`The ${rounded(spent_above)} voters who gave ${summaryData.candidates[w].name} more than ${rounded(split_point.mul(maxScore))} stars are fully represented and will be weighted to 0 for future rounds.`)
results.logs.push(`The ${rounded(spent_above)} voters who gave ${summaryData.candidates[w].name} more than ${rounded(split_point.mul(maxScore))} stars are fully represented and will be removed from future rounds.`)
cand_df.forEach((c, i) => {
if (c.weighted_score.compare(split_point) > 0) {
cand_df[i].ballot_weight = new Fraction(0);
Expand All @@ -156,10 +156,9 @@ export function AllocatedScore(candidates: string[], votes: ballot[], nWinners =

// quota = spent_above + weight_on_split*new_weight
let new_weight = (quota.sub(spent_above)).div(weight_on_split);
results.logs.push(`(${rounded(quota)} - ${rounded(spent_above)}) / ${rounded(weight_on_split)} = ${rounded(new_weight)}`)
results.logs.push(
`The ${rounded(weight_on_split)} voters who gave ${summaryData.candidates[w].name} ${rounded(split_point.mul(maxScore))} stars are partially represented. `+
`${percent(new_weight)} of their vote will go toward ${summaryData.candidates[w].name} and ${percent(new Fraction(1).sub(new_weight))} will be preserved for future rounds.`)
`${percent(new_weight)} of their remaining vote will go toward ${summaryData.candidates[w].name} and ${percent(new Fraction(1).sub(new_weight))} will be preserved for future rounds.`)

summaryData.weight_on_splits.push(weight_on_split.valueOf());
ballot_weights = updateBallotWeights(
Expand Down Expand Up @@ -374,17 +373,17 @@ function normalizeArray(scores: ballot[], maxScore: number) {
}

function findSplitPoint(cand_df_sorted: winner_scores[], quota: typeof Fraction) {
var under_quota : any[] = [];
var under_quota_scores: typeof Fraction[] = [];
var cumsum = new Fraction(0);
cand_df_sorted.forEach((c, i) => {
let cumsum : typeof Fraction = new Fraction(0);
for(const c of cand_df_sorted ){
cumsum = cumsum.add(c.ballot_weight);
if (cumsum < quota || i == 0) {
under_quota.push(c);
under_quota_scores.push(c.weighted_score);

// Since cand_df_sorted is sorted by weighted score we know that this will be the smallest
if(cumsum.compare(quota) >= 0){
return c.weighted_score;
}
});
return findMinFrac(under_quota_scores);
}

return cand_df_sorted.slice(-1)[0].weighted_score
}

function sortMatrix(matrix: number[][], order: number[]) {
Expand All @@ -398,14 +397,4 @@ function sortMatrix(matrix: number[][], order: number[]) {
});
});
return newMatrix
}

function findMinFrac(fracs: typeof Fraction[]) {
let minFrac = fracs[0]
fracs.forEach(frac => {
if (frac.compare(minFrac) < 0) {
minFrac = frac
}
})
return minFrac
}
}
3 changes: 0 additions & 3 deletions packages/backend/src/Tabulators/ParseData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ module.exports = function ParseData(data: ballot[], validityCheck = getStarBallo
}
else if (ballotValidity.isUnderVote) {
underVotes += 1
scores.push(row)
// under votes should not count as a valid vote
//validVotes.push(voter);
}
else {
scores.push(row)
Expand Down
8 changes: 3 additions & 5 deletions packages/frontend/src/components/Election/Results/Results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,11 @@ function PRResultsViewer() {
</WidgetContainer>
<DetailExpander>
<WidgetContainer>
<Widget title={t('results.star_pr.table_title')} wide>
<ResultsTable className='starPRTable' data={tabulationRows}/>
</Widget>
<Widget title={t('results.star_pr.table_title')} wide>
<ResultsTable className='starPRTable' data={tabulationRows}/>
</Widget>
</WidgetContainer>
<WidgetContainer>
{flags.isSet('ALL_STATS') &&
<Widget title={t('results.star.detailed_steps_title')} wide>
<div className='detailedSteps'>
<ol style={{textAlign: 'left'}}>
Expand All @@ -413,7 +412,6 @@ function PRResultsViewer() {
</ol>
</div>
</Widget>
}
</WidgetContainer>
<DetailExpander level={1}>
<WidgetContainer>
Expand Down

0 comments on commit a114de7

Please sign in to comment.