From 70c844261932b6e8aef0fe23e5db73141544325e Mon Sep 17 00:00:00 2001 From: ChaituVR Date: Mon, 3 Mar 2025 16:04:34 +0530 Subject: [PATCH 1/2] fix: normalize copeland score with voting power --- .../__snapshots__/copeland.spec.js.snap | 99 +++++++---- src/voting/copeland.spec.js | 101 +++++++++++ src/voting/copeland.ts | 159 ++++++++++++++---- 3 files changed, 292 insertions(+), 67 deletions(-) diff --git a/src/voting/__snapshots__/copeland.spec.js.snap b/src/voting/__snapshots__/copeland.spec.js.snap index 87fcb492a..81b40e528 100644 --- a/src/voting/__snapshots__/copeland.spec.js.snap +++ b/src/voting/__snapshots__/copeland.spec.js.snap @@ -2,27 +2,27 @@ exports[`Partial ranking 1`] = ` [ - 4, - 6, - 2, + 1.6666666666666665, + 2.5, + 0.8333333333333333, 0, ] `; exports[`getScores 1`] = ` [ - 5, - 5, - 2, + 1.6666666666666667, + 1.6666666666666667, + 0.6666666666666666, 0, ] `; exports[`getScores 2`] = ` [ - 5, - 5, - 2, + 1.6666666666666667, + 1.6666666666666667, + 0.6666666666666666, 0, ] `; @@ -45,16 +45,32 @@ exports[`getScores 4`] = ` ] `; +exports[`getScores with 0 votes 1`] = ` +[ + 0, + 0, + 0, +] +`; + +exports[`getScores with mixed voting powers 1`] = ` +[ + 668334.3333333333, + 334167.1666666666, + 0, +] +`; + exports[`getScoresByStrategy 1`] = ` [ [ - 5, + 1.6666666666666667, ], [ - 5, + 1.6666666666666667, ], [ - 2, + 0.6666666666666666, ], [ 0, @@ -65,13 +81,13 @@ exports[`getScoresByStrategy 1`] = ` exports[`getScoresByStrategy 2`] = ` [ [ - 5, + 1.6666666666666667, ], [ - 5, + 1.6666666666666667, ], [ - 2, + 0.6666666666666666, ], [ 0, @@ -82,19 +98,19 @@ exports[`getScoresByStrategy 2`] = ` exports[`getScoresByStrategy 3`] = ` [ [ - 5, - 5, - 5, + 1.6666666666666667, + 1.6666666666666667, + 1.6666666666666667, ], [ - 5, - 5, - 5, + 1.6666666666666667, + 1.6666666666666667, + 1.6666666666666667, ], [ - 2, - 2, - 2, + 0.6666666666666666, + 0.6666666666666666, + 0.6666666666666666, ], [ 0, @@ -107,19 +123,19 @@ exports[`getScoresByStrategy 3`] = ` exports[`getScoresByStrategy 4`] = ` [ [ - 5, - 5, - 5, + 1.6666666666666667, + 1.6666666666666667, + 1.6666666666666667, ], [ - 5, - 5, - 5, + 1.6666666666666667, + 1.6666666666666667, + 1.6666666666666667, ], [ - 2, - 2, - 2, + 0.6666666666666666, + 0.6666666666666666, + 0.6666666666666666, ], [ 0, @@ -129,6 +145,23 @@ exports[`getScoresByStrategy 4`] = ` ] `; +exports[`getScoresByStrategy normalizes correctly 1`] = ` +[ + [ + 7.5, + 22.5, + ], + [ + 7.5, + 22.5, + ], + [ + 7.5, + 22.5, + ], +] +`; + exports[`getScoresTotal 1`] = `4`; exports[`getScoresTotal 2`] = `12`; diff --git a/src/voting/copeland.spec.js b/src/voting/copeland.spec.js index e6011fecb..c798b0408 100644 --- a/src/voting/copeland.spec.js +++ b/src/voting/copeland.spec.js @@ -49,6 +49,44 @@ const votesWithInvalidChoices2 = () => { return [...invalidVotes, ...example2().votes]; }; +// Helper function to create example with decimal voting powers +const exampleWithDecimals = () => { + const proposal = { + choices: ['Alice', 'Bob', 'Carol'] + }; + const strategies = [{ name: 'ticket', network: '1', params: {} }]; + const votes = [ + { choice: [1, 2, 3], balance: 1.5, scores: [1.5] }, + { choice: [2, 1, 3], balance: 2.75, scores: [2.75] }, + { choice: [3, 2, 1], balance: 0.25, scores: [0.25] } + ]; + + return { + proposal, + strategies, + votes + }; +}; + +// Helper function to create example with high voting powers +const exampleWithHighPowers = () => { + const proposal = { + choices: ['Alice', 'Bob', 'Carol'] + }; + const strategies = [{ name: 'ticket', network: '1', params: {} }]; + const votes = [ + { choice: [1, 2, 3], balance: 1000000, scores: [1000000] }, + { choice: [2, 1, 3], balance: 2500000, scores: [2500000] }, + { choice: [3, 2, 1], balance: 1500000, scores: [1500000] } + ]; + + return { + proposal, + strategies, + votes + }; +}; + // Test cases for getScores method test.each([ [example.proposal, example.votes, example.strategies], @@ -81,6 +119,42 @@ test.each([ expect(copeland.getScoresByStrategy()).toMatchSnapshot(); }); +// Add test for verifying strategy normalization +test('getScoresByStrategy normalizes correctly', () => { + const proposal = { + choices: ['Alice', 'Bob', 'Carol'] + }; + const strategies = [ + { name: 'ticket', network: '1', params: {} }, + { name: 'erc20-balance-of', network: '1', params: {} } + ]; + const votes = [ + { choice: [1, 2, 3], balance: 10, scores: [5, 15] }, + { choice: [2, 3, 1], balance: 20, scores: [10, 30] }, + { choice: [3, 1, 2], balance: 15, scores: [7.5, 22.5] } + ]; + + const copeland = new CopelandVoting(proposal, votes, strategies, [1]); + + const scoresByStrategy = copeland.getScoresByStrategy(); + expect(scoresByStrategy).toMatchSnapshot(); + + // Verify totals per strategy + const strategyTotals = [22.5, 67.5]; // Sum of all votes per strategy + + // Sum the scores for each strategy + const strategyTotalResults = [0, 0]; + for (let i = 0; i < strategies.length; i++) { + for (let j = 0; j < proposal.choices.length; j++) { + strategyTotalResults[i] += scoresByStrategy[j][i]; + } + } + + // Verify total voting power is preserved for each strategy + expect(strategyTotalResults[0]).toBeCloseTo(strategyTotals[0], 5); + expect(strategyTotalResults[1]).toBeCloseTo(strategyTotals[1], 5); +}); + // Test cases for getScoresTotal method test.each([ [example.proposal, example.votes, example.strategies], @@ -127,3 +201,30 @@ test('Partial ranking', () => { ); expect(copeland.getScores()).toMatchSnapshot(); }); + +test('getScores with mixed voting powers', () => { + const proposal = { + choices: ['Alice', 'Bob', 'Carol'] + }; + const votes = [ + { choice: [1, 2, 3], balance: 1000000.75, scores: [1000000.75] }, + { choice: [2, 3, 1], balance: 0.25, scores: [0.25] }, + { choice: [3, 1, 2], balance: 2500.5, scores: [2500.5] } + ]; + const copeland = new CopelandVoting(proposal, votes, example.strategies, [1]); + const scores = copeland.getScores(); + expect(scores).toMatchSnapshot(); + // Verify total voting power is preserved + expect(scores.reduce((a, b) => a + b)).toBeCloseTo(1002501.5, 5); +}); + +test('getScores with 0 votes', () => { + const proposal = { + choices: ['Alice', 'Bob', 'Carol'] + }; + const votes = []; + const copeland = new CopelandVoting(proposal, votes, example.strategies, [1]); + const scores = copeland.getScores(); + expect(scores).toMatchSnapshot(); + // Verify total voting power is preserved +}); diff --git a/src/voting/copeland.ts b/src/voting/copeland.ts index 859f0286c..ebd0e0762 100644 --- a/src/voting/copeland.ts +++ b/src/voting/copeland.ts @@ -57,37 +57,64 @@ export default class CopelandVoting { const pairwiseComparisons = Array.from({ length: choicesCount }, () => Array(choicesCount).fill(0) ); + const totalVotingPower = this.getScoresTotal(); // 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; + for ( + let currentRank = 0; + currentRank < vote.choice.length; + currentRank++ + ) { + for ( + let nextRank = currentRank + 1; + nextRank < vote.choice.length; + nextRank++ + ) { + const preferredChoice = vote.choice[currentRank] - 1; + const lowerChoice = vote.choice[nextRank] - 1; + pairwiseComparisons[preferredChoice][lowerChoice] += vote.balance; + pairwiseComparisons[lowerChoice][preferredChoice] -= 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]++; + let totalCopelandScore = 0; + + for (let choiceIndex = 0; choiceIndex < choicesCount; choiceIndex++) { + for ( + let opponentIndex = 0; + opponentIndex < choicesCount; + opponentIndex++ + ) { + if (choiceIndex !== opponentIndex) { + const comparison = pairwiseComparisons[choiceIndex][opponentIndex]; + if (comparison > 0) { + scores[choiceIndex]++; + } else if (comparison < 0) { + scores[opponentIndex]++; } else { - scores[i] += 0.5; - scores[j] += 0.5; + scores[choiceIndex] += 0.5; + scores[opponentIndex] += 0.5; } } } } - return scores; + // Calculate total Copeland score for normalization + totalCopelandScore = scores.reduce((sum, score) => sum + score, 0); + + // Normalize scores to distribute voting power + if (totalCopelandScore > 0) { + return scores.map( + (score) => (score / totalCopelandScore) * totalVotingPower + ); + } + + // If no clear winners, distribute power equally + return scores.map(() => totalVotingPower / choicesCount); } // Calculates the Copeland scores for each choice, broken down by strategy @@ -99,15 +126,37 @@ export default class CopelandVoting { Array.from({ length: choicesCount }, () => Array(strategiesCount).fill(0)) ); + // Calculate total voting power per strategy + const strategyTotals = Array(strategiesCount).fill(0); + for (const vote of validVotes) { + for (let i = 0; i < strategiesCount; i++) { + strategyTotals[i] += vote.scores[i]; + } + } + // 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]; + for ( + let currentRank = 0; + currentRank < vote.choice.length; + currentRank++ + ) { + for ( + let nextRank = currentRank + 1; + nextRank < vote.choice.length; + nextRank++ + ) { + const preferredChoice = vote.choice[currentRank] - 1; + const lowerChoice = vote.choice[nextRank] - 1; + for ( + let strategyIndex = 0; + strategyIndex < strategiesCount; + strategyIndex++ + ) { + pairwiseComparisons[preferredChoice][lowerChoice][strategyIndex] += + vote.scores[strategyIndex]; + pairwiseComparisons[lowerChoice][preferredChoice][strategyIndex] -= + vote.scores[strategyIndex]; } } } @@ -118,24 +167,66 @@ export default class CopelandVoting { 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]++; + for (let choiceIndex = 0; choiceIndex < choicesCount; choiceIndex++) { + for ( + let opponentIndex = 0; + opponentIndex < choicesCount; + opponentIndex++ + ) { + if (choiceIndex !== opponentIndex) { + for ( + let strategyIndex = 0; + strategyIndex < strategiesCount; + strategyIndex++ + ) { + const comparison = + pairwiseComparisons[choiceIndex][opponentIndex][strategyIndex]; + if (comparison > 0) { + scores[choiceIndex][strategyIndex]++; + } else if (comparison < 0) { + scores[opponentIndex][strategyIndex]++; } else { - scores[i][s] += 0.5; - scores[j][s] += 0.5; + scores[choiceIndex][strategyIndex] += 0.5; + scores[opponentIndex][strategyIndex] += 0.5; } } } } } - return scores; + // Normalize scores by strategy to distribute voting power + const normalizedScores = Array.from({ length: choicesCount }, () => + Array(strategiesCount).fill(0) + ); + + for ( + let strategyIndex = 0; + strategyIndex < strategiesCount; + strategyIndex++ + ) { + // Calculate total Copeland score for this strategy + let totalCopelandScore = 0; + for (let choiceIndex = 0; choiceIndex < choicesCount; choiceIndex++) { + totalCopelandScore += scores[choiceIndex][strategyIndex]; + } + + // Normalize scores to distribute voting power for this strategy + if (totalCopelandScore > 0) { + for (let choiceIndex = 0; choiceIndex < choicesCount; choiceIndex++) { + normalizedScores[choiceIndex][strategyIndex] = + (scores[choiceIndex][strategyIndex] / totalCopelandScore) * + strategyTotals[strategyIndex]; + } + } else if (strategyTotals[strategyIndex] > 0) { + // If no clear winners, distribute power equally for this strategy + for (let choiceIndex = 0; choiceIndex < choicesCount; choiceIndex++) { + normalizedScores[choiceIndex][strategyIndex] = + strategyTotals[strategyIndex] / choicesCount; + } + } + } + + return normalizedScores; } // Calculates the total score (sum of all valid vote balances) From 8eb7059d16b3fd45d4fa75745d79ab131033f018 Mon Sep 17 00:00:00 2001 From: Chaitanya Date: Mon, 3 Mar 2025 16:10:44 +0530 Subject: [PATCH 2/2] v0.12.53 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2f8a15d3c..de0024a51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@snapshot-labs/snapshot.js", - "version": "0.12.52", + "version": "0.12.53", "repository": "snapshot-labs/snapshot.js", "license": "MIT", "main": "dist/snapshot.cjs.js",