-
Notifications
You must be signed in to change notification settings - Fork 205
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
Conversation
7995c4e
to
f620353
Compare
d00b094
to
e0f4f4a
Compare
e0f4f4a
to
1e25870
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WIP
} | ||
}); | ||
|
||
const stats = { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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"?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
1c9ea98
to
a696c43
Compare
There was a problem hiding this 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))) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
}); | ||
|
||
const creatorFacet = Far('adminFacet', { | ||
closeVoting: () => (isOpen = false), |
There was a problem hiding this comment.
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; }
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few things:
- 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. - 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.
- 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.
- 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!"; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs types
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs types
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
}; | ||
|
||
return { | ||
getMethod: () => method, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reduced.
ee7c381
to
e943891
Compare
There was a problem hiding this 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", |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reinstated
packages/governance/src/types.js
Outdated
*/ | ||
|
||
/** | ||
* @typedef {Object} QurorumCounter |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 }); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this just be:
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; | |
}; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const template = makeBinaryBallot(question, aName, bName); | |
const template = makeBinaryBallot(question, positions); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
packages/governance/src/types.js
Outdated
* @param {ChoiceMethod} method | ||
* @param {string} question | ||
* @param {string[]} positions | ||
* @param {bigint} maxChoices |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
packages/governance/src/types.js
Outdated
/** | ||
* @typedef {Object} CompleteOrderedBallot | ||
* @property {string} question | ||
* @property {string[]} ordered - ordered list of position from most prefered to |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
26b31ce
to
c297a97
Compare
doesn't handle Quorum requirements see: #3185
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
c297a97
to
b2c0331
Compare
There was a problem hiding this 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) { |
There was a problem hiding this comment.
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}`)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
case ChoiceMethod.ORDER: | ||
throw Error(`choice method ${ChoiceMethod.ORDER} is unimplemented`); | ||
case ChoiceMethod.WEIGHT: | ||
throw Error(`choice method ${ChoiceMethod.WEIGHT} is unimplemented`); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 }); | ||
}; |
There was a problem hiding this comment.
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
doesn't handle Quorum requirements
see: #3185