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.
@@ -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=
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
@@ -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 &copy; 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"
@@ -0,0 +1,5 @@
1
+ require 'rspec/core/rake_task'
2
+
3
+ RSpec::Core::RakeTask.new(:spec)
4
+
5
+ task :default => :spec
@@ -0,0 +1,9 @@
1
+ module Meekster
2
+ end
3
+
4
+ require 'meekster/ballot'
5
+ require 'meekster/ballot_file'
6
+ require 'meekster/candidate'
7
+ require 'meekster/election'
8
+ require 'meekster/round'
9
+ require 'meekster/version'
@@ -0,0 +1,9 @@
1
+ require File.expand_path('../meekster', File.dirname(__FILE__))
2
+
3
+ class Meekster::Ballot
4
+ attr_accessor :ranking, :weight
5
+
6
+ def initialize(ranking=nil)
7
+ self.ranking = ranking
8
+ end
9
+ end
@@ -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