meekster 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.gitignore +17 -0
- data/Gemfile +3 -0
- data/README.md +31 -0
- data/Rakefile +5 -0
- data/lib/meekster.rb +9 -0
- data/lib/meekster/ballot.rb +9 -0
- data/lib/meekster/ballot_file.rb +60 -0
- data/lib/meekster/candidate.rb +13 -0
- data/lib/meekster/election.rb +85 -0
- data/lib/meekster/round.rb +211 -0
- data/lib/meekster/version.rb +3 -0
- data/meekster.gemspec +19 -0
- data/spec/ballot_file_spec.rb +40 -0
- data/spec/ballot_files/42.blt +8 -0
- data/spec/ballot_files/Anderston-City-2007.blt +2043 -0
- data/spec/meekster_spec.rb +151 -0
- data/spec/round_spec.rb +22 -0
- metadata +93 -0
checksums.yaml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
MzMyNTE4M2Q1YmUwMTRhNDQ4YjE2YzgzZmJkMmUwZWU4ZjYyOGNhZA==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
OWJmYzcxYmJiNjI2Y2QxMDY3Yzk2YjM3YjY4MmEzNzBiNjI3OGQ4Mw==
|
7
|
+
!binary "U0hBNTEy":
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
MDhlODM1MmJhYWNkYzUwMzc0NmI3NmJlOTI1YTJjNDQwZWRjZmVjNTQ1NmQw
|
10
|
+
MjMzYmQxZTAwMTZiYjY0ZjBhNjAwOTFjNmU0Y2JhYTg2OTJmMjJiYTA0MTA1
|
11
|
+
ZTQ4YjYzNjhlMTc5Mzg5ODRkMTA3YmE5MDY3ZmQwYzk3ZTY1NDI=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
YWJmMTc0N2ZmNTM4MjJlNzFlYWM5ZmFjYzYzNTkxZDllMDdkNTRlYWE5ZDI1
|
14
|
+
MjRmZjZiZWEwMzEzNzNjZTZjZDQ1MDA5ODdmYjc2YTU4ZDk1MTBkNzEwZmJi
|
15
|
+
NWNmMTdiNTBiMjg2MzM3MGU1YWFiYzU2ZGNjYzAwMTQwMWM3Nzc=
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
Meekster
|
2
|
+
========
|
3
|
+
|
4
|
+
Meekster implements a Meek Single Transferable Vote (STV) voting system, as described in the [Proportional Representation Foundation's] [1] [Reference Meek Rule] [2], in Ruby.
|
5
|
+
|
6
|
+
This software is a work-in-progress. You probably shouldn't use it for an actual election (yet).
|
7
|
+
|
8
|
+
Some testing has been done against [Droop] [3], a Python Library that implements STV. Thanks to the authors of Droop for the software and test ballot files.
|
9
|
+
|
10
|
+
License
|
11
|
+
-------
|
12
|
+
|
13
|
+
Copyright © 2012 Chris Mear <chris@feedmechocolate.com>.
|
14
|
+
|
15
|
+
This program is free software: you can redistribute it and/or modify
|
16
|
+
it under the terms of the GNU General Public License as published by
|
17
|
+
the Free Software Foundation, either version 3 of the License, or
|
18
|
+
(at your option) any later version.
|
19
|
+
|
20
|
+
This program is distributed in the hope that it will be useful,
|
21
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
22
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
23
|
+
GNU General Public License for more details.
|
24
|
+
|
25
|
+
You should have received a copy of the GNU General Public License
|
26
|
+
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
27
|
+
|
28
|
+
|
29
|
+
[1]: http://prfound.org/ "Proportional Representation Foundation"
|
30
|
+
[2]: http://prfound.org/resources/reference/reference-meek-rule/ "Reference Meek Rule"
|
31
|
+
[3]: http://code.google.com/p/droop/ "Droop"
|
data/Rakefile
ADDED
data/lib/meekster.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
require File.expand_path('ballot', File.dirname(__FILE__))
|
2
|
+
require File.expand_path('candidate', File.dirname(__FILE__))
|
3
|
+
|
4
|
+
class Meekster::BallotFile
|
5
|
+
attr_accessor :candidates, :ballots, :seats
|
6
|
+
|
7
|
+
def initialize(options={})
|
8
|
+
if options[:filename]
|
9
|
+
@file = File.open(options[:filename], 'r')
|
10
|
+
elsif options[:string]
|
11
|
+
@file = StringIO.new(options[:string])
|
12
|
+
elsif options[:file]
|
13
|
+
@file = options[:file]
|
14
|
+
end
|
15
|
+
|
16
|
+
@read = false
|
17
|
+
end
|
18
|
+
|
19
|
+
def read!
|
20
|
+
@file.rewind
|
21
|
+
|
22
|
+
@ballots = []
|
23
|
+
|
24
|
+
candidates_and_seats = @file.gets
|
25
|
+
@candidate_count, @seats = candidates_and_seats.split(' ').map{|n| n.to_i}
|
26
|
+
|
27
|
+
@candidates = Array.new(@candidate_count){Meekster::Candidate.new}
|
28
|
+
|
29
|
+
ballot_line = @file.gets
|
30
|
+
|
31
|
+
until ballot_line.match(/^0/)
|
32
|
+
line_atoms = ballot_line.split(' ')
|
33
|
+
|
34
|
+
count = line_atoms.delete_at(0).to_i
|
35
|
+
line_atoms.delete_at(-1)
|
36
|
+
|
37
|
+
ranking = line_atoms.map{|id| @candidates[id.to_i - 1]}
|
38
|
+
|
39
|
+
count.times do
|
40
|
+
@ballots << Meekster::Ballot.new(ranking)
|
41
|
+
end
|
42
|
+
|
43
|
+
ballot_line = @file.gets
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
@candidate_count.times do |i|
|
48
|
+
candidate_name = @file.gets.strip
|
49
|
+
# Remove double-quotes
|
50
|
+
candidate_name = candidate_name.match(/\A\"(.*)\"\Z/)[1]
|
51
|
+
@candidates[i].name = candidate_name
|
52
|
+
end
|
53
|
+
|
54
|
+
@read = true
|
55
|
+
end
|
56
|
+
|
57
|
+
def read?
|
58
|
+
!!@read
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
|
3
|
+
class Meekster::Candidate
|
4
|
+
STATES = [:hopeful, :withdrawn, :elected, :defeated]
|
5
|
+
|
6
|
+
attr_accessor :name, :state, :keep_factor, :votes
|
7
|
+
|
8
|
+
def initialize(name=nil)
|
9
|
+
self.name = name
|
10
|
+
self.keep_factor = BigDecimal("1")
|
11
|
+
self.votes = BigDecimal('0')
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
require File.expand_path('../round', __FILE__)
|
3
|
+
|
4
|
+
class Meekster::Election
|
5
|
+
attr_accessor :ballots, :candidates, :seats
|
6
|
+
|
7
|
+
def initialize(parameters={})
|
8
|
+
if parameters[:ballot_file]
|
9
|
+
bf = parameters[:ballot_file]
|
10
|
+
bf.read! unless bf.read?
|
11
|
+
self.ballots = bf.ballots
|
12
|
+
self.candidates = bf.candidates
|
13
|
+
self.seats = bf.seats
|
14
|
+
end
|
15
|
+
if parameters[:ballots]
|
16
|
+
self.ballots = parameters[:ballots]
|
17
|
+
end
|
18
|
+
if parameters[:candidates]
|
19
|
+
self.candidates = parameters[:candidates]
|
20
|
+
end
|
21
|
+
if parameters[:seats]
|
22
|
+
self.seats = parameters[:seats]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
|
28
|
+
def run!
|
29
|
+
raise RuntimeError, "ballots not found" unless ballots
|
30
|
+
raise RuntimeError, "candidates not found" unless candidates
|
31
|
+
raise RuntimeError, "seats not found" unless seats
|
32
|
+
|
33
|
+
candidates.each do |candidate|
|
34
|
+
candidate.state = :hopeful
|
35
|
+
end
|
36
|
+
|
37
|
+
@omega = BigDecimal("0.000001")
|
38
|
+
|
39
|
+
@rounds = []
|
40
|
+
|
41
|
+
while true do
|
42
|
+
|
43
|
+
round = Meekster::Round.new(
|
44
|
+
:ballots => ballots,
|
45
|
+
:candidates => candidates,
|
46
|
+
:seats => seats,
|
47
|
+
:omega => @omega
|
48
|
+
)
|
49
|
+
|
50
|
+
round.run!
|
51
|
+
|
52
|
+
break if round.count_complete?
|
53
|
+
|
54
|
+
@rounds.push(round)
|
55
|
+
end
|
56
|
+
|
57
|
+
elected_candidates_count = candidates.select{|c| c.state == :elected}.length
|
58
|
+
hopeful_candidates = candidates.select{|c| c.state == :hopeful}
|
59
|
+
if elected_candidates_count < seats
|
60
|
+
hopeful_candidates.each do |hopeful_candidate|
|
61
|
+
hopeful_candidate.state = :elected
|
62
|
+
end
|
63
|
+
else
|
64
|
+
hopeful_candidates.each do |hopeful_candidate|
|
65
|
+
hopeful_candidate.state = :defeated
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
candidates
|
70
|
+
end
|
71
|
+
|
72
|
+
def results
|
73
|
+
output = ""
|
74
|
+
elected_candidates = candidates.select{|c| c.state == :elected}.sort{|a, b| a.votes <=> b.votes}
|
75
|
+
defeated_candidates = candidates.select{|c| c.state == :defeated}.sort{|a, b| a.name <=> b.name}
|
76
|
+
|
77
|
+
elected_candidates.each do |ec|
|
78
|
+
output << "Elected: #{ec.name} (#{ec.votes.to_f})\n"
|
79
|
+
end
|
80
|
+
output << "Defeated: "
|
81
|
+
output << defeated_candidates.map{|dc| dc.name}.join(', ')
|
82
|
+
output << "\n"
|
83
|
+
output
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,211 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
|
3
|
+
class Meekster::Round
|
4
|
+
attr_accessor :ballots, :candidates, :seats, :omega, :quota, :surplus
|
5
|
+
|
6
|
+
def initialize(parameters={})
|
7
|
+
self.ballots = parameters[:ballots]
|
8
|
+
self.candidates = parameters[:candidates]
|
9
|
+
self.seats = parameters[:seats]
|
10
|
+
self.omega = parameters[:omega]
|
11
|
+
|
12
|
+
@candidate_elected_this_round = false
|
13
|
+
end
|
14
|
+
|
15
|
+
def run!
|
16
|
+
if count_complete?
|
17
|
+
return # actually return something?
|
18
|
+
end
|
19
|
+
|
20
|
+
@previous_surpluses = []
|
21
|
+
|
22
|
+
while true do
|
23
|
+
log "STARTING NEW ITERATION"
|
24
|
+
|
25
|
+
log "Keep factors:"
|
26
|
+
candidates.each do |candidate|
|
27
|
+
log " #{candidate.name} | #{candidate.keep_factor.to_f}"
|
28
|
+
end
|
29
|
+
|
30
|
+
reset_votes!
|
31
|
+
|
32
|
+
distribute_votes!
|
33
|
+
|
34
|
+
log "Counted votes:"
|
35
|
+
candidates.each do |candidate|
|
36
|
+
log " #{candidate.name} | #{candidate.votes.to_f}"
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
update_quota!
|
41
|
+
find_winners!
|
42
|
+
calculate_total_surplus!
|
43
|
+
|
44
|
+
if candidate_elected_this_round?
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
if need_to_defeat_low_candidate?
|
49
|
+
defeat_low_candidate!
|
50
|
+
return
|
51
|
+
end
|
52
|
+
|
53
|
+
update_keep_factors!
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def count_complete?
|
58
|
+
# Are all seats filled?
|
59
|
+
elected_candidates_count = candidates.select{|c| c.state == :elected}.length
|
60
|
+
if elected_candidates_count >= seats
|
61
|
+
log "Count complete: enough elected candidates to fill the seats"
|
62
|
+
return true
|
63
|
+
end
|
64
|
+
|
65
|
+
# Is number of elected plus hopeful candidates less than or equal to number of seats?
|
66
|
+
hopeful_candidates_count = candidates.select{|c| c.state == :hopeful}.length
|
67
|
+
if (elected_candidates_count + hopeful_candidates_count) <= seats
|
68
|
+
log "Count complete: elected+hopeful candidates less than or equal to seats"
|
69
|
+
return true
|
70
|
+
end
|
71
|
+
|
72
|
+
false
|
73
|
+
end
|
74
|
+
|
75
|
+
def reset_votes!
|
76
|
+
candidates.each do |candidate|
|
77
|
+
candidate.votes = BigDecimal('0')
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def distribute_votes!
|
82
|
+
ballots.each do |ballot|
|
83
|
+
ballot.weight = BigDecimal('1')
|
84
|
+
|
85
|
+
ballot.ranking.each do |ranked_candidate|
|
86
|
+
|
87
|
+
weight_times_keep_factor = ballot.weight * ranked_candidate.keep_factor
|
88
|
+
weight_times_keep_factor = Meekster::Round.round_up_to_nine_decimal_places(weight_times_keep_factor)
|
89
|
+
|
90
|
+
ranked_candidate.votes += weight_times_keep_factor
|
91
|
+
|
92
|
+
ballot.weight -= weight_times_keep_factor
|
93
|
+
|
94
|
+
break if ballot.weight <= 0
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def update_quota!
|
100
|
+
sum_of_votes = candidates.inject(BigDecimal('0')){|sum, c| sum + c.votes}
|
101
|
+
new_quota = sum_of_votes / (seats + 1)
|
102
|
+
# TODO truncate to nine decimal places
|
103
|
+
new_quota += BigDecimal('0.000000001')
|
104
|
+
log "Updating quota: #{new_quota.to_f}"
|
105
|
+
self.quota = new_quota
|
106
|
+
end
|
107
|
+
|
108
|
+
def find_winners!
|
109
|
+
candidates.select{|c| c.state == :hopeful}.each do |candidate|
|
110
|
+
if candidate.votes >= quota
|
111
|
+
log "Found winner: #{candidate.name}"
|
112
|
+
candidate.state = :elected
|
113
|
+
@candidate_elected_this_round = true
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def calculate_total_surplus!
|
119
|
+
elected_candidates = candidates.select{|c| c.state == :elected}
|
120
|
+
sum_of_surpluses = elected_candidates.inject(BigDecimal('0')){|memo, c| memo + (c.votes - quota)}
|
121
|
+
log "Calculating total surplus. Sum of surpluses: #{sum_of_surpluses.to_f}"
|
122
|
+
if sum_of_surpluses < 0
|
123
|
+
self.surplus = 0
|
124
|
+
@previous_surpluses.push(surplus)
|
125
|
+
else
|
126
|
+
self.surplus = sum_of_surpluses
|
127
|
+
@previous_surpluses.push(surplus)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def candidate_elected_this_round?
|
132
|
+
!!@candidate_elected_this_round
|
133
|
+
end
|
134
|
+
|
135
|
+
def need_to_defeat_low_candidate?
|
136
|
+
if surplus < omega
|
137
|
+
log "Need to defeat low candidate."
|
138
|
+
return true
|
139
|
+
end
|
140
|
+
|
141
|
+
if @previous_surpluses.length > 1 && (surplus >= @previous_surpluses[-2])
|
142
|
+
log "Need to defeat low candidate (surplus greater than previous iteration)."
|
143
|
+
return true
|
144
|
+
end
|
145
|
+
|
146
|
+
log "Do not need to defeat low candidate."
|
147
|
+
false
|
148
|
+
end
|
149
|
+
|
150
|
+
def defeat_low_candidate!
|
151
|
+
log "Defeating lowest candidate:"
|
152
|
+
hopeful_candidates = candidates.select{|c| c.state == :hopeful}
|
153
|
+
candidate_with_lowest_vote = nil
|
154
|
+
hopeful_candidates.each do |hopeful_candidate|
|
155
|
+
if candidate_with_lowest_vote.nil? || hopeful_candidate.votes < candidate_with_lowest_vote.votes
|
156
|
+
candidate_with_lowest_vote = hopeful_candidate
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
log " Lowest candidate: #{candidate_with_lowest_vote.name}"
|
161
|
+
|
162
|
+
# Detect ties
|
163
|
+
|
164
|
+
hopeful_candidates.delete(candidate_with_lowest_vote)
|
165
|
+
tied_candidates = hopeful_candidates.select{|c| c.votes <= (candidate_with_lowest_vote.votes + surplus)}
|
166
|
+
|
167
|
+
if tied_candidates.empty?
|
168
|
+
candidate_to_defeat = candidate_with_lowest_vote
|
169
|
+
else
|
170
|
+
candidates_with_lowest_votes = tied_candidates
|
171
|
+
candidates_with_lowest_votes.push(candidate_with_lowest_vote)
|
172
|
+
candidate_to_defeat = tiebreaker_select(candidates_with_lowest_votes)
|
173
|
+
end
|
174
|
+
|
175
|
+
candidate_to_defeat.state = :defeated
|
176
|
+
candidate_to_defeat.keep_factor = BigDecimal.new('0')
|
177
|
+
end
|
178
|
+
|
179
|
+
def update_keep_factors!
|
180
|
+
log "Updating keep factors:"
|
181
|
+
elected_candidates = candidates.select{|c| c.state == :elected}
|
182
|
+
quota_rounded = Meekster::Round.round_up_to_nine_decimal_places(quota)
|
183
|
+
elected_candidates.each do |elected_candidate|
|
184
|
+
elected_candidate_votes_rounded = Meekster::Round.round_up_to_nine_decimal_places(elected_candidate.votes)
|
185
|
+
new_keep_factor = elected_candidate.keep_factor * quota_rounded
|
186
|
+
new_keep_factor = new_keep_factor / elected_candidate_votes_rounded
|
187
|
+
log " Candidate #{elected_candidate.name}: #{elected_candidate.keep_factor.to_f} -> #{new_keep_factor.to_f}"
|
188
|
+
elected_candidate.keep_factor = new_keep_factor
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def self.round_up_to_nine_decimal_places(x)
|
193
|
+
((x * BigDecimal('1E9')).ceil)/BigDecimal('1E9')
|
194
|
+
end
|
195
|
+
|
196
|
+
def self.truncate_to_nine_decimal_places(x)
|
197
|
+
((x * BigDecimal('1E9')).floor)/BigDecimal('1E9')
|
198
|
+
end
|
199
|
+
|
200
|
+
def tiebreaker_select(array)
|
201
|
+
array[rand(array.length)]
|
202
|
+
end
|
203
|
+
|
204
|
+
# DEBUGGING
|
205
|
+
|
206
|
+
def log(msg)
|
207
|
+
return # Comment out to enable debugging
|
208
|
+
puts msg
|
209
|
+
gets
|
210
|
+
end
|
211
|
+
end
|