Rebuilding Voting, Part 2: Structure
Welcome back! In Part 1 of our Rebuilding Voting series, I laid out the shortcomings of our current voting approach, and highlighted these four requirements for an updated version:
- The ability for Claims and Predictions to enabled to be voted on more than once;
- The ability to record an opinion on an item, whether or not that opinion counts toward an active vote;
- The addition of two new voting states;
- The ability for users to change their votes on an item;
Here in Part 2, I’m going to lay out the structural changes that will need to be made to accommodate these requirements.
1: The ability for Claims and Predictions to enabled to be voted on more than once
The majority of these requirements will be met by the addition of a Vote Set. Currently the model relationship in Rails is:
Claim
has_many :claim_votes, dependent: :destroy
has_many :votes, -> { distinct }, :through => :claim_votesClaimVote
belongs_to :claim, touch: true, :counter_cache => true
belongs_to :voteVote
has_one :claim_vote, dependent: :destroy
has_one :claim, :through => :claim_vote
This is repeated for Predictions, but it’s structurally the same (so I’ll save everybody the reading time by mostly only talking about Claims). It’s pretty straightforward, with a ClaimVote model connecting Votes to Claims. I have it this way so that the Vote model, which contains information about the vote value and the user who voted, can be re-purposed for both Claims and Predictions.
As a user votes on a Claim, a ClaimVote is added, as well as a Vote object, the latter of which contains the vote value (currently 0 or 1). This works for a single round of voting, but to facilitate the ability to re-open a Claim for voting, we need to expand this. The system could simply delete existing votes, but why? It would be counter to the level of data transparency we’re aiming for. So we’re adding a VoteSet model:
VoteSet
has_many :votes user_id // integer, id of user who created the set
reason // string
active // boolean
As well, the Claim model will be updated:
Claim
has_many :vote_sets vote_set_id // integer
As will Vote:
Vote
vote_set_id // integer
This enables us to add an instance of VoteSet, connected to Claim, which we’ll use to determine which votes are currently considered active.
When are vote sets created? For a Claim, a new instance of VoteSet is created upon the Claim’s creation. For Predictions, since they involve future events, they will be created when the Sidekiq task flips the Prediction to active at the specified date.
When a VoteSet is created, we’ll update the vote_set_id in Claim. When a user votes on a claim, we’ll pass that number along to the Vote object. When the voting period is closed, we’ll mark the VoteSet disabled, and set the vote_set_id in Claim to nil, which will ensure that votes cast outside of an active VoteSet will be recorded properly, as opinions.
So to re-open a Claim for voting, we’ll do something like:
Claim.add_vote_setClaim
def add_vote_set(user_id, reason)
vote_set = VoteSet.new({
user_id: user_id,
reason: reason
}) if vote_set.save
self.update_attributes({vote_set_id: vote_set.id})
self.vote_sets << vote_set
end
end
end
Then, the next time we go to vote, like this:
Claim.vote(value = nil, user = nil)Claim def vote(value, user)
@vote = Vote.create({
user_id: user,
is_true: (value == "true" ? true : false),
is_false: (value == "false" ? true : false),
is_unknown: (value == "unknown" ? true : false),
is_unknowable: (value == "unknowable" ? true : false),
vote_set: self.vote_set_id
}) self.votes << @vote
end
end
The correct vote_set will automatically be detected and used! And we’ll be able to keep a historical record of the votes by set, which will come in handy!
2: The ability to record an opinion on an item, whether or not that opinion counts toward an active vote
A tidy benefit of the above structure is that we can still allow people to vote even if the Claim doesn’t have an active vote set. It can be recorded with a vote_set_id of nil, and we can assume those votes are taken either before a Prediction becomes active, or after either a Claim or Prediction has been closed. We can treat these as Opinions, and calculate the overall view of a Claim, counting both opinions and votes, with no extra overhead!
3: The addition of two new voting states
This one will be pretty straightforward: presently the only options are true (0) and false (1), with a vote_value that gets calculated by summing up the votes and averaging them out. Any vote_value of 0.5 or higher means true, anything less means 0.
But that’s insufficient, for reasons I described in Part 1. We will be adding new fields in the Vote model:
Vote
is_true // boolean
is_false // boolean
is_unknown // boolean
is_unknowable // boolean
To generate the vote tallies, we’ll do something like:
Claim
def vote_tally
votes = [
{ type: "true", value: self.votes.where({is_true: true}).where.not({vote_set_id: nil}).count },
{ type: "false", value: self.votes.where({is_false: true}).where.not({vote_set_id: nil}).count },
{ type: "unknown", value: self.votes.where({is_unknown: true}).where.not({vote_set_id: nil}).count },
{ type: "unknowable", value: self.votes.where({is_unknowable: true}).where.not({vote_set_id: nil}).count }
] return votes.max_by{|k| k[:value] }[:type]
end
end
There are a bunch of other code implications of this approach, in terms of what will need to be updated, but as with all of the pseudo-code in this post, it’ll be fleshed out in Part 3.
4: The ability for users to change their votes on an item
Rather than restricting a user to voting only a single time on a given Claim, there will be no restrictions placed. A user can vote once, or they can vote 1,000 times. Each vote will be recorded with the timestamp, which will allow us to see a user’s changing opinion on the given Claim or Prediction. To calculate properly, we’ll just sum up the last votes, by user.
A nice benefit of this approach — I hope — is that if a bad actor were to, say, write a script to submit votes multiple times in order to sway tallies, it wouldn’t work, because we’ll be ignoring all but the last vote they cast!
That’s it for now! In Part 3 I’ll go through the functioning Rails code!
If you want to follow along with the development of Blundit, please follow this Publication on Blundit. Alternatively you can head over to blundit.com and add yourself to our mailing list, and you can load up dev.blundit.com to see the current version of the site, and find links to our public Github repos, public Trello board, and other things.