Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ballot counter for two-outcome elections #3233

Merged
merged 10 commits into from
Jun 20, 2021
Merged

Conversation

Chris-Hibbert
Copy link
Contributor

doesn't handle Quorum requirements

see: #3185

@Chris-Hibbert Chris-Hibbert added this to the Beta Phase 4: Governance milestone Jun 1, 2021
@Chris-Hibbert Chris-Hibbert self-assigned this Jun 1, 2021
@Chris-Hibbert Chris-Hibbert marked this pull request as draft June 7, 2021 22:52
@Chris-Hibbert Chris-Hibbert marked this pull request as ready for review June 8, 2021 22:23
@Chris-Hibbert Chris-Hibbert force-pushed the buildBallot-3188 branch 2 times, most recently from d00b094 to e0f4f4a Compare June 11, 2021 00:22
@Chris-Hibbert Chris-Hibbert changed the base branch from gov-lib-3186 to master June 11, 2021 16:55
Copy link
Member

@dtribble dtribble left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WIP

packages/governance/src/binaryBallotCounter.js Outdated Show resolved Hide resolved
}
});

const stats = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems to me like the tally should be really actionable. Otherwise how do enforcement mechanisms work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean by "actionable". My expectation was that clients would wait on the outcome. Most things should be able to decide what to do based on that. If they need the extra data in stats, they can examine it. What would be more "actionable"?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has the counts. It doesn't have the voting decision. Where would the code be that implements a required 2/3rds vote for success?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has the counts. It doesn't have the voting decision.

The outcome is published with a notifier in outcomePromise. If you think tally should also include that, I can add that. Would you do that by modifying the existing stats object, or constructing a new one to return once the quorum check (line 79) and decision (lines 83-89) have been made?

Where would the code be that implements a required 2/3rds vote for success?

The quorumChecker (line 79) has access to all the info in the tally. In the tests, I only have cases where it checks for a strict number, but it's as easy to compare to a fraction.

Copy link
Member

@dtribble dtribble left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Soem items that need discussion and an await that must be fixed

],
};

if (!(await E(quorumChecker).check(stats))) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await needs to be top-level

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks.

WEIGHT: 'weight',
};

const buildBallot = (method, question, positions, maxChoices = 1) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a maxChoices value to specify "no limit"? Should that be the default instead of 1? (e.g., if you don't provide a limit, there isn't one)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's reasonable. I changed the default to 0.

packages/governance/src/ballotBuilder.js Show resolved Hide resolved
});

const creatorFacet = Far('adminFacet', {
closeVoting: () => (isOpen = false),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(isOpen = false) seems a bad pattern; it's assignment in an expression, which we typically don't allow. It's not obvious that the result is intended to be returned. That should be { isOpen = false; }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooo, thanks! I blame WebStorm. It offered to "drop the extraneous braces", and then insisted that I add parens around the resulting bare assignment. I'll watch for this in the future.

}
});

const stats = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has the counts. It doesn't have the voting decision. Where would the code be that implements a required 2/3rds vote for success?

Copy link
Contributor

@katelynsills katelynsills left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things:

  1. I think returning a string if it's a tie is going to make things difficult for downstream clients. We should return undefined or a pre-provided default.
  2. I don't understand why we are asking asynchronously whether we have a quorum. Seems like something the ballotCounter should know ahead of time, in the same ways as the question and positions.
  3. We need to be very clear about what is a user-defined value that hasn't been validated yet, and what has already been validated. A few things are never validated.
  4. We need to add types since that will catch a lot that reviewing might miss, and makes reviewing easier.

import { E } from '@agoric/eventual-send';
import { ChoiceMethod, buildBallot } from './ballotBuilder';

const TIE_VOTE = "It's a tie!";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the outcome of the vote is going to be used directly to change a parameter, I think we really do not want to return a string indicating that there was a tie.

Two alternatives: we return undefined in the case of a tie, which requires the downstream client of this to handle ties directly, or we allow the creator of the ballot to pick a default when creating the ballot counter. This default is returned in the case of a tie. I think this might be really useful, especially if binaryBallots are mostly used for YES/NO votes and we want to have a preference of no change (i.e. NO) in the case of a tie.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the idea of specifying the default when the choice is action/no action, but it can't be required because there are both binary questions without a fallback (choosing one of two candidates) and cases with multiple positions where there is no default.

I think I have to go with undefined. That is a better signal than my string.


const start = zcf => {
const { question, positions } = zcf.getTerms();
return makeBinaryBallotCounter(question, positions[0], positions[1]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if positions.length > 2? Can we fail fast with an assert here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put the check as the first thing inside makeBinaryBallotCounter(), so it'll fail fast here, too.

// WEIGHT: voter lists their choices, each with a numerical weight. High
// numbers are most preferred.

const ChoiceMethod = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs types

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

WEIGHT: 'weight',
};

const buildBallot = (method, question, positions, maxChoices = 0) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs types

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

WEIGHT: 'weight',
};

const buildBallot = (method, question, positions, maxChoices = 0) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all of these files, question is never validated. If binaryBallotCounter is the origin of question, we need to clearly add assertions there, and then add a comment here that this function is past the validation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added an assertion in binaryBallotCounter, but that won't be the only client of this method, so I added it here as well.

closeVoting: () => {
isOpen = false;
},
countVotes,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't closing the vote count the votes? Why a separate call here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you convinced me.

};

// Exported for testing purposes
const makeBinaryBallotCounter = (question, aName, bName) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to not use the positions argument here too? It seems like the flow is positions in terms => aName, bName in makeBinaryBallotCounter => positions in makeBinaryBallot. Seems better to just pass it through after validating.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

};

return {
getMethod: () => method,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems strange to make these getters when the data doesn't change. Why not return a record with all the information?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope I haven't misunderstood. I added a method that returns a record containing all the details. I think since we want to return choose() it has to be all methods.


for (const position of chosenPositions) {
assert(
positions.includes(position),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this work with WEIGHT? Seems like it only works if the chosenPositions is a list of strings. But types would help find cases like this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Binary ballot only needs unweighted ballots. I've added some type declarations and some indications of where the unimplemented code would go. I don't think ORDER and CHOOSE_N ballots should use the same representation as WEIGHT.

I also changed the variable in binaryBallotCounter for the strength of a ballot from weight to shares since it's a completely different concept.


// ballot template has position choices; Each ballot in allBallots should
// match. count the valid ballots and report results.
const [positionA, positionB] = template.getPositions();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we getting these from the template because we don't trust aName and bName? Can we make that explicit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reduced.

@Chris-Hibbert Chris-Hibbert force-pushed the buildBallot-3188 branch 3 times, most recently from ee7c381 to e943891 Compare June 18, 2021 22:54
Copy link
Contributor

@katelynsills katelynsills left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All comments are small, and the only thing that I think is necessary is to keep @agoric/nat as a dependency and use it to check BIGINT (should have been in previous PR but wasn't caught)

@@ -31,11 +31,12 @@
"homepage": "https://github.com/Agoric/agoric-sdk#readme",
"dependencies": {
"@agoric/assert": "^0.3.0",
"@agoric/captp": "^1.7.15",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is used anywhere

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dropped

"@agoric/marshal": "^0.4.13",
"@agoric/nat": "^4.1.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, we need this. I realized paramManager ParamType NAT needs to ensure it is both a bigint and a Nat (not a negative bigint)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reinstated

*/

/**
* @typedef {Object} QurorumCounter
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo here that prevents it from being used

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

assert.typeof(positionBName, 'string');
positions.push(positionAName, positionBName);

return buildBallot(ChoiceMethod.CHOOSE_N, question, positions, 1n);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we're still splitting up positions into positionAName and positionBName, and then re-making an array called positions. Why?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dropped

question,
positions,
threshold,
tieOutcome = undefined,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!!

const makeQuorumCounter = quorumThreshold => {
const check = stats => {
const votes = stats.results.reduce(
(runningTotal, { total }) => runningTotal + total,
0n,
);
return votes >= quorumThreshold;
};
/** @type {QuorumCounter} */
return Far('checker', { check });
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this just be:

Suggested change
const makeQuorumCounter = quorumThreshold => {
const check = stats => {
const votes = stats.results.reduce(
(runningTotal, { total }) => runningTotal + total,
0n,
);
return votes >= quorumThreshold;
};
/** @type {QuorumCounter} */
return Far('checker', { check });
};
const checkQuorum = (threshold, stats) => {
const votes = stats.results.reduce(
(runningTotal, { total }) => runningTotal + total,
0n,
);
return votes >= threshold;
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would work, but it wouldn't be compatible with the pluggable QuorumCounter that I intend to use across different counters.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not objecting, but I would be surprised if the quorum checker was remote. That seems more like a module at most rather than a separate remote contract

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's plausible; I'm moving in that direction with my implementation of closure rules, which is next. I'm just not positive that we can foresee every quorum rule, which means it has to be an external contract/component.

X`The default outcome on a tie must be one of the positions, not ${tieOutcome}`,
);
}
const template = makeBinaryBallot(question, aName, bName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const template = makeBinaryBallot(question, aName, bName);
const template = makeBinaryBallot(question, positions);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


if (!makeQuorumCounter(threshold).check(stats)) {
outcomePromise.reject('No quorum');
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that the tally promise never resolves?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. thanks. moved the tally resolution up.

* @param {ChoiceMethod} method
* @param {string} question
* @param {string[]} positions
* @param {bigint} maxChoices
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want this to be a bigint, if we are comparing it to .length, a number? I think none of the code will error as is, but it might be nice for consistency

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

/**
* @typedef {Object} CompleteOrderedBallot
* @property {string} question
* @property {string[]} ordered - ordered list of position from most prefered to
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo in preferred and in line 114

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@Chris-Hibbert Chris-Hibbert force-pushed the buildBallot-3188 branch 2 times, most recently from 26b31ce to c297a97 Compare June 20, 2021 02:52
Add types
flesh out different ballot types a tiny bit
moved quorum counting inside binaryBallotCounter
More assertions
dropped externally visible countVotes(), getQuestionPositions()
dependency updates
Made tie result be a default or undefined
simplify ballotCounter creating ballots
resolve tally promise earlier
typos
Copy link
Member

@dtribble dtribble left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments but nothing blocking.

X`only ${maxChoices} position(s) allowed`,
);

for (const position of chosenPositions) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be better with a single assert of a compound check; e.g.,

assert(chosenPositions.every(
          p => positions.includes(p), 
          details`Some positions in ${chosenPositions} are not valid in ${positions}`)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

packages/governance/src/ballotBuilder.js Show resolved Hide resolved
case ChoiceMethod.ORDER:
throw Error(`choice method ${ChoiceMethod.ORDER} is unimplemented`);
case ChoiceMethod.WEIGHT:
throw Error(`choice method ${ChoiceMethod.WEIGHT} is unimplemented`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor: I would share the unimplemented code by putting both labels on the same unimplemented case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done. yarn lint didn't object.

const makeQuorumCounter = quorumThreshold => {
const check = stats => {
const votes = stats.results.reduce(
(runningTotal, { total }) => runningTotal + total,
0n,
);
return votes >= quorumThreshold;
};
/** @type {QuorumCounter} */
return Far('checker', { check });
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not objecting, but I would be surprised if the quorum checker was remote. That seems more like a module at most rather than a separate remote contract

@Chris-Hibbert Chris-Hibbert merged commit 6dddaa6 into master Jun 20, 2021
@Chris-Hibbert Chris-Hibbert deleted the buildBallot-3188 branch June 20, 2021 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Governance Governance
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants