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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +121 -0
  4. data/exe/equalshares +7 -0
  5. data/lib/equalshares/cli/formatter.rb +79 -0
  6. data/lib/equalshares/cli.rb +100 -0
  7. data/lib/equalshares/comparison.rb +48 -0
  8. data/lib/equalshares/completion.rb +106 -0
  9. data/lib/equalshares/compute.rb +19 -0
  10. data/lib/equalshares/election.rb +77 -0
  11. data/lib/equalshares/errors.rb +11 -0
  12. data/lib/equalshares/fixed_budget.rb +242 -0
  13. data/lib/equalshares/greedy.rb +12 -0
  14. data/lib/equalshares/instance.rb +60 -0
  15. data/lib/equalshares/max_flow.rb +89 -0
  16. data/lib/equalshares/maximin.rb +12 -0
  17. data/lib/equalshares/mes_general.rb +13 -0
  18. data/lib/equalshares/pabulib/csv.rb +56 -0
  19. data/lib/equalshares/pabulib/parser.rb +85 -0
  20. data/lib/equalshares/pabulib/project_row_parser.rb +60 -0
  21. data/lib/equalshares/pabulib/vote_row_parser.rb +96 -0
  22. data/lib/equalshares/pabulib/writer.rb +44 -0
  23. data/lib/equalshares/pabulib.rb +43 -0
  24. data/lib/equalshares/params.rb +61 -0
  25. data/lib/equalshares/phragmen.rb +12 -0
  26. data/lib/equalshares/result.rb +47 -0
  27. data/lib/equalshares/rules/base.rb +44 -0
  28. data/lib/equalshares/rules/cardinal_mes.rb +98 -0
  29. data/lib/equalshares/rules/greedy.rb +49 -0
  30. data/lib/equalshares/rules/maximin.rb +119 -0
  31. data/lib/equalshares/rules/method_of_equal_shares.rb +80 -0
  32. data/lib/equalshares/rules/phragmen.rb +68 -0
  33. data/lib/equalshares/satisfaction.rb +39 -0
  34. data/lib/equalshares/statistics.rb +34 -0
  35. data/lib/equalshares/tie_breaker.rb +95 -0
  36. data/lib/equalshares/tie_breaking.rb +48 -0
  37. data/lib/equalshares/version.rb +5 -0
  38. data/lib/equalshares.rb +40 -0
  39. 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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/equalshares"
5
+ require_relative "../lib/equalshares/cli"
6
+
7
+ exit Equalshares::CLI.start(ARGV)
@@ -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