From 5658c40fa3db04098453353dbdd13fa633769946 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Tue, 1 Jun 2021 15:16:23 -0700 Subject: [PATCH] feat: ballot counter for two-outcome elections doesn't handle Quorum requirements see: #3185 --- packages/governance/src/ballotBuilder.js | 30 +++ .../governance/src/binaryBallotCounter.js | 103 ++++++++++ packages/governance/test/test-ballotCount.js | 190 ++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 packages/governance/src/ballotBuilder.js create mode 100644 packages/governance/src/binaryBallotCounter.js create mode 100644 packages/governance/test/test-ballotCount.js diff --git a/packages/governance/src/ballotBuilder.js b/packages/governance/src/ballotBuilder.js new file mode 100644 index 000000000000..3a61995afb66 --- /dev/null +++ b/packages/governance/src/ballotBuilder.js @@ -0,0 +1,30 @@ +// @ts-check + +import { assert, details as X } from '@agoric/assert'; + +// CHOOSE_ONE: voter indicates only their favorite. ORDER: voter lists their +// choices from most to least favorite. RANK: voter lists their choices, each +// with a numerical ranking. Low numbers are most preferred. +const ChoiceMethod = { + CHOOSE_ONE: 'choose_one', + ORDER: 'order', + RANK: 'rank', +}; + +function buildBallot(method, question, positions) { + function choose(position) { + assert(positions.includes(position), X`Not a valid position: ${position}`); + return { question, chosen: [position] }; + } + + return { + getMethod: () => method, + getQuestion: () => question, + getPositions: () => positions, + choose, + }; +} + +harden(buildBallot); + +export { ChoiceMethod, buildBallot }; diff --git a/packages/governance/src/binaryBallotCounter.js b/packages/governance/src/binaryBallotCounter.js new file mode 100644 index 000000000000..11def185dbe5 --- /dev/null +++ b/packages/governance/src/binaryBallotCounter.js @@ -0,0 +1,103 @@ +// @ts-check + +import { assert, details as X } from '@agoric/assert'; +import { makeStore } from '@agoric/store'; +import { makePromiseKit } from '@agoric/promise-kit'; +import { Far } from '@agoric/marshal'; + +import { ChoiceMethod, buildBallot } from './ballotBuilder'; + +function makeWeightedBallot(ballot, weight) { + return { ballot, weight }; +} + +function makeBinaryBallot(question, positionAName, positionBName) { + const positions = []; + assert.typeof(positionAName, 'string'); + assert.typeof(positionBName, 'string'); + positions.push(positionAName, positionBName); + + return buildBallot(ChoiceMethod.CHOOSE_ONE, question, positions); +} + +function makeBinaryBallotCounter(question, aName, bName) { + const template = makeBinaryBallot(question, aName, bName); + + assert( + template.getMethod() === ChoiceMethod.CHOOSE_ONE, + X`Binary ballot counter only works with CHOOSE_ONE`, + ); + let isOpen = true; + const outcomePromise = makePromiseKit(); + const tallyPromise = makePromiseKit(); + const allBallots = makeStore('seat'); + + // TODO: quorum: by weight, by proportion + const quorum = true; + + function recordBallot(seat, filledBallot, weight = 1n) { + allBallots.has(seat) + ? allBallots.set(seat, makeWeightedBallot(filledBallot, weight)) + : allBallots.init(seat, makeWeightedBallot(filledBallot, weight)); + } + + function countVotes() { + assert(!isOpen, X`can't count votes while the election is open`); + + // ballot template has position choices; Each ballot in allBallots should + // match. count the valid ballots and report results. + const [positionA, positionB] = template.getPositions(); + let spoiled = 0n; + const tally = { + [positionA]: 0n, + [positionB]: 0n, + }; + + allBallots.entries().forEach(([_, { ballot, weight }]) => { + const choice = ballot.chosen[0]; + if (!template.getPositions().includes(choice)) { + spoiled += weight; + } else { + tally[choice] += weight; + } + }); + if (!quorum) { + outcomePromise.reject('No quorum'); + } + + if (tally[positionA] > tally[positionB]) { + outcomePromise.resolve(positionA); + } else if (tally[positionB] > tally[positionA]) { + outcomePromise.resolve(positionB); + } else { + outcomePromise.resolve("It's a tie!"); + } + + const stats = { + spoiled, + votes: allBallots.entries().length, + results: [ + { position: positionA, total: tally[positionA] }, + { position: positionB, total: tally[positionB] }, + ], + }; + tallyPromise.resolve(stats); + } + + const adminFacet = Far('adminFacet', { + closeVoting: () => (isOpen = false), + countVotes, + submitVote: recordBallot, + }); + + const publicFacet = Far('publicFacet', { + getBallotTemplate: () => template, + isOpen: () => isOpen, + getOutcome: () => outcomePromise.promise, + getStats: () => tallyPromise.promise, + }); + return { publicFacet, adminFacet }; +} +harden(makeBinaryBallotCounter); + +export { makeBinaryBallotCounter }; diff --git a/packages/governance/test/test-ballotCount.js b/packages/governance/test/test-ballotCount.js new file mode 100644 index 000000000000..96855b3b9a58 --- /dev/null +++ b/packages/governance/test/test-ballotCount.js @@ -0,0 +1,190 @@ +// @ts-check + +import { test } from '@agoric/zoe/tools/prepare-test-env-ava'; +import '@agoric/zoe/exported'; +import { E } from '@agoric/eventual-send'; + +import { makeHandle } from '@agoric/zoe/src/makeHandle'; +import { makeBinaryBallotCounter } from '../src/binaryBallotCounter'; + +const QUESTION = 'Fish or cut bait?'; +const FISH = 'Fish'; +const BAIT = 'Cut Bait'; + +test('binary ballot', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceSeat = makeHandle('Seat'); + + const alicePositions = aliceTemplate.getPositions(); + t.deepEqual(alicePositions.length, 2); + t.deepEqual(alicePositions[0], FISH); + adminFacet.submitVote(aliceSeat, aliceTemplate.choose(alicePositions[0])); + adminFacet.closeVoting(); + adminFacet.countVotes(); + const outcome = await E(publicFacet).getOutcome(); + t.deepEqual(outcome, FISH); +}); + +test('binary spoiled', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceSeat = makeHandle('Seat'); + + const alicePositions = aliceTemplate.getPositions(); + t.deepEqual(alicePositions.length, 2); + t.deepEqual(alicePositions[0], FISH); + adminFacet.submitVote(aliceSeat, { + question: QUESTION, + chosen: ['no'], + }); + adminFacet.closeVoting(); + adminFacet.countVotes(); + const outcome = await E(publicFacet).getOutcome(); + t.deepEqual(outcome, "It's a tie!"); + const tally = await E(publicFacet).getStats(); + t.deepEqual(tally.spoiled, 1n); +}); + +test('binary tied', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceSeat = makeHandle('Seat'); + const bobSeat = makeHandle('Seat'); + + const positions = aliceTemplate.getPositions(); + adminFacet.submitVote(aliceSeat, aliceTemplate.choose(positions[0])); + adminFacet.submitVote(bobSeat, aliceTemplate.choose(positions[1])); + adminFacet.closeVoting(); + adminFacet.countVotes(); + const outcome = await E(publicFacet).getOutcome(); + t.deepEqual(outcome, "It's a tie!"); +}); + +test('binary bad vote', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceSeat = makeHandle('Seat'); + + t.throws( + () => adminFacet.submitVote(aliceSeat, aliceTemplate.choose('worms')), + { + message: 'Not a valid position: "worms"', + }, + ); +}); + +test('binary no votes', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + + adminFacet.closeVoting(); + adminFacet.countVotes(); + const outcome = await E(publicFacet).getOutcome(); + t.deepEqual(outcome, "It's a tie!"); +}); + +test('binary still open', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceSeat = makeHandle('Seat'); + + const alicePositions = aliceTemplate.getPositions(); + t.deepEqual(alicePositions.length, 2); + t.deepEqual(alicePositions[0], 'Fish'); + adminFacet.submitVote(aliceSeat, aliceTemplate.choose(alicePositions[0])); + t.throws(() => adminFacet.countVotes(), { + message: `can't count votes while the election is open`, + }); +}); + +test('binary weights', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceSeat = makeHandle('Seat'); + + const alicePositions = aliceTemplate.getPositions(); + t.deepEqual(alicePositions.length, 2); + t.deepEqual(alicePositions[0], 'Fish'); + adminFacet.submitVote( + aliceSeat, + aliceTemplate.choose(alicePositions[0]), + 37n, + ); + adminFacet.closeVoting(); + adminFacet.countVotes(); + const outcome = await E(publicFacet).getOutcome(); + t.deepEqual(outcome, 'Fish'); +}); + +test('binary contested', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + const template = publicFacet.getBallotTemplate(); + const aliceSeat = makeHandle('Seat'); + const bobSeat = makeHandle('Seat'); + + const positions = template.getPositions(); + t.deepEqual(positions.length, 2); + + adminFacet.submitVote(aliceSeat, template.choose(positions[0]), 23n); + adminFacet.submitVote(bobSeat, template.choose(positions[1]), 47n); + adminFacet.closeVoting(); + adminFacet.countVotes(); + + const outcome = await E(publicFacet).getOutcome(); + t.deepEqual(outcome, BAIT); +}); + +test('binary revote', async t => { + const { publicFacet, adminFacet } = makeBinaryBallotCounter( + QUESTION, + FISH, + BAIT, + ); + const template = publicFacet.getBallotTemplate(); + const aliceSeat = makeHandle('Seat'); + const bobSeat = makeHandle('Seat'); + + const positions = template.getPositions(); + t.deepEqual(positions.length, 2); + + adminFacet.submitVote(aliceSeat, template.choose(positions[0]), 23n); + adminFacet.submitVote(bobSeat, template.choose(positions[1]), 47n); + adminFacet.submitVote(bobSeat, template.choose(positions[1]), 15n); + adminFacet.closeVoting(); + adminFacet.countVotes(); + + const outcome = await E(publicFacet).getOutcome(); + t.deepEqual(outcome, FISH); +});