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,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