decidim-stratified_sortitions 0.0.1
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-AGPLv3.txt +661 -0
- data/README.md +91 -0
- data/Rakefile +42 -0
- data/app/cells/decidim/stratified_sortitions/stratified_sortition_cell.rb +21 -0
- data/app/cells/decidim/stratified_sortitions/stratified_sortition_l_cell.rb +22 -0
- data/app/cells/decidim/stratified_sortitions/stratified_sortition_metadata_cell.rb +63 -0
- data/app/commands/decidim/stratified_sortitions/admin/create_stratified_sortition.rb +84 -0
- data/app/commands/decidim/stratified_sortitions/admin/destroy_stratified_sortition.rb +49 -0
- data/app/commands/decidim/stratified_sortitions/admin/duplicate_stratified_sortition.rb +74 -0
- data/app/commands/decidim/stratified_sortitions/admin/import_sample.rb +40 -0
- data/app/commands/decidim/stratified_sortitions/admin/remove_uploaded_samples.rb +53 -0
- data/app/commands/decidim/stratified_sortitions/admin/update_stratified_sortition.rb +174 -0
- data/app/controllers/concerns/decidim/stratified_sortitions/orderable_stratified_sortitions.rb +37 -0
- data/app/controllers/concerns/decidim/stratified_sortitions/strata_charts_data.rb +91 -0
- data/app/controllers/decidim/stratified_sortitions/admin/application_controller.rb +19 -0
- data/app/controllers/decidim/stratified_sortitions/admin/samples_controller.rb +143 -0
- data/app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb +235 -0
- data/app/controllers/decidim/stratified_sortitions/application_controller.rb +13 -0
- data/app/controllers/decidim/stratified_sortitions/charts_pdf_controller_helper.rb +17 -0
- data/app/controllers/decidim/stratified_sortitions/stratified_sortitions_controller.rb +61 -0
- data/app/forms/decidim/stratified_sortitions/admin/sample_upload_form.rb +15 -0
- data/app/forms/decidim/stratified_sortitions/admin/stratified_sortitions_form.rb +155 -0
- data/app/forms/decidim/stratified_sortitions/admin/stratum_form.rb +69 -0
- data/app/forms/decidim/stratified_sortitions/admin/substratum_form.rb +26 -0
- data/app/helpers/decidim/stratified_sortitions/admin/stratified_sortitions_helper.rb +18 -0
- data/app/helpers/decidim/stratified_sortitions/application_helper.rb +85 -0
- data/app/helpers/decidim/stratified_sortitions/charts_pdf_helper.rb +73 -0
- data/app/helpers/decidim/stratified_sortitions/stratified_sortition_cells_helper.rb +56 -0
- data/app/javascript/channels/sample_import_progress.js +12 -0
- data/app/jobs/decidim/stratified_sortitions/admin/execute_sortition_job.rb +21 -0
- data/app/jobs/decidim/stratified_sortitions/admin/import_sample_job.rb +170 -0
- data/app/jobs/decidim/stratified_sortitions/admin/remove_samples_job.rb +19 -0
- data/app/jobs/decidim/stratified_sortitions/admin/sortition_results_export_job.rb +28 -0
- data/app/mailers/decidim/stratified_sortitions/admin/import_mailer.rb +26 -0
- data/app/models/decidim/stratified_sortitions/application_record.rb +9 -0
- data/app/models/decidim/stratified_sortitions/panel_portfolio.rb +165 -0
- data/app/models/decidim/stratified_sortitions/sample_import.rb +14 -0
- data/app/models/decidim/stratified_sortitions/sample_participant.rb +12 -0
- data/app/models/decidim/stratified_sortitions/sample_participant_stratum.rb +11 -0
- data/app/models/decidim/stratified_sortitions/stratified_sortition.rb +74 -0
- data/app/models/decidim/stratified_sortitions/stratum.rb +22 -0
- data/app/models/decidim/stratified_sortitions/substratum.rb +19 -0
- data/app/packs/entrypoints/decidim_stratified_sortitions.js +10 -0
- data/app/packs/entrypoints/decidim_stratified_sortitions_admin.js +9 -0
- data/app/packs/entrypoints/decidim_stratified_sortitions_admin.scss +1 -0
- data/app/packs/images/decidim/stratified_sortitions/icon.svg +1 -0
- data/app/packs/src/decidim/stratified_sortitions/application.js +3 -0
- data/app/packs/src/decidim/stratified_sortitions/results_tabs.js +59 -0
- data/app/packs/src/decidim/stratified_sortitions/stratum_fields.js +302 -0
- data/app/packs/src/decidim/stratified_sortitions/substratum_fields.js +165 -0
- data/app/packs/src/decidim/stratified_sortitions/upload_sample.js +16 -0
- data/app/packs/stylesheets/decidim/stratified_sortitions/admin/stratified_sortitions.scss +210 -0
- data/app/packs/stylesheets/decidim/stratified_sortitions/charts_pdf.scss +148 -0
- data/app/packs/stylesheets/stratified_sortitions.scss +56 -0
- data/app/permissions/decidim/stratified_sortitions/admin/permissions.rb +74 -0
- data/app/permissions/decidim/stratified_sortitions/permissions.rb +23 -0
- data/app/presenters/decidim/stratified_sortitions/admin_log/stratified_sortition_presenter.rb +35 -0
- data/app/queries/decidim/stratified_sortitions/filtered_stratified_sortitions.rb +39 -0
- data/app/serializers/decidim/stratified_sortitions/sortition_result_serializer.rb +116 -0
- data/app/services/decidim/stratified_sortitions/charts_pdf_generator.rb +96 -0
- data/app/services/decidim/stratified_sortitions/fair_sortition_service.rb +247 -0
- data/app/services/decidim/stratified_sortitions/leximin/constraint_builder.rb +180 -0
- data/app/services/decidim/stratified_sortitions/leximin/distribution_solver.rb +217 -0
- data/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb +222 -0
- data/app/services/decidim/stratified_sortitions/leximin/panel_generator.rb +149 -0
- data/app/services/decidim/stratified_sortitions/leximin/panel_sampler.rb +125 -0
- data/app/services/decidim/stratified_sortitions/leximin_selector.rb +136 -0
- data/app/services/decidim/stratified_sortitions/sortition_results_exporter.rb +161 -0
- data/app/views/decidim/admin/components/index.html.erb +54 -0
- data/app/views/decidim/stratified_sortitions/admin/import_mailer/import.html.erb +22 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_execute_modal.html.erb +19 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_participants_modal.html.erb +20 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_form.html.erb +55 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_navigation_menu.html.erb +10 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_strata.html.erb +22 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_stratum.html.erb +75 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_substrata.html.erb +34 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_substratum.html.erb +54 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/edit.html.erb +21 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/execute.html.erb +155 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/export_charts_pdf.html.erb +38 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/index.html.erb +75 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/new.html.erb +21 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/upload_sample.html.erb +117 -0
- data/app/views/decidim/stratified_sortitions/shared/_strata_charts.html.erb +21 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/_results_count.html.erb +15 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/_stratified_sortition.html.erb +1 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/_stratified_sortitions.html.erb +13 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/_tags.html.erb +9 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/index.html.erb +22 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/index.js.erb +5 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/show.html.erb +142 -0
- data/app/views/layouts/decidim/admin/_sidebar_menu.html.erb +45 -0
- data/app/views/layouts/decidim/stratified_sortitions/charts_pdf.html.erb +14 -0
- data/config/assets.rb +24 -0
- data/config/locales/ca.yml +361 -0
- data/config/locales/en.yml +360 -0
- data/config/locales/es.yml +360 -0
- data/config/locales/oc.yml +2 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20251023103900_create_decidim_stratified_sortitions_stratified_sortitions.rb +18 -0
- data/db/migrate/20251104120000_create_stratified_sortitions_strata_and_substrata.decidim_stratified_sortitions.rb +27 -0
- data/db/migrate/20251219120000_create_sample_imports.rb +17 -0
- data/db/migrate/20251219120100_create_sample_participants.rb +19 -0
- data/db/migrate/20251219120200_create_sample_participant_strata.rb +16 -0
- data/db/migrate/20251230130000_change_value_to_text_in_substrata.rb +11 -0
- data/db/migrate/20260102114744_add_status_to_stratified_sortitions.rb +11 -0
- data/db/migrate/20260109105643_allow_null_value_in_substrata.rb +10 -0
- data/db/migrate/20260113121726_add_position_to_strata_and_substrata.rb +8 -0
- data/db/migrate/20260127100000_rename_weighing_to_max_quota_percentage.rb +12 -0
- data/db/migrate/20260127120000_create_panel_portfolios.rb +46 -0
- data/db/migrate/20260518000000_add_foreign_key_to_stratified_sortitions_component.rb +10 -0
- data/db/migrate/20260521000000_add_cascade_to_stratified_sortitions_foreign_keys.rb +64 -0
- data/db/migrate/20260616000000_add_execution_error_to_stratified_sortitions.rb +11 -0
- data/lib/decidim/stratified_sortitions/admin.rb +10 -0
- data/lib/decidim/stratified_sortitions/admin_engine.rb +49 -0
- data/lib/decidim/stratified_sortitions/component.rb +41 -0
- data/lib/decidim/stratified_sortitions/engine.rb +34 -0
- data/lib/decidim/stratified_sortitions/seeds.rb +24 -0
- data/lib/decidim/stratified_sortitions/test/factories.rb +80 -0
- data/lib/decidim/stratified_sortitions/version.rb +13 -0
- data/lib/decidim/stratified_sortitions.rb +29 -0
- metadata +222 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
module Leximin
|
|
6
|
+
# Builds constraint matrices and quota structures for the LEXIMIN algorithm.
|
|
7
|
+
#
|
|
8
|
+
# Extracts volunteer information, category memberships, and quota constraints
|
|
9
|
+
# from the database models into efficient data structures for optimization.
|
|
10
|
+
#
|
|
11
|
+
class ConstraintBuilder
|
|
12
|
+
attr_reader :stratified_sortition
|
|
13
|
+
|
|
14
|
+
def initialize(stratified_sortition)
|
|
15
|
+
@stratified_sortition = stratified_sortition
|
|
16
|
+
@cached_data = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Panel size (k) - number of candidates to select
|
|
20
|
+
#
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
def panel_size
|
|
23
|
+
@panel_size ||= stratified_sortition.num_candidates
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# All volunteers in the pool
|
|
27
|
+
#
|
|
28
|
+
# @return [Array<Integer>] volunteer IDs
|
|
29
|
+
def volunteer_ids
|
|
30
|
+
build_cache unless @cached_data
|
|
31
|
+
@cached_data[:volunteer_ids]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Number of volunteers
|
|
35
|
+
#
|
|
36
|
+
# @return [Integer]
|
|
37
|
+
def num_volunteers
|
|
38
|
+
volunteer_ids.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# All substratum (category) IDs
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Integer>]
|
|
44
|
+
def category_ids
|
|
45
|
+
build_cache unless @cached_data
|
|
46
|
+
@cached_data[:category_ids]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Quota constraints for each category
|
|
50
|
+
# min_quota is always 0, max_quota is calculated from percentage
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash{Integer => Hash{Symbol => Integer}}]
|
|
53
|
+
# { substratum_id => { min: 0, max: Integer } }
|
|
54
|
+
def quotas
|
|
55
|
+
build_cache unless @cached_data
|
|
56
|
+
@cached_data[:quotas]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Membership matrix: which volunteers belong to which categories
|
|
60
|
+
#
|
|
61
|
+
# @return [Hash{Integer => Set<Integer>}]
|
|
62
|
+
# { volunteer_id => Set of substratum_ids }
|
|
63
|
+
def volunteer_categories
|
|
64
|
+
build_cache unless @cached_data
|
|
65
|
+
@cached_data[:volunteer_categories]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Inverse membership: which volunteers belong to a category
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash{Integer => Set<Integer>}]
|
|
71
|
+
# { substratum_id => Set of volunteer_ids }
|
|
72
|
+
def category_volunteers
|
|
73
|
+
build_cache unless @cached_data
|
|
74
|
+
@cached_data[:category_volunteers]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get volunteer index for ILP variables
|
|
78
|
+
#
|
|
79
|
+
# @param volunteer_id [Integer]
|
|
80
|
+
# @return [Integer] index in the volunteer array
|
|
81
|
+
def volunteer_index(volunteer_id)
|
|
82
|
+
@volunteer_index_map ||= volunteer_ids.each_with_index.to_h
|
|
83
|
+
@volunteer_index_map[volunteer_id]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get category index for constraint rows
|
|
87
|
+
#
|
|
88
|
+
# @param category_id [Integer]
|
|
89
|
+
# @return [Integer] index in the category array
|
|
90
|
+
def category_index(category_id)
|
|
91
|
+
@category_index_map ||= category_ids.each_with_index.to_h
|
|
92
|
+
@category_index_map[category_id]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Stratum information for debugging and validation
|
|
96
|
+
#
|
|
97
|
+
# @return [Array<Hash>] Array of stratum info with substrata
|
|
98
|
+
def strata_info
|
|
99
|
+
build_cache unless @cached_data
|
|
100
|
+
@cached_data[:strata_info]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def build_cache
|
|
106
|
+
@cached_data = {
|
|
107
|
+
volunteer_ids: [],
|
|
108
|
+
category_ids: [],
|
|
109
|
+
quotas: {},
|
|
110
|
+
volunteer_categories: Hash.new { |h, k| h[k] = Set.new },
|
|
111
|
+
category_volunteers: Hash.new { |h, k| h[k] = Set.new },
|
|
112
|
+
strata_info: [],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
load_volunteers
|
|
116
|
+
load_strata_and_quotas
|
|
117
|
+
load_memberships
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def load_volunteers
|
|
121
|
+
@cached_data[:volunteer_ids] = stratified_sortition
|
|
122
|
+
.sample_participants
|
|
123
|
+
.pluck(:id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def load_strata_and_quotas
|
|
127
|
+
stratified_sortition.strata.includes(:substrata).find_each do |stratum|
|
|
128
|
+
stratum_info = {
|
|
129
|
+
id: stratum.id,
|
|
130
|
+
name: stratum.name,
|
|
131
|
+
kind: stratum.kind,
|
|
132
|
+
substrata: [],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
stratum.substrata.each do |substratum|
|
|
136
|
+
@cached_data[:category_ids] << substratum.id
|
|
137
|
+
|
|
138
|
+
# Calculate max quota from percentage
|
|
139
|
+
# max_quota_percentage is stored as a string like "25.5" meaning 25.5%
|
|
140
|
+
percentage = substratum.max_quota_percentage.to_f
|
|
141
|
+
max_quota = if percentage.positive?
|
|
142
|
+
(percentage / 100.0 * panel_size).ceil
|
|
143
|
+
else
|
|
144
|
+
panel_size # No restriction if percentage is 0 or not set
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
@cached_data[:quotas][substratum.id] = {
|
|
148
|
+
min: 0, # Always 0 as per requirements
|
|
149
|
+
max: max_quota,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
stratum_info[:substrata] << {
|
|
153
|
+
id: substratum.id,
|
|
154
|
+
name: substratum.name,
|
|
155
|
+
percentage:,
|
|
156
|
+
max_quota:,
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@cached_data[:strata_info] << stratum_info
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def load_memberships
|
|
165
|
+
# Load all participant-substratum relationships efficiently
|
|
166
|
+
SampleParticipantStratum
|
|
167
|
+
.where(
|
|
168
|
+
decidim_stratified_sortitions_sample_participant_id: @cached_data[:volunteer_ids]
|
|
169
|
+
)
|
|
170
|
+
.where.not(decidim_stratified_sortitions_substratum_id: nil)
|
|
171
|
+
.pluck(:decidim_stratified_sortitions_sample_participant_id, :decidim_stratified_sortitions_substratum_id)
|
|
172
|
+
.each do |volunteer_id, category_id|
|
|
173
|
+
@cached_data[:volunteer_categories][volunteer_id] << category_id
|
|
174
|
+
@cached_data[:category_volunteers][category_id] << volunteer_id
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
module Leximin
|
|
6
|
+
# Computes the LEXIMIN-optimal probability distribution over panels.
|
|
7
|
+
#
|
|
8
|
+
# The LEXIMIN criterion maximizes fairness by:
|
|
9
|
+
# 1. Maximizing the minimum selection probability
|
|
10
|
+
# 2. Then maximizing the second-minimum probability (with the first fixed)
|
|
11
|
+
# 3. And so on...
|
|
12
|
+
#
|
|
13
|
+
# Uses Linear Programming (LP) with the CBC solver.
|
|
14
|
+
#
|
|
15
|
+
class DistributionSolver
|
|
16
|
+
class CbcNotAvailableError < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Small epsilon for numerical stability
|
|
19
|
+
EPSILON = 1e-9
|
|
20
|
+
|
|
21
|
+
def initialize(constraint_builder)
|
|
22
|
+
@cb = constraint_builder
|
|
23
|
+
require_cbc!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def require_cbc!
|
|
29
|
+
require "ruby-cbc"
|
|
30
|
+
rescue LoadError
|
|
31
|
+
raise CbcNotAvailableError,
|
|
32
|
+
"The CBC solver is required for LEXIMIN selection. " \
|
|
33
|
+
"Install the gem with: bundle add ruby-cbc\n" \
|
|
34
|
+
"And system libraries: sudo apt install coinor-cbc coinor-libcbc-dev"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
public
|
|
38
|
+
|
|
39
|
+
# Compute the LEXIMIN-optimal distribution over panels
|
|
40
|
+
#
|
|
41
|
+
# @param panels [Array<Array<Integer>>] Array of panels (each panel is array of volunteer IDs)
|
|
42
|
+
# @return [Hash] { probabilities: Array<Float>, dual_prices: Hash<Integer, Float> }
|
|
43
|
+
def compute(panels)
|
|
44
|
+
return empty_result if panels.empty?
|
|
45
|
+
|
|
46
|
+
# Build panel-volunteer incidence matrix
|
|
47
|
+
# incidence[i][p] = 1 if volunteer i is in panel p
|
|
48
|
+
incidence = build_incidence_matrix(panels)
|
|
49
|
+
|
|
50
|
+
# Compute LEXIMIN distribution using iterative LP
|
|
51
|
+
probabilities, _selection_probs = solve_leximin_lp(panels, incidence)
|
|
52
|
+
|
|
53
|
+
# Compute dual prices for column generation
|
|
54
|
+
dual_prices = compute_dual_prices(panels, probabilities, incidence)
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
probabilities:,
|
|
58
|
+
dual_prices:,
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def empty_result
|
|
65
|
+
{ probabilities: [], dual_prices: {} }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_incidence_matrix(panels)
|
|
69
|
+
# incidence[volunteer_id] = array of panel indices containing this volunteer
|
|
70
|
+
incidence = Hash.new { |h, k| h[k] = [] }
|
|
71
|
+
|
|
72
|
+
panels.each_with_index do |panel, panel_idx|
|
|
73
|
+
panel.each do |volunteer_id|
|
|
74
|
+
incidence[volunteer_id] << panel_idx
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
incidence
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Solve LEXIMIN LP iteratively
|
|
82
|
+
#
|
|
83
|
+
# We solve a sequence of LPs:
|
|
84
|
+
# 1. Maximize π_min = min_i π_i
|
|
85
|
+
# 2. Fix π_i ≥ π_min for all i, maximize second-minimum
|
|
86
|
+
# 3. Continue until all probabilities are fixed
|
|
87
|
+
#
|
|
88
|
+
# For efficiency, we use a single LP with a max-min formulation.
|
|
89
|
+
#
|
|
90
|
+
def solve_leximin_lp(panels, incidence) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
91
|
+
num_panels = panels.size
|
|
92
|
+
return [Array.new(num_panels, 1.0 / num_panels), uniform_selection_probs(panels)] if num_panels == 1
|
|
93
|
+
|
|
94
|
+
# Use a max-min LP formulation:
|
|
95
|
+
# maximize z
|
|
96
|
+
# subject to:
|
|
97
|
+
# π_i ≥ z for all volunteers i
|
|
98
|
+
# ∑_p λ_p = 1
|
|
99
|
+
# λ_p ≥ 0
|
|
100
|
+
# π_i = ∑_{p: i ∈ p} λ_p
|
|
101
|
+
|
|
102
|
+
model = Cbc::Model.new
|
|
103
|
+
|
|
104
|
+
# Variables: λ_p for each panel (probability of selecting panel p)
|
|
105
|
+
lambda_vars = (0...num_panels).map do |p|
|
|
106
|
+
model.cont_var(name: "lambda_#{p}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Variable: z (minimum probability to maximize)
|
|
110
|
+
z = model.cont_var(name: "z")
|
|
111
|
+
|
|
112
|
+
# Constraint: probabilities sum to 1
|
|
113
|
+
model.enforce(sum_vars(lambda_vars) == 1)
|
|
114
|
+
|
|
115
|
+
# Constraint: π_i ≥ z for each volunteer
|
|
116
|
+
@cb.volunteer_ids.each do |vid|
|
|
117
|
+
panel_indices = incidence[vid]
|
|
118
|
+
next if panel_indices.empty?
|
|
119
|
+
|
|
120
|
+
# π_i = ∑_{p: i ∈ p} λ_p
|
|
121
|
+
pi_i = sum_vars(panel_indices.map { |p| lambda_vars[p] })
|
|
122
|
+
|
|
123
|
+
# π_i ≥ z
|
|
124
|
+
model.enforce(pi_i >= z)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Objective: maximize z (the minimum probability)
|
|
128
|
+
model.maximize(z)
|
|
129
|
+
|
|
130
|
+
# Solve
|
|
131
|
+
problem = model.to_problem
|
|
132
|
+
problem.solve
|
|
133
|
+
|
|
134
|
+
unless problem.proven_optimal?
|
|
135
|
+
# Fallback to uniform distribution
|
|
136
|
+
uniform_prob = 1.0 / num_panels
|
|
137
|
+
return [Array.new(num_panels, uniform_prob), uniform_selection_probs(panels)]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Extract probabilities
|
|
141
|
+
probabilities = lambda_vars.map { |v| [problem.value_of(v), 0.0].max }
|
|
142
|
+
|
|
143
|
+
# Normalize to ensure they sum to 1
|
|
144
|
+
total = probabilities.sum
|
|
145
|
+
probabilities = probabilities.map { |p| p / total } if total > EPSILON
|
|
146
|
+
|
|
147
|
+
# Compute selection probabilities
|
|
148
|
+
selection_probs = {}
|
|
149
|
+
@cb.volunteer_ids.each do |vid|
|
|
150
|
+
panel_indices = incidence[vid]
|
|
151
|
+
selection_probs[vid] = panel_indices.sum { |p| probabilities[p] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
[probabilities, selection_probs]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def uniform_selection_probs(panels)
|
|
158
|
+
probs = Hash.new(0.0)
|
|
159
|
+
return probs if panels.empty?
|
|
160
|
+
|
|
161
|
+
prob_per_panel = 1.0 / panels.size
|
|
162
|
+
panels.each do |panel|
|
|
163
|
+
panel.each { |vid| probs[vid] += prob_per_panel }
|
|
164
|
+
end
|
|
165
|
+
probs
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Compute dual prices for the column generation subproblem
|
|
169
|
+
#
|
|
170
|
+
# The dual prices indicate how much each volunteer's inclusion
|
|
171
|
+
# in a new panel would improve the objective.
|
|
172
|
+
#
|
|
173
|
+
# For LEXIMIN, we use the marginal value of increasing each volunteer's
|
|
174
|
+
# selection probability.
|
|
175
|
+
#
|
|
176
|
+
def compute_dual_prices(_panels, probabilities, incidence)
|
|
177
|
+
dual_prices = {}
|
|
178
|
+
|
|
179
|
+
# Compute current selection probabilities
|
|
180
|
+
current_probs = {}
|
|
181
|
+
@cb.volunteer_ids.each do |vid|
|
|
182
|
+
panel_indices = incidence[vid]
|
|
183
|
+
current_probs[vid] = panel_indices.sum { |p| probabilities[p] }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Find minimum probability
|
|
187
|
+
min_prob = current_probs.values.min || 0.0
|
|
188
|
+
|
|
189
|
+
# Dual price is higher for volunteers with lower selection probability
|
|
190
|
+
# This encourages new panels to include under-represented volunteers
|
|
191
|
+
@cb.volunteer_ids.each do |vid|
|
|
192
|
+
current = current_probs[vid] || 0.0
|
|
193
|
+
|
|
194
|
+
# Inverse relationship: lower probability = higher dual price
|
|
195
|
+
dual_prices[vid] = if current <= min_prob + EPSILON
|
|
196
|
+
# Volunteers at minimum get highest price
|
|
197
|
+
1.0
|
|
198
|
+
elsif current < 1.0
|
|
199
|
+
# Others get price inversely proportional to their probability
|
|
200
|
+
(1.0 - current) / (1.0 - min_prob + EPSILON)
|
|
201
|
+
else
|
|
202
|
+
0.0
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
dual_prices
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def sum_vars(vars)
|
|
210
|
+
return 0 if vars.empty?
|
|
211
|
+
|
|
212
|
+
vars.reduce(:+)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
module Leximin
|
|
6
|
+
# Validates that the LEXIMIN algorithm can run with the given data.
|
|
7
|
+
#
|
|
8
|
+
# Checks:
|
|
9
|
+
# 1. Pool has enough volunteers (n >= k)
|
|
10
|
+
# 2. Quota constraints are consistent (sum of mins <= k <= sum of maxs)
|
|
11
|
+
# 3. Each category has enough volunteers to potentially meet quota
|
|
12
|
+
# 4. Panel size is positive
|
|
13
|
+
#
|
|
14
|
+
class FeasibilityChecker
|
|
15
|
+
def initialize(constraint_builder)
|
|
16
|
+
@cb = constraint_builder
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Check if the problem is feasible
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash] { feasible: Boolean, errors: Array<String> }
|
|
22
|
+
def check
|
|
23
|
+
errors = []
|
|
24
|
+
|
|
25
|
+
errors.concat(check_basic_requirements)
|
|
26
|
+
errors.concat(check_pool_size)
|
|
27
|
+
errors.concat(check_quota_consistency)
|
|
28
|
+
errors.concat(check_category_coverage)
|
|
29
|
+
errors.concat(check_cross_strata_feasibility) if errors.empty?
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
feasible: errors.empty?,
|
|
33
|
+
errors:,
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def check_basic_requirements
|
|
40
|
+
errors = []
|
|
41
|
+
|
|
42
|
+
errors << I18n.t("decidim.stratified_sortitions.errors.feasibility.panel_size_positive") if @cb.panel_size.nil? || @cb.panel_size <= 0
|
|
43
|
+
|
|
44
|
+
errors << I18n.t("decidim.stratified_sortitions.errors.feasibility.no_categories") if @cb.category_ids.empty?
|
|
45
|
+
|
|
46
|
+
errors
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_pool_size
|
|
50
|
+
errors = []
|
|
51
|
+
n = @cb.num_volunteers
|
|
52
|
+
k = @cb.panel_size
|
|
53
|
+
|
|
54
|
+
if n.zero?
|
|
55
|
+
errors << I18n.t("decidim.stratified_sortitions.errors.feasibility.no_volunteers")
|
|
56
|
+
elsif n < k
|
|
57
|
+
errors << I18n.t("decidim.stratified_sortitions.errors.feasibility.pool_too_small",
|
|
58
|
+
volunteers_count: n,
|
|
59
|
+
panel_size: k)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
errors
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check_quota_consistency
|
|
66
|
+
errors = []
|
|
67
|
+
k = @cb.panel_size
|
|
68
|
+
return errors if k.nil? || k <= 0
|
|
69
|
+
|
|
70
|
+
# Group substrata by stratum to check per-stratum constraints
|
|
71
|
+
# For each stratum, the sum of max quotas should be >= k
|
|
72
|
+
# (at least one substratum per stratum must be selected for each member)
|
|
73
|
+
|
|
74
|
+
@cb.strata_info.each do |stratum|
|
|
75
|
+
stratum_name = extract_name(stratum[:name])
|
|
76
|
+
substrata = stratum[:substrata]
|
|
77
|
+
|
|
78
|
+
next if substrata.empty?
|
|
79
|
+
|
|
80
|
+
# Sum of max quotas for this stratum
|
|
81
|
+
total_max = substrata.sum { |s| s[:max_quota] }
|
|
82
|
+
|
|
83
|
+
if total_max < k
|
|
84
|
+
errors << I18n.t("decidim.stratified_sortitions.errors.feasibility.stratum_insufficient_quotas",
|
|
85
|
+
stratum_name:,
|
|
86
|
+
total_max:,
|
|
87
|
+
panel_size: k)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check that percentages within a stratum don't exceed 100%
|
|
91
|
+
total_percentage = substrata.sum { |s| s[:percentage] }
|
|
92
|
+
next unless total_percentage > 100 + 0.01 # Small tolerance for floating point
|
|
93
|
+
|
|
94
|
+
errors << I18n.t("decidim.stratified_sortitions.errors.feasibility.stratum_percentages_exceed",
|
|
95
|
+
stratum_name:,
|
|
96
|
+
total_percentage: total_percentage.round(1))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
errors
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check_category_coverage
|
|
103
|
+
errors = []
|
|
104
|
+
|
|
105
|
+
@cb.strata_info.each do |stratum|
|
|
106
|
+
stratum_name = extract_name(stratum[:name])
|
|
107
|
+
|
|
108
|
+
stratum[:substrata].each do |substratum|
|
|
109
|
+
cat_id = substratum[:id]
|
|
110
|
+
substratum_name = extract_name(substratum[:name])
|
|
111
|
+
min_quota = @cb.quotas[cat_id][:min]
|
|
112
|
+
|
|
113
|
+
next if min_quota.zero? # No minimum requirement
|
|
114
|
+
|
|
115
|
+
volunteers_count = @cb.category_volunteers[cat_id]&.size || 0
|
|
116
|
+
|
|
117
|
+
next unless volunteers_count < min_quota
|
|
118
|
+
|
|
119
|
+
errors << I18n.t("decidim.stratified_sortitions.errors.feasibility.substratum_insufficient_volunteers",
|
|
120
|
+
substratum_name:,
|
|
121
|
+
stratum_name:,
|
|
122
|
+
min_quota:,
|
|
123
|
+
volunteers_count:)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Also check that every volunteer belongs to at least one substratum per stratum
|
|
128
|
+
volunteers_without_strata = find_volunteers_without_complete_strata
|
|
129
|
+
if volunteers_without_strata.any?
|
|
130
|
+
count = volunteers_without_strata.size
|
|
131
|
+
errors << I18n.t("decidim.stratified_sortitions.errors.feasibility.volunteers_missing_strata",
|
|
132
|
+
count:)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
errors
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def find_volunteers_without_complete_strata
|
|
139
|
+
incomplete = []
|
|
140
|
+
num_strata = @cb.strata_info.size
|
|
141
|
+
|
|
142
|
+
return incomplete if num_strata.zero?
|
|
143
|
+
|
|
144
|
+
@cb.volunteer_ids.each do |vid|
|
|
145
|
+
categories = @cb.volunteer_categories[vid]
|
|
146
|
+
# Each volunteer should have exactly one substratum per stratum
|
|
147
|
+
# We can't easily verify this without more complex logic, so we just
|
|
148
|
+
# check they have at least some categories
|
|
149
|
+
incomplete << vid if categories.blank?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
incomplete
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Checks that the combination of max_quota_percentage constraints across all strata
|
|
156
|
+
# can be satisfied simultaneously. For each pair of substrata from different strata,
|
|
157
|
+
# there must be enough volunteers that belong to both categories.
|
|
158
|
+
def check_cross_strata_feasibility
|
|
159
|
+
errors = []
|
|
160
|
+
k = @cb.panel_size
|
|
161
|
+
return errors if k.nil? || k <= 0
|
|
162
|
+
|
|
163
|
+
strata = @cb.strata_info
|
|
164
|
+
return errors if strata.size < 2
|
|
165
|
+
|
|
166
|
+
strata.each do |stratum|
|
|
167
|
+
errors.concat(validate_stratum_quotas(stratum))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
errors
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_stratum_quotas(stratum)
|
|
174
|
+
errors = []
|
|
175
|
+
stratum_name = extract_name(stratum[:name])
|
|
176
|
+
|
|
177
|
+
stratum[:substrata].each do |substratum|
|
|
178
|
+
error = validate_substratum_quota(substratum, stratum_name)
|
|
179
|
+
errors << error if error.present?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
errors
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def validate_substratum_quota(substratum, stratum_name)
|
|
186
|
+
cat_id = substratum[:id]
|
|
187
|
+
max_quota = substratum[:max_quota]
|
|
188
|
+
percentage = substratum[:percentage]
|
|
189
|
+
volunteers_in_cat = @cb.category_volunteers[cat_id]&.size || 0
|
|
190
|
+
|
|
191
|
+
# Skip if no restriction (percentage 0 means unrestricted), no quota, or no volunteers
|
|
192
|
+
return nil if percentage.zero? || max_quota.zero? || volunteers_in_cat.zero?
|
|
193
|
+
|
|
194
|
+
# Check if this substratum's max quota exceeds available volunteers
|
|
195
|
+
return nil unless max_quota > volunteers_in_cat
|
|
196
|
+
|
|
197
|
+
substratum_name = extract_name(substratum[:name])
|
|
198
|
+
|
|
199
|
+
I18n.t(
|
|
200
|
+
"decidim.stratified_sortitions.errors.feasibility.substratum_quota_exceeds_volunteers",
|
|
201
|
+
substratum_name:,
|
|
202
|
+
stratum_name:,
|
|
203
|
+
max_quota:,
|
|
204
|
+
volunteers_count: volunteers_in_cat,
|
|
205
|
+
percentage:
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def extract_name(name_field)
|
|
210
|
+
case name_field
|
|
211
|
+
when Hash
|
|
212
|
+
name_field[I18n.locale.to_s] || name_field["en"] || name_field.values.first || I18n.t("decidim.stratified_sortitions.errors.feasibility.no_name")
|
|
213
|
+
when String
|
|
214
|
+
name_field
|
|
215
|
+
else
|
|
216
|
+
I18n.t("decidim.stratified_sortitions.errors.feasibility.no_name")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|