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
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ module Rules
5
+ # The Method of Equal Shares for approval ballots — the equalshares.net rule,
6
+ # including its completion methods (none/utilitarian/add1/...) and comparison step.
7
+ # Faithful port of equalShares() in js/methodOfEqualSharesWorker.js.
8
+ #
9
+ # Cost and budget are treated as floats at this level (statistics, comparison,
10
+ # utilitarian/greedy committees, the everything-affordable check); only the inner
11
+ # fixed-budget MES loop switches to exact rationals for accuracy "fractions".
12
+ class MethodOfEqualShares < Base
13
+ ADD1_COMPLETIONS = %w[add1 add1e add1u add1eu].freeze
14
+ UTILITARIAN_COMPLETIONS = %w[utilitarian add1u].freeze
15
+
16
+ def call
17
+ start = now
18
+ voter_ids = election.voter_ids
19
+ project_ids = election.project_ids
20
+ approvers = election.approvers
21
+
22
+ # Strings for the MES loop (it wraps them per accuracy); floats for the
23
+ # completion/comparison/statistics bookkeeping (as the JS does).
24
+ cost_source = project_ids.to_h { |c| [c, instance.projects[c]["cost"]] }
25
+ cost = election.float_costs
26
+ b_float = election.float_budget
27
+
28
+ mes = run_mes(cost_source, instance.budget)
29
+ winners = mes.fetch(:winners)
30
+ report = mes.fetch(:report)
31
+ notes = {
32
+ endowment: report[:endowment],
33
+ money_behind_candidate: report[:money_behind_candidate],
34
+ effective_vote_count: report[:effective_vote_count]
35
+ }
36
+
37
+ # utilitarian completion if needed
38
+ if UTILITARIAN_COMPLETIONS.include?(params.completion)
39
+ completion = Completion.utilitarian(voter_ids, project_ids, cost, approvers, b_float, winners)
40
+ winners = completion.fetch(:winners)
41
+ notes[:added_by_utilitarian_completion] = completion.fetch(:added)
42
+ end
43
+
44
+ # comparison step
45
+ greedy = Completion.utilitarian(voter_ids, project_ids, cost, approvers, b_float, []).fetch(:winners)
46
+ unless params.comparison == "none"
47
+ cmp = Comparison.comparison_step(voter_ids, approvers, greedy, winners, params)
48
+ unless cmp[:stick_to_mes]
49
+ winners = greedy
50
+ notes[:comparison] =
51
+ "The committee chosen by the greedy algorithm is preferred by #{cmp[:prefers_greedy]} voters, " \
52
+ "while the committee chosen by the method of equal shares is preferred by #{cmp[:prefers_mes]} voters."
53
+ end
54
+ end
55
+
56
+ notes[:greedy_stats] = election.statistics(greedy)
57
+ result(winners, start, notes)
58
+ end
59
+
60
+ private
61
+
62
+ def run_mes(cost_source, budget_source)
63
+ voter_ids = election.voter_ids
64
+ project_ids = election.project_ids
65
+ approvers = election.approvers
66
+ everything_affordable = election.float_costs.values.sum <= election.float_budget
67
+
68
+ if %w[none utilitarian].include?(params.completion) || everything_affordable
69
+ # don't use Add1 if everything is affordable
70
+ FixedBudget.run(voter_ids, project_ids, cost_source, approvers, budget_source, params,
71
+ report_details: true, progress: progress)
72
+ elsif ADD1_COMPLETIONS.include?(params.completion)
73
+ Completion.add1(voter_ids, project_ids, cost_source, approvers, budget_source, params, progress: progress)
74
+ else
75
+ raise ComputeError, "Unknown completion rule: #{params.completion}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ module Rules
5
+ # Phragmén's sequential rule for approval participatory budgeting. Faithful port of
6
+ # pabutools' sequential_phragmen (resolute case).
7
+ #
8
+ # Voters accumulate "load" (starting at 0). Buying a project raises all its
9
+ # approvers to a common load level (sum of their loads + cost) / |approvers|. At
10
+ # each step the project minimising that new maximum load is bought; the rule stops
11
+ # once the next project to buy would exceed the budget.
12
+ class Phragmen < Base
13
+ def call
14
+ start = now
15
+ project_ids = election.project_ids
16
+ approvers = election.approvers
17
+ cost = election.costs
18
+ budget_limit = election.budget
19
+ approval_score = project_ids.to_h { |c| [c, approvers[c].length] }
20
+
21
+ loads = Hash.new(0) # voter id -> current load
22
+ remaining = project_ids.select { |c| cost[c] <= budget_limit }
23
+ winners = []
24
+ current_cost = 0
25
+
26
+ loop do
27
+ break if remaining.empty?
28
+
29
+ min_maxload, argmin = min_new_maxload(remaining, approvers, loads, cost, approval_score)
30
+
31
+ # Stop as soon as the next project to be bought would violate the budget.
32
+ break if argmin.any? { |c| current_cost + cost[c] > budget_limit }
33
+
34
+ selected = Tie.resolve_one(project_ids, cost, approvers, params, argmin)
35
+ approvers[selected].each { |i| loads[i] = min_maxload }
36
+ winners << selected
37
+ current_cost += cost[selected]
38
+ remaining.delete(selected)
39
+ progress&.call((100 * current_cost / budget_limit).floor)
40
+ end
41
+
42
+ result(winners, start)
43
+ end
44
+
45
+ private
46
+
47
+ def min_new_maxload(remaining, approvers, loads, cost, approval_score)
48
+ min_maxload = nil
49
+ argmin = []
50
+ remaining.each do |c|
51
+ new_maxload =
52
+ if approval_score[c].zero?
53
+ Float::INFINITY
54
+ else
55
+ (approvers[c].sum { |i| loads[i] } + cost[c]) / approval_score[c]
56
+ end
57
+ if min_maxload.nil? || new_maxload < min_maxload
58
+ min_maxload = new_maxload
59
+ argmin = [c]
60
+ elsif new_maxload == min_maxload
61
+ argmin << c
62
+ end
63
+ end
64
+ [min_maxload, argmin]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # Additive satisfaction measures for approval ballots, matching pabutools.
5
+ #
6
+ # For the Method of Equal Shares, only the per-approver marginal utility u_i(c)
7
+ # of a project matters, and for these measures it is uniform across all approvers
8
+ # of c (it depends on c, not on i). This lets the fixed-budget loop stay unchanged
9
+ # in its payment mechanics (each project still has to raise its full cost, split as
10
+ # equally as possible) while the *selection* criterion becomes u(c) / maxPayment.
11
+ #
12
+ # Cost_Sat u(c) = cost(c) (equalshares.net default)
13
+ # Cardinality_Sat u(c) = 1
14
+ #
15
+ # Both are verified to match pabutools exactly (Cost_Sat and Cardinality_Sat).
16
+ # Each strategy returns the per-approver utility in the same numeric family as the
17
+ # cost passed in (Rational for exact mode, Float for float mode), so downstream
18
+ # arithmetic keeps its accuracy.
19
+ module Satisfaction
20
+ module_function
21
+
22
+ NAMES = %w[cost cardinality effort].freeze
23
+
24
+ def for(name)
25
+ case name
26
+ when "cost" then COST
27
+ when "cardinality" then CARDINALITY
28
+ when "effort" then EFFORT
29
+ else
30
+ raise ComputeError, "Unknown satisfaction measure: #{name} (allowed: #{NAMES.join(', ')})"
31
+ end
32
+ end
33
+
34
+ # Each measure is a callable per_voter(cost_c, num_approvers) -> Numeric.
35
+ COST = ->(cost_c, _n) { cost_c }
36
+ CARDINALITY = ->(_cost_c, _n) { 1 }
37
+ EFFORT = ->(cost_c, n) { cost_c / n }
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # Outcome statistics. Faithful port of gatherOutcomeStatistics() in
5
+ # js/methodOfEqualSharesWorker.js.
6
+ module Statistics
7
+ module_function
8
+
9
+ # `cost` is the numeric cost map (Float or Rational).
10
+ def gather(voter_ids, cost, approvers, winners)
11
+ n = voter_ids.length
12
+ total_cost = winners.sum { |c| cost[c] }
13
+ avg_approved_projects = winners.sum { |c| approvers[c].length }.to_f / n
14
+ avg_cost_of_winning_approved = winners.sum { |c| approvers[c].length * cost[c] }.to_f / n
15
+
16
+ voter_utility = Hash.new(0)
17
+ winners.each do |c|
18
+ approvers[c].each { |i| voter_utility[i] += 1 }
19
+ end
20
+
21
+ # For each r, how many voters approve exactly r winning projects?
22
+ utility_distribution = {}
23
+ (0..winners.length).each { |util| utility_distribution[util] = 0 }
24
+ voter_ids.each { |i| utility_distribution[voter_utility[i]] += 1 }
25
+
26
+ {
27
+ total_cost: total_cost,
28
+ avg_approved_projects: avg_approved_projects,
29
+ avg_cost_of_winning_approved_projects: avg_cost_of_winning_approved,
30
+ utility_distribution: utility_distribution
31
+ }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # Tie-breaking strategies. Each element of params.tie_breaking maps to one strategy
5
+ # object (via TieBreaker.for). A strategy can either narrow a tied set (#filter, used
6
+ # to resolve a single tie in priority order) or contribute a sort key (#sort_key, used
7
+ # to build a total order over all projects). Mirrors pabutools' TieBreakingRule.
8
+ module TieBreaker
9
+ # Cost/approver lookups a strategy needs. `cost` is the rule's numeric cost map.
10
+ Context = Struct.new(:cost, :approvers)
11
+
12
+ module_function
13
+
14
+ def for(method)
15
+ case method
16
+ when "maxVotes" then MaxVotes.new
17
+ when "minCost" then MinCost.new
18
+ when "maxCost" then MaxCost.new
19
+ when Hash then Lexico.new(lexico_order(method))
20
+ when Array then ExplicitList.new(method)
21
+ else
22
+ raise ComputeError, "Unknown tie-breaking method: #{method}"
23
+ end
24
+ end
25
+
26
+ def lexico_order(method)
27
+ order = method[:lexico] || method["lexico"]
28
+ raise ComputeError, "Unknown tie-breaking method: #{method}" unless order.is_a?(Array)
29
+
30
+ order
31
+ end
32
+
33
+ # Keep the projects tied at the best value of some key.
34
+ class Extreme
35
+ def filter(remaining, ctx)
36
+ best = remaining.map { |c| key(c, ctx) }.public_send(extreme)
37
+ remaining.select { |c| key(c, ctx) == best }
38
+ end
39
+ end
40
+
41
+ class MaxVotes < Extreme
42
+ def key(project_id, ctx) = ctx.approvers[project_id].length
43
+ def extreme = :max
44
+ def sort_key(project_id, ctx) = -ctx.approvers[project_id].length
45
+ end
46
+
47
+ class MinCost < Extreme
48
+ def key(project_id, ctx) = ctx.cost[project_id]
49
+ def extreme = :min
50
+ def sort_key(project_id, ctx) = ctx.cost[project_id]
51
+ end
52
+
53
+ class MaxCost < Extreme
54
+ def key(project_id, ctx) = ctx.cost[project_id]
55
+ def extreme = :max
56
+ def sort_key(project_id, ctx) = -ctx.cost[project_id]
57
+ end
58
+
59
+ # A total order (pabutools-style): rank tied projects by their position in the list
60
+ # and keep the first.
61
+ class Lexico
62
+ def initialize(order)
63
+ @order = order
64
+ @position = order.each_with_index.to_h
65
+ end
66
+
67
+ def filter(remaining, _ctx)
68
+ [remaining.min_by { |c| @position[c] || @order.length }]
69
+ end
70
+
71
+ def sort_key(project_id, _ctx)
72
+ @position[project_id] || @order.length
73
+ end
74
+ end
75
+
76
+ # JS-faithful explicit list: keep the first *choice* that appears anywhere in the
77
+ # list (does NOT respect the list's ordering). Kept for bit-compatibility with the
78
+ # equalshares.net tool; prefer Lexico for an order-respecting tie-break.
79
+ class ExplicitList
80
+ def initialize(list)
81
+ @list = list
82
+ @position = list.each_with_index.to_h
83
+ end
84
+
85
+ def filter(remaining, _ctx)
86
+ found = remaining.find { |c| @list.include?(c) }
87
+ found ? [found] : remaining
88
+ end
89
+
90
+ def sort_key(project_id, _ctx)
91
+ @position[project_id] || @list.length
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ # Tie-breaking facade. Composes the TieBreaker strategies named in params.tie_breaking
5
+ # to either resolve a single tie (break_ties) or build a total order (total_order).
6
+ module Tie
7
+ module_function
8
+
9
+ # Resolve a tie among `choices` by applying each tie-breaking strategy in priority
10
+ # order, narrowing the set. Returns the surviving candidates (callers treat more
11
+ # than one as an unresolved tie).
12
+ def break_ties(_project_ids, cost, approvers, params, choices)
13
+ ctx = TieBreaker::Context.new(cost, approvers)
14
+ remaining = params.tie_breaking.reduce(choices.dup) do |current, method|
15
+ TieBreaker.for(method).filter(current, ctx)
16
+ end
17
+
18
+ raise ComputeError, "Tie-breaking failed in a way that should not happen: #{choices}" if remaining.empty?
19
+
20
+ remaining
21
+ end
22
+
23
+ # Resolve a tie down to a single winner, raising if the tie-breaking rules leave
24
+ # more than one candidate. Used by the sequential rules.
25
+ def resolve_one(project_ids, cost, approvers, params, choices)
26
+ resolved = break_ties(project_ids, cost, approvers, params, choices)
27
+ if resolved.length > 1
28
+ raise ComputeError,
29
+ "Tie-breaking failed: tie between projects #{resolved.join(', ')} could not be resolved. " \
30
+ "Another tie-breaking needs to be added."
31
+ end
32
+ resolved[0]
33
+ end
34
+
35
+ # A total order of all projects induced by params.tie_breaking, used by rules that
36
+ # need a full ordering (e.g. greedy welfare). Projects are sorted by each strategy's
37
+ # sort key in priority order, falling back to their original (JS Object.keys) order.
38
+ def total_order(project_ids, cost, approvers, params)
39
+ ctx = TieBreaker::Context.new(cost, approvers)
40
+ strategies = params.tie_breaking.map { |method| TieBreaker.for(method) }
41
+ base_index = project_ids.each_with_index.to_h
42
+
43
+ project_ids.sort_by do |c|
44
+ strategies.map { |strategy| strategy.sort_key(c, ctx) } + [base_index[c]]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Equalshares
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "equalshares/version"
4
+ require_relative "equalshares/errors"
5
+ require_relative "equalshares/instance"
6
+ require_relative "equalshares/satisfaction"
7
+ require_relative "equalshares/params"
8
+ require_relative "equalshares/pabulib"
9
+ require_relative "equalshares/tie_breaker"
10
+ require_relative "equalshares/tie_breaking"
11
+ require_relative "equalshares/statistics"
12
+ require_relative "equalshares/election"
13
+ require_relative "equalshares/fixed_budget"
14
+ require_relative "equalshares/completion"
15
+ require_relative "equalshares/comparison"
16
+ require_relative "equalshares/max_flow"
17
+ require_relative "equalshares/result"
18
+
19
+ # Rule objects
20
+ require_relative "equalshares/rules/base"
21
+ require_relative "equalshares/rules/method_of_equal_shares"
22
+ require_relative "equalshares/rules/cardinal_mes"
23
+ require_relative "equalshares/rules/phragmen"
24
+ require_relative "equalshares/rules/greedy"
25
+ require_relative "equalshares/rules/maximin"
26
+
27
+ # Facades over the rule objects (stable public API)
28
+ require_relative "equalshares/phragmen"
29
+ require_relative "equalshares/greedy"
30
+ require_relative "equalshares/maximin"
31
+ require_relative "equalshares/mes_general"
32
+ require_relative "equalshares/compute"
33
+
34
+ module Equalshares
35
+ # Convenience: parse a .pb file and compute the outcome in one call.
36
+ def self.compute_file(path, params = Params.new, progress: nil)
37
+ instance = Pabulib.parse_file(path)
38
+ Compute.equal_shares(instance, params, progress: progress)
39
+ end
40
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: equalshares
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - takahashim
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A Ruby implementation of the equalshares.net compute tool. Parses pabulib
13
+ (.pb) files and computes winning projects using the Method of Equal Shares (with
14
+ tie-breaking, Add1/utilitarian completion and a comparison step), plus Phragmén's
15
+ sequential rule, greedy utilitarian welfare and maximin support. Supports approval,
16
+ cardinal (scoring/cumulative) and ordinal ballots, several satisfaction measures,
17
+ and both exact (Rational) and floating-point arithmetic. Cross-checked against pabutools.
18
+ email:
19
+ - takahashimm@gmail.com
20
+ executables:
21
+ - equalshares
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - LICENSE.txt
26
+ - README.md
27
+ - exe/equalshares
28
+ - lib/equalshares.rb
29
+ - lib/equalshares/cli.rb
30
+ - lib/equalshares/cli/formatter.rb
31
+ - lib/equalshares/comparison.rb
32
+ - lib/equalshares/completion.rb
33
+ - lib/equalshares/compute.rb
34
+ - lib/equalshares/election.rb
35
+ - lib/equalshares/errors.rb
36
+ - lib/equalshares/fixed_budget.rb
37
+ - lib/equalshares/greedy.rb
38
+ - lib/equalshares/instance.rb
39
+ - lib/equalshares/max_flow.rb
40
+ - lib/equalshares/maximin.rb
41
+ - lib/equalshares/mes_general.rb
42
+ - lib/equalshares/pabulib.rb
43
+ - lib/equalshares/pabulib/csv.rb
44
+ - lib/equalshares/pabulib/parser.rb
45
+ - lib/equalshares/pabulib/project_row_parser.rb
46
+ - lib/equalshares/pabulib/vote_row_parser.rb
47
+ - lib/equalshares/pabulib/writer.rb
48
+ - lib/equalshares/params.rb
49
+ - lib/equalshares/phragmen.rb
50
+ - lib/equalshares/result.rb
51
+ - lib/equalshares/rules/base.rb
52
+ - lib/equalshares/rules/cardinal_mes.rb
53
+ - lib/equalshares/rules/greedy.rb
54
+ - lib/equalshares/rules/maximin.rb
55
+ - lib/equalshares/rules/method_of_equal_shares.rb
56
+ - lib/equalshares/rules/phragmen.rb
57
+ - lib/equalshares/satisfaction.rb
58
+ - lib/equalshares/statistics.rb
59
+ - lib/equalshares/tie_breaker.rb
60
+ - lib/equalshares/tie_breaking.rb
61
+ - lib/equalshares/version.rb
62
+ homepage: https://github.com/takahashim/equalshares
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ homepage_uri: https://github.com/takahashim/equalshares
67
+ source_code_uri: https://github.com/takahashim/equalshares
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.2.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 4.0.10
84
+ specification_version: 4
85
+ summary: Method of Equal Shares computation for participatory budgeting
86
+ test_files: []