meekster 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|