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,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
module Leximin
|
|
6
|
+
# Generates feasible panels using Integer Linear Programming (ILP).
|
|
7
|
+
#
|
|
8
|
+
# Uses the COIN-OR CBC solver to find panels that satisfy:
|
|
9
|
+
# - Exactly k volunteers selected (panel size)
|
|
10
|
+
# - Quota constraints for each category (min/max)
|
|
11
|
+
#
|
|
12
|
+
# For column generation, it can also find panels that improve the
|
|
13
|
+
# current LEXIMIN objective using dual prices.
|
|
14
|
+
#
|
|
15
|
+
class PanelGenerator
|
|
16
|
+
class CbcNotAvailableError < StandardError; end
|
|
17
|
+
|
|
18
|
+
def initialize(constraint_builder)
|
|
19
|
+
@cb = constraint_builder
|
|
20
|
+
require_cbc!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def require_cbc!
|
|
26
|
+
require "ruby-cbc"
|
|
27
|
+
rescue LoadError
|
|
28
|
+
raise CbcNotAvailableError,
|
|
29
|
+
"The CBC solver is required for LEXIMIN selection. " \
|
|
30
|
+
"Install the gem with: bundle add ruby-cbc\n" \
|
|
31
|
+
"And system libraries: sudo apt install coinor-cbc coinor-libcbc-dev"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
public
|
|
35
|
+
|
|
36
|
+
# Find any feasible panel satisfying all constraints
|
|
37
|
+
#
|
|
38
|
+
# @return [Array<Integer>, nil] Array of volunteer IDs or nil if infeasible
|
|
39
|
+
def find_feasible_panel
|
|
40
|
+
solve_panel_ilp(nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Find a panel that improves the LEXIMIN objective
|
|
44
|
+
#
|
|
45
|
+
# Uses dual prices from the master problem to find a panel with
|
|
46
|
+
# positive reduced cost (improving the objective).
|
|
47
|
+
#
|
|
48
|
+
# @param dual_prices [Hash{Integer => Float}] dual prices for each volunteer
|
|
49
|
+
# @return [Array<Integer>, nil] Array of volunteer IDs or nil if no improving panel
|
|
50
|
+
def find_improving_panel(dual_prices)
|
|
51
|
+
return find_feasible_panel if dual_prices.blank?
|
|
52
|
+
|
|
53
|
+
solve_panel_ilp(dual_prices)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Solve the panel selection ILP
|
|
59
|
+
#
|
|
60
|
+
# Variables: x_i ∈ {0,1} for each volunteer i
|
|
61
|
+
#
|
|
62
|
+
# Constraints:
|
|
63
|
+
# ∑_i x_i = k (panel size)
|
|
64
|
+
# ℓ_j ≤ ∑_{i ∈ C_j} x_i ≤ u_j for each category j
|
|
65
|
+
#
|
|
66
|
+
# Objective:
|
|
67
|
+
# - If dual_prices nil: any feasible solution (maximize constant)
|
|
68
|
+
# - If dual_prices given: maximize ∑_i π_i * x_i (reduced cost)
|
|
69
|
+
#
|
|
70
|
+
# @param dual_prices [Hash, nil]
|
|
71
|
+
# @return [Array<Integer>, nil]
|
|
72
|
+
def solve_panel_ilp(dual_prices) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
73
|
+
return nil if @cb.num_volunteers.zero?
|
|
74
|
+
|
|
75
|
+
model = Cbc::Model.new
|
|
76
|
+
|
|
77
|
+
# Create binary variables for each volunteer
|
|
78
|
+
x = @cb.volunteer_ids.map do |vid|
|
|
79
|
+
model.bin_var(name: "x_#{vid}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Constraint: exactly k volunteers selected
|
|
83
|
+
model.enforce(sum_vars(x) == @cb.panel_size)
|
|
84
|
+
|
|
85
|
+
# Quota constraints for each category
|
|
86
|
+
@cb.category_ids.each do |cat_id|
|
|
87
|
+
quota = @cb.quotas[cat_id]
|
|
88
|
+
volunteers_in_cat = @cb.category_volunteers[cat_id]
|
|
89
|
+
|
|
90
|
+
next if volunteers_in_cat.empty?
|
|
91
|
+
|
|
92
|
+
# Sum of x_i for volunteers in this category
|
|
93
|
+
cat_vars = volunteers_in_cat.map do |vid|
|
|
94
|
+
idx = @cb.volunteer_index(vid)
|
|
95
|
+
x[idx]
|
|
96
|
+
end.compact
|
|
97
|
+
|
|
98
|
+
next if cat_vars.empty?
|
|
99
|
+
|
|
100
|
+
cat_sum = sum_vars(cat_vars)
|
|
101
|
+
|
|
102
|
+
# min <= sum <= max
|
|
103
|
+
model.enforce(cat_sum >= quota[:min]) if (quota[:min]).positive?
|
|
104
|
+
model.enforce(cat_sum <= quota[:max]) if quota[:max] < @cb.panel_size
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Objective function
|
|
108
|
+
if dual_prices.blank?
|
|
109
|
+
# Just find any feasible solution - maximize sum (constant effect)
|
|
110
|
+
model.maximize(sum_vars(x))
|
|
111
|
+
else
|
|
112
|
+
# Maximize reduced cost: ∑_i π_i * x_i
|
|
113
|
+
objective_terms = @cb.volunteer_ids.map.with_index do |vid, idx|
|
|
114
|
+
price = dual_prices[vid] || 0.0
|
|
115
|
+
x[idx] * price
|
|
116
|
+
end
|
|
117
|
+
model.maximize(sum_vars(objective_terms))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Solve
|
|
121
|
+
problem = model.to_problem
|
|
122
|
+
problem.solve
|
|
123
|
+
|
|
124
|
+
return nil unless problem.proven_optimal?
|
|
125
|
+
|
|
126
|
+
# Check if reduced cost is positive (improving)
|
|
127
|
+
if dual_prices.present?
|
|
128
|
+
obj_value = problem.objective_value
|
|
129
|
+
return nil if obj_value <= LeximinSelector::CONVERGENCE_THRESHOLD
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Extract selected volunteer IDs
|
|
133
|
+
selected = []
|
|
134
|
+
@cb.volunteer_ids.each_with_index do |vid, idx|
|
|
135
|
+
selected << vid if problem.value_of(x[idx]) > 0.5
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
selected.empty? ? nil : selected
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def sum_vars(vars)
|
|
142
|
+
return 0 if vars.empty?
|
|
143
|
+
|
|
144
|
+
vars.reduce(:+)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
module Leximin
|
|
6
|
+
# Samples a single panel from the LEXIMIN distribution.
|
|
7
|
+
#
|
|
8
|
+
# Given a set of panels and their probabilities (from LeximinSelector),
|
|
9
|
+
# this service selects one panel according to the probability distribution.
|
|
10
|
+
#
|
|
11
|
+
# The sampling is cryptographically secure using SecureRandom.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# leximin_result = LeximinSelector.new(stratified_sortition).call
|
|
15
|
+
# sampler = PanelSampler.new(leximin_result.panels, leximin_result.probabilities)
|
|
16
|
+
# selected_panel = sampler.sample
|
|
17
|
+
# # => [participant_id_1, participant_id_2, ...]
|
|
18
|
+
#
|
|
19
|
+
class PanelSampler
|
|
20
|
+
# Result object for sampling operation
|
|
21
|
+
Result = Struct.new(:selected_panel, :selected_index, :random_value, :success, :error, keyword_init: true) do
|
|
22
|
+
def success?
|
|
23
|
+
success
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(panels, probabilities, random_seed: nil)
|
|
28
|
+
@panels = panels
|
|
29
|
+
@probabilities = normalize_probabilities(probabilities)
|
|
30
|
+
@random_seed = random_seed
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Sample a panel according to the probability distribution
|
|
34
|
+
#
|
|
35
|
+
# @return [Result] containing the selected panel and metadata
|
|
36
|
+
def sample
|
|
37
|
+
return error_result("No hi ha panels disponibles per mostrejar") if @panels.empty?
|
|
38
|
+
return error_result("Les probabilitats no coincideixen amb el nombre de panels") if @panels.size != @probabilities.size
|
|
39
|
+
|
|
40
|
+
# Generate random value in [0, 1)
|
|
41
|
+
random_value = generate_random_value
|
|
42
|
+
|
|
43
|
+
# Select panel using cumulative distribution
|
|
44
|
+
selected_index = select_panel_index(random_value)
|
|
45
|
+
|
|
46
|
+
Result.new(
|
|
47
|
+
selected_panel: @panels[selected_index],
|
|
48
|
+
selected_index:,
|
|
49
|
+
random_value:,
|
|
50
|
+
success: true,
|
|
51
|
+
error: nil
|
|
52
|
+
)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
error_result("Error en el mostreig: #{e.message}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sample multiple panels (with replacement)
|
|
58
|
+
#
|
|
59
|
+
# @param count [Integer] number of panels to sample
|
|
60
|
+
# @return [Array<Result>] array of sampling results
|
|
61
|
+
def sample_multiple(count)
|
|
62
|
+
Array.new(count) { sample }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get the cumulative distribution function
|
|
66
|
+
#
|
|
67
|
+
# @return [Array<Float>] cumulative probabilities
|
|
68
|
+
def cumulative_distribution
|
|
69
|
+
cumsum = 0.0
|
|
70
|
+
@probabilities.map { |p| cumsum += p }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def normalize_probabilities(probs)
|
|
76
|
+
return [] if probs.blank?
|
|
77
|
+
|
|
78
|
+
total = probs.sum.to_f
|
|
79
|
+
return probs if (total - 1.0).abs < 1e-9
|
|
80
|
+
|
|
81
|
+
# Normalize to sum to 1
|
|
82
|
+
if total.positive?
|
|
83
|
+
probs.map { |p| p / total }
|
|
84
|
+
else
|
|
85
|
+
# Uniform distribution if all zeros
|
|
86
|
+
uniform = 1.0 / probs.size
|
|
87
|
+
Array.new(probs.size, uniform)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def generate_random_value
|
|
92
|
+
if @random_seed
|
|
93
|
+
# Deterministic for testing
|
|
94
|
+
Random.new(@random_seed).rand
|
|
95
|
+
else
|
|
96
|
+
# Cryptographically secure random
|
|
97
|
+
SecureRandom.random_number
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def select_panel_index(random_value)
|
|
102
|
+
cumsum = 0.0
|
|
103
|
+
|
|
104
|
+
@probabilities.each_with_index do |prob, idx|
|
|
105
|
+
cumsum += prob
|
|
106
|
+
return idx if random_value < cumsum
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Edge case: return last panel (handles floating point errors)
|
|
110
|
+
@panels.size - 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def error_result(message)
|
|
114
|
+
Result.new(
|
|
115
|
+
selected_panel: [],
|
|
116
|
+
selected_index: nil,
|
|
117
|
+
random_value: nil,
|
|
118
|
+
success: false,
|
|
119
|
+
error: message
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
# LEXIMIN Selector Service
|
|
6
|
+
#
|
|
7
|
+
# Implements the LEXIMIN fair selection algorithm using column generation.
|
|
8
|
+
# Based on "Fair algorithms for selecting citizens' assemblies" paper.
|
|
9
|
+
#
|
|
10
|
+
# The algorithm finds an optimal portfolio of panels (feasible selections)
|
|
11
|
+
# and computes a probability distribution over them that maximizes fairness
|
|
12
|
+
# in selection probabilities using the leximin criterion.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# result = LeximinSelector.new(stratified_sortition).call
|
|
16
|
+
# if result.success?
|
|
17
|
+
# result.panels # Array of panels (each panel is array of participant IDs)
|
|
18
|
+
# result.probabilities # Probability for each panel
|
|
19
|
+
# else
|
|
20
|
+
# result.error # Error message
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
class LeximinSelector
|
|
24
|
+
# Maximum number of column generation iterations
|
|
25
|
+
MAX_ITERATIONS = 1000
|
|
26
|
+
|
|
27
|
+
# Convergence threshold for reduced cost
|
|
28
|
+
CONVERGENCE_THRESHOLD = 1e-6
|
|
29
|
+
|
|
30
|
+
# Result object returned by the algorithm
|
|
31
|
+
Result = Struct.new(:panels, :probabilities, :selection_probabilities, :iterations, :converged, :success, :error, keyword_init: true) do
|
|
32
|
+
def success?
|
|
33
|
+
success
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(stratified_sortition)
|
|
38
|
+
@stratified_sortition = stratified_sortition
|
|
39
|
+
@constraint_builder = Leximin::ConstraintBuilder.new(stratified_sortition)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Execute the LEXIMIN algorithm
|
|
43
|
+
#
|
|
44
|
+
# @return [Result] containing panels, probabilities, and success status
|
|
45
|
+
def call
|
|
46
|
+
# Step 1: Check feasibility
|
|
47
|
+
feasibility = Leximin::FeasibilityChecker.new(@constraint_builder).check
|
|
48
|
+
unless feasibility[:feasible]
|
|
49
|
+
return Result.new(
|
|
50
|
+
panels: [],
|
|
51
|
+
probabilities: [],
|
|
52
|
+
selection_probabilities: {},
|
|
53
|
+
success: false,
|
|
54
|
+
error: feasibility[:errors].join("; ")
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Step 2: Initialize with one feasible panel
|
|
59
|
+
panel_generator = Leximin::PanelGenerator.new(@constraint_builder)
|
|
60
|
+
initial_panel = panel_generator.find_feasible_panel
|
|
61
|
+
return infeasible_result(I18n.t("decidim.stratified_sortitions.errors.leximin.no_feasible_panel")) if initial_panel.nil?
|
|
62
|
+
|
|
63
|
+
panels = [initial_panel]
|
|
64
|
+
|
|
65
|
+
# Step 3: Column generation loop
|
|
66
|
+
distribution_solver = Leximin::DistributionSolver.new(@constraint_builder)
|
|
67
|
+
|
|
68
|
+
MAX_ITERATIONS.times do
|
|
69
|
+
# Compute optimal distribution over current panels
|
|
70
|
+
distribution = distribution_solver.compute(panels)
|
|
71
|
+
|
|
72
|
+
# Try to find a new panel that improves the objective
|
|
73
|
+
new_panel = panel_generator.find_improving_panel(distribution[:dual_prices])
|
|
74
|
+
|
|
75
|
+
# Check convergence: no improving panel found
|
|
76
|
+
break if new_panel.nil? || panels_include?(panels, new_panel)
|
|
77
|
+
|
|
78
|
+
panels << new_panel
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Step 4: Compute final distribution
|
|
82
|
+
final_distribution = distribution_solver.compute(panels)
|
|
83
|
+
|
|
84
|
+
# Step 5: Compute selection probabilities for each volunteer
|
|
85
|
+
selection_probs = compute_selection_probabilities(panels, final_distribution[:probabilities])
|
|
86
|
+
|
|
87
|
+
Result.new(
|
|
88
|
+
panels:,
|
|
89
|
+
probabilities: final_distribution[:probabilities],
|
|
90
|
+
selection_probabilities: selection_probs,
|
|
91
|
+
success: true,
|
|
92
|
+
error: nil
|
|
93
|
+
)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
Rails.logger.debug { "LEXIMIN Selector error: #{e.message}\n#{e.backtrace.join("\n")}" }
|
|
96
|
+
Result.new(
|
|
97
|
+
panels: [],
|
|
98
|
+
probabilities: [],
|
|
99
|
+
selection_probabilities: {},
|
|
100
|
+
success: false,
|
|
101
|
+
error: I18n.t("decidim.stratified_sortitions.errors.leximin.internal_error", error: e.message)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def infeasible_result(message)
|
|
108
|
+
Result.new(
|
|
109
|
+
panels: [],
|
|
110
|
+
probabilities: [],
|
|
111
|
+
selection_probabilities: {},
|
|
112
|
+
success: false,
|
|
113
|
+
error: message
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def panels_include?(panels, new_panel)
|
|
118
|
+
new_panel_set = Set.new(new_panel)
|
|
119
|
+
panels.any? { |p| Set.new(p) == new_panel_set }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def compute_selection_probabilities(panels, probabilities)
|
|
123
|
+
selection_probs = Hash.new(0.0)
|
|
124
|
+
|
|
125
|
+
panels.each_with_index do |panel, idx|
|
|
126
|
+
prob = probabilities[idx] || 0.0
|
|
127
|
+
panel.each do |volunteer_id|
|
|
128
|
+
selection_probs[volunteer_id] += prob
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
selection_probs
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "rubyXL"
|
|
5
|
+
require "rubyXL/convenience_methods/cell"
|
|
6
|
+
require "rubyXL/convenience_methods/font"
|
|
7
|
+
require "rubyXL/convenience_methods/workbook"
|
|
8
|
+
require "rubyXL/convenience_methods/worksheet"
|
|
9
|
+
|
|
10
|
+
module Decidim
|
|
11
|
+
module StratifiedSortitions
|
|
12
|
+
# Custom exporter for sortition results that produces a layout with:
|
|
13
|
+
# Row 1: Metadata headers
|
|
14
|
+
# Row 2: Metadata values
|
|
15
|
+
# Row 3: Blank
|
|
16
|
+
# Row 4: Participant headers
|
|
17
|
+
# Row 5+: Participant data
|
|
18
|
+
class SortitionResultsExporter
|
|
19
|
+
def initialize(stratified_sortition)
|
|
20
|
+
@stratified_sortition = stratified_sortition
|
|
21
|
+
@portfolio = stratified_sortition.panel_portfolio
|
|
22
|
+
@audit_log = @portfolio.audit_log
|
|
23
|
+
@strata = stratified_sortition.strata.order(:position)
|
|
24
|
+
@participants = SampleParticipant
|
|
25
|
+
.where(id: @portfolio.selected_panel)
|
|
26
|
+
.includes(sample_participant_strata: [:decidim_stratified_sortitions_stratum, :decidim_stratified_sortitions_substratum])
|
|
27
|
+
.order(:id)
|
|
28
|
+
@total_participants = stratified_sortition.sample_participants.count
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def export_csv(col_sep = Decidim.default_csv_col_sep)
|
|
32
|
+
data = ::CSV.generate(col_sep:) do |csv|
|
|
33
|
+
csv << metadata_headers
|
|
34
|
+
csv << metadata_values
|
|
35
|
+
csv << [] # blank row
|
|
36
|
+
csv << participant_headers
|
|
37
|
+
@participants.each { |p| csv << participant_row(p) }
|
|
38
|
+
end
|
|
39
|
+
Decidim::Exporters::ExportData.new(data, "csv")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def export_excel
|
|
43
|
+
workbook = RubyXL::Workbook.new
|
|
44
|
+
worksheet = workbook[0]
|
|
45
|
+
|
|
46
|
+
# Row 0: metadata headers
|
|
47
|
+
metadata_headers.each_with_index do |header, col|
|
|
48
|
+
worksheet.add_cell(0, col, header)
|
|
49
|
+
worksheet.change_column_width(col, 20)
|
|
50
|
+
end
|
|
51
|
+
style_header_row(worksheet, 0, metadata_headers.size)
|
|
52
|
+
|
|
53
|
+
# Row 1: metadata values
|
|
54
|
+
metadata_values.each_with_index { |val, col| worksheet.add_cell(1, col, val) }
|
|
55
|
+
|
|
56
|
+
# Row 2: blank
|
|
57
|
+
# Row 3: participant headers
|
|
58
|
+
participant_headers.each_with_index do |header, col|
|
|
59
|
+
worksheet.add_cell(3, col, header)
|
|
60
|
+
current_width = worksheet.get_column_width(col)
|
|
61
|
+
worksheet.change_column_width(col, 20) if current_width.nil? || current_width < 20
|
|
62
|
+
end
|
|
63
|
+
style_header_row(worksheet, 3, participant_headers.size)
|
|
64
|
+
|
|
65
|
+
# Row 4+: participant data
|
|
66
|
+
@participants.each_with_index do |participant, idx|
|
|
67
|
+
participant_row(participant).each_with_index do |val, col|
|
|
68
|
+
worksheet.add_cell(4 + idx, col, val)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Decidim::Exporters::ExportData.new(workbook.stream.string, "xlsx")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def export_json
|
|
76
|
+
data = {
|
|
77
|
+
metadata: metadata_headers.zip(metadata_values).to_h,
|
|
78
|
+
participants: @participants.map do |p|
|
|
79
|
+
participant_headers.zip(participant_row(p)).to_h
|
|
80
|
+
end,
|
|
81
|
+
}
|
|
82
|
+
Decidim::Exporters::ExportData.new(JSON.pretty_generate(data), "json")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def i18n_scope
|
|
88
|
+
"decidim.stratified_sortitions.admin.stratified_sortitions.execute"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def metadata_headers
|
|
92
|
+
@metadata_headers ||= [
|
|
93
|
+
I18n.t("algorithm", scope: i18n_scope),
|
|
94
|
+
I18n.t("total_participants", scope: i18n_scope),
|
|
95
|
+
I18n.t("generated_at", scope: i18n_scope),
|
|
96
|
+
I18n.t("generation_time", scope: i18n_scope),
|
|
97
|
+
I18n.t("num_panels", scope: i18n_scope),
|
|
98
|
+
I18n.t("selected_at", scope: i18n_scope),
|
|
99
|
+
I18n.t("random_value_used", scope: i18n_scope),
|
|
100
|
+
I18n.t("selected_panel_probability", scope: i18n_scope),
|
|
101
|
+
]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def metadata_values
|
|
105
|
+
@metadata_values ||= [
|
|
106
|
+
"#{@audit_log[:algorithm]} v#{@audit_log[:version]}",
|
|
107
|
+
@total_participants,
|
|
108
|
+
@portfolio.generated_at,
|
|
109
|
+
@portfolio.generation_time_seconds,
|
|
110
|
+
@portfolio.num_panels,
|
|
111
|
+
@portfolio.selected_at,
|
|
112
|
+
@portfolio.random_value_used,
|
|
113
|
+
format_percentage(@portfolio.selected_panel_probability),
|
|
114
|
+
]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def participant_headers
|
|
118
|
+
@participant_headers ||= begin
|
|
119
|
+
template_scope = "decidim.stratified_sortitions.admin.samples.template"
|
|
120
|
+
headers = [
|
|
121
|
+
I18n.t("personal_data_1", scope: template_scope),
|
|
122
|
+
I18n.t("personal_data_2", scope: template_scope),
|
|
123
|
+
I18n.t("personal_data_3", scope: template_scope),
|
|
124
|
+
I18n.t("personal_data_4", scope: template_scope),
|
|
125
|
+
]
|
|
126
|
+
@strata.each do |stratum|
|
|
127
|
+
stratum_name = stratum.name.values.compact.first || stratum.id.to_s
|
|
128
|
+
headers << stratum_name
|
|
129
|
+
end
|
|
130
|
+
headers
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def participant_row(participant)
|
|
135
|
+
row = [
|
|
136
|
+
participant.personal_data_1,
|
|
137
|
+
participant.personal_data_2,
|
|
138
|
+
participant.personal_data_3,
|
|
139
|
+
participant.personal_data_4,
|
|
140
|
+
]
|
|
141
|
+
@strata.each do |stratum|
|
|
142
|
+
ps = participant.sample_participant_strata.find { |s| s.decidim_stratified_sortitions_stratum_id == stratum.id }
|
|
143
|
+
substratum_name = ps&.decidim_stratified_sortitions_substratum&.name&.values&.compact&.first
|
|
144
|
+
row << (substratum_name || "-")
|
|
145
|
+
end
|
|
146
|
+
row
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def style_header_row(worksheet, row_index, _col_count)
|
|
150
|
+
worksheet.change_row_fill(row_index, "c0c0c0")
|
|
151
|
+
worksheet.change_row_bold(row_index, true)
|
|
152
|
+
worksheet.change_row_horizontal_alignment(row_index, "center")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def format_percentage(value)
|
|
156
|
+
pct = value.to_f * 100
|
|
157
|
+
"#{format("%.2f", pct)}%"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<% add_decidim_page_title(t("components.title", scope: "decidim.admin")) %>
|
|
2
|
+
<%
|
|
3
|
+
allowed_emails = ENV.fetch("STRATIFIED_SORTITIONS_ALLOWED_EMAILS", "").split(",").map { |email| email.strip.downcase }.reject(&:blank?)
|
|
4
|
+
can_see_stratified_sortitions = allowed_emails.include?(current_user&.email.to_s.strip.downcase)
|
|
5
|
+
@components = if can_see_stratified_sortitions
|
|
6
|
+
@components
|
|
7
|
+
else
|
|
8
|
+
@components.reject { |component| component.manifest_name.to_s == "stratified_sortitions" }
|
|
9
|
+
end
|
|
10
|
+
%>
|
|
11
|
+
|
|
12
|
+
<div class="card">
|
|
13
|
+
<div class="item_show__header">
|
|
14
|
+
<h1 class="item_show__header-title">
|
|
15
|
+
<%= t("components.title", scope: "decidim.admin") %>
|
|
16
|
+
|
|
17
|
+
<% if allowed_to?(:create, :component) %>
|
|
18
|
+
<div class="relative">
|
|
19
|
+
<button class="button button__sm button__secondary button--title" data-toggle="add-component-dropdown">
|
|
20
|
+
<%= t "components.index.add", scope: "decidim.admin" %>
|
|
21
|
+
<%= icon "arrow-down-s-line", class: "dropdown-filter-icon" %>
|
|
22
|
+
</button>
|
|
23
|
+
<div class="dropdown-pane" id="add-component-dropdown" data-dropdown data-auto-focus="true" data-close-on-click="true">
|
|
24
|
+
<ul class="vertical menu add-components font-normal">
|
|
25
|
+
<% @manifests.each do |manifest| %>
|
|
26
|
+
<% next if manifest.name.to_s == "stratified_sortitions" && !can_see_stratified_sortitions %>
|
|
27
|
+
<li>
|
|
28
|
+
<%= link_to new_component_path(type: manifest.name), class: manifest.name do %>
|
|
29
|
+
<%= t("#{manifest.name}.name", scope: "decidim.components") %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</li>
|
|
32
|
+
<% end %>
|
|
33
|
+
</ul>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
</h1>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="table-scroll">
|
|
41
|
+
<table class="table-list">
|
|
42
|
+
<thead>
|
|
43
|
+
<th><%= t(".headers.name") %></th>
|
|
44
|
+
<th><%= t(".headers.type") %></th>
|
|
45
|
+
<th><%= t(".headers.scope") %></th>
|
|
46
|
+
<th><%= t(".headers.actions") %></th>
|
|
47
|
+
</thead>
|
|
48
|
+
|
|
49
|
+
<tbody>
|
|
50
|
+
<%= render partial: "component", collection: @components %>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<p class="email-instructions">
|
|
2
|
+
<%= t(".ready") %>
|
|
3
|
+
</p>
|
|
4
|
+
<p class="email-instructions">
|
|
5
|
+
<ul>
|
|
6
|
+
<li><strong><%= t(".total_rows") %>:</strong> <%= @sample_import.total_rows %></li>
|
|
7
|
+
<li><strong><%= t(".imported_rows") %>:</strong> <%= @sample_import.imported_rows %></li>
|
|
8
|
+
<li><strong><%= t(".failed_rows") %>:</strong> <%= @sample_import.failed_rows %></li>
|
|
9
|
+
</ul>
|
|
10
|
+
|
|
11
|
+
<% if @sample_import.failed_rows.positive? %>
|
|
12
|
+
<br>
|
|
13
|
+
<strong><%= t(".see_details") %></strong>
|
|
14
|
+
<ul>
|
|
15
|
+
<% @sample_import.import_errors.each do |error| %>
|
|
16
|
+
<li>
|
|
17
|
+
<strong><%= t(".participant_id") %> <%= error["row"].values.first %></strong>
|
|
18
|
+
</li>
|
|
19
|
+
<% end %>
|
|
20
|
+
</ul>
|
|
21
|
+
<% end %>
|
|
22
|
+
</p>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%= decidim_modal id: "confirm-execute-modal" do %>
|
|
2
|
+
<div data-dialog-container>
|
|
3
|
+
<h2 id="dialog-title-confirm-execute-modal" tabindex="-1" data-dialog-title>
|
|
4
|
+
<%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_execute_title") %>
|
|
5
|
+
</h2>
|
|
6
|
+
<div>
|
|
7
|
+
<p id="dialog-desc-confirm-execute-modal"><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_execute_body") %></p>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
<div data-dialog-actions class="flex flex-col-reverse md:flex-row">
|
|
11
|
+
<button class="button button__lg button__transparent-secondary w-full md:w-auto" data-dialog-close="confirm-execute-modal">
|
|
12
|
+
<span><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_execute_cancel") %></span>
|
|
13
|
+
</button>
|
|
14
|
+
<%= button_to t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_execute_ok"),
|
|
15
|
+
execute_stratified_sortition_stratified_sortition_path(stratified_sortition),
|
|
16
|
+
method: :post,
|
|
17
|
+
class: "button button__lg button__secondary w-full md:w-auto" %>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%= decidim_modal id: "confirm-participants-modal" do %>
|
|
2
|
+
<div data-dialog-container>
|
|
3
|
+
<h2 id="dialog-title-confirm-participants-modal" tabindex="-1" data-dialog-title>
|
|
4
|
+
<%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_participants_title") %>
|
|
5
|
+
</h2>
|
|
6
|
+
<div>
|
|
7
|
+
<p id="dialog-desc-confirm-participants-modal"><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_participants_body") %></p>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
<div data-dialog-actions class="flex flex-col-reverse md:flex-row">
|
|
11
|
+
<button class="button button__lg button__transparent-secondary w-full md:w-auto" data-dialog-close="confirm-participants-modal">
|
|
12
|
+
<span><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_participants_cancel") %></span>
|
|
13
|
+
</button>
|
|
14
|
+
<button type="button" class="button button__lg button__secondary w-full md:w-auto" id="confirm-show-participants"
|
|
15
|
+
data-log-url="<%= log_view_participants_stratified_sortition_path(stratified_sortition) %>"
|
|
16
|
+
data-authenticity-token="<%= form_authenticity_token %>">
|
|
17
|
+
<span><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_participants_ok") %></span>
|
|
18
|
+
</button>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|