Rebuilding Voting, Part 2: Structure

Brian Hogg
Blundit
Published in
5 min readJun 8, 2018
Image from https://hulshofschmidt.wordpress.com

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_votes
ClaimVote
belongs_to :claim, touch: true, :counter_cache => true
belongs_to :vote
Vote
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.

--

--