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,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
module Pabulib
|
|
5
|
+
# Parses a single row of the VOTES section into the accumulator, turning the vote
|
|
6
|
+
# into approvals and (for scoring/cumulative/ordinal ballots) per-voter scores.
|
|
7
|
+
class VoteRowParser
|
|
8
|
+
def initialize(accumulator)
|
|
9
|
+
@acc = accumulator
|
|
10
|
+
vote_type = accumulator.meta["vote_type"]
|
|
11
|
+
@cardinal = CARDINAL_VOTE_TYPES.include?(vote_type)
|
|
12
|
+
@ordinal = vote_type == "ordinal"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse(row, header, line_number)
|
|
16
|
+
voter_id_idx = header.index("voter_id")
|
|
17
|
+
vote_idx = header.index("vote")
|
|
18
|
+
points_idx = header.index("points")
|
|
19
|
+
|
|
20
|
+
require_columns!(voter_id_idx, vote_idx, line_number)
|
|
21
|
+
validate_column_count!(row, header, line_number)
|
|
22
|
+
|
|
23
|
+
voter_id = row[voter_id_idx].strip
|
|
24
|
+
raise ParseError, "Line #{line_number}: Duplicate voter ID '#{voter_id}' found." if @acc.voter_ids_set[voter_id]
|
|
25
|
+
|
|
26
|
+
record_vote(row, vote_idx, points_idx, voter_id, line_number)
|
|
27
|
+
store_voter(row, header, voter_id)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def require_columns!(voter_id_idx, vote_idx, line_number)
|
|
33
|
+
return unless voter_id_idx.nil? || vote_idx.nil?
|
|
34
|
+
|
|
35
|
+
missing = []
|
|
36
|
+
missing << "voter_id " if voter_id_idx.nil?
|
|
37
|
+
missing << "vote" if vote_idx.nil?
|
|
38
|
+
raise ParseError, "Line #{line_number}: Missing required column(s) in votes section: #{missing.join}."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_column_count!(row, header, line_number)
|
|
42
|
+
return if row.length == header.length
|
|
43
|
+
|
|
44
|
+
raise ParseError, "Line #{line_number}: Invalid number of columns in votes section."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_vote(row, vote_idx, points_idx, voter_id, line_number)
|
|
48
|
+
return if row[vote_idx] == ""
|
|
49
|
+
|
|
50
|
+
project_list = row[vote_idx].split(",")
|
|
51
|
+
points = points_list(row, points_idx)
|
|
52
|
+
project_list.each_with_index do |project_id, k|
|
|
53
|
+
pid = project_id.strip
|
|
54
|
+
unless @acc.project_ids_set[pid]
|
|
55
|
+
raise ParseError, "Line #{line_number}: Invalid project ID '#{pid}' found in vote."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
add_support(pid, voter_id, score_for(project_list, points, k))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def points_list(row, points_idx)
|
|
63
|
+
return nil unless @cardinal && points_idx && row[points_idx] != ""
|
|
64
|
+
|
|
65
|
+
row[points_idx].split(",")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Borda score for ordinal ballots (ballot length - rank - 1, last-ranked = 0);
|
|
69
|
+
# the raw points value for scoring/cumulative ballots; nil for plain approval.
|
|
70
|
+
def score_for(project_list, points, index)
|
|
71
|
+
if @ordinal
|
|
72
|
+
(project_list.length - index - 1).to_s
|
|
73
|
+
elsif points
|
|
74
|
+
points[index].to_s.strip
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def add_support(pid, voter_id, score)
|
|
79
|
+
if @ordinal || score
|
|
80
|
+
return unless Float(score, exception: false)&.positive? # supporters have a positive score
|
|
81
|
+
|
|
82
|
+
@acc.approvers[pid] << voter_id
|
|
83
|
+
(@acc.scores[pid] ||= {})[voter_id] = score
|
|
84
|
+
else
|
|
85
|
+
@acc.approvers[pid] << voter_id
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def store_voter(row, header, voter_id)
|
|
90
|
+
@acc.voter_ids_set[voter_id] = true
|
|
91
|
+
@acc.votes[voter_id] = {}
|
|
92
|
+
header.each_index { |idx| @acc.votes[voter_id][header[idx].strip] = row[idx].strip }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
module Pabulib
|
|
5
|
+
# Serialises an Instance back to pabulib (.pb) text. Inverse of the parser:
|
|
6
|
+
# META / PROJECTS / VOTES sections, ';'-separated, with fields containing ';', '"'
|
|
7
|
+
# or newlines quoted (and embedded '"' doubled). Parsing the result yields an
|
|
8
|
+
# equivalent Instance (round-trip safe).
|
|
9
|
+
module Writer
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def write_string(instance)
|
|
13
|
+
lines = ["META", "key;value"]
|
|
14
|
+
instance.meta.each { |key, value| lines << "#{escape(key)};#{escape(value)}" }
|
|
15
|
+
write_section(lines, "PROJECTS", instance.projects)
|
|
16
|
+
write_section(lines, "VOTES", instance.votes)
|
|
17
|
+
"#{lines.join("\n")}\n"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write_file(instance, path)
|
|
21
|
+
File.write(path, write_string(instance))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def write_section(lines, header_name, rows_by_id)
|
|
25
|
+
lines << header_name
|
|
26
|
+
return if rows_by_id.empty?
|
|
27
|
+
|
|
28
|
+
header = rows_by_id.values.first.keys
|
|
29
|
+
lines << header.map { |h| escape(h) }.join(";")
|
|
30
|
+
rows_by_id.each_value do |row|
|
|
31
|
+
lines << header.map { |h| escape(row[h]) }.join(";")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Inverse of the CSV field handling in Pabulib::Csv.parse_line.
|
|
36
|
+
def escape(value)
|
|
37
|
+
str = value.to_s
|
|
38
|
+
return str unless str.match?(/[;"\n]/)
|
|
39
|
+
|
|
40
|
+
%("#{str.gsub('"', '""')}")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pabulib/csv"
|
|
4
|
+
require_relative "pabulib/project_row_parser"
|
|
5
|
+
require_relative "pabulib/vote_row_parser"
|
|
6
|
+
require_relative "pabulib/parser"
|
|
7
|
+
require_relative "pabulib/writer"
|
|
8
|
+
|
|
9
|
+
module Equalshares
|
|
10
|
+
# Reads and writes pabulib (.pb) files: a sectioned, semicolon-delimited CSV with
|
|
11
|
+
# META / PROJECTS / VOTES sections. Faithful port of js/pabulibParser.js, extended
|
|
12
|
+
# to cardinal (scoring/cumulative) and ordinal ballots.
|
|
13
|
+
#
|
|
14
|
+
# This module is a thin facade; the work lives in Pabulib::Parser (which delegates
|
|
15
|
+
# rows to Pabulib::ProjectRowParser / Pabulib::VoteRowParser) and Pabulib::Writer.
|
|
16
|
+
module Pabulib
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
SECTIONS = %w[meta projects votes].freeze
|
|
20
|
+
# vote_type values that carry per-voter cardinal scores (a `points` column).
|
|
21
|
+
CARDINAL_VOTE_TYPES = %w[scoring cumulative].freeze
|
|
22
|
+
# vote_type values that produce per-voter utilities (scores) for the general MES:
|
|
23
|
+
# cardinal ballots plus ordinal ballots (via Borda scores).
|
|
24
|
+
SCORED_VOTE_TYPES = %w[scoring cumulative ordinal].freeze
|
|
25
|
+
|
|
26
|
+
def parse_file(path)
|
|
27
|
+
parse_from_string(File.read(path))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns an Equalshares::Instance.
|
|
31
|
+
def parse_from_string(filetext)
|
|
32
|
+
Parser.new.parse(filetext)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def write_string(instance)
|
|
36
|
+
Writer.write_string(instance)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write_file(instance, path)
|
|
40
|
+
Writer.write_file(instance, path)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Computation parameters. Mirrors the `equalSharesParams` object from js/main.js
|
|
5
|
+
# and the allowed values wired up in js/interface/formHandler.js.
|
|
6
|
+
class Params
|
|
7
|
+
TIE_BREAKING_METHODS = %w[maxVotes minCost maxCost].freeze
|
|
8
|
+
COMPLETIONS = %w[none utilitarian add1 add1e add1u add1eu].freeze
|
|
9
|
+
ADD1_OPTIONS = %w[exhaustive integral].freeze
|
|
10
|
+
COMPARISONS = %w[none satisfaction exclusionRatio].freeze
|
|
11
|
+
ACCURACIES = %w[floats fractions].freeze
|
|
12
|
+
SATISFACTIONS = Satisfaction::NAMES
|
|
13
|
+
|
|
14
|
+
attr_reader :tie_breaking, :completion, :add1_options, :comparison, :accuracy, :increment, :satisfaction
|
|
15
|
+
|
|
16
|
+
# Defaults match js/main.js:6-13. `satisfaction` selects the MES satisfaction
|
|
17
|
+
# measure (pabutools parity); "cost" is the equalshares.net default.
|
|
18
|
+
def initialize(tie_breaking: [], completion: "add1u", add1_options: %w[exhaustive integral],
|
|
19
|
+
comparison: "none", accuracy: "floats", increment: 1, satisfaction: "cost")
|
|
20
|
+
@tie_breaking = Array(tie_breaking)
|
|
21
|
+
@completion = completion
|
|
22
|
+
@add1_options = Array(add1_options)
|
|
23
|
+
@comparison = comparison
|
|
24
|
+
@accuracy = accuracy
|
|
25
|
+
@increment = increment
|
|
26
|
+
@satisfaction = satisfaction
|
|
27
|
+
validate!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add1_option?(name)
|
|
31
|
+
@add1_options.include?(name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate!
|
|
37
|
+
@tie_breaking.each do |m|
|
|
38
|
+
# A tie-breaking method is a known keyword, an explicit candidate-order list
|
|
39
|
+
# (Array), or a total order ({ lexico: [...] }).
|
|
40
|
+
next if m.is_a?(Array) || m.is_a?(Hash) || TIE_BREAKING_METHODS.include?(m)
|
|
41
|
+
|
|
42
|
+
raise ComputeError, "Unknown tie-breaking method: #{m}"
|
|
43
|
+
end
|
|
44
|
+
validate_inclusion(:completion, @completion, COMPLETIONS)
|
|
45
|
+
validate_inclusion(:comparison, @comparison, COMPARISONS)
|
|
46
|
+
validate_inclusion(:accuracy, @accuracy, ACCURACIES)
|
|
47
|
+
validate_inclusion(:satisfaction, @satisfaction, SATISFACTIONS)
|
|
48
|
+
@add1_options.each { |o| validate_inclusion(:add1_options, o, ADD1_OPTIONS) }
|
|
49
|
+
return if @increment.is_a?(Integer) && @increment.positive?
|
|
50
|
+
|
|
51
|
+
raise ComputeError,
|
|
52
|
+
"increment must be a positive integer"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_inclusion(field, value, allowed)
|
|
56
|
+
return if allowed.include?(value)
|
|
57
|
+
|
|
58
|
+
raise ComputeError, "Invalid #{field}: #{value.inspect} (allowed: #{allowed.join(', ')})"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Facade for Phragmén's sequential rule; delegates to Rules::Phragmen.
|
|
5
|
+
module Phragmen
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def sequential(instance, params = Params.new, progress: nil)
|
|
9
|
+
Rules::Phragmen.call(instance, params, progress: progress)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Equalshares
|
|
6
|
+
# The outcome of a voting rule: the winning project ids together with outcome
|
|
7
|
+
# statistics, timing and any rule-specific notes (endowment, effective vote counts,
|
|
8
|
+
# comparison message, greedy statistics, ...).
|
|
9
|
+
class Result
|
|
10
|
+
attr_reader :winners, :notes
|
|
11
|
+
|
|
12
|
+
def initialize(winners:, notes:)
|
|
13
|
+
@winners = winners
|
|
14
|
+
@notes = notes
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def stats
|
|
18
|
+
notes[:stats]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def time
|
|
22
|
+
notes[:time]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def total_cost
|
|
26
|
+
stats[:total_cost]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def endowment
|
|
30
|
+
notes[:endowment]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Last recorded effective vote count for a project (Method of Equal Shares only),
|
|
34
|
+
# or nil for rules/projects without one.
|
|
35
|
+
def effective_vote_count(project_id)
|
|
36
|
+
(notes[:effective_vote_count] || {})[project_id]&.last
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
{ winners: winners, notes: notes }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_json(*)
|
|
44
|
+
to_h.to_json(*)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
# Voting rules as objects. Each concrete rule is a Rules::Base subclass that computes
|
|
5
|
+
# winners from an instance; the thin module facades (Equalshares::Phragmen, etc.)
|
|
6
|
+
# delegate to these classes.
|
|
7
|
+
module Rules
|
|
8
|
+
# Holds the (instance, params) and the derived Election numeric view, and assembles
|
|
9
|
+
# the { winners:, notes: } result with statistics and timing. Subclasses implement
|
|
10
|
+
# #call and return `result(winners, start, extra_notes)`.
|
|
11
|
+
class Base
|
|
12
|
+
def self.call(instance, params = Params.new, progress: nil)
|
|
13
|
+
new(instance, params, progress: progress).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(instance, params = Params.new, progress: nil)
|
|
17
|
+
@instance = instance
|
|
18
|
+
@params = params
|
|
19
|
+
@progress = progress
|
|
20
|
+
@election = Election.new(instance, params)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :instance, :params, :election, :progress
|
|
30
|
+
|
|
31
|
+
def now
|
|
32
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build the Result, adding outcome statistics and elapsed time to any
|
|
36
|
+
# rule-specific notes.
|
|
37
|
+
def result(winners, since, extra_notes = {})
|
|
38
|
+
notes = extra_notes.merge(stats: election.statistics(winners),
|
|
39
|
+
time: format("%.1f", now - since))
|
|
40
|
+
Result.new(winners: winners, notes: notes)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
module Rules
|
|
5
|
+
# Method of Equal Shares for ballots carrying per-voter utilities (vote_type
|
|
6
|
+
# "scoring"/"cumulative", or "ordinal" via Borda), where each voter i has a
|
|
7
|
+
# per-project utility u_i(c). Faithful port of the core of pabutools'
|
|
8
|
+
# method_of_equal_shares with an additive satisfaction (the "poor/rich"
|
|
9
|
+
# affordability computation), for the pure rule (no completion).
|
|
10
|
+
#
|
|
11
|
+
# The approval path (Rules::MethodOfEqualShares) is kept separate; this general
|
|
12
|
+
# path is used when instance.cardinal? is true.
|
|
13
|
+
class CardinalMes < Base
|
|
14
|
+
def call
|
|
15
|
+
unless instance.cardinal?
|
|
16
|
+
raise ComputeError,
|
|
17
|
+
"CardinalMes applies to instances with per-voter scores (scoring/cumulative/ordinal)"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
start = now
|
|
21
|
+
voter_ids = election.voter_ids
|
|
22
|
+
project_ids = election.project_ids
|
|
23
|
+
approvers = election.approvers
|
|
24
|
+
cost = election.costs
|
|
25
|
+
budget_limit = election.budget
|
|
26
|
+
util = project_ids.to_h { |c| [c, election.utilities(c)] }
|
|
27
|
+
n = voter_ids.length
|
|
28
|
+
|
|
29
|
+
budget = {}
|
|
30
|
+
voter_ids.each { |i| budget[i] = budget_limit / n } # endowment B/|N|
|
|
31
|
+
|
|
32
|
+
remaining = project_ids.select { |c| cost[c].positive? && !approvers[c].empty? }
|
|
33
|
+
winners = []
|
|
34
|
+
|
|
35
|
+
loop do
|
|
36
|
+
argmin = argmin_by_affordability(remaining, approvers, budget, util, cost)
|
|
37
|
+
break if argmin.empty?
|
|
38
|
+
|
|
39
|
+
selected = Tie.resolve_one(project_ids, cost, approvers, params, argmin)
|
|
40
|
+
charge(selected, approvers, budget, util, cost)
|
|
41
|
+
winners << selected
|
|
42
|
+
remaining.delete(selected)
|
|
43
|
+
progress&.call((100 * winners.sum { |c| cost[c] } / budget_limit).floor)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
result(winners, start)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def argmin_by_affordability(remaining, approvers, budget, util, cost)
|
|
52
|
+
min_rho = nil
|
|
53
|
+
argmin = []
|
|
54
|
+
remaining.dup.each do |c|
|
|
55
|
+
supporters = approvers[c]
|
|
56
|
+
if supporters.sum { |i| budget[i] } < cost[c]
|
|
57
|
+
remaining.delete(c) # can never become affordable again
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
rho = affordability(supporters, budget, util[c], cost[c])
|
|
61
|
+
if min_rho.nil? || rho < min_rho
|
|
62
|
+
min_rho = rho
|
|
63
|
+
argmin = [c]
|
|
64
|
+
elsif rho == min_rho
|
|
65
|
+
argmin << c
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
argmin
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def charge(selected, approvers, budget, util, cost)
|
|
72
|
+
rho = affordability(approvers[selected], budget, util[selected], cost[selected])
|
|
73
|
+
zero = election.exact? ? 0 : 0.0
|
|
74
|
+
approvers[selected].each do |i|
|
|
75
|
+
payment = rho * util[selected][i]
|
|
76
|
+
budget[i] = budget[i] > payment ? budget[i] - payment : zero
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The "poor/rich" affordability factor rho: minimal rho such that
|
|
81
|
+
# sum_i min(budget_i, rho * u_i(c)) == cost(c). Port of pabutools'
|
|
82
|
+
# affordability_poor_rich.
|
|
83
|
+
def affordability(supporters, budget, util, cost)
|
|
84
|
+
rich = supporters.dup
|
|
85
|
+
poor_budget = 0
|
|
86
|
+
loop do
|
|
87
|
+
denominator = rich.sum { |i| util[i] }
|
|
88
|
+
rho = (cost - poor_budget) / denominator
|
|
89
|
+
new_poor = rich.select { |i| budget[i] < rho * util[i] }
|
|
90
|
+
return rho if new_poor.empty?
|
|
91
|
+
|
|
92
|
+
poor_budget += new_poor.sum { |i| budget[i] }
|
|
93
|
+
rich -= new_poor
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
module Rules
|
|
5
|
+
# Greedy approximation of utilitarian welfare, generalised over a satisfaction
|
|
6
|
+
# measure. Faithful port of pabutools' greedy_utilitarian_welfare (additive,
|
|
7
|
+
# resolute case).
|
|
8
|
+
#
|
|
9
|
+
# Projects are picked in order of decreasing "satisfaction density" = total
|
|
10
|
+
# satisfaction of the project divided by its cost, resolving ties by the
|
|
11
|
+
# tie-breaking order; any project that still fits the remaining budget is taken
|
|
12
|
+
# (knapsack greedy). For Cost_Sat the density is the approver count.
|
|
13
|
+
class Greedy < Base
|
|
14
|
+
def call
|
|
15
|
+
start = now
|
|
16
|
+
project_ids = election.project_ids
|
|
17
|
+
approvers = election.approvers
|
|
18
|
+
cost = election.costs
|
|
19
|
+
sat = Satisfaction.for(params.satisfaction)
|
|
20
|
+
|
|
21
|
+
order = Tie.total_order(project_ids, cost, approvers, params)
|
|
22
|
+
order_index = order.each_with_index.to_h
|
|
23
|
+
ranked = project_ids.sort_by { |c| [-density(c, cost, approvers, sat), order_index[c]] }
|
|
24
|
+
|
|
25
|
+
winners = []
|
|
26
|
+
remaining_budget = election.budget
|
|
27
|
+
ranked.each do |c|
|
|
28
|
+
next unless cost[c] <= remaining_budget
|
|
29
|
+
|
|
30
|
+
winners << c
|
|
31
|
+
remaining_budget -= cost[c]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
result(winners, start)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def density(project_id, cost, approvers, sat)
|
|
40
|
+
total_sat = sat.call(cost[project_id], approvers[project_id].length) * approvers[project_id].length
|
|
41
|
+
if total_sat.positive?
|
|
42
|
+
cost[project_id].positive? ? total_sat / cost[project_id] : Float::INFINITY
|
|
43
|
+
else
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Equalshares
|
|
4
|
+
module Rules
|
|
5
|
+
# The maximin support rule (Aziz, Lee & Talmon 2018; "Generalised Sequential
|
|
6
|
+
# Phragmén"), for approval ballots. Faithful port of pabutools' maximin_support.
|
|
7
|
+
#
|
|
8
|
+
# At each step, for every still-affordable project, the minimum achievable maximum
|
|
9
|
+
# voter load of the committee W ∪ {c} is computed, and the project minimising it is
|
|
10
|
+
# bought. Rather than an LP (as pabutools uses), the minimum max-load is computed
|
|
11
|
+
# exactly: it equals the max-density subgraph value
|
|
12
|
+
# z*(W) = max_{S ⊆ W, S≠∅} cost(S) / |approvers(S)|
|
|
13
|
+
# solved via a parametric max-flow (Dinkelbach iterations) in exact rational
|
|
14
|
+
# arithmetic. This keeps the rule pure-Ruby, dependency-free and exact.
|
|
15
|
+
#
|
|
16
|
+
# Requires integer project costs (as in real pabulib data).
|
|
17
|
+
class Maximin < Base
|
|
18
|
+
def call
|
|
19
|
+
start = now
|
|
20
|
+
project_ids = election.project_ids
|
|
21
|
+
approvers = election.approvers
|
|
22
|
+
# maximin needs integer costs to scale the max-flow network exactly.
|
|
23
|
+
cost = project_ids.to_h { |c| [c, integer_cost(instance.projects[c]["cost"])] }
|
|
24
|
+
budget_limit = integer_cost(instance.budget)
|
|
25
|
+
|
|
26
|
+
available = project_ids.select { |c| cost[c].between?(0, budget_limit) }
|
|
27
|
+
winners = []
|
|
28
|
+
remaining_budget = budget_limit
|
|
29
|
+
|
|
30
|
+
loop do
|
|
31
|
+
available = available.select { |c| !winners.include?(c) && cost[c] <= remaining_budget }
|
|
32
|
+
break if available.empty?
|
|
33
|
+
|
|
34
|
+
argmin = argmin_by_load(available, winners, cost, approvers)
|
|
35
|
+
selected = Tie.resolve_one(project_ids, cost, approvers, params, argmin)
|
|
36
|
+
|
|
37
|
+
winners << selected
|
|
38
|
+
remaining_budget -= cost[selected]
|
|
39
|
+
progress&.call((100 * winners.sum { |c| cost[c] } / budget_limit).floor)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
result(winners, start)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def argmin_by_load(available, winners, cost, approvers)
|
|
48
|
+
min_load = nil
|
|
49
|
+
argmin = []
|
|
50
|
+
available.each do |c|
|
|
51
|
+
load = min_max_load(winners + [c], cost, approvers)
|
|
52
|
+
if min_load.nil? || load < min_load
|
|
53
|
+
min_load = load
|
|
54
|
+
argmin = [c]
|
|
55
|
+
elsif load == min_load
|
|
56
|
+
argmin << c
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
argmin
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Minimum achievable maximum voter load for the committee, in exact rationals.
|
|
63
|
+
# Equals the max-density subgraph value; Float::INFINITY if some project has cost
|
|
64
|
+
# but no approvers (its cost cannot be distributed).
|
|
65
|
+
def min_max_load(committee, cost, approvers)
|
|
66
|
+
projects = committee.select { |c| cost[c].positive? }
|
|
67
|
+
return 0 if projects.empty?
|
|
68
|
+
return Float::INFINITY if projects.any? { |c| approvers[c].empty? }
|
|
69
|
+
|
|
70
|
+
total_cost = projects.sum { |c| cost[c] }
|
|
71
|
+
density = Rational(0)
|
|
72
|
+
(projects.length + 2).times do
|
|
73
|
+
source_side = feasible_or_violating_set(projects, cost, approvers, density, total_cost)
|
|
74
|
+
return density if source_side.nil? # feasible at this level: density is optimal
|
|
75
|
+
|
|
76
|
+
covered = source_side.flat_map { |c| approvers[c] }.uniq.length
|
|
77
|
+
density = Rational(source_side.sum { |c| cost[c] }, covered)
|
|
78
|
+
end
|
|
79
|
+
density
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Build the parametric max-flow at load level `density` and return the set of
|
|
83
|
+
# projects on the source side of the min cut (a set denser than `density`), or nil
|
|
84
|
+
# if the whole cost can be routed (density is >= the true max-density).
|
|
85
|
+
def feasible_or_violating_set(projects, cost, approvers, density, total_cost)
|
|
86
|
+
den = density.denominator
|
|
87
|
+
num = density.numerator
|
|
88
|
+
voters = projects.flat_map { |c| approvers[c] }.uniq
|
|
89
|
+
|
|
90
|
+
source = 0
|
|
91
|
+
sink = 1
|
|
92
|
+
project_node = {}
|
|
93
|
+
projects.each_with_index { |c, i| project_node[c] = 2 + i }
|
|
94
|
+
voter_node = {}
|
|
95
|
+
voters.each_with_index { |v, i| voter_node[v] = 2 + projects.length + i }
|
|
96
|
+
|
|
97
|
+
flow = MaxFlow.new(2 + projects.length + voters.length)
|
|
98
|
+
infinity = total_cost * den # >= any single project's scaled cost
|
|
99
|
+
projects.each do |c|
|
|
100
|
+
flow.add_edge(source, project_node[c], cost[c] * den)
|
|
101
|
+
approvers[c].each { |v| flow.add_edge(project_node[c], voter_node[v], infinity) }
|
|
102
|
+
end
|
|
103
|
+
voters.each { |v| flow.add_edge(voter_node[v], sink, num) }
|
|
104
|
+
|
|
105
|
+
pushed = flow.max_flow(source, sink)
|
|
106
|
+
return nil if pushed == total_cost * den # all cost routed -> feasible
|
|
107
|
+
|
|
108
|
+
reachable = flow.reachable_from(source)
|
|
109
|
+
projects.select { |c| reachable[project_node[c]] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def integer_cost(value)
|
|
113
|
+
Integer(value.to_s)
|
|
114
|
+
rescue ArgumentError, TypeError
|
|
115
|
+
raise ComputeError, "The maximin support rule requires integer costs (got #{value.inspect})"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|