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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bedaa40769542c8d4c0354779a69cecd2f7194f2470ef22127a87eae54a904df
|
|
4
|
+
data.tar.gz: 0c16a5cc2254befb86a5edcb919e17e9c32832a3debcd754ee6fdf208ce6aafb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7eb39a0cebd691d353e114b43bdb1dd155dfe6915e042c2ae1ebba86498cbc0efd310cfb0a0acb0b99cfb66493948230be3a970c9b4bcfd78d1c06ac2baa4f8b
|
|
7
|
+
data.tar.gz: a1f8fe965faa1be02260e0218ab207cc7aae627197b6ac6421d7a9b4f43c713f8453b65012e77221f7528832ea69b35017de15966fcc232ac9b4c41dc5b0a523
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Dominik Peters (original author)
|
|
4
|
+
Copyright (c) 2026 Masayoshi Takahashi (Ruby version)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Equalshares
|
|
2
|
+
|
|
3
|
+
A Ruby implementation of the [equalshares.net](https://equalshares.net/tools/compute/) compute tool for participatory budgeting.
|
|
4
|
+
It parses [pabulib](http://pabulib.org/) `.pb` files and computes winning projects using the [Method of Equal Shares](https://equalshares.net/) and related rules.
|
|
5
|
+
|
|
6
|
+
This started as a faithful port of the vanilla-JavaScript
|
|
7
|
+
[equalshares-compute-tool](https://github.com/equalshares/equalshares-compute-tool)
|
|
8
|
+
(parser + Method of Equal Shares) and was then extended for parity with
|
|
9
|
+
[pabutools](https://github.com/COMSOC-Community/pabutools), the academic reference
|
|
10
|
+
implementation. Every rule and satisfaction measure is cross-checked against pabutools
|
|
11
|
+
(see [Tests](#tests)).
|
|
12
|
+
|
|
13
|
+
Highlights:
|
|
14
|
+
|
|
15
|
+
- **Rules**: Method of Equal Shares (`mes`), Phragmén's sequential rule (`phragmen`),
|
|
16
|
+
greedy utilitarian welfare (`greedy`), maximin support (`maximin`).
|
|
17
|
+
- **Ballots**: approval, cardinal (`scoring` / `cumulative`), and ordinal (via Borda).
|
|
18
|
+
- **Satisfaction measures**: `cost` (the equalshares.net default), `cardinality`, `effort`.
|
|
19
|
+
- **Exact or fast**: exact rational arithmetic (`fractions`) or floating point (`floats`).
|
|
20
|
+
- **No runtime dependencies**; the maximin rule is solved with a pure-Ruby max-flow
|
|
21
|
+
instead of an LP solver.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Install from git (or build the gem locally):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gem install equalshares
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
or add to a Gemfile:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem "equalshares"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Command line
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Compute winners with exact arithmetic
|
|
41
|
+
equalshares path/to/instance.pb --completion add1 --accuracy fractions
|
|
42
|
+
|
|
43
|
+
# Choose the rule and satisfaction measure
|
|
44
|
+
equalshares instance.pb --rule phragmen
|
|
45
|
+
equalshares instance.pb --rule greedy --satisfaction cardinality
|
|
46
|
+
equalshares instance.pb --rule maximin
|
|
47
|
+
|
|
48
|
+
# Output formats and tie-breaking
|
|
49
|
+
equalshares instance.pb --format csv
|
|
50
|
+
equalshares instance.pb --format json
|
|
51
|
+
equalshares instance.pb --tie-breaking maxVotes,minCost --comparison satisfaction --progress
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Options: `--rule` (`mes`/`phragmen`/`greedy`/`maximin`), `--completion`
|
|
55
|
+
(`none`/`utilitarian`/`add1`/`add1u`/…), `--accuracy` (`floats`/`fractions`),
|
|
56
|
+
`--satisfaction` (`cost`/`cardinality`/`effort`), `--tie-breaking`
|
|
57
|
+
(comma-separated `maxVotes,minCost,maxCost`), `--add1-options` (`exhaustive,integral`),
|
|
58
|
+
`--comparison` (`none`/`satisfaction`/`exclusionRatio`), `--increment N`, `--format`
|
|
59
|
+
(`human`/`csv`/`json`), `--progress`.
|
|
60
|
+
|
|
61
|
+
## Library
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
require "equalshares"
|
|
65
|
+
|
|
66
|
+
instance = Equalshares::Pabulib.parse_file("instance.pb")
|
|
67
|
+
params = Equalshares::Params.new(completion: "add1u", accuracy: "fractions")
|
|
68
|
+
|
|
69
|
+
result = Equalshares::Compute.equal_shares(instance, params)
|
|
70
|
+
result[:winners] # => ["24", "41", ...] winning project IDs
|
|
71
|
+
result[:notes][:stats] # => total_cost, avg_approved_projects, utility_distribution, ...
|
|
72
|
+
|
|
73
|
+
# Other rules
|
|
74
|
+
Equalshares::Phragmen.sequential(instance, params)
|
|
75
|
+
Equalshares::Greedy.utilitarian_welfare(instance, Equalshares::Params.new(satisfaction: "cardinality"))
|
|
76
|
+
Equalshares::Maximin.support(instance)
|
|
77
|
+
|
|
78
|
+
# Serialise back to .pb
|
|
79
|
+
Equalshares::Pabulib.write_file(instance, "out.pb")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Satisfaction measures
|
|
83
|
+
|
|
84
|
+
By default MES uses **cost** satisfaction (the equalshares.net rule). `cardinality`
|
|
85
|
+
(each funded approved project counts as 1) and `effort` (cost divided by approver count)
|
|
86
|
+
are also supported and match pabutools' `Cost_Sat`, `Cardinality_Sat` and `Effort_Sat`.
|
|
87
|
+
|
|
88
|
+
### Ballot types
|
|
89
|
+
|
|
90
|
+
`scoring` / `cumulative` instances carry per-voter scores (a `points` column); `ordinal`
|
|
91
|
+
instances carry rankings, converted to per-voter **Borda** scores (ballot length − rank
|
|
92
|
+
− 1). In all cases `instance.cardinal?` is true and the utilities feed the general MES
|
|
93
|
+
(`Equalshares::MesGeneral`), verified against pabutools' `Additive_Cardinal_Sat` and
|
|
94
|
+
`Additive_Borda_Sat`.
|
|
95
|
+
|
|
96
|
+
### Tie-breaking
|
|
97
|
+
|
|
98
|
+
`params.tie_breaking` is a priority list of: the criteria `"maxVotes"`, `"minCost"`,
|
|
99
|
+
`"maxCost"`; a total order `{ lexico: [ids...] }` that ranks tied projects by their
|
|
100
|
+
position in the list (matches pabutools' lexicographic tie-breaking — the recommended
|
|
101
|
+
form); or a bare `Array`, which reproduces the equalshares.net tool's explicit-list
|
|
102
|
+
behaviour but does **not** respect the list ordering.
|
|
103
|
+
|
|
104
|
+
## Tests
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bundle install
|
|
108
|
+
bundle exec rake test
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The suite cross-checks the implementation against the original JavaScript tool and
|
|
112
|
+
against pabutools. The pabutools reference fixtures under `test/fixtures/` are committed,
|
|
113
|
+
so the tests run without pabutools installed (the pabutools cross-checks skip if their
|
|
114
|
+
reference JSON is absent). To regenerate a reference, install `pabutools`
|
|
115
|
+
(`pip install pabutools`) and run the corresponding `test/fixtures/generate_*.py` script.
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT. The Method of Equal Shares algorithm and pabulib parser are ports of the
|
|
120
|
+
MIT-licensed [equalshares-compute-tool](https://github.com/equalshares/equalshares-compute-tool)
|
|
121
|
+
by Dominik Peters. See `LICENSE.txt`.
|
data/exe/equalshares
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "csv"
|
|
5
|
+
|
|
6
|
+
module Equalshares
|
|
7
|
+
class CLI
|
|
8
|
+
# Renders a computation Result for a given instance as human-readable text, CSV or
|
|
9
|
+
# JSON. Each renderer returns a String (including the trailing newline).
|
|
10
|
+
class Formatter
|
|
11
|
+
def initialize(instance, result)
|
|
12
|
+
@instance = instance
|
|
13
|
+
@result = result
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render(format)
|
|
17
|
+
case format
|
|
18
|
+
when "json" then json
|
|
19
|
+
when "csv" then csv
|
|
20
|
+
else human
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def json
|
|
25
|
+
"#{JSON.pretty_generate(@result.to_h)}\n"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def csv
|
|
29
|
+
CSV.generate do |csv|
|
|
30
|
+
csv << %w[project_id name cost votes effective_vote_count]
|
|
31
|
+
@result.winners.each do |c|
|
|
32
|
+
csv << [c, project(c, "name"), project(c, "cost"),
|
|
33
|
+
@instance.approvers[c].length, @result.effective_vote_count(c)]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def human
|
|
39
|
+
lines = summary_lines + [""]
|
|
40
|
+
rows = @result.winners.map do |c|
|
|
41
|
+
[c, truncate(project(c, "name"), 50), project(c, "cost"),
|
|
42
|
+
@instance.approvers[c].length.to_s, format_number(@result.effective_vote_count(c))]
|
|
43
|
+
end
|
|
44
|
+
"#{(lines + table(%w[id name cost votes eff.votes], rows)).join("\n")}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def project(project_id, field)
|
|
50
|
+
@instance.projects[project_id][field]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def summary_lines
|
|
54
|
+
lines = ["Winners: #{@result.winners.length} projects, " \
|
|
55
|
+
"total cost #{format_number(@result.total_cost)} of budget #{@instance.budget}"]
|
|
56
|
+
lines << "Voter endowment: #{format_number(@result.endowment)}" if @result.endowment
|
|
57
|
+
lines << "Avg. approved winning projects per voter: #{@result.stats[:avg_approved_projects].round(3)}"
|
|
58
|
+
lines << "Computation time: #{@result.time}s"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def table(headers, rows)
|
|
62
|
+
widths = headers.each_index.map do |i|
|
|
63
|
+
([headers[i]] + rows.map { |r| r[i].to_s }).map(&:length).max
|
|
64
|
+
end
|
|
65
|
+
line = ->(cells) { cells.each_index.map { |i| cells[i].to_s.ljust(widths[i]) }.join(" ") }
|
|
66
|
+
[line.call(headers), widths.map { |w| "-" * w }.join(" ")] + rows.map { |r| line.call(r) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def truncate(str, max)
|
|
70
|
+
str.to_s.length > max ? "#{str[0, max - 1]}…" : str.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_number(value)
|
|
74
|
+
f = value.to_f
|
|
75
|
+
f == f.round ? f.round.to_s : f.round(2).to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "cli/formatter"
|
|
6
|
+
|
|
7
|
+
module Equalshares
|
|
8
|
+
# Command-line interface: parse a .pb file and print the Method of Equal Shares outcome.
|
|
9
|
+
class CLI
|
|
10
|
+
def self.start(argv)
|
|
11
|
+
new.run(argv)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Each rule name maps to a runner (instance, params, progress) -> Result.
|
|
15
|
+
RULE_RUNNERS = {
|
|
16
|
+
"mes" => ->(instance, params, progress) { Compute.equal_shares(instance, params, progress: progress) },
|
|
17
|
+
"phragmen" => ->(instance, params, progress) { Phragmen.sequential(instance, params, progress: progress) },
|
|
18
|
+
"greedy" => ->(instance, params, _progress) { Greedy.utilitarian_welfare(instance, params) },
|
|
19
|
+
"maximin" => ->(instance, params, progress) { Maximin.support(instance, params, progress: progress) }
|
|
20
|
+
}.freeze
|
|
21
|
+
RULES = RULE_RUNNERS.keys.freeze
|
|
22
|
+
|
|
23
|
+
def run(argv)
|
|
24
|
+
options = { format: "human", progress: false, rule: "mes" }
|
|
25
|
+
param_opts = {}
|
|
26
|
+
parser = build_parser(options, param_opts)
|
|
27
|
+
files = parser.parse(argv)
|
|
28
|
+
|
|
29
|
+
if files.length != 1
|
|
30
|
+
warn parser.help
|
|
31
|
+
return 2
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
params = Params.new(**param_opts)
|
|
35
|
+
progress = options[:progress] ? ->(pct) { warn "\rComputing... #{pct}%" } : nil
|
|
36
|
+
|
|
37
|
+
instance = Pabulib.parse_file(files.first)
|
|
38
|
+
result = RULE_RUNNERS.fetch(options[:rule]).call(instance, params, progress)
|
|
39
|
+
warn "" if options[:progress]
|
|
40
|
+
|
|
41
|
+
print Formatter.new(instance, result).render(options[:format])
|
|
42
|
+
0
|
|
43
|
+
rescue OptionParser::ParseError => e
|
|
44
|
+
warn "Error: #{e.message}"
|
|
45
|
+
2
|
|
46
|
+
rescue ParseError, ComputeError, Errno::ENOENT => e
|
|
47
|
+
warn "Error: #{e.message}"
|
|
48
|
+
1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def build_parser(options, param_opts)
|
|
54
|
+
OptionParser.new do |o|
|
|
55
|
+
o.banner = "Usage: equalshares [options] FILE.pb"
|
|
56
|
+
o.separator ""
|
|
57
|
+
o.separator "Computation options:"
|
|
58
|
+
o.on("--rule NAME", RULES, "Voting rule: mes (default), phragmen, greedy, maximin") do |v|
|
|
59
|
+
options[:rule] = v
|
|
60
|
+
end
|
|
61
|
+
o.on("--completion NAME", Params::COMPLETIONS, "Completion method (default: add1u)") do |v|
|
|
62
|
+
param_opts[:completion] = v
|
|
63
|
+
end
|
|
64
|
+
o.on("--accuracy NAME", Params::ACCURACIES, "floats (default) or fractions") do |v|
|
|
65
|
+
param_opts[:accuracy] = v
|
|
66
|
+
end
|
|
67
|
+
o.on("--satisfaction NAME", Params::SATISFACTIONS,
|
|
68
|
+
"MES satisfaction: cost (default), cardinality, effort") do |v|
|
|
69
|
+
param_opts[:satisfaction] = v
|
|
70
|
+
end
|
|
71
|
+
o.on("--tie-breaking LIST", Array, "Comma-separated: maxVotes,minCost,maxCost") do |v|
|
|
72
|
+
param_opts[:tie_breaking] = v
|
|
73
|
+
end
|
|
74
|
+
o.on("--add1-options LIST", Array, "Comma-separated: exhaustive,integral") do |v|
|
|
75
|
+
param_opts[:add1_options] = v
|
|
76
|
+
end
|
|
77
|
+
o.on("--comparison NAME", Params::COMPARISONS, "none (default), satisfaction, exclusionRatio") do |v|
|
|
78
|
+
param_opts[:comparison] = v
|
|
79
|
+
end
|
|
80
|
+
o.on("--increment N", Integer, "Budget increment for Add1 (default: 1)") do |v|
|
|
81
|
+
param_opts[:increment] = v
|
|
82
|
+
end
|
|
83
|
+
o.separator ""
|
|
84
|
+
o.separator "Output options:"
|
|
85
|
+
o.on("--format FMT", %w[human csv json], "human (default), csv, or json") do |v|
|
|
86
|
+
options[:format] = v
|
|
87
|
+
end
|
|
88
|
+
o.on("--progress", "Show computation progress on stderr") { options[:progress] = true }
|
|
89
|
+
o.on("-h", "--help", "Show this help") do
|
|
90
|
+
puts o
|
|
91
|
+
exit 0
|
|
92
|
+
end
|
|
93
|
+
o.on("-v", "--version", "Show version") do
|
|
94
|
+
puts Equalshares::VERSION
|
|
95
|
+
exit 0
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Comparison step. Faithful port of comparisonStep() in
|
|
5
|
+
# js/methodOfEqualSharesWorker.js. Compares the MES committee against the greedy
|
|
6
|
+
# committee; if a strict majority of voters prefer greedy, the outcome switches.
|
|
7
|
+
module Comparison
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def comparison_step(voter_ids, approvers, greedy, winners, params)
|
|
11
|
+
prefers_mes = 0
|
|
12
|
+
prefers_greedy = 0
|
|
13
|
+
|
|
14
|
+
case params.comparison
|
|
15
|
+
when "satisfaction"
|
|
16
|
+
mes_satisfaction = Hash.new(0)
|
|
17
|
+
greedy_satisfaction = Hash.new(0)
|
|
18
|
+
[[winners, mes_satisfaction], [greedy, greedy_satisfaction]].each do |candidates, satisfaction|
|
|
19
|
+
candidates.each do |c|
|
|
20
|
+
approvers[c].each { |i| satisfaction[i] += 1 }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
voter_ids.each do |i|
|
|
24
|
+
if mes_satisfaction[i] > greedy_satisfaction[i]
|
|
25
|
+
prefers_mes += 1
|
|
26
|
+
elsif greedy_satisfaction[i] > mes_satisfaction[i]
|
|
27
|
+
prefers_greedy += 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
when "exclusionRatio"
|
|
31
|
+
mes_approvals = {}
|
|
32
|
+
winners.each { |c| approvers[c].each { |i| mes_approvals[i] = true } }
|
|
33
|
+
greedy_approvals = {}
|
|
34
|
+
greedy.each { |c| approvers[c].each { |i| greedy_approvals[i] = true } }
|
|
35
|
+
voter_ids.each do |i|
|
|
36
|
+
if mes_approvals[i] && !greedy_approvals[i]
|
|
37
|
+
prefers_mes += 1
|
|
38
|
+
elsif greedy_approvals[i] && !mes_approvals[i]
|
|
39
|
+
prefers_greedy += 1
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
stick_to_mes = prefers_greedy <= prefers_mes
|
|
45
|
+
{ stick_to_mes: stick_to_mes, prefers_mes: prefers_mes, prefers_greedy: prefers_greedy }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Completion methods. Faithful port of equalSharesAdd1() and utilitarianCompletion()
|
|
5
|
+
# in js/methodOfEqualSharesWorker.js.
|
|
6
|
+
module Completion
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Method of Equal Shares with Add1 completion: repeatedly re-run fixed-budget MES
|
|
10
|
+
# with increasing per-voter budgets until adding more would exceed the real budget.
|
|
11
|
+
def add1(voter_ids, project_ids, cost_source, approvers, budget_source, params, progress: nil)
|
|
12
|
+
n = voter_ids.length
|
|
13
|
+
b = Float(budget_source)
|
|
14
|
+
|
|
15
|
+
start_budget = budget_source
|
|
16
|
+
if params.add1_option?("integral")
|
|
17
|
+
per_voter = (b / n).floor
|
|
18
|
+
start_budget = per_voter * n
|
|
19
|
+
end
|
|
20
|
+
start_budget = parse_number(start_budget)
|
|
21
|
+
|
|
22
|
+
# The virtual budget is raised in steps of `step`. The linear scan (as in the JS tool)
|
|
23
|
+
# stops at the first step whose outcome is either exhaustive (if enabled) or exceeds the
|
|
24
|
+
# real budget b. Both stopping conditions are monotone in the number of steps (once true
|
|
25
|
+
# they stay true as the budget grows), so we binary-search for the last accepted step and
|
|
26
|
+
# compute O(log) fixed-budget outcomes instead of O(steps). The result is identical to the
|
|
27
|
+
# scan: FixedBudget never mutates `approvers`, so the final reported run is independent of
|
|
28
|
+
# which budgets were probed.
|
|
29
|
+
step = n * params.increment
|
|
30
|
+
exhaustive_enabled = params.add1_option?("exhaustive")
|
|
31
|
+
memo = {}
|
|
32
|
+
|
|
33
|
+
eval_step = lambda do |k|
|
|
34
|
+
memo[k] ||= begin
|
|
35
|
+
winners = FixedBudget.run(voter_ids, project_ids, cost_source, approvers,
|
|
36
|
+
start_budget + (k * step), params).fetch(:winners)
|
|
37
|
+
step_cost = winners.sum { |c| float_cost(cost_source, c) }
|
|
38
|
+
exhaustive = exhaustive_enabled &&
|
|
39
|
+
project_ids.none? do |extra|
|
|
40
|
+
!winners.include?(extra) && step_cost + float_cost(cost_source, extra) <= b
|
|
41
|
+
end
|
|
42
|
+
progress&.call((100 * [step_cost, b].min / b).floor)
|
|
43
|
+
{ winners: winners, cost: step_cost, exhaustive: exhaustive }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Would the linear scan reach and accept the budget at step k? Step 0 is the starting point;
|
|
48
|
+
# step k >= 1 is accepted iff no earlier step was exhaustive and step k is still within
|
|
49
|
+
# budget (monotonicity lets us test only the preceding exhaustiveness and this cost).
|
|
50
|
+
accepts = lambda do |k|
|
|
51
|
+
next true if k <= 0
|
|
52
|
+
next false if exhaustive_enabled && eval_step.call(k - 1)[:exhaustive]
|
|
53
|
+
|
|
54
|
+
eval_step.call(k)[:cost] <= b
|
|
55
|
+
rescue ComputeError
|
|
56
|
+
# Far above the accepted region the outcome can become unresolvable (e.g. an unbreakable
|
|
57
|
+
# tie); the linear scan never reaches there, so treat it as past the boundary.
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# gallop to bracket the boundary, then binary-search the last accepted step
|
|
62
|
+
hi = 1
|
|
63
|
+
hi *= 2 while accepts.call(hi)
|
|
64
|
+
lo = hi / 2 # accepts(lo) is true
|
|
65
|
+
while lo + 1 < hi
|
|
66
|
+
mid = (lo + hi) / 2
|
|
67
|
+
accepts.call(mid) ? (lo = mid) : (hi = mid)
|
|
68
|
+
end
|
|
69
|
+
budget = start_budget + (lo * step)
|
|
70
|
+
|
|
71
|
+
# recompute with final budget while reporting details
|
|
72
|
+
FixedBudget.run(voter_ids, project_ids, cost_source, approvers, budget, params, report_details: true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Greedy completion by number of approvers (also used to build the greedy committee).
|
|
76
|
+
# `cost` here is the numeric cost map (Float or Rational).
|
|
77
|
+
def utilitarian(_voter_ids, project_ids, cost, approvers, budget_total, already_winners)
|
|
78
|
+
winners = already_winners.dup
|
|
79
|
+
cost_so_far = winners.sum { |c| cost[c] }
|
|
80
|
+
added = []
|
|
81
|
+
# Stable descending sort by approver count (ties keep original order), matching JS Array.sort.
|
|
82
|
+
sorted = project_ids.each_with_index
|
|
83
|
+
.sort_by { |c, idx| [-approvers[c].length, idx] }
|
|
84
|
+
.map(&:first)
|
|
85
|
+
sorted.each do |c|
|
|
86
|
+
next if winners.include?(c) || cost_so_far + cost[c] > budget_total
|
|
87
|
+
|
|
88
|
+
winners << c
|
|
89
|
+
added << c
|
|
90
|
+
cost_so_far += cost[c]
|
|
91
|
+
end
|
|
92
|
+
{ winners: winners, added: added }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def float_cost(cost_source, cost_id)
|
|
96
|
+
Float(cost_source[cost_id])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Preserve integer budgets (Add1 increments are integral); fall back to float.
|
|
100
|
+
def parse_number(value)
|
|
101
|
+
Integer(value.to_s)
|
|
102
|
+
rescue ArgumentError, TypeError
|
|
103
|
+
Float(value)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Facade for the Method of Equal Shares. Delegates to the rule objects: cardinal
|
|
5
|
+
# ballots (per-voter utilities) go to Rules::CardinalMes, approval ballots to
|
|
6
|
+
# Rules::MethodOfEqualShares (with the equalshares.net completion/comparison steps).
|
|
7
|
+
module Compute
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Returns { winners: Array<String>, notes: Hash }.
|
|
11
|
+
def equal_shares(instance, params = Params.new, progress: nil)
|
|
12
|
+
if instance.cardinal?
|
|
13
|
+
Rules::CardinalMes.call(instance, params, progress: progress)
|
|
14
|
+
else
|
|
15
|
+
Rules::MethodOfEqualShares.call(instance, params, progress: progress)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# A numeric view of an instance for a given accuracy. It exposes the voters,
|
|
5
|
+
# projects and supporters together with the per-project cost, the budget and the
|
|
6
|
+
# per-voter utilities, all converted to the right numeric type (exact Rational for
|
|
7
|
+
# "fractions", Float for "floats"). This centralises the setup that every rule
|
|
8
|
+
# previously repeated, and provides the float-based statistics used for reporting.
|
|
9
|
+
class Election
|
|
10
|
+
attr_reader :instance, :params
|
|
11
|
+
|
|
12
|
+
def initialize(instance, params = Params.new)
|
|
13
|
+
@instance = instance
|
|
14
|
+
@params = params
|
|
15
|
+
@exact = params.accuracy == "fractions"
|
|
16
|
+
@costs = instance.project_ids.to_h { |c| [c, numeric(instance.projects[c]["cost"])] }
|
|
17
|
+
@float_costs = instance.project_ids.to_h { |c| [c, Float(instance.projects[c]["cost"])] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def voter_ids
|
|
21
|
+
instance.voter_ids
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def project_ids
|
|
25
|
+
instance.project_ids
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def approvers
|
|
29
|
+
instance.approvers
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def supporters(project_id)
|
|
33
|
+
instance.approvers[project_id]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exact?
|
|
37
|
+
@exact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Per-project cost in the accuracy's numeric type, as a Hash{project_id => Numeric}.
|
|
41
|
+
attr_reader :costs
|
|
42
|
+
|
|
43
|
+
# Per-project cost as floats, as a Hash{project_id => Float} (for statistics and the
|
|
44
|
+
# cost/comparison bookkeeping that the equalshares.net tool always does in floats).
|
|
45
|
+
attr_reader :float_costs
|
|
46
|
+
|
|
47
|
+
def budget
|
|
48
|
+
@budget ||= numeric(instance.budget)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def float_budget
|
|
52
|
+
@float_budget ||= Float(instance.budget)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Per-project voter utilities for cardinal/ordinal ballots: { voter_id => Numeric }.
|
|
56
|
+
def utilities(project_id)
|
|
57
|
+
(instance.scores[project_id] || {}).transform_values { |score| numeric(score) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def statistics(winners)
|
|
61
|
+
Statistics.gather(voter_ids, @float_costs, instance.approvers, winners)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Convert a cost/budget/score string to the accuracy's numeric type.
|
|
65
|
+
def numeric(value)
|
|
66
|
+
@exact ? self.class.rational_of(value) : Float(value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Exact rational from a cost/budget string, matching fraction.js's decimal handling
|
|
70
|
+
# ("5000" -> 5000, "5000.5" -> 10001/2).
|
|
71
|
+
def self.rational_of(value)
|
|
72
|
+
Rational(value.to_s)
|
|
73
|
+
rescue ArgumentError
|
|
74
|
+
Float(value).to_r
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a pabulib (.pb) file cannot be parsed.
|
|
7
|
+
class ParseError < Error; end
|
|
8
|
+
|
|
9
|
+
# Raised for invalid parameters or unresolvable ties during computation.
|
|
10
|
+
class ComputeError < Error; end
|
|
11
|
+
end
|