Skip to content

Commit

Permalink
feat: ballot counter for two-outcome elections
Browse files Browse the repository at this point in the history
doesn't handle Quorum requirements

see: #3185
  • Loading branch information
Chris-Hibbert committed Jun 1, 2021
1 parent 84c1c20 commit 7995c4e
Show file tree
Hide file tree
Showing 3 changed files with 322 additions and 0 deletions.
30 changes: 30 additions & 0 deletions packages/governance/src/ballotBuilder.js
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 };
102 changes: 102 additions & 0 deletions packages/governance/src/binaryBallotCounter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @ts-check

import { assert, details as X } from '@agoric/assert';
import { makeStore } from '@agoric/store';

import { makePromiseKit } from '@agoric/promise-kit';
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 = {
closeVoting: () => (isOpen = false),
countVotes,
submitVote: recordBallot,
};

const publicFacet = {
getBallotTemplate: () => template,
isOpen: () => isOpen,
getOutcome: () => outcomePromise.promise,
getStats: () => tallyPromise.promise,
};
return { publicFacet, adminFacet };
}
harden(makeBinaryBallotCounter);

export { makeBinaryBallotCounter };
190 changes: 190 additions & 0 deletions packages/governance/test/test-ballotCount.js
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);
});

0 comments on commit 7995c4e

Please sign in to comment.