equalshares 0.1.0
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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +121 -0
- data/exe/equalshares +7 -0
- data/lib/equalshares/cli/formatter.rb +79 -0
- data/lib/equalshares/cli.rb +100 -0
- data/lib/equalshares/comparison.rb +48 -0
- data/lib/equalshares/completion.rb +106 -0
- data/lib/equalshares/compute.rb +19 -0
- data/lib/equalshares/election.rb +77 -0
- data/lib/equalshares/errors.rb +11 -0
- data/lib/equalshares/fixed_budget.rb +242 -0
- data/lib/equalshares/greedy.rb +12 -0
- data/lib/equalshares/instance.rb +60 -0
- data/lib/equalshares/max_flow.rb +89 -0
- data/lib/equalshares/maximin.rb +12 -0
- data/lib/equalshares/mes_general.rb +13 -0
- data/lib/equalshares/pabulib/csv.rb +56 -0
- data/lib/equalshares/pabulib/parser.rb +85 -0
- data/lib/equalshares/pabulib/project_row_parser.rb +60 -0
- data/lib/equalshares/pabulib/vote_row_parser.rb +96 -0
- data/lib/equalshares/pabulib/writer.rb +44 -0
- data/lib/equalshares/pabulib.rb +43 -0
- data/lib/equalshares/params.rb +61 -0
- data/lib/equalshares/phragmen.rb +12 -0
- data/lib/equalshares/result.rb +47 -0
- data/lib/equalshares/rules/base.rb +44 -0
- data/lib/equalshares/rules/cardinal_mes.rb +98 -0
- data/lib/equalshares/rules/greedy.rb +49 -0
- data/lib/equalshares/rules/maximin.rb +119 -0
- data/lib/equalshares/rules/method_of_equal_shares.rb +80 -0
- data/lib/equalshares/rules/phragmen.rb +68 -0
- data/lib/equalshares/satisfaction.rb +39 -0
- data/lib/equalshares/statistics.rb +34 -0
- data/lib/equalshares/tie_breaker.rb +95 -0
- data/lib/equalshares/tie_breaking.rb +48 -0
- data/lib/equalshares/version.rb +5 -0
- data/lib/equalshares.rb +40 -0
- metadata +86 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# The fixed-budget Method of Equal Shares loop.
|
|
5
|
+
#
|
|
6
|
+
# This unifies the two near-identical JS implementations
|
|
7
|
+
# (equalSharesFixedBudgetFractions and equalSharesFixedBudgetFloats in
|
|
8
|
+
# js/methodOfEqualSharesWorker.js) into a single loop driven by the numeric type
|
|
9
|
+
# of the values passed in:
|
|
10
|
+
# - fractions mode: `cost` values and `b_total` are Rational -> exact arithmetic
|
|
11
|
+
# - floats mode: `cost` values and `b_total` are Float -> IEEE-754 arithmetic
|
|
12
|
+
#
|
|
13
|
+
# The arithmetic (order and types) is kept bit-identical to the JS tool; the class
|
|
14
|
+
# below only reorganises the control flow into evaluate/charge steps.
|
|
15
|
+
module FixedBudget
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Dispatch on params.accuracy, preparing cost/budget in the right numeric type.
|
|
19
|
+
# `cost_source` maps project_id => original cost string; `budget_source` is the
|
|
20
|
+
# budget string. This mirrors JS where costs come from parseFloat, then (in the
|
|
21
|
+
# fractions path) get wrapped by new Fraction(...).
|
|
22
|
+
def run(voter_ids, project_ids, cost_source, approvers, budget_source, params,
|
|
23
|
+
report_details: false, progress: nil)
|
|
24
|
+
case params.accuracy
|
|
25
|
+
when "fractions"
|
|
26
|
+
cost = cost_source.transform_values { |c| Election.rational_of(c) }
|
|
27
|
+
b_total = Election.rational_of(budget_source)
|
|
28
|
+
when "floats"
|
|
29
|
+
cost = cost_source.transform_values { |c| Float(c) }
|
|
30
|
+
b_total = Float(budget_source)
|
|
31
|
+
else
|
|
32
|
+
raise ComputeError, "Unknown accuracy parameter"
|
|
33
|
+
end
|
|
34
|
+
Loop.new(voter_ids, project_ids, cost, approvers, b_total, params,
|
|
35
|
+
report_details: report_details, progress: progress).run
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Collects the per-candidate explanation data produced while running the rule
|
|
39
|
+
# (money behind each candidate and its effective vote count over the rounds, plus
|
|
40
|
+
# the per-voter endowment). Kept separate from the selection logic; #to_h yields the
|
|
41
|
+
# same hash shape the callers consume.
|
|
42
|
+
class Report
|
|
43
|
+
def initialize(project_ids, endowment)
|
|
44
|
+
@endowment = endowment
|
|
45
|
+
@money_behind_candidate = {}
|
|
46
|
+
@effective_vote_count = {}
|
|
47
|
+
project_ids.each do |c|
|
|
48
|
+
@money_behind_candidate[c] = []
|
|
49
|
+
@effective_vote_count[c] = []
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def record_money_behind(project_id, value)
|
|
54
|
+
@money_behind_candidate[project_id] << value.to_f
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record_effective_vote_count(project_id, value)
|
|
58
|
+
@effective_vote_count[project_id] << value.to_f
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def record_unaffordable(project_id)
|
|
62
|
+
@effective_vote_count[project_id] << 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_h
|
|
66
|
+
{ money_behind_candidate: @money_behind_candidate,
|
|
67
|
+
effective_vote_count: @effective_vote_count,
|
|
68
|
+
endowment: @endowment }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# One fixed-budget MES run. Generalises the shared logic of
|
|
73
|
+
# equalSharesFixedBudgetFractions/Floats over an additive satisfaction measure (see
|
|
74
|
+
# Equalshares::Satisfaction): the payment mechanics are unchanged (each project
|
|
75
|
+
# raises its full cost), while the selection criterion is effective vote count =
|
|
76
|
+
# u(c) / maxPayment, with u(c) the per-approver utility. For Cost_Sat (u = cost)
|
|
77
|
+
# this is exactly the original rule.
|
|
78
|
+
#
|
|
79
|
+
# Ruby's Hash preserves insertion order and mirrors the JS Map semantics used for
|
|
80
|
+
# `remaining` (re-assigning an existing key keeps its position; delete removes it).
|
|
81
|
+
class Loop
|
|
82
|
+
CandidateEvaluation = Struct.new(:project_id, :money_behind, :effective_vote_count,
|
|
83
|
+
:max_payment, :affordable?, keyword_init: true)
|
|
84
|
+
|
|
85
|
+
def initialize(voter_ids, project_ids, cost, approvers, b_total, params,
|
|
86
|
+
report_details: false, progress: nil)
|
|
87
|
+
@project_ids = project_ids
|
|
88
|
+
@cost = cost
|
|
89
|
+
@approvers = approvers
|
|
90
|
+
@b_total = b_total
|
|
91
|
+
@params = params
|
|
92
|
+
@report_details = report_details
|
|
93
|
+
@progress = progress
|
|
94
|
+
@sat = Satisfaction.for(params.satisfaction)
|
|
95
|
+
@n = voter_ids.length
|
|
96
|
+
|
|
97
|
+
initialize_budget(voter_ids)
|
|
98
|
+
initialize_report
|
|
99
|
+
initialize_remaining
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def run
|
|
103
|
+
winners = []
|
|
104
|
+
loop do
|
|
105
|
+
best, max_payment_of = select_best
|
|
106
|
+
if best.empty?
|
|
107
|
+
# no remaining candidates are affordable
|
|
108
|
+
unless @remaining.empty?
|
|
109
|
+
raise ComputeError,
|
|
110
|
+
"No available candidate found even though there are still affordable candidates: " \
|
|
111
|
+
"#{@remaining.keys}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
break
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
best = Tie.resolve_one(@project_ids, @cost, @approvers, @params, best)
|
|
118
|
+
winners << best
|
|
119
|
+
@progress&.call((100 * winners.sum { |c| @cost[c] } / @b_total).floor)
|
|
120
|
+
charge_winner(best, max_payment_of[best])
|
|
121
|
+
@remaining.delete(best)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
{ winners: winners, report: @report.to_h }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def initialize_budget(voter_ids)
|
|
130
|
+
@budget = {}
|
|
131
|
+
voter_ids.each { |i| @budget[i] = @b_total / @n }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def initialize_report
|
|
135
|
+
@report = Report.new(@project_ids, @b_total.to_f / @n)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def initialize_remaining
|
|
139
|
+
@remaining = {} # candidate -> previous effective vote count
|
|
140
|
+
@project_ids.each do |c|
|
|
141
|
+
next unless @cost[c].positive? && !@approvers[c].empty?
|
|
142
|
+
|
|
143
|
+
# effective vote count when budgets are ample: u(c) * |approvers| / cost(c)
|
|
144
|
+
@remaining[c] =
|
|
145
|
+
(@sat.call(@cost[c], @approvers[c].length) * @approvers[c].length) / @cost[c]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Pick the affordable candidate(s) with the highest effective vote count this
|
|
150
|
+
# round, returning [tied_best, max_payment_by_candidate].
|
|
151
|
+
def select_best
|
|
152
|
+
best = []
|
|
153
|
+
best_eff_vote_count = 0
|
|
154
|
+
max_payment_of = {} # candidate -> per-approver payment cap computed this round
|
|
155
|
+
|
|
156
|
+
# Walk remaining candidates in order of decreasing previous effective vote count.
|
|
157
|
+
# Stable descending sort (ties keep insertion order), matching JS Array.sort.
|
|
158
|
+
remaining_sorted = @remaining.keys.each_with_index
|
|
159
|
+
.sort_by { |c, idx| [-@remaining[c], idx] }
|
|
160
|
+
.map(&:first)
|
|
161
|
+
|
|
162
|
+
remaining_sorted.each do |c|
|
|
163
|
+
# c cannot beat the best so far (optimization only when not reporting details).
|
|
164
|
+
break if @remaining[c] < best_eff_vote_count && !@report_details
|
|
165
|
+
|
|
166
|
+
evaluation = evaluate_candidate(c)
|
|
167
|
+
record_evaluation(evaluation)
|
|
168
|
+
next unless evaluation.affordable?
|
|
169
|
+
|
|
170
|
+
max_payment_of[c] = evaluation.max_payment
|
|
171
|
+
if evaluation.effective_vote_count > best_eff_vote_count
|
|
172
|
+
best_eff_vote_count = evaluation.effective_vote_count
|
|
173
|
+
best = [c]
|
|
174
|
+
elsif evaluation.effective_vote_count == best_eff_vote_count
|
|
175
|
+
best << c
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
[best, max_payment_of]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Effective vote count and per-approver payment cap for candidate c this round, or
|
|
183
|
+
# an unaffordable result if c cannot raise its cost with current supporter budgets.
|
|
184
|
+
def evaluate_candidate(project_id)
|
|
185
|
+
money_behind_now = @approvers[project_id].sum(numeric_zero) { |i| @budget[i] }
|
|
186
|
+
if money_behind_now < @cost[project_id]
|
|
187
|
+
return CandidateEvaluation.new(project_id: project_id, money_behind: money_behind_now,
|
|
188
|
+
effective_vote_count: 0,
|
|
189
|
+
max_payment: nil, affordable?: false)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Split the cost as equally as possible among approvers. Sort a copy by budget so
|
|
193
|
+
# the instance's approver order is not mutated during computation.
|
|
194
|
+
supporters_by_budget = @approvers[project_id].sort_by { |i| @budget[i] }
|
|
195
|
+
paid_so_far = 0
|
|
196
|
+
denominator = supporters_by_budget.length # approvers who can afford the max payment
|
|
197
|
+
supporters_by_budget.each do |i|
|
|
198
|
+
max_payment = (@cost[project_id] - paid_so_far) / denominator # if remaining approvers pay equally
|
|
199
|
+
if max_payment > @budget[i]
|
|
200
|
+
# i cannot afford the max payment, so pays entire remaining budget
|
|
201
|
+
paid_so_far += @budget[i]
|
|
202
|
+
denominator -= 1
|
|
203
|
+
else
|
|
204
|
+
eff_vote_count = @sat.call(@cost[project_id], @approvers[project_id].length) / max_payment
|
|
205
|
+
return CandidateEvaluation.new(project_id: project_id, money_behind: money_behind_now,
|
|
206
|
+
effective_vote_count: eff_vote_count,
|
|
207
|
+
max_payment: max_payment, affordable?: true)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
raise ComputeError, "Candidate #{project_id} was affordable but no payment cap could be computed."
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Apply an evaluation: update the core `remaining` state and record the numbers in
|
|
214
|
+
# the report (the latter delegated to the Report collaborator).
|
|
215
|
+
def record_evaluation(evaluation)
|
|
216
|
+
project_id = evaluation.project_id
|
|
217
|
+
@report.record_money_behind(project_id, evaluation.money_behind)
|
|
218
|
+
|
|
219
|
+
if evaluation.affordable?
|
|
220
|
+
@remaining[project_id] = evaluation.effective_vote_count
|
|
221
|
+
@report.record_effective_vote_count(project_id, evaluation.effective_vote_count)
|
|
222
|
+
else
|
|
223
|
+
@remaining.delete(project_id)
|
|
224
|
+
@report.record_unaffordable(project_id)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Charge each approver of the winning candidate its per-approver payment cap
|
|
229
|
+
# (equals cost/eff only for Cost_Sat), capped at their remaining budget.
|
|
230
|
+
def charge_winner(best, best_max_payment)
|
|
231
|
+
@approvers[best].each do |i|
|
|
232
|
+
@budget[i] = @budget[i] > best_max_payment ? @budget[i] - best_max_payment : numeric_zero
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Additive identity in the same numeric family as the budget (0 or 0.0).
|
|
237
|
+
def numeric_zero
|
|
238
|
+
@b_total.is_a?(Float) ? 0.0 : 0
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Facade for greedy utilitarian welfare; delegates to Rules::Greedy.
|
|
5
|
+
module Greedy
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def utilitarian_welfare(instance, params = Params.new)
|
|
9
|
+
Rules::Greedy.call(instance, params)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# A parsed pabulib instance.
|
|
5
|
+
#
|
|
6
|
+
# Mirrors the JS `{ meta, projects, votes, approvers }` shape:
|
|
7
|
+
# meta - Hash{String => String} key/value metadata (includes "budget", "vote_type")
|
|
8
|
+
# projects - Hash{String => Hash{String => String}} project_id => raw row fields (incl "cost", "name")
|
|
9
|
+
# votes - Hash{String => Hash{String => String}} voter_id => raw row fields
|
|
10
|
+
# approvers - Hash{String => Array<String>} project_id => list of voter_ids approving it
|
|
11
|
+
class Instance
|
|
12
|
+
attr_reader :meta, :projects, :votes, :approvers, :scores
|
|
13
|
+
|
|
14
|
+
# `scores` is nil for approval instances; for cardinal ballots (vote_type
|
|
15
|
+
# "scoring"/"cumulative") it is Hash{project_id => Hash{voter_id => score string}}.
|
|
16
|
+
def initialize(meta:, projects:, votes:, approvers:, scores: nil)
|
|
17
|
+
@meta = meta
|
|
18
|
+
@projects = projects
|
|
19
|
+
@votes = votes
|
|
20
|
+
@approvers = approvers
|
|
21
|
+
@scores = scores
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# True for cardinal (scoring/cumulative) instances that carry per-voter scores.
|
|
25
|
+
def cardinal?
|
|
26
|
+
!@scores.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def vote_type
|
|
30
|
+
@meta["vote_type"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Voter IDs (JS: Object.keys(votes))
|
|
34
|
+
def voter_ids
|
|
35
|
+
@voter_ids ||= self.class.js_key_order(@votes.keys)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Project IDs (JS: Object.keys(projects))
|
|
39
|
+
def project_ids
|
|
40
|
+
@project_ids ||= self.class.js_key_order(@projects.keys)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def budget
|
|
44
|
+
@meta["budget"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Replicate the iteration order of JavaScript's Object.keys, which the original
|
|
48
|
+
# tool relies on (C = Object.keys(projects), N = Object.keys(votes)): integer
|
|
49
|
+
# "array index" keys come first in ascending numeric order, followed by all
|
|
50
|
+
# other keys in insertion order. Project/voter IDs are typically numeric strings,
|
|
51
|
+
# so this ordering affects tie stability (e.g. greedy utilitarian completion).
|
|
52
|
+
ARRAY_INDEX = /\A(?:0|[1-9]\d*)\z/
|
|
53
|
+
UINT32_MAX = (2**32) - 1
|
|
54
|
+
|
|
55
|
+
def self.js_key_order(keys)
|
|
56
|
+
indices, others = keys.partition { |k| ARRAY_INDEX.match?(k) && k.to_i < UINT32_MAX }
|
|
57
|
+
indices.sort_by(&:to_i) + others
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Dinic's maximum-flow algorithm with integer capacities. Used by the maximin
|
|
5
|
+
# support rule to compute the exact minimum max-load (a max-density subgraph) via
|
|
6
|
+
# a parametric max-flow, keeping the gem pure-Ruby and dependency-free.
|
|
7
|
+
class MaxFlow
|
|
8
|
+
def initialize(num_nodes)
|
|
9
|
+
@num_nodes = num_nodes
|
|
10
|
+
@graph = Array.new(num_nodes) { [] } # node -> array of edges [to, cap, rev_index]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add_edge(from, to, cap)
|
|
14
|
+
@graph[from] << [to, cap, @graph[to].size]
|
|
15
|
+
@graph[to] << [from, 0, @graph[from].size - 1]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def max_flow(source, sink)
|
|
19
|
+
flow = 0
|
|
20
|
+
while build_levels(source, sink)
|
|
21
|
+
@iter = Array.new(@num_nodes, 0)
|
|
22
|
+
while (pushed = augment(source, sink, Float::INFINITY)).positive?
|
|
23
|
+
flow += pushed
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
flow
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Nodes reachable from `source` in the residual graph (the source side of a
|
|
30
|
+
# minimum cut after max_flow has run).
|
|
31
|
+
def reachable_from(source)
|
|
32
|
+
visited = Array.new(@num_nodes, false)
|
|
33
|
+
visited[source] = true
|
|
34
|
+
queue = [source]
|
|
35
|
+
until queue.empty?
|
|
36
|
+
node = queue.shift
|
|
37
|
+
@graph[node].each do |edge|
|
|
38
|
+
to, cap, = edge
|
|
39
|
+
next unless cap.positive? && !visited[to]
|
|
40
|
+
|
|
41
|
+
visited[to] = true
|
|
42
|
+
queue << to
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
visited
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Builds the BFS level graph; returns whether the sink is reachable.
|
|
51
|
+
def build_levels(source, sink) # rubocop:disable Naming/PredicateMethod
|
|
52
|
+
@level = Array.new(@num_nodes, -1)
|
|
53
|
+
@level[source] = 0
|
|
54
|
+
queue = [source]
|
|
55
|
+
until queue.empty?
|
|
56
|
+
node = queue.shift
|
|
57
|
+
@graph[node].each do |edge|
|
|
58
|
+
to, cap, = edge
|
|
59
|
+
next unless cap.positive? && @level[to].negative?
|
|
60
|
+
|
|
61
|
+
@level[to] = @level[node] + 1
|
|
62
|
+
queue << to
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
!@level[sink].negative?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The level graph is at most a few layers deep (source-project-voter-sink), so
|
|
69
|
+
# this recursion stays shallow.
|
|
70
|
+
def augment(node, sink, limit)
|
|
71
|
+
return limit if node == sink
|
|
72
|
+
|
|
73
|
+
while @iter[node] < @graph[node].size
|
|
74
|
+
edge = @graph[node][@iter[node]]
|
|
75
|
+
to, cap = edge
|
|
76
|
+
if cap.positive? && @level[node] < @level[to]
|
|
77
|
+
delta = augment(to, sink, [limit, cap].min)
|
|
78
|
+
if delta.positive?
|
|
79
|
+
edge[1] -= delta
|
|
80
|
+
@graph[to][edge[2]][1] += delta
|
|
81
|
+
return delta
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
@iter[node] += 1
|
|
85
|
+
end
|
|
86
|
+
0
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Facade for the maximin support rule; delegates to Rules::Maximin.
|
|
5
|
+
module Maximin
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def support(instance, params = Params.new, progress: nil)
|
|
9
|
+
Rules::Maximin.call(instance, params, progress: progress)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Facade for the Method of Equal Shares on cardinal/ordinal ballots; delegates to
|
|
5
|
+
# Rules::CardinalMes.
|
|
6
|
+
module MesGeneral
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def equal_shares(instance, params = Params.new, progress: nil)
|
|
10
|
+
Rules::CardinalMes.call(instance, params, progress: progress)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
module Pabulib
|
|
5
|
+
# Low-level lexing shared by the parser: the ';'-delimited, double-quote-escaped
|
|
6
|
+
# field splitter (port of parseCSVLine in js/pabulibParser.js) and the JS-style
|
|
7
|
+
# numeric check.
|
|
8
|
+
module Csv
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def parse_line(line)
|
|
12
|
+
result = []
|
|
13
|
+
current = +""
|
|
14
|
+
in_quotes = false
|
|
15
|
+
i = 0
|
|
16
|
+
|
|
17
|
+
while i < line.length
|
|
18
|
+
char = line[i]
|
|
19
|
+
if char == '"'
|
|
20
|
+
if in_quotes && i + 1 < line.length && line[i + 1] == '"'
|
|
21
|
+
current << '"'
|
|
22
|
+
i += 2
|
|
23
|
+
else
|
|
24
|
+
in_quotes = !in_quotes
|
|
25
|
+
i += 1
|
|
26
|
+
end
|
|
27
|
+
elsif char == ";" && !in_quotes
|
|
28
|
+
result << current
|
|
29
|
+
current = +""
|
|
30
|
+
i += 1
|
|
31
|
+
else
|
|
32
|
+
current << char
|
|
33
|
+
i += 1
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result << current
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Whether a string parses as a number the way JavaScript's Number() does: empty
|
|
42
|
+
# or whitespace-only strings count as numeric (they coerce to 0), nil does not.
|
|
43
|
+
def numeric_string?(value)
|
|
44
|
+
return false if value.nil?
|
|
45
|
+
|
|
46
|
+
str = value.strip
|
|
47
|
+
return true if str.empty?
|
|
48
|
+
|
|
49
|
+
Float(str)
|
|
50
|
+
true
|
|
51
|
+
rescue ArgumentError, TypeError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
module Pabulib
|
|
5
|
+
# Mutable bag of results filled in as the sections are read.
|
|
6
|
+
Accumulator = Struct.new(:meta, :projects, :votes, :approvers, :scores,
|
|
7
|
+
:project_ids_set, :voter_ids_set, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
# Reads a pabulib (.pb) document: drives the META / PROJECTS / VOTES section state
|
|
10
|
+
# machine and delegates each data row to the appropriate row parser.
|
|
11
|
+
class Parser
|
|
12
|
+
def parse(text)
|
|
13
|
+
@acc = Accumulator.new(meta: {}, projects: {}, votes: {}, approvers: {}, scores: {},
|
|
14
|
+
project_ids_set: {}, voter_ids_set: {})
|
|
15
|
+
@encountered = {}
|
|
16
|
+
@section = ""
|
|
17
|
+
@header = []
|
|
18
|
+
@line_number = 0
|
|
19
|
+
|
|
20
|
+
text.split("\n").each do |line|
|
|
21
|
+
@line_number += 1
|
|
22
|
+
handle_line(line) unless line.strip.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
finish
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def handle_line(line)
|
|
31
|
+
row = Csv.parse_line(line)
|
|
32
|
+
if section_header?(row)
|
|
33
|
+
start_section(row)
|
|
34
|
+
elsif @header.empty?
|
|
35
|
+
read_header(row)
|
|
36
|
+
else
|
|
37
|
+
dispatch_row(row)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def section_header?(row)
|
|
42
|
+
SECTIONS.include?(row[0].strip.downcase)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def start_section(row)
|
|
46
|
+
@section = row[0].strip.downcase
|
|
47
|
+
@encountered[@section] = true
|
|
48
|
+
@header = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_header(row)
|
|
52
|
+
@header = row.map(&:strip)
|
|
53
|
+
return unless @section == "meta" && (@header[0] != "key" || @header[1] != "value")
|
|
54
|
+
|
|
55
|
+
raise ParseError, "Line #{@line_number}: Invalid header in meta section (expecting \"key;value\")."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def dispatch_row(row)
|
|
59
|
+
case @section
|
|
60
|
+
when "meta"
|
|
61
|
+
@acc.meta[row[0]] = row[1].strip
|
|
62
|
+
when "projects"
|
|
63
|
+
(@project_row_parser ||= ProjectRowParser.new(@acc)).parse(row, @header, @line_number)
|
|
64
|
+
when "votes"
|
|
65
|
+
(@vote_row_parser ||= VoteRowParser.new(@acc)).parse(row, @header, @line_number)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def finish
|
|
70
|
+
SECTIONS.each do |section_name|
|
|
71
|
+
next if @encountered[section_name]
|
|
72
|
+
|
|
73
|
+
raise ParseError, "The file is missing the required '#{section_name}' section."
|
|
74
|
+
end
|
|
75
|
+
unless Csv.numeric_string?(@acc.meta["budget"])
|
|
76
|
+
raise ParseError, "The 'budget' in the meta section is not a numeric value."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
scored = SCORED_VOTE_TYPES.include?(@acc.meta["vote_type"])
|
|
80
|
+
Instance.new(meta: @acc.meta, projects: @acc.projects, votes: @acc.votes,
|
|
81
|
+
approvers: @acc.approvers, scores: scored ? @acc.scores : nil)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
module Pabulib
|
|
5
|
+
# Parses a single row of the PROJECTS section into the accumulator.
|
|
6
|
+
class ProjectRowParser
|
|
7
|
+
def initialize(accumulator)
|
|
8
|
+
@acc = accumulator
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def parse(row, header, line_number)
|
|
12
|
+
project_id_idx = header.index("project_id")
|
|
13
|
+
cost_idx = header.index("cost")
|
|
14
|
+
name_idx = header.index("name")
|
|
15
|
+
|
|
16
|
+
require_columns!(project_id_idx, cost_idx, line_number)
|
|
17
|
+
# Validate the column count before any indexed access, so a short row raises a
|
|
18
|
+
# ParseError rather than a NoMethodError on nil.
|
|
19
|
+
validate_column_count!(row, header, line_number)
|
|
20
|
+
|
|
21
|
+
project_id = row[project_id_idx].strip
|
|
22
|
+
if @acc.project_ids_set[project_id]
|
|
23
|
+
raise ParseError, "Line #{line_number}: Duplicate project ID '#{project_id}' found."
|
|
24
|
+
end
|
|
25
|
+
if row[project_id_idx].to_s.empty? || !Csv.numeric_string?(row[cost_idx])
|
|
26
|
+
raise ParseError, "Line #{line_number}: Invalid or missing values in projects section."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
store(row, header, project_id, name_idx)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def require_columns!(project_id_idx, cost_idx, line_number)
|
|
35
|
+
return unless project_id_idx.nil? || cost_idx.nil?
|
|
36
|
+
|
|
37
|
+
missing = []
|
|
38
|
+
missing << "project_id " if project_id_idx.nil?
|
|
39
|
+
missing << "cost" if cost_idx.nil?
|
|
40
|
+
raise ParseError, "Line #{line_number}: Missing required column(s) in projects section: #{missing.join}."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_column_count!(row, header, line_number)
|
|
44
|
+
return if row.length == header.length
|
|
45
|
+
|
|
46
|
+
raise ParseError, "Line #{line_number}: Invalid number of columns in projects section."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def store(row, header, project_id, name_idx)
|
|
50
|
+
@acc.project_ids_set[project_id] = true
|
|
51
|
+
@acc.projects[project_id] = {}
|
|
52
|
+
@acc.approvers[project_id] = []
|
|
53
|
+
header.each_index { |idx| @acc.projects[project_id][header[idx].strip] = row[idx].strip }
|
|
54
|
+
|
|
55
|
+
name = @acc.projects[project_id]["name"]
|
|
56
|
+
@acc.projects[project_id]["name"] = project_id if name_idx.nil? || name.nil? || name.strip.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|