-
Notifications
You must be signed in to change notification settings - Fork 217
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: ballot counter for two-outcome elections
doesn't handle Quorum requirements see: #3185
- Loading branch information
1 parent
e3ea9aa
commit 45c427b
Showing
3 changed files
with
323 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); |